Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-test-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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 }}
Expand Down
5 changes: 2 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
53 changes: 53 additions & 0 deletions doc/API/async-client.rst
Original file line number Diff line number Diff line change
@@ -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
11 changes: 2 additions & 9 deletions doc/API/client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,12 @@ 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
---------

.. automodule:: snap7.client
:members:
:inherited-members:

s7.AsyncClient
--------------

.. automodule:: snap7.async_client
:members:
:inherited-members:
:exclude-members: AsyncISOTCPConnection
7 changes: 4 additions & 3 deletions doc/connecting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Welcome to python-snap7's documentation!
:caption: API Reference

API/client
API/async-client
API/server
API/partner
API/logo
Expand Down
124 changes: 85 additions & 39 deletions doc/multi-variable.rst
Original file line number Diff line number Diff line change
@@ -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``.
61 changes: 61 additions & 0 deletions doc/thread-safety.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading