Files
lmxopcua/docs/drivers/FOCAS.md
Joseph Doherty 4b0664bd55 FOCAS — retire Tier-C split, inline managed wire client, make read-only
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>
2026-04-24 14:10:59 -04:00

11 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 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 ReadAsynccnc_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'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.

Handle recycling

FANUC CNCs have a finite FWLIB handle pool (~510 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 teststests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ cover the driver surface via FakeFocasClient. Includes the alarm-projection raise / clear diffing tests.
  • Integration teststests/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 scriptscripts/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
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