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:
Binary file not shown.
@@ -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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user