test(s7): live python-snap7 coverage for the #632/#638 codecs, TSAP override and PDU size (#632)

Extends the s7_1500 profile (DB1 2048 bytes, 'hex' seed type) with distinct
non-zero seeds for STRING/WSTRING/CHAR/WCHAR/DTL/DATE/TOD/TIME/S5TIME plus
undecodable buffers; S7_1500CodecTests covers reads, BadOutOfRange paths,
write round-trips (S7_SIM_ALLOW_WRITES=1), TSAP keys and negotiated PDU size.
Skips when S7_SIM_ENDPOINT is unreachable.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 16:53:02 -04:00
parent 41e600e069
commit 2aae3c094f
6 changed files with 332 additions and 5 deletions
@@ -93,7 +93,7 @@ known offsets:
| `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default |
Seed types supported: `u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`,
`bool` (with `"bit": 0..7`), `ascii` (S7 STRING).
`bool` (with `"bit": 0..7`), `ascii` (S7 STRING), `hex` (raw bytes).
### Span-planner gate seeds (offsets 300+, `S7_1500SpanReadTests`)
@@ -119,6 +119,32 @@ inside the holes cluster A's span reads through. No tag maps to them; they are
there so a mis-computed slice offset returns garbage instead of a zero that could
match by luck. Every seed above is distinct and non-zero for the same reason.
### Codec gate seeds (offsets 800+, `S7_1500CodecTests`, #632/#638)
DB1 is 2048 bytes. Seeds use the `hex` seed type (raw big-endian bytes) for WSTRING/DTL/undecodable
headers. All distinct and non-zero. Writes go to the **unseeded** 1100..1199 scratch area
(`S7_SIM_ALLOW_WRITES=1` required).
| Address | Type | Seed | Decodes to |
|---|---|---|---|
| `DB1.DBB800` | STRING max 32 | `Siemens S7` | `"Siemens S7"` |
| `DB1.DBB840` | WSTRING max 8 | `0008 0005 0047 0072 00fc 00df 0065` | `"Grüße"` |
| `DB1.DBB870` | CHAR | `0x51` | `"Q"` |
| `DB1.DBB872` | WCHAR | `0x20AC` | `"€"` |
| `DB1.DBB880` | DTL | `07e8 03 0f 06 0d 2d 38 2f075f4c` | 2024-03-15 13:45:56.7890123 |
| `DB1.DBW900` | DATE | `11488` | 2021-06-15 |
| `DB1.DBD904` | TOD | `49556789` | 49 556 789 ms (13:45:56.789) |
| `DB1.DBD910` / `914` | TIME | `123456789` / `-86400123` | same, ms |
| `DB1.DBW920` / `922` | S5TIME | `0x2789` / `0x0123` | 789 000 ms / 1 230 ms |
| `DB1.DBW960` | DATE | `0xFFFF` | 2169-06-06 |
| `DB1.DBB930` | DTL | all zero (unseeded) | BadOutOfRange (uninitialised) |
| `DB1.DBW940` | S5TIME | `0x0A5F` | BadOutOfRange (non-BCD nibble) |
| `DB1.DBD944` | TOD | `0xFFFFFFFF` | BadOutOfRange (past midnight) |
| `DB1.DBB950` | WSTRING max 3 | `0003 0009` | BadOutOfRange (cur > max) |
snap7 does not validate STRING/WSTRING headers or TSAPs, so the TSAP-override theory proves the keys
reach the wire and the connection still works — not TSAP semantics (hardware only).
### Timer / Counter (TM / CT) areas
`TM0` and `CT0` are 64 bytes each — **32 device cells**, since a timer is a 2-byte
@@ -4,7 +4,7 @@
{
"area": "DB",
"index": 1,
"size": 1024,
"size": 2048,
"seeds": [
{ "_desc": "DB1.DBW0 — read-back probe, S7Driver default ProbeAddress target is MW0; this shadows it",
"offset": 0, "type": "u16", "value": 4242 },
@@ -46,7 +46,30 @@
{ "_desc": "DB1.DBW500 — isolated narrow scalar: its own single-member span, demoted by PrefersPerTagRead into a leftover that rides the multi-var pass",
"offset": 500, "type": "u16", "value": 60001 },
{ "_desc": "DB1.DBW600 — second isolated narrow scalar, likewise a multi-var leftover",
"offset": 600, "type": "i16", "value": -32001 }
"offset": 600, "type": "i16", "value": -32001 },
{ "_desc": "CODEC GATE (S7_1500CodecTests, #632/#638). Every seed from 800 to 969 is distinct and non-zero so a decode that returns zero/default can never pass. 800: STRING max 32 cur 10 'Siemens S7'",
"offset": 800, "type": "ascii", "value": "Siemens S7", "max_len": 32 },
{ "_desc": "DB1 @840 — WSTRING max 8 cur 5 'Grüße' (ü=00FC, ß=00DF) — non-ASCII proves UTF-16BE, not Latin-1",
"offset": 840, "type": "hex", "value": "0008 0005 0047 0072 00fc 00df 0065" },
{ "_desc": "DB1 @870 — CHAR 'Q'", "offset": 870, "type": "u8", "value": 81 },
{ "_desc": "DB1 @872 — WCHAR U+20AC '€' (does not fit Latin-1)", "offset": 872, "type": "u16", "value": 8364 },
{ "_desc": "DB1 @880 — DTL 2024-03-15 13:45:56.7890123 (weekday 6 = Friday, ns 789012300)",
"offset": 880, "type": "hex", "value": "07e8 03 0f 06 0d 2d 38 2f075f4c" },
{ "_desc": "DB1 @900 — DATE 2021-06-15 = 11488 days since 1990-01-01", "offset": 900, "type": "u16", "value": 11488 },
{ "_desc": "DB1 @904 — TOD 13:45:56.789 = 49556789 ms", "offset": 904, "type": "u32", "value": 49556789 },
{ "_desc": "DB1 @910 — TIME +123456789 ms", "offset": 910, "type": "i32", "value": 123456789 },
{ "_desc": "DB1 @914 — TIME -86400123 ms (negative duration)", "offset": 914, "type": "i32", "value": -86400123 },
{ "_desc": "DB1 @920 — S5TIME 0x2789: base 2 (x1 s), BCD 789 = 789000 ms", "offset": 920, "type": "u16", "value": 10121 },
{ "_desc": "DB1 @922 — S5TIME 0x0123: base 0 (x10 ms), BCD 123 = 1230 ms", "offset": 922, "type": "u16", "value": 291 },
{ "_desc": "UNDECODABLE buffers — reads must surface BadOutOfRange, not a value. @930 DTL stays all-zero (uninitialised) on purpose. @940: S5TIME 0x0A5F has a non-BCD nibble",
"offset": 940, "type": "u16", "value": 2655 },
{ "_desc": "DB1 @944 — TOD 0xFFFFFFFF, past midnight", "offset": 944, "type": "u32", "value": 4294967295 },
{ "_desc": "DB1 @950 — WSTRING max 3 cur 9 (current length exceeds reserved)",
"offset": 950, "type": "hex", "value": "0003 0009" },
{ "_desc": "DB1 @960 — DATE 0xFFFF = 65535 days = 2169-06-06, the far end of the DATE range (still a valid decode)",
"offset": 960, "type": "u16", "value": 65535 }
]
},
{
@@ -45,7 +45,7 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
"""Poke seed values into the area buffer at declared byte offsets.
Each seed is {"offset": int, "type": str, "value": int|float|bool|str}
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii}. Endianness is
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii, hex}. Endianness is
big-endian (Siemens wire format).
"""
for seed in seeds:
@@ -73,6 +73,11 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
buf[off] |= (1 << bit)
else:
buf[off] &= ~(1 << bit) & 0xFF
elif t == "hex":
# Raw bytes, big-endian wire order as written. Used for the multi-byte S7 types the
# scalar seeds cannot express (WSTRING, DTL, ...): {"type": "hex", "value": "07e8030f..."}.
raw = bytes.fromhex(str(v).replace(" ", ""))
buf[off:off + len(raw)] = raw
elif t == "ascii":
# Siemens STRING type: byte 0 = max length, byte 1 = current length,
# bytes 2+ = payload. Seeds supply the payload text; we fill max/cur.