Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
S7 integration-test fixture — python-snap7
python-snap7 Server class
wrapped in a pinned python:3.12-slim-bookworm image. Docker is the
only supported launch path — a fresh clone needs Docker Desktop and
nothing else.
| File | Purpose |
|---|---|
Dockerfile |
python:3.12-slim-bookworm + python-snap7>=2.0 + the server shim + the profile JSONs |
docker-compose.yml |
One service per profile; currently only s7_1500 |
server.py |
Same Python shim the native fallback uses — copy kept in the build context |
profiles/*.json |
Area-seed definitions (DB1 / MB layouts with typed seeds) |
Run
From the repo root:
docker compose -f tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up
Detached + stop:
docker compose -f tests\...\Docker\docker-compose.yml --profile s7_1500 up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile s7_1500 down
⚠️ Editing a profile requires --build
Dockerfile COPYs profiles/ into the image, so the seeds are baked in at
build time — they are not bind-mounted. A plain up -d, and even
up -d --force-recreate, reuses the existing otopcua-python-snap7:1.0 image
and silently keeps serving the OLD seeds. After editing a profile JSON:
docker compose --profile s7_1500 up -d --build --force-recreate
Confirm it took: docker logs otopcua-python-snap7-s7_1500 prints
seeded DB1 size=2048 seeds=<n> at startup — check <n> matches the seed count
you just wrote. This is the fastest way to tell a stale image from a bad address.
On the shared Docker host the deployment mirror at /opt/otopcua-s7/ is
root-owned, so a plain rsync fails with status 23 and leaves the old
files in place while still printing the file list. Push with:
rsync -av --rsync-path="sudo rsync" \
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/ \
dohertj2@10.100.0.35:/opt/otopcua-s7/
Endpoint
- Default:
localhost:1102(non-privileged; sidesteps Windows Firewall prompt + Linux's root-required bind on port 102). - Override with
S7_SIM_ENDPOINTto point at a real S7 CPU on:102. - Tests that write (
S7_1500ArrayWriteTests) run only withS7_SIM_ALLOW_WRITES=1, so a real-CPU endpoint is never written by accident. - The driver's S7DriverOptions.Port flows through S7netplus's 5-arg
Plc(CpuType, host, port, rack, slot)ctor so the non-standard port works end-to-end.
Run the integration tests
In a separate shell with the container up:
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests
Snap7ServerFixture probes localhost:1102 at collection init + records
a SkipReason when unreachable, so tests stay green on a fresh clone
without Docker running.
What's encoded in profiles/s7_1500.json
DB1 (2048 bytes) + MB (256 bytes) + TM0/CT0 (64 bytes each) with typed seeds at known offsets:
| Address | Type | Seed | Purpose |
|---|---|---|---|
DB1.DBW0 |
u16 | 4242 |
read-back probe |
DB1.DBW10 |
i16 | -12345 |
smoke i16 read |
DB1.DBD20 |
i32 | 1234567890 |
smoke i32 read |
DB1.DBD30 |
f32 | 3.14159 |
smoke f32 read (big-endian) |
DB1.DBX50.3 |
bool | true |
smoke bool read at bit 3 |
DB1.DBW100 |
u16 | 0 |
scratch for write-then-read |
DB1.STRING[200] |
S7 STRING | "Hello" |
S7 STRING read |
MW0 |
u16 | 1 |
S7ProbeOptions.ProbeAddress default |
Seed types supported: u8, i8, u16, i16, u32, i32, f32,
bool (with "bit": 0..7), ascii (S7 STRING), hex (raw bytes).
Span-planner gate seeds (offsets 300+, S7_1500SpanReadTests)
These exist to give S7ReadSpanPlanner a layout with a known partition under
the shipped 16-byte-gap / 220-byte-span defaults — the offsets are the test, so
moving one changes what the gate proves.
| Address | Type | Seed | Role in the partition |
|---|---|---|---|
DB1.DBW300 |
u16 | 51001 |
cluster A — merges with the four below into ONE 300..320 span |
DB1.DBW302 |
i16 | -21001 |
cluster A (gap 0) |
DB1.DBD304 |
i32 | 305419896 |
cluster A (gap 0) |
DB1.DBX310.5 |
bool | true |
cluster A (gap 2) |
DB1.DBD316 |
f32 | 2.7182817 |
cluster A (gap 5); seals the span at 20 bytes |
DB1.DBW400 |
u16 | 40201 |
cluster B — 80 bytes on, so the gap budget forces a SECOND span |
DB1.DBW402 |
u16 | 40202 |
cluster B's second member (keeps it off the PrefersPerTagRead demotion) |
DB1.DBW500 |
u16 | 60001 |
isolated ⇒ demoted single-member span ⇒ multi-var leftover |
DB1.DBW600 |
i16 | -32001 |
isolated ⇒ multi-var leftover |
MW10 |
u16 | 4097 |
multi-var leftover in a different area — a block read can't cross (area, DB), multi-var can |
Offsets 308, 311 and 312 carry poison (0xDEAD, 0x5A, 0xDEADBEEF)
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
S5TIME word and a counter a 2-byte count word, with device number N at byte
offset 2 × N. They exist so S7_1500SpanReadTests can pin, against real wire,
that S7ReadSpanPlanner excludes Timer/Counter from every span and the multi-var
pass excludes them again on IsBufferType — each takes its own area-typed
ReadBytes round-trip instead.
| Address | Byte offset | Seed | Decodes to |
|---|---|---|---|
T0 |
0 | 0x2456 |
456.0 s (S5TIME base 2 = ×1 s, BCD 456) |
T5 |
10 | 0x1123 |
12.3 s (base 1 = ×0.1 s, BCD 123) |
T9 |
18 | 0x0250 |
2.5 s (base 0 = ×0.01 s, BCD 250) |
C0 |
0 | 4711 |
4711 (raw big-endian count word) |
C7 |
14 | 31337 |
31337 |
C11 |
22 | 1234 |
1234 |
A timer decodes to seconds as a double (S7.Net Timer.FromByteArray) and a
counter to an int count (Counter.FromByteArray, raw word — not BCD).
⚠️ Only device 0 is addressable on this fixture
Live-probed 2026-08-17: snap7's server resolves the TM/CT request address as a
bit address — it serves byte addr >> 3 of the area — whereas S7.Net, and a
real S7 CPU, put the device number in that field. Measured with S7netplus
0.20.0 against 10.100.0.35:1102 running the profile above:
ReadBytes(Timer,db=0,start=0,count=8) => [24 56 00 00 00 00 00 00] # TM bytes 0..7: T0 = 0x2456
ReadBytes(Timer,db=0,start=0,count=2) => [24 56] # T0 -> byte 0. 0>>3 == 2*0, so BOTH conventions agree here
ReadBytes(Timer,db=0,start=5,count=2) => [24 56] # T5 -> 5>>3 = byte 0. WRONG cell: T5 lives at bytes 10..11 (0x1123)
ReadBytes(Timer,db=0,start=80,count=2) => [11 23] # byte 10 needs start 80 (= 10*8): the T5 cell, reached the bit-address way
ReadBytes(Counter,db=0,start=0,count=2) => [12 67] # C0 -> byte 0 = 4711. Both conventions agree
ReadBytes(Counter,db=0,start=7,count=2) => [12 67] # C7 -> 7>>3 = byte 0. WRONG cell: C7 lives at bytes 14..15 (0x7A69 = 31337)
ReadBytes(Counter,db=0,start=112,count=2) => [7A 69] # byte 14 needs start 112 (= 14*8): the C7 cell
Note lines 2 and 3: asking for T0 and asking for T5 return the same bytes
— every device 0..7 collapses onto TM byte 0. Same for C0 / C7.
No error is raised — the reads succeed, they just land on the wrong cell. Device
0 is the single fixed point of the two conventions (0 >> 3 == 2 × 0 == 0),
so T0 / C0 are the only cells whose value the fixture can pin; that is what
Timer_and_counter_tags_never_enter_a_multi_var_request asserts, and the
wire-shape half of that test is unaffected by the quirk. The 2 × N cells above
are seeded for a real CPU: run
Timer_and_counter_decode_the_seeded_values_at_their_device_numbers against one
with
S7_SIM_ENDPOINT=<cpu>:102 S7_TIMER_NUMBER_ADDRESSING=1 \
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests
It skips (naming this limitation) whenever that variable is unset.
Known limitations
From the snap7.server.Server docstring upstream:
"Legacy S7 server implementation. Emulates a Siemens S7 PLC for testing and development purposes. [...] pure Python emulator implementation that simulates PLC behaviour for protocol compliance testing rather than full industrial-grade functionality."
Not exercised here — needs a lab rig:
- S7-1500 Optimized-DB symbolic access
- PG / OP / S7-Basic session-type differentiation
- PUT/GET-disabled-by-default enforcement
- Device-number addressing of the TM / CT areas (see the ⚠️ above — the reads are served, but resolved as bit addresses, so only device 0 lands where a CPU would put it)
See docs/drivers/S7-Test-Fixture.md
for the full coverage map.
References
- python-snap7 GitHub
docs/drivers/S7-Test-Fixture.md— coverage mapdocs/v2/dev-environment.md§Docker fixtures