Docs + code-comment sweep — remove stale Pymodbus/ + PythonSnap7/ + LocateBinary references left behind by the native-fallback removal PR. Answer to "is the dev inventory + documentation updated": it was partial; this PR finishes the job.

Files touched — docs/drivers/Modbus-Test-Fixture.md dropped the key-files pointer at deleted Pymodbus/ + flipped "primary launcher is Docker, native fallback retained" framing to "Docker is the only supported launch path" (matching the code). docs/v2/dev-environment.md dropped the "skips both Docker + native-binary paths" parenthetical from AB_SERVER_ENDPOINT + flipped the "Native fallbacks" subsection to a one-liner that says Docker is the only supported path. docs/v2/modbus-test-plan.md rewrote §Harness from "pip install pymodbus + serve.ps1" setup pattern to "docker compose --profile <…> up" + updated the §PR 43 status bullet to point at Docker/profiles/. docs/v2/test-data-sources.md §"CI fixture (task #180)" rewrote the AB CIP section from "LocateBinary() picks binary off PATH" + GitHub Actions zip-download step to "Docker is the only supported reproducible build path" + docker compose GitHub Actions step; dropped the pinned-version SHA256 table + lock-file reference because the Dockerfile's LIBPLCTAG_TAG build-arg is the new pin.

