Files
lmxopcua/docs/drivers/Modbus-Test-Fixture.md
Joseph Doherty 6609141493 Dockerize Modbus + AB CIP + S7 test fixtures for reproducibility. Every driver integration simulator now has a pinned Docker image alongside the existing native launcher — Docker is the primary path, native fallbacks kept for contributors who prefer them. Matches the already-Dockerized OpcUaClient/opc-plc pattern from #215 so every fixture in the fleet presents the same compose-up/test/compose-down loop. Reproducibility gain: what used to require a local pip/Python install (Modbus pymodbus, S7 python-snap7) or a per-OS C build from source (AB CIP ab_server from libplctag) now collapses to a Dockerfile + docker compose up. Modbus — new tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/ with Dockerfile (python:3.12-slim-bookworm + pymodbus[simulator]==3.13.0) + docker-compose.yml with four compose profiles (standard / dl205 / mitsubishi / s7_1500) backed by the existing profile JSONs copied under Docker/profiles/ as canonical; native fallback in Pymodbus/ retained with the same JSON set (symlink-equivalent — manual re-sync when profiles change, noted in both READMEs). Port 5020 unchanged so MODBUS_SIM_ENDPOINT + ModbusSimulatorFixture work without code change. Dropped the --no_http CLI arg the old serve.ps1 + compose draft passed — pymodbus 3.13 doesn't recognize it; the simulator's http ui just binds inside the container where nothing maps it out and costs nothing. S7 — new tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/ with Dockerfile (python:3.12-slim-bookworm + python-snap7>=2.0) + docker-compose.yml with one s7_1500 compose profile; copies the existing server.py shim + s7_1500.json seed profile; runs python -u server.py ... --port 1102. Native fallback in PythonSnap7/ retained. Port 1102 unchanged. AB CIP — hardest because ab_server is a source-only C tool in libplctag's src/tools/ab_server/. New tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/ Dockerfile is multi-stage: build stage (debian:bookworm-slim + build-essential + cmake) clones libplctag at a pinned tag + cmake --build build --target ab_server; runtime stage (debian:bookworm-slim) copies just the binary from /src/build/bin_dist/ab_server. docker-compose.yml ships four compose profiles (controllogix / compactlogix / micro800 / guardlogix) with per-family ab_server CLI args matching AbServerProfile.cs. AbServerFixture updated: tries TCP probe on 127.0.0.1:44818 first (Docker path) + spawns the native binary only as fallback when no listener is there. AB_SERVER_ENDPOINT env var supported for pointing at a real PLC. AbServerFact/Theory attributes updated to IsServerAvailable() which accepts any of: live listener on 44818, AB_SERVER_ENDPOINT set, or binary on PATH. Required two CLI-compat fixes to ab_server's argument expectations that the existing native profile never caught because it was never actually run at CI: --plc is case-sensitive (ControlLogix not controllogix), CIP tags need [size] bracket notation (DINT[1] not bare DINT), ControlLogix also requires --path=1,0. Compose files carry the corrected flags; the existing native-path AbServerProfile.cs was never invoked in practice so we don't rewrite it here. Micro800 now uses the --plc=Micro800 mode rather than falling back to ControlLogix emulation — ab_server does have the dedicated mode, the old Notes saying otherwise were wrong. Updated docs: three fixture coverage docs (Modbus-Test-Fixture.md, S7-Test-Fixture.md, AbServer-Test-Fixture.md) flip their "What the fixture is" section from native-only to Docker-primary-with-native-fallback; dev-environment.md §Resource Inventory replaces the old ambiguous "Docker Desktop + ab_server native" mix with four per-driver rows (each listing the image, compose file, compose profiles, port, credentials) + a new Docker fixtures — quick reference subsection giving the one-line docker compose -f <…> --profile <…> up for each driver + the env-var override names + the native fallback install recipes. drivers/README.md coverage map table updated — Modbus/AB CIP/S7 entries now read "Dockerized …" consistent with OpcUaClient's line. Verified end-to-end against live containers: Modbus DL205 smoke 1/1, S7 3/3, AB CIP ControlLogix 4/4 (all family theory rows). Container lifecycle clean (up/test/down, no leaked state). Every fixture keeps its skip-when-absent probe + env-var endpoint override so dotnet test on a fresh clone without Docker running still gets a green run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:09:44 -04:00

5.1 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) — primary launcher is a pinned Docker image at tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/. Native-Python fallback under Pymodbus/ is kept for contributors who don't want Docker.
  • 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 are canonical under Docker/profiles/; Pymodbus/ carries a copy for the native fallback.
  • 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 → OPC UA StatusCode mapping
  • 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 responses — a JSON flag per register saying "next read returns exception 0x04."
  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/Pymodbus/ — simulator driver script + per-family JSON profiles