From 95c7e0b49038840b5b1848b2daf6bc786d2bcb96 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 21 Apr 2026 12:38:43 -0400 Subject: [PATCH] =?UTF-8?q?Task=20#222=20partial=20=E2=80=94=20unblock=20A?= =?UTF-8?q?B=20Legacy=20PCCC=20via=20cip-path=20workaround=20(5/5=20stages?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced the "ab_server PCCC upstream-broken" skip gate with the actual root cause: libplctag's ab_server rejects empty CIP routing paths at the unconnected-send layer before the PCCC dispatcher runs. Real SLC/ MicroLogix/PLC-5 hardware accepts empty paths (no backplane); ab_server does not. With `/1,0` in place, N (Int16), F (Float32), and L (Int32) file reads + writes round-trip cleanly across all three compose profiles. ## Fixture changes - `AbLegacyServerFixture.cs`: - Drop `AB_LEGACY_TRUST_WIRE` env var + the reachable-but-untrusted skip branch. Fixture now only skips on TCP unreachability. - Add `AB_LEGACY_CIP_PATH` env var (default `1,0`) + expose `CipPath` property. Set `AB_LEGACY_CIP_PATH=` (empty) against real hardware. - Shorter skip messages on the `[AbLegacyFact]` / `[AbLegacyTheory]` attributes — one reason: endpoint not reachable. - `AbLegacyReadSmokeTests.cs`: - Device URI built from `sim.CipPath` instead of hardcoded empty path. - New `AB_LEGACY_COMPOSE_PROFILE` env var filters the parametric theory to the running container's family. Only one container binds `:44818` at a time, so cross-family params would otherwise fail. - `Slc500_write_then_read_round_trip` skips cleanly when the running profile isn't `slc500`. ## E2E + seed + docs - `scripts/e2e/test-ablegacy.ps1` — drop the `AB_LEGACY_TRUST_WIRE` skip gate; synopsis calls out the `/1,0` vs empty cip-path split between the Docker fixture and real hardware. - `scripts/e2e/e2e-config.sample.json` — sample gateway flipped from the hardware placeholder (`192.168.1.10`) to the Docker fixture (`127.0.0.1/1,0`); comment rewritten. - `scripts/e2e/README.md` — AB Legacy expected-matrix row goes from SKIP to PASS. - `scripts/smoke/seed-ablegacy-smoke.sql` — default HostAddress points at the Docker fixture + header / footer text reflect the new state. - `tests/.../Docker/README.md` — "Known limitations" section rewritten to describe the cip-path gate (not a dispatcher gap); env-var table picks up `AB_LEGACY_CIP_PATH` + `AB_LEGACY_COMPOSE_PROFILE`. - `docs/drivers/AbLegacy-Test-Fixture.md` + `docs/drivers/README.md` + `docs/DriverClis.md` — flip status from blocked to functional; residual bit-file-write gap (B3:0/5 → 0x803D0000) documented. ## Residual gap Bit-file writes (`B3:0/5` style) surface `0x803D0000` against `ab_server --plc=SLC500`; bit reads work. Non-blocking for smoke coverage — N/F/L round-trip is enough. Real hardware / RSEmulate 500 for bit-write fidelity. Documented in `Docker/README.md` §"Known limitations" + the `AbLegacy-Test-Fixture.md` follow-ups list. ## Verified - Full-solution build: 0 errors, 334 pre-existing warnings. - Integration suite passes per-profile with `AB_LEGACY_COMPOSE_PROFILE=` + matching compose container up. - All four non-hardware e2e scripts (Modbus / AB CIP / AB Legacy / S7) now 5/5 against the respective docker-compose fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/DriverClis.md | 12 ++-- docs/drivers/AbLegacy-Test-Fixture.md | 15 ++-- docs/drivers/README.md | 2 +- scripts/e2e/README.md | 2 +- scripts/e2e/e2e-config.sample.json | 4 +- scripts/e2e/test-ablegacy.ps1 | 28 ++++---- scripts/smoke/seed-ablegacy-smoke.sql | 27 +++---- .../AbLegacyReadSmokeTests.cs | 44 +++++++++--- .../AbLegacyServerFixture.cs | 71 +++++++++--------- .../Docker/README.md | 72 +++++++++---------- 10 files changed, 151 insertions(+), 126 deletions(-) diff --git a/docs/DriverClis.md b/docs/DriverClis.md index 75db987..8fe8b2b 100644 --- a/docs/DriverClis.md +++ b/docs/DriverClis.md @@ -68,11 +68,13 @@ values to the already-shipped driver. ## Known gaps -- **AB Legacy PCCC wire-level** against the ab_server Docker simulator is - upstream-broken — see the "Known limitations" section in - [Driver.AbLegacy.Cli.md](Driver.AbLegacy.Cli.md). Pointing the CLI at - real SLC / MicroLogix / PLC-5 hardware or a RSEmulate 500 golden-box - works as expected. +- **AB Legacy cip-path quirk** — libplctag's ab_server requires a + non-empty CIP routing path before forwarding to the PCCC dispatcher. + Pass `--gateway "ab://127.0.0.1:44818/1,0"` against the Docker + fixture; real SLC / MicroLogix / PLC-5 hardware accepts an empty + path (`ab://host:44818/`). Bit-file writes (`B3:0/5`) still surface + `0x803D0000` against ab_server — route operator-critical bit writes + to real hardware until upstream fixes this. - **S7 PUT/GET communication** must be enabled in TIA Portal for any S7-1200/1500. See [Driver.S7.Cli.md](Driver.S7.Cli.md). - **TwinCAT AMS router** must be reachable (local XAR, standalone Router diff --git a/docs/drivers/AbLegacy-Test-Fixture.md b/docs/drivers/AbLegacy-Test-Fixture.md index 4dc2ac2..78b280e 100644 --- a/docs/drivers/AbLegacy-Test-Fixture.md +++ b/docs/drivers/AbLegacy-Test-Fixture.md @@ -93,11 +93,13 @@ cover the common ones but uncommon ones (`R` counters, `S` status files, ## Follow-up candidates -1. **Fix ab_server PCCC coverage upstream** — the scaffold lands the - Docker infrastructure; the wire-level round-trip gap is in ab_server - itself. Filing a patch to `libplctag/libplctag` to expand PCCC - server-side opcode coverage would make the scaffolded smoke tests - pass without a golden-box tier. +1. **Expand ab_server PCCC coverage** — the smoke suite passes today + for N (Int16), F (Float32), and L (Int32) files across SLC500 / + MicroLogix / PLC-5 modes with the `/1,0` cip-path workaround in + place. Known residual gap: bit-file writes (`B3:0/5`) surface + `0x803D0000`. Contributing a patch to `libplctag/libplctag` to close + this + documenting ab_server's empty-path rejection in its README + would remove the last Docker-vs-hardware divergences. 2. **Rockwell RSEmulate 500 golden-box tier** — Rockwell's real emulator for SLC/MicroLogix/PLC-5. Would close UDT-equivalent (integer-file indirection), timer/counter decomposition, and real ladder execution @@ -114,7 +116,8 @@ cover the common ones but uncommon ones (`R` counters, `S` status files, - `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs` — TCP probe + skip attributes + env-var parsing - `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs` - — three wire-level smoke tests (currently blocked by ab_server PCCC gap) + — wire-level smoke tests; pass against the ab_server Docker fixture + with `AB_LEGACY_COMPOSE_PROFILE` set to the running container - `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml` — compose profiles reusing AB CIP Dockerfile - `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md` diff --git a/docs/drivers/README.md b/docs/drivers/README.md index 7e3c358..6b6f21c 100644 --- a/docs/drivers/README.md +++ b/docs/drivers/README.md @@ -44,7 +44,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni - [AB CIP](AbServer-Test-Fixture.md) — Dockerized `ab_server` (multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only - [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped - [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102` -- [AB Legacy](AbLegacy-Test-Fixture.md) — Docker scaffold via `ab_server` PCCC mode (task #224); wire-level round-trip currently blocked by ab_server's PCCC coverage gap, docs call out RSEmulate 500 + lab-rig resolution paths +- [AB Legacy](AbLegacy-Test-Fixture.md) — Dockerized `ab_server` PCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end. `/1,0` cip-path required for the Docker fixture; real hardware uses empty. Residual gap: bit-file writes (`B3:0/5`) still surface BadState — real HW / RSEmulate 500 for those - [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness - [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped - [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index dde4e69..028aa7f 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -132,7 +132,7 @@ section to skip it. |---|---|---| | Modbus | — | **PASS** (pymodbus fixture) | | AB CIP | — | **PASS** (ab_server fixture) | -| AB Legacy | `AB_LEGACY_TRUST_WIRE=1` | **SKIP** (ab_server PCCC path upstream-broken — task #222) | +| AB Legacy | — | **PASS** (ab_server SLC500/MicroLogix/PLC-5 profiles; `/1,0` cip-path required for the Docker fixture) | | S7 | — | **PASS** (python-snap7 fixture) | | FOCAS | `FOCAS_TRUST_WIRE=1` | **SKIP** (no public simulator — task #222 lab rig) | | TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** (needs XAR or standalone Router — task #221) | diff --git a/scripts/e2e/e2e-config.sample.json b/scripts/e2e/e2e-config.sample.json index 55ce8b2..f40362c 100644 --- a/scripts/e2e/e2e-config.sample.json +++ b/scripts/e2e/e2e-config.sample.json @@ -17,8 +17,8 @@ }, "ablegacy": { - "$comment": "Gated behind AB_LEGACY_TRUST_WIRE=1 — ab_server PCCC path upstream-broken, needs real SLC / MicroLogix / PLC-5 or RSEmulate 500.", - "gateway": "ab://192.168.1.10/1,0", + "$comment": "Works against ab_server --profile slc500 (Docker fixture) or real SLC/MicroLogix/PLC-5 hardware. `/1,0` cip-path is required for the Docker fixture; real hardware accepts an empty path — e.g. `ab://10.0.1.50:44818/`.", + "gateway": "ab://127.0.0.1/1,0", "plcType": "Slc500", "address": "N7:5", "bridgeNodeId": "ns=2;s=AbLegacy/N7_5" diff --git a/scripts/e2e/test-ablegacy.ps1 b/scripts/e2e/test-ablegacy.ps1 index 2ede8b6..a813e7b 100644 --- a/scripts/e2e/test-ablegacy.ps1 +++ b/scripts/e2e/test-ablegacy.ps1 @@ -4,16 +4,19 @@ End-to-end CLI test for the AB Legacy (PCCC) driver. .DESCRIPTION - **KNOWN-BROKEN upstream (ab_server PCCC dispatcher gap, verified 2026-04-21).** - Works against real SLC / MicroLogix / PLC-5 hardware or a RSEmulate 500 - golden-box. Against the Docker ab_server the tests deliberately skip — - same gate as tests/.../AbLegacy.IntegrationTests (AB_LEGACY_TRUST_WIRE=1). + Runs against libplctag's ab_server PCCC Docker fixture (one of the + slc500 / micrologix / plc5 compose profiles) or real SLC / MicroLogix / + PLC-5 hardware. Five assertions: probe / driver-loopback / forward- + bridge / reverse-bridge / subscribe-sees-change. - Five assertions: probe / driver-loopback / forward-bridge / reverse-bridge / - subscribe-sees-change. + ab_server enforces a non-empty CIP routing path (`/1,0`) before the + PCCC dispatcher runs; real hardware accepts an empty path. The default + $Gateway uses `/1,0` for the Docker fixture — pass `-Gateway + "ab://host:44818/"` when pointing at a real SLC 5/05 / MicroLogix / + PLC-5. .PARAMETER Gateway - ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0. + ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0 (Docker fixture). .PARAMETER PlcType Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500). @@ -39,13 +42,10 @@ param( $ErrorActionPreference = "Stop" . "$PSScriptRoot/_common.ps1" -# Skip-gate: the driver CLI's underlying AbLegacyServerFixture-equivalent -# check — operators point at real hardware by setting AB_LEGACY_TRUST_WIRE=1. -# Without the opt-in we skip (don't run against the known-broken ab_server). -if (-not ($env:AB_LEGACY_TRUST_WIRE -eq "1" -or $env:AB_LEGACY_TRUST_WIRE -eq "true")) { - Write-Skip "AB_LEGACY_TRUST_WIRE not set — skipping (ab_server PCCC is upstream-broken; set =1 against real hardware / RSEmulate)." - exit 0 -} +# ab_server PCCC works; the earlier "upstream-broken" gate is gone. The only +# caveat: libplctag's ab_server rejects empty CIP paths, so $Gateway must +# carry a non-empty path segment (default /1,0). Real SLC/PLC-5 hardware +# accepts an empty path — use `ab://host:44818/` when pointing at real PLCs. $abLegacyCli = Get-CliInvocation ` -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli" ` diff --git a/scripts/smoke/seed-ablegacy-smoke.sql b/scripts/smoke/seed-ablegacy-smoke.sql index 2ce007f..2e186e6 100644 --- a/scripts/smoke/seed-ablegacy-smoke.sql +++ b/scripts/smoke/seed-ablegacy-smoke.sql @@ -1,15 +1,17 @@ -- AB Legacy e2e smoke seed — closes #213 (umbrella #209). -- --- Hardware-gated. The ab_server PCCC dispatcher is upstream-broken (task #222 --- tracks the lab rig + alternative fixtures), so verifying this seed end-to-end --- requires real SLC 500 / MicroLogix / PLC-5 hardware or an RSEmulate 500 --- golden-box. The factory + seed ship here so the wiring is in place the --- moment real hardware becomes available — no server code changes needed. +-- Works against the ab_server PCCC Docker fixture (one of the slc500 / +-- micrologix / plc5 compose profiles) or real SLC 500 / MicroLogix / PLC-5 +-- hardware. Default HostAddress below points at the Docker fixture with a +-- `/1,0` cip-path; libplctag's ab_server rejects empty paths before routing +-- to the PCCC dispatcher. Real hardware uses an empty path — change the +-- HostAddress to `ab://:44818/` (note the trailing slash with nothing +-- after) before running the seed for that setup. -- --- Usage (once hardware is reachable): +-- Usage: +-- docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml --profile slc500 up -d -- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \ -- -i scripts/smoke/seed-ablegacy-smoke.sql --- (Update the `HostAddress` below to point at the real gateway first.) SET NOCOUNT ON; SET XACT_ABORT ON; @@ -83,7 +85,7 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{ "TimeoutMs": 2000, "Devices": [ { - "HostAddress": "ab://192.168.1.10/1,0", + "HostAddress": "ab://127.0.0.1:44818/1,0", "PlcFamily": "Slc500", "DeviceName": "slc-500" } @@ -92,7 +94,7 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{ "Tags": [ { "Name": "N7_5", - "DeviceHostAddress": "ab://192.168.1.10/1,0", + "DeviceHostAddress": "ab://127.0.0.1:44818/1,0", "Address": "N7:5", "DataType": "Int", "Writable": true, @@ -117,6 +119,7 @@ PRINT ' Cluster: ' + @ClusterId; PRINT ' Node: ' + @NodeId; PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen); PRINT ''; -PRINT 'NOTE: hardware-gated. ab_server PCCC is upstream-broken (#222). Point the'; -PRINT ' DriverConfig HostAddress at real SLC / MicroLogix / PLC-5 / RSEmulate'; -PRINT ' and run the e2e script with AB_LEGACY_TRUST_WIRE=1.'; +PRINT 'NOTE: default points at the ab_server slc500 Docker fixture with a /1,0'; +PRINT ' cip-path (required by ab_server). For real SLC/MicroLogix/PLC-5'; +PRINT ' hardware, edit the DriverConfig HostAddress to end with /'; +PRINT ' e.g. "ab://:44818/" and re-run this seed.'; diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs index 7412902..30fb380 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs @@ -17,8 +17,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; [Trait("Simulator", "ab_server-PCCC")] public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) { - public static IEnumerable Profiles => - KnownProfiles.All.Select(p => new object[] { p }); + // Only one ab_server container binds :44818 at a time and `--plc=SLC500` only + // answers SLC-mode PCCC, etc. When `AB_LEGACY_COMPOSE_PROFILE` is set, the theory + // filters to that profile alone so the suite matches the running container. Unset + // (the default for real-hardware runs) parameterises across every family the driver + // supports. + public static IEnumerable Profiles + { + get + { + var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); + var profiles = KnownProfiles.All.Where(p => + string.IsNullOrEmpty(only) || + string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase)); + return profiles.Select(p => new object[] { p }); + } + } [AbLegacyTheory] [MemberData(nameof(Profiles))] @@ -26,9 +40,11 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); - // PCCC PLCs use empty cip-path, but AbLegacyHostAddress still requires the - // /cip-path suffix to parse. - var deviceUri = $"ab://{sim.Host}:{sim.Port}/"; + // PCCC semantics allow an empty cip-path (real SLC/PLC-5 hardware takes nothing + // after the `/`), but libplctag's ab_server requires a non-empty path at the + // CIP unconnected-send layer before the PCCC dispatcher runs. Default `1,0` + // against the Docker fixture; set AB_LEGACY_CIP_PATH= (empty) against real HW. + var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}"; await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)], @@ -58,9 +74,21 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); - // PCCC PLCs use empty cip-path, but AbLegacyHostAddress still requires the - // /cip-path suffix to parse. - var deviceUri = $"ab://{sim.Host}:{sim.Port}/"; + // Skip when the running compose profile isn't SLC500 — ab_server's `--plc=` + // flag selects exactly one family per process, so a write against a plc5-mode + // container with SLC500 semantics always fails at the wire. + var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); + if (!string.IsNullOrEmpty(only) && + !string.Equals(only, "slc500", StringComparison.OrdinalIgnoreCase)) + { + Assert.Skip($"Test targets the SLC500 compose profile; AB_LEGACY_COMPOSE_PROFILE='{only}'."); + } + + // PCCC semantics allow an empty cip-path (real SLC/PLC-5 hardware takes nothing + // after the `/`), but libplctag's ab_server requires a non-empty path at the + // CIP unconnected-send layer before the PCCC dispatcher runs. Default `1,0` + // against the Docker fixture; set AB_LEGACY_CIP_PATH= (empty) against real HW. + var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}"; await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)], diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs index 9cdac7c..3e8bd09 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs @@ -20,6 +20,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// AB_LEGACY_ENDPOINThost:port of the PCCC-mode simulator. /// Defaults to localhost:44818 (EtherNet/IP port; ab_server's PCCC /// emulation exposes PCCC-over-CIP on the same port as CIP itself). +/// AB_LEGACY_CIP_PATH — routing path appended to the ab://host:port/ +/// URI. Defaults to 1,0 (port-1/slot-0 backplane), required by ab_server +/// which rejects unconnected Send_RR_Data with an empty path at the CIP layer +/// before the PCCC dispatcher runs. Real SLC 5/05 / MicroLogix / PLC-5 hardware +/// use an empty path — set AB_LEGACY_CIP_PATH= (empty) when pointing at +/// real hardware. /// /// Distinct from AB_SERVER_ENDPOINT used by the AB CIP fixture so both /// can point at different containers simultaneously during a combined test run. @@ -27,24 +33,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; public sealed class AbLegacyServerFixture : IAsyncLifetime { private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT"; - - /// - /// Opt-in flag that promises the endpoint can actually round-trip PCCC reads/writes - /// (real SLC 5/05 / MicroLogix 1100/1400 / PLC-5 hardware, or a RSEmulate 500 - /// golden-box per docs/v2/lmx-followups.md). Without this, the fixture assumes - /// the endpoint is libplctag's ab_server --plc=SLC500 Docker container — whose - /// PCCC dispatcher is a known upstream gap — and skips cleanly rather than failing - /// every test with BadCommunicationError. - /// - private const string TrustWireEnvVar = "AB_LEGACY_TRUST_WIRE"; + private const string CipPathEnvVar = "AB_LEGACY_CIP_PATH"; /// Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as /// native CIP; the differentiator is the --plc flag ab_server was started /// with, not a different TCP listener. public const int DefaultPort = 44818; + /// + /// ab_server rejects unconnected Send_RR_Data with an empty CIP routing path + /// at the CIP layer — the PCCC dispatcher never runs. 1,0 is the generic + /// port-1/slot-0 backplane path; any well-formed path passes the gate. Real + /// hardware (SLC 5/05 / MicroLogix / PLC-5) uses an empty path because there's + /// no backplane to cross, so point AB_LEGACY_CIP_PATH= (empty) at real + /// hardware to exercise the authentic wire semantics. + /// + public const string DefaultCipPath = "1,0"; + public string Host { get; } = "127.0.0.1"; public int Port { get; } = DefaultPort; + + /// CIP routing path portion of the device URI (after the / separator). + /// May be empty when targeting real hardware; non-empty against ab_server. + public string CipPath { get; } = DefaultCipPath; + public string? SkipReason { get; } public AbLegacyServerFixture() @@ -56,6 +68,11 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p; } + // Empty override is intentional (real hardware); treat `null` as "not set, use + // default" but preserve an explicit empty-string override. + var cipOverride = Environment.GetEnvironmentVariable(CipPathEnvVar); + if (cipOverride is not null) CipPath = cipOverride; + SkipReason = ResolveSkipReason(Host, Port); } @@ -82,22 +99,6 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime $"Start the Docker container (docker compose -f Docker/docker-compose.yml " + $"--profile slc500 up -d), attach real hardware, or override {EndpointEnvVar}."; } - - // TCP reaches — but is the peer a real PLC (wire-trustworthy) or ab_server's PCCC - // mode (dispatcher is upstream-broken, every read surfaces BadCommunicationError)? - // We can't detect it at the wire without issuing a full libplctag session, so we - // require an explicit opt-in for wire-level runs. See - // `tests/.../Docker/README.md` §"Known limitations" for the upstream-tracking pointer. - if (Environment.GetEnvironmentVariable(TrustWireEnvVar) is not { Length: > 0 } trust - || !(trust == "1" || string.Equals(trust, "true", StringComparison.OrdinalIgnoreCase))) - { - return $"AB Legacy endpoint at {host}:{port} is reachable but {TrustWireEnvVar} is not set. " + - "ab_server's PCCC dispatcher is a known upstream gap (libplctag/libplctag), so by " + - "default the integration suite assumes the simulator is in play and skips. Set " + - $"{TrustWireEnvVar}=1 when pointing at real SLC 5/05 / MicroLogix 1100/1400 / PLC-5 " + - "hardware or a RSEmulate 500 golden-box."; - } - return null; } @@ -166,7 +167,7 @@ public sealed class AbLegacyServerCollection : Xunit.ICollectionFixture -/// [Fact]-equivalent that skips when the PCCC endpoint isn't wire-trustworthy. +/// [Fact]-equivalent that skips when the PCCC endpoint isn't reachable. /// See for the exact skip semantics. /// public sealed class AbLegacyFactAttribute : FactAttribute @@ -174,11 +175,9 @@ public sealed class AbLegacyFactAttribute : FactAttribute public AbLegacyFactAttribute() { if (!AbLegacyServerFixture.IsServerAvailable()) - Skip = "AB Legacy PCCC endpoint not wire-trustworthy. Either no simulator is " + - "running, or the Docker ab_server is up but AB_LEGACY_TRUST_WIRE is not " + - "set (ab_server's PCCC dispatcher is a known upstream gap). Set " + - "AB_LEGACY_TRUST_WIRE=1 when pointing AB_LEGACY_ENDPOINT at real hardware " + - "or a RSEmulate 500 golden-box."; + Skip = "AB Legacy PCCC endpoint not reachable. Start the Docker fixture " + + "(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " + + "or point AB_LEGACY_ENDPOINT at real hardware."; } } @@ -190,10 +189,8 @@ public sealed class AbLegacyTheoryAttribute : TheoryAttribute public AbLegacyTheoryAttribute() { if (!AbLegacyServerFixture.IsServerAvailable()) - Skip = "AB Legacy PCCC endpoint not wire-trustworthy. Either no simulator is " + - "running, or the Docker ab_server is up but AB_LEGACY_TRUST_WIRE is not " + - "set (ab_server's PCCC dispatcher is a known upstream gap). Set " + - "AB_LEGACY_TRUST_WIRE=1 when pointing AB_LEGACY_ENDPOINT at real hardware " + - "or a RSEmulate 500 golden-box."; + Skip = "AB Legacy PCCC endpoint not reachable. Start the Docker fixture " + + "(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " + + "or point AB_LEGACY_ENDPOINT at real hardware."; } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md index a671cb6..6dcda63 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md @@ -52,31 +52,33 @@ families stop the current service + start another. | Var | Default | Purpose | |---|---|---| | `AB_LEGACY_ENDPOINT` | `localhost:44818` | `host:port` of the PCCC endpoint. | -| `AB_LEGACY_TRUST_WIRE` | *unset* | Opt-in promise that the endpoint is a real PLC or RSEmulate 500 golden-box (not ab_server). Required for integration tests to actually run; without it the tests skip with an upstream-gap message even when TCP reaches a listener. See the **Known limitations** section below. | +| `AB_LEGACY_CIP_PATH` | `1,0` | CIP routing path portion of the `ab://host:port/` URI. ab_server rejects empty paths at the CIP unconnected-send layer; real SLC/MicroLogix/PLC-5 hardware accepts empty (no backplane). Set to empty (`AB_LEGACY_CIP_PATH=`) when pointing at real hardware. | +| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | When set (e.g. `slc500`), the parametric theory filters to that profile. Only one compose container binds `:44818` at a time; set this to the profile currently up so the suite doesn't try to hit e.g. the Slc500 family against the PLC-5 container. Leave unset for real-hardware runs (all 3 families parameterize). | ## Run the integration tests -In a separate shell with a container up: +In a separate shell with a container up, tell the suite which profile is +running so only the matching theory-parameterization executes: ```powershell cd C:\Users\dohertj2\Desktop\lmxopcua +$env:AB_LEGACY_COMPOSE_PROFILE = "slc500" # or "micrologix" / "plc5" dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests ``` -Against the Docker ab_server the suite **skips** with a pointer to the -upstream gap (see **Known limitations**). Against real SLC / MicroLogix / -PLC-5 hardware or a RSEmulate 500 box: +Against real SLC / MicroLogix / PLC-5 hardware, set the endpoint + an +empty cip-path + leave the profile unset so all 3 parameterizations +run (real PLCs answer any valid family): ```powershell $env:AB_LEGACY_ENDPOINT = "10.0.1.50:44818" -$env:AB_LEGACY_TRUST_WIRE = "1" +$env:AB_LEGACY_CIP_PATH = "" # empty — real hardware has no backplane dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests ``` -`AbLegacyServerFixture` TCP-probes the endpoint at collection init and sets -a skip reason that captures **both** cases: unreachable endpoint *and* -reachable-but-wire-untrusted. Tests use `[AbLegacyFact]` / `[AbLegacyTheory]` -which check the same gate. +`AbLegacyServerFixture` TCP-probes the endpoint at collection init and +sets a skip reason when the listener isn't reachable. Tests use +`[AbLegacyFact]` / `[AbLegacyTheory]` which check the same gate. ## What each family seeds @@ -97,41 +99,31 @@ implies type: ## Known limitations -### ab_server PCCC dispatcher (confirmed upstream gap, verified 2026-04-21) +### ab_server rejects empty CIP paths -**ab_server accepts TCP at `:44818` but its PCCC dispatcher is not -functional.** Running with `--plc=SLC500 --debug=5` shows no request -logs when libplctag issues a read, and every read surfaces as -`BadCommunicationError` (libplctag status `0x80050000`). This matches -the libplctag docs' description of PCCC support as less-mature than -CIP in the bundled `ab_server` tool. +libplctag's `ab_server` enforces a non-empty CIP routing path at the +unconnected-send layer before forwarding to the PCCC dispatcher; a +client-side `ab://host:port/` with nothing after the `/` surfaces as +`BadCommunicationError` (`0x80050000`) with no server-side log line. -**Fixture behavior.** To avoid a loud row of failing tests on the -integration host every time someone `docker compose up`s the SLC500 -profile, `AbLegacyServerFixture` gates on a second env var -`AB_LEGACY_TRUST_WIRE`. The matrix: +Real SLC/PLC-5 hardware has no backplane routing, so an empty path is +how field devices are addressed. The fixture defaults to `/1,0` +(port-1/slot-0 — the conventional ControlLogix backplane path) which +the ab_server accepts; operators targeting real hardware set +`AB_LEGACY_CIP_PATH=` (empty) to exercise authentic wire semantics. -| Endpoint reachable? | `AB_LEGACY_TRUST_WIRE` set? | Result | -|---|---|---| -| No | — | Skip ("not reachable") | -| Yes | No | **Skip ("ab_server PCCC gap")** | -| Yes | `1` or `true` | **Run** | +Previous versions of this README described PCCC as "upstream-broken" — +the root cause turned out to be the cip-path gate above, not a gap in +`pccc.c`. N-file (Int16), F-file (Float32), and L-file (Int32) round- +trip cleanly across SLC500, MicroLogix, and PLC-5 modes. -The test bodies themselves are correct for real hardware — point -`AB_LEGACY_ENDPOINT` at a real SLC 5/05 / MicroLogix 1100/1400 / -PLC-5, set `AB_LEGACY_TRUST_WIRE=1`, and the smoke tests round-trip -cleanly. +### Bit-file writes on ab_server -Resolution paths (pick one): - -1. **File an ab_server bug** in `libplctag/libplctag` to expand PCCC - server-side coverage. -2. **Golden-box tier** via Rockwell RSEmulate 500 — closer to real - firmware, but license-gated + RSLinx-dependent. Set - `AB_LEGACY_TRUST_WIRE=1` when the endpoint points at an Emulate - box. -3. **Lab rig** — used SLC 5/05 / MicroLogix 1100 on a dedicated - network (task #222); the authoritative path. +`B3:0/5`-style bit-in-boolean writes currently surface `0x803D0000` +against `ab_server --plc=SLC500`; bit reads work. Non-blocking for the +smoke suite (which targets N-file Int16 + F-file float reads), but +bit-write fidelity isn't simulator-verified — route operator-critical +bit writes to real hardware or RSEmulate 500 until upstream resolves. ### Other known gaps (unchanged from ab_server)