Auto: focas-f4a — write infrastructure + per-tag opt-in

Closes #268
This commit is contained in:
Joseph Doherty
2026-04-26 04:32:43 -04:00
parent 6f1657b1c0
commit 1bfe8fba0e
13 changed files with 521 additions and 11 deletions

View File

@@ -120,6 +120,25 @@ parameter-write switch enabled.
**Writes are non-idempotent by default** — a timeout after the CNC already
applied the write will NOT auto-retry (plan decisions #44 + #45).
#### Server-side `Writes.Enabled` enforcement (issue #268, plan PR F4-a)
The OtOpcUa server gates every FOCAS write behind two independent opt-ins:
`FocasDriverOptions.Writes.Enabled` (driver-level master switch, default
`false`) and `FocasTagDefinition.Writable` (per-tag, default `false`). When
either is off, the server-side `WriteAsync` short-circuits to
`BadNotWritable` before the wire client is touched. See
[`docs/drivers/FOCAS.md`](drivers/FOCAS.md) "Writes (opt-in, off by
default)" subsection + [`docs/v2/decisions.md`](v2/decisions.md) for the
decision record.
**The CLI bypasses the server-side flag.** `otopcua-focas-cli write` is a
per-invocation operator tool — it sets `Writes.Enabled = true` locally for
the lifetime of one process and creates the synthesised tag with
`Writable = true`. This is intentional: the CLI is the operator's
direct-to-CNC fallback, not a long-lived process bound to the central
config DB. Configuring the server still requires both opt-ins to be set
explicitly in the DriverInstance JSON.
### `subscribe` — watch an address until Ctrl+C
FOCAS has no push model; the shared `PollGroupEngine` handles the tick

View File

@@ -53,3 +53,56 @@ giant request. Typical FANUC ring buffers cap at ~100 entries; the default
- 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, plan PR F4-a
Writes ship behind two independent opt-ins. Both 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. |
| `FocasTagDefinition.Writable` *(per-tag opt-in)* | `false` | The per-tag check returns `BadNotWritable` for that tag even when the driver-level flag is on. |
### Config shape
```jsonc
{
"Writes": { "Enabled": true },
"Tags": [
{ "Name": "RPM", "Address": "PARAM:1815", "DataType": "Int32",
"Writable": true, "WriteIdempotent": false }
]
}
```
`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).
### Status-code semantics post-F4-a
- `BadNotWritable` — driver-level `Writes.Enabled = false`, OR per-tag
`Writable = false`. Two distinct paths, same status code.
- `BadNotSupported` — both opt-ins flipped on, but the wire client doesn't
yet implement the kind being written. F4-a wires the dispatch surface;
F4-b/c land the actual macro / parameter / PMC writes for unimplemented
kinds, replacing those `BadNotSupported` responses with real wire calls.
- `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.

73
docs/v2/decisions.md Normal file
View File

@@ -0,0 +1,73 @@
# Decisions
Architecture-level decisions taken during the v2 implementation, captured
once and referenced from feature docs / PR descriptions / ADR-style
follow-ups. Each entry lists the decision, the alternatives we considered,
and the rationale that tipped the call.
## FOCAS write-path opt-in
**Issue:** [#268](https://github.com/dohertj2/lmxopcua/issues/268). **Plan PR:** F4-a.
### Decision
The FOCAS driver ships writes behind two independent opt-ins, both default
off:
1. **Driver-level master switch**`FocasDriverOptions.Writes.Enabled`,
default `false`. When off, every entry in a `WriteAsync` batch short-
circuits to `BadNotWritable` with status text `writes disabled at
driver level`. The wire client is never touched.
2. **Per-tag opt-in**`FocasTagDefinition.Writable`, default `false`
(flipped from `true` in F4-a). A `Writable = false` tag returns
`BadNotWritable` even when the driver-level flag is on.
`BadNotSupported` is reserved for kinds the wire client hasn't yet
implemented; F4-b/c land actual macro / parameter / PMC writes that
currently dispatch to `BadNotSupported` (or to `Good` against the F4-a
fake) for unimplemented branches.
### Alternatives considered
- **Always-on writes (the pre-F4-a default).** Rejected: a single
misconfigured tag flipping `Writable = true` by accident would let an
operator overwrite a CNC parameter from any OPC UA client. The two-
opt-in posture means an accidental tag flip alone isn't enough.
- **Driver-level switch only.** Rejected: doesn't protect against an
operator with admin rights flipping the master switch to do bulk diag
reads but inheriting write capability for tags that were intended
read-only.
- **Per-tag opt-in only.** Rejected: doesn't give the deployment an "all
writes off" emergency lever — useful during a CNC commissioning where
writes are unsafe across the board for a period.
### Rationale
CNC writes are non-idempotent in the field's worst-case shape: feed
overrides, M-code pulses, alarm acks, recipe-step advances. Two opt-ins
is the cheapest defence-in-depth posture that still lets writes ship.
Both default off so a fresh deployment is read-only — the explicit choice
to enable writes lands at config time where it's reviewable, not at
runtime where it's invisible.
`WriteIdempotent` plumbs through `CapabilityInvoker.ExecuteWriteAsync`
into the Polly retry pipeline; default `false` means failed writes are
not auto-retried (plan decisions #44 / #45). Per-tag flip required for
genuinely-idempotent writes.
### CLI carve-out
`otopcua-focas-cli write` sets `Writes.Enabled = true` locally for the
lifetime of one process and synthesises a `Writable = true` tag. The CLI
is a per-operator direct-to-CNC tool — not a long-lived process bound to
the central config DB. Configuring the server still requires both opt-ins
to be set explicitly in the DriverInstance JSON. The bypass is documented
in `docs/Driver.FOCAS.Cli.md` so operators understand the asymmetry.
### Migration
Pre-F4-a deployments that relied on the `Writable = true` default need to
add `"Writable": true` to every tag they intend to write + an enclosing
`"Writes": { "Enabled": true }` block in their DriverInstance JSON.
Bootstrap rows seeded before F4-a get `Writable = false` after upgrade —
this is intentional; review-then-flip is the safer migration path.