Files
lmxopcua/docs/drivers/S7-Test-Fixture.md
Joseph Doherty 6609141493 Dockerize Modbus + AB CIP + S7 test fixtures for reproducibility. Every driver integration simulator now has a pinned Docker image alongside the existing native launcher — Docker is the primary path, native fallbacks kept for contributors who prefer them. Matches the already-Dockerized OpcUaClient/opc-plc pattern from #215 so every fixture in the fleet presents the same compose-up/test/compose-down loop. Reproducibility gain: what used to require a local pip/Python install (Modbus pymodbus, S7 python-snap7) or a per-OS C build from source (AB CIP ab_server from libplctag) now collapses to a Dockerfile + docker compose up. Modbus — new tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/ with Dockerfile (python:3.12-slim-bookworm + pymodbus[simulator]==3.13.0) + docker-compose.yml with four compose profiles (standard / dl205 / mitsubishi / s7_1500) backed by the existing profile JSONs copied under Docker/profiles/ as canonical; native fallback in Pymodbus/ retained with the same JSON set (symlink-equivalent — manual re-sync when profiles change, noted in both READMEs). Port 5020 unchanged so MODBUS_SIM_ENDPOINT + ModbusSimulatorFixture work without code change. Dropped the --no_http CLI arg the old serve.ps1 + compose draft passed — pymodbus 3.13 doesn't recognize it; the simulator's http ui just binds inside the container where nothing maps it out and costs nothing. S7 — new tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/ with Dockerfile (python:3.12-slim-bookworm + python-snap7>=2.0) + docker-compose.yml with one s7_1500 compose profile; copies the existing server.py shim + s7_1500.json seed profile; runs python -u server.py ... --port 1102. Native fallback in PythonSnap7/ retained. Port 1102 unchanged. AB CIP — hardest because ab_server is a source-only C tool in libplctag's src/tools/ab_server/. New tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/ Dockerfile is multi-stage: build stage (debian:bookworm-slim + build-essential + cmake) clones libplctag at a pinned tag + cmake --build build --target ab_server; runtime stage (debian:bookworm-slim) copies just the binary from /src/build/bin_dist/ab_server. docker-compose.yml ships four compose profiles (controllogix / compactlogix / micro800 / guardlogix) with per-family ab_server CLI args matching AbServerProfile.cs. AbServerFixture updated: tries TCP probe on 127.0.0.1:44818 first (Docker path) + spawns the native binary only as fallback when no listener is there. AB_SERVER_ENDPOINT env var supported for pointing at a real PLC. AbServerFact/Theory attributes updated to IsServerAvailable() which accepts any of: live listener on 44818, AB_SERVER_ENDPOINT set, or binary on PATH. Required two CLI-compat fixes to ab_server's argument expectations that the existing native profile never caught because it was never actually run at CI: --plc is case-sensitive (ControlLogix not controllogix), CIP tags need [size] bracket notation (DINT[1] not bare DINT), ControlLogix also requires --path=1,0. Compose files carry the corrected flags; the existing native-path AbServerProfile.cs was never invoked in practice so we don't rewrite it here. Micro800 now uses the --plc=Micro800 mode rather than falling back to ControlLogix emulation — ab_server does have the dedicated mode, the old Notes saying otherwise were wrong. Updated docs: three fixture coverage docs (Modbus-Test-Fixture.md, S7-Test-Fixture.md, AbServer-Test-Fixture.md) flip their "What the fixture is" section from native-only to Docker-primary-with-native-fallback; dev-environment.md §Resource Inventory replaces the old ambiguous "Docker Desktop + ab_server native" mix with four per-driver rows (each listing the image, compose file, compose profiles, port, credentials) + a new Docker fixtures — quick reference subsection giving the one-line docker compose -f <…> --profile <…> up for each driver + the env-var override names + the native fallback install recipes. drivers/README.md coverage map table updated — Modbus/AB CIP/S7 entries now read "Dockerized …" consistent with OpcUaClient's line. Verified end-to-end against live containers: Modbus DL205 smoke 1/1, S7 3/3, AB CIP ControlLogix 4/4 (all family theory rows). Container lifecycle clean (up/test/down, no leaked state). Every fixture keeps its skip-when-absent probe + env-var endpoint override so dotnet test on a fresh clone without Docker running still gets a green run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:09:44 -04:00

5.2 KiB

Siemens S7 test fixture

Coverage map + gap inventory for the S7 driver.