Code docstrings + error messages — these are developer-facing operational text too. ModbusSimulatorFixture SkipReason strings (both branches) now point at `docker compose -f Docker/docker-compose.yml --profile standard up -d` instead of the deleted `Pymodbus\serve.ps1`; doc-comment at the top references Docker/docker-compose.yml. Snap7ServerFixture SkipReason strings + doc-comment point at Docker/docker-compose.yml instead of PythonSnap7/serve.ps1. S7_1500Profile.cs docstring updated. Modbus Dockerfile comment pointing at deleted tests/.../Pymodbus/README.md redirected to docs/drivers/Modbus-Test-Fixture.md. DL205Profile.cs + DL205StringQuirkTests.cs + S7_1500Profile.cs (in Modbus project) docstrings flipped from Pymodbus/*.json references to Docker/profiles/*.json.

Left untouched deliberately: docs/v2/implementation/exit-gate-phase-2-closed.md — that's a historical as-of-2026-04-18 snapshot documenting what was skipped at Phase 2 closure; rewriting would lose the date-stamped context. Its "oitc/modbus-server Docker container not started" + "ab_server binary not on PATH" lines describe the fixture landscape that existed at close time, not current operational guidance.

Final sweep confirms zero remaining `Pymodbus/` / `PythonSnap7/` / `LocateBinary` / `AbServerSeedTag` / `BuildCliArgs` / `AbServerPlcArg` mentions anywhere in tracked files outside that historical exit-gate doc. Whole-solution build still 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-20 12:36:19 -04:00
parent 4473197cf5
commit fdb268cee0
11 changed files with 49 additions and 56 deletions

View File

@@ -113,5 +113,5 @@ Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
- `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
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/`
Dockerfile + compose + per-family JSON profiles

View File

@@ -174,7 +174,7 @@ libplctag + compiles C). Stop with `docker compose -f <compose> --profile <…>
real PLC instead of the simulator:
- `MODBUS_SIM_ENDPOINT` (default `localhost:5020`)
- `AB_SERVER_ENDPOINT` (no default; when set, skips both Docker + native-binary paths)
- `AB_SERVER_ENDPOINT` (no default; overrides the local container endpoint)
- `S7_SIM_ENDPOINT` (default `localhost:1102`)
- `OPCUA_SIM_ENDPOINT` (default `opc.tcp://localhost:50000`)

View File

@@ -13,9 +13,9 @@ confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
## Harness
**Chosen simulator: pymodbus 3.13.0** (`pip install 'pymodbus[simulator]==3.13.0'`).
Replaced ModbusPal in PR 43 — see `tests/.../Pymodbus/README.md` for the
trade-off rationale. Headline reasons:
**Chosen simulator: pymodbus 3.13.0** packaged as a pinned Docker image
under `tests/.../Modbus.IntegrationTests/Docker/`. See that folder's
`README.md` for image-build notes + compose profiles. Headline reasons:
- **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner.
- **Maintained** — current stable 3.13.0; ModbusPal 1.6b is abandoned.
@@ -26,17 +26,18 @@ trade-off rationale. Headline reasons:
- **Per-register raw uint16 seeding** — encoding the DL205 string-byte-order
/ BCD / CDAB-float quirks stays explicit (the quirk math lives in the
`_quirk` JSON-comment fields next to each register).
- Pip-installable on Windows; sidesteps the privileged-port admin
requirement by defaulting to TCP **5020** instead of 502.
- **Dockerized** — pinned image means the CI simulator surface is
reproducible + no `pip install` step on the dev box.
- Defaults to TCP **5020** (matches the compose port-map + the fixture
default endpoint; sidesteps the Windows Firewall prompt on 502).
**Setup pattern**:
1. `pip install "pymodbus[simulator]==3.13.0"`.
2. Start the simulator with one of the in-repo profiles:
`tests\.../Pymodbus\serve.ps1 -Profile standard` (or `-Profile dl205`).
3. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests`
1. `docker compose -f tests\...\Modbus.IntegrationTests\Docker\docker-compose.yml --profile <standard|dl205|mitsubishi|s7_1500> up -d`.
2. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests`
tests auto-skip when the endpoint is unreachable. Default endpoint is
`localhost:5020`; override via `MODBUS_SIM_ENDPOINT` for a real PLC on its
native port 502.
3. `docker compose -f ... --profile <…> down` when finished.
## Per-device quirk catalog
@@ -113,9 +114,11 @@ vendors get promoted into driver defaults or opt-in options:
- **PR 42 — ModbusPal `.xmpp` profiles** — **SUPERSEDED by PR 43**. Replaced
with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only
exposes 2 of the 4 standard tables.
- **PR 43 — pymodbus JSON profiles** — **DONE**. `Pymodbus/standard.json` +
`Pymodbus/dl205.json` + `Pymodbus/serve.ps1` runner. Both bind TCP 5020.
- **PR 43 — pymodbus JSON profiles** — **DONE**. Dockerized under
`Docker/profiles/` (standard.json, dl205.json, mitsubishi.json,
s7_1500.json); compose file launches each via a named profile.
All bind TCP 5020.
- **PR 44+**: one PR per confirmed DL205 quirk, landing the named test + any
driver-side adjustment (string byte order, BCD decoder, V-memory address
helper, FC16 cap-per-device-family) needed to pass it. Each quirk's value
is already pre-encoded in `Pymodbus/dl205.json`.
is already pre-encoded in `Docker/profiles/dl205.json`.

View File

@@ -191,40 +191,30 @@ Modbus has no native String, DateTime, or Int64 — those rows are skipped on th
### CI fixture (task #180)
The integration harness at `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/` exposes two test-time contracts:
The integration harness at `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/` is Docker-only — `ab_server` is a source-only tool under libplctag's `src/tools/ab_server/`, and the fixture's multi-stage `Docker/Dockerfile` is the only supported reproducible build path.
- **`AbServerFixture(AbServerProfile)`** — starts the simulator with the CLI args composed from the profile's `--plc` family + seed-tag set. One fixture instance per family, one simulator process per test case (smoke tier). For larger suites that can share a simulator across several reads/writes, use a `IClassFixture<AbServerFixture>` wrapper per family.
- **`KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`** — the four per-family profiles. Drives the simulator's `--plc` mode + the preseed `--tag name:type[:size]` set. Micro800 + GuardLogix fall back to `controllogix` under the hood because ab_server has no dedicated mode for them the driver-side family profile still enforces the narrower connection shape / safety classification separately.
- **`AbServerFixture(AbServerProfile)`** — thin TCP probe against `127.0.0.1:44818` (or `AB_SERVER_ENDPOINT` override). Does not spawn the simulator; the operator brings up the compose service for whichever family the test class targets (`controllogix` / `compactlogix` / `micro800` / `guardlogix`).
- **`KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`** — thin `(Family, ComposeProfile, Notes)` records. The compose file (`Docker/docker-compose.yml`) is the canonical source of truth for which tags each family seeds + which `--plc` mode the simulator boots in. `Micro800` uses the dedicated `--plc=Micro800` mode; `GuardLogix` uses `ControlLogix` emulation because ab_server has no safety subsystem (the `_S`-suffixed seed tag triggers driver-side ViewOnly classification only).
**Pinned version** (recorded in `ci/ab-server.lock.json` so drift is one-file visible):
- `libplctag` **v2.6.16** (published 2026-03-29) — `ab_server.exe` ships inside the `_tools.zip` asset alongside `plctag.dll` + two `list_tags_*` helpers.
- Windows x64: `libplctag_2.6.16_windows_x64_tools.zip` — SHA256 `9b78a3dee73d9cd28ca348c090f453dbe3ad9d07ad6bf42865a9dc3a79bc2232`
- Windows x86: `libplctag_2.6.16_windows_x86_tools.zip` — SHA256 `fdfefd58b266c5da9a1ded1a430985e609289c9e67be2544da7513b668761edf`
- Windows ARM64: `libplctag_2.6.16_windows_arm64_tools.zip` — SHA256 `d747728e4c4958bb63b4ac23e1c820c4452e4778dfd7d58f8a0aecd5402d4944`
**Pinned version**: the `Docker/Dockerfile` clones libplctag at a pinned tag (currently the `release` branch) via its `LIBPLCTAG_TAG` build-arg and compiles `ab_server` from source. Bump deliberately alongside a driver-side change that needs the newer simulator.
**CI step:**
```yaml
# GitHub Actions step placed before `dotnet test`:
- name: Fetch ab_server (libplctag v2.6.16)
- name: Start ab_server Docker container
shell: pwsh
run: |
$pin = Get-Content ci/ab-server.lock.json | ConvertFrom-Json
$asset = $pin.assets.'windows-x64' # swap to windows-x86 / windows-arm64 on non-x64 runners
$url = "https://github.com/libplctag/libplctag/releases/download/$($pin.tag)/$($asset.file)"
$zip = Join-Path $env:RUNNER_TEMP 'libplctag-tools.zip'
Invoke-WebRequest $url -OutFile $zip
$actual = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
if ($actual -ne $asset.sha256) { throw "libplctag tools SHA256 mismatch: expected $($asset.sha256), got $actual" }
$dest = Join-Path $env:RUNNER_TEMP 'libplctag-tools'
Expand-Archive $zip -DestinationPath $dest
Add-Content $env:GITHUB_PATH $dest
docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml `
--profile controllogix up -d --build
# Wait for :44818 to accept connections (compose healthcheck-equivalent)
for ($i = 0; $i -lt 30; $i++) {
if ((Test-NetConnection -ComputerName localhost -Port 44818 -WarningAction SilentlyContinue).TcpTestSucceeded) { break }
Start-Sleep -Seconds 1
}
```
The fixture's `LocateBinary()` picks the binary up off PATH so the C# harness doesn't own the download — CI YAML is the right place for version pinning + hash verification. Developer workstations install the binary once from source (`cmake + make ab_server` under a libplctag clone) and the same fixture works identically.
Tests without ab_server on PATH are marked `Skip` via `AbServerFactAttribute` / `AbServerTheoryAttribute`, so fresh-clone runs without the simulator still pass all unit suites in this project.
Tests skip via `AbServerFactAttribute` / `AbServerTheoryAttribute` when the probe fails, so fresh-clone runs without Docker still pass all unit suites in this project.
---

View File

@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the pymodbus
/// <c>dl205.json</c> profile in <c>Pymodbus/dl205.json</c> exposes (or the real PLC, when
/// <c>dl205.json</c> profile in <c>Docker/profiles/dl205.json</c> exposes (or the real PLC, when
/// <see cref="ModbusSimulatorFixture"/> is pointed at one).
/// </summary>
/// <remarks>
@@ -16,8 +16,8 @@ public static class DL205Profile
{
/// <summary>
/// Holding register the smoke test writes + reads. Address 200 is the first cell of the
/// scratch HR range in both <c>Pymodbus/standard.json</c> (HR[200..209] = 0) and
/// <c>Pymodbus/dl205.json</c> (HR[4096..4103] added in PR 43 for the same purpose), so
/// scratch HR range in both <c>Docker/profiles/standard.json</c> (HR[200..209] = 0) and
/// <c>Docker/profiles/dl205.json</c> (HR[4096..4103] added in PR 43 for the same purpose), so
/// the smoke test runs identically against either simulator profile. Originally
/// targeted HR[100] — moved to HR[200] when the standard profile claimed HR[100] as
/// the auto-incrementing register that drives subscribe-and-receive tests.

View File

@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// </summary>
/// <remarks>
/// <para>
/// Requires the dl205 profile (<c>Pymodbus\serve.ps1 -Profile dl205</c>). The standard
/// Requires the dl205 profile (<c>docker compose -f Docker/docker-compose.yml --profile dl205 up</c>). The standard
/// profile does not seed HR[1040..1042] with string bytes, so running this against the
/// standard profile returns <c>"\0\0\0\0\0"</c> and the test fails. Skip when the env
/// var <c>MODBUS_SIM_PROFILE</c> is not set to <c>dl205</c>.

View File

@@ -1,7 +1,7 @@
# pymodbus simulator container for the Modbus integration suite.
#
# Pinned base + package version so the fixture surface is reproducible —
# matches the version referenced in tests/.../Pymodbus/README.md.
# matches the version referenced in docs/drivers/Modbus-Test-Fixture.md.
FROM python:3.12-slim-bookworm
LABEL org.opencontainers.image.source="https://github.com/dohertj2/lmxopcua" \

View File

@@ -3,8 +3,8 @@ using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// Reachability probe for a Modbus TCP simulator (pymodbus-driven, see
/// <c>Pymodbus/serve.ps1</c>) or a real PLC. Parses
/// Reachability probe for a Modbus TCP simulator (pymodbus in Docker, see
/// <c>Docker/docker-compose.yml</c>) or a real PLC. Parses
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:5020</c> per PR 43) and TCP-connects once at
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running
@@ -28,7 +28,7 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable
{
// PR 43: default port is 5020 (pymodbus convention) instead of 502 (Modbus standard).
// Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the
// port baked into the pymodbus simulator JSON profiles in Pymodbus/. Override with
// port baked into the pymodbus simulator JSON profiles in Docker/profiles/. Override with
// MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502.
private const string DefaultEndpoint = "localhost:5020";
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
@@ -61,15 +61,15 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " +
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile standard up -d) " +
$"or override {EndpointEnvVar}, then re-run.";
}
}
catch (Exception ex)
{
SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " +
$"or override {EndpointEnvVar}, then re-run.";
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile standard up -d) " +
$"or override {EndpointEnvVar}, then re-run.";
}
}

View File

@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
/// <summary>
/// Tag map for the Siemens SIMATIC S7-1500 device class with the <c>MB_SERVER</c> library
/// block mapping HR[0..] to DB1.DBW0+. Mirrors <c>s7_1500.json</c> in <c>Pymodbus/</c>.
/// block mapping HR[0..] to DB1.DBW0+. Mirrors <c>s7_1500.json</c> in <c>Docker/profiles/</c>.
/// </summary>
/// <remarks>
/// Unlike DL205, S7 has no fixed Modbus memory map — every site wires MB_SERVER to a

View File

@@ -3,7 +3,7 @@ using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Driver-side configuration matching what <c>PythonSnap7/s7_1500.json</c> seeds
/// Driver-side configuration matching what <c>Docker/profiles/s7_1500.json</c> seeds
/// into the simulator's DB1 + MB areas. Tag names here become the full references
/// the smoke tests read/write against; addresses map 1:1 to the JSON profile's
/// seed offsets so a seed drift in the JSON surfaces as a driver-side read

View File

@@ -3,8 +3,8 @@ using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
/// <summary>
/// Reachability probe for a python-snap7 simulator (see
/// <c>PythonSnap7/serve.ps1</c>) or a real S7 PLC. Parses <c>S7_SIM_ENDPOINT</c>
/// Reachability probe for the python-snap7 simulator Docker container (see
/// <c>Docker/docker-compose.yml</c>) or a real S7 PLC. Parses <c>S7_SIM_ENDPOINT</c>
/// (default <c>localhost:1102</c>) + TCP-connects once at fixture construction.
/// Tests check <see cref="SkipReason"/> + call <c>Assert.Skip</c> when unreachable, so
/// `dotnet test` stays green on a fresh box without the simulator installed —
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
/// </remarks>
public sealed class Snap7ServerFixture : IAsyncDisposable
{
// Default 1102 (non-privileged) matches PythonSnap7/server.py. Override with
// Default 1102 (non-privileged) matches Docker/server.py. Override with
// S7_SIM_ENDPOINT to point at a real PLC on its native 102.
private const string DefaultEndpoint = "localhost:1102";
private const string EndpointEnvVar = "S7_SIM_ENDPOINT";
@@ -63,13 +63,13 @@ public sealed class Snap7ServerFixture : IAsyncDisposable
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"python-snap7 simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start it (PythonSnap7\\serve.ps1 -Profile s7_1500) or override {EndpointEnvVar}.";
$"Start it (docker compose -f Docker/docker-compose.yml --profile s7_1500 up -d) or override {EndpointEnvVar}.";
}
}
catch (Exception ex)
{
SkipReason = $"python-snap7 simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start it (PythonSnap7\\serve.ps1 -Profile s7_1500) or override {EndpointEnvVar}.";
$"Start it (docker compose -f Docker/docker-compose.yml --profile s7_1500 up -d) or override {EndpointEnvVar}.";
}
}