feat(focas): real FANUC 30i/31i-B PDU-v3 support (live-validated on a 31i-B)

First real FOCAS hardware contact (Makino Pro 5 / 31i-B @ 10.201.31.5). A full
v3 data-PDU capture corrected the initial diagnosis: the v3 block envelope is
identical to v1, so only specific payload structs / request math / one client
robustness gap were wrong — not "framing rewrites".

Fixes (all re-validated live through the fixed driver):
- version gate: accept inbound PDU {1,3}, keep emitting v1 (FocasWireProtocol).
- cnc_rdtimer: 8-byte {minute,msec} payload is little-endian (ParseTimer) — the
  only decode with an in-range msec field.
- pmc_rdpmcrng: request range widened to the data-type byte width
  (end = start + width - 1) so a Word/Long isn't truncated to 0 values
  (was spurious BadOutOfRange); decode extracted to ParsePmcRange.
- cnc_rdsvmeter: per-axis LOADELM is 8 bytes (not 12) and names come from the
  0x0089 block — ParseServoMeters fixes the misaligned 655360 garbage. Also the
  "hang" was NetworkStream.ReadAsync not aborting a stalled socket: ReadExactlyAsync
  now disposes the stream on cancellation so a stalled peer can't wedge a poll loop.
- cnc_rddynamic2: contract guard rejecting axis < 1 (driver poll already 1-based).
- FocasDriverProbe: run a real wire session (initiate + cnc_statinfo) instead of
  degrading to Ok=true "TCP reachability only" when FWLIB is absent — a bare TCP
  listener no longer reports HEALTHY.

cnc_rdparam (0x000e) is unsupported on this control — EW_FUNC across 14
request-framing variants x 4 known-present params; needs a reference FWLIB trace
or is restricted. Deferred (deployed config uses macros, not parameters).

Tests: FOCAS suite 234 green (+16), full solution builds 0 errors. Raw v3
captures checked in under tests/.../Fixtures/v3/. Capture tools under scripts/focas/.

