Files
lmxopcua/docs/v2/implementation/focas-wire-protocol.md
Joseph Doherty 33054c3275 docs: drop dangling FOCAS refs + link unreferenced v2 design docs
- docs/drivers/FOCAS.md and docs/v2/implementation/focas-wire-protocol.md
  pointed at focas-deployment.md and focas-simulator-plan.md, both of
  which were untracked drafts that have since been removed. Drop the
  refs (the wire-protocol companion now stands on its own; deployment
  guidance lives inline in the FOCAS driver doc).
- Link the orphan v2 design docs from docs/README.md (multi-host
  dispatch, v2 release readiness, the historical lmx-followups tracker)
  and from modbus-test-plan.md (s7.md, mitsubishi.md per-family quirk
  catalogs, sibling to dl205.md).

Surfaced by the doc audit; no content changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:42:28 -04:00

12 KiB
Raw Blame History

FOCAS wire protocol — what's authoritative vs. what's guessed

Written during Stream B on 2026-04-23 after a research pass through strangesast/fwlib + public FOCAS documentation. Purpose: separate what we know about the FOCAS wire protocol (can quote with confidence) from what we're guessing (will need Wireshark traces to validate in Stream C).

This document directly informs tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/server/.

Authoritative — from Fanuc's public fwlib32.h

The header file is distributed with the FOCAS Developer Kit and mirrored in OSS repos (notably strangesast/fwlib). The struct layouts documented there are stable across FOCAS versions and authoritative for the payload shapes our Python mock has to emit.

ODBM — macro variable read buffer

typedef struct odbm {
    short datano;    // macro variable number
    short dummy;     // reserved / alignment padding
    long  mcr_val;   // 32-bit signed macro value
    short dec_val;   // decimal-point count (0-9)
} ODBM;

With #pragma pack(push, 4) (the FOCAS default), total size is 10 bytes on Windows: 2 + 2 + 4 + 2. Our FwlibNative.cs matches this exactly.

Our mock's _READ_RESP_STRUCT = struct.Struct(">iH") is only 6 bytes — missing datano + dummy. A real Fwlib decoding the scaffold response will read garbage. Stream C fix: prepend two short fields.

IODBPSD — CNC parameter read/write buffer

typedef struct iodbpsd {
    short datano;       // parameter number
    short type;         // axis index (0 for non-axis parameters)
    union {
        char  cdata;
        short idata;
        long  ldata;
        char  cdatas[MAX_AXIS];   // MAX_AXIS varies — 8 on 0i, 32 on 30i
        short idatas[MAX_AXIS];
        long  ldatas[MAX_AXIS];
    } u;
} IODBPSD;

With pack(4) and MAX_AXIS=8, total size = 2 + 2 + 32 = 36 bytes. Our FwlibNative.cs matches this ([SizeConst = 32] data buffer).

Our mock's current param handler doesn't return bytes in IODBPSD shape — response payload is just the raw value. Stream C fix: wrap in 4-byte header

  • union-padded data.

ODBST — status info

typedef struct odbst {
    short dummy;      // reserved
    short tmmode;     // Memory / Tape / MDI / EDIT / DNC
    short aut;        // automatic mode
    short run;        // running state
    short motion;     // motion state
    short mstb;       // M/S/T/B finish signal
    short emergency;  // emergency stop
    short alarm;      // alarm state
    short edit;       // edit mode sub-state
} ODBST;

9 × short = 18 bytes. Our mock already emits 18 bytes via struct.Struct(">9h"). ✓ correct.

IODBPMC — PMC range read/write buffer

typedef struct iodbpmc {
    short type_a;       // PMC address letter encoded as ADR_* numeric code
    short type_d;       // data type: 0=byte, 1=word, 2=long, 4=float, 5=double
    unsigned short datano_s;  // start address number
    unsigned short datano_e;  // end address number
    union {
        char   cdata[5];
        short  idata[5];
        long   ldata[5];
        float  fdata[5];
        double dbdata[5];
    } u;   // 40-byte union (widest = dbdata = 5×8 bytes)
} IODBPMC;

