15 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.
Fixed-tree Production/ projection — issue #258 (F1-b) + issue #272 (F5-a)
Per-device read-only nodes refreshed from the same cnc_rdparam /
cycle-timer poll the probe loop already runs. No additional wire calls
are issued for any of these — they are all cache-or-derive reads.
| Node | DataType | Source | Notes |
|---|---|---|---|
Production/PartsProduced |
Int32 |
cnc_rdparam(6711) |
Active parts-count counter. Wraps to 0 on operator reset. |
Production/PartsRequired |
Int32 |
cnc_rdparam(6712) |
Operator-set target. |
Production/PartsTotal |
Int32 |
cnc_rdparam(6713) |
Lifetime parts counter. |
Production/CycleTimeSeconds |
Int32 |
cnc_rdtimer (channel 0) |
Live cycle-time accumulator. Resets to 0 on next cycle start (CNC-side behaviour). |
Production/LastCycleSeconds |
Float64 |
derived | Plan PR F5-a — seconds for the most recently completed cycle, computed as CycleTimeSeconds(now) - CycleTimeSeconds(at previous parts-count increment). null until the second observed parts-count increment establishes a delta. Pure derivation, no new wire calls. See edge-case rules below. |
Production/LastCycleStartUtc |
DateTime (UTC) |
derived | Plan PR F5-a — UTC wall-clock of the most-recent cycle's start, computed as nowUtc - LastCycleSeconds. null alongside LastCycleSeconds until the second observed increment. |
F5-a derivation edge-case rules
- First observation establishes the baseline;
LastCycleSeconds/LastCycleStartUtcstaynulluntil the second observed parts-count increment produces the first delta. - Parts-count counter reset (current value goes backwards, e.g.
shift-change zero) preserves the last published values so an
operator reading the tag mid-shift-change sees the last known cycle
duration rather than
null/ Bad. The next positive transition produces a fresh delta from the new baseline. - Cycle-timer rollover (delta would be negative — e.g. CNC zeroes
the cycle timer at part completion) leaves the previously-published
values unchanged for one tick and re-baselines so the next
increment produces a clean delta. The driver does NOT publish a
negative
LastCycleSeconds. - Parts-count jumps
> 1(backfill — e.g. counter increments by 3 at once) publish the timer delta over the window asLastCycleSeconds. The plan's "delta over the window between successive parts-count increments" definition does not divide by the count delta; the value reflects the actual elapsed timer between the two observations. - Reconnect / reinit clears the derivation state — the prior CNC session's cycle-timer + parts-count snapshots may be invalidated by the FWLIB session boundary, so the next post-reconnect probe tick re-establishes the baseline before the next delta publishes.
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(seedocs/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-tagWritable) 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:
- Reads the parent byte via
pmc_rdpmcrng(1 byte atR100). - Masks the target bit (set:
current | (1 << bit); clear:current & ~(1 << bit)). - Writes the modified byte back via
pmc_wrpmcrng(1 byte atR100).
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:
- On connect, calls
IFocasClient.UnlockAsync(password, ct)after the FWLIB handle opens but before any read/write fires. The FWLIB-backed client emitscnc_wrunlockparamwith the password ASCII-encoded into the 4-byte FOCAS password slot (right-padded with0x00, truncated at 4 bytes — that's the shape the public Fanuc samples document). - On
BadUserAccessDeniedfrom any gated read or write, re-issuesUnlockAsyncand retries the call exactly once. A secondEW_PASSWDpropagates unchanged so a wrong password doesn't loop forever on the wire. - 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:
FocasDeviceOptionsoverrides the record's auto-generatedToStringto printPassword = ***when the field is non-null. Any Serilog destructure that flows the device options through{Device}gets the redaction for free.FwlibFocasClient.UnlockAsyncdoes not include the password in any exception message — only the FWLIB return code (EW_PASSWD,EW_HANDLE, etc.) makes it into the surface.FocasDriverlogs only"FOCAS unlock applied for {host}"when the unlock succeeds — no password.- The Driver.FOCAS.Cli
--cnc-passwordflag is also redacted at the sameFocasDeviceOptionschoke point. - See
docs/v2/focas-deployment.md§ "FOCAS password handling" for the storage/rotation runbook + the cross-link todocs/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-levelWrites.Enabled = false; per-tagWritable = false;Writes.AllowParameter = falsefor aPARAM:tag (F4-b);Writes.AllowMacro = falsefor aMACRO:tag (F4-b);Writes.AllowPmc = falsefor a PMC tag (F4-c). Same status code, five distinct paths — operators distinguish by checking the knobs.BadUserAccessDenied— F4-b — the CNC reportedEW_PASSWD(parameter-write switch off / unlock required). F4-d wires thecnc_wrunlockparamretry path on top: whenPasswordis configured the driver re-issues unlock + retries the gated call once before surfacing this status. A persistentBadUserAccessDeniedafter 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 typedWriteParameterAsync/WriteMacroAsyncentry points whose default impls returnBadNotSupportedso transports compiled against a staleIFocasClientsurface still build.BadNodeIdUnknown— full-reference doesn't match any configuredFocasTagDefinition.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.