Skip to content

Commit a3f5be0

Browse files
gijzelaerrclaude
andcommitted
Add missing AsyncClient methods, tests, and fix concurrent sequence bug
- Add error_text, get_pg_block_info, set/clear_session_password - Fix _send_receive: extract expected sequence from request bytes instead of using shared protocol counter (which races under concurrency) - Add 26 async tests including concurrent safety validation with asyncio.gather Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4278071 commit a3f5be0

2 files changed

Lines changed: 400 additions & 8 deletions

File tree

snap7/async_client.py

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from .connection import TPDUSize
1717
from .s7protocol import S7Protocol, get_return_code_description
1818
from .datatypes import S7Area, S7WordLen
19-
from .error import S7Error, S7ConnectionError, S7ProtocolError, S7StalePacketError, S7TimeoutError
19+
from .error import S7Error, S7ConnectionError, S7ProtocolError, S7TimeoutError
2020

2121

2222
logger = logging.getLogger(__name__)
@@ -295,6 +295,7 @@ def __init__(self) -> None:
295295
self.local_tsap = 0x0100
296296
self.remote_tsap = 0x0102
297297
self.connection_type = 1 # PG
298+
self.session_password: Optional[str] = None
298299

299300
self._exec_time = 0
300301
self._last_error = 0
@@ -324,24 +325,38 @@ async def _send_receive(self, request: bytes, max_stale_retries: int = 3) -> dic
324325
325326
The lock ensures that concurrent coroutines never interleave
326327
send/receive on the same TCP socket.
328+
329+
Unlike the sync client, we do NOT use protocol.validate_pdu_reference()
330+
because the protocol's shared sequence counter can be incremented by
331+
a concurrent coroutine between request building and lock acquisition.
332+
Instead, we extract the expected sequence directly from the request
333+
bytes (S7 header bytes 4-5).
327334
"""
328335
conn = self._get_connection()
329336

337+
# Extract the sequence number we embedded in this request's S7 header.
338+
# S7 header: 0x32 | pdu_type | reserved(2) | sequence(2) | ...
339+
expected_seq = struct.unpack(">H", request[4:6])[0]
340+
330341
async with self._lock:
331342
await conn.send_data(request)
332343

333344
for attempt in range(max_stale_retries + 1):
334345
response_data = await conn.receive_data()
335346
response = self.protocol.parse_response(response_data)
336347

337-
try:
338-
self.protocol.validate_pdu_reference(response["sequence"])
348+
resp_seq = response.get("sequence", 0)
349+
if resp_seq == expected_seq:
339350
return response
340-
except S7StalePacketError:
341-
if attempt < max_stale_retries:
342-
logger.warning(f"Stale packet (attempt {attempt + 1}/{max_stale_retries}), retrying receive")
343-
continue
344-
raise S7ProtocolError(f"Max stale packet retries ({max_stale_retries}) exceeded")
351+
352+
# Stale packet — response is for an older request
353+
if attempt < max_stale_retries:
354+
logger.warning(
355+
f"Stale packet: expected seq {expected_seq}, got {resp_seq} "
356+
f"(attempt {attempt + 1}/{max_stale_retries}), retrying receive"
357+
)
358+
continue
359+
raise S7ProtocolError(f"Max stale packet retries ({max_stale_retries}) exceeded")
345360

346361
raise S7ProtocolError("Failed to receive valid response") # Should not reach here
347362

@@ -1215,6 +1230,54 @@ def get_last_error(self) -> int:
12151230
"""Get last error code."""
12161231
return self._last_error
12171232

1233+
def error_text(self, error_code: int) -> str:
1234+
"""Get error text for error code."""
1235+
error_texts = {
1236+
0: "OK",
1237+
0x0001: "Invalid resource",
1238+
0x0002: "Invalid handle",
1239+
0x0003: "Not connected",
1240+
0x0004: "Connection error",
1241+
0x0005: "Data error",
1242+
0x0006: "Timeout",
1243+
0x0007: "Function not supported",
1244+
0x0008: "Invalid PDU size",
1245+
0x0009: "Invalid PLC answer",
1246+
0x000A: "Invalid CPU state",
1247+
0x01E00000: "CPU : Invalid password",
1248+
0x00D00000: "CPU : Invalid value supplied",
1249+
0x02600000: "CLI : Cannot change this param now",
1250+
}
1251+
return error_texts.get(error_code, f"Unknown error: {error_code}")
1252+
1253+
def get_pg_block_info(self, data: bytearray) -> TS7BlockInfo:
1254+
"""Get block info from raw block data."""
1255+
block_info = TS7BlockInfo()
1256+
1257+
if len(data) >= 36:
1258+
block_info.BlkType = data[5]
1259+
block_info.BlkNumber = struct.unpack(">H", data[6:8])[0]
1260+
block_info.BlkLang = data[4]
1261+
block_info.MC7Size = struct.unpack(">I", data[8:12])[0]
1262+
block_info.LoadSize = struct.unpack(">I", data[12:16])[0]
1263+
block_info.SBBLength = struct.unpack(">I", data[28:32])[0]
1264+
block_info.CheckSum = struct.unpack(">H", data[32:34])[0]
1265+
block_info.Version = data[34]
1266+
block_info.CodeDate = b"2019/06/27"
1267+
block_info.IntfDate = b"2019/06/27"
1268+
1269+
return block_info
1270+
1271+
def set_session_password(self, password: str) -> int:
1272+
"""Set session password."""
1273+
self.session_password = password
1274+
return 0
1275+
1276+
def clear_session_password(self) -> int:
1277+
"""Clear session password."""
1278+
self.session_password = None
1279+
return 0
1280+
12181281
def set_connection_params(self, address: str, local_tsap: int, remote_tsap: int) -> None:
12191282
"""Set connection parameters."""
12201283
self.host = address

0 commit comments

Comments
 (0)