With pack(4) the union is 40 bytes; struct total = 8 + 40 = 48 bytes. Our FwlibNative.cs matches this.

Our mock's PMC handler takes a different layout (uint16 handle + uint8 letter

  • ...). Stream C fix: rewrite to IODBPMC shape.

Reference trace findings (2026-04-23 dev-box reversing)

Good news — we don't need a bench CNC for first-pass reversing. Loading Fwlib64.dll in otopcua-focas-cli + pointing it at our Python simulator on 127.0.0.1:8193 + enabling OTOPCUA_FOCAS_RAW_CAPTURE=1 on the sim lets us observe Fwlib's outbound bytes + iterate on reply shapes. Each cycle is ~5s; progress measure is "Fwlib sends more bytes before disconnecting".

Confirmed wire facts

Magic prefix — every frame Fwlib sends begins with 0xA0 0xA0 0xA0 0xA0 (4 bytes). This is NOT a length prefix — our scaffold tried to decode it as uint32-big-endian = 2.7 GB and died. It's a fixed protocol marker.

Handshake requestcnc_allclibhndl3 produces this 8-byte frame:

a0 a0 a0 a0   00 01 01 01
└─ magic ─┘   └── negotiation ──┘

The 4-byte negotiation field is stable across our observations (always 00 01 01 01). Interpretation TBD — possibly (version_major=0x0001, version_minor=0x0101) or (protocol=0x01, subtype=0x010101).

