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>
239 lines
11 KiB
Markdown
239 lines
11 KiB
Markdown
# 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`](../v2/driver-specs.md),
|
||
for deployment details read [`docs/v2/focas-deployment.md`](../v2/focas-deployment.md),
|
||
for the test-harness map read [FOCAS-Test-Fixture.md](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`](https://github.com/Ladder99/focas-mock)
|
||
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:
|
||
|
||
```jsonc
|
||
"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`](../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.
|
||
|
||
```jsonc
|
||
"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:
|
||
|
||
```jsonc
|
||
"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 (~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:
|
||
|
||
```jsonc
|
||
"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 via `FakeFocasClient`. 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.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`](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
|
||
|
||
- [`docs/v2/driver-specs.md §7`](../v2/driver-specs.md) — full OPC UA node
|
||
mapping, pre-defined tag set, per-API notes
|
||
- [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) —
|
||
per-series macro / parameter / PMC range table
|
||
- [`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)
|
||
— captured FOCAS2 wire semantics (magic prefix, handshake, command-id table)
|
||
- [upstream `Focas.Wire`](https://github.com/Ladder99/focas-mock/tree/main/dotnet/Focas.Wire)
|
||
— the managed client implementation OtOpcUa consumes as a NuGet dependency
|