12 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). Docker is the only supported launch path.
Snap7ServerFixture probes the port at collection init + skips with a
clear message when unreachable (matches the pymodbus pattern).
server.py (baked into the image under Docker/) reads a JSON profile
- seeds DB/MB bytes at declared offsets; seeds are typed (
u16/i16/i32/f32/bool/asciifor 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 pathS7_1500SmokeTests.Driver_reads_seeded_typed_batch— i16, i32, f32, bool-with-bit in one batch call; proves typed decode per S7DataTypeS7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word—DB1.DBW100write → read-back; proves write path + buffer visibilityS7_1500DiagnosticsTests.Driver_exposes_negotiated_pdu_size_post_init— assertsDriverHealth.Diagnostics["S7.NegotiatedPduSize"]is non-zero afterInitializeAsync; proves the negotiated PDU size surfaces in driver health (Snap7 fixture pins this at 240 bytes — see fixture README)
Unit
S7AddressParserTests— S7 address syntax (DB1.DBD0,M10.3,IW4, etc.)S7DriverScaffoldTests—IDriverlifecycle (init / reinit / shutdown / health)S7DriverReadWriteTests— error paths (uninitialized read/write, bad addresses, transport exceptions)S7DiscoveryAndSubscribeTests—ITagDiscovery.DiscoverAsync+ polledISubscribablecontract with the sharedPollGroupEngine
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.
Optimized DB / S7Plus is the variant-shaped gap with the biggest field
impact. snap7 happens to behave like a classic-S7comm-only PLC, so the
integration suite cannot reproduce the shape that an S7-1500 with default
"Optimized block access" checked would return (BadDeviceFailure on every
absolute-offset read). The decision is documented at
docs/v2/s7.md § Optimized DB constraint (S7Plus)
and tracked in docs/featuregaps.md row #1; the
project ships Track 1 (operator unchecks Optimized block access in TIA
Portal) and Track 3 (bridge via the OpcUaClient driver against the
CPU's onboard OPC UA server). A custom S7Plus implementation is out of
scope.
5. Data types beyond the scalars
STRING with length-prefix quirks, DTL / DATE_AND_TIME, arrays of
structs — not covered. UDT fan-out IS covered (PR-S7-D2 / #300) via the
udt_layout meta-seed in Docker/profiles/s7_1500.json and the
Driver_fans_out_udt_into_member_tags integration test.
6. SZL (System Status List) — @System.* virtual addresses
PR-S7-E1 / #302
adds a virtual @System.* address surface (CPU type, firmware, scan-cycle
stats, diagnostic-buffer ring) backed by SZL reads. snap7 does not
implement SZL — the simulator answers every SZL request with a function-
not-supported error, so the integration profile exercises only the
not-supported semantics (@System.CpuType against snap7 returns
BadNotSupported). Live-firmware SZL coverage is parked behind a
[Fact(Skip = ...)] until either S7netplus exposes a public ReadSzlAsync
or we ship a raw S7comm PDU helper. See
docs/v2/s7.md "CPU diagnostics (SZL)"
for the wire-status detail.
7. Password / protection levels — not modelled by snap7
PR-S7-E2 / #303 adds
Password + ProtectionLevel options that emit a connection-level password
right after OpenAsync. snap7 does not model S7 protection levels — the
simulator accepts every connection regardless of the password set on the
client, so the integration profile cannot distinguish "password sent
correctly" from "password ignored". Coverage stays at the unit-test seam:
S7PasswordOptionsTests injects a fake IS7PlcAuthGate to assert the
dispatch contract (Password=null skips the call; Password+SupportsSendPassword
calls the gate; auth-failed wraps to a clean InvalidOperationException),
plus the no-log invariant on S7DriverOptions.ToString().
The wire path is also fundamentally limited until S7netplus 0.20 exposes a
public SendPassword — the driver currently logs a warning and continues
when the API is missing. See
docs/v2/s7.md "PLC password / protection levels"
for the library-limitation note. Live-firmware coverage of the unlock path
requires a hardened S7-1500 lab rig with TIA Portal "Protection & Security"
configured, which is parked as a follow-up.
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?" | yes (Snap7 + udt_layout meta-seed) |
yes |
Follow-up candidates
-
Snap7 server — Snap7 ships a C-library-based S7 server that could run in-CI on Linux. A pinned build + a fixture shape similar to
ab_serverwould give S7 parity with Modbus / AB CIP coverage. -
Plcsim Advanced — Siemens' paid emulator. Licensed per-seat; fits a lab rig but not CI.
-
Real S7 lab rig — cheapest physical PLC (CPU 1212C) on a dedicated network port, wired via self-hosted runner.
-
PR-S7-C5 — PUT/GET-disabled pre-flight rejection. Snap7 does not model the hardened-CPU PUT/GET response (it accepts every read once the COTP handshake completes), so the failure path of the pre-flight probe —
S7PutGetDisabledExceptionthrown fromInitializeAsyncwhen the PLC rejects the probe read withErrorCode.WrongCPU_Type/ErrorCode.ReadData— needs a real S7-1500 with PUT/GET disabled in TIA Portal. The integration suite covers the happy path (Driver_preflight_passes_when_probe_address_seeded); the failure path should be added as a--with-real-plcopt-in test that the self-hosted runner with the lab rig executes. The classifier branch (S7PreflightClassifier.IsPutGetDisabled) is unit-tested without a network inS7PreflightTests.Classifier_matches_only_PUT_GET_disabled_error_codes. -
Live-firmware Optimized-block-access toggle (PR-S7-F / #304). snap7 happens to behave like a classic-S7comm CPU, so the integration profile cannot reproduce the failure that a default new TIA Portal V14+ project produces (
BadDeviceFailureonDB1.DBW0against an Optimized DB). A manual smoke test on the lab rig, gated behind--with-real-plc, would close that loop. Suggested checklist on a real S7-1500 V2.5+:- Create
DB1in TIA Portal with three INT members at offsets 0, 2, 4. Leave Optimized block access checked (the default). - Compile + download to the PLC.
- Drive the OtOpcUa S7 driver against
DB1.DBW0— assert that the read returnsBadDeviceFailure(the Track-1-not-applied symptom). This is the failure shape the docs warn about. - Open
DB1's properties → uncheck Optimized block access → compile → download. Re-run the read; assert it returns the seeded INT value at offset 0. (Track 1 verified end-to-end.) - Track 3 verification (separate run on the same rig): with
Optimized access re-enabled on
DB1, activate the CPU's onboard OPC UA server in TIA Portal, exposeDB1.<MemberName>through a Server interface, register anOpcUaClientdriver againstopc.tcp://<plc-ip>:4840, and assert the symbolic read returns the same seeded value. This proves the bridge path against a real Optimized DB without the operator having to disable Optimized access.
The test must stay manual: TIA Portal compile + download cannot be automated from CI without a Siemens engineering toolchain license, and download-with-CPU-stop is destructive on a shared lab rig. Document results inline in PR descriptions when the rig is available.
- Create
-
PR-S7-E1 — live SZL test against a real S7-1500. snap7 doesn't implement SZL at all, and S7netplus 0.20 doesn't expose a public
ReadSzlAsync, so the@System.*virtual address surface currently answersBadNotSupportedagainst every backend. The parser (S7SzlParser) is unit-tested against golden bytes; flipping the wire path on requires either an S7netplus PR or a raw-PDU helper. Once that's in,S7_1500SzlTests.System_CpuType_against_live_S7_1500_returns_non_empty_stringshould be flipped from[Fact(Skip = ...)]to env-var-gated against the self-hosted runner with the lab rig.
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 harnesssrc/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs— ctor takesIS7ClientFactorywhich tests fake; docstring lines 8-20 note the deferred integration fixture