Skip to content

Commit 0a9e8a3

Browse files
QuakeStringclaude
andcommitted
feat: add TCP keepalive and heartbeat_read() for redundant PLC support
- Enable SO_KEEPALIVE with aggressive probes (KEEPIDLE=2s, KEEPINTVL=1s, KEEPCNT=2) on all TCP connections for fast dead-peer detection (~4s) - Add Client.heartbeat_read(timeout_ms) for lightweight liveness checks reading 1 byte from M0 with independent short timeout - Update README with documentation for all optimization features: parallel dispatch, plan caching, TCP keepalive, heartbeat read, and model-specific tuning table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b51a496 commit 0a9e8a3

File tree

3 files changed

+127
-4
lines changed

3 files changed

+127
-4
lines changed

README.rst

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,89 @@ New modules:
5252
- Extended ``snap7/server/__init__.py`` — Server-side multi-item read support for testing.
5353

5454

55+
Parallel Packet Dispatch
56+
========================
57+
58+
For PLCs that support it (S7-1200, S7-400, S7-1500), multiple optimized read
59+
packets are dispatched back-to-back on the single TCP connection. Responses are
60+
matched by S7 sequence number as they arrive, maximizing throughput.
61+
62+
- Auto-tuned ``max_parallel`` based on PLC capabilities (CP info or PDU heuristic).
63+
- Stale-packet detection with automatic retry.
64+
- S7-300 / LOGO / S7-200 automatically fall back to sequential dispatch.
65+
66+
67+
Optimization Plan Caching
68+
=========================
69+
70+
The optimization pipeline result (sort → merge → packetize) is cached across
71+
repeated ``read_multi_vars()`` calls with the same item list. This eliminates
72+
re-computation overhead during cyclic polling — only the first call pays the
73+
optimization cost; subsequent calls reuse the cached plan.
74+
75+
76+
TCP Keepalive for Fast Dead-Peer Detection
77+
==========================================
78+
79+
All TCP connections enable ``SO_KEEPALIVE`` with aggressive probe settings:
80+
81+
- **Linux**: ``TCP_KEEPIDLE=2s``, ``TCP_KEEPINTVL=1s``, ``TCP_KEEPCNT=2``
82+
— detects dead connections within ~4 seconds.
83+
- **macOS**: ``TCP_KEEPALIVE=2s``.
84+
85+
This catches network-level failures (cable pull, PLC power loss, OS crash)
86+
far faster than the default TCP timeout (typically 30–120 seconds), which is
87+
critical for industrial applications where data gaps must be minimized.
88+
89+
90+
Heartbeat Read for Redundant PLC Monitoring
91+
===========================================
92+
93+
New ``Client.heartbeat_read(timeout_ms=500)`` method provides a lightweight
94+
liveness check designed for monitoring standby PLCs in redundant S7 systems
95+
(S7-300H, S7-400H, S7-1500R/H).
96+
97+
.. code-block:: python
98+
99+
client = snap7.Client()
100+
client.connect("192.168.1.11", 0, 1)
101+
102+
# Fast liveness check — reads 1 byte from M0
103+
if client.heartbeat_read(timeout_ms=300):
104+
print("Standby PLC is alive")
105+
else:
106+
print("Standby PLC is unreachable")
107+
108+
- Reads 1 byte from Merker area (M0) — negligible PLC scan cycle impact.
109+
- Uses a short, independent timeout (default 500 ms) without affecting the
110+
client's normal socket timeout.
111+
- Returns ``True`` / ``False`` — no exceptions on timeout or connection error.
112+
- Designed for background heartbeat threads monitoring the inactive CPU in
113+
a dual-connection redundancy setup.
114+
115+
116+
Model-Specific Tuning
117+
=====================
118+
119+
Read optimization respects PLC-specific limits:
120+
121+
============================== ================== ===============
122+
PLC Model Max Read Block Max Parallel
123+
============================== ================== ===============
124+
S7-200 / S7-200 Smart / LOGO 100 bytes 1 (sequential)
125+
S7-300 200 bytes 1 (sequential)
126+
S7-400 200 bytes 4
127+
S7-1200 1000 bytes 8
128+
S7-1500 1000 bytes 8
129+
============================== ================== ===============
130+
131+
55132
Installation
56133
============
57134

58135
Install using pip::
59136

60-
$ pip install python-snap7
137+
$ pip install snap7-optimized
61138

62-
No native libraries or platform-specific dependencies are required - python-snap7 is a pure Python package that works on all platforms.
139+
No native libraries or platform-specific dependencies are required - this is a
140+
pure Python package that works on all platforms (Linux, Windows, macOS).

snap7/client.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import logging
8+
import socket
89
import struct
910
import time
1011
from typing import List, Any, Optional, Tuple, Union, Callable, cast
@@ -337,6 +338,38 @@ def get_connected(self) -> bool:
337338
return False
338339
return self.connection.check_connection()
339340

341+
def heartbeat_read(self, timeout_ms: int = 500) -> bool:
342+
"""Fast liveness check — reads 1 byte from Merker area with short timeout.
343+
344+
Designed for redundant PLC heartbeat monitoring. Reads M0 (1 byte)
345+
which has negligible impact on PLC scan cycle. Uses a short timeout
346+
to detect dead connections quickly.
347+
348+
Args:
349+
timeout_ms: Timeout in milliseconds for this single read (default 500ms).
350+
351+
Returns:
352+
True if PLC responded, False if timeout/error occurred.
353+
"""
354+
if not self.connected or self.connection is None:
355+
return False
356+
357+
original_timeout = self.connection.socket.gettimeout() if self.connection.socket else None
358+
try:
359+
self.connection.socket.settimeout(timeout_ms / 1000.0)
360+
self.read_area(Area.MK, 0, 0, 1)
361+
return True
362+
except (S7TimeoutError, S7ConnectionError, socket.timeout, socket.error, OSError):
363+
return False
364+
except Exception:
365+
return False
366+
finally:
367+
if self.connection and self.connection.socket and original_timeout is not None:
368+
try:
369+
self.connection.socket.settimeout(original_timeout)
370+
except OSError:
371+
pass
372+
340373
def db_read(self, db_number: int, start: int, size: int) -> bytearray:
341374
"""
342375
Read data from DB.

snap7/connection.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,13 +197,25 @@ def receive_data(self) -> bytes:
197197
raise S7ConnectionError(f"Receive failed: {e}")
198198

199199
def _tcp_connect(self) -> None:
200-
"""Establish TCP connection."""
200+
"""Establish TCP connection with TCP keepalive for fast dead-peer detection."""
201201
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
202202
self.socket.settimeout(self.timeout)
203203

204204
try:
205205
self.socket.connect((self.host, self.port))
206-
logger.debug(f"TCP connected to {self.host}:{self.port}")
206+
207+
# Enable TCP keepalive for fast detection of dead connections.
208+
# This catches network-level failures (cable pull, OS crash)
209+
# within ~4 seconds instead of waiting for the default TCP timeout.
210+
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
211+
if hasattr(socket, 'TCP_KEEPIDLE'): # Linux
212+
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 2)
213+
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 1)
214+
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 2)
215+
elif hasattr(socket, 'TCP_KEEPALIVE'): # macOS
216+
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 2)
217+
218+
logger.debug(f"TCP connected to {self.host}:{self.port} (keepalive enabled)")
207219
except socket.error as e:
208220
raise S7ConnectionError(f"TCP connection failed: {e}")
209221

0 commit comments

Comments
 (0)