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:
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Passive FOCAS/2 wire capture for protocol-version scoping.
|
||||
|
||||
Sends ONLY the initiate PDU the OtOpcUa WireFocasClient sends (a read-only handshake;
|
||||
NO data/write PDUs) and dumps the raw response so the real wire framing — notably the
|
||||
PDU version field — can be inspected. Used to diagnose the 30i-B PDU-v3 gap; see
|
||||
docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md.
|
||||
|
||||
Usage: python3 scripts/focas/capture-initiate.py <host> [port]
|
||||
"""
|
||||
import socket, sys
|
||||
|
||||
MAGIC = bytes([0xA0, 0xA0, 0xA0, 0xA0])
|
||||
|
||||
|
||||
def build_pdu(version, type_, direction, body):
|
||||
h = bytearray(MAGIC)
|
||||
h += version.to_bytes(2, "big")
|
||||
h += bytes([type_, direction])
|
||||
h += len(body).to_bytes(2, "big")
|
||||
return bytes(h) + body
|
||||
|
||||
|
||||
def hexdump(b):
|
||||
return " ".join(f"{x:02x}" for x in b)
|
||||
|
||||
|
||||
def parse_header(b):
|
||||
if len(b) < 10:
|
||||
return f"(short read, {len(b)} bytes)"
|
||||
return (f"magic={hexdump(b[0:4])} version={int.from_bytes(b[4:6],'big')} "
|
||||
f"type=0x{b[6]:02x} dir=0x{b[7]:02x} bodyLen={int.from_bytes(b[8:10],'big')}")
|
||||
|
||||
|
||||
def try_initiate(host, port, version, socket_index):
|
||||
print(f"\n=== initiate header version={version}, socketIndex={socket_index} ===")
|
||||
pdu = build_pdu(version, 0x01, 0x01, socket_index.to_bytes(2, "big"))
|
||||
print(f" -> {len(pdu)} bytes: {hexdump(pdu)}")
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(6)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
s.sendall(pdu)
|
||||
resp = b""
|
||||
try:
|
||||
while len(resp) < 512:
|
||||
chunk = s.recv(512 - len(resp))
|
||||
if not chunk:
|
||||
break
|
||||
resp += chunk
|
||||
if len(resp) >= 10 and len(resp) >= 10 + int.from_bytes(resp[8:10], "big"):
|
||||
break
|
||||
except socket.timeout:
|
||||
pass
|
||||
print(f" <- {len(resp)} bytes: {hexdump(resp)}")
|
||||
print(f" <- header: {parse_header(resp)}")
|
||||
if len(resp) > 10:
|
||||
print(f" <- body: {hexdump(resp[10:])}")
|
||||
except Exception as e:
|
||||
print(f" !! {type(e).__name__}: {e}")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
host = sys.argv[1]
|
||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8193
|
||||
try_initiate(host, port, 1, 1) # what OtOpcUa sends today (header version=1)
|
||||
try_initiate(host, port, 3, 1) # probe whether the CNC negotiates on our advertised version
|
||||
Reference in New Issue
Block a user