From 79820436731574bc4e6a358499acd5817d561875 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 05:51:47 -0400 Subject: [PATCH] fix(focas-tests): honour fixture endpoint + fix mock timer endianness (9F/1P -> 10/10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test/fixture bugs; NO OtOpcUa driver change. 1. Topology: FocasSimFixture reads an env-overridable endpoint (OTOPCUA_FOCAS_SIM_ENDPOINT, default localhost:8193) and exposes Host/Port, but WireBackendTests + WireBackendCoverageTests hardcoded focas://127.0.0.1:8193. Against a remote fixture the skip-gate passed (remote reachable) but the driver dialed localhost -> KeyNotFound. Added a DeviceUri property to the fixture; each test now derives DeviceHost from it. Recovered 8 of 9. 2. Mock timer endianness (surfaced once #1 let the tests reach the mock): the last failure read 60397977600 for a 3600 s power-on timer (= 0x3C000000 x 60). The focas-mock's _wire_timer encoded the minute/msec fields big-endian (_u32), but cnc_rdtimer's timer fields are LITTLE-endian on real hardware — a documented, live-31i-B-validated quirk that FocasWireClient.ParseTimer decodes LE (docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md). The driver was correct; the mock was wrong. _wire_timer now emits little-endian (_u32_le). Suite 9F/1P -> 10/10 against the remote fixture at ~/otopcua-focas. Integration-sweep follow-up #3. --- archreview/plans/INTEGRATION-SWEEP-STATUS.md | 40 ++++++++++++------- .../focas-mock/src/focas_mock/server.py | 12 +++++- .../FocasSimFixture.cs | 5 +++ .../Series/WireBackendCoverageTests.cs | 4 +- .../Series/WireBackendTests.cs | 4 +- 5 files changed, 47 insertions(+), 18 deletions(-) diff --git a/archreview/plans/INTEGRATION-SWEEP-STATUS.md b/archreview/plans/INTEGRATION-SWEEP-STATUS.md index 97c32694..5398b8b1 100644 --- a/archreview/plans/INTEGRATION-SWEEP-STATUS.md +++ b/archreview/plans/INTEGRATION-SWEEP-STATUS.md @@ -25,7 +25,7 @@ need sudo to create new ones — I deployed FOCAS to `~/otopcua-focas` on the ho | **TwinCAT** | ⏭️ 13 skip | no TC3 XAR fixture exists (expected clean-skip) | | **Modbus** (dl205 / mitsubishi / s7_1500 / exception_injection) | ✅ 16/13/10/17 (was 8F/5F/3F/8F) | **NOT drift** — fixture-cycling artifact, re-verified GREEN 2026-07-15 — see below | | **AbCip** | ✅ 10P/3skip (was 6F/1P/3skip) | static-init-order bug FIXED 2026-07-15 (`326b22a7`) — see below | -| **FOCAS** | ❌ 9F/1P | tests assume a localhost-colocated mock — see below | +| **FOCAS** | ✅ 10/10 (was 9F/1P) | topology + mock-timer-endianness FIXED 2026-07-15 — see below | | **AbLegacy** | ✅ slc500 2/2, micrologix 1/1, plc5 1/1 (was 4F) | fixture-deployment gap FIXED 2026-07-15 — see below | | **OpcUaServer** | ✅ 4/4 (was 4F) | cert-store gap + SDK read-throws drift FIXED 2026-07-15 — see below | @@ -38,9 +38,9 @@ and all driver **unit** suites + the live `/run` gates pass. These integration s and never run, so they'd bit-rotted in several distinct ways. **Follow-up progress (2026-07-15):** #1 AbCip (PR #446), #5 OpcUaServer (PR #447), #2 Modbus (PR #448 — false -alarm, no code), and #4 AbLegacy (PR #449 — fixture-deployment gap, no code) all closed. **4 of 7 done; all -driver suites now verified GREEN.** Remaining: #3 FOCAS + #6 Host (each needs a design/infra decision) and #7 -(FOCAS fixture deploy). +alarm), #4 AbLegacy (PR #449 — fixture gap), #3 FOCAS (PR #450 — topology + mock-timer-endianness), and #7 +(fixture deploys) all closed. **6 of 7 done; every driver + server suite now verified GREEN.** Remaining: +**#6 Host only** — swap the gone `bitnami/openldap:2.6` image + relax deadlines for amd64-emulated SQL. --- @@ -73,12 +73,18 @@ driver suites now verified GREEN.** Remaining: #3 FOCAS + #6 Host (each needs a GuardLogix];` (evaluated on access, after the fields are set). Unblocks the suite; then re-run to see if the actual sim tests pass. -- **FOCAS** — tests key on `focas://127.0.0.1:8193/Axes/X/AbsolutePosition` (KeyNotFound) — they assume a - **localhost-colocated mock**, not a remote docker fixture at `10.100.0.35:8193`. The repo fixture - (`tests/.../FOCAS.IntegrationTests/Docker/`, publishes `8193:8193`) was NOT deployed to the host; I deployed - it to `~/otopcua-focas` and it serves, but the tests' node-key/topology assumption (127.0.0.1) doesn't match - a remote sim. **Fix:** reconcile the mock topology — either run the FOCAS mock on localhost, or make the test - node-keys honor `OTOPCUA_FOCAS_SIM_ENDPOINT`/`FOCAS_MOCK_HOST`. +- **FOCAS** — ✅ **FIXED 2026-07-15 (PR #450). Two test/fixture bugs, no OtOpcUa driver change.** + 1. *Topology:* `FocasSimFixture` already reads an env-overridable endpoint (`OTOPCUA_FOCAS_SIM_ENDPOINT`, + default `localhost:8193`) and exposes `Host`/`Port`, but `WireBackendTests` + `WireBackendCoverageTests` + **hardcoded** `focas://127.0.0.1:8193`. Against a remote fixture the skip-gate passed (remote reachable) but + the driver dialed localhost → KeyNotFound. Fix: added a `DeviceUri` property to the fixture and derived each + test's `DeviceHost` from it — recovered **8 of 9**. + 2. *Mock timer endianness (newly surfaced):* the last failure (`Timers_populate`) read `60397977600` for a + `3600 s` power-on timer (= `0x3C000000 × 60`). Root cause: the focas-mock's `_wire_timer` encoded the + `minute`/`msec` fields **big-endian** (`_u32`), but `cnc_rdtimer`'s timer fields are **little-endian** on + real hardware — a documented, live-31i-B-validated quirk (`FocasWireClient.ParseTimer` decodes them LE, per + `docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`). **The driver was correct; the mock was wrong.** Fix: + `_wire_timer` now emits little-endian (`_u32_le`) → suite **10/10**. Fixture deployed at `~/otopcua-focas`. - **AbLegacy** — ✅ **FIXED 2026-07-15 (PR #449). Fixture-deployment gap, not a code defect.** The AbLegacy suite needs `ab_server` in **PCCC mode** (`--plc=SLC500|Micrologix|PLC/5`), which reuses the **AbCip image** @@ -123,7 +129,10 @@ driver suites now verified GREEN.** Remaining: #3 FOCAS + #6 Host (each needs a pymodbus 3.13.0; the 8F/5F/3F/8F was a fixture-cycling artifact (stale profile container holding `:5020`). Re-verified all four profiles GREEN (16/13/10/17). Added a profile-cycling gotcha + reliable-cycle recipe to `Docker/README.md`. -3. **FOCAS** — reconcile the 127.0.0.1-mock topology with the remote-fixture model. +3. ~~**FOCAS** — reconcile the 127.0.0.1-mock topology with the remote-fixture model.~~ + ✅ **DONE 2026-07-15 (PR #450).** Tests now derive `DeviceHost` from the fixture endpoint (recovered 8/9); + also fixed a newly-surfaced mock bug — `_wire_timer` encoded the timer big-endian but real hardware + + driver use little-endian. Suite **10/10**. No OtOpcUa driver change. 4. ~~**AbLegacy** — deploy an AbLegacy sim fixture (none on host).~~ ✅ **DONE 2026-07-15 (PR #449) — fixture-deployment gap, no code defect.** Deployed the PCCC compose to `~/otopcua-ablegacy` (reuses the AbCip image, already on host). All 3 families GREEN (slc500 2/2, micrologix @@ -136,8 +145,9 @@ driver suites now verified GREEN.** Remaining: #3 FOCAS + #6 Host (each needs a *throws* `ServiceResultException(BadNodeIdUnknown)` for a removed node instead of returning a bad `DataValue` — updated the two subscription-survival assertions to `Should.ThrowAsync`. Suite **4F → 4/4**. 6. **Host** — replace `bitnami/openldap:2.6` (image gone); run E2E on real SQL or raise deadlines. -7. **Fixture deployment gap** — FOCAS fixture isn't on the host at `/opt/otopcua-*` (and `/opt` needs sudo — - use `~/otopcua-`). **AbLegacy is now deployed** at `~/otopcua-ablegacy` (2026-07-15, PR #449). +7. ~~**Fixture deployment gap** — FOCAS + AbLegacy fixtures aren't on the host.~~ + ✅ **RESOLVED 2026-07-15.** Both now deployed under the home dir: `~/otopcua-ablegacy` (PR #449) + + `~/otopcua-focas` (PR #450). (`/opt` still needs sudo; the home-dir deploys work fine.) --- @@ -151,7 +161,7 @@ ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile ssh dohertj2@10.100.0.35 'cd /opt/otopcua-s7 && docker compose --profile s7_1500 up -d' # :1102 ssh dohertj2@10.100.0.35 'cd /opt/otopcua-opcuaclient && docker compose up -d' # :50000 ssh dohertj2@10.100.0.35 'cd ~/otopcua-ablegacy && docker compose --profile slc500 up -d' # :44818 (PCCC; reuses AbCip image) -# FOCAS not on host — rsync tests/.../Docker/ to ~/otopcua-focas then `docker compose up -d --build` +ssh dohertj2@10.100.0.35 'cd ~/otopcua-focas && docker compose up -d --build' # :8193 ``` Run per suite (env → project): @@ -163,7 +173,7 @@ Run per suite (env → project): | Modbus | `MODBUS_SIM_ENDPOINT=10.100.0.35:5020` `MODBUS_SIM_PROFILE=` (swap the host sim to match) | `tests/Drivers/…Modbus.IntegrationTests` | | AbCip | `AB_SERVER_ENDPOINT=10.100.0.35:44818` `AB_SERVER_PROFILE=controllogix` | `tests/Drivers/…AbCip.IntegrationTests` | | AbLegacy | `AB_LEGACY_ENDPOINT=10.100.0.35:44818` `AB_LEGACY_COMPOSE_PROFILE=` (**must** match the running PCCC container — else the `[Theory]` runs all 3 families and 2 fail) | `tests/Drivers/…AbLegacy.IntegrationTests` | -| FOCAS | `OTOPCUA_FOCAS_SIM_ENDPOINT=10.100.0.35:8193` (but tests assume 127.0.0.1 — see above) | `tests/Drivers/…FOCAS.IntegrationTests` | +| FOCAS | `OTOPCUA_FOCAS_SIM_ENDPOINT=10.100.0.35:8193` (tests now honour this — no longer hardcoded to 127.0.0.1) | `tests/Drivers/…FOCAS.IntegrationTests` | | Host | `OTOPCUA_HARNESS_USE_SQL=1` (+ SQL `:14331` via `tests/Server/…Host.IntegrationTests/docker-compose.yml up sql`) `OTOPCUA_HARNESS_USE_LDAP=0` | `tests/Server/…Host.IntegrationTests` | **Modbus profile cycling:** the sim services share `:5020`, so to run a profile: diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py index c3d8e67d..827355ea 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py @@ -415,7 +415,12 @@ class FocasMockServer: timer_type = self._block_u32(request_block, 8) key_map = {0: "power_on", 1: "operating", 2: "cutting", 3: "cycle"} seconds = int(self.store.snapshot()["timers"].get(key_map.get(timer_type, "power_on"), 0)) - return self._u32(seconds // 60) + self._u32((seconds % 60) * 1000) + # cnc_rdtimer's minute+msec fields are LITTLE-endian on the wire — unlike the big-endian + # block envelope and every other payload. Hardware-validated against a live FANUC 31i-B + # (2026-06-25); the driver's FocasWireClient.ParseTimer decodes them little-endian. Encoding + # them big-endian here (the old bug) made the driver read minute=60 as 0x3C000000. See + # docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md. + return self._u32_le(seconds // 60) + self._u32_le((seconds % 60) * 1000) def _wire_alarms(self, request_block: bytes) -> bytes: requested = max(self._block_u32(request_block, 12), 1) @@ -481,6 +486,11 @@ class FocasMockServer: def _u32(self, value: int) -> bytes: return int(value).to_bytes(4, "big", signed=True) + def _u32_le(self, value: int) -> bytes: + # Little-endian u32 — only cnc_rdtimer's minute/msec fields use this on real hardware + # (see _wire_timer). Every other wire field is big-endian via _u32. + return int(value).to_bytes(4, "little", signed=True) + def _ascii_fixed(self, value: str, length: int) -> bytes: return value.encode("ascii", errors="replace")[:length].ljust(length, b" ") diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs index d53875b0..d46faf6a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs @@ -29,6 +29,11 @@ public sealed class FocasSimFixture : IAsyncDisposable /// Gets the TCP port of the focas-mock simulator. public int Port { get; } + /// Gets the focas://host:port device URI the driver connects to — derived from + /// / so tests honour OTOPCUA_FOCAS_SIM_ENDPOINT (a remote + /// fixture on the Docker host) instead of assuming a localhost-colocated mock. + public string DeviceUri => $"focas://{Host}:{Port}"; + /// focas-mock profile stem the fixture should load (e.g. fwlib30i64, /// ThirtyOne_i — both resolve via the mock's alias table). Null when unset. public string? ExpectedProfile { get; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs index 52f1b22c..1da9f2c6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs @@ -23,7 +23,9 @@ public sealed class WireBackendCoverageTests /// The FOCAS simulation fixture. public WireBackendCoverageTests(FocasSimFixture fx) => _fx = fx; - private const string DeviceHost = "focas://127.0.0.1:8193"; + // Derived from the fixture endpoint (OTOPCUA_FOCAS_SIM_ENDPOINT) so the driver dials the SAME + // mock the fixture probed — a remote fixture on the Docker host, not a hardcoded 127.0.0.1. + private string DeviceHost => _fx.DeviceUri; /// Verifies that user tag reads route via the wire backend. [Fact] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs index 36b48d9c..ec373748 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs @@ -26,7 +26,9 @@ public sealed class WireBackendTests /// The FOCAS simulator fixture. public WireBackendTests(FocasSimFixture fx) => _fx = fx; - private const string DeviceHost = "focas://127.0.0.1:8193"; + // Derived from the fixture endpoint (OTOPCUA_FOCAS_SIM_ENDPOINT) so the driver dials the SAME + // mock the fixture probed — a remote fixture on the Docker host, not a hardcoded 127.0.0.1. + private string DeviceHost => _fx.DeviceUri; /// Verifies that identity axes and dynamic data populate via the wire backend. [Fact]