An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:
1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
editor, the address picker, the deploy-time inspector and driver init.
Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).
E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
33 KiB
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,
for the test-harness map read 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
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-codesWritable: falseregardless of any authored"writable": true— previously such a tag advertised a writable node whose write always failed at the wire withBadNotWritable; 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 requestswritable: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. Honourwritablehere 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). The drivercnc_rddynamic2poll is 1-based.cnc_rdparamis unsupported on this control — every request-framing variant returnsEW_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
…-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:
"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 — 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 |
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: 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 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 |
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.
"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 areAA6andAA7, and the wire layer used to read only 2 of each 4-byte name record. Since the name-record fix those axes publish asAxes/AA6/andAxes/AA7/; tags committed againstAxes/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 againstAxes/AA/…on such a machine readsBadNodeIdUnknownrather 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 thirdAAappears, 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
BadCommunicationErrorrather than continuing to serve the last snapshot asGood; 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_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).
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.
"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 (PARAM:6711)
There is deliberately no dedicated parts-count node (#258): the count is an ordinary CNC parameter, so it
is already readable as a hand-authored PARAM: tag (Int32), and a fixed Parts/ subtree would hard-code
parameter numbers that are control- and MTB-specific. On the standard FANUC 0i / 16i / 30i parameter map:
| Address | Parameter | Meaning |
|---|---|---|
PARAM:6711 |
No. 6711 | Number of parts machined (resets with the operator's clear) |
PARAM:6712 |
No. 6712 | Total number of parts machined (cumulative) |
PARAM:6713 |
No. 6713 | Number of parts required (the target) |
The count-up trigger (which M code or cycle end increments it) is set by No. 6710 and bit 0 of No. 6700,
so confirm in the machine's parameter manual that the counter is actually enabled and that 6711–6713 mean
what the MTB says they mean before relying on them. Cycle time per part is not provided; the Timers/
subtree exposes CycleSeconds and a client can difference it across a PARAM:6711 change.
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.
"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 setEnabledtofalse. - A transport failure or timeout on the merged read fails every member with
BadCommunicationErrorand does not retry per tag — the link is down, so per-tag retries would only multiply timeouts. - Read-only: this changes
pmc_rdpmcrngreads 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).
// 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:ServoandALARM:*.*summaries andOTHERtags never claim an alarm, so a summary besideOTHERdoes 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 =Retriggerwith the next one down (the MTConnect precedent); a non-displayed member leaves = nothing; nothing matches =Clear. - Severity, explicitly. An authored integer
alarm.severityis 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 aseverity, so a UI-authored tag always uses its authored value; the type table applies to analarmobject that carries none (CSV import, API). - Unmatched alarms — active, but selected by no
*,OTHERor type tag on the device — are logged once each at Information.
Delivery and failure.
- A failed read is not "no alarms".
IFocasClient.ReadAlarmsAsyncthrows on a down session, a non-OK return code or a wire fault; that device is skipped for the tick and its conditions keep their state. (It used to answer[], which would have cleared every alarm on a comms blip and re-raised them un-acknowledged on recovery.) Driver-wide comms loss is the host'sBadNoCommunicationquality annotation (#477) — Active is kept. - Diffed against what the server was TOLD, recorded only while a handler is attached, and held for the driver's lifetime: a reconnect (same-set re-subscribe), a re-initialise and a shutdown/init cycle re-raise nothing, so an acknowledgement survives them. A changed subscribed set (an alarm-bearing deploy) prunes only the RawPaths that left (#641).
- Polling: one
cnc_rdalmmsg2perAlarmProjection.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.Enabledis retained so old configs bind, and has no effect. Before #647SubscribeAlarmsAsyncthrew 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);
AcknowledgeAsyncis 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.
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:
"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 viaFakeFocasClient.FocasAlarmSelectorTestspins theALARM:grammar and the inspector;FocasAlarmProjectionTeststhe transitions,OTHER, failed-read-keeps-state and the told record. The end-to-end proof that a CNC alarm reaches a condition isFocasNativeAlarmEndToEndTestsinRuntime.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/WireBackendStatusAndPmcTestscoversStatus/and PMC coalescing against the mock'sstatinfoandpmcstate); seedocs/v2/implementation/focas-wire-protocol.mdfor what the simulator emits vs. real CNC behaviour. - E2E script —
scripts/e2e/test-focas.ps1stages Host + Proxy + a real CNC (or the simulator) and exercises connect → read → write → subscribe round-trips. Seedocs/drivers/FOCAS-Test-Fixture.mdfor 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).
Further reading
docs/v2/driver-specs.md §7— full OPC UA node mapping, pre-defined tag set, per-API notesdocs/v2/focas-version-matrix.md— per-series macro / parameter / PMC range tabledocs/v2/implementation/focas-wire-protocol.md— captured FOCAS2 wire semantics (magic prefix, handshake, command-id table)- upstream
Focas.Wire— the managed client implementation OtOpcUa consumes as a NuGet dependency