Modbus exception-injection profile — wire-level coverage for codes 0x01/0x03/0x04/0x05/0x06/0x0A/0x0B #174

Merged
dohertj2 merged 1 commits from modbus-exception-injection-profile into v2 2026-04-20 15:14:02 -04:00
Owner

Closes the integration-side gap for the driver's Modbus-exception → OPC UA StatusCode mapping. pymodbus's simulator naturally emits only codes 0x02 + 0x03; unit tests lock the translation function; integration coverage was previously just one case (DL205 profile @ unmapped register). This PR adds wire-level coverage for the remaining seven codes without depending on device-specific quirks.

What lands

  • Docker/exception_injector.py — standalone pure-stdlib Modbus/TCP server (~200 lines). Speaks the wire protocol directly (FC 01/02/03/04/05/06/15/16), dispatches on (fc, address), and responds with [fc | 0x80, exception_code] on matching rules.
  • Docker/profiles/exception_injection.json — rules for every exception code on FC03 (1000-1007), FC06 (2000-2001), FC16 (3000).
  • Docker/docker-compose.yml — new exception_injection compose profile binds :5020 and runs the injector.
  • Dockerfile — COPY's exception_injector.py into the existing pymodbus image (no new pip install).
  • ExceptionInjectionTests.cs — 11 tests: eight FC03-read theories (one per exception code), two FC06-write theories (0x04 + 0x06), one sanity-check non-injected read.

Test baselines

  • Modbus unit tests: 182/182 (unchanged)
  • Exception-injection integration tests: 11/11 green with the injector container live
  • Other integration profiles (dl205/mitsubishi/s7_1500) unaffected — they skip on MODBUS_SIM_PROFILE mismatch

Docs

  • Docker/README.md §Exception injection — explains what pymodbus can and can't emit, what the injector does, where the rules live, how to append new ones.
  • docs/drivers/Modbus-Test-Fixture.md — follow-up #2 strikethrough (shipped); unit-level section adds ExceptionInjectionTests next to DL205ExceptionCodeTests so the split is explicit.

Extending the coverage

Append-only: add a {fc, address, exception, description} entry to the JSON, restart the service, add an [InlineData] row in ExceptionInjectionTests.

Closes the integration-side gap for the driver's Modbus-exception → OPC UA StatusCode mapping. pymodbus's simulator naturally emits only codes 0x02 + 0x03; unit tests lock the translation function; integration coverage was previously just one case (DL205 profile @ unmapped register). This PR adds wire-level coverage for the remaining seven codes without depending on device-specific quirks. ## What lands - `Docker/exception_injector.py` — standalone pure-stdlib Modbus/TCP server (~200 lines). Speaks the wire protocol directly (FC 01/02/03/04/05/06/15/16), dispatches on (fc, address), and responds with `[fc | 0x80, exception_code]` on matching rules. - `Docker/profiles/exception_injection.json` — rules for every exception code on FC03 (1000-1007), FC06 (2000-2001), FC16 (3000). - `Docker/docker-compose.yml` — new `exception_injection` compose profile binds `:5020` and runs the injector. - `Dockerfile` — COPY's `exception_injector.py` into the existing pymodbus image (no new pip install). - `ExceptionInjectionTests.cs` — 11 tests: eight FC03-read theories (one per exception code), two FC06-write theories (0x04 + 0x06), one sanity-check non-injected read. ## Test baselines - Modbus unit tests: **182/182** (unchanged) - Exception-injection integration tests: **11/11** green with the injector container live - Other integration profiles (dl205/mitsubishi/s7_1500) unaffected — they skip on `MODBUS_SIM_PROFILE` mismatch ## Docs - `Docker/README.md` §Exception injection — explains what pymodbus can and can't emit, what the injector does, where the rules live, how to append new ones. - `docs/drivers/Modbus-Test-Fixture.md` — follow-up #2 strikethrough (shipped); unit-level section adds `ExceptionInjectionTests` next to `DL205ExceptionCodeTests` so the split is explicit. ## Extending the coverage Append-only: add a `{fc, address, exception, description}` entry to the JSON, restart the service, add an `[InlineData]` row in `ExceptionInjectionTests`.
dohertj2 added 1 commit 2026-04-20 15:13:50 -04:00
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>
dohertj2 merged commit 8384e58655 into v2 2026-04-20 15:14:02 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#174