diff --git a/docs/deployments/wonder-app-vd03-makino-z-34184.md b/docs/deployments/wonder-app-vd03-makino-z-34184.md new file mode 100644 index 00000000..244f82f2 --- /dev/null +++ b/docs/deployments/wonder-app-vd03-makino-z-34184.md @@ -0,0 +1,69 @@ +# Deployment record — wonder-app-vd03 · Makino Pro 5 (Z-34184) FOCAS + +**Date configured:** 2026-06-25 +**Host:** `wonder-app-vd03.zmr.zimmer.com` (OtOpcUaHost Windows service, single fused admin+driver node) +**Status:** Configured + deployed + served. **Live values blocked** by the FOCAS PDU-v3 driver gap — +see [`docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`](../plans/2026-06-25-focas-pdu-v3-30i-b-support.md). + +## Equipment + +| Field | Value | +|---|---| +| Machine | **Makino Pro 5** (FANUC **30i-B** control) | +| **IP / FOCAS endpoint** | **`10.201.31.5:8193`** (FOCAS Ethernet, TCP) — reachable from the host (and from VPN clients) | +| **ZTag** | **`Z-34184`** | +| Manufacturer / Model | `Makino` / `Pro 5` | +| CNC series (driver) | `Thirty_i` (30i) | + +## OtOpcUa host endpoints + +- AdminUI: `http://wonder-app-vd03.zmr.zimmer.com:9000` (login disabled — `Security__Auth__DisableLogin=true`) +- OPC UA: `opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa` +- Akka cluster: `:4053` · ConfigDb: SQL `OtOpcUaConfig` on `:1433` + +## Deployed configuration (cluster `DEV`, Enterprise `zb` / Site `wonder-app-vd03`) + +First config ever deployed on this node. All authored via the AdminUI. + +| Object | Value | +|---|---| +| Namespace | `dev-equipment` (Equipment), URI `urn:zb:wonder-app-vd03:equipment` | +| UNS path | `zb / wonder-app-vd03 / machining / makino` | +| FOCAS driver instance | `focas-z-34184` ("Makino Pro 5 Z-34184 (FOCAS)"), backend `wire`, FixedTree enabled | +| Device | `10.201.31.5:8193`, series `Thirty_i` | +| Equipment | `z-34184` → `EquipmentId = EQ-3686c0272279`, MachineCode `Z-34184`, ZTag `Z-34184` | +| Tags | `parts-count` → `MACRO:3901` (Float64/Double, Read); `parts-required` → `MACRO:3902` (Float64/Double, Read) | +| Deployment | `0c2db588` (rev `924b59097eba…`) — **Sealed / "In sync"** | + +OPC UA node IDs (verified served via the OtOpcUa CLI client): +- Equipment: `ns=2;s=EQ-3686c0272279` +- `ns=2;s=EQ-3686c0272279/parts-count` +- `ns=2;s=EQ-3686c0272279/parts-required` + +## Host change required to deploy at all (Akka cluster roles) + +This node was joining the Akka cluster **role-less**, so no deployment could ever complete +("a task was canceled"; Fleet status: "no driver-role nodes are Up"). Root cause: `OTOPCUA_ROLES` +(set to `admin,driver`) drives Program.cs actor wiring but does **not** populate the Akka member roles — +those come from config `Cluster:Roles`, which was unset (no `Cluster__Roles` env var; no `Cluster` +section in any appsettings). No code bridges `OTOPCUA_ROLES` → `AkkaClusterOptions.Roles`. + +**Fix applied to the host service** (`HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaHost` → `Environment`): +added `Cluster__Roles__0=admin` and `Cluster__Roles__1=driver`, then restarted the service. The member +now joins **UP with roles ADMIN+DRIVER** and deployments seal. Prior env backed up on the host at +`E:\ApiInstall\OtOpcUa\_envbak-20260625T145303.txt`. + +> Product follow-up: either wire `OTOPCUA_ROLES` → `AkkaClusterOptions.Roles`, or bake `Cluster:Roles` +> into the deploy template, so a node redeploy doesn't regress to role-less. + +## Live verification (OtOpcUa CLI client → server) + +`connect` + `browse` confirm the equipment + both tags are served at the node IDs above. + +**Driver fix shipped (2026-06-25):** the wire client now accepts PDU v3 (see the linked plan). Validated +directly against `10.201.31.5` — `MACRO:3901`/`3902` read **Good** from the live 30i-B. **However, the +OtOpcUaHost on this box is still running the pre-v3 driver binary**, so the live OPC UA tags will keep +returning `Bad_WaitingForInitialData` until the rebuilt `ZB.MOM.WW.OtOpcUa.Driver.FOCAS.dll` (or a fresh +self-contained host publish) is deployed to `E:\ApiInstall\OtOpcUa\` and `OtOpcUaHost` is restarted. Once +redeployed, `parts-count`/`parts-required` should go Good (FixedTree + PMC/Parameter still pending the +follow-on v3 command work). diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md index f467799d..0507c775 100644 --- a/docs/drivers/FOCAS.md +++ b/docs/drivers/FOCAS.md @@ -15,6 +15,21 @@ OtOpcUa is **read-only** against FOCAS; all reads go over the native wire protocol using the documented command IDs. Writes return `BadNotWritable` by design. +> **PDU version (v3 / FANUC 30i/31i).** The wire client **accepts inbound PDU versions `{1, 3}`** +> (`FocasWireProtocol`) while still emitting v1 on requests. Older controls + the docker mock answer +> v1; modern controls (Makino Pro 5 / FANUC **31i-B**) answer v3. **Validated live against a real +> 31i-B (`10.201.31.5`) 2026-06-25:** sysinfo, axis/spindle names, dynamic positions/feed/spindle, +> program/mode, **macros**, **timers** (8-byte payload is little-endian {minute, msec}), **PMC range** +> (request widened to the data-type byte width), **servo meters** (8-byte LOADELM stride + names from +> the 0x0089 block; load *scaling* still to be confirmed at commissioning), and **alarms** all read +> correctly. The driver `cnc_rddynamic2` poll is 1-based. **`cnc_rdparam` is unsupported on this +> control** — every request-framing variant returns `EW_FUNC` (likely a wrong v3 command id; needs a +> reference FWLIB trace). The Test-Connect probe now runs a real wire session (initiate + cnc_statinfo) +> instead of a bare TCP check. Full finding, captured wire bytes, validation results, and analysis: +> [`docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`](../plans/2026-06-25-focas-pdu-v3-30i-b-support.md) +> + [`…-implementation-plan.md`](../plans/2026-06-25-focas-pdu-v3-implementation-plan.md). +> Capture tools: `scripts/focas/capture-initiate.py`, `scripts/focas/capture-v3.py`. + ## Project split | Project | Target | Role | diff --git a/docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md b/docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md new file mode 100644 index 00000000..7e64829c --- /dev/null +++ b/docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md @@ -0,0 +1,222 @@ +# FOCAS wire-protocol PDU v3 — finding + support analysis (Makino Pro 5 / FANUC 30i-B) + +**Date:** 2026-06-25 +**Author:** field investigation against a live CNC (first real FOCAS hardware contact) +**Status:** **RESOLVED + live-validated on a real 31i-B (`10.201.31.5`) 2026-06-25** — version gate, timer, PMC range, servo-meter, alarms, probe all fixed and verified live; `cnc_rdparam` found unsupported on this control (see Resolution). See `2026-06-25-focas-pdu-v3-implementation-plan.md` for the per-phase record. +**Components:** `ZB.MOM.WW.OtOpcUa.Driver.FOCAS` (`Wire/FocasWireProtocol.cs`, `Wire/FocasWireClient.cs`) + +--- + +## TL;DR + +The pure-managed `WireFocasClient` only implements **FOCAS Ethernet wire-protocol PDU version 1** +and hard-rejects every other version. A real **Makino Pro 5 (FANUC 30i-B)** at `10.201.31.5:8193` +speaks **PDU version 3**, so the driver fails the session with +`BadCommunicationError` / "Unsupported FOCAS PDU version 3" and no tag ever produces a value. + +A live experiment (relaxing the version gate to accept v3) showed the **v3 initiate handshake and the +macro-read data framing are already compatible** — `MACRO:*` reads returned correct values from the +real machine. **PMC** and **Parameter** reads still failed and need per-command v3 work. So v3 support +is *not* a rewrite, but it is also *not* a one-line version bump if PMC/PARAM/FixedTree are required. + +This was the **first time the driver has ever been pointed at real FOCAS hardware** — the wire +implementation was written from `strangesast/fwlib` + public docs with an explicit "needs Wireshark +traces to validate" caveat (see `docs/v2/implementation/focas-wire-protocol.md`). This finding is +those traces. + +--- + +## Resolution (2026-06-25 — implemented + live-validated) + +A full v3 data-PDU capture (`scripts/focas/capture-v3.py`, fixtures under +`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/`) **substantially corrected the +initial diagnosis** — 4 of the 6 "v3 framing failures" were not framing problems at all. The block +envelope is byte-identical to v1; only specific payload structs / request-range math / a client-side +robustness gap were wrong. Every fix below was re-validated by reading the live 31i-B through the +fixed driver. + +| Surface | Root cause (from the capture) | Fix | Live result | +|---|---|---|---| +| version gate | hard `version != 1` reject | accept `{1,3}` inbound (`FocasWireProtocol`) | macros + all reads work on v3 | +| `cnc_rdtimer` | 8-byte {minute,msec} payload is **little-endian** (only decode with an in-range msec) | `FocasWireClient.ParseTimer` reads LE | cutting = 1,110,700 min / 41,872 ms | +| `pmc_rdpmcrng` | request asked for `end=start` (1 byte) but a Word needs its byte width → 0 values → spurious `BadOutOfRange` | `WireFocasClient.ReadPmcAsync` sets `end = start + width − 1`; decode extracted to `ParsePmcRange` | R0 = 7873, R100 = 0, status Good | +| `cnc_rdsvmeter` | (a) **no wire hang** — CNC answers fully + promptly; the "hang" was `NetworkStream.ReadAsync` not aborting a genuinely stalled socket. (b) per-axis LOADELM is **8 bytes**, not 12 → 12-byte stride misaligned (→ 655360 garbage); names live in the 0x0089 block | (a) `ReadExactlyAsync` dispose-on-cancel abort. (b) `ParseServoMeters` 8-byte stride + name correlation | 7 axes X,Y,Z,B,C,AA,AA, aligned values (≈0 idle) | +| `cnc_rdalmmsg2` | not broken — empty payload = no active alarms | none (parser already handles empty) | returned active alarm `#3080 WRONG PALLET IN MACHINE` | +| `cnc_rddynamic2` axis 0 | not a driver bug — the FixedTree poll already iterates 1..N; only a direct harness call used 0 | contract guard in `ReadDynamicAsync` (reject `axisIndex < 1`) | axes 1..N read clean | +| Test-Connect probe | degraded to `Ok=true` "TCP reachability only" when FWLIB absent → any TCP listener looked HEALTHY | `FocasDriverProbe` now runs a real wire session (initiate + `cnc_statinfo`) | `Ok=true` vs real CNC, `Ok=false` vs bare listener | + +### `cnc_rdparam` — unsupported on this control (blocked) + +The one genuine v3 problem. A live matrix of **14 request-framing variants × 4 known-present +parameters** (8130 / 1320 / 1825 / 3201) — every combination of arg ordering, axis, length, +request-class, and extra payload (`scripts/focas/param-probe.py`) — returned **`EW_FUNC(1)` +uniformly**. That is not a tweakable-framing bug. `0x000e` is also the ignored post-connect setup +command, which makes it a doubtful parameter opcode. Either parameter read is genuinely restricted on +this control via the wire path, or the v3 command id differs from `0x000e` and cannot be recovered +without a reference FWLIB Wireshark trace (the long-blocked "Stream C.2"). Parameter support is parked +on that reference; the deployed config uses macros, not parameters, so nothing live depends on it. + +### Open caveats +- **Servo-load magnitude/scaling** (`data / 10^dec`; `dec` read as 10 → idle loads ≈ 0) is inferred + from the wire and unconfirmed against the machine's servo-meter screen — confirm at commissioning. +- **Timer type→counter mapping**: power-on / operating / cycle read 0 while cutting is non-zero on + this control. The *decode* is correct; whether type 0/1/3 map to populated counters here is a + CNC-configuration question for commissioning. + +## Environment + +- **CNC:** Makino Pro 5, FANUC 30i-B control, `10.201.31.5:8193` (ZTag `Z-34184`). FOCAS Ethernet + reachable (TCP 8193 open from both the OtOpcUa host `wonder-app-vd03` and a dev laptop over VPN). +- **Driver backend:** `wire` (the default and now only real backend — `fwlib`/`ipc` were retired in + the Wire migration; see `FocasDriverFactoryExtensions.BuildClientFactory`). FANUC FWLIB is NOT + installed on the host, and is not used for reads. +- **Deployment using this CNC:** see `docs/deployments/wonder-app-vd03-makino-z-34184.md`. + +## Symptom + +Equipment tags backed by this device never leave OPC UA `Bad_WaitingForInitialData` (`0x80320000`), +and the FOCAS FixedTree emits no nodes (capability detection against the CNC never succeeds). + +## Reproduction (driver CLI, straight to the CNC — no OPC UA server involved) + +```bash +dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli -- \ + probe -h 10.201.31.5 -s Thirty_i --timeout-ms 6000 --verbose +``` + +``` +CNC: 10.201.31.5:8193 +Series: Thirty_i +Health: Degraded +Last error: Unsupported FOCAS PDU version 3. +R100 → 0x80050000 (BadCommunicationError) +``` + +## Root cause (exact) + +`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs` + +- Line 22: `public const ushort Version = 1;` +- `ReadPduAsync` (~line 102) and `ReadPdu` (~line 125): `if (version != Version) throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");` + +The 10-byte PDU header is `A0 A0 A0 A0` magic + `u16` version + type byte + direction byte + `u16` +body length. The client emits version 1 and **rejects any response whose version field != 1**. The +30i-B answers the initiate with version 3. + +## What the wire actually shows (captured live, read-only initiate handshake) + +Request the driver sends (socket-1 initiate, exactly as today): +``` +a0 a0 a0 a0 00 01 01 01 00 02 00 01 +^magic------ ^v=1 ^t ^d ^len ^sockIdx=1 +``` + +Response from the real 30i-B: +``` +a0 a0 a0 a0 00 03 01 02 01 68 + 360-byte body +^magic------ ^v=3 ^t ^d ^len=0x0168(360) +body[0..]: 00 08 00 05 00 03 00 20 00 08 00 0a 00 03 00 03 00 0f 00 0f 00 01 00 02 00 01 00 00 00 01 02 00 ... +``` + +Key observations: +- **The 10-byte header framing is byte-identical to v1** — same magic, same `type=0x01` (initiate), + same `direction=0x02` (response). **Only the version field differs (3 vs 1).** +- The initiate-response **body is 360 bytes** (v3 carries a larger capability/version descriptor block; + it parses as a run of big-endian `u16` words). The current client doesn't deeply parse the initiate + body, so its size/shape did not block the handshake once the version gate was relaxed. +- **No version negotiation:** sending version 3 in our *request* header produced the identical response — + the CNC speaks v3 unconditionally. + +(Capture is reproducible with `scripts/focas/capture-initiate.py `.) + +## Implemented + validated — accept v3 on inbound PDUs + +Shipped change (`Wire/FocasWireProtocol.cs`): the hard `version != Version` reject is replaced by a +supported-read-version set `{1, 3}` (`SupportedReadVersions` + `IsSupportedReadVersion`). We still +**emit** `Version` (v1) on requests — the 30i-B accepts v1 request framing — and now **accept** v1 or v3 +on inbound PDUs. Covered by `FocasWireProtocolTests.ReadPduAsync_accepts_supported_version` (v1 + v3 +theory) with the existing v99-rejection test still green; full FOCAS unit suite 218/218. + +Validated live against `10.201.31.5` (30i-B) with the change in source — reading several addresses: + +| Address | Type | Result | +|---|---|---| +| `MACRO:500` | Float64 | **0.02 — Good (`0x0`)** | +| `MACRO:3901` (parts total) | Float64 | **0 — Good (`0x0`)** | +| `MACRO:3902` (parts required) | Float64 | Good | +| `R100` (PMC R-file) | Int16 | `0x803C0000` BadOutOfRange | +| `PARAM:1320/0` | Int32 | `0x803D0000` BadNotSupported | + +Interpretation: +- **Initiate handshake + macro command (`cnc_rdmacro`, cmd path) data framing are already v3-compatible.** + Macro reads returned correct, plausible values from the real machine. The deployed equipment tags + (`MACRO:3901`/`3902`) would go **Good** with nothing more than the version-gate relaxation. +- **PMC (`pmc_rdpmcrng`) and Parameter (`cnc_rdparam`) reads still fail.** Two candidate causes, not yet + separated: (a) the v3 response *block/struct* framing for these specific commands differs from the + v1-shaped parser (so the return-code/value lands at the wrong offset → spurious `BadOutOfRange` / + `BadNotSupported`); or (b) genuine CNC restrictions (PMC path/range, parameter not present). Macro + working argues the *envelope* is fine, so this is per-command struct work, not a framing rewrite. + +## Status-command validation on v3 (the FixedTree surface) — 2026-06-25 + +Drove every `IFocasClient` status call directly against the live control (v3 accepted). **Most of the +FixedTree lights up on v3.** Note: sysinfo reveals the control is actually a **31i** (CncType=31, Series +`G431`, MaxAxis 32, 7 axes, MtType MM) — the deployment declared `Thirty_i`; same family, reads fine. + +| Call (FOCAS fn) | Result on v3 | +|---|---| +| `GetSysInfoAsync` (`cnc_sysinfo`) → Identity | ✅ real — CncType 31, Series G431, 7 axes, MtType MM | +| `GetAxisNamesAsync` (`cnc_rdaxisname`) | ✅ real — X,Y,Z,B,C,A,A | +| `GetSpindleNamesAsync` (`cnc_rdspdlname`) | ✅ real — S1 | +| `GetProgramInfoAsync` (program/mode) | ✅ real — `//CNC_MEM/USER/LIBRARY/O1111`, Mode 1 | +| `ReadDynamicAsync(n)` (`cnc_rddynamic2`) | ✅ real for axes 1..N (feed 4200, spindle ~15000, live positions). **axis 0 → `EW_4`** — the call is 1-based; FixedTree must iterate 1..N, not 0 | +| `GetTimerAsync(*)` | ⚠️ **misparsed** — a running machine shows PowerOn/Operating/Cycle = 0 and Cutting = garbage; the v3 timer struct differs | +| `GetServoLoadsAsync` (`cnc_rdsvmeter`) | ❌ **hangs** — blocks awaiting bytes that never arrive (v3 framing differs) *and* ignores the cancellation token (poll-loop-stalling robustness bug; the read must honor CT regardless) | +| `ReadAlarmsAsync` (`cnc_rdalmmsg2`) | ❓ untested — ServoLoads hung ahead of it; validate once the hang is fixed | + +Remaining v3 work, now scoped concretely: timer struct; 1-based axis iteration for dynamic; the +`cnc_rdsvmeter` framing + a cancellation-honoring read; and the PMC (`pmc_rdpmcrng`) + Parameter +(`cnc_rdparam`) struct diffs. Identity / axes / positions / feed / spindle / program-mode already work. + +## Implementation analysis — what "support PDU v3" actually involves + +1. **Accept v3 at the framing layer (cheap, validated). — ✅ DONE.** Replaced the `version != Version` + hard reject with the supported-set `{1, 3}`. This alone makes the **initiate handshake + all macro + reads** work on a real 30i-B (validated live; deployed `MACRO:3901`/`3902` read Good). We still emit + v1 on requests (the CNC accepts it). If a future command turns out to need the request version echoed, + thread the negotiated version from the initiate response onto the connection. +2. **Validate each command family against v3 response framing.** Capture v3 `0x21` data-PDU responses + for `cnc_rdparam`, `pmc_rdpmcrng`, `cnc_statinfo`, `cnc_rddynamic2`, `cnc_rdaxisname`, and the timer + reads (the FixedTree set), and diff the block/struct offsets vs the v1 assumptions in + `FocasWireModels.cs` / the `ParseX` helpers. Where they differ, add v3 parsing. Capture by extending + `scripts/focas/capture-initiate.py` to complete the handshake and issue one data request per command. +3. **FixedTree depends on (2).** Identity/Axes/Timers/Program nodes only emit if `cnc_sysinfo` + + `cnc_rdaxisname` + dynamic/timer reads succeed at discovery — so they come online once `cnc_statinfo` + / `cnc_rddynamic2` / timer framing is v3-validated. +4. **Don't let the gate lie.** Shipping only step 1 makes the driver accept v3 while PMC/PARAM/FixedTree + silently misbehave. Either gate macro-only configs as "supported on v3" with the others explicitly + flagged, or land steps 1–3 together. + +### Alternative considered: reinstate an FWLIB-backed client +The official FANUC FWLIB (`Fwlib64.dll`) handles all protocol versions natively. But the `fwlib`/`ipc` +backends were deliberately retired in the Wire migration (native Windows component, x86/x64 + STA, and +licensing — the exact coupling the managed client removed). Reintroducing it reverses that decision and +is heavier than completing v3 in the wire client; recommend only if multiple controls need surfaces the +managed client can't reach. + +## Secondary finding — the Test-Connect / health probe is misleading without FWLIB + +`FocasDriverProbe` Phase 2 (the real `cnc_allclibhndl3` FWLIB handshake) **catches the FWLIB-absent load +failure and degrades to `Ok=true` ("TCP reachability only")**. On a host with no FWLIB (the normal case +for the managed wire client), the driver therefore reports **HEALTHY off a bare TCP connect** — which is +exactly how this CNC looked "healthy" while no data flowed. The probe should exercise the wire-client +path (open a `WireFocasClient` session + one sample read) so health reflects real FOCAS reachability, +not just an open socket. + +## Recommended next steps + +1. Land step 1 (accept v3) + capture/validate PMC + Parameter + FixedTree command framing (step 2), + ideally in one change, tested against `10.201.31.5` while access lasts. +2. Fix the probe to use the wire client so HEALTHY means "FOCAS session + read OK," not "TCP open." +3. Add a real-hardware row to `docs/v2/focas-version-matrix.md` (currently hardware-free) recording that + 30i-B = PDU v3, macro reads validated. diff --git a/docs/plans/2026-06-25-focas-pdu-v3-implementation-plan.md b/docs/plans/2026-06-25-focas-pdu-v3-implementation-plan.md new file mode 100644 index 00000000..21ea0e4b --- /dev/null +++ b/docs/plans/2026-06-25-focas-pdu-v3-implementation-plan.md @@ -0,0 +1,139 @@ +# FOCAS PDU v3 — implementation plan (finish real 30i/31i-B support) + +**Date:** 2026-06-25 +**Companion to:** [`2026-06-25-focas-pdu-v3-30i-b-support.md`](2026-06-25-focas-pdu-v3-30i-b-support.md) (finding + live captures + per-command validation) and [`../deployments/wonder-app-vd03-makino-z-34184.md`](../deployments/wonder-app-vd03-makino-z-34184.md) (the deployment this unblocks). +**Goal:** make the managed `WireFocasClient` fully interoperate with a real FANUC 30i/31i-B (FOCAS Ethernet **PDU v3**), then light up the live OPC UA data on `wonder-app-vd03` for the Makino Pro 5 (`Z-34184`, `10.201.31.5`). + +## ⏳ Time-boxed asset — capture/validate live FIRST +`10.201.31.5:8193` (real 31i-B) is reachable from the dev box and the wonder host **right now**. Every +per-command v3 framing fix needs a live capture + live re-validation. **Do all captures in Phase 1 while +access lasts**; the parser fixes + unit tests can be finished offline against the captured bytes later. +Tools already in place: `scripts/focas/capture-initiate.py ` (initiate only — extend it) and the +throwaway status harness pattern (see the finding doc). + +## Current status — DONE (2026-06-25), live-validated on the real 31i-B (`10.201.31.5`) +The Phase-1 capture corrected the diagnosis (4 of 6 "framing failures" were not framing problems). +All tractable phases are implemented + unit-tested (FOCAS suite **234 green**, full solution builds 0 +errors) + re-validated live. The full corrected diagnosis + per-surface evidence is in the companion +finding doc's **Resolution** section. + +| Phase | Item | State | +|---|---|---| +| 0 | inbound PDU-version gate `{1,3}` | DONE — macros + all status reads work on v3 | +| 1 | capture every v3 data PDU | DONE — 20 fixtures under `tests/.../Fixtures/v3/` | +| 2 | servo "hang" → CT-bound reads | DONE — `ReadExactlyAsync` dispose-on-cancel; servo answers in 0 ms (no real wire hang) | +| 3 | request-version policy | DONE — keep emitting v1 (CNC accepts it); no command needed v3 requests | +| 4 | servo + alarms framing | DONE — servo 8-byte stride + names from 0x0089; alarms already correct (read `#3080` live) | +| 5 | timer v3 struct | DONE — `ParseTimer` little-endian {minute, msec} | +| 6 | dynamic axis iteration | DONE — driver poll already 1-based; added a `ReadDynamicAsync` contract guard | +| 7 | PMC framing | DONE — `end = start + width - 1`; **parameter framing BLOCKED** (EW_FUNC across 14 variants — see finding) | +| 8 | probe truthfulness | DONE — `FocasDriverProbe` runs a real wire session (initiate + cnc_statinfo) | +| 9 | docs + version matrix | DONE — this plan, the finding doc, `FOCAS.md`, `focas-version-matrix.md` | +| 10 | deploy to wonder + e2e | PENDING — awaiting go-ahead (production box) | +| 11 | commit + push | commit DONE on `feat/focas-pdu-v3`; push PENDING go-ahead | + +**Only genuinely open v3 item:** `cnc_rdparam` (EW_FUNC on every framing — needs a reference FWLIB +trace or is restricted on this control). Deferred; the deployed config uses macros, not parameters. + +--- + +## Phase 1 — Capture every v3 data-PDU response (live, do first) +- Extend `scripts/focas/capture-initiate.py` (or add a C# capture mode to `Driver.FOCAS.Cli`) to: run the + two-socket initiate, then send each `0x21` data request (command IDs in + `docs/v2/implementation/focas-wire-protocol.md`) and dump the raw v3 response: `cnc_rdtimer`, + `cnc_rdsvmeter`, `pmc_rdpmcrng` (R100), `cnc_rdparam` (e.g. 1320), `cnc_rdalmmsg2`, `cnc_rddynamic2` + (axis 1 — a known-good — as the v3 reference layout). +- Save raw bytes as fixtures under `tests/Drivers/.../Fixtures/v3/` for offline unit tests. +- **Acceptance:** raw v3 response bytes captured + checked in for all six commands. + +## Phase 2 — Safety: `cnc_rdsvmeter` must never hang +- Root cause: the read blocks waiting for a body length the v3 response never satisfies, and the wait + doesn't observe the `CancellationToken`. A hang here can wedge the FixedTree poll loop. +- Make the wire read honor the per-operation timeout/CT **regardless of framing** (the socket read path in + `FocasWireClient` must be CT-bound), so a bad parse fails fast as `BadCommunicationError`/timeout. +- **Acceptance:** `GetServoLoadsAsync` returns or fails within the timeout on the live 31i-B; a unit test + proves a truncated/oversized body length cancels rather than blocks. **Gating:** FixedTree must not be + enabled on a v3 control until this lands (capability probe could otherwise hang at init). + +## Phase 3 — Decide request-version policy +- We currently emit v1 requests and accept v1/v3 responses; macro + most status reads work that way. +- If any Phase 5–7 command turns out to need v3-framed *requests*, thread the version negotiated from the + initiate response onto `FocasWireClient` and have `BuildPdu` emit it. Otherwise keep emitting v1. +- **Acceptance:** documented decision; negotiated version plumbed only if a command requires it. + +## Phase 4 — Servo load + alarms v3 framing +- Diff captured `cnc_rdsvmeter` / `cnc_rdalmmsg2` bytes vs the v1 struct assumptions in `FocasWireModels.cs` + + the `ParseServoLoad` / alarm parsers; fix offsets/strides for v3. +- **Acceptance:** servo-load % values are plausible; `ReadAlarmsAsync` returns the real active-alarm set; + unit tests over the Phase-1 fixtures; live re-validation. + +## Phase 5 — Timer v3 struct +- Diff captured `cnc_rdtimer` bytes; fix the timer struct parse (running machine must show non-zero + PowerOn/Operating; Cutting sane). +- **Acceptance:** all four timers plausible on the live machine; fixture unit test; matches the + FixedTree `Timers/*` node expectations. + +## Phase 6 — Dynamic axis iteration (1-based) +- FixedTree currently probes axis 0 → `EW_4`. Iterate `1..AxesCount` (from `cnc_sysinfo`); never request 0. +- **Acceptance:** every configured axis (per sysinfo `AxesCount`) yields a `FocasDynamicSnapshot`; no `EW_4`. + +## Phase 7 — PMC + Parameter v3 framing +- Diff captured `pmc_rdpmcrng` (R100) + `cnc_rdparam` (1320) bytes vs the v1 `IODBPMC0` / `IODBPSD` shapes; + fix v3 parsing. Confirm whether the failures are framing or genuine CNC restriction (PMC path / param + presence) — macro working proves the envelope is fine, so suspect struct offsets first. +- **Acceptance:** `R100` reads a plausible value (or a *correct* status if genuinely restricted); a known + parameter reads its value; fixture unit tests; live re-validation. + +## Phase 8 — Probe truthfulness +- `FocasDriverProbe` Phase-2 degrades to `Ok=true` ("TCP only") when FWLIB is absent → HEALTHY off a bare + socket. Replace with a wire-client probe: open `WireFocasClient` + one sample read (e.g. sysinfo). Keep + the TCP preflight for fast rejection. +- **Acceptance:** probe reports unhealthy when the CNC TCP-accepts but FOCAS reads fail; HEALTHY only on a + real session + read. + +## Phase 9 — Docs + version matrix +- Add a real-hardware row to `docs/v2/focas-version-matrix.md`: 30i/31i-B → PDU v3; record which command + families are validated. Update `docs/drivers/FOCAS.md` + this plan's status as phases land. + +## Phase 10 — Deploy to wonder + end-to-end verify +- Optional: set the device series to `ThirtyOne_i` (sysinfo says CncType 31; capability ranges identical to + `Thirty_i`, so cosmetic). +- Rebuild a self-contained win-x64 publish of `ZB.MOM.WW.OtOpcUa.Host` (or swap just + `ZB.MOM.WW.OtOpcUa.Driver.FOCAS.dll`) into `E:\ApiInstall\OtOpcUa\` on `wonder-app-vd03`, preserving + `appsettings*.json` + `data\`; restart `OtOpcUaHost`. (Access: servecli `:2222`, key + `~/.ssh/servecli_wonder` — see the deployment doc + memory.) +- **Re-run a deployment** in the AdminUI afterward — FixedTree nodes are emitted at `DiscoverAsync`, so the + address space must be rebuilt to surface them. +- **Acceptance (via the OtOpcUa CLI client → `opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa`):** + `ns=2;s=EQ-3686c0272279/parts-count` + `/parts-required` read **Good**; FixedTree Identity/Axes/Program + nodes present with live values; (timers/servo-load good once Phases 4–5 land). + +## Phase 11 — Commit + push +- Commit source + tests + docs on a branch `feat/focas-pdu-v3` (keep it separate from the unrelated + pre-existing local edits in the tree). Push to gitea per the repo's flow. The Akka-roles host fix is a + separate concern (see deployment doc) — note it but it's a box config change, not repo code. + +--- + +## Test strategy +- **Offline (CI-safe):** unit tests over the Phase-1 captured v3 byte fixtures for every parser + (`FocasWireProtocolTests` + new `FocasWireModels`/parse tests). Keep the docker mock (v1) green. +- **Live (env-gated):** the `Driver.FOCAS.Cli` (`probe`/`read`) + the status harness, against + `10.201.31.5`. Gate behind an env var / `[Trait]` so CI without a CNC skips. + +## Sequencing notes +- Phase 1 (capture) unblocks 4/5/7. Phase 2 (servo-load safety) gates enabling FixedTree on v3. Phases 4–7 + are independent and parallelizable once captures exist. Phase 10 depends on whichever surfaces you want + live (macro tags already work after Phase 0, so a minimal deploy could happen now; full FixedTree wants + Phases 2/5/6). +- **Keep emitting v1 requests** unless Phase 3 proves otherwise — it's validated and minimal. + +## File map +- `src/Drivers/.../Wire/FocasWireProtocol.cs` — version gate (done), request-version policy (Phase 3). +- `src/Drivers/.../Wire/FocasWireClient.cs` — CT-bound reads (Phase 2), per-command requests. +- `src/Drivers/.../Wire/FocasWireModels.cs` + parse helpers — per-command v3 struct fixes (Phases 4–7). +- `src/Drivers/.../FocasDriver.cs` — FixedTree axis iteration (Phase 6), FixedTree enable gating (Phase 2). +- `src/Drivers/.../FocasDriverProbe.cs` — wire-client probe (Phase 8). +- `scripts/focas/capture-initiate.py` — extend to data PDUs (Phase 1). +- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` + `Fixtures/v3/` — fixtures + parser tests. +- `docs/v2/focas-version-matrix.md`, `docs/drivers/FOCAS.md` — docs (Phase 9). diff --git a/docs/v2/focas-version-matrix.md b/docs/v2/focas-version-matrix.md index 7b89ca02..864866f8 100644 --- a/docs/v2/focas-version-matrix.md +++ b/docs/v2/focas-version-matrix.md @@ -143,3 +143,25 @@ The expensive half is Tier-C process isolation so that a crashing `Fwlib64.dll` doesn't take the main OPC UA server down with it. See [`docs/v2/implementation/focas-isolation-plan.md`](implementation/focas-isolation-plan.md) for that plan (task #220). + +## Real-hardware validation (first live FOCAS contact) + +**2026-06-25 — FANUC 31i-B (Makino Pro 5), `10.201.31.5:8193`.** The first time the managed wire +client met real FOCAS hardware. The control answers **PDU version 3**; the 10-byte header framing and +response block envelope are byte-identical to v1 (only the version field differs). `cnc_sysinfo` +reports CncType 31, Series `G431`, 7 axes (X Y Z B C A A), MtType MM. + +| Command family | v3 result | Notes | +| --- | --- | --- | +| `cnc_sysinfo`, `cnc_rdaxisname`, `cnc_rdspdlname` | ✅ validated | identity / axis / spindle names | +| `cnc_rddynamic2` (positions, feed, spindle, program/seq) | ✅ validated | 1-based axis iteration; program O-number reads via big-endian | +| `cnc_rdopmode`, `cnc_exeprgname2` | ✅ validated | mode + executing program | +| `cnc_rdmacro` | ✅ validated | deployed `MACRO:3901/3902` tags read Good | +| `cnc_rdtimer` | ✅ validated | 8-byte {minute, msec} payload is **little-endian** (unlike the big-endian envelope) | +| `pmc_rdpmcrng` | ✅ validated | request range must be widened to the data-type byte width (`end = start + width - 1`) | +| `cnc_rdsvmeter` | ✅ validated | per-axis LOADELM is **8 bytes**, not 12; names come from the 0x0089 block; load *scaling* unconfirmed | +| `cnc_rdalmmsg2` | ✅ validated | read a live active alarm (`#3080`) | +| `cnc_rdparam` | ❌ **unsupported** | `EW_FUNC` across 14 request-framing variants × 4 known-present params — likely wrong v3 command id (`0x000e`) or restricted on this control; needs a reference FWLIB trace | + +Full finding + captured wire bytes + the fix per surface: +[`../plans/2026-06-25-focas-pdu-v3-30i-b-support.md`](../plans/2026-06-25-focas-pdu-v3-30i-b-support.md). diff --git a/scripts/focas/capture-initiate.py b/scripts/focas/capture-initiate.py new file mode 100644 index 00000000..07435a25 --- /dev/null +++ b/scripts/focas/capture-initiate.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Passive FOCAS/2 wire capture for protocol-version scoping. + +Sends ONLY the initiate PDU the OtOpcUa WireFocasClient sends (a read-only handshake; +NO data/write PDUs) and dumps the raw response so the real wire framing — notably the +PDU version field — can be inspected. Used to diagnose the 30i-B PDU-v3 gap; see +docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md. + +Usage: python3 scripts/focas/capture-initiate.py [port] +""" +import socket, sys + +MAGIC = bytes([0xA0, 0xA0, 0xA0, 0xA0]) + + +def build_pdu(version, type_, direction, body): + h = bytearray(MAGIC) + h += version.to_bytes(2, "big") + h += bytes([type_, direction]) + h += len(body).to_bytes(2, "big") + return bytes(h) + body + + +def hexdump(b): + return " ".join(f"{x:02x}" for x in b) + + +def parse_header(b): + if len(b) < 10: + return f"(short read, {len(b)} bytes)" + return (f"magic={hexdump(b[0:4])} version={int.from_bytes(b[4:6],'big')} " + f"type=0x{b[6]:02x} dir=0x{b[7]:02x} bodyLen={int.from_bytes(b[8:10],'big')}") + + +def try_initiate(host, port, version, socket_index): + print(f"\n=== initiate header version={version}, socketIndex={socket_index} ===") + pdu = build_pdu(version, 0x01, 0x01, socket_index.to_bytes(2, "big")) + print(f" -> {len(pdu)} bytes: {hexdump(pdu)}") + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(6) + try: + s.connect((host, port)) + s.sendall(pdu) + resp = b"" + try: + while len(resp) < 512: + chunk = s.recv(512 - len(resp)) + if not chunk: + break + resp += chunk + if len(resp) >= 10 and len(resp) >= 10 + int.from_bytes(resp[8:10], "big"): + break + except socket.timeout: + pass + print(f" <- {len(resp)} bytes: {hexdump(resp)}") + print(f" <- header: {parse_header(resp)}") + if len(resp) > 10: + print(f" <- body: {hexdump(resp[10:])}") + except Exception as e: + print(f" !! {type(e).__name__}: {e}") + finally: + s.close() + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(__doc__) + sys.exit(2) + host = sys.argv[1] + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8193 + try_initiate(host, port, 1, 1) # what OtOpcUa sends today (header version=1) + try_initiate(host, port, 3, 1) # probe whether the CNC negotiates on our advertised version diff --git a/scripts/focas/capture-v3.py b/scripts/focas/capture-v3.py new file mode 100644 index 00000000..c4aa3022 --- /dev/null +++ b/scripts/focas/capture-v3.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""FOCAS/2 v3 data-PDU capture for offline parser work. + +Replays exactly the connect sequence ``FocasWireClient.ConnectCoreAsync`` performs +(two-socket initiate, then the ``cnc_sysinfo`` 0x0018 + ``0x000e``/0x26f0 setup +requests on socket 2), then issues each target data request on socket 2 and dumps the +RAW v3 response PDU bytes. Read-only: NOT a single write/operate PDU is sent. + +Every socket read is timeout-bounded, so a command that "hangs" the C# client +(``cnc_rdsvmeter`` on v3) shows up here as a recorded partial/timeout rather than an +infinite block. Captured ``.bin`` files become offline unit-test fixtures. + +The request framing here mirrors ``Wire/FocasWireProtocol.cs`` / +``Wire/FocasWireClient.cs`` byte-for-byte: + PDU = a0 a0 a0 a0 | ver(u16) | type | dir | bodyLen(u16) | body + data body = blockCount(u16) | block... + block = blockLen(u16) | reqClass(u16) | pathId(u16) | cmd(u16) + | arg1(i32) | arg2(i32) | arg3(i32) | arg4(i32) | arg5(u16) | extraLen(u16) | extra +We EMIT version=1 (the CNC accepts v1 requests) and ACCEPT whatever version it answers. + +Usage: python3 scripts/focas/capture-v3.py [port] [--out DIR] +See: docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md + docs/plans/2026-06-25-focas-pdu-v3-implementation-plan.md (Phase 1) +""" +import os +import socket +import sys + +MAGIC = bytes([0xA0, 0xA0, 0xA0, 0xA0]) +EMIT_VERSION = 1 # what the WireFocasClient puts in request headers +TYPE_INITIATE, TYPE_DATA = 0x01, 0x21 +DIR_REQUEST = 0x01 +READ_TIMEOUT = 6.0 # seconds; a hang becomes a recorded timeout + + +def hexdump(b): + return " ".join(f"{x:02x}" for x in b) + + +def build_pdu(version, type_, direction, body): + h = bytearray(MAGIC) + h += version.to_bytes(2, "big") + h += bytes([type_, direction]) + h += len(body).to_bytes(2, "big") + return bytes(h) + body + + +def build_block(cmd, a1=0, a2=0, a3=0, a4=0, a5=0, req_class=1, path_id=1, extra=b""): + blk = bytearray() + blk += (0x1C + len(extra)).to_bytes(2, "big") + blk += req_class.to_bytes(2, "big") + blk += path_id.to_bytes(2, "big") + blk += cmd.to_bytes(2, "big") + for a in (a1, a2, a3, a4): + blk += int(a).to_bytes(4, "big", signed=True) + blk += (a5 & 0xFFFF).to_bytes(2, "big") + blk += len(extra).to_bytes(2, "big") + blk += extra + return bytes(blk) + + +def build_data_pdu(blocks): + body = bytearray(len(blocks).to_bytes(2, "big")) + for b in blocks: + body += b + return build_pdu(EMIT_VERSION, TYPE_DATA, DIR_REQUEST, bytes(body)) + + +def recv_exactly(sock, n): + """Read exactly n bytes; returns (data, complete). Stops early on timeout/close.""" + buf = b"" + while len(buf) < n: + try: + chunk = sock.recv(n - len(buf)) + except socket.timeout: + return buf, False + if not chunk: + return buf, False + buf += chunk + return buf, True + + +def read_pdu(sock): + """Read one full response PDU. Returns dict with header fields + raw bytes.""" + header, ok = recv_exactly(sock, 10) + if not ok or len(header) < 10: + return {"ok": False, "raw": header, "note": f"short/timeout header ({len(header)} bytes)"} + version = int.from_bytes(header[4:6], "big") + body_len = int.from_bytes(header[8:10], "big") + body, body_ok = recv_exactly(sock, body_len) + return { + "ok": body_ok, + "version": version, + "type": header[6], + "dir": header[7], + "body_len": body_len, + "body": body, + "raw": header + body, + "note": "" if body_ok else f"body short/timeout: got {len(body)}/{body_len}", + } + + +def loose_parse_blocks(body): + """Best-effort v1-layout block walk so we can eyeball cmd/rc/payloadLen on v3. + + Returns a list of human-readable lines; never raises (v3 structural drift just + yields a 'parse aborted' note while the raw bytes remain authoritative). + """ + out = [] + try: + if len(body) < 2: + return ["(body < 2 bytes; no block count)"] + count = int.from_bytes(body[0:2], "big") + out.append(f"blockCount={count}") + off = 2 + for i in range(count): + if off + 16 > len(body): + out.append(f" block[{i}] truncated at off={off}") + break + blk_len = int.from_bytes(body[off:off + 2], "big") + cmd = int.from_bytes(body[off + 6:off + 8], "big") + rc = int.from_bytes(body[off + 8:off + 10], "big", signed=True) + payload_len = int.from_bytes(body[off + 14:off + 16], "big") + payload = body[off + 16:off + 16 + payload_len] + out.append( + f" block[{i}] blkLen={blk_len} cmd=0x{cmd:04x} rc={rc} " + f"payloadLen={payload_len} payload={hexdump(payload) or '(empty)'}") + if blk_len < 0x10: + out.append(" !! blkLen < 0x10 — aborting walk (v3 layout differs)") + break + off += blk_len + except Exception as e: # noqa: BLE001 - capture tool, never crash on parse + out.append(f" !! loose parse aborted: {type(e).__name__}: {e}") + return out + + +class Session: + def __init__(self, host, port, log): + self.host, self.port, self.log = host, port, log + self.s1 = self.s2 = None + + def _connect_socket(self): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + s.settimeout(READ_TIMEOUT) + s.connect((self.host, self.port)) + return s + + def connect(self): + self.log("=== two-socket initiate handshake ===") + self.s1 = self._connect_socket() + self.s1.sendall(build_pdu(EMIT_VERSION, TYPE_INITIATE, DIR_REQUEST, (1).to_bytes(2, "big"))) + r1 = read_pdu(self.s1) + self.log(f" socket1 initiate <- v={r1.get('version')} bodyLen={r1.get('body_len')} ok={r1['ok']}") + + self.s2 = self._connect_socket() + self.s2.sendall(build_pdu(EMIT_VERSION, TYPE_INITIATE, DIR_REQUEST, (2).to_bytes(2, "big"))) + r2 = read_pdu(self.s2) + self.log(f" socket2 initiate <- v={r2.get('version')} bodyLen={r2.get('body_len')} ok={r2['ok']}") + + # mirror ConnectCoreAsync: sysinfo (cached) then the 0x000e/0x26f0 setup request + self.request("setup-sysinfo-0x0018", [build_block(0x0018, path_id=1)], save=False) + self.request("setup-0x000e-26f0", [build_block(0x000E, 0x26F0, 0x26F0, path_id=1)], save=False) + return r1, r2 + + def request(self, label, blocks, save=True): + pdu = build_data_pdu(blocks) + self.log(f"\n--- {label} ---") + self.log(f" -> {len(pdu)}B req: {hexdump(pdu)}") + try: + self.s2.sendall(pdu) + resp = read_pdu(self.s2) + except Exception as e: # noqa: BLE001 + self.log(f" !! send/read failed: {type(e).__name__}: {e}") + return None + self.log(f" <- header: version={resp.get('version')} type=0x{resp.get('type', 0):02x} " + f"dir=0x{resp.get('dir', 0):02x} bodyLen={resp.get('body_len')} complete={resp['ok']}") + if resp.get("note"): + self.log(f" <- NOTE: {resp['note']}") + self.log(f" <- raw ({len(resp['raw'])}B): {hexdump(resp['raw'])}") + if resp.get("body"): + for line in loose_parse_blocks(resp["body"]): + self.log(" " + line) + if save and OUT_DIR and resp.get("raw"): + path = os.path.join(OUT_DIR, f"{label}.bin") + with open(path, "wb") as f: + f.write(resp["raw"]) + return resp + + def close(self): + for s in (self.s2, self.s1): + try: + if s: + s.close() + except Exception: # noqa: BLE001 + pass + + +# (label, [blocks]) — references first (known-good on v3), then the failing targets. +def target_specs(): + return [ + # ---- known-good references (calibrate the v3 block envelope) ---- + ("ref-sysinfo-0x0018", [build_block(0x0018, path_id=1)]), + ("ref-axisname-0x0089", [build_block(0x0089, path_id=1)]), + ("ref-spdlname-0x008a", [build_block(0x008A, path_id=1)]), + ("ref-macro-3901-0x0015", [build_block(0x0015, 3901, 3901, path_id=1)]), + ("ref-macro-500-0x0015", [build_block(0x0015, 500, 500, path_id=1)]), + ("ref-opmode-0x0057", [build_block(0x0057, path_id=1)]), + ("ref-exeprgname-0x00fc", [build_block(0x00FC, path_id=1)]), + ("ref-statinfo", [ + build_block(0x0019, path_id=1), + build_block(0x00E1, path_id=1), + build_block(0x0098, path_id=1), + ]), + # cnc_rddynamic2 axis 1 — the full 9-block bundle the client sends (known-good) + ("ref-dynamic2-axis1", [ + build_block(0x001A, path_id=1), + build_block(0x001C, path_id=1), + build_block(0x001D, path_id=1), + build_block(0x0024, path_id=1), + build_block(0x0025, path_id=1), + build_block(0x0026, 4, 1, path_id=1), + build_block(0x0026, 1, 1, path_id=1), + build_block(0x0026, 6, 1, path_id=1), + build_block(0x0026, 7, 1, path_id=1), + ]), + # ---- failing / target commands ---- + ("timer-poweron-0x0120-t0", [build_block(0x0120, 0, path_id=1)]), + ("timer-operating-0x0120-t1", [build_block(0x0120, 1, path_id=1)]), + ("timer-cutting-0x0120-t2", [build_block(0x0120, 2, path_id=1)]), + ("timer-cycle-0x0120-t3", [build_block(0x0120, 3, path_id=1)]), + # cnc_rdsvmeter — the C# client sends 0x0056(arg1=1) + 0x0089 together (this HANGS). + ("svmeter-pair-0x0056-0x0089", [ + build_block(0x0056, 1, path_id=1), + build_block(0x0089, path_id=1), + ]), + # and 0x0056 alone, to isolate which block stalls + ("svmeter-alone-0x0056", [build_block(0x0056, 1, path_id=1)]), + # pmc_rdpmcrng R100 (area R=5, dataType Word=1, reqClass=2) — BadOutOfRange + ("pmcrng-R100-0x8001", [build_block(0x8001, 100, 100, 5, 1, req_class=2, path_id=1)]), + ("pmcrng-R0-0x8001", [build_block(0x8001, 0, 0, 5, 1, req_class=2, path_id=1)]), + # cnc_rdparam 1320 (axis 0 -> arg2=dataNumber) — BadNotSupported + ("param-1320-0x000e", [build_block(0x000E, 1320, 1320, path_id=1)]), + ("param-100-0x000e", [build_block(0x000E, 100, 100, path_id=1)]), + # cnc_rdalmmsg2 (type=-1 all, count=32, arg3=2, arg4=0x40) — untested + ("alarms-0x0023", [build_block(0x0023, -1, 32, 2, 0x40, path_id=1)]), + ] + + +OUT_DIR = None + + +def main(): + global OUT_DIR + argv = [a for a in sys.argv[1:] if a] + if not argv: + print(__doc__) + sys.exit(2) + host = argv[0] + port = 8193 + rest = argv[1:] + while rest: + a = rest.pop(0) + if a == "--out": + OUT_DIR = rest.pop(0) + else: + port = int(a) + if OUT_DIR: + os.makedirs(OUT_DIR, exist_ok=True) + + lines = [] + + def log(msg): + print(msg) + lines.append(msg) + + log(f"# FOCAS v3 capture {host}:{port} (emit version={EMIT_VERSION})") + sess = Session(host, port, log) + try: + sess.connect() + for label, blocks in target_specs(): + sess.request(label, blocks) + finally: + sess.close() + + if OUT_DIR: + with open(os.path.join(OUT_DIR, "capture-log.txt"), "w") as f: + f.write("\n".join(lines) + "\n") + log(f"\n# wrote .bin fixtures + capture-log.txt to {OUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/scripts/focas/param-probe.py b/scripts/focas/param-probe.py new file mode 100644 index 00000000..5d6de8a5 --- /dev/null +++ b/scripts/focas/param-probe.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Exploratory cnc_rdparam (0x000e) v3 request-framing probe against a live FANUC. + +The committed WireFocasClient sends arg1=number, arg2=number(when axis 0), arg3=0 and +gets EW_FUNC(1) for a VALID parameter on the 31i-B. cnc_rdparam(h, number, axis, length, +&IODBPSD) needs axis + length, so this tries a matrix of (arg ordering, axis, length, +reqClass, extra) and prints which combos return rc=0 with a non-empty payload. + +Read-only. Usage: python3 param-probe.py [port] +""" +import socket +import sys + +MAGIC = bytes([0xA0, 0xA0, 0xA0, 0xA0]) +EMIT_VERSION = 1 + + +def build_pdu(version, type_, direction, body): + h = bytearray(MAGIC) + version.to_bytes(2, "big") + bytes([type_, direction]) + len(body).to_bytes(2, "big") + return bytes(h) + body + + +def build_block(cmd, a1=0, a2=0, a3=0, a4=0, a5=0, req_class=1, path_id=1, extra=b""): + blk = bytearray() + blk += (0x1C + len(extra)).to_bytes(2, "big") + blk += req_class.to_bytes(2, "big") + blk += path_id.to_bytes(2, "big") + blk += cmd.to_bytes(2, "big") + for a in (a1, a2, a3, a4): + blk += int(a).to_bytes(4, "big", signed=True) + blk += (a5 & 0xFFFF).to_bytes(2, "big") + blk += len(extra).to_bytes(2, "big") + blk += extra + return bytes(blk) + + +def data_pdu(blocks): + body = bytearray(len(blocks).to_bytes(2, "big")) + for b in blocks: + body += b + return build_pdu(EMIT_VERSION, 0x21, 0x01, bytes(body)) + + +def recv_exactly(sock, n): + buf = b"" + while len(buf) < n: + try: + chunk = sock.recv(n - len(buf)) + except socket.timeout: + return buf, False + if not chunk: + return buf, False + buf += chunk + return buf, True + + +def read_pdu(sock): + header, ok = recv_exactly(sock, 10) + if not ok or len(header) < 10: + return None + body_len = int.from_bytes(header[8:10], "big") + body, _ = recv_exactly(sock, body_len) + return body + + +def parse_first_block(body): + """Return (cmd, rc, payload_hex) of the first response block, or None.""" + if not body or len(body) < 2: + return None + count = int.from_bytes(body[0:2], "big") + if count == 0 or len(body) < 18: + return (None, None, "") + blk_len = int.from_bytes(body[2:4], "big") + cmd = int.from_bytes(body[8:10], "big") + rc = int.from_bytes(body[10:12], "big", signed=True) + payload_len = int.from_bytes(body[16:18], "big") + payload = body[18:18 + payload_len] + return (cmd, rc, " ".join(f"{x:02x}" for x in payload) or "(empty)") + + +class Sess: + def __init__(self, host, port): + self.host, self.port = host, port + self.s1 = self.s2 = None + + def _sock(self): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + s.settimeout(6) + s.connect((self.host, self.port)) + return s + + def connect(self): + self.s1 = self._sock() + self.s1.sendall(build_pdu(EMIT_VERSION, 0x01, 0x01, (1).to_bytes(2, "big"))) + read_pdu(self.s1) + self.s2 = self._sock() + self.s2.sendall(build_pdu(EMIT_VERSION, 0x01, 0x01, (2).to_bytes(2, "big"))) + read_pdu(self.s2) + # mirror the client's setup requests + self.req([build_block(0x0018, path_id=1)]) + self.req([build_block(0x000E, 0x26F0, 0x26F0, path_id=1)]) + + def req(self, blocks): + self.s2.sendall(data_pdu(blocks)) + return read_pdu(self.s2) + + def close(self): + for s in (self.s2, self.s1): + try: + s and s.close() + except Exception: + pass + + +def main(): + host = sys.argv[1] if len(sys.argv) > 1 else "10.201.31.5" + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8193 + # 8130 = total controlled axes (global, always present); 1320 = +stroke limit (axis); + # 1825 = servo loop gain (axis); 3201 = setting. All exist on a 31i. + params = [8130, 1320, 1825, 3201] + # (label, builder(P)) — each builder returns a single request block for param P + variants = [ + ("cur arg1=P,arg2=P,a3=0,rc1", lambda P: build_block(0x000E, P, P, 0, 0, req_class=1)), + ("arg2=0(axis),a3=0,rc1", lambda P: build_block(0x000E, P, 0, 0, 0, req_class=1)), + ("arg2=0,a3=8(len),rc1", lambda P: build_block(0x000E, P, 0, 8, 0, req_class=1)), + ("arg2=0,a3=36(len),rc1", lambda P: build_block(0x000E, P, 0, 36, 0, req_class=1)), + ("arg2=0,a3=4(len),rc1", lambda P: build_block(0x000E, P, 0, 4, 0, req_class=1)), + ("arg2=-1(allaxis),a3=8,rc1", lambda P: build_block(0x000E, P, -1, 8, 0, req_class=1)), + ("arg2=1(axis1),a3=8,rc1", lambda P: build_block(0x000E, P, 1, 8, 0, req_class=1)), + ("arg2=0,a3=8,rc2", lambda P: build_block(0x000E, P, 0, 8, 0, req_class=2)), + ("arg2=0,a3=36,rc2", lambda P: build_block(0x000E, P, 0, 36, 0, req_class=2)), + ("len-first arg1=8,arg2=P,rc1", lambda P: build_block(0x000E, 8, P, 0, 0, req_class=1)), + ("len-first arg1=36,arg2=P,rc1", lambda P: build_block(0x000E, 36, P, 0, 0, req_class=1)), + ("arg2=0,a3=0,a4=8,rc1", lambda P: build_block(0x000E, P, 0, 0, 8, req_class=1)), + ("arg2=0,a5=8,rc1", lambda P: build_block(0x000E, P, 0, 0, 0, a5=8, req_class=1)), + ("extra: datano+type+len(8)", lambda P: build_block(0x000E, P, 0, 8, 0, req_class=1, + extra=P.to_bytes(2, "big") + b"\x00\x00" + (8).to_bytes(2, "big"))), + ] + + sess = Sess(host, port) + sess.connect() + print(f"# cnc_rdparam v3 probe {host}:{port}\n") + hits = [] + try: + for P in params: + print(f"--- param {P} ---") + for label, build in variants: + body = sess.req([build(P)]) + parsed = parse_first_block(body) + if parsed is None: + print(f" {label:32s} -> (no/short response)") + continue + cmd, rc, payload = parsed + mark = " <== HIT" if rc == 0 and payload not in ("", "(empty)") else "" + print(f" {label:32s} -> rc={rc} payload={payload}{mark}") + if mark: + hits.append((P, label, payload)) + print() + finally: + sess.close() + + print("# HITS (rc=0, non-empty payload):") + for P, label, payload in hits: + print(f" param {P}: {label} -> {payload}") + if not hits: + print(" (none — cnc_rdparam may be genuinely restricted on this control)") + + +if __name__ == "__main__": + main() diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs index f04ee0aa..c83d87bd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs @@ -1,9 +1,9 @@ using System.Diagnostics; using System.Net.Sockets; -using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; @@ -11,35 +11,29 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// Two-phase Test-Connect probe for the -shaped driver config. /// Phase 1: bare TCP connect to the first device's FOCAS Ethernet address + port to quickly /// reject unreachable targets (preserves the original "Connect failed" / "timed out" -/// messages). Phase 2: attempts the FANUC FWLIB handle handshake — allocates a CNC handle via -/// cnc_allclibhndl3(host, port, timeoutSec, out handle) and immediately frees it with -/// cnc_freelibhndl. A handle that allocates (EW_OK) confirms the remote endpoint -/// is a real FOCAS CNC, not just a TCP listener. +/// messages). Phase 2: a real FOCAS session via the managed — the +/// two-socket initiate handshake plus one sample read (cnc_statinfo). A handshake + +/// read that succeeds confirms the remote endpoint is a real FOCAS CNC, not just a TCP +/// listener. /// -/// The P/Invoke is issued directly (it does NOT route through -/// , whose EnsureUsable() throws by -/// design) so the handshake works on a real Windows+FWLIB host and degrades everywhere else. -/// The synchronous native call can block, so it runs on a worker bounded by a linked CTS -/// (ct + CancelAfter(timeout)) — the probe always returns within the timeout -/// budget even if FWLIB hangs. +/// Why a wire-client probe (not FWLIB). The pure-managed wire client is the driver's +/// only read backend (the FWLIB / out-of-process paths were retired in the Wire migration), so +/// the probe must exercise the same path the driver actually uses. The previous probe issued +/// the cnc_allclibhndl3 FWLIB P/Invoke and, on any host without the native library (the +/// normal case — macOS dev boxes, Linux CI, and the Windows hosts that run the managed client), +/// degraded to Ok=true "TCP reachability only". That made every bare TCP listener look +/// HEALTHY — exactly how a Makino 31i-B looked "healthy" while no FOCAS data flowed. The wire +/// probe reports HEALTHY only on a genuine FOCAS session + read. See +/// docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md (Phase 8). /// /// -/// Degrade guard (the crux). On a host without the FWLIB native library — this dev box -/// (macOS) and the Linux CI containers — the cnc_allclibhndl3 P/Invoke fails to bind -/// and throws (or a related load failure: -/// , , -/// , ). Those are -/// caught and the probe falls back to Ok=true with a "FWLIB absent — TCP reachability -/// only" note, so the probe is never worse than the original TCP-only behaviour on FWLIB-less -/// hosts. The happy path and the FWLIB-present CNC-error path are live-verify deferred (no CNC -/// and no FWLIB on the rig). +/// The wire client honours the linked CTS (ct + CancelAfter(timeout)) and its +/// reads are abort-bounded (see ), so the probe always returns +/// within the timeout budget even against a host that accepts TCP then stalls. /// /// public sealed class FocasDriverProbe : IDriverProbe { - /// FANUC FWLIB return code for success (EW_OK). - private const short EwOk = 0; - private static readonly JsonSerializerOptions _opts = new() { PropertyNameCaseInsensitive = true, @@ -83,75 +77,32 @@ public sealed class FocasDriverProbe : IDriverProbe return new(false, ex.Message, null); } - // Phase 2: FOCAS handle handshake via cnc_allclibhndl3. The native call is synchronous and - // can block, so run it on a worker bounded by a linked CTS = ct + CancelAfter(timeout). - using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + // Phase 2: real FOCAS session via the managed wire client — initiate handshake + one + // sample read. Bounded by a linked CTS = ct + CancelAfter(budget); the wire reads are + // abort-bounded so a TCP-accept-then-stall host can't hold the probe past the budget. + using var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var budget = timeout > TimeSpan.Zero ? timeout : TimeSpan.FromSeconds(1); - handshakeCts.CancelAfter(budget); + sessionCts.CancelAfter(budget); try { - var (degraded, rc) = await Task.Run( - () => TryAllocateAndFreeHandle(host, port, budget), - handshakeCts.Token); - + await using var wire = new FocasWireClient(); + await wire.ConnectAsync(host, port, budget, sessionCts.Token).ConfigureAwait(false); + var status = await wire.ReadStatusAsync(sessionCts.Token, budget).ConfigureAwait(false); sw.Stop(); - if (degraded) - { - // FWLIB absent / cannot load — never worse than the original TCP-only probe. - return new( - true, - $"Reachable at {host}:{port} (FOCAS handshake unavailable on this host — " + - "FWLIB absent, TCP reachability only)", - sw.Elapsed); - } - - if (rc == EwOk) - return new(true, "FOCAS handle OK", sw.Elapsed); - - // FWLIB present but the remote returned an error — reachable TCP but not a CNC. - return new(false, $"Reachable at {host}:{port} but FOCAS handshake failed: focas_rc={rc}", null); + return status.IsOk + ? new(true, $"FOCAS session OK at {host}:{port} (cnc_statinfo)", sw.Elapsed) + : new(false, $"Reachable at {host}:{port} but FOCAS read failed: EW_{status.Rc}", null); } catch (OperationCanceledException) { - // The caller cancelled, or the Task.Run was cancelled before the native call started. - // (A native cnc_allclibhndl3 that is already running is bounded by the timeoutSeconds - // argument passed into it, not by handshakeCts — see TryAllocateAndFreeHandle.) return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); } - } - - /// - /// Attempts the FWLIB handle handshake against /. - /// On success the handle is freed immediately. Returns degraded=true when the native - /// library cannot be loaded (FWLIB absent — the dev/CI reality); otherwise - /// degraded=false with the FWLIB return code (EW_OK = handle allocated). - /// - private static (bool degraded, short rc) TryAllocateAndFreeHandle(string host, int port, TimeSpan timeout) - { - var timeoutSeconds = (int)Math.Ceiling(timeout.TotalSeconds); - if (timeoutSeconds <= 0) timeoutSeconds = 1; - - ushort handle = 0; - try + catch (FocasWireException ex) { - var rc = NativeFwlib.cnc_allclibhndl3(host, (ushort)port, timeoutSeconds, out handle); - return (degraded: false, rc); - } - catch (DllNotFoundException) { return (degraded: true, rc: default); } - catch (TypeInitializationException) { return (degraded: true, rc: default); } - catch (NotSupportedException) { return (degraded: true, rc: default); } - catch (BadImageFormatException) { return (degraded: true, rc: default); } - catch (EntryPointNotFoundException) { return (degraded: true, rc: default); } - finally - { - // Best-effort free if a handle was actually allocated (incl. after a timeout race). - if (handle != 0) - { - try { NativeFwlib.cnc_freelibhndl(handle); } - catch { /* best-effort — never let teardown hide the probe result */ } - } + // TCP-reachable but the FOCAS initiate/read failed — a listener that is not a CNC. + return new(false, $"Reachable at {host}:{port} but FOCAS session failed: {ex.Message}", null); } } @@ -166,28 +117,4 @@ public sealed class FocasDriverProbe : IDriverProbe return (parsed.Host, parsed.Port); } - - /// - /// Minimal P/Invoke surface for the two FANUC FWLIB entry points the probe needs: - /// cnc_allclibhndl3 to allocate a CNC handle against a host/port, and - /// cnc_freelibhndl to release it. The native library (fwlib32.dll / - /// fwlib64.dll on Windows, libfwlib32.so on Linux) is only present on a host - /// with the FANUC FWLIB redistributable installed. On every other host the JIT fails to - /// bind these entry points and throws — caught by the - /// probe's degrade guard. - /// - private static class NativeFwlib - { - private const string Library = "fwlib32"; - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] - internal static extern short cnc_allclibhndl3( - [MarshalAs(UnmanagedType.LPStr)] string ipaddr, - ushort port, - int timeout, - out ushort handle); - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern short cnc_freelibhndl(ushort handle); - } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs index 66a40701..9aa27033 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs @@ -369,19 +369,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable var rc = AggregateRc(blocks); if (rc != 0) return new FocasResult>(rc, null); - var payload = FindPayload(blocks, 0x0056); - var result = new List(); - for (var offset = 0; offset + 12 <= payload.Length && result.Count < maxCount; offset += 12) - { - var name = FocasWireProtocol.ReadNameRecord(payload.AsSpan(offset + 8, 4)); - result.Add(new WireServoMeter( - (short)(result.Count + 1), - name, - ReadInt32(payload, offset), - ReadInt16(payload, offset + 4), - ReadInt16(payload, offset + 6))); - } - + var result = ParseServoMeters(FindPayload(blocks, 0x0056), FindPayload(blocks, 0x0089), maxCount); return new FocasResult>(rc, result); } @@ -602,31 +590,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable callTimeout.Token, new RequestBlock(0x8001, start, end, area, dataType, RequestClass: 2, PathId: EffectivePathId(pathId))).ConfigureAwait(false); - return ToResult(block, payload => - { - var width = dataType switch - { - 1 => 2, - 2 or 4 => 4, - 5 => 8, - _ => 1, - }; - - var values = new List(); - for (var offset = 0; offset + width <= payload.Length; offset += width) - { - values.Add(width switch - { - 1 => payload[offset], - 2 => ReadInt16(payload, offset), - 4 => ReadInt32(payload, offset), - 8 => BinaryPrimitives.ReadInt64BigEndian(payload.AsSpan(offset, 8)), - _ => 0, - }); - } - - return new WirePmcRange(area, dataType, start, end, values); - }); + return ToResult(block, payload => ParsePmcRange(area, dataType, start, end, payload)); } /// Typed overload for . @@ -740,7 +704,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable ushort? pathId = null) => ReadSingleWithTimeoutAsync( 0x0120, - payload => new WireTimer(type, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 8 ? ReadInt32(payload, 4) : 0), + payload => ParseTimer(type, payload), cancellationToken, timeout, EffectivePathId(pathId), type); // ---- internal plumbing ------------------------------------------------------------ @@ -922,6 +886,88 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable private static short AggregateRc(IReadOnlyList blocks) => blocks.FirstOrDefault(block => block.Rc != 0)?.Rc ?? 0; + /// + /// Decode a cnc_rdtimer (0x0120) payload into . The 8-byte + /// data block is two 32-bit fields {minute, msec}, and they are little-endian on the + /// wire — unlike the big-endian block envelope and every other payload (sysinfo / dynamic / + /// macro). Validated against a live FANUC 31i-B (2026-06-25): the msec field only falls in + /// valid range (0..59999) under little-endian; big-endian decoded it as ~2.4e9. Captured + /// cutting-time payload ac f2 10 00 90 a3 00 00 → minute=1110188, msec=41872. See + /// docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md. + /// + internal static WireTimer ParseTimer(short type, byte[] payload) => new( + type, + payload.Length >= 4 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(0, 4)) : 0, + payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0); + + /// + /// Decode a cnc_rdsvmeter response into records. On the + /// 31i-B (v3) each per-axis LOADELM is 8 bytes — {int32 data; int16 dec; int16 unit} + /// — NOT the 12-byte shape the original parser assumed. The 12-byte stride misaligned after + /// the first record (it read the dec/unit shorts of the prior record as the next record's + /// data → wild values like 655360). Axis NAMES are not in the svmeter payload; they come + /// from the cnc_rdaxisname (0x0089) block requested alongside it and are correlated + /// by index. Validated against a live 31i-B 2026-06-25. + /// Scaling caveat: downstream applies LoadPercent = data / 10^dec; on the 31i-B + /// dec read as 10, which makes idle loads vanishingly small. The data/dec/unit field + /// semantics for servo load are inferred from the wire and not yet confirmed against the + /// machine's servo-meter screen — confirm magnitude at commissioning. The alignment + name + /// fix here is what removes the gross misaligned garbage. + /// + internal static IReadOnlyList ParseServoMeters(byte[] svPayload, byte[] axisNamePayload, int maxCount) + { + var result = new List(); + for (var offset = 0; offset + 8 <= svPayload.Length && result.Count < maxCount; offset += 8) + { + var index = result.Count; + var nameOffset = index * 4; + var name = nameOffset + 4 <= axisNamePayload.Length + ? FocasWireProtocol.ReadNameRecord(axisNamePayload.AsSpan(nameOffset, 4)) + : string.Empty; + result.Add(new WireServoMeter( + (short)(index + 1), + name, + ReadInt32(svPayload, offset), + ReadInt16(svPayload, offset + 4), + ReadInt16(svPayload, offset + 6))); + } + return result; + } + + /// Byte width of one PMC slot for a FOCAS data-type code: Byte=1, Word=2, Long/Real=4, Double=8. + internal static int PmcByteWidth(short dataType) => dataType switch + { + 1 => 2, + 2 or 4 => 4, + 5 => 8, + _ => 1, + }; + + /// + /// Decode a pmc_rdpmcrng payload into . The CNC returns + /// (end-start+1) BYTES; this slices them into width-sized big-endian slots. Callers must + /// size the request range to width bytes per value (see + /// WireFocasClient.ReadPmcAsync) or a trailing partial slot is dropped — which on the + /// 31i-B surfaced as a spurious BadOutOfRange for a single Word read. 2026-06-25. + /// + internal static WirePmcRange ParsePmcRange(short area, short dataType, ushort start, ushort end, byte[] payload) + { + var width = PmcByteWidth(dataType); + var values = new List(); + for (var offset = 0; offset + width <= payload.Length; offset += width) + { + values.Add(width switch + { + 1 => payload[offset], + 2 => ReadInt16(payload, offset), + 4 => ReadInt32(payload, offset), + 8 => BinaryPrimitives.ReadInt64BigEndian(payload.AsSpan(offset, 8)), + _ => 0, + }); + } + return new WirePmcRange(area, dataType, start, end, values); + } + private static byte[] FindPayload(IReadOnlyList blocks, ushort command) => blocks.FirstOrDefault(block => block.Command == command)?.Payload ?? Array.Empty(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs index 9bf8e880..502a1a48 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs @@ -19,7 +19,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; /// internal static class FocasWireProtocol { + /// The PDU version this client emits in every outgoing request header. public const ushort Version = 1; + + /// + /// PDU versions accepted on inbound PDUs. The 10-byte header framing is identical across + /// these (only the version field differs), so the framing layer accepts both while we keep + /// emitting (v1) on requests. The docker mock + older controls answer + /// v1; modern controls answer v3 — FANUC 30i-B validated live 2026-06-25 (macro reads OK). + /// See docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md. + /// + private static readonly ushort[] SupportedReadVersions = [1, 3]; + + /// True when is a PDU version this client can frame-parse. + internal static bool IsSupportedReadVersion(ushort version) => + Array.IndexOf(SupportedReadVersions, version) >= 0; + public const byte DirectionRequest = 0x01; public const byte DirectionResponse = 0x02; public const byte TypeInitiate = 0x01; @@ -99,7 +114,7 @@ internal static class FocasWireProtocol throw new FocasWireException("Invalid FOCAS PDU magic."); var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2)); - if (version != Version) + if (!IsSupportedReadVersion(version)) throw new FocasWireException($"Unsupported FOCAS PDU version {version}."); var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2)); @@ -122,7 +137,7 @@ internal static class FocasWireProtocol throw new FocasWireException("Invalid FOCAS PDU magic."); var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2)); - if (version != Version) + if (!IsSupportedReadVersion(version)) throw new FocasWireException($"Unsupported FOCAS PDU version {version}."); var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2)); @@ -135,13 +150,29 @@ internal static class FocasWireProtocol private static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, CancellationToken cancellationToken) { + // NetworkStream.ReadAsync's CancellationToken does not reliably abort a socket read that is + // blocked waiting for bytes the peer never sends — a CNC that TCP-accepts then stalls + // mid-PDU (the cnc_rdsvmeter "hang" the 31i-B work chased). Register a hard abort that + // disposes the stream on cancellation so a stalled read throws instead of wedging the + // caller's poll loop, and normalize the resulting failure to OperationCanceledException so + // the request path tears the transport down as a transient. See + // docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md (Phase 2). + await using var abort = cancellationToken.Register(static s => ((IDisposable)s!).Dispose(), stream); var offset = 0; - while (offset < buffer.Length) + try { - var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, cancellationToken).ConfigureAwait(false); - if (read == 0) - throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read."); - offset += read; + while (offset < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken).ConfigureAwait(false); + if (read == 0) + throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read."); + offset += read; + } + } + catch (Exception ex) when (cancellationToken.IsCancellationRequested && ex is not OperationCanceledException) + { + // The stalled read was aborted by the dispose-on-cancel registration above. + throw new OperationCanceledException(cancellationToken); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs index 666d8450..5138ca1b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs @@ -196,6 +196,11 @@ public sealed class WireFocasClient : IFocasClient /// The dynamic snapshot of the axis. public async Task ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken) { + // FOCAS axes are 1-based; cnc_rddynamic2 with axis 0 returns EW_4 (live-confirmed on the + // 31i-B). The FixedTree poll already iterates 1..AxesCount, but enforce the contract so a + // future caller can't silently request axis 0. 2026-06-25. + if (axisIndex < 1) + throw new ArgumentOutOfRangeException(nameof(axisIndex), axisIndex, "FOCAS axis index is 1-based."); RequireConnected(); var result = await _wire.ReadDynamic2Async((short)axisIndex, cancellationToken).ConfigureAwait(false); ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk); @@ -337,7 +342,11 @@ public sealed class WireFocasClient : IFocasClient if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown); var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type); var start = (ushort)address.Number; - var end = start; + // pmc_rdpmcrng returns (end-start+1) BYTES. A multi-byte slot (Word/Long/Real/Double) needs + // the range widened to its byte width, else the value parser gets too few bytes → 0 values + // → spurious BadOutOfRange (live-confirmed on the 31i-B: a Word read with end=start returned + // a single byte). Bit/Byte stay width 1 so end==start. 2026-06-25. + var end = (ushort)(start + FocasWireClient.PmcByteWidth((short)dataType) - 1); try { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/alarms-0x0023.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/alarms-0x0023.bin new file mode 100644 index 00000000..350d2a5c Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/alarms-0x0023.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/capture-log.txt b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/capture-log.txt new file mode 100644 index 00000000..3caf088d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/capture-log.txt @@ -0,0 +1,169 @@ +# FOCAS v3 capture 10.201.31.5:8193 (emit version=1) +=== two-socket initiate handshake === + socket1 initiate <- v=3 bodyLen=360 ok=True + socket2 initiate <- v=3 bodyLen=360 ok=True + +--- setup-sysinfo-0x0018 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=36 complete=True + <- raw (46B): a0 a0 a0 a0 00 03 21 02 00 24 00 01 00 22 00 01 00 01 00 18 00 00 00 00 00 00 00 12 02 02 00 20 33 31 4d 4d 47 34 33 31 32 32 2e 30 30 37 + blockCount=1 + block[0] blkLen=34 cmd=0x0018 rc=0 payloadLen=18 payload=02 02 00 20 33 31 4d 4d 47 34 33 31 32 32 2e 30 30 37 + +--- setup-0x000e-26f0 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 0e 00 00 26 f0 00 00 26 f0 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=18 complete=True + <- raw (28B): a0 a0 a0 a0 00 03 21 02 00 12 00 01 00 10 00 01 00 01 00 0e 00 01 00 00 00 00 00 00 + blockCount=1 + block[0] blkLen=16 cmd=0x000e rc=1 payloadLen=0 payload=(empty) + +--- ref-sysinfo-0x0018 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=36 complete=True + <- raw (46B): a0 a0 a0 a0 00 03 21 02 00 24 00 01 00 22 00 01 00 01 00 18 00 00 00 00 00 00 00 12 02 02 00 20 33 31 4d 4d 47 34 33 31 32 32 2e 30 30 37 + blockCount=1 + block[0] blkLen=34 cmd=0x0018 rc=0 payloadLen=18 payload=02 02 00 20 33 31 4d 4d 47 34 33 31 32 32 2e 30 30 37 + +--- ref-axisname-0x0089 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 89 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=46 complete=True + <- raw (56B): a0 a0 a0 a0 00 03 21 02 00 2e 00 01 00 2c 00 01 00 01 00 89 00 00 00 00 00 00 00 1c 58 00 00 00 59 00 00 00 5a 00 00 00 42 00 00 00 43 00 00 00 41 41 36 00 41 41 37 00 + blockCount=1 + block[0] blkLen=44 cmd=0x0089 rc=0 payloadLen=28 payload=58 00 00 00 59 00 00 00 5a 00 00 00 42 00 00 00 43 00 00 00 41 41 36 00 41 41 37 00 + +--- ref-spdlname-0x008a --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 8a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=22 complete=True + <- raw (32B): a0 a0 a0 a0 00 03 21 02 00 16 00 01 00 14 00 01 00 01 00 8a 00 00 00 00 00 00 00 04 53 31 00 00 + blockCount=1 + block[0] blkLen=20 cmd=0x008a rc=0 payloadLen=4 payload=53 31 00 00 + +--- ref-macro-3901-0x0015 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 15 00 00 0f 3d 00 00 0f 3d 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True + <- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 00 15 00 00 00 00 00 00 00 08 00 00 00 00 00 0a 00 00 + blockCount=1 + block[0] blkLen=24 cmd=0x0015 rc=0 payloadLen=8 payload=00 00 00 00 00 0a 00 00 + +--- ref-macro-500-0x0015 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 15 00 00 01 f4 00 00 01 f4 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True + <- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 00 15 00 00 00 00 00 00 00 08 05 f5 e1 00 00 0a 00 08 + blockCount=1 + block[0] blkLen=24 cmd=0x0015 rc=0 payloadLen=8 payload=05 f5 e1 00 00 0a 00 08 + +--- ref-opmode-0x0057 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 57 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=34 complete=True + <- raw (44B): a0 a0 a0 a0 00 03 21 02 00 22 00 01 00 20 00 01 00 01 00 57 00 00 00 00 00 00 00 10 00 02 00 e1 0a 00 08 00 00 5a 00 00 00 42 00 00 + blockCount=1 + block[0] blkLen=32 cmd=0x0057 rc=0 payloadLen=16 payload=00 02 00 e1 0a 00 08 00 00 5a 00 00 00 42 00 00 + +--- ref-exeprgname-0x00fc --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 fc 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=274 complete=True + <- raw (284B): a0 a0 a0 a0 00 03 21 02 01 12 00 01 01 10 00 01 00 01 00 fc 00 00 00 00 00 00 01 00 2f 2f 43 4e 43 5f 4d 45 4d 2f 55 53 45 52 2f 4c 49 42 52 41 52 59 2f 4f 39 30 30 31 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + blockCount=1 + block[0] blkLen=272 cmd=0x00fc rc=0 payloadLen=256 payload=2f 2f 43 4e 43 5f 4d 45 4d 2f 55 53 45 52 2f 4c 49 42 52 41 52 59 2f 4f 39 30 30 31 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +--- ref-statinfo --- + -> 96B req: a0 a0 a0 a0 00 01 21 01 00 56 00 03 00 1c 00 01 00 01 00 19 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 e1 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 98 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=70 complete=True + <- raw (80B): a0 a0 a0 a0 00 03 21 02 00 46 00 03 00 1e 00 01 00 01 00 19 00 00 00 00 00 00 00 0e 00 01 00 03 00 00 00 01 00 00 00 00 00 00 00 14 00 01 00 01 00 e1 00 00 00 00 00 00 00 04 00 00 00 03 00 12 00 01 00 01 00 98 00 00 00 00 00 00 00 02 00 00 + blockCount=3 + block[0] blkLen=30 cmd=0x0019 rc=0 payloadLen=14 payload=00 01 00 03 00 00 00 01 00 00 00 00 00 00 + block[1] blkLen=20 cmd=0x00e1 rc=0 payloadLen=4 payload=00 00 00 03 + block[2] blkLen=18 cmd=0x0098 rc=0 payloadLen=2 payload=00 00 + +--- ref-dynamic2-axis1 --- + -> 264B req: a0 a0 a0 a0 00 01 21 01 00 fe 00 09 00 1c 00 01 00 01 00 1a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 1c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 1d 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 24 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 25 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 26 00 00 00 04 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 26 00 00 00 01 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 26 00 00 00 06 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 26 00 00 00 07 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=210 complete=True + <- raw (220B): a0 a0 a0 a0 00 03 21 02 00 d2 00 09 00 14 00 01 00 01 00 1a 00 00 00 00 00 00 00 04 00 00 00 00 00 18 00 01 00 01 00 1c 00 00 00 00 00 00 00 08 00 00 23 29 00 00 04 57 00 14 00 01 00 01 00 1d 00 00 00 00 00 00 00 04 00 00 00 01 00 18 00 01 00 01 00 24 00 00 00 00 00 00 00 08 00 00 00 00 00 0a 00 00 00 18 00 01 00 01 00 25 00 00 00 00 00 00 00 08 00 00 00 00 00 0a 00 00 00 18 00 01 00 01 00 26 00 00 00 00 00 00 00 08 00 2a bf a6 00 0a 00 04 00 18 00 01 00 01 00 26 00 00 00 00 00 00 00 08 00 2a be 30 00 0a 00 04 00 18 00 01 00 01 00 26 00 00 00 00 00 00 00 08 00 00 00 20 00 0a 00 04 00 18 00 01 00 01 00 26 00 00 00 00 00 00 00 08 00 00 00 00 00 0a 00 04 + blockCount=9 + block[0] blkLen=20 cmd=0x001a rc=0 payloadLen=4 payload=00 00 00 00 + block[1] blkLen=24 cmd=0x001c rc=0 payloadLen=8 payload=00 00 23 29 00 00 04 57 + block[2] blkLen=20 cmd=0x001d rc=0 payloadLen=4 payload=00 00 00 01 + block[3] blkLen=24 cmd=0x0024 rc=0 payloadLen=8 payload=00 00 00 00 00 0a 00 00 + block[4] blkLen=24 cmd=0x0025 rc=0 payloadLen=8 payload=00 00 00 00 00 0a 00 00 + block[5] blkLen=24 cmd=0x0026 rc=0 payloadLen=8 payload=00 2a bf a6 00 0a 00 04 + block[6] blkLen=24 cmd=0x0026 rc=0 payloadLen=8 payload=00 2a be 30 00 0a 00 04 + block[7] blkLen=24 cmd=0x0026 rc=0 payloadLen=8 payload=00 00 00 20 00 0a 00 04 + block[8] blkLen=24 cmd=0x0026 rc=0 payloadLen=8 payload=00 00 00 00 00 0a 00 04 + +--- timer-poweron-0x0120-t0 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 01 20 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True + <- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 01 20 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00 00 + blockCount=1 + block[0] blkLen=24 cmd=0x0120 rc=0 payloadLen=8 payload=00 00 00 00 00 00 00 00 + +--- timer-operating-0x0120-t1 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 01 20 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True + <- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 01 20 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00 00 + blockCount=1 + block[0] blkLen=24 cmd=0x0120 rc=0 payloadLen=8 payload=00 00 00 00 00 00 00 00 + +--- timer-cutting-0x0120-t2 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 01 20 00 00 00 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True + <- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 01 20 00 00 00 00 00 00 00 08 ac f2 10 00 90 a3 00 00 + blockCount=1 + block[0] blkLen=24 cmd=0x0120 rc=0 payloadLen=8 payload=ac f2 10 00 90 a3 00 00 + +--- timer-cycle-0x0120-t3 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 01 20 00 00 00 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True + <- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 01 20 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00 00 + blockCount=1 + block[0] blkLen=24 cmd=0x0120 rc=0 payloadLen=8 payload=00 00 00 00 00 00 00 00 + +--- svmeter-pair-0x0056-0x0089 --- + -> 68B req: a0 a0 a0 a0 00 01 21 01 00 3a 00 02 00 1c 00 01 00 01 00 56 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 89 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=318 complete=True + <- raw (328B): a0 a0 a0 a0 00 03 21 02 01 3e 00 02 01 10 00 01 00 01 00 56 00 00 00 00 00 00 01 00 00 00 00 00 00 0a 00 00 00 00 00 32 00 0a 00 00 ff ff ff fd 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 2c 00 01 00 01 00 89 00 00 00 00 00 00 00 1c 58 00 00 00 59 00 00 00 5a 00 00 00 42 00 00 00 43 00 00 00 41 41 36 00 41 41 37 00 + blockCount=2 + block[0] blkLen=272 cmd=0x0056 rc=0 payloadLen=256 payload=00 00 00 00 00 0a 00 00 00 00 00 32 00 0a 00 00 ff ff ff fd 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 + block[1] blkLen=44 cmd=0x0089 rc=0 payloadLen=28 payload=58 00 00 00 59 00 00 00 5a 00 00 00 42 00 00 00 43 00 00 00 41 41 36 00 41 41 37 00 + +--- svmeter-alone-0x0056 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 56 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=274 complete=True + <- raw (284B): a0 a0 a0 a0 00 03 21 02 01 12 00 01 01 10 00 01 00 01 00 56 00 00 00 00 00 00 01 00 00 00 00 01 00 0a 00 00 00 00 00 32 00 0a 00 00 ff ff ff fd 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 + blockCount=1 + block[0] blkLen=272 cmd=0x0056 rc=0 payloadLen=256 payload=00 00 00 01 00 0a 00 00 00 00 00 32 00 0a 00 00 ff ff ff fd 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 + +--- pmcrng-R100-0x8001 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 02 00 01 80 01 00 00 00 64 00 00 00 64 00 00 00 05 00 00 00 01 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=19 complete=True + <- raw (29B): a0 a0 a0 a0 00 03 21 02 00 13 00 01 00 11 00 02 00 01 80 01 00 00 00 00 00 00 00 01 00 + blockCount=1 + block[0] blkLen=17 cmd=0x8001 rc=0 payloadLen=1 payload=00 + +--- pmcrng-R0-0x8001 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 02 00 01 80 01 00 00 00 00 00 00 00 00 00 00 00 05 00 00 00 01 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=19 complete=True + <- raw (29B): a0 a0 a0 a0 00 03 21 02 00 13 00 01 00 11 00 02 00 01 80 01 00 00 00 00 00 00 00 01 00 + blockCount=1 + block[0] blkLen=17 cmd=0x8001 rc=0 payloadLen=1 payload=00 + +--- param-1320-0x000e --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 0e 00 00 05 28 00 00 05 28 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=18 complete=True + <- raw (28B): a0 a0 a0 a0 00 03 21 02 00 12 00 01 00 10 00 01 00 01 00 0e 00 01 00 00 00 00 00 00 + blockCount=1 + block[0] blkLen=16 cmd=0x000e rc=1 payloadLen=0 payload=(empty) + +--- param-100-0x000e --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 0e 00 00 00 64 00 00 00 64 00 00 00 00 00 00 00 00 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=18 complete=True + <- raw (28B): a0 a0 a0 a0 00 03 21 02 00 12 00 01 00 10 00 01 00 01 00 0e 00 01 00 00 00 00 00 00 + blockCount=1 + block[0] blkLen=16 cmd=0x000e rc=1 payloadLen=0 payload=(empty) + +--- alarms-0x0023 --- + -> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 23 ff ff ff ff 00 00 00 20 00 00 00 02 00 00 00 40 00 00 00 00 + <- header: version=3 type=0x21 dir=0x02 bodyLen=18 complete=True + <- raw (28B): a0 a0 a0 a0 00 03 21 02 00 12 00 01 00 10 00 01 00 01 00 23 00 00 00 00 00 00 00 00 + blockCount=1 + block[0] blkLen=16 cmd=0x0023 rc=0 payloadLen=0 payload=(empty) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/param-100-0x000e.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/param-100-0x000e.bin new file mode 100644 index 00000000..6d13f542 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/param-100-0x000e.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/param-1320-0x000e.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/param-1320-0x000e.bin new file mode 100644 index 00000000..6d13f542 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/param-1320-0x000e.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/pmcrng-R0-0x8001.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/pmcrng-R0-0x8001.bin new file mode 100644 index 00000000..c9fc9c76 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/pmcrng-R0-0x8001.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/pmcrng-R100-0x8001.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/pmcrng-R100-0x8001.bin new file mode 100644 index 00000000..c9fc9c76 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/pmcrng-R100-0x8001.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-axisname-0x0089.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-axisname-0x0089.bin new file mode 100644 index 00000000..0b797521 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-axisname-0x0089.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-dynamic2-axis1.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-dynamic2-axis1.bin new file mode 100644 index 00000000..7d74bcc4 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-dynamic2-axis1.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-exeprgname-0x00fc.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-exeprgname-0x00fc.bin new file mode 100644 index 00000000..c223b4eb Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-exeprgname-0x00fc.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-macro-3901-0x0015.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-macro-3901-0x0015.bin new file mode 100644 index 00000000..cc6d16e2 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-macro-3901-0x0015.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-macro-500-0x0015.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-macro-500-0x0015.bin new file mode 100644 index 00000000..d1154c51 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-macro-500-0x0015.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-opmode-0x0057.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-opmode-0x0057.bin new file mode 100644 index 00000000..fc7a4662 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-opmode-0x0057.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-spdlname-0x008a.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-spdlname-0x008a.bin new file mode 100644 index 00000000..dc34d3df Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-spdlname-0x008a.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-statinfo.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-statinfo.bin new file mode 100644 index 00000000..2c4dc8f3 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-statinfo.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-sysinfo-0x0018.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-sysinfo-0x0018.bin new file mode 100644 index 00000000..6e483513 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/ref-sysinfo-0x0018.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/svmeter-alone-0x0056.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/svmeter-alone-0x0056.bin new file mode 100644 index 00000000..9b661007 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/svmeter-alone-0x0056.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/svmeter-pair-0x0056-0x0089.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/svmeter-pair-0x0056-0x0089.bin new file mode 100644 index 00000000..08b564ed Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/svmeter-pair-0x0056-0x0089.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-cutting-0x0120-t2.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-cutting-0x0120-t2.bin new file mode 100644 index 00000000..00c01896 Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-cutting-0x0120-t2.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-cycle-0x0120-t3.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-cycle-0x0120-t3.bin new file mode 100644 index 00000000..53f8cffc Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-cycle-0x0120-t3.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-operating-0x0120-t1.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-operating-0x0120-t1.bin new file mode 100644 index 00000000..53f8cffc Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-operating-0x0120-t1.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-poweron-0x0120-t0.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-poweron-0x0120-t0.bin new file mode 100644 index 00000000..53f8cffc Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/timer-poweron-0x0120-t0.bin differ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs index 626925e7..81abcb08 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs @@ -7,17 +7,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Unit tests for . Covers the offline-determinable failure -/// paths (invalid JSON, missing host/port, unreachable closed port) plus the degrade path: -/// on a host with no FANUC FWLIB native library present (this dev box / CI Linux containers), -/// the cnc_allclibhndl3 P/Invoke throws at JIT bind -/// time, so a TCP-reachable target must still report Ok=true with a "FWLIB absent" -/// note — never worse than the pre-Phase-5 TCP-only probe. +/// paths (invalid JSON, missing host/port, unreachable closed port) plus the Phase-8 +/// truthfulness behaviour: a TCP-reachable endpoint that is NOT a FOCAS CNC (a bare listener) +/// must report Ok=false, because the probe now completes a real FocasWireClient +/// session (initiate handshake + cnc_statinfo) rather than degrading to "TCP +/// reachability only" when FWLIB is absent. /// -/// Live-verify DEFERRED. The happy path (a real CNC answers cnc_allclibhndl3 -/// with EW_OK → "FOCAS handle OK") and the CNC-error path (FWLIB present but the -/// remote returns e.g. EW_SOCKET/EW_PROTOCOL → "FOCAS handshake failed: -/// focas_rc=...") cannot run on this rig: there is neither a FANUC CNC nor the FWLIB native -/// library available. Those two paths are verified manually against a real Windows+FWLIB host. +/// Live-verify DEFERRED. The happy path (a real CNC completes the handshake + read → +/// "FOCAS session OK") cannot run on this rig — there is no FANUC CNC available at unit-test +/// time. It is verified against the live 31i-B at 10.201.31.5 (see the implementation +/// plan's deploy/validate step). /// /// [Trait("Category", "Unit")] @@ -118,32 +117,32 @@ public sealed class FocasDriverProbeTests } // ------------------------------------------------------------------------- - // 4. Degrade path — TCP reachable, FWLIB absent (the key test) + // 4. TCP reachable but not a CNC — wire-session probe must say Ok=false (Phase 8) // ------------------------------------------------------------------------- /// - /// Against an in-process that accepts the connection, the TCP - /// preflight succeeds. On this box the FANUC FWLIB native library is absent, so the - /// cnc_allclibhndl3 P/Invoke throws (or a - /// related load failure). The probe MUST degrade gracefully — return Ok=true with - /// a "FWLIB absent ... TCP reachability only" note — proving no regression versus the - /// pre-Phase-5 TCP-only probe on FWLIB-less hosts. + /// Against an in-process that accepts the connection but speaks no + /// FOCAS (drops each accepted socket), the TCP preflight succeeds but the Phase-2 wire + /// session can't complete the initiate handshake + cnc_statinfo read. The probe MUST + /// report Ok=false — a bare TCP listener is not a CNC. This is the Phase-8 fix: the + /// old probe degraded such a listener to Ok=true "FWLIB absent, TCP reachability + /// only", which made any TCP listener look HEALTHY. /// [Fact] - public async Task TcpReachable_FwlibAbsent_Degrades_To_OkTrue_WithReachabilityNote() + public async Task TcpReachable_NotACnc_Returns_OkFalse() { // Accept-only listener: completes the TCP handshake but speaks no FOCAS bytes. var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; - // Keep accepting so the connect always completes; ignore the accepted socket. + // Keep accepting so the connect always completes; drop the accepted socket. _ = AcceptLoopAsync(listener, TestContext.Current.CancellationToken); try { using var cts = CancellationTokenSource.CreateLinkedTokenSource( TestContext.Current.CancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(5)); + cts.CancelAfter(TimeSpan.FromSeconds(15)); var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}"; var result = await Probe.ProbeAsync( @@ -151,13 +150,11 @@ public sealed class FocasDriverProbeTests TimeSpan.FromSeconds(3), cts.Token); - // No FWLIB here → degrade, never worse than TCP-only. - result.Ok.ShouldBeTrue( - $"Expected degrade to Ok=true on an FWLIB-less host but got: {result.Message}"); + // A bare listener is not a CNC — the FOCAS session fails, so the probe is NOT ok. + result.Ok.ShouldBeFalse( + $"Expected Ok=false for a non-CNC TCP listener but got: {result.Message}"); result.Message.ShouldNotBeNull(); - result.Message!.ShouldContain("FWLIB absent"); - result.Message!.ShouldContain("TCP reachability only"); - result.Latency.ShouldNotBeNull(); + result.Latency.ShouldBeNull(); } finally { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireProtocolTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireProtocolTests.cs index 1bf7b0e9..ad6bb6fd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireProtocolTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireProtocolTests.cs @@ -107,6 +107,36 @@ public sealed class FocasWireProtocolTests finally { client.Dispose(); server.Dispose(); } } + // The 10-byte header framing is identical across supported versions (only the version field + // differs) — older controls + the mock answer v1, modern controls answer v3 (FANUC 30i-B). + // Validated live 2026-06-25; see docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md. + [Theory] + [InlineData((ushort)1)] + [InlineData((ushort)3)] + public async Task ReadPduAsync_accepts_supported_version(ushort version) + { + var body = new byte[] { 9, 8, 7 }; + var pdu = new byte[10 + body.Length]; + new byte[] { 0xa0, 0xa0, 0xa0, 0xa0 }.CopyTo(pdu, 0); + BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(4, 2), version); + pdu[6] = FocasWireProtocol.TypeData; + pdu[7] = FocasWireProtocol.DirectionResponse; + BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(8, 2), (ushort)body.Length); + body.CopyTo(pdu.AsSpan(10)); + + var (client, server) = await ConnectedPairAsync(); + try + { + await server.GetStream().WriteAsync(pdu); + var read = await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None); + + read.Type.ShouldBe(FocasWireProtocol.TypeData); + read.Direction.ShouldBe(FocasWireProtocol.DirectionResponse); + read.Body.ShouldBe(body); + } + finally { client.Dispose(); server.Dispose(); } + } + // ---- BuildRequestBody framing ---- [Fact] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs new file mode 100644 index 00000000..d3866896 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs @@ -0,0 +1,178 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Byte-level coverage for the FOCAS PDU-v3 fixes derived from a live FANUC 31i-B capture +/// (2026-06-25). The fixtures under Fixtures/v3/ are the raw responses; the specific +/// payload bytes are inlined here so the tests stay hermetic. See +/// docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md + +/// docs/plans/2026-06-25-focas-pdu-v3-implementation-plan.md. +/// +[Trait("Category", "Unit")] +public sealed class FocasWireV3Tests +{ + // ---- cnc_rdtimer (0x0120): little-endian {minute, msec} ---- + + // Captured cutting-time (type 2) payload from the live 31i-B. + private static readonly byte[] CuttingTimerPayload = [0xac, 0xf2, 0x10, 0x00, 0x90, 0xa3, 0x00, 0x00]; + + [Fact] + public void ParseTimer_decodes_the_live_payload_as_little_endian_minute_and_msec() + { + var timer = FocasWireClient.ParseTimer(2, CuttingTimerPayload); + + timer.Type.ShouldBe((short)2); + timer.Minutes.ShouldBe(1_110_700); // 0x0010F2AC little-endian + timer.Milliseconds.ShouldBe(41_872); // 0x0000A390 little-endian + } + + [Fact] + public void ParseTimer_little_endian_is_the_only_decode_with_an_in_range_msec() + { + // The whole point: under big-endian the msec field is nonsensical (~2.4e9), so it cannot be + // the "fractional milliseconds 0..59999" field the model documents. Little-endian is in range. + var beMsec = BinaryPrimitives.ReadUInt32BigEndian(CuttingTimerPayload.AsSpan(4, 4)); + beMsec.ShouldBeGreaterThan(59_999u); + + var timer = FocasWireClient.ParseTimer(2, CuttingTimerPayload); + timer.Milliseconds.ShouldBeInRange(0, 59_999); + } + + [Fact] + public void ParseTimer_handles_a_short_payload_without_throwing() + { + var timer = FocasWireClient.ParseTimer(0, []); + timer.Minutes.ShouldBe(0); + timer.Milliseconds.ShouldBe(0); + } + + // ---- pmc_rdpmcrng (0x8001): byte width + range decode ---- + + [Theory] + [InlineData((short)0, 1)] // Byte + [InlineData((short)1, 2)] // Word + [InlineData((short)2, 4)] // Long + [InlineData((short)4, 4)] // Real + [InlineData((short)5, 8)] // Double + [InlineData((short)99, 1)] // unknown → 1 + public void PmcByteWidth_maps_focas_datatype_codes(short dataType, int expectedWidth) + { + FocasWireClient.PmcByteWidth(dataType).ShouldBe(expectedWidth); + } + + [Fact] + public void ParsePmcRange_decodes_a_two_byte_word_into_one_value() + { + // A Word read of R100 must request bytes 100..101 (2 bytes); the CNC then returns 2 bytes. + var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 1, start: 100, end: 101, payload: [0x00, 0x64]); + range.Values.Count.ShouldBe(1); + range.Values[0].ShouldBe(100L); // 0x0064 big-endian + } + + [Fact] + public void ParsePmcRange_with_a_single_byte_for_a_word_yields_no_value() + { + // This is the pre-fix bug: requesting end==start for a Word returned 1 byte, the 2-byte + // slot never completed, so the value list was empty → WireFocasClient mapped it BadOutOfRange. + var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 1, start: 100, end: 100, payload: [0x00]); + range.Values.ShouldBeEmpty(); + } + + [Fact] + public void ParsePmcRange_decodes_a_single_byte_value() + { + var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 0, start: 0, end: 0, payload: [0x07]); + range.Values.Count.ShouldBe(1); + range.Values[0].ShouldBe(7L); + } + + // ---- cnc_rdsvmeter: 8-byte LOADELM stride + axis-name correlation ---- + + [Fact] + public void ParseServoMeters_uses_an_eight_byte_stride_and_names_from_the_axis_block() + { + // Three 8-byte records {int32 data; int16 dec=10; int16 unit=0}: data = 0, 50, -3. + byte[] sv = + [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, // axis 1: data=0 + 0x00, 0x00, 0x00, 0x32, 0x00, 0x0a, 0x00, 0x00, // axis 2: data=50 + 0xff, 0xff, 0xff, 0xfd, 0x00, 0x0a, 0x00, 0x00, // axis 3: data=-3 + ]; + // 0x0089 axis-name block: 4-byte records X, Y, Z. + byte[] names = + [ + 0x58, 0x00, 0x00, 0x00, // "X" + 0x59, 0x00, 0x00, 0x00, // "Y" + 0x5a, 0x00, 0x00, 0x00, // "Z" + ]; + + var meters = FocasWireClient.ParseServoMeters(sv, names, maxCount: 32); + + meters.Count.ShouldBe(3); + meters[0].Name.ShouldBe("X"); + meters[0].Value.ShouldBe(0); + meters[1].Name.ShouldBe("Y"); + meters[1].Value.ShouldBe(50); // 8-byte stride: a 12-byte stride would misread this as 655360 + meters[1].Decimal.ShouldBe((short)10); + meters[2].Name.ShouldBe("Z"); + meters[2].Value.ShouldBe(-3); + } + + // ---- cnc_rddynamic2: 1-based axis guard ---- + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task ReadDynamicAsync_rejects_a_non_positive_axis_index(int axisIndex) + { + using var client = new WireFocasClient(); + await Should.ThrowAsync(async () => + await client.ReadDynamicAsync(axisIndex, CancellationToken.None)); + } + + // ---- read-stall hardening: a stalled peer must not wedge the poll loop ---- + + [Fact] + public async Task ReadPduAsync_aborts_a_stalled_peer_within_the_cancellation_budget() + { + var (client, server) = await ConnectedPairAsync(); + try + { + // Send only 5 of the 10 header bytes, then stall forever — the cnc_rdsvmeter hang shape. + await server.GetStream().WriteAsync(new byte[] { 0xa0, 0xa0, 0xa0, 0xa0, 0x00 }); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + var sw = Stopwatch.StartNew(); + await Should.ThrowAsync(async () => + await FocasWireProtocol.ReadPduAsync(client.GetStream(), cts.Token)); + sw.Stop(); + + // Must abort near the 300ms budget, not hang — generous ceiling for CI jitter. + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); + } + finally { client.Dispose(); server.Dispose(); } + } + + private static async Task<(TcpClient client, TcpClient server)> ConnectedPairAsync() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var client = new TcpClient(); + var connect = client.ConnectAsync(IPAddress.Loopback, port); + var server = await listener.AcceptTcpClientAsync(); + await connect; + return (client, server); + } + finally { listener.Stop(); } + } +}