Auto: focas-f4b — cnc_wrmacro + cnc_wrparam writes

Closes #269
This commit is contained in:
Joseph Doherty
2026-04-26 04:54:28 -04:00
parent 71af554497
commit f48f31cfc7
15 changed files with 1066 additions and 36 deletions

View File

@@ -110,23 +110,50 @@ Values parse per `--type` with invariant culture. Booleans accept
```powershell
otopcua-focas-cli write -h 192.168.1.50 -a R100 -t Int16 -v 42
otopcua-focas-cli write -h 192.168.1.50 -a G50.3 -t Bit -v on
otopcua-focas-cli write -h 192.168.1.50 -a MACRO:500 -t Float64 -v 3.14
# MACRO: write — recipe / setpoint surface (server-side WriteOperate ACL)
otopcua-focas-cli write -h 192.168.1.50 -a MACRO:500 -t Int32 -v 42
# PARAM: write — commissioning surface (server-side WriteConfigure ACL,
# CNC must be in MDI mode + parameter-write switch enabled, else EW_PASSWD
# surfaces as BadUserAccessDenied)
otopcua-focas-cli write -h 192.168.1.50 -a PARAM:1815 -t Int32 -v 100
```
PMC G/R writes land on a running machine — be careful which file you hit.
Parameter writes may require the CNC to be in MDI mode with the
parameter-write switch enabled.
#### Server-enforced ACL — issue #269, plan PR F4-b
When the same write flows through the OtOpcUa server (rather than the CLI's
direct-to-CNC path), the server-layer ACL gates by tag kind:
- `PARAM:` writes require **`WriteConfigure`** group membership — heavier
ACL because a misdirected parameter write can put the CNC in a bad
state.
- `MACRO:` writes require **`WriteOperate`** — matches the standard HMI
recipe / setpoint surface.
- PMC R/G/F writes require **`WriteOperate`**.
The classification is declared by the FOCAS driver per tag and enforced by
`DriverNodeManager`; the driver itself never inspects user identity. See
[`docs/security.md`](security.md) for the full LDAP-group → permission
mapping, [`docs/v2/acl-design.md`](v2/acl-design.md) for the design, and
[`docs/v2/focas-deployment.md`](v2/focas-deployment.md) "Write safety" for
the operator pre-check runbook (MDI mode, parameter-write switch).
**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)
#### Server-side `Writes` enforcement (issue #268 F4-a + #269 F4-b)
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
The OtOpcUa server gates every FOCAS write behind multiple independent
opt-ins: `FocasDriverOptions.Writes.Enabled` (driver-level master switch),
`Writes.AllowParameter` (PARAM kill switch — F4-b), `Writes.AllowMacro`
(MACRO kill switch — F4-b), and `FocasTagDefinition.Writable` (per-tag).
All default `false`; any one off short-circuits the server-side
`WriteAsync` 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.

View File

