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
`BadNotWritable` by design.
> **PDU version (v3 / FANUC 30i/31i).** The wire client **accepts inbound PDU versions `{1, 3}`**
> (`FocasWireProtocol`) while still emitting v1 on requests. Older controls + the docker mock answer
> v1; modern controls (Makino Pro 5 / FANUC **31i-B**) answer v3. **Validated live against a real
> 31i-B (`10.201.31.5`) 2026-06-25:** sysinfo, axis/spindle names, dynamic positions/feed/spindle,
> program/mode, **macros**, **timers** (8-byte payload is little-endian {minute, msec}), **PMC range**
> (request widened to the data-type byte width), **servo meters** (8-byte LOADELM stride + names from
> the 0x0089 block; load *scaling* still to be confirmed at commissioning), and **alarms** all read
> correctly. The driver `cnc_rddynamic2` poll is 1-based. **`cnc_rdparam` is unsupported on this
> control** — every request-framing variant returns `EW_FUNC` (likely a wrong v3 command id; needs a
> reference FWLIB trace). The Test-Connect probe now runs a real wire session (initiate + cnc_statinfo)
> instead of a bare TCP check. Full finding, captured wire bytes, validation results, and analysis:
> [`docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`](../plans/2026-06-25-focas-pdu-v3-30i-b-support.md)
> + [`…-implementation-plan.md`](../plans/2026-06-25-focas-pdu-v3-implementation-plan.md).
> Capture tools: `scripts/focas/capture-initiate.py`, `scripts/focas/capture-v3.py`.
## Project split
| Project | Target | Role |
@@ -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
[`docs/v2/implementation/focas-isolation-plan.md`](implementation/focas-isolation-plan.md)
for that plan (task #220).
## Real-hardware validation (first live FOCAS contact)
**2026-06-25 — FANUC 31i-B (Makino Pro 5), `10.201.31.5:8193`.** The first time the managed wire
client met real FOCAS hardware. The control answers **PDU version 3**; the 10-byte header framing and
response block envelope are byte-identical to v1 (only the version field differs). `cnc_sysinfo`
reports CncType 31, Series `G431`, 7 axes (X Y Z B C A A), MtType MM.
| Command family | v3 result | Notes |
| --- | --- | --- |
| `cnc_sysinfo`, `cnc_rdaxisname`, `cnc_rdspdlname` | ✅ validated | identity / axis / spindle names |
| `cnc_rddynamic2` (positions, feed, spindle, program/seq) | ✅ validated | 1-based axis iteration; program O-number reads via big-endian |
| `cnc_rdopmode`, `cnc_exeprgname2` | ✅ validated | mode + executing program |
| `cnc_rdmacro` | ✅ validated | deployed `MACRO:3901/3902` tags read Good |
| `cnc_rdtimer` | ✅ validated | 8-byte {minute, msec} payload is **little-endian** (unlike the big-endian envelope) |
| `pmc_rdpmcrng` | ✅ validated | request range must be widened to the data-type byte width (`end = start + width - 1`) |
| `cnc_rdsvmeter` | ✅ validated | per-axis LOADELM is **8 bytes**, not 12; names come from the 0x0089 block; load *scaling* unconfirmed |
| `cnc_rdalmmsg2` | ✅ validated | read a live active alarm (`#3080`) |
| `cnc_rdparam` | ❌ **unsupported** | `EW_FUNC` across 14 request-framing variants × 4 known-present params — likely wrong v3 command id (`0x000e`) or restricted on this control; needs a reference FWLIB trace |
Full finding + captured wire bytes + the fix per surface:
[`../plans/2026-06-25-focas-pdu-v3-30i-b-support.md`](../plans/2026-06-25-focas-pdu-v3-30i-b-support.md).