Files
lmxopcua/docs/drivers/Modbus-Test-Fixture.md
Joseph Doherty 96940aeb24 Modbus exception-injection profile — closes the end-to-end test gap for exception codes 0x01/0x03/0x04/0x05/0x06/0x0A/0x0B. pymodbus simulator naturally emits only 0x02 (Illegal Data Address on reads outside configured ranges) + 0x03 (Illegal Data Value on over-length); the driver's MapModbusExceptionToStatus table translates eight codes, but only 0x02 had integration-level coverage (via DL205's unmapped-register test). Unit tests lock the translation function in isolation but an integration test was missing for everything else. This PR lands wire-level coverage for the remaining seven codes without depending on device-specific quirks to naturally produce them.
New exception_injector.py — standalone pure-Python-stdlib Modbus/TCP server shipped alongside the pymodbus image. Speaks the wire protocol directly (MBAP header parse + FC 01/02/03/04/05/06/15/16 dispatch + store-backed happy-path reads/writes + spec-enforced length caps) and looks up each (fc, starting-address) against a rules list loaded from JSON; a matching rule makes the server respond [fc|0x80, exception_code] instead of the normal response. Zero runtime dependencies outside the stdlib — the Dockerfile just COPY's the script into /fixtures/ alongside the pymodbus profile JSONs, no new pip install needed. ~200 lines. New exception_injection.json profile carries rules for every exception code on FC03 (addresses 1000-1007, one per code), FC06 (2000-2001 for CPU-PROGRAM-mode and busy), and FC16 (3000 for server failure). New exception_injection compose profile binds :5020 like every other service + runs python /fixtures/exception_injector.py --config /fixtures/exception_injection.json.

New ExceptionInjectionTests.cs in Modbus.IntegrationTests — 11 tests. Eight FC03-read theories exercise every exception code 0x01/0x02/0x03/0x04/0x05/0x06/0x0A/0x0B asserting the driver's expected OPC UA StatusCode mapping (BadNotSupported/BadOutOfRange/BadOutOfRange/BadDeviceFailure/BadDeviceFailure/BadDeviceFailure/BadCommunicationError/BadCommunicationError). Two FC06-write theories cover the write path for 0x04 (Server Failure, CPU in PROGRAM mode) + 0x06 (Server Busy). One sanity-check read at address 5 confirms the injector isn't globally broken + non-injected reads round-trip cleanly with Value=5/StatusCode=Good. All tests follow the MODBUS_SIM_PROFILE=exception_injection skip guard so they no-op on a fresh clone without Docker running.

Docker/README.md gains an §Exception injection section explaining what pymodbus can and cannot emit, what the injector does, where the rules live, and how to append new ones. docs/drivers/Modbus-Test-Fixture.md follow-up item #2 (extend pymodbus profiles to inject exceptions) gets a shipped strikethrough with the new coverage inventory; the unit-level section adds ExceptionInjectionTests next to DL205ExceptionCodeTests so the split-of-responsibilities is explicit (DL205 test = natural out-of-range via dl205 profile, ExceptionInjectionTests = every other code via the injector).

Test baselines: Modbus unit 182/182 green (unchanged); Modbus integration with exception_injection profile live 11/11 new tests green. Existing DL205/S7/Mitsubishi integration tests unaffected since they skip on MODBUS_SIM_PROFILE mismatch.

