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

291 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```c
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
```c
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
```c
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
```c
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 request**`cnc_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_SOCKET``EW_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
+ licensed Fwlib64.dll + real CNC are all available. See the implementer's
checklist in
[`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/README.md`](../../../tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/README.md).
### 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
- [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs) — P/Invoke surface, authoritative struct layouts
- [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs) — reference C# implementation of each FWLIB call
- [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs) — EW_* → OPC UA status mapping
- Fanuc FOCAS Developer Kit (licensed, not in repo) — ultimate source of truth
- `strangesast/fwlib` on GitHub — redistributes `fwlib32.h` + runtime binaries; no wire protocol docs