@@ -54,9 +54,9 @@ giant request. Typical FANUC ring buffers cap at ~100 entries; the default
surfacing the FWLIB struct fields directly into
`FocasAlarmHistoryEntry`.
## Writes (opt-in, off by default) — issue #268, plan PR F4-a
## Writes (opt-in, off by default) — issue #268 (F4-a) + #269 (F4-b)
Writes ship behind two independent opt-ins. Both default off so a freshly
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".
@@ -64,20 +64,49 @@ choice. Decision record: [`docs/v2/decisions.md`](../v2/decisions.md) →
| 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. |
| **`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.** |
| `FocasTagDefinition.Writable` *(per-tag opt-in)* | `false` | The per-tag check returns `BadNotWritable` for that tag even when the driver-level flags are on. |
### Config shape
### Config shape — F4-b
```jsonc
{
"Writes": { "Enabled": true },
"Writes": {
"Enabled": true,
"AllowParameter": true, // F4-b — opt into cnc_wrparam
"AllowMacro": true // F4-b — opt into cnc_wrmacro
},
"Tags": [
{ "Name": "RPM", "Address": "PARAM:1815", "DataType": "Int32",
"Writable": true, "WriteIdempotent": false },
{ "Name": "Recipe", "Address": "MACRO:500", "DataType": "Int32",
"Writable": true, "WriteIdempotent": false }
]
}
```
### 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
@@ -86,14 +115,23 @@ 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
### Status-code semantics post-F4-b
- `BadNotWritable` — driver-level `Writes.Enabled = false`, OR per-tag
`Writable = false`. Two distinct paths, same status code.
- `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)**. Same
status code, four distinct paths — operators distinguish by checking the
knobs.
- `BadUserAccessDenied`**F4-b** — the CNC reported `EW_PASSWD`
(parameter-write switch off / unlock required). F4-d will land the
unlock workflow on top of this surface; today the deployment instructs
the operator to flip the parameter-write switch on the CNC pendant.
- `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.
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,

View File

@@ -43,3 +43,98 @@ The history projection emits each unseen entry through
reported wall-clock — keep CNC clocks on UTC so the dedup key
`(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across DST
transitions.
## Write safety — issue #269, plan PR F4-b
The FOCAS driver supports `cnc_wrparam` and `cnc_wrmacro` writes behind
multiple independent opt-ins. A misdirected parameter write can put the
CNC in a bad state, so the runbook below MUST be followed before flipping
the granular kill switches on.
### Operator pre-checks (every deployment, every change)
1. **CNC must be in MDI mode.** Most parameter writes fail with `EW_PASSWD`
(surfaces as `BadUserAccessDenied`) unless the CNC is in MDI. The
server-side write returns immediately with the access-denied status; no
value reaches the wire.
2. **Parameter-write switch enabled on the CNC pendant.** Even in MDI mode
protected parameters require the operator to physically enable the
parameter-write switch. Without it `cnc_wrparam` returns `EW_PASSWD`.
Plan PR F4-d will land an OPC UA-side unlock workflow; today the only
path is the pendant.
3. **Verify each tag's address against the FANUC manual.** Ranges vary per
CNC series; the
[`focas-version-matrix`](./focas-version-matrix.md) capability matrix
rejects out-of-range numbers at startup, but address-vs-meaning is the
operator's job.
4. **Dry run with `Writable = true` but `Writes.AllowParameter = false`.**
Staged opt-in catches mis-mapped tags: every PARAM write returns
`BadNotWritable` until you flip the granular flag, so you can confirm
the tag list before any wire write fires.
### LDAP group requirements
Per [`docs/security.md`](../security.md) the server-layer ACL maps
`SecurityClassification` to LDAP groups. Post-F4-b:
| Tag kind | LDAP group required |
| --- | --- |
| `PARAM:N` (writable) | **`WriteConfigure`** — heaviest write tier; matches commissioning roles |
| `MACRO:N` (writable) | `WriteOperate` — standard HMI recipe / setpoint group |
| PMC R/G/F (writable) | `WriteOperate` |
| Read-only | `ReadOnly` |
Per the `feedback_acl_at_server_layer` design note, the FOCAS driver
declares the classification but does NOT enforce it; `DriverNodeManager`
applies the gate before the driver's `WriteAsync` ever runs. A user
without `WriteConfigure` who attempts a `PARAM:` write gets
`BadUserAccessDenied` from the server with no driver-level audit entry —
the OPC UA layer's audit log catches it.
### Audit-log expectations
Every successful write produces:
- An OPC UA AuditWriteEvent (server layer — see
[`docs/security.md`](../security.md) "Audit logging").
- A FOCAS driver-level Serilog entry tagged `Driver=FOCAS DriverInstanceId=...
TagName=... Address=... ResultStatus=...`.
- A `Writes/LastWriteAt` and `Writes/LastWriteStatus` diagnostic counter
refresh on the device's `Diagnostics/` fixed-tree node (planned;
populated as F4-c lands).
Failures to write (`BadUserAccessDenied`, `BadCommunicationError`, etc.)
produce the same audit entries with the failure status code so a
post-incident reviewer sees the same shape regardless of whether the write
succeeded.
### Granular config example
```jsonc
{
"Drivers": {
"FOCAS": {
"Devices": [
{ "HostAddress": "focas://10.0.0.5:8193", "Series": "Series30i" }
],
"Writes": {
"Enabled": true,
"AllowMacro": true, // recipe / setpoint writes — operator role
"AllowParameter": false // commissioning only — keep locked except during planned work
},
"Tags": [
{ "Name": "Recipe.PartCount", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "MACRO:500", "DataType": "Int32",
"Writable": true, "WriteIdempotent": true },
{ "Name": "MaxFeedrate", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "PARAM:1815", "DataType": "Int32",
"Writable": false /* keep read-only until commissioning window */ }
]
}
}
}
```
Flipping `AllowParameter` on for the commissioning window (and back off
afterward) is the recommended deployment cadence — the granular kill
switch is a lightweight runtime toggle, not a config-DB redeploy.

