241 lines
12 KiB
Markdown
241 lines
12 KiB
Markdown
# FOCAS driver
|
|
|
|
Fanuc CNC driver for the FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / 35i /
|
|
Power Mate i families. Talks to the controller via the licensed
|
|
`Fwlib32.dll` (Tier C, process-isolated per
|
|
[`docs/v2/driver-stability.md`](../v2/driver-stability.md)).
|
|
|
|
For range-validation and per-series capability surface see
|
|
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md).
|
|
|
|
## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a
|
|
|
|
`FocasAlarmProjection` exposes two modes via `FocasDriverOptions.AlarmProjection`:
|
|
|
|
| Mode | Behaviour |
|
|
| --- | --- |
|
|
| `ActiveOnly` *(default)* | Subscribe / unsubscribe / acknowledge wire up so capability negotiation works, but no history poll runs. Back-compat with every pre-F3-a deployment. |
|
|
| `ActivePlusHistory` | On subscribe (== "on connect") and on every `HistoryPollInterval` tick, the projection issues `cnc_rdalmhistry` for the most recent `HistoryDepth` entries. Each previously-unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's reported timestamp — OPC UA dashboards see the real occurrence time, not the moment the projection polled. |
|
|
|
|
### Config knobs
|
|
|
|
```jsonc
|
|
{
|
|
"AlarmProjection": {
|
|
"Mode": "ActivePlusHistory", // "ActiveOnly" (default) | "ActivePlusHistory"
|
|
"HistoryPollInterval": "00:05:00", // default 5 min
|
|
"HistoryDepth": 100 // default 100, capped at 250
|
|
}
|
|
}
|
|
```
|
|
|
|
### Dedup key
|
|
|
|
`(OccurrenceTime, AlarmNumber, AlarmType)`. The same triple across two
|
|
polls only emits once. The dedup set is in-memory and **resets on
|
|
reconnect** — first poll after reconnect re-emits everything in the ring
|
|
buffer. OPC UA clients that need exactly-once semantics dedupe client-side
|
|
on the same triple (the timestamp + type + number tuple is stable across
|
|
the boundary).
|
|
|
|
### `HistoryDepth` cap
|
|
|
|
Capped at `FocasAlarmProjectionOptions.MaxHistoryDepth = 250` so an
|
|
operator who types `10000` by accident can't blast the wire session with a
|
|
giant request. Typical FANUC ring buffers cap at ~100 entries; the default
|
|
`HistoryDepth = 100` matches the most common ring-buffer size.
|
|
|
|
### Wire surface
|
|
|
|
- Wire-protocol command id: `0x0F1A` (see
|
|
[`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)).
|
|
- ODBALMHIS struct decoder: `Wire/FocasAlarmHistoryDecoder.cs`.
|
|
- Tier-C Fwlib32 backend short-circuits the packed-buffer decoder by
|
|
surfacing the FWLIB struct fields directly into
|
|
`FocasAlarmHistoryEntry`.
|
|
|
|
## Writes (opt-in, off by default) — issue #268 (F4-a) + #269 (F4-b) + #270 (F4-c)
|
|
|
|
Writes ship behind multiple independent opt-ins. All default off so a freshly
|
|
deployed FOCAS driver is read-only until the deployment makes a deliberate
|
|
choice. Decision record: [`docs/v2/decisions.md`](../v2/decisions.md) →
|
|
"FOCAS write-path opt-in".
|
|
|
|
| Knob | Default | Effect when off |
|
|
| --- | --- | --- |
|
|
| `FocasDriverOptions.Writes.Enabled` *(driver-level master switch)* | `false` | Every entry in a `WriteAsync` batch short-circuits to `BadNotWritable` with status text `writes disabled at driver level`. Wire client never gets touched. |
|
|
| **`FocasDriverOptions.Writes.AllowParameter`** *(F4-b granular kill switch)* | **`false`** | **`PARAM:` writes return `BadNotWritable` with no wire client constructed. Defense in depth — even if `Enabled = true` an operator must explicitly opt into parameter writes per kind because a misdirected `cnc_wrparam` can put the CNC in a bad state.** |
|
|
| **`FocasDriverOptions.Writes.AllowMacro`** *(F4-b granular kill switch)* | **`false`** | **`MACRO:` writes return `BadNotWritable` with no wire client constructed. Macro writes are the normal HMI-driven recipe / setpoint surface; gating them separately from `AllowParameter` lets a deployment open MACRO without exposing the heavier PARAM write surface.** |
|
|
| **`FocasDriverOptions.Writes.AllowPmc`** *(F4-c granular kill switch)* | **`false`** | **PMC writes (R/G/F/D/X/Y/K/A/E/T/C letters, both Bit and Byte) return `BadNotWritable` with no wire client constructed. PMC is ladder working memory — a mistargeted bit can move motion, latch a feedhold, or flip a safety interlock, so PMC writes are gated separately from PARAM/MACRO so an operator team can open PARAM (commissioning) without exposing the much higher-blast-radius PMC surface.** |
|
|
| `FocasTagDefinition.Writable` *(per-tag opt-in)* | `false` | The per-tag check returns `BadNotWritable` for that tag even when the driver-level flags are on. |
|
|
|
|
> **PMC SAFETY CALLOUT** — PMC is the FANUC ladder's working memory. A
|
|
> mistargeted bit can move motion (a Y-coil writing to a servo enable),
|
|
> latch a feedhold (an internal R-relay the ladder ANDs with cycle-start),
|
|
> or flip a safety interlock (an X-input shadow). **Treat PMC writes the
|
|
> same way you'd treat editing a live ladder:** verify e-stop is live and
|
|
> the machine is in jog mode before issuing the first write of a session.
|
|
> The driver gates these writes behind THREE independent opt-ins
|
|
> (`Writes.Enabled` + `Writes.AllowPmc` + per-tag `Writable`) precisely
|
|
> because the blast radius is higher than parameter writes.
|
|
|
|
### PMC bit-write read-modify-write semantics — F4-c
|
|
|
|
The FOCAS wire call `pmc_wrpmcrng` is **byte-addressed** — there is no
|
|
sub-byte write primitive. When the driver receives a write request on a
|
|
`Bit` tag (e.g. `R100.3`), it:
|
|
|
|
1. Reads the parent byte via `pmc_rdpmcrng` (1 byte at `R100`).
|
|
2. Masks the target bit (set: `current | (1 << bit)`; clear: `current & ~(1 << bit)`).
|
|
3. Writes the modified byte back via `pmc_wrpmcrng` (1 byte at `R100`).
|
|
|
|
A **per-byte semaphore** serialises concurrent bit writes against the same
|
|
byte so two updates that race never lose one another's bit. RMW means **a
|
|
PMC bit write reads first, then writes back the whole byte** — if the ladder
|
|
is also writing to that byte at the same instant, there is a small window
|
|
where the driver's value can clobber the ladder's. Operators who care about
|
|
this race must coordinate the write through a ladder-side handshake (e.g.
|
|
the operator sets a request bit, the ladder reads + clears it).
|
|
|
|
### Config shape — F4-c
|
|
|
|
```jsonc
|
|
{
|
|
"Writes": {
|
|
"Enabled": true,
|
|
"AllowParameter": true, // F4-b — opt into cnc_wrparam
|
|
"AllowMacro": true, // F4-b — opt into cnc_wrmacro
|
|
"AllowPmc": true // F4-c — opt into pmc_wrpmcrng (incl. RMW bit writes)
|
|
},
|
|
"Tags": [
|
|
{ "Name": "RPM", "Address": "PARAM:1815", "DataType": "Int32",
|
|
"Writable": true, "WriteIdempotent": false },
|
|
{ "Name": "Recipe", "Address": "MACRO:500", "DataType": "Int32",
|
|
"Writable": true, "WriteIdempotent": false },
|
|
{ "Name": "StartFlag", "Address": "R100.3", "DataType": "Bit",
|
|
"Writable": true, "WriteIdempotent": true }
|
|
]
|
|
}
|
|
```
|
|
|
|
### Server-layer ACL (LDAP groups)
|
|
|
|
Per the [`docs/v2/acl-design.md`](../v2/acl-design.md) tier model, the FOCAS
|
|
driver only declares per-tag `SecurityClassification`; `DriverNodeManager`
|
|
applies the gate. The classification post-F4-b is:
|
|
|
|
| Tag kind | Classification | LDAP group required (default mapping) |
|
|
| --- | --- | --- |
|
|
| `PARAM:N` writable | `Configure` | **`WriteConfigure`** |
|
|
| `MACRO:N` writable | `Operate` | `WriteOperate` |
|
|
| Other writable (PMC R/G/F/...) | `Operate` | `WriteOperate` |
|
|
| Non-writable | `ViewOnly` | (no write permission) |
|
|
|
|
Parameter writes need the heavier `WriteConfigure` group because they're
|
|
mostly emergency commissioning territory; macro writes use `WriteOperate`
|
|
because they're the normal HMI recipe surface. The driver-level
|
|
`AllowParameter` / `AllowMacro` kill switches sit independently of ACL — an
|
|
operator-team kill switch the deployment can flip without redeploying ACL
|
|
group memberships. See [`docs/security.md`](../security.md) for the full
|
|
group/permission map.
|
|
|
|
`WriteIdempotent` is plumbed through Polly retry by the server-layer
|
|
`CapabilityInvoker.ExecuteWriteAsync`. When `false` (default), failed writes
|
|
are NOT auto-retried per plan decisions #44/#45 — a timeout that fires after
|
|
the CNC already accepted the write would otherwise risk a duplicate
|
|
non-idempotent action (alarm acks, M-code pulses, recipe steps). Flip
|
|
`WriteIdempotent` on per tag for genuinely-idempotent writes (a parameter
|
|
value that the operator simply wants forced to a target).
|
|
|
|
### FOCAS password — issue #271 (F4-d)
|
|
|
|
Some controllers — notably 16i and certain 30i firmwares with the
|
|
parameter-protect switch on — gate `cnc_wrparam` and a handful of reads
|
|
behind a connection-level password. Without unlocking the session, every
|
|
gated wire call returns `EW_PASSWD`, which the F4-b mapping surfaces as
|
|
`BadUserAccessDenied`.
|
|
|
|
`FocasDeviceOptions.Password` plumbs the password through the device config:
|
|
|
|
```jsonc
|
|
{
|
|
"Devices": [
|
|
{
|
|
"HostAddress": "focas://10.0.0.5:8193",
|
|
"Password": "1234" // F4-d — optional CNC password
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
When set, the driver:
|
|
|
|
1. **On connect**, calls `IFocasClient.UnlockAsync(password, ct)` after
|
|
the FWLIB handle opens but before any read/write fires. The FWLIB-backed
|
|
client emits `cnc_wrunlockparam` with the password ASCII-encoded into
|
|
the 4-byte FOCAS password slot (right-padded with `0x00`, truncated at
|
|
4 bytes — that's the shape the public Fanuc samples document).
|
|
2. **On `BadUserAccessDenied` from any gated read or write**, re-issues
|
|
`UnlockAsync` and retries the call **exactly once**. A second
|
|
`EW_PASSWD` propagates unchanged so a wrong password doesn't loop
|
|
forever on the wire.
|
|
3. **Reset on reconnect** — FWLIB unlock state lives on the handle, so
|
|
any reconnect path (planned or unplanned) re-runs unlock automatically
|
|
via `EnsureConnectedAsync`.
|
|
|
|
**No-log invariant.** The password is a secret. The driver MUST NOT log
|
|
it. Specifically:
|
|
|
|
- `FocasDeviceOptions` overrides the record's auto-generated `ToString`
|
|
to print `Password = ***` when the field is non-null. Any Serilog
|
|
destructure that flows the device options through `{Device}` gets the
|
|
redaction for free.
|
|
- `FwlibFocasClient.UnlockAsync` does not include the password in any
|
|
exception message — only the FWLIB return code (`EW_PASSWD`,
|
|
`EW_HANDLE`, etc.) makes it into the surface.
|
|
- `FocasDriver` logs only `"FOCAS unlock applied for {host}"` when the
|
|
unlock succeeds — no password.
|
|
- The Driver.FOCAS.Cli `--cnc-password` flag is also redacted at the
|
|
same `FocasDeviceOptions` choke point.
|
|
- See [`docs/v2/focas-deployment.md`](../v2/focas-deployment.md)
|
|
§ "FOCAS password handling" for the storage/rotation runbook + the
|
|
cross-link to [`docs/Security.md`](../Security.md).
|
|
|
|
When the controller does **not** need a password, leave `Password`
|
|
unset (`null`) and the driver short-circuits the unlock call entirely —
|
|
no wire-level cost.
|
|
|
|
### Status-code semantics post-F4-b
|
|
|
|
- `BadNotWritable` — one of: driver-level `Writes.Enabled = false`; per-tag
|
|
`Writable = false`; **`Writes.AllowParameter = false` for a `PARAM:` tag
|
|
(F4-b)**; **`Writes.AllowMacro = false` for a `MACRO:` tag (F4-b)**;
|
|
**`Writes.AllowPmc = false` for a PMC tag (F4-c)**. Same status code,
|
|
five distinct paths — operators distinguish by checking the knobs.
|
|
- `BadUserAccessDenied` — **F4-b** — the CNC reported `EW_PASSWD`
|
|
(parameter-write switch off / unlock required). **F4-d** wires the
|
|
`cnc_wrunlockparam` retry path on top: when `Password` is configured
|
|
the driver re-issues unlock + retries the gated call once before
|
|
surfacing this status. A persistent `BadUserAccessDenied` after F4-d
|
|
means either (a) the password doesn't match the controller, or (b)
|
|
the parameter-write switch on the pendant is still off and the
|
|
controller wants both the switch + the password.
|
|
- `BadNotSupported` — both opt-ins flipped on, but the wire client doesn't
|
|
implement the kind being written (e.g. older transport variant). F4-a
|
|
wired the generic dispatch; F4-b adds typed `WriteParameterAsync` /
|
|
`WriteMacroAsync` entry points whose default impls return
|
|
`BadNotSupported` so transports compiled against a stale `IFocasClient`
|
|
surface still build.
|
|
- `BadNodeIdUnknown` — full-reference doesn't match any configured
|
|
`FocasTagDefinition.Name`.
|
|
- `BadCommunicationError` — wire failure (DLL not loaded, IPC peer dead,
|
|
etc.).
|
|
|
|
### CLI bypass
|
|
|
|
`otopcua-focas-cli write` ([`docs/Driver.FOCAS.Cli.md`](../Driver.FOCAS.Cli.md))
|
|
sets `Writes.Enabled=true` locally for the lifetime of one invocation
|
|
because the CLI is a per-operator tool — not a long-lived process bound to
|
|
the central config DB. The server-side flag is untouched; configure-the-
|
|
server code paths remain safer-by-default.
|