Files
lmxopcua/docs/drivers/FOCAS.md
2026-04-26 05:45:13 -04:00

12 KiB

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

For range-validation and per-series capability surface see docs/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

{
  "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).
  • 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 → "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

{
  "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 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 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:

{
  "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 § "FOCAS password handling" for the storage/rotation runbook + the cross-link to docs/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.
  • BadUserAccessDeniedF4-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) 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.