Files
lmxopcua/docs/drivers/FOCAS.md
T
Joseph Doherty 64d2514e2a fix(abcip): report ALMD transitions with ConditionId = RawPath and a Kind (#641)
Found while verifying item 4 of #641. The host routes a native alarm transition
on an exact ConditionId == RawPath match (DriverHostActor.ForwardNativeAlarm),
but AbCipAlarmProjection emitted "{ref}#active" and no Kind, so every ALMD
raise/clear was dropped as "no alarm condition" — and even if routed, an
Unspecified kind projects no state change. It now reports the subscribed
reference (the RawPath) and Raise/Clear. A host test pins that a decorated id
is dropped.

FOCAS has the same id shape plus a device filter keyed by host address (a
RawPath subscription polls nothing); it needs a mapping decision and is
documented in docs/drivers/FOCAS.md rather than patched here.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:08:47 -04:00

23 KiB
Raw Blame History

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-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). 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

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

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
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 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
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 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.

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's ALM_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.

⚠️ Not routable to a v3 raw condition (found under Gitea #641). The server routes a native transition only when ConditionId equals an alarm-bearing raw tag's RawPath, and the host subscribes those RawPaths — but this projection filters devices by host address (so a RawPath list matches no device and nothing is polled) and emits "{host}#{Type}:{AlarmNumber}" with no Kind. FOCAS native alarms therefore reach no Part 9 condition today. Fixing it needs a mapping decision (the CNC exposes one flat alarm list per device, not one condition per tag), so it is tracked separately rather than patched.

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 via FakeFocasClient. Includes the alarm-projection raise / clear diffing tests.
  • Integration tests — tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/ hold the Docker simulator scaffold; see docs/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 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
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 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