fix(focas-tests): honour fixture endpoint + fix mock timer endianness (9F/1P → 10/10) #450
@@ -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-<driver>`). **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=<standard\|dl205\|mitsubishi\|s7_1500\|exception_injection>` (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=<slc500\|micrologix\|plc5>` (**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:
|
||||
|
||||
+11
-1
@@ -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" ")
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ public sealed class FocasSimFixture : IAsyncDisposable
|
||||
/// <summary>Gets the TCP port of the focas-mock simulator.</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>Gets the <c>focas://host:port</c> device URI the driver connects to — derived from
|
||||
/// <see cref="Host"/>/<see cref="Port"/> so tests honour <c>OTOPCUA_FOCAS_SIM_ENDPOINT</c> (a remote
|
||||
/// fixture on the Docker host) instead of assuming a localhost-colocated mock.</summary>
|
||||
public string DeviceUri => $"focas://{Host}:{Port}";
|
||||
|
||||
/// <summary>focas-mock profile stem the fixture should load (e.g. <c>fwlib30i64</c>,
|
||||
/// <c>ThirtyOne_i</c> — both resolve via the mock's alias table). Null when unset.</summary>
|
||||
public string? ExpectedProfile { get; }
|
||||
|
||||
+3
-1
@@ -23,7 +23,9 @@ public sealed class WireBackendCoverageTests
|
||||
/// <param name="fx">The FOCAS simulation fixture.</param>
|
||||
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;
|
||||
|
||||
/// <summary>Verifies that user tag reads route via the wire backend.</summary>
|
||||
[Fact]
|
||||
|
||||
+3
-1
@@ -26,7 +26,9 @@ public sealed class WireBackendTests
|
||||
/// <param name="fx">The FOCAS simulator fixture.</param>
|
||||
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;
|
||||
|
||||
/// <summary>Verifies that identity axes and dynamic data populate via the wire backend.</summary>
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user