Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker
Joseph Doherty 00f39c849e
v2-ci / build (pull_request) Successful in 4m13s
v2-ci / unit-tests (pull_request) Failing after 9m41s
docs(modbus-tests): document profile-cycling gotcha; sweep #2 was a false alarm
The 2026-07 integration sweep flagged the Modbus non-standard profiles as
8F/5F/3F/8F and guessed 'pymodbus 4.x drift'. Re-investigation shows that was
wrong on both counts:

- The image runs the PINNED pymodbus 3.13.0 (verified via import pymodbus;
  __version__). The '...removed in v4' log is 3.13.0's forward-deprecation
  warning, misread as running 4.x.
- The failures were a fixture-cycling artifact: each profile is a separate
  profile-gated compose service sharing :5020, and a plain 'docker compose down'
  (no --profile) does NOT stop the running profile container. It kept holding
  :5020; the next '--profile up' container silently failed to bind (compose
  still says 'Started'), so every later profile run hit the STALE sim. Proven by
  a raw Modbus probe: the 'mitsubishi' sim served DL205's 0xCAFE at HR0.

After force-removing all modbus containers and bringing up each profile cleanly
(verifying it actually published :5020), all four pass: dl205 16/16,
mitsubishi 13/13, s7_1500 10/10, exception_injection 17/17. No OtOpcUa or test
code change needed.

Added a profile-cycling gotcha + reliable-cycle recipe to Docker/README.md and
corrected the sweep record. Integration-sweep follow-up #2.
2026-07-15 05:12:52 -04:00
..

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\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile standard up

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

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

# Siemens S7-1500 MB_SERVER quirks
docker compose -f tests\Drivers\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\Drivers\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.

⚠️ Profile-cycling gotcha (bit us in the 2026-07 sweep — see archreview/plans/INTEGRATION-SWEEP-STATUS.md). Each profile is a separate compose service selected by --profile, so a plain docker compose down (no --profile) does not stop the currently-running profile container — it keeps holding :5020. Start another profile and the new container silently fails to bind the port (compose still reports "Started"); every test then hits the stale profile's data and fails with wrong values / Illegal-Data-Address. The symptom is deceptive: reads succeed but return the previous profile's seed (e.g. Mitsubishi's D0 reads DL205's 0xCAFE). Always tear the old container down by name / with its profile before bringing up the next, and verify the new container actually published :5020:

# reliable cycle (force-remove ALL modbus containers, then bring one up)
docker rm -f $(docker ps -aq --filter name=otopcua-modbus --filter name=otopcua-pymodbus)
docker compose --profile <profile> up -d --force-recreate
docker ps --filter name=otopcua --format '{{.Names}}  {{.Ports}}'   # MUST show 0.0.0.0:5020->5020

The exception_injection service uses a different container name (otopcua-modbus-exception-injector, not otopcua-pymodbus-*), so a name-filtered docker rm that only matches otopcua-pymodbus will miss it — the --force-recreate above covers that case.

Profile coverage matrix

The two general-purpose profiles cover disjoint test sets. A full pass of the integration suite requires running both — serially on a single docker host (the :5020 collision), or in parallel on two hosts.

Job Bring up Env to set Expected outcome
modbus-standard lmxopcua-fix up modbus standard unset MODBUS_SIM_PROFILE (or set to standard) Standard round-trip + AddressingGrammar suites pass; ExceptionInjectionTests (32 rows) skip with MODBUS_SIM_PROFILE != exception_injection.
modbus-exception lmxopcua-fix up modbus exception_injection MODBUS_SIM_PROFILE=exception_injection ExceptionInjectionTests (32 rows) pass against the per-(fc,address) rule set; standard-profile suites (round-trip, AddressingGrammar) skip.

The DL205 / Mitsubishi / S7-1500 profiles are similar — each gates its own quirks suite via MODBUS_SIM_PROFILE=<profile>. Tests that don't need a specific profile (the basic round-trip set) run under any of the three pymodbus-based profiles. The exception_injection profile is the only one that runs exception_injector.py instead of pymodbus.

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\Drivers\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