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

> **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; load *scaling* still to be confirmed at commissioning), and **alarms** all read
> correctly. 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`](../plans/2026-06-25-focas-pdu-v3-30i-b-support.md)
> + [`…-implementation-plan.md`](../plans/2026-06-25-focas-pdu-v3-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:

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

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

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

```jsonc
"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 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/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`](../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`](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 | `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`](../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
