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 }} 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" 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/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/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. 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") 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", 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