A CNC can report duplicate sibling names — the wonder Makino returns two
axes both called AA — which made the Axes/ subtree untrustworthy: the
browser offered two identical folders the commit gate had to refuse, and
the poll loop's name-keyed snapshot cache let the second axis silently
overwrite the first. Spindles and servo loads had the same collision in
their own keying, plus a resolve-to-first-match on the spindle read path.
Scheme: every member of a collision set gets a 1-based occurrence-ordinal
suffix in slot order (AA_1, AA_2); unique names stay bare. Chosen over the
alternatives deliberately:
- Names, not slot numbers, stay the primary identity — the segment map is
re-derived from the live device each session, so a name-keyed path
follows the physical axis across config changes, where a slot-qualified
path would silently rebind on any axis-list shift and rename every axis
on every machine for one pathological CNC.
- The WHOLE collision set is suffixed: bare AA ceases to exist when
ambiguous, so a tag authored against it reads BadNodeIdUnknown rather
than one arbitrary axis's data — the #534/#540 silent-wrongness class.
- Occurrence ordinal, not absolute slot: inserting/removing an unrelated
axis never renames an existing duplicate pair. If the collision-set
membership itself changes, segments change on the next browse and stale
authored segments read Bad, never another axis's data.
One pure helper (FixedTreeNames.Disambiguate, collision-proof against
genuine names by suffix escalation) feeds FocasFixedTreeCache.AxisSegments
/ SpindleSegments, and the emitter, axis snapshot keys, servo-load keys
(cnc_rdsvmeter answers in slot order, so the k-th AA load belongs to the
k-th AA axis) and the spindle read resolution all consume those — they can
no longer disagree. The nameless-spindle S{n} fallback applies before
disambiguation so a fallback name colliding with a real one is handled
like any other duplicate.
Tests are red-first on the pre-fix driver (all five driver-level tests
fail on master's code): browse emits AA_1/AA_2 and no bare AA, each
duplicate reads its own position and servo load, duplicate spindles
resolve per slot, bare AA reads Bad. The wire half runs live against
focas-mock (duplicate names survive the managed wire parse and publish as
suffixed segments); the mock's dynamic store is name-keyed so the
distinct-values half stays with the offline tests. FOCAS 327/327,
integration 11/11, FOCAS.Cli 52, AdminUI 1113, ControlPlane 231.
Closes #548.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
19 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; load scaling still to be confirmed at commissioning), and alarms all read correctly. 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 |
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 |
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 |
Opt-in — set AlarmProjection.Enabled=true; diffs (AlarmNumber, Type) between ticks |
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.
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 real CNC can report duplicate sibling names, and they are disambiguated
automatically (#548). One observed Makino returns two axes both called
AA; every member of such a collision set gets a 1-based occurrence suffix in slot order — the tree offersAxes/AA_1/andAxes/AA_2/, each reading its own axis — while unique names stay bare (Axes/Y/). 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 (percentage), MaxRpm — one folder per discovered spindle |
cnc_rdspdlname once + cnc_rdspload + cnc_rdspmaxrpm |
Program/ |
Name (filename), ONumber, Number, MainNumber, Sequence, BlockCount |
cnc_exeprgname2 + cnc_rdblkcount + cached cnc_rddynamic2 |
OperationMode/ |
Mode (int), ModeText ("AUTO", "MDI", "EDIT", …) |
cnc_rdopmode |
Timers/ |
PowerOnSeconds, OperatingSeconds, CuttingSeconds, CycleSeconds |
cnc_rdtimer × 4 |
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/, 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 CNC-internal scaled integers exposed as Float64 OPC UA nodes. The driver converts them
to engineering units using the per-device PositionDecimalPlaces config field (default 0
= no scaling). When set to a positive integer d, each position value is divided by 10^d
before publishing, so a CNC that reports millimetres × 1000 is corrected by setting
PositionDecimalPlaces: 3.
"Devices": [
{
"HostAddress": "focas://10.20.30.40:8193",
"Series": "ThirtyOne_i",
"PositionDecimalPlaces": 3 // 123456 → 123.456 mm
}
]
The driver now attempts to auto-scale each axis position using the CNC's cnc_getfigure figure when available; PositionDecimalPlaces is the per-axis fallback used only when the CNC reports no figure for that axis. Caveat: the managed WireFocasClient (FOCAS/2 Ethernet wire protocol) does not issue cnc_getfigure today, so on real hardware all axes still fall back to the manual PositionDecimalPlaces config field. Live auto-fetch is a follow-up requiring a cnc_getfigure command in FocasWireClient. Negative values from PositionDecimalPlaces are clamped to 0.
Follow-ups (known gaps, non-blocking):
- No upper-bound clamp on the figure magnitude returned by the CNC — a misbehaving figure could overflow
Math.Pow; a max-figure guard should be added. - The negative-figure branch (falls back to manual
PositionDecimalPlaces) is handled but not explicitly unit-tested.
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.
Alarm projection
Alarm surfacing is disabled by default because the polling cost is wasted on sites that don't consume CNC alarms. Opt in per driver instance:
"Config": {
"Devices": [ ... ],
"Tags": [ ... ],
"AlarmProjection": {
"Enabled": true,
"PollInterval": "00:00:02"
}
}
Every alarm transition fires OnAlarmEvent with:
SourceNodeId= the device host address (FOCAS has no per-node alarm model; the CNC exposes a single flat active-alarm list per session)ConditionId="{host}#{Type}:{AlarmNumber}"AlarmType= projected from FANUC'sALM_TYPE_*(e.g.Overtravel,Servo,Parameter,MacroAlarm)Severity= Overtravel / Servo / PulseCode →Critical; Parameter / Macro →Medium; everything else →High
Cleared alarms fire a second event with " (cleared)" appended to the message
so downstream consumers can ignore the clear if they only care about raises.
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. Includes the alarm-projection raise / clear diffing tests. - Integration tests —
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/hold the Docker simulator scaffold; 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 |
| Alarm events never fire | AlarmProjection.Enabled left at default (false) |
Set it to true in the driver config |
| Axis position values seem 1000× too large | PositionDecimalPlaces not set |
Add "PositionDecimalPlaces": 3 (or the MTB-specific value) to the device entry in Devices[] |
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