Found + fixed during validation: a stale native pymodbus simulator from April 18 was still listening on port 5020 on IPv6 localhost (Windows was load-balancing between it + the Docker IPv4 forward, making injected exceptions intermittently come back as pymodbus's default 0x02). Killed the leftover. Documented the debugging path in the commit as a note for anyone who hits the same "my tests see exception 0x02 but the injector log has no request" symptom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:11:32 -04:00

5.6 KiB
Raw Blame History

Modbus test fixture

Coverage map + gap inventory for the Modbus TCP driver's integration-test harness backed by pymodbus simulator profiles per PLC family.

TL;DR: Modbus is the best-covered driver — a real pymodbus server on localhost with per-family seed-register profiles, plus a skip-gate when the simulator port isn't reachable. Covers DL205 / Mitsubishi MELSEC / Siemens S7-1500 family quirks end-to-end. Gaps are mostly error-path + alarm/history shaped (neither is a Modbus-side concept).

What the fixture is

  • Simulator: pymodbus (Python, BSD) launched as a pinned Docker container at tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/. Docker is the only supported launch path.
  • Lifecycle: ModbusSimulatorFixture (collection-scoped) TCP-probes localhost:5020 on first use. MODBUS_SIM_ENDPOINT env var overrides the endpoint so the same suite can target a real PLC.
  • Profiles: DL205Profile, MitsubishiProfile, S7_1500Profile — each composes device-specific register-format + quirk-seed JSON for pymodbus. Profile JSONs live under Docker/profiles/ and are baked into the image.
  • Compose services: one per profile (standard / dl205 / mitsubishi / s7_1500); only one binds :5020 at a time.
  • Tests skip via Assert.Skip(sim.SkipReason) when the probe fails; no custom FactAttribute needed because ModbusSimulatorCollection carries the skip reason.

What it actually covers

DL205 (Automation Direct)

  • DL205SmokeTests — FC16 write → FC03 read round-trip on holding register
  • DL205CoilMappingTests — Y-output / C-relay / X-input address mapping (octal → Modbus offset)
  • DL205ExceptionCodeTests — Modbus exception 0x02 → OPC UA BadOutOfRange against the dl205 profile (natural out-of-range path)
  • ExceptionInjectionTests — every other exception code in the mapping table (0x01 / 0x03 / 0x04 / 0x05 / 0x06 / 0x0A / 0x0B) against the exception_injection profile on both read + write paths
  • DL205FloatCdabQuirkTests — CDAB word-swap float encoding
  • DL205StringQuirkTests — packed-string V-memory layout
  • DL205VMemoryQuirkTests — V-memory octal addressing
  • DL205XInputTests — X-register read-only enforcement

Mitsubishi MELSEC

  • MitsubishiSmokeTests — read + write round-trip
  • MitsubishiQuirkTests — word-order, device-code mapping (D/M/X/Y ranges)

Siemens S7-1500 (Modbus gateway flavor)

  • S7_1500SmokeTests — read + write round-trip
  • S7_ByteOrderTests — ABCD/DCBA/BADC/CDAB byte-order matrix

Capability surfaces hit

  • IReadable + IWritable — full round-trip
  • ISubscribable — via the shared PollGroupEngine (polled subscription)
  • IHostConnectivityProbe — TCP-reach transitions

What it does NOT cover

1. No ITagDiscovery

Modbus has no symbol table — the driver requires a static tag map from DriverConfig. There is no discovery path to test + none in the fixture.

2. Error-path fuzzing

pymodbus serves the seeded values happily; the fixture can't easily inject exception responses (code 0x010x0B) or malformed PDUs. The AbCipStatusMapper-equivalent for exception codes is unit-tested via DL205ExceptionCodeTests but the simulator itself never refuses a read.

3. Variant-specific quirks beyond the three profiles

  • FX5U / QJ71MT91 Mitsubishi variants — profile scaffolds exist, no tests yet
  • Non-S7-1500 Siemens (S7-1200 / ET200SP) — byte-order covered but connection-pool + fragmentation quirks untested
  • DL205-family cousins (DL06, DL260) — no dedicated profile

4. Subscription stress

PollGroupEngine is unit-tested standalone but the simulator doesn't exercise it under multi-register packing stress (FC03 with 125-register batches, boundary splits, etc.).

5. Alarms / history

Not a Modbus concept. Driver doesn't implement IAlarmSource or IHistoryProvider; no test coverage is the correct shape.

When to trust the Modbus fixture, when to reach for a rig

Question Fixture Unit tests Real PLC
"Does FC03/FC06/FC16 work end-to-end?" yes - yes
"Does DL205 octal addressing map correctly?" yes yes yes
"Does float CDAB word-swap round-trip?" yes yes yes
"Does the driver handle exception responses?" no yes yes (required)
"Does packing 125 regs into one FC03 work?" no no yes (required)
"Does FX5U behave like Q-series?" no no yes (required)

Follow-up candidates

  1. Add MODBUS_SIM_ENDPOINT override documentation to docs/v2/test-data-sources.md so operators can point the suite at a lab rig.
  2. Extend pymodbus profiles to inject exception responsesshipped via the exception_injection compose profile + standalone exception_injector.py server. Rules in Docker/profiles/exception_injection.json map (fc, address) to an exception code; ExceptionInjectionTests exercises every code in MapModbusExceptionToStatus (0x01 / 0x02 / 0x03 / 0x04 / 0x05 / 0x06 / 0x0A / 0x0B) end-to-end on both read (FC03) and write (FC06) paths.
  3. Add an FX5U profile once a lab rig is available; the scaffolding is in place.

Key fixture / config files

  • tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs
  • tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs
  • tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiProfile.cs
  • tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/S7_1500Profile.cs
  • tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/ — Dockerfile + compose + per-family JSON profiles