Docs: docs/plans/2026-06-25-focas-pdu-v3-{30i-b-support,implementation-plan}.md,
docs/drivers/FOCAS.md, docs/v2/focas-version-matrix.md,
docs/deployments/wonder-app-vd03-makino-z-34184.md.
This commit is contained in:
Joseph Doherty
2026-06-25 16:41:42 -04:00
parent fd01448ac4
commit 5f0a52864c
36 changed files with 1567 additions and 177 deletions
@@ -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).
+15
View File
@@ -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 protocol using the documented command IDs. Writes return
`BadNotWritable` by design. `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 split
| Project | Target | Role | | Project | Target | Role |
@@ -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 <host>`.)
## 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 13 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.
@@ -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 <host>` (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 57 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 45 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 47
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 47).
- `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).
+22
View File
@@ -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 `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) [`docs/v2/implementation/focas-isolation-plan.md`](implementation/focas-isolation-plan.md)
for that plan (task #220). 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).
+72
View File
@@ -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 <host> [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
+293
View File
@@ -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 <host> [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()
+171
View File
@@ -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 <host> [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()
@@ -1,9 +1,9 @@
using System.Diagnostics; using System.Diagnostics;
using System.Net.Sockets; using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; 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 <see cref="FocasDriverOptions"/>-shaped driver config. /// Two-phase Test-Connect probe for the <see cref="FocasDriverOptions"/>-shaped driver config.
/// Phase 1: bare TCP connect to the first device's FOCAS Ethernet address + port to quickly /// 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" /// reject unreachable targets (preserves the original "Connect failed" / "timed out"
/// messages). Phase 2: attempts the FANUC FWLIB handle handshake — allocates a CNC handle via /// messages). Phase 2: a real FOCAS session via the managed <see cref="FocasWireClient"/> — the
/// <c>cnc_allclibhndl3(host, port, timeoutSec, out handle)</c> and immediately frees it with /// two-socket initiate handshake plus one sample read (<c>cnc_statinfo</c>). A handshake +
/// <c>cnc_freelibhndl</c>. A handle that allocates (<c>EW_OK</c>) confirms the remote endpoint /// read that succeeds confirms the remote endpoint is a real FOCAS CNC, not just a TCP
/// is a real FOCAS CNC, not just a TCP listener. /// listener.
/// <para> /// <para>
/// The P/Invoke is issued directly (it does NOT route through /// <b>Why a wire-client probe (not FWLIB).</b> The pure-managed wire client is the driver's
/// <see cref="UnimplementedFocasClientFactory"/>, whose <c>EnsureUsable()</c> throws by /// only read backend (the FWLIB / out-of-process paths were retired in the Wire migration), so
/// design) so the handshake works on a real Windows+FWLIB host and degrades everywhere else. /// the probe must exercise the same path the driver actually uses. The previous probe issued
/// The synchronous native call can block, so it runs on a worker bounded by a linked CTS /// the <c>cnc_allclibhndl3</c> FWLIB P/Invoke and, on any host without the native library (the
/// (<c>ct</c> + <c>CancelAfter(timeout)</c>) — the probe always returns within the timeout /// normal case — macOS dev boxes, Linux CI, and the Windows hosts that run the managed client),
/// budget even if FWLIB hangs. /// degraded to <c>Ok=true</c> "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
/// <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c> (Phase 8).
/// </para> /// </para>
/// <para> /// <para>
/// <b>Degrade guard (the crux).</b> On a host without the FWLIB native library — this dev box /// The wire client honours the linked CTS (<c>ct</c> + <c>CancelAfter(timeout)</c>) and its
/// (macOS) and the Linux CI containers — the <c>cnc_allclibhndl3</c> P/Invoke fails to bind /// reads are abort-bounded (see <see cref="FocasWireProtocol"/>), so the probe always returns
/// and throws <see cref="DllNotFoundException"/> (or a related load failure: /// within the timeout budget even against a host that accepts TCP then stalls.
/// <see cref="TypeInitializationException"/>, <see cref="NotSupportedException"/>,
/// <see cref="BadImageFormatException"/>, <see cref="EntryPointNotFoundException"/>). Those are
/// caught and the probe falls back to <c>Ok=true</c> 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).
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class FocasDriverProbe : IDriverProbe public sealed class FocasDriverProbe : IDriverProbe
{ {
/// <summary>FANUC FWLIB return code for success (<c>EW_OK</c>).</summary>
private const short EwOk = 0;
private static readonly JsonSerializerOptions _opts = new() private static readonly JsonSerializerOptions _opts = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
@@ -83,75 +77,32 @@ public sealed class FocasDriverProbe : IDriverProbe
return new(false, ex.Message, null); return new(false, ex.Message, null);
} }
// Phase 2: FOCAS handle handshake via cnc_allclibhndl3. The native call is synchronous and // Phase 2: real FOCAS session via the managed wire client — initiate handshake + one
// can block, so run it on a worker bounded by a linked CTS = ct + CancelAfter(timeout). // sample read. Bounded by a linked CTS = ct + CancelAfter(budget); the wire reads are
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); // 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); var budget = timeout > TimeSpan.Zero ? timeout : TimeSpan.FromSeconds(1);
handshakeCts.CancelAfter(budget); sessionCts.CancelAfter(budget);
try try
{ {
var (degraded, rc) = await Task.Run( await using var wire = new FocasWireClient();
() => TryAllocateAndFreeHandle(host, port, budget), await wire.ConnectAsync(host, port, budget, sessionCts.Token).ConfigureAwait(false);
handshakeCts.Token); var status = await wire.ReadStatusAsync(sessionCts.Token, budget).ConfigureAwait(false);
sw.Stop(); sw.Stop();
if (degraded) return status.IsOk
{ ? new(true, $"FOCAS session OK at {host}:{port} (cnc_statinfo)", sw.Elapsed)
// FWLIB absent / cannot load — never worse than the original TCP-only probe. : new(false, $"Reachable at {host}:{port} but FOCAS read failed: EW_{status.Rc}", null);
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);
} }
catch (OperationCanceledException) 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); return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
} }
} catch (FocasWireException ex)
/// <summary>
/// Attempts the FWLIB handle handshake against <paramref name="host"/>/<paramref name="port"/>.
/// On success the handle is freed immediately. Returns <c>degraded=true</c> when the native
/// library cannot be loaded (FWLIB absent — the dev/CI reality); otherwise
/// <c>degraded=false</c> with the FWLIB return code (<c>EW_OK</c> = handle allocated).
/// </summary>
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
{ {
var rc = NativeFwlib.cnc_allclibhndl3(host, (ushort)port, timeoutSeconds, out handle); // TCP-reachable but the FOCAS initiate/read failed — a listener that is not a CNC.
return (degraded: false, rc); return new(false, $"Reachable at {host}:{port} but FOCAS session failed: {ex.Message}", null);
}
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 */ }
}
} }
} }
@@ -166,28 +117,4 @@ public sealed class FocasDriverProbe : IDriverProbe
return (parsed.Host, parsed.Port); return (parsed.Host, parsed.Port);
} }
/// <summary>
/// Minimal P/Invoke surface for the two FANUC FWLIB entry points the probe needs:
/// <c>cnc_allclibhndl3</c> to allocate a CNC handle against a host/port, and
/// <c>cnc_freelibhndl</c> to release it. The native library (<c>fwlib32.dll</c> /
/// <c>fwlib64.dll</c> on Windows, <c>libfwlib32.so</c> 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 <see cref="DllNotFoundException"/> — caught by the
/// probe's degrade guard.
/// </summary>
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);
}
} }
@@ -369,19 +369,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
var rc = AggregateRc(blocks); var rc = AggregateRc(blocks);
if (rc != 0) return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, null); if (rc != 0) return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, null);
var payload = FindPayload(blocks, 0x0056); var result = ParseServoMeters(FindPayload(blocks, 0x0056), FindPayload(blocks, 0x0089), maxCount);
var result = new List<WireServoMeter>();
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)));
}
return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, result); return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, result);
} }
@@ -602,31 +590,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
callTimeout.Token, callTimeout.Token,
new RequestBlock(0x8001, start, end, area, dataType, RequestClass: 2, PathId: EffectivePathId(pathId))).ConfigureAwait(false); new RequestBlock(0x8001, start, end, area, dataType, RequestClass: 2, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult(block, payload => return ToResult(block, payload => ParsePmcRange(area, dataType, start, end, payload));
{
var width = dataType switch
{
1 => 2,
2 or 4 => 4,
5 => 8,
_ => 1,
};
var values = new List<long>();
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);
});
} }
/// <summary>Typed overload for <see cref="ReadPmcRangeAsync(short, short, ushort, ushort, CancellationToken, TimeSpan?, ushort?)"/>.</summary> /// <summary>Typed overload for <see cref="ReadPmcRangeAsync(short, short, ushort, ushort, CancellationToken, TimeSpan?, ushort?)"/>.</summary>
@@ -740,7 +704,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
ushort? pathId = null) ushort? pathId = null)
=> ReadSingleWithTimeoutAsync( => ReadSingleWithTimeoutAsync(
0x0120, 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); cancellationToken, timeout, EffectivePathId(pathId), type);
// ---- internal plumbing ------------------------------------------------------------ // ---- internal plumbing ------------------------------------------------------------
@@ -922,6 +886,88 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
private static short AggregateRc(IReadOnlyList<ResponseBlock> blocks) private static short AggregateRc(IReadOnlyList<ResponseBlock> blocks)
=> blocks.FirstOrDefault(block => block.Rc != 0)?.Rc ?? 0; => blocks.FirstOrDefault(block => block.Rc != 0)?.Rc ?? 0;
/// <summary>
/// Decode a <c>cnc_rdtimer</c> (0x0120) payload into <see cref="WireTimer"/>. The 8-byte
/// data block is two 32-bit fields {minute, msec}, and they are <b>little-endian</b> 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 <c>ac f2 10 00 90 a3 00 00</c> → minute=1110188, msec=41872. See
/// <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c>.
/// </summary>
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);
/// <summary>
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. On the
/// 31i-B (v3) each per-axis LOADELM is <b>8 bytes</b> — {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 <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated
/// by index. Validated against a live 31i-B 2026-06-25.
/// <para><b>Scaling caveat:</b> downstream applies LoadPercent = data / 10^dec; on the 31i-B
/// <c>dec</c> 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.</para>
/// </summary>
internal static IReadOnlyList<WireServoMeter> ParseServoMeters(byte[] svPayload, byte[] axisNamePayload, int maxCount)
{
var result = new List<WireServoMeter>();
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;
}
/// <summary>Byte width of one PMC slot for a FOCAS data-type code: Byte=1, Word=2, Long/Real=4, Double=8.</summary>
internal static int PmcByteWidth(short dataType) => dataType switch
{
1 => 2,
2 or 4 => 4,
5 => 8,
_ => 1,
};
/// <summary>
/// Decode a <c>pmc_rdpmcrng</c> payload into <see cref="WirePmcRange"/>. The CNC returns
/// (end-start+1) BYTES; this slices them into width-sized big-endian slots. Callers must
/// size the request range to <c>width</c> bytes per value (see
/// <c>WireFocasClient.ReadPmcAsync</c>) or a trailing partial slot is dropped — which on the
/// 31i-B surfaced as a spurious <c>BadOutOfRange</c> for a single Word read. 2026-06-25.
/// </summary>
internal static WirePmcRange ParsePmcRange(short area, short dataType, ushort start, ushort end, byte[] payload)
{
var width = PmcByteWidth(dataType);
var values = new List<long>();
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<ResponseBlock> blocks, ushort command) private static byte[] FindPayload(IReadOnlyList<ResponseBlock> blocks, ushort command)
=> blocks.FirstOrDefault(block => block.Command == command)?.Payload ?? Array.Empty<byte>(); => blocks.FirstOrDefault(block => block.Command == command)?.Payload ?? Array.Empty<byte>();
@@ -19,7 +19,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// </remarks> /// </remarks>
internal static class FocasWireProtocol internal static class FocasWireProtocol
{ {
/// <summary>The PDU version this client emits in every outgoing request header.</summary>
public const ushort Version = 1; public const ushort Version = 1;
/// <summary>
/// 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 <see cref="Version"/> (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 <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c>.
/// </summary>
private static readonly ushort[] SupportedReadVersions = [1, 3];
/// <summary>True when <paramref name="version"/> is a PDU version this client can frame-parse.</summary>
internal static bool IsSupportedReadVersion(ushort version) =>
Array.IndexOf(SupportedReadVersions, version) >= 0;
public const byte DirectionRequest = 0x01; public const byte DirectionRequest = 0x01;
public const byte DirectionResponse = 0x02; public const byte DirectionResponse = 0x02;
public const byte TypeInitiate = 0x01; public const byte TypeInitiate = 0x01;
@@ -99,7 +114,7 @@ internal static class FocasWireProtocol
throw new FocasWireException("Invalid FOCAS PDU magic."); throw new FocasWireException("Invalid FOCAS PDU magic.");
var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2)); var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
if (version != Version) if (!IsSupportedReadVersion(version))
throw new FocasWireException($"Unsupported FOCAS PDU version {version}."); throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2)); var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
@@ -122,7 +137,7 @@ internal static class FocasWireProtocol
throw new FocasWireException("Invalid FOCAS PDU magic."); throw new FocasWireException("Invalid FOCAS PDU magic.");
var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2)); var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
if (version != Version) if (!IsSupportedReadVersion(version))
throw new FocasWireException($"Unsupported FOCAS PDU version {version}."); throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2)); 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) 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; var offset = 0;
while (offset < buffer.Length) try
{ {
var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, cancellationToken).ConfigureAwait(false); while (offset < buffer.Length)
if (read == 0) {
throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read."); var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken).ConfigureAwait(false);
offset += read; 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);
} }
} }
@@ -196,6 +196,11 @@ public sealed class WireFocasClient : IFocasClient
/// <returns>The dynamic snapshot of the axis.</returns> /// <returns>The dynamic snapshot of the axis.</returns>
public async Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken) public async Task<FocasDynamicSnapshot> 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(); RequireConnected();
var result = await _wire.ReadDynamic2Async((short)axisIndex, cancellationToken).ConfigureAwait(false); var result = await _wire.ReadDynamic2Async((short)axisIndex, cancellationToken).ConfigureAwait(false);
ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk); ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk);
@@ -337,7 +342,11 @@ public sealed class WireFocasClient : IFocasClient
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown); if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type); var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type);
var start = (ushort)address.Number; 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 try
{ {
@@ -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)
@@ -7,17 +7,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary> /// <summary>
/// Unit tests for <see cref="FocasDriverProbe"/>. Covers the offline-determinable failure /// Unit tests for <see cref="FocasDriverProbe"/>. Covers the offline-determinable failure
/// paths (invalid JSON, missing host/port, unreachable closed port) plus the degrade path: /// paths (invalid JSON, missing host/port, unreachable closed port) plus the Phase-8
/// on a host with no FANUC FWLIB native library present (this dev box / CI Linux containers), /// truthfulness behaviour: a TCP-reachable endpoint that is NOT a FOCAS CNC (a bare listener)
/// the <c>cnc_allclibhndl3</c> P/Invoke throws <see cref="DllNotFoundException"/> at JIT bind /// must report <c>Ok=false</c>, because the probe now completes a real <c>FocasWireClient</c>
/// time, so a TCP-reachable target must still report <c>Ok=true</c> with a "FWLIB absent" /// session (initiate handshake + <c>cnc_statinfo</c>) rather than degrading to "TCP
/// note — never worse than the pre-Phase-5 TCP-only probe. /// reachability only" when FWLIB is absent.
/// <para> /// <para>
/// <b>Live-verify DEFERRED.</b> The happy path (a real CNC answers <c>cnc_allclibhndl3</c> /// <b>Live-verify DEFERRED.</b> The happy path (a real CNC completes the handshake + read →
/// with <c>EW_OK</c> → "FOCAS handle OK") and the CNC-error path (FWLIB present but the /// "FOCAS session OK") cannot run on this rig — there is no FANUC CNC available at unit-test
/// remote returns e.g. <c>EW_SOCKET</c>/<c>EW_PROTOCOL</c> → "FOCAS handshake failed: /// time. It is verified against the live 31i-B at <c>10.201.31.5</c> (see the implementation
/// focas_rc=...") cannot run on this rig: there is neither a FANUC CNC nor the FWLIB native /// plan's deploy/validate step).
/// library available. Those two paths are verified manually against a real Windows+FWLIB host.
/// </para> /// </para>
/// </summary> /// </summary>
[Trait("Category", "Unit")] [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)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/// <summary> /// <summary>
/// Against an in-process <see cref="TcpListener"/> that accepts the connection, the TCP /// Against an in-process <see cref="TcpListener"/> that accepts the connection but speaks no
/// preflight succeeds. On this box the FANUC FWLIB native library is absent, so the /// FOCAS (drops each accepted socket), the TCP preflight succeeds but the Phase-2 wire
/// <c>cnc_allclibhndl3</c> P/Invoke throws <see cref="DllNotFoundException"/> (or a /// session can't complete the initiate handshake + <c>cnc_statinfo</c> read. The probe MUST
/// related load failure). The probe MUST degrade gracefully — return <c>Ok=true</c> with /// report <c>Ok=false</c> — a bare TCP listener is not a CNC. This is the Phase-8 fix: the
/// a "FWLIB absent ... TCP reachability only" note — proving no regression versus the /// old probe degraded such a listener to <c>Ok=true</c> "FWLIB absent, TCP reachability
/// pre-Phase-5 TCP-only probe on FWLIB-less hosts. /// only", which made any TCP listener look HEALTHY.
/// </summary> /// </summary>
[Fact] [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. // Accept-only listener: completes the TCP handshake but speaks no FOCAS bytes.
var listener = new TcpListener(IPAddress.Loopback, 0); var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start(); listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port; 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); _ = AcceptLoopAsync(listener, TestContext.Current.CancellationToken);
try try
{ {
using var cts = CancellationTokenSource.CreateLinkedTokenSource( using var cts = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5)); cts.CancelAfter(TimeSpan.FromSeconds(15));
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}"; var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
var result = await Probe.ProbeAsync( var result = await Probe.ProbeAsync(
@@ -151,13 +150,11 @@ public sealed class FocasDriverProbeTests
TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3),
cts.Token); cts.Token);
// No FWLIB here → degrade, never worse than TCP-only. // A bare listener is not a CNC — the FOCAS session fails, so the probe is NOT ok.
result.Ok.ShouldBeTrue( result.Ok.ShouldBeFalse(
$"Expected degrade to Ok=true on an FWLIB-less host but got: {result.Message}"); $"Expected Ok=false for a non-CNC TCP listener but got: {result.Message}");
result.Message.ShouldNotBeNull(); result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("FWLIB absent"); result.Latency.ShouldBeNull();
result.Message!.ShouldContain("TCP reachability only");
result.Latency.ShouldNotBeNull();
} }
finally finally
{ {
@@ -107,6 +107,36 @@ public sealed class FocasWireProtocolTests
finally { client.Dispose(); server.Dispose(); } 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 ---- // ---- BuildRequestBody framing ----
[Fact] [Fact]
@@ -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;
/// <summary>
/// Byte-level coverage for the FOCAS PDU-v3 fixes derived from a live FANUC 31i-B capture
/// (2026-06-25). The fixtures under <c>Fixtures/v3/</c> are the raw responses; the specific
/// payload bytes are inlined here so the tests stay hermetic. See
/// <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c> +
/// <c>docs/plans/2026-06-25-focas-pdu-v3-implementation-plan.md</c>.
/// </summary>
[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<ArgumentOutOfRangeException>(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<OperationCanceledException>(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(); }
}
}