View File

@@ -29,6 +29,8 @@ command ids (and their request/response payloads) don't drift between the
| `0x0010` | `pmc_rdpmcrng` | reads PMC byte ranges |
| `0x0020` | `cnc_modal` | reads cached modal MSTB per profile |
| ... | ... | ... |
| **`0x0102`** | **`cnc_wrparam`** | **mutates per-profile parameter map; returns `EW_PASSWD` (`11`) when the profile's `unlock_state` is off (sets up F4-d's unlock workflow) — issue #269, plan PR F4-b** |
| **`0x0103`** | **`cnc_wrmacro`** | **mutates per-profile macro map; integer-only writes for now (decimalPointCount=0) — issue #269, plan PR F4-b** |
| **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
## `cnc_rdalmhistry` mock behaviour
@@ -100,3 +102,94 @@ Integration test `Series/AlarmHistoryProjectionTests.cs` will assert:
These tests are blocked on the focas-mock + integration-test project
landing; the unit-test coverage in `FocasAlarmProjectionTests` already
exercises every same-process invariant.
## `cnc_wrparam` / `cnc_wrmacro` mock behaviour — issue #269, plan PR F4-b
When the focas-mock fixture lands, it MUST implement the contract below.
The .NET side already ships against this contract (`FwlibFocasClient.cs`
write helpers, `FakeFocasClient` round-trip support); writing the simulator
to the same shape lets the existing integration-test scaffolds at
`tests/.../IntegrationTests/Series/ParameterWriteTests.cs` and
`MacroWriteTests.cs` (when they materialise) light up without driver
changes.
### Per-profile state
Each profile owns:
- `parameters: Dict[int, int]` — map from parameter number to current value.
- `macros: Dict[int, int]` — map from macro number to current scaled-int
value (decimal-point count fixed at 0 for F4-b).
- `unlock_state: bool` — defaults `False`. When `False`, every
`cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of
parameter. Macro writes are NOT gated by `unlock_state`.
- `last_write: Optional[LastWrite]` — most-recent successful
`(kind, number, value, ts)` tuple, surfaced via the admin endpoint
below for audit-log assertions.
### `cnc_wrparam` request decode
```
[int16 LE datano][int16 LE axis][int8|int16|int32 LE value]
```
Width of the value field is determined by the request frame trailer
length per the table in
[`focas-wire-protocol.md`](./focas-wire-protocol.md). On
`unlock_state == False` short-circuit to `[int16 LE 11]` (`EW_PASSWD`).
Otherwise mutate `parameters[datano] = value`, set `last_write`, return
`[int16 LE 0]`.
### `cnc_wrmacro` request decode
```
[int16 LE number][int16 LE length=8][int32 LE mcr_val][int16 LE dec_val]
```
Always accept (no `unlock_state` gate). Mutate
`macros[number] = mcr_val` (we ignore `dec_val` for F4-b — integer-only).
Return `[int16 LE 0]`. Round-trip: a subsequent `cnc_rdmacro(number)`
returns `(mcr_val, 0)`.
### Admin endpoint — `POST /admin/mock_set_unlock_state`
Toggles `unlock_state` for the F4-d unlock workflow tests. Without this,
F4-b parameter-write integration tests can't reproduce the
`EW_PASSWD``BadUserAccessDenied` mapping.
```
POST /admin/mock_set_unlock_state
{ "profile": "Series30i", "unlocked": true }
```
### Admin endpoint — `GET /admin/mock_get_last_write`
Returns the simulator's view of the most-recent successful write, used by
F4-b audit-log integration assertions ("did the write actually reach the
fixture, and is the audit log capturing the right kind/number/value?").
```
GET /admin/mock_get_last_write?profile=Series30i
->
{
"kind": "param", // "param" | "macro"
"number": 1815,
"value": 100,
"writtenAt": "2026-04-25T13:30:00Z"
}
```
When no write has happened the endpoint returns `null` rather than 404 so
the test helper can assert "no writes since fixture reset" without
exception handling.
### Status
focas-mock simulator has not landed yet (tracked separately from F4-b).
F4-b lands the .NET-side wire encoders + dispatch + status mapping
unconditionally; the integration-test scaffolds at
`tests/.../IntegrationTests/Series/ParameterWriteTests.cs` and
`MacroWriteTests.cs` are deferred until the simulator + integration-test
project land. Until then unit-test coverage in
`FocasWriteParameterTests` / `FocasWriteMacroTests` exercises every
same-process invariant against the in-memory `FakeFocasClient`.

View File

@@ -19,6 +19,8 @@ Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are
| `0x0003` | `cnc_rdmacro` | macro variable value |
| `0x0004` | `cnc_rddiag` | diagnostic value |
| ... | ... | ... |
| **`0x0102`** | **`cnc_wrparam`** | **IODBPSD parameter-write packet (issue #269, plan PR F4-b)** |
| **`0x0103`** | **`cnc_wrmacro`** | **ODBM macro-write packet (issue #269, plan PR F4-b)** |
| `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`)
@@ -74,3 +76,81 @@ DST transitions. The .NET decoder
unstable anyway.
- `msg_len` overrunning the payload truncates the entry list at the
malformed entry rather than throwing.
## IODBPSD — parameter write (`cnc_wrparam`, command `0x0102`)
Issue #269, plan PR F4-b. The write-side payload is the **byte-symmetric
inverse of the `cnc_rdparam` read** — the same `IODBPSD` struct shape, and
the .NET wire client uses the read-side decoder reversed (`EncodeParamValue`
in `FwlibFocasClient.cs`) so the encoder/decoder are guaranteed to stay in
lock-step.
### Request
| Offset | Width | Field |
| --- | --- | --- |
| 0 | `int16 LE` | `datano` — parameter number (e.g. `1815`) |
| 2 | `int16 LE` | `type` — axis index (1-based; `0` = whole-CNC parameter) |
| 4 | `length` | `data` payload — width depends on parameter type |
`length` (request frame trailer, drives `data` width):
| FocasDataType | `length` | Payload encoding |
| --- | --- | --- |
| `Byte` | `4 + 1` | one signed byte at offset 4 |
| `Int16` | `4 + 2` | int16 LE at offset 4 |
| `Int32` | `4 + 4` | int32 LE at offset 4 |
Bit-addressed parameters (`PARAM:1815/0` form) are not supported by F4-b
and surface as `BadNotSupported`; F4-c will land the read-modify-write
helper alongside the PMC bit RMW path.
### Response
Single `int16 LE` return code per the standard FWLIB convention:
- `0``Good`
- `11` (`EW_PASSWD`) → **`BadUserAccessDenied`** (was `BadNotWritable`
pre-F4-b — see `FocasStatusMapper`). Means the parameter-write switch is
off or the CNC isn't in MDI mode; the F4-d unlock workflow will close
the loop on this from the OPC UA side.
- Other `EW_*` codes map per
[`FocasStatusMapper.MapFocasReturn`](../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs).
## ODBM — macro write (`cnc_wrmacro`, command `0x0103`)
Issue #269, plan PR F4-b. The write-side payload mirrors the
`cnc_rdmacro` read shape: the same `(mcr_val, dec_val)` (integer +
decimal-point count) split, but emitted from the .NET side rather than
decoded.
### Request
| Offset | Width | Field |
| --- | --- | --- |
| 0 | `int16 LE` | `number` — macro variable number (e.g. `500`) |
| 2 | `int16 LE` | `length` — fixed at `8` for ODBM |
| 4 | `int32 LE` | `mcr_val` — scaled integer value |
| 8 | `int16 LE` | `dec_val` — decimal-point count |
F4-b ships **integer-only writes** (`dec_val = 0`) to match the most
common HMI pattern; a future `WriteMacroScaled` overload will land if the
field calls for fractional macro setpoints. Read-side decoders apply
`mcr_val / 10^dec_val`, so a `dec_val = 0` write surfaces back as the
integer it was emitted as.
### Response
Same single-int16 envelope as `cnc_wrparam`. `EW_PASSWD` is rare on macro
writes (the gate-switch protection is parameter-specific) but the mapper
treats both kinds identically.
### Symmetry note
The plan carries a "byte layout symmetry" requirement — the encoder for
each kind is the read-side decoder reversed. Adding a new parameter type
(e.g. `Int64` parameters, when they ship) means extending both sides in
the same PR; the unit test
`FocasWriteParameterTests.ParameterWrite_round_trip_stores_value_visible_to_subsequent_read`
exercises encode → store → decode with the fake wire client and is the
canary for symmetry regressions.