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,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()
|
||||
Reference in New Issue
Block a user