TL;DR: S7 now has a wire-level integration fixture backed by python-snap7's Server class (task #216). Atomic reads (u16 / i16 / i32 / f32 / bool-with-bit) + DB write-then-read round-trip are exercised end-to-end through S7netplus + real ISO-on-TCP on localhost:1102. Unit tests still carry everything else (address parsing, error-branch handling, probe-loop contract). Gaps remaining are variant-quirk-shaped: Optimized-DB symbolic access, PG/OP session types, PUT/GET-disabled enforcement — all need real hardware.

What the fixture is

Integration layer (task #216): tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ stands up a python-snap7 Server via Docker/docker-compose.yml --profile s7_1500 on localhost:1102 (pinned python:3.12-slim-bookworm base + python-snap7>=2.0). Native-Python fallback under PythonSnap7/ kept for contributors who prefer to avoid Docker. Snap7ServerFixture probes the port at collection init + skips with a clear message when unreachable (matches the pymodbus pattern). server.py reads a JSON profile + seeds DB/MB bytes at declared offsets; seeds are typed (u16 / i16 / i32 / f32 / bool / ascii for S7 STRING).

Unit layer: tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ covers everything the wire-level suite doesn't — address parsing, error branches, probe-loop contract. All tests tagged [Trait("Category", "Unit")].

The driver ctor change that made this possible: Plc(CpuType, host, port, rack, slot) — S7netplus 0.20's 5-arg overload — wires S7DriverOptions.Port through so the simulator can bind 1102 (non-privileged) instead of 102 (root / Firewall-prompt territory).

What it actually covers

Integration (python-snap7, task #216)

  • S7_1500SmokeTests.Driver_reads_seeded_u16_through_real_S7comm — DB1.DBW0 read via real S7netplus over TCP + simulator; proves handshake + read path
  • S7_1500SmokeTests.Driver_reads_seeded_typed_batch — i16, i32, f32, bool-with-bit in one batch call; proves typed decode per S7DataType
  • S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_wordDB1.DBW100 write → read-back; proves write path + buffer visibility

Unit

  • S7AddressParserTests — S7 address syntax (DB1.DBD0, M10.3, IW4, etc.)
  • S7DriverScaffoldTestsIDriver lifecycle (init / reinit / shutdown / health)
  • S7DriverReadWriteTests — error paths (uninitialized read/write, bad addresses, transport exceptions)
  • S7DiscoveryAndSubscribeTestsITagDiscovery.DiscoverAsync + polled ISubscribable contract with the shared PollGroupEngine

Capability surfaces whose contract is verified: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe. Wire-level surfaces verified: IReadable, IWritable.

What it does NOT cover

1. Wire-level anything

No ISO-on-TCP frame is ever sent during the test suite. S7netplus is the only wire-path abstraction and it has no in-process fake mode; the shipping choice was to contract-test via IS7Client rather than patch into S7netplus internals.

2. Read/write happy path

Every S7DriverReadWriteTests case exercises error branches. A successful read returning real PLC data is not tested end-to-end — the return value is whatever the fake says it is.

3. Mailbox serialization under concurrent reads

The driver's SemaphoreSlim serializes S7netplus calls because the S7 CPU's comm mailbox is scanned at most once per cycle. Contention behavior under real PLC latency is not exercised.

4. Variant quirks

S7-1200 vs S7-1500 vs S7-300/400 connection semantics (PG vs OP vs S7-Basic) not differentiated at test time.

5. Data types beyond the scalars

UDT fan-out, STRING with length-prefix quirks, DTL / DATE_AND_TIME, arrays of structs — not covered.

When to trust the S7 tests, when to reach for a rig

Question Unit tests Real PLC
"Does the address parser accept X syntax?" yes -
"Does the driver lifecycle hang / crash?" yes yes
"Does a real read against an S7-1500 return correct bytes?" no yes (required)
"Does mailbox serialization actually prevent PG timeouts?" no yes (required)
"Does a UDT fan-out produce usable member variables?" no yes (required)

Follow-up candidates

  1. Snap7 serverSnap7 ships a C-library-based S7 server that could run in-CI on Linux. A pinned build + a fixture shape similar to ab_server would give S7 parity with Modbus / AB CIP coverage.
  2. Plcsim Advanced — Siemens' paid emulator. Licensed per-seat; fits a lab rig but not CI.
  3. Real S7 lab rig — cheapest physical PLC (CPU 1212C) on a dedicated network port, wired via self-hosted runner.

Without any of these, S7 driver correctness against real hardware is trusted from field deployments, not from the test suite.

Key fixture / config files

  • tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ — unit tests only, no harness
  • src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs — ctor takes IS7ClientFactory which tests fake; docstring lines 8-20 note the deferred integration fixture