Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/README.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

4.6 KiB

Modbus integration-test fixture — pymodbus simulator

The Modbus driver's integration tests talk to a pymodbus simulator running as a pinned Docker container. One image, per-profile service in compose, same port binding (5020) regardless of which profile is live. Docker is the only supported launch path — a fresh clone needs Docker Desktop and nothing else.

File Purpose
Dockerfile python:3.12-slim-bookworm + pymodbus[simulator]==3.13.0 + every profile JSON + exception_injector.py
docker-compose.yml One service per profile (standard / dl205 / mitsubishi / s7_1500 / exception_injection); all bind :5020 so only one runs at a time
profiles/*.json Same seed-register definitions the native launcher uses — canonical source
exception_injector.py Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the exception_injection profile

Run

From the repo root:

# Build + start the standard profile
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile standard up

# DL205 quirks
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile dl205 up

# Mitsubishi MELSEC quirks
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile mitsubishi up

# Siemens S7-1500 MB_SERVER quirks
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up

# Exception-injection — end-to-end coverage of every Modbus exception code
# (01/02/03/04/05/06/0A/0B), not just the 02 + 03 pymodbus emits naturally
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile exception_injection up

Detached + stop:

docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down

Only one profile binds :5020 at a time; switch by stopping the current service + starting another. The integration tests discriminate by a separate MODBUS_SIM_PROFILE env var so they skip correctly when the wrong profile is live.

Endpoint

  • Default: localhost:5020
  • Override with MODBUS_SIM_ENDPOINT (e.g. a real PLC on :502).

Run the integration tests

In a separate shell with one profile live:

cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests

ModbusSimulatorFixture probes localhost:5020 at collection init + records a SkipReason when unreachable, so tests stay green on a fresh clone without Docker running.

Exception injection

pymodbus's simulator naturally emits only Modbus exception codes 0x02 (Illegal Data Address, on reads outside its configured ranges) and 0x03 (Illegal Data Value, on over-length requests). The driver's MapModbusExceptionToStatus table translates eight codes: 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0A, 0x0B. Unit tests lock the translation function; the integration side previously only proved the wire-to-status path for 0x02.

The exception_injection profile runs exception_injector.py — a tiny standalone Modbus/TCP server written against the Python stdlib (zero dependencies outside what's in the base image). It speaks the wire protocol directly (FC 01/02/03/04/05/06/15/16) and looks up each incoming (fc, address) against the rules in profiles/exception_injection.json; a matching rule makes the server reply with [fc | 0x80, exception_code] instead of the normal response.

Current rules (see the JSON file for the canonical list):

  • FC03 @1000..1007 — one per exception code (0x01/0x02/0x03/0x04/0x05/0x06/0x0A/0x0B)
  • FC06 @2000..20010x04 Server Failure, 0x06 Server Busy (write-path coverage)
  • FC16 @30000x04 Server Failure (multi-register write path)

Adding more coverage is append-only: drop a new {fc, address, exception, description} entry into the JSON, restart the service, add an [InlineData] row in ExceptionInjectionTests.

References