Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md
T

9.1 KiB
Raw Blame History

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=1024 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_ENDPOINT to point at a real S7 CPU on :102.
  • 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 (1024 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).

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.

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