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,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>
/// Unit tests for <see cref="FocasDriverProbe"/>. Covers the offline-determinable failure
/// paths (invalid JSON, missing host/port, unreachable closed port) plus the degrade path:
/// on a host with no FANUC FWLIB native library present (this dev box / CI Linux containers),
/// the <c>cnc_allclibhndl3</c> P/Invoke throws <see cref="DllNotFoundException"/> at JIT bind
/// time, so a TCP-reachable target must still report <c>Ok=true</c> with a "FWLIB absent"
/// note — never worse than the pre-Phase-5 TCP-only probe.
/// paths (invalid JSON, missing host/port, unreachable closed port) plus the Phase-8
/// truthfulness behaviour: a TCP-reachable endpoint that is NOT a FOCAS CNC (a bare listener)
/// must report <c>Ok=false</c>, because the probe now completes a real <c>FocasWireClient</c>
/// session (initiate handshake + <c>cnc_statinfo</c>) rather than degrading to "TCP
/// reachability only" when FWLIB is absent.
/// <para>
/// <b>Live-verify DEFERRED.</b> The happy path (a real CNC answers <c>cnc_allclibhndl3</c>
/// with <c>EW_OK</c> → "FOCAS handle OK") and the CNC-error path (FWLIB present but the
/// remote returns e.g. <c>EW_SOCKET</c>/<c>EW_PROTOCOL</c> → "FOCAS handshake failed:
/// focas_rc=...") cannot run on this rig: there is neither a FANUC CNC nor the FWLIB native
/// library available. Those two paths are verified manually against a real Windows+FWLIB host.
/// <b>Live-verify DEFERRED.</b> The happy path (a real CNC completes the handshake + read →
/// "FOCAS session OK") cannot run on this rig — there is no FANUC CNC available at unit-test
/// time. It is verified against the live 31i-B at <c>10.201.31.5</c> (see the implementation
/// plan's deploy/validate step).
/// </para>
/// </summary>
[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>
/// Against an in-process <see cref="TcpListener"/> that accepts the connection, the TCP
/// preflight succeeds. On this box the FANUC FWLIB native library is absent, so the
/// <c>cnc_allclibhndl3</c> P/Invoke throws <see cref="DllNotFoundException"/> (or a
/// related load failure). The probe MUST degrade gracefully — return <c>Ok=true</c> with
/// a "FWLIB absent ... TCP reachability only" note — proving no regression versus the
/// pre-Phase-5 TCP-only probe on FWLIB-less hosts.
/// Against an in-process <see cref="TcpListener"/> that accepts the connection but speaks no
/// FOCAS (drops each accepted socket), the TCP preflight succeeds but the Phase-2 wire
/// session can't complete the initiate handshake + <c>cnc_statinfo</c> read. The probe MUST
/// report <c>Ok=false</c> — a bare TCP listener is not a CNC. This is the Phase-8 fix: the
/// old probe degraded such a listener to <c>Ok=true</c> "FWLIB absent, TCP reachability
/// only", which made any TCP listener look HEALTHY.
/// </summary>
[Fact]
public async Task TcpReachable_FwlibAbsent_Degrades_To_OkTrue_WithReachabilityNote()
public async Task TcpReachable_NotACnc_Returns_OkFalse()
{
// Accept-only listener: completes the TCP handshake but speaks no FOCAS bytes.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
// Keep accepting so the connect always completes; ignore the accepted socket.
// Keep accepting so the connect always completes; drop the accepted socket.
_ = AcceptLoopAsync(listener, TestContext.Current.CancellationToken);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));
cts.CancelAfter(TimeSpan.FromSeconds(15));
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
var result = await Probe.ProbeAsync(
@@ -151,13 +150,11 @@ public sealed class FocasDriverProbeTests
TimeSpan.FromSeconds(3),
cts.Token);
// No FWLIB here → degrade, never worse than TCP-only.
result.Ok.ShouldBeTrue(
$"Expected degrade to Ok=true on an FWLIB-less host but got: {result.Message}");
// A bare listener is not a CNC — the FOCAS session fails, so the probe is NOT ok.
result.Ok.ShouldBeFalse(
$"Expected Ok=false for a non-CNC TCP listener but got: {result.Message}");
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("FWLIB absent");
result.Message!.ShouldContain("TCP reachability only");
result.Latency.ShouldNotBeNull();
result.Latency.ShouldBeNull();
}
finally
{
@@ -107,6 +107,36 @@ public sealed class FocasWireProtocolTests
finally { client.Dispose(); server.Dispose(); }
}
// The 10-byte header framing is identical across supported versions (only the version field
// differs) — older controls + the mock answer v1, modern controls answer v3 (FANUC 30i-B).
// Validated live 2026-06-25; see docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md.
[Theory]
[InlineData((ushort)1)]
[InlineData((ushort)3)]
public async Task ReadPduAsync_accepts_supported_version(ushort version)
{
var body = new byte[] { 9, 8, 7 };
var pdu = new byte[10 + body.Length];
new byte[] { 0xa0, 0xa0, 0xa0, 0xa0 }.CopyTo(pdu, 0);
BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(4, 2), version);
pdu[6] = FocasWireProtocol.TypeData;
pdu[7] = FocasWireProtocol.DirectionResponse;
BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(8, 2), (ushort)body.Length);
body.CopyTo(pdu.AsSpan(10));
var (client, server) = await ConnectedPairAsync();
try
{
await server.GetStream().WriteAsync(pdu);
var read = await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None);
read.Type.ShouldBe(FocasWireProtocol.TypeData);
read.Direction.ShouldBe(FocasWireProtocol.DirectionResponse);
read.Body.ShouldBe(body);
}
finally { client.Dispose(); server.Dispose(); }
}
// ---- BuildRequestBody framing ----
[Fact]
@@ -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(); }
}
}