From 88feee230f66069661d124beeca9022268be682d Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Wed, 11 Nov 2026 14:48:33 +0200 Subject: [PATCH 1/6] Replace the retired macos-14 runner in CI GitHub retired the macos-14 images on 2026-11-02. The job now ends with "The configuration 'macos-14-arm64-us-default' is not supported", which marks every push to master as cancelled. Use macos-15 and macos-26 in the test and post-publish matrices, set fail-fast: false, and only cancel superseded runs on pull requests so pushes to master always finish. --- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/publish-test-pypi.yml | 2 +- .github/workflows/test.yml | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 1bdb586d..2d4ac476 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -60,7 +60,7 @@ jobs: needs: publish strategy: matrix: - os: ["ubuntu-24.04", "ubuntu-26.04", "macos-14", "macos-15", "windows-2022", "windows-2025"] + os: ["ubuntu-24.04", "ubuntu-26.04", "macos-15", "macos-26", "windows-2022", "windows-2025"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.os }} steps: diff --git a/.github/workflows/publish-test-pypi.yml b/.github/workflows/publish-test-pypi.yml index 25344318..60a3529a 100644 --- a/.github/workflows/publish-test-pypi.yml +++ b/.github/workflows/publish-test-pypi.yml @@ -56,7 +56,7 @@ jobs: needs: publish strategy: matrix: - os: ["ubuntu-24.04", "ubuntu-26.04", "macos-14", "macos-15", "windows-2022", "windows-2025"] + os: ["ubuntu-24.04", "ubuntu-26.04", "macos-15", "macos-26", "windows-2022", "windows-2025"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.os }} permissions: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9265c146..48af774c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,10 +8,11 @@ permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: test: strategy: + fail-fast: false matrix: include: - {python-version: "3.10", os: ubuntu-24.04} @@ -21,8 +22,8 @@ jobs: - {python-version: "3.14", os: ubuntu-22.04} - {python-version: "3.14", os: ubuntu-24.04} - {python-version: "3.14", os: ubuntu-26.04} - - {python-version: "3.14", os: macos-14} - {python-version: "3.14", os: macos-15} + - {python-version: "3.14", os: macos-26} - {python-version: "3.14", os: windows-2022} - {python-version: "3.14", os: windows-2025} runs-on: ${{ matrix.os }} From 96618d71d278483a999109a73351016bf2101a96 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Wed, 11 Nov 2026 14:50:08 +0200 Subject: [PATCH 2/6] Make the example scripts import against the current API example.py imported print_row from a util.db module that does not exist, and most scripts connected to a PLC at import time. Import from s7, move the connections under a __main__ guard, and size the buffer in boolean.py so set_int no longer refuses it. read_multi.py and write_multi.py built ctypes S7DataItem arrays and checked a Result field the client never sets. Rewrite them around read_tags and the dict form of read_multi_vars/write_multi_vars, and lead doc/multi-variable.rst with the same. --- doc/multi-variable.rst | 124 ++++++++++++++++++++++++++++------------- example/boolean.py | 44 ++++++++------- example/example.py | 27 ++++----- example/logo_7_8.py | 60 +++++++++++--------- example/read_multi.py | 88 ++++++++++------------------- example/s7_symbols.py | 44 ++++++++------- example/write_multi.py | 78 +++++++++++++------------- 7 files changed, 249 insertions(+), 216 deletions(-) diff --git a/doc/multi-variable.rst b/doc/multi-variable.rst index f7743cd7..aafe0aa3 100644 --- a/doc/multi-variable.rst +++ b/doc/multi-variable.rst @@ -1,51 +1,97 @@ -Multi-Variable Read -=================== +Multi-Variable Read and Write +============================= -The ``read_multi_vars`` method reads multiple variables in a single PDU -request, which is significantly faster than individual reads. +Reading several variables in one call is faster than reading them one by one: +the client merges adjacent addresses and packs the rest into as few PDU +exchanges as the negotiated PDU size allows. + +Reading tags +------------ + +``read_tags`` takes address strings (or :class:`~snap7.tags.Tag` objects) and +returns the decoded values in the same order: .. code-block:: python from s7 import Client - from s7.type import Area, WordLen, S7DataItem - from ctypes import c_uint8, cast, POINTER client = Client() client.connect("192.168.1.10", 0, 1) - # Prepare items to read - items = [] - - # Item 1: 4 bytes from DB1, offset 0 - item1 = S7DataItem() - item1.Area = Area.DB - item1.WordLen = WordLen.Byte - item1.DBNumber = 1 - item1.Start = 0 - item1.Amount = 4 - buffer1 = (c_uint8 * 4)() - item1.pData = cast(buffer1, POINTER(c_uint8)) - items.append(item1) - - # Item 2: 2 bytes from DB2, offset 10 - item2 = S7DataItem() - item2.Area = Area.DB - item2.WordLen = WordLen.Byte - item2.DBNumber = 2 - item2.Start = 10 - item2.Amount = 2 - buffer2 = (c_uint8 * 2)() - item2.pData = cast(buffer2, POINTER(c_uint8)) - items.append(item2) - - # Execute the multi-read - result, data_items = client.read_multi_vars(items) - - # Access the returned data - value1 = bytearray(buffer1) - value2 = bytearray(buffer2) + level, setpoint, count = client.read_tags( + ["DB200.DBD16:REAL", "DB200.DBD12:REAL", "DB200.DBW2:INT"] + ) + +See :doc:`API/tags` for the address syntax. + +Reading raw bytes +----------------- + +``read_multi_vars`` takes a list of dicts with ``area``, ``start``, ``size`` +and, for data blocks, ``db_number``. It returns a result code and one +``bytearray`` per item, in the order of the request: + +.. code-block:: python + + from s7.type import Area + from s7.util import get_int, get_real + + items = [ + {"area": Area.DB, "db_number": 200, "start": 16, "size": 4}, + {"area": Area.DB, "db_number": 200, "start": 2, "size": 2}, + {"area": Area.MK, "start": 20, "size": 1}, + ] + _, buffers = client.read_multi_vars(items) + + level = get_real(buffers[0], 0) + count = get_int(buffers[1], 0) + +A failed read raises an exception from :mod:`snap7.error`; there is no +per-item status to check. + +Writing +------- + +``write_multi_vars`` takes the same dicts with ``data`` instead of ``size``: + +.. code-block:: python + + client.write_multi_vars( + [ + {"area": Area.DB, "db_number": 1, "start": 0, "data": bytearray([0, 10, 0, 20])}, + {"area": Area.MK, "start": 20, "data": bytearray([0x01])}, + ] + ) + +The items are written one after the other, each in its own request. The call +raises on the first item the PLC refuses, and the items before it stay +written. + +Limits and tuning +----------------- .. warning:: - The S7 protocol limits multi-variable reads to **20 items** per request. - If you need more, split them across multiple calls. + One call accepts at most **20 items** (``Client.MAX_VARS``) and raises + ``ValueError`` above that. Split longer lists across several calls. + +* The read optimizer is experimental. Set ``client.use_optimizer = False`` to + read each item with its own request instead. See :doc:`API/optimizer`. +* When a read needs more than one PDU, ``Client`` sends up to + ``client.max_parallel`` requests before it reads the replies. See + :doc:`thread-safety`. +* ``AsyncClient`` has the same methods as coroutines, but reads the items one + after the other without the optimizer. + +The ``S7DataItem`` form +----------------------- + +``Client.read_multi_vars`` and ``Client.write_multi_vars`` still accept the +ctypes ``S7DataItem`` structures of the original Snap7 library, so that old +code keeps working. This path reads and writes the items one at a time and +never sets the ``Result`` field of an item; errors raise exceptions instead. +``AsyncClient`` does not accept it. Use ``read_tags`` or the dict form in new +code. + +Complete scripts are in ``example/read_multi.py`` and +``example/write_multi.py``. diff --git a/example/boolean.py b/example/boolean.py index e4bbb5ec..a54673f6 100644 --- a/example/boolean.py +++ b/example/boolean.py @@ -17,30 +17,36 @@ the minimun amount of data being read or written to a plc is 1 byte. """ -from snap7 import Client -from snap7.util import set_bool, set_int +from s7 import Client +from s7.type import Area +from s7.util import set_bool, set_int -plc = Client() -plc.connect("192.168.200.24", 0, 3) -# In this example boolean in DB 31 at byte 120 and bit 5 is changed. = 120.5 +def main() -> None: + plc = Client() + plc.connect("192.168.200.24", 0, 3) -reading = plc.db_read(31, 120, 1) # read 1 byte from db 31 staring from byte 120 -set_bool(reading, 0, 5, True) # set a value of fifth bit -plc.db_write(31, 120, reading) # write back the bytearray and now the boolean value is changed in the PLC. + # In this example boolean in DB 31 at byte 120 and bit 5 is changed. = 120.5 -# NOTE you could also use the read_area and write_area functions. -# then you can specify an area to read from: -# https://github.com/gijzelaerr/python-snap7/blob/master/snap7/types.py + reading = plc.db_read(31, 120, 1) # read 1 byte from db 31 staring from byte 120 + set_bool(reading, 0, 5, True) # set a value of fifth bit + plc.db_write(31, 120, reading) # write back the bytearray and now the boolean value is changed in the PLC. -from snap7.type import Area # noqa: E402 + # NOTE you could also use the read_area and write_area functions. + # then you can specify an area to read from: + # https://github.com/gijzelaerr/python-snap7/blob/master/snap7/type.py + # play with these functions. + plc.read_area(area=Area.MK, db_number=0, start=20, size=2) -# play with these functions. -plc.read_area(area=Area.MK, db_number=0, start=20, size=2) + data = bytearray(2) # an INT is two bytes + set_int(data, 0, 127) + plc.write_area(area=Area.MK, db_number=0, start=20, data=data) + # read the client source code! + # and official snap7 documentation -data = bytearray() -set_int(data, 0, 127) -plc.write_area(area=Area.MK, db_number=0, start=20, data=data) -# read the client source code! -# and official snap7 documentation + plc.disconnect() + + +if __name__ == "__main__": + main() diff --git a/example/example.py b/example/example.py index c3456549..e45a9465 100644 --- a/example/example.py +++ b/example/example.py @@ -8,12 +8,11 @@ from db_layouts import rc_if_db_1_layout from db_layouts import tank_rc_if_db_layout -from snap7 import Client, Row, DB -from snap7.type import Area -from util.db import print_row +from s7 import Client, Row, DB +from s7.type import Area +from s7.util.db import print_row client = Client() -client.connect("192.168.200.24", 0, 3) def get_db1() -> None: @@ -190,12 +189,14 @@ def print_open() -> None: print(row) -# read_tank_db() -# open_and_close() -# open_and_close_db1() -# time.sleep(1) -# show_row(2) -print_tag() -# print_open() - -client.disconnect() +if __name__ == "__main__": + client.connect("192.168.200.24", 0, 3) + try: + # read_tank_db() + # open_and_close() + # open_and_close_db1() + # show_row(2) + print_tag() + # print_open() + finally: + client.disconnect() diff --git a/example/logo_7_8.py b/example/logo_7_8.py index 4e3fb86b..ed6944ce 100644 --- a/example/logo_7_8.py +++ b/example/logo_7_8.py @@ -13,41 +13,47 @@ logger = logging.getLogger(__name__) -plc = Logo() -plc.connect("192.168.0.41", 0x1000, 0x2000) -if plc.get_connected(): - logger.info("connected") +def main() -> None: + plc = Logo() + plc.connect("192.168.0.41", 0x1000, 0x2000) - # read I1 from logo - vm_address = "V923.0" if Logo_7 else "V1024.0" - print(f"I1: {str(plc.read(vm_address))}") + if plc.get_connected(): + logger.info("connected") - # write some values in VM addresses between 0 and 100 + # read I1 from logo + vm_address = "V923.0" if Logo_7 else "V1024.0" + print(f"I1: {str(plc.read(vm_address))}") - value_1 = 0b10110001 - value_2 = 480 + # write some values in VM addresses between 0 and 100 - print("write 0b10110001 to V10") - plc.write("V10", value_1) + value_1 = 0b10110001 + value_2 = 480 - print(f"read V10.0 must be 1 - check: {str(plc.read('V10.0'))}") - print(f"read V10.3 must be 0 - check: {str(plc.read('V10.3'))}") - print(f"read V10.7 must be 1 - check: {str(plc.read('V10.7'))}") + print("write 0b10110001 to V10") + plc.write("V10", value_1) - print("write 480 analog value to VW20") - plc.write("VW20", value_2) + print(f"read V10.0 must be 1 - check: {str(plc.read('V10.0'))}") + print(f"read V10.3 must be 0 - check: {str(plc.read('V10.3'))}") + print(f"read V10.7 must be 1 - check: {str(plc.read('V10.7'))}") - print(f"read VW20 must be 480 - check: {str(plc.read('VW20'))}") + print("write 480 analog value to VW20") + plc.write("VW20", value_2) - print("trigger V10.2") - plc.write("V10.2", 0) - plc.write("V10.2", 1) - plc.write("V10.2", 0) + print(f"read VW20 must be 480 - check: {str(plc.read('VW20'))}") -else: - logger.error("Conncetion failed") + print("trigger V10.2") + plc.write("V10.2", 0) + plc.write("V10.2", 1) + plc.write("V10.2", 0) -plc.disconnect() -logger.info("Disconnected") -plc.destroy() + else: + logger.error("Connection failed") + + plc.disconnect() + logger.info("Disconnected") + plc.destroy() + + +if __name__ == "__main__": + main() diff --git a/example/read_multi.py b/example/read_multi.py index 98264437..11dd4de0 100644 --- a/example/read_multi.py +++ b/example/read_multi.py @@ -1,68 +1,40 @@ """ -Example usage of the read_multi_vars function +Read several variables in one call. -This was tested against a S7-319 CPU -""" - -import ctypes - -from snap7 import Client -from snap7.error import check_error -from snap7.type import S7DataItem, Area, WordLen -from snap7.util import get_real, get_int - -client = Client() -client.connect("10.100.5.2", 0, 2) +``read_tags`` takes address strings and returns decoded values. ``read_multi_vars`` +takes a list of dicts and returns the raw bytes, for when you decode them yourself. +Both batch the reads into as few PDU exchanges as the PLC allows. -data_items = (S7DataItem * 3)() - -data_items[0].Area = ctypes.c_int32(Area.DB.value) -data_items[0].WordLen = ctypes.c_int32(WordLen.Byte.value) -data_items[0].Result = ctypes.c_int32(0) -data_items[0].DBNumber = ctypes.c_int32(200) -data_items[0].Start = ctypes.c_int32(16) -data_items[0].Amount = ctypes.c_int32(4) # reading a REAL, 4 bytes - -data_items[1].Area = ctypes.c_int32(Area.DB.value) -data_items[1].WordLen = ctypes.c_int32(WordLen.Byte.value) -data_items[1].Result = ctypes.c_int32(0) -data_items[1].DBNumber = ctypes.c_int32(200) -data_items[1].Start = ctypes.c_int32(12) -data_items[1].Amount = ctypes.c_int32(4) # reading a REAL, 4 bytes - -data_items[2].Area = ctypes.c_int32(Area.DB.value) -data_items[2].WordLen = ctypes.c_int32(WordLen.Byte.value) -data_items[2].Result = ctypes.c_int32(0) -data_items[2].DBNumber = ctypes.c_int32(200) -data_items[2].Start = ctypes.c_int32(2) -data_items[2].Amount = ctypes.c_int32(2) # reading an INT, 2 bytes +Usage: + python example/read_multi.py 192.168.1.10 +""" -# create buffers to receive the data -# use the Amount attribute on each item to size the buffer -for di in data_items: - # create the buffer - buffer = ctypes.create_string_buffer(di.Amount) +import sys - # cast the pointer to the buffer to the required type - pBuffer = ctypes.cast(ctypes.pointer(buffer), ctypes.POINTER(ctypes.c_uint8)) - di.pData = pBuffer +from s7 import Client +from s7.type import Area +from s7.util import get_int, get_real -result, data_items = client.read_multi_vars(data_items) -for di in data_items: - check_error(di.Result) +def main(address: str) -> None: + client = Client() + client.connect(address, 0, 1) + try: + # Decoded values, in the order of the request. + level, setpoint, count = client.read_tags(["DB200.DBD16:REAL", "DB200.DBD12:REAL", "DB200.DBW2:INT"]) + print(f"level={level!r} setpoint={setpoint!r} count={count!r}") -result_values = [] -# function to cast bytes to match data_types[] above -byte_to_value = [get_real, get_real, get_int] + # Raw bytes: one bytearray per item, in the order of the request. + items = [ + {"area": Area.DB, "db_number": 200, "start": 16, "size": 4}, # REAL + {"area": Area.DB, "db_number": 200, "start": 12, "size": 4}, # REAL + {"area": Area.DB, "db_number": 200, "start": 2, "size": 2}, # INT + ] + _, buffers = client.read_multi_vars(items) + print([get_real(buffers[0], 0), get_real(buffers[1], 0), get_int(buffers[2], 0)]) + finally: + client.disconnect() -# unpack and test the result of each read -for i in range(0, len(data_items)): - btv = byte_to_value[i] - di = data_items[i] - value = btv(di.pData, 0) - result_values.append(value) -print(result_values) -client.disconnect() -client.destroy() +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "192.168.1.10") diff --git a/example/s7_symbols.py b/example/s7_symbols.py index a05edda8..75c8e497 100644 --- a/example/s7_symbols.py +++ b/example/s7_symbols.py @@ -9,30 +9,34 @@ from s7 import Client from s7.tags import Tag -address = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.10" -client = Client() -client.connect(address, 0, 1) +def main(address: str) -> None: + client = Client() + client.connect(address, 0, 1) -# Ad-hoc tag read using PLC4X-style syntax -speed = client.read_tag("DB1.DBD0:REAL") -running = client.read_tag("DB1.DBX4.0:BOOL") -print(f"Speed: {speed!r}, Running: {running!r}") + # Ad-hoc tag read using PLC4X-style syntax + speed = client.read_tag("DB1.DBD0:REAL") + running = client.read_tag("DB1.DBX4.0:BOOL") + print(f"Speed: {speed!r}, Running: {running!r}") -# Write a value -client.write_tag("DB1.DBW6:INT", 1500) + # Write a value + client.write_tag("DB1.DBW6:INT", 1500) -# Read multiple tags in one optimized request -values = client.read_tags(["DB1.DBD0:REAL", "DB1.DBW6:INT"]) -print(f"Batch: {values!r}") + # Read multiple tags in one optimized request + values = client.read_tags(["DB1.DBD0:REAL", "DB1.DBW6:INT"]) + print(f"Batch: {values!r}") -# Or build tags programmatically / load from file -# tags = load_csv("tags.csv") # returns dict[str, Tag] -# value = client.read_tag(tags["Motor.Speed"]) + # Or build tags programmatically / load from file + # tags = load_csv("tags.csv") # returns dict[str, Tag] + # value = client.read_tag(tags["Motor.Speed"]) -# Tag instances are also accepted directly -temperature_tag = Tag.from_string("DB1.DBD0:REAL", name="Temperature") -temp = client.read_tag(temperature_tag) -print(f"Temperature: {temp!r}") + # Tag instances are also accepted directly + temperature_tag = Tag.from_string("DB1.DBD0:REAL", name="Temperature") + temp = client.read_tag(temperature_tag) + print(f"Temperature: {temp!r}") -client.disconnect() + client.disconnect() + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "192.168.1.10") diff --git a/example/write_multi.py b/example/write_multi.py index 07840a1a..905b4ddb 100644 --- a/example/write_multi.py +++ b/example/write_multi.py @@ -1,52 +1,50 @@ -import ctypes -import snap7 -from snap7.type import Area, S7DataItem, WordLen -from snap7.util import set_int, set_real, get_int, get_real, get_s5time +""" +Write several variables in one call. +``write_multi_vars`` takes a list of dicts, each with the bytes to write. The items +are written one after the other and the call raises on the first one the PLC refuses, +so earlier items stay written. Use ``write_tag`` to write a single typed value. -client = snap7.client.Client() -client.connect("192.168.100.100", 0, 2) +Usage: + python example/write_multi.py 192.168.1.10 +""" -items = [] +import sys +from s7 import Client +from s7.type import Area +from s7.util import set_int, set_real -def set_data_item(area: Area, word_len: int, db_number: int, start: int, amount: int, data: bytearray) -> S7DataItem: - item = S7DataItem() - item.Area = ctypes.c_int32(area) - item.WordLen = ctypes.c_int32(word_len) - item.DBNumber = ctypes.c_int32(db_number) - item.Start = ctypes.c_int32(start) - item.Amount = ctypes.c_int32(amount) - array_class = ctypes.c_uint8 * len(data) - cdata = array_class.from_buffer_copy(data) - item.pData = ctypes.cast(cdata, ctypes.POINTER(array_class)).contents - return item +def main(address: str) -> None: + client = Client() + client.connect(address, 0, 1) + try: + int_values = [10, 20, 30, 40] + ints = bytearray(2 * len(int_values)) + for i, value in enumerate(int_values): + set_int(ints, 2 * i, value) -int_values = [10, 20, 30, 40] -ints = bytearray(len(int_values) * 2) -for i, value in enumerate(int_values): - set_int(ints, i * 2, value) + real = bytearray(4) + set_real(real, 0, 42.5) -real = bytearray(4) -set_real(real, 0, 42.5) + client.write_multi_vars( + [ + {"area": Area.DB, "db_number": 1, "start": 0, "data": ints}, + {"area": Area.DB, "db_number": 1, "start": 8, "data": real}, + {"area": Area.MK, "start": 20, "data": bytearray([0x01])}, + ] + ) -counters = bytearray(0x2999.to_bytes(2, "big") + 0x1111.to_bytes(2, "big")) + # A single typed value by address. + client.write_tag("DB1.DBW12:INT", 1500) -item1 = set_data_item(area=Area.DB, word_len=WordLen.Word.value, db_number=1, start=0, amount=4, data=ints) -item2 = set_data_item(area=Area.DB, word_len=WordLen.Real.value, db_number=1, start=8, amount=1, data=real) -item3 = set_data_item(area=Area.TM, word_len=WordLen.Timer.value, db_number=0, start=2, amount=2, data=counters) + # Read everything back. + tags = [f"DB1.DBW{2 * i}:INT" for i in range(len(int_values))] + ["DB1.DBD8:REAL", "DB1.DBW12:INT", "MB20:BYTE"] + print(dict(zip(tags, client.read_tags(tags)))) + finally: + client.disconnect() -items.append(item1) -items.append(item2) -items.append(item3) -client.write_multi_vars(items) - -db_int = client.db_read(1, 0, 8) -db_real = client.db_read(1, 8, 12) -db_counters = client.ct_read(2, 2) - -print(f"int values: {[get_int(db_int, i * 2) for i in range(4)]}") -print(f"real value: {get_real(db_real, 0)}") -print(f"counters: {get_s5time(counters, 0)}, {get_s5time(counters, 2)}") +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "192.168.1.10") From 72aaa90abd734a899b6543afa54b96e5c0b0245b Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Wed, 11 Nov 2026 14:51:34 +0200 Subject: [PATCH 3/6] Document the async client, the heartbeat thread and max_parallel Give AsyncClient its own API page with its concurrency model and the differences from Client. Extend the thread-safety page with what the heartbeat thread does, what the internal lock covers, where callbacks run, and how max_parallel is chosen and overridden. Correct the connecting page, which described snap7 as an alias for s7; it is the other way round. --- doc/API/async-client.rst | 53 ++++++++++++++++++++++++++++++++++ doc/API/client.rst | 11 ++------ doc/connecting.rst | 7 +++-- doc/index.rst | 1 + doc/thread-safety.rst | 61 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 doc/API/async-client.rst diff --git a/doc/API/async-client.rst b/doc/API/async-client.rst new file mode 100644 index 00000000..27123f4a --- /dev/null +++ b/doc/API/async-client.rst @@ -0,0 +1,53 @@ +Async Client +============ + +``AsyncClient`` is the ``asyncio`` counterpart of :doc:`client`. It has the +same PLC operations, and every method that talks to the PLC is a coroutine:: + + import asyncio + from s7 import AsyncClient + + async def main() -> None: + async with AsyncClient() as client: + await client.connect("192.168.1.10", 0, 1) + data = await client.db_read(1, 0, 4) + await client.db_write(1, 0, bytearray([1, 2, 3, 4])) + + asyncio.run(main()) + +Leaving the ``async with`` block disconnects the client. + +Concurrency +----------- + +An internal ``asyncio.Lock`` serialises each request and its reply, so several +coroutines can share one client:: + + async def read_all(client: AsyncClient) -> list[bytearray]: + return await asyncio.gather( + client.db_read(1, 0, 4), + client.db_read(1, 10, 4), + client.db_read(2, 0, 8), + ) + +The requests still go to the PLC one after the other; ``gather`` only lets the +rest of the application run while they wait. One client belongs to one event +loop. Do not call it from other threads. + +Differences from ``Client`` +--------------------------- + +* There is no heartbeat thread and no ``heartbeat_interval`` argument. + ``is_alive`` reports the connection state seen by the last operation. +* ``read_multi_vars`` and ``write_multi_vars`` accept only the dict form, and + ``read_multi_vars`` and ``read_tags`` read the items one after the other. + There is no read optimizer and no ``max_parallel``. See + :doc:`../multi-variable`. + +s7.AsyncClient +-------------- + +.. automodule:: snap7.async_client + :members: + :inherited-members: + :exclude-members: AsyncISOTCPConnection diff --git a/doc/API/client.rst b/doc/API/client.rst index e7777af9..6266e820 100644 --- a/doc/API/client.rst +++ b/doc/API/client.rst @@ -27,7 +27,8 @@ Use ``AsyncClient`` when the surrounding application uses ``asyncio``:: Both clients expose the same main PLC operations, including area and DB access, block transfer, CPU information and control, clock access, force operations, and session passwords. Methods that perform I/O are coroutines on -``AsyncClient``. See :doc:`../advanced` for the less common operations. +``AsyncClient``, which is documented in :doc:`async-client`. See +:doc:`../advanced` for the less common operations. s7.Client --------- @@ -35,11 +36,3 @@ s7.Client .. automodule:: snap7.client :members: :inherited-members: - -s7.AsyncClient --------------- - -.. automodule:: snap7.async_client - :members: - :inherited-members: - :exclude-members: AsyncISOTCPConnection diff --git a/doc/connecting.rst b/doc/connecting.rst index fe7a7410..bf556398 100644 --- a/doc/connecting.rst +++ b/doc/connecting.rst @@ -153,11 +153,12 @@ When the target PLC sits on a different subnet behind a gateway PLC, use data = client.db_read(1, 0, 4) client.disconnect() -Legacy ``snap7`` Package +The ``snap7`` import name ------------------------- -If you have existing code using ``snap7.Client``, it continues to work -unchanged — ``snap7`` is an alias for ``s7``: +Existing code that uses ``snap7.Client`` continues to work unchanged. The +implementation lives in the ``snap7`` package and ``s7`` re-exports it, so +both names give you the same classes: .. code-block:: python diff --git a/doc/index.rst b/doc/index.rst index f5cfd2f1..01f69e6f 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -43,6 +43,7 @@ Welcome to python-snap7's documentation! :caption: API Reference API/client + API/async-client API/server API/partner API/logo diff --git a/doc/thread-safety.rst b/doc/thread-safety.rst index 31e2cae0..0429addb 100644 --- a/doc/thread-safety.rst +++ b/doc/thread-safety.rst @@ -37,3 +37,64 @@ and cause unpredictable errors. def safe_read(db: int, start: int, size: int) -> bytearray: with lock: return client.db_read(db, start, size) + +The heartbeat thread +-------------------- + +``Client(heartbeat_interval=10)`` starts a daemon thread named +``s7-heartbeat`` when the client connects. Every interval it reads the CPU +state to check that the PLC still answers, and ``client.is_alive`` reports the +result of the last probe. ``disconnect()`` stops the thread. With the default +``heartbeat_interval=0`` no thread is started. + +The client holds an internal lock around each request and its reply, so a +probe never lands in the middle of one of your requests. This lock exists for +the heartbeat. It covers a single exchange, not a whole operation: a large +read, an upload or a download takes several exchanges. It does not make a +shared ``Client`` safe to call from several threads, so the two options above +still apply. + +Two things follow from the probe running on its own thread: + +* With ``auto_reconnect=True`` the heartbeat thread reconnects when a probe + fails, so the ``on_disconnect`` and ``on_reconnect`` callbacks can run on + the heartbeat thread. Keep them short and do not touch state that your main + thread uses without a lock of your own. +* A probe is a request like any other. It counts against + ``max_requests_per_second``, and your next call waits while one is in + flight. + +``AsyncClient`` has no heartbeat thread. See :doc:`API/async-client`. + +Parallel requests on one connection: ``max_parallel`` +----------------------------------------------------- + +``max_parallel`` is not a thread count. When ``read_multi_vars`` or +``read_tags`` needs more than one PDU, the client sends up to +``client.max_parallel`` requests on its single connection before it reads the +replies, and matches each reply to its request by sequence number. All of this +happens in the calling thread. + +``connect()`` sets the value from the negotiated PDU size: + +================== ================ +PDU size (bytes) ``max_parallel`` +================== ================ +960 or more 8 +480 to 959 4 +240 to 479 2 +below 240 1 +================== ================ + +A PLC accepts only a limited number of requests in flight. If multi-variable +reads time out or the PLC drops the connection, send them one at a time: + +.. code-block:: python + + client.connect("192.168.1.10", 0, 1) + client.max_parallel = 1 + +Set it after ``connect()``, because connecting (but not an automatic +reconnect) sets it again. The value is left alone when +``client.use_optimizer`` is ``False``, and it only affects multi-variable +reads; every other operation sends one request and waits for its reply. From 66e9d304f527dcae72b7d7049d27881eabf4dc0e Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Wed, 11 Nov 2026 14:53:48 +0200 Subject: [PATCH 4/6] Run the pre-commit mypy hook on s7, tests and example The hook only checked snap7, while tox and the contribution guide check snap7, s7, tests and example. Widen it and give its environment pytest, pytest-asyncio, pytest-bdd and hypothesis so the test decorators are typed. Drop the exclude for snap7/protocol.py, which does not exist. --- .pre-commit-config.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5c06e66b..382194a1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,12 +27,11 @@ repos: rev: 'v1.19.1' hooks: - id: mypy - additional_dependencies: [types-setuptools, types-click] - files: ^snap7 + additional_dependencies: [types-setuptools, types-click, pytest, pytest-asyncio, "pytest-bdd>=8.1,<10", hypothesis] + files: ^(snap7|s7|tests|example)/ - repo: https://github.com/astral-sh/ruff-pre-commit rev: 'v0.17.2' hooks: - id: ruff - id: ruff-format -exclude: "snap7/protocol.py" From fcc8695f7320b1cea9d5fd40b5940a99cd7cc75e Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Wed, 11 Nov 2026 14:53:48 +0200 Subject: [PATCH 5/6] Declare the package OS independent The classifier said POSIX only, but the library is pure Python and CI tests it on Windows, Linux and macOS. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ab40c07b..f82c918b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Topic :: System :: Hardware", "Intended Audience :: Developers", "Intended Audience :: Manufacturing", - "Operating System :: POSIX", + "Operating System :: OS Independent", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", From 3ec8bed13bf487e138bcc50e1d10f88f5ea81ba4 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Wed, 11 Nov 2026 14:53:56 +0200 Subject: [PATCH 6/6] Remove the stale sudo partner command from tox.ini The commented-out 'sudo pytest -m partner' dates from when the partner tests needed a privileged port, and allowlist_externals = sudo existed only for it. --- tox.ini | 2 -- 1 file changed, 2 deletions(-) diff --git a/tox.ini b/tox.ini index 0b56471b..7fe3a91d 100644 --- a/tox.ini +++ b/tox.ini @@ -11,10 +11,8 @@ isolated_build = true [testenv] extras = test -allowlist_externals = sudo commands = pytest -m "server or util or client or mainloop" - # sudo pytest -m partner [testenv:mypy] basepython = python3.13