Migration closes the FOCAS Tier-C architecture. OtOpcUa previously had
`Driver.FOCAS.Host` (NSSM-wrapped Windows service loading Fwlib64.dll via
P/Invoke) + `Driver.FOCAS.Shared` (MessagePack IPC contracts) + a C shim
DLL stand-in for unit tests. All of it is deleted; the driver is now a
single in-process managed assembly talking the FOCAS/2 Ethernet binary
protocol directly on TCP:8193.
Architecture
- Pure-managed `FocasWireClient` inlined at `src/.../Driver.FOCAS/Wire/`
(owner-imported — see Wire/FocasWireClient.cs for the full surface).
Opens two TCP sockets, runs the initiate handshake, serialises requests
on socket 2 through a semaphore, closes cleanly with PDU + socket
teardown. Both sync `IDisposable` and async `IAsyncDisposable`.
- `WireFocasClient` (same folder) adapts the wire client to OtOpcUa's
`IFocasClient` surface — fixed-tree reads, PARAM/MACRO/PMC addresses,
alarms. Writes return `BadNotWritable` by design — OtOpcUa is read-only
against FOCAS.
- `FocasDriverFactoryExtensions` now accepts `"Backend": "wire"` (default)
and `"Backend": "unimplemented"`. Legacy `ipc` and `fwlib` backends are
rejected at startup with a diagnostic pointing at the migration doc.
Deletions
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/` — whole project + Ipc/,
Backend/, Stability/, Program.cs.
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/` — Contracts/, FrameReader,
FrameWriter, whole project.
- `tests/...Driver.FOCAS.Host.Tests/` + `.Shared.Tests/` — whole projects.
- `src/.../Driver.FOCAS/FwlibNative.cs` + `FwlibFocasClient.cs` — 21
P/Invokes + 7 `Pack=1` marshalling structs + the Fwlib-backed
`IFocasClient` implementation.
- `src/.../Driver.FOCAS/Ipc/` + `Supervisor/` — IPC client wrapper +
Host-process supervisor (backoff, circuit breaker, heartbeat, post-
mortem reader, process launcher).
- `scripts/install/Install-FocasHost.ps1` — NSSM service installer.
- `tests/.../Driver.FOCAS.Tests/{IpcFocasClientTests, IpcLoopback,
FwlibNativeHelperTests, PostMortemReaderCompatibilityTests,
SupervisorTests, FocasDriverFactoryExtensionsTests}.cs` — tests that
exercised the retired surfaces.
- `tests/.../Driver.FOCAS.IntegrationTests/Shim/` — the zig-built C shim
DLL that masqueraded as Fwlib64.dll.
Solution changes
- `ZB.MOM.WW.OtOpcUa.slnx` drops the 4 retired project refs.
- `src/.../Driver.FOCAS.csproj` drops the Shared ProjectReference, adds
`Microsoft.Extensions.Logging.Abstractions` for the optional `ILogger`
hook in `FocasWireClient`.
- `src/.../Driver.FOCAS.Cli.csproj` drops the six `<Content Include>`
entries that copied `vendor/fanuc/*.dll` into the CLI bin. CLI now uses
`WireFocasClient` directly.
- `FocasDriver` default factory flips to `Wire.WireFocasClientFactory`.
Integration tests
- New `tests/.../Driver.FOCAS.IntegrationTests/` project covering fixed-
tree reads (identity, axes, dynamic, program, operation mode, timers,
spindle load + max RPM, servo meters), user-authored PARAM / MACRO /
PMC reads, `DiscoverAsync` emission, `SubscribeAsync` + `OnDataChange`,
`IAlarmSource` raise/clear transitions, and `ProbeAsync` /
`OnHostStatusChanged`. 9 e2e tests against the focas-mock fixture
(Docker container with the vendored Python mock's native FOCAS/2
Ethernet responder).
- `scripts/integration/run-focas.ps1` orchestrates compose up → tests →
compose down. Dropped the shim-build stage + DLL-copy step + the split
testhost workaround (the latter only existed because of native-DLL
lifecycle bugs the shim tripped).
- Docker compose collapses from 11 per-series services to one `focas-sim`
service. Tests seed per-series state via `mock_load_profile` at test
start.
- Vendored focas-mock snapshot refreshed to pick up upstream's native
FOCAS/2 Ethernet responder (was 660 lines, now 1018) — the
pre-refresh snapshot only spoke the JSON admin protocol.
Tests
- 145/145 unit tests in `Driver.FOCAS.Tests` pass (was 208 pre-deletion;
63 removed tests exercised the retired IPC/shim/supervisor/Fwlib
surfaces).
- 9/9 integration tests pass against the refreshed mock.
- `FocasScaffoldingTests.Unimplemented_factory_throws_on_Create…` updated
to assert the new diagnostic message pointing at
`docs/drivers/FOCAS.md` rather than the now-gone `Fwlib64.dll`.
Docs
- `docs/drivers/FOCAS.md` rewritten for the managed wire topology —
deployment collapses to one `"Backend": "wire"` config block, no
separate service, no DLL deployment, no pipe ACL.
- `docs/drivers/FOCAS-Test-Fixture.md` updated — single TCP probe skip
gate instead of TCP + shim probe; fewer moving parts.
- `docs/drivers/README.md` row for FOCAS reflects the Tier-A managed
topology (previously listed Tier-C + `Fwlib64.dll` P/Invoke).
- `docs/Driver.FOCAS.Cli.md` drops the Tier-C architecture-note section.
- `docs/v2/implementation/focas-isolation-plan.md` marked historical —
the plan it documents was executed then superseded by the wire client.
- `docs/v2/v2-release-readiness.md` re-audited 2026-04-24. Phase 5
driver complement closed. FOCAS change-log entry added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 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 deployment details read docs/v2/focas-deployment.md,
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.
Project split
| Project | Target | Role |
|---|---|---|
src/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 |
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.
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 |
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
}
}
What gets populated (all under FOCAS/{deviceHostAddress}/):
| 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 are scaled integers (matching FOCAS's convention). The
managed side exposes them as Float64 OPC UA nodes; a future
cnc_getfigure integration will add per-axis decimal scaling. Until
then, treat the raw integer as the value the CNC reports and scale on
the client side if decimal precision matters.
Still user-authored: PARAM:6711, MACRO:500, R100 etc. — specific
numbers whose meaning is MTB-specific. 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/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/cover the driver surface viaFakeFocasClient. Includes the alarm-projection raise / clear diffing tests. - Integration tests —
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/hold the Docker simulator scaffold (Stream B / C of the simulator plan —docs/v2/implementation/focas-simulator-plan.md). - 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 |
|---|---|---|
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 |
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