c940349fe8
The hw2 read-only Makino gate (Z-34184, 31i-B) logged the fixed-tree bootstrap failing with ObjectDisposedException: FocasWireClient. EnsureConnectedAsync was unserialized: the alarm poll loop and the fixed-tree loop both reached it right after init, the second saw the first's still-connecting client as not connected, disposed it and opened a second session, and the first caller's catch then disposed the second caller's client (and recorded a spurious backoff failure). A per-device ConnectGate now serializes the connect with a re-check after acquire, and the failure path disposes only its own client. FocasConcurrentConnectTests goes red without the gate. Docs: a Byte PMC tag is signed, so G12 at 100% feed override reads -101 on the Makino and the recipe's 255 - value gave 356; recipe now masks. Adds the live statinfo/alarm-watch observations. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
638 lines
43 KiB
Markdown
638 lines
43 KiB
Markdown
# 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/<field>` 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<code>`) |
|
||
| `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<code>`. 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<code>` 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
|