# FOCAS Driver Getting-started guide for the FANUC FOCAS2 driver. This is the short path — for the exhaustive per-node mapping read [`docs/v2/driver-specs.md §7`](../v2/driver-specs.md), for the test-harness map read [FOCAS-Test-Fixture.md](FOCAS-Test-Fixture.md). ## What it talks to FANUC CNCs (0i-D / 0i-F / 0i-MF / 0i-TF / 16i / 30i / 31i / 32i / Power Motion i) over the proprietary FOCAS2 protocol on TCP port 8193. The wire is spoken directly by the pure-managed [`Focas.Wire`](https://github.com/Ladder99/focas-mock) client — no Fwlib64.dll, no P/Invoke, no out-of-process isolation needed. OtOpcUa is **read-only** against FOCAS; all reads go over the native wire protocol using the documented command IDs. Writes return `BadNotWritable` by design. > **Equipment tags are forced read-only (R2-11, migration note).** The equipment-tag parser > (`FocasEquipmentTagParser`) now hard-codes `Writable: false` regardless of any authored > `"writable": true` — previously such a tag advertised a writable node whose write always failed at > the wire with `BadNotWritable`; now it fails one step earlier at the driver seam (same family of Bad > status, no successful operation changes). The AdminUI FOCAS editor shows the Writable checkbox > disabled/unchecked, and the deploy gate warns when a config requests `writable:true`. Additionally, > equipment-tag references now run the same capability-matrix pre-flight authored tags get: a reference > whose address violates the device series' matrix fails to resolve (`BadNodeIdUnknown`) instead of > reaching the wire. Honour `writable` here once PMC writes ship in the wire client. > **PDU version (v3 / FANUC 30i/31i).** The wire client **accepts inbound PDU versions `{1, 3}`** > (`FocasWireProtocol`) while still emitting v1 on requests. Older controls + the docker mock answer > v1; modern controls (Makino Pro 5 / FANUC **31i-B**) answer v3. **Validated live against a real > 31i-B (`10.201.31.5`) 2026-06-25:** sysinfo, axis/spindle names, dynamic positions/feed/spindle, > program/mode, **macros**, **timers** (8-byte payload is little-endian {minute, msec}), **PMC range** > (request widened to the data-type byte width), **servo meters** (8-byte LOADELM stride + names from > the 0x0089 block), and **alarms** all read correctly. Several *decode* errors that survived that > validation (because they decode without failing — just wrongly) were later corrected from the > sister ignitionoee driver's live work on the same family of controls; see > [Wire decode corrections (2026-10)](#wire-decode-corrections-2026-10). The driver `cnc_rddynamic2` poll is 1-based. **`cnc_rdparam` is unsupported on this > control** — every request-framing variant returns `EW_FUNC` (likely a wrong v3 command id; needs a > reference FWLIB trace). The Test-Connect probe now runs a real wire session (initiate + cnc_statinfo) > instead of a bare TCP check. Full finding, captured wire bytes, validation results, and analysis: > [`docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`](../plans/2026-06-25-focas-pdu-v3-30i-b-support.md) > + [`…-implementation-plan.md`](../plans/2026-06-25-focas-pdu-v3-implementation-plan.md). > Capture tools: `scripts/focas/capture-initiate.py`, `scripts/focas/capture-v3.py`. ## Project split | Project | Target | Role | |---------|--------|------| | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/` | net10.0 | In-process driver — hosts `WireFocasClient` which speaks FOCAS2 over TCP directly | Previous `Driver.FOCAS.Host` / `Driver.FOCAS.Shared` Tier-C split has been retired — the managed wire client removes the native-crash blast radius that justified the out-of-process service. ## Minimum deployment Register the driver instance in the main server's `appsettings.json`. No separate service, no DLL deployment, no shared-secret handshake: ```jsonc "Drivers": { "focas-cnc-1": { "Type": "FOCAS", "Config": { "Backend": "wire", "Devices": [ { "HostAddress": "focas://10.20.30.40:8193", "Series": "ThirtyOne_i" } ], "Tags": [ { "Name": "Mode", "DeviceHostAddress": "focas://10.20.30.40:8193", "Address": "PARAM:3402", "DataType": "Int32", "Writable": false }, { "Name": "SpndLoad", "DeviceHostAddress": "focas://10.20.30.40:8193", "Address": "MACRO:500", "DataType": "Float64", "Writable": false } ] } } } ``` The main server opens two TCP sockets per configured device and speaks the FOCAS2 binary protocol directly. No local privileged components, no platform bitness constraint — the driver runs on every host OtOpcUa runs on. ## Address forms | Form | Example | Meaning | |------|---------|---------| | `X0.0` / `R100` / `R100.3` | PMC bit or byte | Letter + number; optional `.bit` for bit access | | `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index | | `MACRO:500` | Custom macro variable | System / user macro variable number | | `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only | | `ALARM:*` / `ALARM:Servo/401-453` / `ALARM:OTHER` | CNC alarm selector | One Part 9 condition per tag, with the `alarm` object — see [Alarm conditions](#alarm-conditions-alarm-gitea-647) | PMC letters are series-gated by `FocasCapabilityMatrix.PmcLetters`; the 16i accepts `X Y F G R D` (F/G are the CNC-to-PMC / PMC-to-CNC handshake groups its ladders use, #265 — reads only, FOCAS stays read-only), while `M C E A K T` remain 0i-F / 30i-family. The first three name a location the driver asks the CNC for on demand. `FIXED:` does not: it names a field of the per-device fixed-tree snapshot the driver's own poll loop already maintains, so a `FIXED:` read issues no wire traffic. It exists because the fixed tree is the only part of the FOCAS surface with no address of its own, and v3 publishes nodes only for authored tags — so without a form to author, the fixed tree could not be published at all. **An address never carries the device.** A tag's device comes from its raw-tag Device row; an address that also named a host would be a second, conflicting routing channel. This is worth stating because it is exactly what went wrong in [#540](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/540): browse leaves advertised `{deviceHost}/{path}` as their reference, the browse-commit wrote that into the address, and every committed tag read `BadNodeIdUnknown` forever. Addresses are validated against the per-device `Series` at `InitializeAsync` — a config referencing a number outside the documented range for that series fails at load time with an error message naming the limit. See [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) for the authoritative range table. (`FIXED:` paths have no numeric range to check; a path the machine does not expose reads `BadNodeIdUnknown`.) An address the parser cannot read is rejected **when you author it** — by the `/raw` tag modal, and by the deploy-time inspector for config arriving via CSV import or the API. It is not a warning you can deploy past into a driver that skips the tag at startup. ## Backend selection The driver picks its client from `Config.Backend`: | Value | Client | Use it for | |-------|--------|------------| | `wire` (default) | `WireFocasClient` | Production — pure-managed FOCAS2 over TCP | | `unimplemented` / `none` / `stub` | `UnimplementedFocasClientFactory` | Scaffolding a DriverInstance row before the CNC endpoint is reachable | **Fail-fast on `unimplemented` / `none` / `stub`:** a driver instance configured with any of these backends now **faults immediately at `InitializeAsync`** with a clear error message rather than reporting healthy and then failing on the first read/write/subscribe. The driver moves to `Faulted` state and the error is visible on the Admin UI driver-status panel. This is intentional — an operator who forgets to switch to `"wire"` before deploying to production sees a driver fault at startup, not a phantom-healthy driver that silently rejects every request. Previous backends (`fwlib`, `fwlib32`, `ipc`) have been retired along with `Driver.FOCAS.Host` and the Fwlib P/Invoke path. Configs that still reference them will throw at startup with a message pointing here. ## Capability surface | Capability | Wire path | Notes | |------------|-----------|-------| | `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally | | `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented, and none will be (policy: the stranded write/unlock backlog items #268–#271 are won't-do; a write path on a production CNC is a safety decision, not a feature gap) | | `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device | | `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups | | `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` | | `IPerCallHostResolver` | lookup in `_tagsByName` | Each call routes to the device of the referenced tag | | `IAlarmSource` | polled `cnc_rdalmmsg2` via `FocasAlarmProjection` | One condition per authored `ALARM:` tag at its RawPath — see [Alarm conditions](#alarm-conditions-alarm-gitea-647) | Ack is a no-op — FANUC clears alarms on its own once the underlying condition resolves, so `AcknowledgeAsync` swallows the batch rather than surfacing `BadNotSupported`; the Part 9 acknowledge is applied server-side. ## Fixed node tree Enable a pre-defined hierarchy of CNC nodes populated automatically from `cnc_sysinfo` + `cnc_rdaxisname` + `cnc_rddynamic2` + related FWLIB calls, so operators get an out-of-the-box view of identity / axes / program / timers without declaring per-address tags. ```jsonc "Config": { "Devices": [ ... ], "Tags": [ ... ], "FixedTree": { "Enabled": true, "PollInterval": "00:00:00.250", // fast — per-axis dynamic reads "ProgramPollInterval": "00:00:01", // medium — program + mode changes "TimerPollInterval": "00:00:30" // slow — cumulative counters } } ``` `Enabled` defaults to **false** so an upgrade does not grow nodes on configs that never asked for them. You do not have to set it to use the tree: **authoring any `FIXED:` tag arms the poll loop by itself**, because committing those tags already says you want it. The flag remains the way to publish the tree without authoring anything (and the browser turns it on for the duration of a browse). ### Authoring fixed-tree tags Browse the device (`/raw` → device → **Browse device…**, or the tag modal's **Browse machine**) and commit the leaves you want. Each leaf carries its own `FIXED:` address, so the commit produces working tags with no editing. The manual composer next to the browser covers PMC / `PARAM:` / `MACRO:` only — fixed-tree paths are discovered from the machine, not composed from parts. Two caveats worth knowing before you browse: - **A CNC that reports duplicate sibling names is disambiguated automatically** (#548): every member of a collision set gets a 1-based occurrence suffix in slot order (`Axes/AA_1/`, `Axes/AA_2/`), each reading its own axis, while unique names stay bare (`Axes/Y/`). ⚠️ The Makino that motivated #548 does **not** in fact report duplicates — its axes are `AA6` and `AA7`, and the wire layer used to read only 2 of each 4-byte name record. Since the name-record fix those axes publish as `Axes/AA6/` and `Axes/AA7/`; tags committed against `Axes/AA_1/…` / `Axes/AA_2/…` on such a machine must be re-browsed and re-committed. The bare name of a duplicate set deliberately does not resolve: a tag authored against `Axes/AA/…` on such a machine reads `BadNodeIdUnknown` rather than one arbitrary axis's data. The suffix counts occurrences of the *same* name, so adding an unrelated axis never renames an existing pair — but if the collision-set membership itself changes (a third `AA` appears, or one of two is removed), the segments change on the next browse, and previously committed tags on the old segments read Bad until re-committed. The same rule covers spindles. - **Values are dated from the last answered poll, not from the read.** If a CNC stops answering, its fixed-tree values go `BadCommunicationError` rather than continuing to serve the last snapshot as `Good`; the budget is eight poll cadences, floored at 30 s. What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as `FIXED:{subtree}/{field}`): | Subtree | Nodes | Source call | |---------|-------|-------------| | `Identity/` | `SeriesNumber`, `Version`, `MaxAxes`, `CncType`, `MtType`, `AxisCount` | `cnc_sysinfo` once at bootstrap | | `Axes/{name}/` | `AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`, `ServoLoad` — one folder per discovered axis | `cnc_rdaxisname` once + `cnc_rddynamic2` + `cnc_rdsvmeter` per tick | | `Axes/FeedRate/Actual`, `Axes/SpindleSpeed/Actual` | Current feed + spindle RPM | `cnc_rddynamic2` | | `Spindle/{name}/` | `Load` (Float64, % of rated load), `MotorSpeedPct` (Float64, % of maximum speed) — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspmeter` type 0 + type 1 per tick | | `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` — a field the CNC did not report (refused sub-read, payload without an O-number) reads `BadNoData`, never `0` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` | | `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` | | `Status/` | `Auto`, `Run`, `Motion`, `Mstb`, `Emergency`, `Alarm`, `Edit`, `TmMode` (raw FANUC codes, Int32) + `RunText` ("RESET", "STOP", "HOLD", "START", "MSTR") — see [CNC status flags](#cnc-status-flags-status) | `cnc_statinfo` (polled on `ProgramPollInterval`) | | `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 | ### CNC status flags (`Status/`) `cnc_statinfo` (the `ODBST` block) is exposed as read-only `FIXED:Status/` nodes (#257). Values are the control's raw codes, published verbatim as Int32 so a client sees exactly what the CNC reported: | Field | Codes | |---|---| | `Run` | 0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR (`RunText` is the label) | | `Auto` | operation mode (0 MDI, 1 MEM, 3 EDIT, 4 HND, 5 JOG, … — control-dependent) | | `Motion` | 0 none, 1 axis motion, 2 dwell | | `Mstb` | 0 idle, 1 executing an M/S/T/B function | | `Emergency` | 0 not, 1 emergency stop, 2 reset | | `Alarm` | 0 none, 1 alarm, … (battery-low etc. are control-dependent) | | `Edit`, `TmMode` | program-edit state; T/M mode | Author one the same way as any fixed-tree tag — `FIXED:Status/Run` (Int32), `FIXED:Status/RunText` (String). Live (hw2 gate, 2026-10-08, Makino Z-34184 31i-B, idle): `cnc_statinfo` answers a 14-byte primary block and read steadily `Auto=3 Run=0 (RESET) Motion=0 Mstb=0 Emergency=0 Alarm=0 Edit=0 TmMode=0` over 6 minutes, with an empty `cnc_rdalmmsg2` list — consistent. ⚠️ `Auto=3` (EDIT on the table above) disagrees with `OperationMode/ModeText`, which read `AUTO` (`cnc_rdopmode` 0x0057 = 1) throughout; ignitionoee independently found 0x0057 toggling 1↔2 with no explained meaning on the same machine, so treat `OperationMode/` as unconfirmed on the 31i-B and confirm `Auto` against the panel before relying on either. Like `Program/`, the subtree is gated on a bootstrap probe: a control that refuses `cnc_statinfo` gets no `Status/` nodes and the rest of the tree is unaffected. Reads are served from the poll snapshot, so they issue no wire traffic and go `BadCommunicationError` once the device has stopped answering. ### Per-series node suppression The driver probes each optional call once at bootstrap. If the target CNC returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the corresponding subtree is **not emitted** — the operator doesn't see nodes that will only ever return `BadDeviceFailure`. Capability suppression covers `Spindle/`, `Program/` + `OperationMode/`, `Status/`, `Timers/`, and per-axis `ServoLoad` independently. Identity + `Axes/*` position reads (which every Fanuc CNC supports) are always emitted. Position values (`AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`) are published as `Float64` in engineering units. **Each `cnc_rddynamic2` position block carries its own scale** — `[i32 raw][u16 base][u16 exponent]`, value = `raw / base^exponent` (a real 31i-B reports base 10 / exponent 4: `2801574` → `280.1574` mm) — and the driver always applies it. A block that describes no usable position (the `0xFFFF` vacant-slot marker, a base other than 2 or 10, an exponent > 18) publishes `BadNoData` on that node alone. `PositionDecimalPlaces` (per device, default `0`) is now only the **fallback for a block that carries no scale** (a 4-byte payload, e.g. an older simulator): such a position is divided by `10^PositionDecimalPlaces`. Negative values are clamped to `0`. ```jsonc "Devices": [ { "HostAddress": "focas://10.20.30.40:8193", "Series": "ThirtyOne_i", "PositionDecimalPlaces": 3 // only used if the CNC's blocks carry no scale } ] ``` There is no `cnc_getfigure` read: the `0x00D3` command the driver used to send is not a FOCAS wire command (a real 31i-B refuses it with `rc=1`), so on hardware every axis had silently fallen back to `PositionDecimalPlaces` and published raw counts. **Still hand-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific numbers whose meaning is MTB-specific, and which no browse can discover. Those go under the device folder alongside the fixed subtree. ### Parts count (`MACRO:3901`, or `PARAM:6711`) There is deliberately **no** dedicated parts-count node (#258): the count is an ordinary CNC value, so it is already readable as a hand-authored tag, and a fixed `Parts/` subtree would hard-code numbers that are control- and MTB-specific. Two addresses carry it; **which one works depends on the control**: | Address | What | Use when | |---|---|---| | `MACRO:3901` | Custom macro system variable #3901 — parts machined (`#3902` = parts required) | **Recommended.** Works over the wire on a Makino 31i-B (Z-34184, G431), where `cnc_rdparam` is refused | | `PARAM:6711` | CNC parameter No. 6711 — number of parts machined (`6712` total, `6713` required) | Controls that answer `cnc_rdparam` (standard FANUC 0i / 16i / 30i parameter map) | ⚠️ **`PARAM:` reads do not work on the Makino.** Its 31i-B answers `cnc_rdparam` (0x000E) with `EW_FUNC`, so a `PARAM:6711` tag reads `BadNotSupported` there. `cnc_rdmacro` is accepted, so `MACRO:3901` is the address to author on that machine. Both are Int32 tags. The count-up trigger (which M code or cycle end increments the counter) is set by No. 6710 and bit 0 of No. 6700, so confirm in the machine's manual that the counter is actually enabled before relying on either address — a disabled counter reads a constant `0` (a *vacant* macro reads `BadNoData`, never `0`; see [Wire decode corrections](#wire-decode-corrections-2026-10)). A `0` from `MACRO:3901` is therefore a valid decode of "counter not counting", not a driver fault. Cycle time per part is not provided as a node — see the [virtual-tag recipe](#recipe-cycle-time-per-part-virtual-tag-272). ### Recipe: feed / rapid / spindle override via PMC (#259) The CNC's override values are ordinary PMC signals (the MTB ladder copies the operator-panel switches into the G area), so they are readable today with plain PMC tags — read-only, no new driver surface. On the standard FANUC signal map: | Signal | PMC address | Notes | |---|---|---| | Feed override `*FV0-*FV7` | `G12` (Byte) | **Inverted**: the byte holds the one's complement. Percent = `~value & 0xFF` for a binary-coded switch (raw `0xFF` = 0%, raw `0x9B` = 100%); gray-coded/ladder-converted panels differ | | Rapid override `ROV1`, `ROV2` | `G14.0`, `G14.1` (Bit) | Two bits select 100% / 50% / 25% / F0 | | Spindle override `SOV0-SOV7` | `G30` (Byte) | Percent, usually a direct 0-150 value — mask it (`value & 0xFF`): 128-150 % read negative | Author them as normal PMC tags (`G12`, `G14.0`, `G30` — G is accepted on the 30i family and the 16i). ⚠️ A `Byte` tag is **signed** (`sbyte`, published as Int32), so a raw byte above `0x7F` reads negative: on the Makino Z-34184 at 100 % feed override `G12` reads **`-101`** (raw `0x9B` = 155). `255 - value` would therefore give 356, not 100 — mask before inverting. To publish a percentage rather than the raw byte, derive it with a virtual tag (`~(int)ctx.GetTag("{{equip}}/FeedOverrideRaw").Value & 0xFF` for `G12`; `(int)… & 0xFF` for `G30`). Read-only live check (hw2 gate, 2026-10-08, Makino Z-34184 idle at the panel's 100 % positions): `G12 = -101` (→ 100 %), `G14.0 = G14.1 = false` (→ rapid 100 %), `G30 = 100` (→ 100 %). Plausible against the standard map; not yet cross-checked against the switches at other positions. ⚠️ **The addresses above are the FANUC *standard* signal assignment; the meaning is defined by the machine builder's ladder.** Confirm each against the machine's own PMC signal list before trusting the number, and validate with the panel switches at known positions (read-only observation — never write PMC). **Modal G/M/T codes are not available**: `cnc_modal` has no known wire command id here (see [Not ported](#not-ported-633-backlog)). ### Recipe: cycle time per part (virtual tag, #272) A per-part cycle time is **not** a driver feature: `FIXED:Timers/CycleSeconds` and the parts count (`MACRO:3901` / `PARAM:6711`) are both already readable, and the result is a pure function of them. Author the count as an equipment tag named `PartsCount` and `FIXED:Timers/CycleSeconds` as `CycleSeconds`, then add a virtual tag (Double, change-triggered by those two references): ```csharp // Average cycle seconds per part since the counters were last cleared. var parts = (double)ctx.GetTag("{{equip}}/PartsCount").Value; var cycle = (double)ctx.GetTag("{{equip}}/CycleSeconds").Value; return parts > 0 ? cycle / parts : null; // no parts yet: no average ``` This is a **running average**, valid only while both counters accumulate from the same reset (an operator clearing one but not the other skews it until they are cleared together). The exact *last-cycle delta* (difference `CycleSeconds` across a parts-count step) needs the script to remember the previous sample, and virtual-tag scripts have no documented persistent state (see [`docs/ScriptEditor.md`](../ScriptEditor.md)) — compute that in the consuming system (e.g. ScadaBridge) from the two raw tags instead. Both inputs hold while either is Bad, per the quality contract. ## PMC range coalescing A poll batch that reads several nearby PMC tags used to issue one `pmc_rdpmcrng` per tag. The driver now merges tags of the same PMC letter and width that sit close together into **one** range read and slices each tag's value out of the result (#266). Single, isolated PMC tags — the common "one tag per area" case — are read exactly as before. ```jsonc "PmcCoalesce": { "Enabled": true, // default "MaxSpanBytes": 128, // hard cap on one merged range "MaxGapBytes": 8 // bytes nobody asked for tolerated between two merged tags; 0 = only strictly adjacent } ``` Rules worth knowing: - Tags merge only within the same letter and width class (Bit and Byte are one class; Word, Long, Float, Double are separate). A multi-byte tag joins a range only when its offset from the range start is a multiple of its width, because the CNC splits a range into width-sized slots from the start. - **Spare gap bytes must be valid PMC addresses.** A gap that crosses a hole in the CNC's PMC map makes the CNC refuse the whole range. That is handled, not fatal: a refused range is remembered per device and its tags are read **individually**, each with its own true status (a refusal is never stamped onto healthy members). If that happens routinely, lower `MaxGapBytes` (0 disables gap-spanning entirely) or set `Enabled` to `false`. - A transport failure or timeout on the merged read fails every member with `BadCommunicationError` and does **not** retry per tag — the link is down, so per-tag retries would only multiply timeouts. - Read-only: this changes `pmc_rdpmcrng` reads only; nothing here can write. The config key lives in the driver `Config` JSON. The AdminUI typed form does not yet surface it (an unknown key edited through that form is dropped), so set it in the raw config. ## Alarm conditions (`ALARM:`, Gitea #647) A FANUC CNC exposes **one flat active-alarm list per device** (`cnc_rdalmmsg2`), not one structure per alarm. The driver maps that list onto Part 9 conditions through authored raw tags: a tag whose address is an `ALARM:` selector **and** whose `TagConfig` carries the cross-driver `alarm` object becomes **exactly one** condition, with `ConditionId` = the tag's RawPath (the key the host routes on). Same opt-in-per-tag shape as MTConnect CONDITION alarms (#626). ```jsonc // raw tag TagConfig { "address": "ALARM:Servo/401-453", "dataType": "Int32", "alarm": { "alarmType": "AlarmCondition", "severity": 800 } } ``` | Address | Condition is active while… | |---|---| | `ALARM:*` | any alarm is active on the tag's device (device summary) | | `ALARM:Servo` / `ALARM:T4` | any alarm of that type is active (a type name, or a raw type code `T`) | | `ALARM:Servo/401` | that type + number is active | | `ALARM:Servo/401-453` | that type, number in the inclusive range | | `ALARM:OTHER` | an alarm is active that **no type selector** on the same device claims — the "unmatched" bucket | Type names: `Parameter`, `PulseCode`, `Overtravel`, `Overheat`, `Servo`, `DataIo`, `MemoryCheck`, `MacroAlarm` (case-insensitive). The grammar is `FocasAlarmSelector` in the Contracts assembly, and it is the **one authority**: the `/raw` tag modal, the address picker ("CNC alarm (condition)"), the deploy-time inspector and the driver's init all parse with it, and a malformed selector gets the same reason text from the editor and the gate. ⚠️ **The type table is the 16i/18i/21i-era `ALM_*` numbering** (`Parameter=0, PulseCode=1, Overtravel=2, Overheat=3, Servo=4, DataIo=5, MemoryCheck=6, MacroAlarm=13`) and is the only one the driver knows. The 30i/31i/32i series number `alm_type` differently; that table has not been confirmed against a FOCAS reference, so it is deliberately **not** encoded — a guessed code would silently route the wrong alarms. On a 30i-series control (the Makino is a 31i-B) a type *name* may therefore select the wrong family. Use the raw-code form there: every condition message carries the raw code (`… (type 4 Servo, axis 1)`), so observe it, then author `ALARM:T`. Number ranges are series-independent. **Semantics.** - **Identity is (type, number).** Cross-family number collisions exist. The axis is not part of the key: one alarm number reported on two axes is one alarm, and the message lists both axes. - **Overlap is intended.** One servo alarm drives `ALARM:Servo` *and* `ALARM:*`. `*` summaries and `OTHER` tags never claim an alarm, so a summary beside `OTHER` does not starve it. - **Displayed alarm** = the most severe matching one (by the type table), ties to the lowest (type, number) — never wire order, so a reordered list cannot re-raise. Message: `401 SERVO V-READY OFF (type 4 Servo, axis 1) [+2 more]`; a summary condition whose read hit the per-read cap (32) adds `[alarm list truncated]` (and the driver warns once). - **Transitions** (`AlarmEventArgs.Kind` — the host's projector reads nothing else): inactive → active = `Raise`; a key **not in the matched set** appears = `Retrigger` (un-acknowledges: a new CNC alarm is a statement the operator has not seen); the **displayed** alarm leaves while others remain = `Retrigger` with the next one down (the MTConnect precedent); a **non-displayed** member leaves = nothing; nothing matches = `Clear`. - **Severity, explicitly.** An authored integer `alarm.severity` is the condition's exact Part 9 severity (`OpcUaSeverity`; the host clamps 1..1000). Without one, the bucket comes from the displayed alarm's type: Overtravel / Servo / PulseCode → Critical (900), Parameter / MacroAlarm → Medium (500), everything else including unknown codes → High (700). On a 30i-series control, author a severity rather than rely on the 16i-era table. Note the AdminUI native-alarm editor always writes a `severity`, so a UI-authored tag always uses its authored value; the type table applies to an `alarm` object that carries none (CSV import, API). - **Unmatched alarms** — active, but selected by no `*`, `OTHER` or type tag on the device — are logged once each at Information. **Delivery and failure.** - **A failed read is not "no alarms".** `IFocasClient.ReadAlarmsAsync` throws on a down session, a non-OK return code, a wire fault, or a non-empty reply that fits neither the 76- nor the 80-byte record layout (an empty reply is "no alarms"). That device is skipped for the tick and its conditions keep their Active/Acked state. (It used to answer `[]`, which would have cleared every alarm on a comms blip and re-raised them un-acknowledged on recovery.) - **…but an unreachable source says so.** The first failed read of an outage annotates every subscribed condition on that device `BadNoCommunication` through the #626 quality-only path (no state change, no `/alerts` row) and logs ONE Warning naming `cnc_rdalmmsg2`; the next successful read lifts it to Good (before any transition it also finds, e.g. Good then Clear for an alarm that cleared during the outage). Transport failures also reach driver health: `Degraded` with `LastSuccessfulRead` frozen (only a successful read advances it), so a driver whose only tags are `ALARM:` tags trips the comms-loss watchdog (#527) and the host's driver-wide #477 annotation like any other. - **A CNC refusal is not comms loss.** A non-OK FOCAS return from `cnc_rdalmmsg2` (the CNC answered and declined, e.g. `EW_FUNC`) marks the conditions `BadNotSupported` (quality-only, once), logs one Warning, and **leaves driver health alone** — polling continues at the normal cadence. Treating it as comms loss would make the #527 watchdog reconnect a production control every 90 s forever. Only a transport fault (session down, wire fault, malformed reply) degrades. - **Health is the worst of the value path and the alarm path, computed together** (`GetHealth`). A failing alarm read keeps a mixed driver `Degraded` however many value reads succeed (so `/hosts` does not flip per poll), and a good alarm read cannot mask a value-path `Degraded`. `LastSuccessfulRead` is the later of the two paths' last successes, so the watchdog fires only when both have stopped. - **Diffed against what the server was TOLD**, recorded only while a handler is attached. The record lives on the driver instance: a reconnect (same-set re-subscribe) and an in-place re-initialise (Force Reconnect, #630) re-raise nothing. **A deploy that changes the driver's config respawns it** (the planner stops + recreates the driver, a new instance with an empty record), and its first poll sends `Raise` for every still-active alarm. The host treats a `Raise` on a condition it already shows Active **with the same message** as a **re-statement**: the acknowledgement is kept and no second "Activated" `/alerts` row is written (`NativeAlarmProjector`, #647 review). The condition message is a deterministic function of the matched set (winner, type code, axes, `[+N more]`), so a true re-statement matches exactly, while an alarm set that changed during the respawn — including one that only LOST a member — reads as a new statement and un-acks (the conservative direction). Only `Retrigger` — or inactive → active — un-acknowledges, which is why a genuinely new CNC alarm in a matched set is sent as `Retrigger`. - **Polling:** one `cnc_rdalmmsg2` per `AlarmProjection.PollInterval` (default 2 s) for each device that owns a **subscribed** alarm tag; devices without one are never polled for alarms. - **Opt-in is the tag.** `AlarmProjection.Enabled` is retained so old configs bind, and has no effect. Before #647 `SubscribeAlarmsAsync` threw while it was false, so an authored alarm tag on a default config stayed gated with a Warning on every reconnect. - **Read-only.** Nothing is ever written to the CNC. Acknowledge and AddComment are applied server-side (native conditions support only those two methods); `AcknowledgeAsync` is a no-op success. The CNC clears an alarm when its cause is reset. **Authoring mistakes are skipped and reported** (the driver's skipped-tags ledger + a deploy-time advisory): an `ALARM:` address without the `alarm` object (not a condition, and no value to read), and an `alarm` object on a non-`ALARM:` address (a condition nothing can raise). Reading an `ALARM:` tag's RawPath through the driver returns `BadNotSupported`; writing it, `BadNotWritable`. **Live (passive, read-only) — hw2 gate 2026-10-08, Makino Z-34184.** `ALARM:*`, `ALARM:OTHER`, `ALARM:Servo` and `ALARM:T6` subscribed for 6 minutes at a 2 s poll: the control answered `cnc_rdalmmsg2` `rc=0` with an **empty** (0-byte) payload every time, no condition event of any kind was raised (no spurious Raise, no quality churn), and driver health stayed Healthy. With no alarm active, the 76-vs-80-byte layout and the 30i type codes remain unobserved on this control. **Known residuals.** Whether the alarm *list* itself flickers at a CNC reset (the status word does, one poll) is unobserved; if the live gate shows it, a clear-debounce knob is the follow-up. Real-machine raise/clear is only observable passively — never induce an alarm on a production control. ## Handle recycling FANUC CNCs have a finite FWLIB handle pool (~5–10 concurrent connections) and certain series have documented handle-leak bugs that manifest after long uptime. The driver can proactively close + reopen each device's session on a cadence to release its slot back to the pool: ```jsonc "Config": { "Devices": [ ... ], "HandleRecycle": { "Enabled": true, "Interval": "01:00:00" } } ``` Disabled by default — a healthy CNC + driver doesn't need it. Enable when field experience shows handle exhaustion. Typical tuning: 30 min for sites running multiple OtOpcUa instances against the same CNC (they share the pool); 6 h for a single-client deployment. Reads / writes during recycle simply wait for the reconnect rather than failing — worst case, an operator sees a brief read latency spike once per cadence. ## Testing - **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` cover the driver surface via `FakeFocasClient`. `FocasAlarmSelectorTests` pins the `ALARM:` grammar and the inspector; `FocasAlarmProjectionTests` the transitions, `OTHER`, failed-read-keeps-state and the told record. The end-to-end proof that a CNC alarm reaches a condition is `FocasNativeAlarmEndToEndTests` in `Runtime.Tests` (real driver → `DriverHostActor.ForwardNativeAlarm` → `NativeAlarmProjector`). - **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/` hold the Docker simulator scaffold (`OTOPCUA_FOCAS_SIM_ENDPOINT=host:port`; they skip when it is absent; `Series/WireBackendStatusAndPmcTests` covers `Status/` and PMC coalescing against the mock's `statinfo` and `pmc` state); see [`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md) for what the simulator emits vs. real CNC behaviour. - **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real CNC (or the simulator) and exercises connect → read → write → subscribe round-trips. See [`docs/drivers/FOCAS-Test-Fixture.md`](FOCAS-Test-Fixture.md) for the coverage map. ## Troubleshooting | Symptom | Likely cause | Fix | |---------|--------------|-----| | Driver faults immediately at startup with "unimplemented" in the error | `Backend` is `"unimplemented"` / `"none"` / `"stub"` | Change `Backend` to `"wire"` and supply the real CNC endpoint in `Devices[]` | | `BadCommunicationError` on every read | CNC unreachable on TCP:8193 | Check firewall / LAN reachability; FOCAS Ethernet option must be licensed on the CNC side | | Every read returns `BadNotWritable` on writes | Expected — OtOpcUa is read-only against FOCAS | If you actually need writes, open a feature request — the driver's managed wire client doesn't expose the write commands | | `BadOutOfRange` on reads for a macro/parameter | Config address outside the declared `Series` range | Check `docs/v2/focas-version-matrix.md` — either fix the address or widen the `Series` | | An `ALARM:` condition never activates | The tag lacks the `alarm` object, or an `alarm` object sits on a non-`ALARM:` address (both are skipped — check the skipped-tags report); or, on a 30i-series control, a type *name* selects the wrong 16i-era code | Pair the address with the `alarm` object; on a 30i use the raw `ALARM:T` form, reading the code from an `ALARM:*` condition's message | | Axis position values seem 1000× too large | The CNC's position blocks carry no scale (older control / simulator) and `PositionDecimalPlaces` is not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]`. A real 30i/31i carries the scale in each block and needs no setting | | A fixed-tree node or `MACRO:` tag reads `BadNoData` | The CNC answered but described no value — a vacant macro variable, an undescribable position/meter record, a program field it did not report | Check the address on the control; this is the control's answer, not a comms fault | | An integer-typed `MACRO:` tag reads `BadTypeMismatch` | The macro holds a fractional value (e.g. `148.5`) | Author the tag as `Float64`, or point it at a whole-valued variable — a fraction is never truncated into an integer tag | ## Wire decode corrections (2026-10) Each of these decoded without ever failing — just wrongly — so live validation that only checked "the read succeeds" passed them. All were found by the sister **ignitionoee** FOCAS driver against a real Makino 31i-B (Z-34184, series G431) and ported here with its captured payloads as test fixtures (`tests/.../Driver.FOCAS.Tests/Fixtures/v3/`). Client-visible changes are marked ⚠️. | What | Before | Now | |------|--------|-----| | `cnc_rdmacro` decimal | read at payload offset 4 — a constant `10` length field — so `#500 = 1.0` published `0.01` | `dec_val` at offset 6; `mantissa / 10^dec`. Vacant variable → `BadNoData`; fractional value on an integer tag → `BadTypeMismatch`; integer tags receive the exact whole value | | Axis positions | scaled by `cnc_getfigure` (0x00D3), which does not exist — on hardware: raw counts | scaled by each block's own `base^exponent`; undescribable block → `BadNoData` | | Axis / spindle names | first 2 of 4 record bytes — `AA6`/`AA7` collapsed to `AA` | whole record up to the first NUL (residue after it discarded) — ⚠️ `Axes/AA_1`→`Axes/AA6` on such a machine | | `ServoLoad` | `value / 10^base` (offset 4 is the base, 10) — always ~0 | `value / base^exponent`, a direct percentage | | `Spindle/{n}/Load` | raw meter count, Int32 (`9155`) | ⚠️ Float64 % of rated load (`55.9`): the meter is a 1/16384 fraction of full scale | | `Spindle/{n}/MaxRpm` | the type-1 meter read once at bootstrap and served as a "max RPM" | ⚠️ **renamed `MotorSpeedPct`**, Float64 % of maximum speed, polled every tick | | Program `ONumber` / `Name` / `BlockCount` / `Mode` | defaulted to `0` / `""` when not reported (Mode `0` = MDI) | `BadNoData` | Not ported (and why): the ignitionoee `RunState` run-word decode and fault debounce (this driver publishes no run-state — `cnc_statinfo` is used only as the connectivity probe); `cnc_rdparam3` (0x008D) and its echo guard (this driver never sends 0x008D — the real 31i-B answers it with 264-byte records that are not a parameter read); the IODBPSD-offset and Fanuc-real `FLOAT` re-interpretation of `cnc_rdparam` (no successful `cnc_rdparam` capture exists on either side — the reference 31i-B refuses it with `EW_FUNC`, which already reads `BadNotSupported` here); and the all-axes multi-block `cnc_rddynamic2` batching (a latency optimisation, not a correctness fix). ## Not ported (#633 backlog) The #607 porting backlog (#257-#272) was triaged against this read-only driver. Disposition: **Done / documented:** #257 `Status/` fixed subtree, #258 parts count ([`MACRO:3901`](#parts-count-macro3901-or-param6711)), #259 overrides ([PMC recipe](#recipe-feed--rapid--spindle-override-via-pmc-259)), #265 16i PMC F/G, #266 PMC range coalescing, #272 ([virtual-tag recipe](#recipe-cycle-time-per-part-virtual-tag-272)). **Won't do — the read-only policy (#268-#271).** The FOCAS driver is read-only by design (`WireFocasClient.WriteAsync` returns `BadNotWritable`) and that is a safety decision for real CNCs, not a gap: no write infrastructure / per-tag opt-in (#268), no `cnc_wrmacro` / `cnc_wrparam` (#269), no `pmc_wrpmcrng` or PMC bit read-modify-write (#270), no password / unlock parameter (#271). Nothing in the driver or CLI should be read as a path toward them. **Blocked — need an authoritative wire capture first.** None of the following has a wire command id known to this repo or to the sister ignitionoee client, and neither mock serves them over the binary wire: | Item | Capability | Blocked by | |---|---|---| | #259 (modal part) | Modal G / M / T codes (`cnc_modal`) | no wire id | | #260 | Tool number / tool life, work-coordinate and tool offsets (`cnc_rdtofs` / `rdzofs`) | no wire ids | | #261 | Operator messages (`cnc_rdopmsg`) and the currently-executing block text (`cnc_rdexecprog`) | no wire ids | | #263 | `DIAG:` address area (`cnc_diagnoss`) — the highest-value one: a single generic area would unlock many fields | no wire id | | #264 | Multi-path / multi-channel (`cnc_getpath`) — the wire layer has `PathId` plumbing but no per-device/tag option | no wire id, and no multi-path control to validate against (the Makino is single-path) | | #267 | Alarm history (`cnc_rdalmhistry`) | no wire id; the live alarm surface ([#647](#alarm-projection)) comes first | ⚠️ **Do not discover these ids by probing the production Makino.** An unknown or guessed command id could map to a function that is not a read, and even known ids misbehave on the 31i-B (`cnc_rdparam` 0x000E answers `EW_FUNC`). The 0x00D3 `cnc_getfigure` lesson (see Wire decode corrections) already showed that an id agreed between two mocks can be fiction. The prerequisite is an authoritative capture: real FWLIB on a Windows host against a **non-production** control, or a passive capture of an existing FWLIB application's traffic. ## Further reading - [`docs/v2/driver-specs.md §7`](../v2/driver-specs.md) — full OPC UA node mapping, pre-defined tag set, per-API notes - [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) — per-series macro / parameter / PMC range table - [`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md) — captured FOCAS2 wire semantics (magic prefix, handshake, command-id table) - [upstream `Focas.Wire`](https://github.com/Ladder99/focas-mock/tree/main/dotnet/Focas.Wire) — the managed client implementation OtOpcUa consumes as a NuGet dependency