Handshake reply that Fwlib accepts (empirically confirmed — doesn't disconnect):

a0 a0 a0 a0   00 01 01 01   00 XX   00 YY
└─ magic ─┘   └── echo ──┘   handle  api_version

12 bytes: magic + echoed negotiation + 2-byte handle + 2-byte api_version code.

Post-handshake frame shape — decoded via drain mode

The simulator's OTOPCUA_FOCAS_DRAIN_AFTER_HANDSHAKE=1 mode reads all inbound bytes for 1000 ms after the handshake reply without attempting any decode. Captured payload from cnc_allclibhndl3:

00 02 00 02   a0 a0 a0 a0   00 01 21 01 00 00
└── prefix ─┘   └── magic ─┘   └─── body ────┘
  4 bytes         4 bytes         6 bytes     (total = 14 bytes)

Key discovery: post-handshake frames have a 4-byte prefix BEFORE the magic, not magic-first. Frame shape:

uint16  msg_counter     // starts at 2; handshake was #1 implicitly
uint16  handle_echo     // matches the handle our open reply returned
4 bytes FOCAS_MAGIC     // 0xA0A0A0A0
N bytes body            // function-specific

Session 1's drain captured only the prefix (00 02 00 01) before timing out — TCP multiplexed the two test sessions's bytes differently. Session 2 caught the full 14-byte frame.

Body bytes — first post-handshake request

Body on cnc_allclibhndl3 first post-handshake frame:

00 01 21 01 00 00

Informed guesses (unvalidated):

  • 00 01 = body length (1 useful byte?) or sub-request count
  • 21 01 = function code / operation tag — 0x21 is seen in public FOCAS reverse-engineering notes associated with "system info" / "controller identification" queries
  • 00 00 = padding / reserved

Likely this is Fwlib's "tell me what CNC you are" query — part of cnc_allclibhndl3's internal handshake continuation before the handle is fully established. Returning an empty or malformed response causes Fwlib to declare the far end "not a CNC" and error with EW_FUNC (16).

Iteration 3 — echo response, error-code advances

Sending back <prefix><magic><echoed body> (14 bytes matching request shape) advances Fwlib's client-side error code from EW_-16 (socket-level) to EW_-17 (protocol-level rejection). Fwlib reads our response in full before disconnecting with peer closed mid-frame.

Meaning: our frame structure is correct enough that Fwlib parses it as a valid FOCAS frame; the body content (the 6 bytes after magic) is where the semantic mismatch now lives. Fwlib expects specific bytes back for the 0x2101 system-info query and an echo doesn't match.

Current iteration block

Going deeper without reference requires either:

  • A bench CNC (#54) to capture a real response to the 0x2101 query. Stream C.2 Wireshark trace gives us the exact byte pattern Fwlib expects.
  • Published FOCAS response specs for sub-function 0x2101 — not present in strangesast/fwlib headers; likely only in the licensed Developer Kit binary docs.
  • Blind enumeration — try N variations of the 6-byte body response until Fwlib's error code changes again. High cost, low signal.

The first two are both blocked on resources we don't have. The third is ~hundreds of cycles with no guarantee of convergence.

Diminishing-returns checkpoint

What we've proven without hardware:

  1. Magic prefix 0xA0A0A0A0 confirmed
  2. Handshake request format decoded (magic + 4-byte negotiation)
  3. Handshake response format that Fwlib accepts (magic + echo + handle + api)
  4. Post-handshake frame format decoded (prefix + magic + body)
  5. First post-handshake function code observed (0x2101 — likely system-info)
  6. Error code progression EW_SOCKETEW_PROTOCOL confirms our framing is structurally correct

What we can't prove without bench CNC or reference docs:

  1. The exact 6-byte response body Fwlib expects for 0x2101
  2. The full list of post-handshake function codes + their body shapes
  3. Whether subsequent frames use length prefixes or fixed body sizes

Recommendation: checkpoint here. The framing discoveries above are preserved in server/frames.py + server/state.py + server/focas_server.py

  • server/handlers/__init__.py. When bench-CNC access unblocks Stream C.2's reference trace, the iteration loop (with the framing work already done) should converge in hours rather than days.

Still unknown

  • Response shape for the post-handshake body request — we can frame the prefix + magic correctly now, but what the 6-byte body response should carry (CNC series ID? version? capability flags?) needs further iteration.
  • Function-id numeric values for the 9 FWLIB calls our driver makes — one per call, need to be observed separately.
  • Error encoding on the wire.

Next iteration cycles

With the handshake working, each subsequent function gets its own probe-and-observe loop. The simulator now has a RAW_FRAME_MARKER = 0xFFFF sentinel that lets a handler return exact wire bytes (bypassing the scaffold envelope) — use that to try different post-handshake replies and watch Fwlib's reaction.

Stream C work order

Given what's authoritative vs. guessed, here's the most efficient path:

Phase 1 — payload shapes (no hardware required)

  • Rewrite server/handlers/macro.py response to return 10-byte ODBM: short datano, short dummy, int32 mcr_val, short dec_val
  • Rewrite server/handlers/param.py response to return 36-byte IODBPSD: short datano, short type, bytes[32] u
  • Rewrite server/handlers/pmc.py response to return 48-byte IODBPMC: short type_a, short type_d, uint16 datano_s, uint16 datano_e, bytes[40] u
  • Add unit tests asserting byte-exact sizes
  • Update validate_harness.py to match the new shapes

Effect: when Stream C gets its first Wireshark trace, the payload-layer of the mock is already correct. Only the framing layer needs iteration.

Phase 2 — framing (requires hardware)

This is the iterative Wireshark loop — no point starting until the Windows rig

Phase 3 — flip the C# test gate

Once Phase 2 proves Fwlib64 can talk to the mock:

  • Flip OTOPCUA_FOCAS_SIM_WIRE_COMPAT=1 in the CI env
  • Expand tests/.../IntegrationTests/Series/WireCompatGatedTests.cs with real per-series assertions
  • Update scripts/e2e/test-focas.ps1 to accept -ProfileName
  • Close Stream D

References