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
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Exploratory cnc_rdparam (0x000e) v3 request-framing probe against a live FANUC.
The committed WireFocasClient sends arg1=number, arg2=number(when axis 0), arg3=0 and
gets EW_FUNC(1) for a VALID parameter on the 31i-B. cnc_rdparam(h, number, axis, length,
&IODBPSD) needs axis + length, so this tries a matrix of (arg ordering, axis, length,
reqClass, extra) and prints which combos return rc=0 with a non-empty payload.
Read-only. Usage: python3 param-probe.py <host> [port]
"""
import socket
import sys
MAGIC = bytes([0xA0, 0xA0, 0xA0, 0xA0])
EMIT_VERSION = 1
def build_pdu(version, type_, direction, body):
h = bytearray(MAGIC) + version.to_bytes(2, "big") + bytes([type_, direction]) + len(body).to_bytes(2, "big")
return bytes(h) + body
def build_block(cmd, a1=0, a2=0, a3=0, a4=0, a5=0, req_class=1, path_id=1, extra=b""):
blk = bytearray()
blk += (0x1C + len(extra)).to_bytes(2, "big")
blk += req_class.to_bytes(2, "big")
blk += path_id.to_bytes(2, "big")
blk += cmd.to_bytes(2, "big")
for a in (a1, a2, a3, a4):
blk += int(a).to_bytes(4, "big", signed=True)
blk += (a5 & 0xFFFF).to_bytes(2, "big")
blk += len(extra).to_bytes(2, "big")
blk += extra
return bytes(blk)
def data_pdu(blocks):
body = bytearray(len(blocks).to_bytes(2, "big"))
for b in blocks:
body += b
return build_pdu(EMIT_VERSION, 0x21, 0x01, bytes(body))
def recv_exactly(sock, n):
buf = b""
while len(buf) < n:
try:
chunk = sock.recv(n - len(buf))
except socket.timeout:
return buf, False
if not chunk:
return buf, False
buf += chunk
return buf, True
def read_pdu(sock):
header, ok = recv_exactly(sock, 10)
if not ok or len(header) < 10:
return None
body_len = int.from_bytes(header[8:10], "big")
body, _ = recv_exactly(sock, body_len)
return body
def parse_first_block(body):
"""Return (cmd, rc, payload_hex) of the first response block, or None."""
if not body or len(body) < 2:
return None
count = int.from_bytes(body[0:2], "big")
if count == 0 or len(body) < 18:
return (None, None, "")
blk_len = int.from_bytes(body[2:4], "big")
cmd = int.from_bytes(body[8:10], "big")
rc = int.from_bytes(body[10:12], "big", signed=True)
payload_len = int.from_bytes(body[16:18], "big")
payload = body[18:18 + payload_len]
return (cmd, rc, " ".join(f"{x:02x}" for x in payload) or "(empty)")
class Sess:
def __init__(self, host, port):
self.host, self.port = host, port
self.s1 = self.s2 = None
def _sock(self):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
s.settimeout(6)
s.connect((self.host, self.port))
return s
def connect(self):
self.s1 = self._sock()
self.s1.sendall(build_pdu(EMIT_VERSION, 0x01, 0x01, (1).to_bytes(2, "big")))
read_pdu(self.s1)
self.s2 = self._sock()
self.s2.sendall(build_pdu(EMIT_VERSION, 0x01, 0x01, (2).to_bytes(2, "big")))
read_pdu(self.s2)
# mirror the client's setup requests
self.req([build_block(0x0018, path_id=1)])
self.req([build_block(0x000E, 0x26F0, 0x26F0, path_id=1)])
def req(self, blocks):
self.s2.sendall(data_pdu(blocks))
return read_pdu(self.s2)
def close(self):
for s in (self.s2, self.s1):
try:
s and s.close()
except Exception:
pass
def main():
host = sys.argv[1] if len(sys.argv) > 1 else "10.201.31.5"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8193
# 8130 = total controlled axes (global, always present); 1320 = +stroke limit (axis);
# 1825 = servo loop gain (axis); 3201 = setting. All exist on a 31i.
params = [8130, 1320, 1825, 3201]
# (label, builder(P)) — each builder returns a single request block for param P
variants = [
("cur arg1=P,arg2=P,a3=0,rc1", lambda P: build_block(0x000E, P, P, 0, 0, req_class=1)),
("arg2=0(axis),a3=0,rc1", lambda P: build_block(0x000E, P, 0, 0, 0, req_class=1)),
("arg2=0,a3=8(len),rc1", lambda P: build_block(0x000E, P, 0, 8, 0, req_class=1)),
("arg2=0,a3=36(len),rc1", lambda P: build_block(0x000E, P, 0, 36, 0, req_class=1)),
("arg2=0,a3=4(len),rc1", lambda P: build_block(0x000E, P, 0, 4, 0, req_class=1)),
("arg2=-1(allaxis),a3=8,rc1", lambda P: build_block(0x000E, P, -1, 8, 0, req_class=1)),
("arg2=1(axis1),a3=8,rc1", lambda P: build_block(0x000E, P, 1, 8, 0, req_class=1)),
("arg2=0,a3=8,rc2", lambda P: build_block(0x000E, P, 0, 8, 0, req_class=2)),
("arg2=0,a3=36,rc2", lambda P: build_block(0x000E, P, 0, 36, 0, req_class=2)),
("len-first arg1=8,arg2=P,rc1", lambda P: build_block(0x000E, 8, P, 0, 0, req_class=1)),
("len-first arg1=36,arg2=P,rc1", lambda P: build_block(0x000E, 36, P, 0, 0, req_class=1)),
("arg2=0,a3=0,a4=8,rc1", lambda P: build_block(0x000E, P, 0, 0, 8, req_class=1)),
("arg2=0,a5=8,rc1", lambda P: build_block(0x000E, P, 0, 0, 0, a5=8, req_class=1)),
("extra: datano+type+len(8)", lambda P: build_block(0x000E, P, 0, 8, 0, req_class=1,
extra=P.to_bytes(2, "big") + b"\x00\x00" + (8).to_bytes(2, "big"))),
]
sess = Sess(host, port)
sess.connect()
print(f"# cnc_rdparam v3 probe {host}:{port}\n")
hits = []
try:
for P in params:
print(f"--- param {P} ---")
for label, build in variants:
body = sess.req([build(P)])
parsed = parse_first_block(body)
if parsed is None:
print(f" {label:32s} -> (no/short response)")
continue
cmd, rc, payload = parsed
mark = " <== HIT" if rc == 0 and payload not in ("", "(empty)") else ""
print(f" {label:32s} -> rc={rc} payload={payload}{mark}")
if mark:
hits.append((P, label, payload))
print()
finally:
sess.close()
print("# HITS (rc=0, non-empty payload):")
for P, label, payload in hits:
print(f" param {P}: {label} -> {payload}")
if not hits:
print(" (none — cnc_rdparam may be genuinely restricted on this control)")
if __name__ == "__main__":
main()