Auto: focas-f4c — pmc_wrpmcrng with bit-level RMW

Closes #270
This commit is contained in:
Joseph Doherty
2026-04-26 05:15:52 -04:00
parent 0c967af645
commit 54c09d4d5d
17 changed files with 837 additions and 101 deletions

View File

@@ -120,6 +120,18 @@ otopcua-focas-cli write -h 192.168.1.50 -a MACRO:500 -t Int32 -v 42
otopcua-focas-cli write -h 192.168.1.50 -a PARAM:1815 -t Int32 -v 100
```
> **WARNING — `write -a G50.3 -t Bit -v on` is a read-modify-write.**
> The wire call `pmc_wrpmcrng` is byte-addressed; the driver reads the
> parent byte at `G50` first, sets bit 3, and writes the byte back. Other
> bits in `G50` that the ladder is concurrently updating may be clobbered
> by the byte we read a millisecond ago. Coordinate via a ladder-side
> handshake when this matters. **PMC writes also bypass the ladder's
> normal MDI-mode protection** — a misdirected bit can move motion or
> latch a feedhold the moment it lands. Verify e-stop is live and the
> machine is in JOG mode before issuing the first PMC write of a
> session. See [`docs/drivers/FOCAS.md`](drivers/FOCAS.md) "PMC bit-write
> read-modify-write semantics" for the full RMW flow.
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.
@@ -146,17 +158,17 @@ 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` enforcement (issue #268 F4-a + #269 F4-b)
#### Server-side `Writes` enforcement (issue #268 F4-a + #269 F4-b + #270 F4-c)
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.
(MACRO kill switch — F4-b), `Writes.AllowPmc` (PMC kill switch — F4-c),
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.
**The CLI bypasses the server-side flag.** `otopcua-focas-cli write` is a
per-invocation operator tool — it sets `Writes.Enabled = true` locally for

View File

@@ -54,7 +54,7 @@ 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 (F4-a) + #269 (F4-b)
## 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
@@ -66,22 +66,54 @@ choice. Decision record: [`docs/v2/decisions.md`](../v2/decisions.md) →
| `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. |
### Config shape — F4-b
> **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
"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 }
"Writable": true, "WriteIdempotent": false },
{ "Name": "StartFlag", "Address": "R100.3", "DataType": "Bit",
"Writable": true, "WriteIdempotent": true }
]
}
```
@@ -119,9 +151,9 @@ value that the operator simply wants forced to a target).
- `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.
(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 will land the
unlock workflow on top of this surface; today the deployment instructs

View File

@@ -44,12 +44,13 @@ 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
## Write safety — issue #269 (PARAM/MACRO, F4-b) + issue #270 (PMC, F4-c)
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.
The FOCAS driver supports `cnc_wrparam`, `cnc_wrmacro`, and `pmc_wrpmcrng`
writes behind multiple independent opt-ins. A misdirected parameter write
can put the CNC in a bad state; a misdirected PMC write can move motion or
latch a feedhold. The runbook below MUST be followed before flipping any
of the granular kill switches on.
### Operator pre-checks (every deployment, every change)
@@ -72,6 +73,35 @@ the granular kill switches on.
`BadNotWritable` until you flip the granular flag, so you can confirm
the tag list before any wire write fires.
### PMC pre-checks (in addition to the above) — F4-c
PMC writes have a higher blast radius than PARAM/MACRO writes because PMC
is the ladder's working memory — bits in R/G/F/D directly drive servo
enables, feedhold latches, and safety interlocks. Before flipping
`Writes.AllowPmc` on:
1. **E-stop verified live + reachable.** The first PMC write of a session
should be issued with the operator's hand on the e-stop. PMC writes
bypass the ladder's normal MDI-mode protections; a misdirected bit can
move motion the moment it lands on the wire.
2. **Machine in JOG mode (or equivalent low-energy mode).** Auto / MEM
modes interpret PMC state immediately; JOG / MDI surface symptoms
slowly enough that the e-stop is the recovery path. **Never issue the
first PMC write of a deployment in Auto.**
3. **Audit the PMC tag list against the ladder print-out.** `R100.3` on
one machine is "homing complete"; on another it's "feedhold released".
The driver has no way to distinguish — the ladder source is the only
ground truth.
4. **Bit writes are read-modify-write — see
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) "PMC bit-write read-modify-write semantics".**
`pmc_wrpmcrng` is byte-addressed; the driver reads the parent byte
first, masks the target bit, and writes the byte back. Concurrent
ladder writes to the same byte create a small race window. Coordinate
through a ladder-side handshake when this matters.
5. **Dry run with `Writable = true` but `Writes.AllowPmc = false`.** Same
staged-opt-in pattern as PARAM/MACRO — confirm tag mapping before any
PMC byte hits the wire.
### LDAP group requirements
Per [`docs/security.md`](../security.md) the server-layer ACL maps
@@ -108,6 +138,14 @@ 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.
**Audit PMC writes specifically.** Because PMC writes have the highest blast
radius of the three write kinds, ops should set up a saved-search /
dashboard query for `Driver=FOCAS` + `Address` matching the PMC letter
prefixes (`R*`, `G*`, `F*`, `D*`, `Y*`, etc.) and review on the same
cadence as ladder change reviews. A spike in PMC write rate or a write
to an address outside the audited tag list is the leading indicator of a
misconfigured client or compromised credential.
### Granular config example
```jsonc
@@ -120,7 +158,8 @@ succeeded.
"Writes": {
"Enabled": true,
"AllowMacro": true, // recipe / setpoint writes — operator role
"AllowParameter": false // commissioning only — keep locked except during planned work
"AllowParameter": false, // commissioning only — keep locked except during planned work
"AllowPmc": false // PMC writes — keep locked unless the deployment specifically needs them
},
"Tags": [
{ "Name": "Recipe.PartCount", "DeviceHostAddress": "focas://10.0.0.5:8193",
@@ -128,13 +167,19 @@ succeeded.
"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 */ }
"Writable": false /* keep read-only until commissioning window */ },
{ "Name": "OperatorRequest", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "R100.3", "DataType": "Bit",
"Writable": false /* keep PMC read-only until ladder handshake reviewed */ }
]
}
}
}
```
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.
Flipping `AllowParameter` / `AllowPmc` on for the commissioning window
(and back off afterward) is the recommended deployment cadence — the
granular kill switches are lightweight runtime toggles, not config-DB
redeploys. PMC in particular should default OFF in production and only
flip on for windows where the ladder team has signed off on the write
path.

View File

@@ -31,6 +31,7 @@ command ids (and their request/response payloads) don't drift between the
| ... | ... | ... |
| **`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** |
| **`0x0104`** | **`pmc_wrpmcrng`** | **mutates per-profile PMC byte tables; byte-aligned writes preserve untouched bytes; bit-level writes never reach the simulator (driver wraps with RMW) — issue #270, plan PR F4-c** |
| **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
## `cnc_rdalmhistry` mock behaviour
@@ -183,13 +184,97 @@ 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.
## `pmc_wrpmcrng` mock behaviour — issue #270, plan PR F4-c
The simulator keeps a per-profile PMC byte table keyed by `(addr_type,
byte_address)` — the same map the existing `pmc_rdpmcrng` handler reads
from. The write handler mutates the same map so a subsequent read sees
the written bytes.
### Per-profile state
Each profile carries:
```python
pmc: Dict[int, bytearray] # addr_type -> bytearray (one per PMC letter, default 256 bytes each)
```
`addr_type` is the PMC area code (R=5, G=4, F=3, D=8, X=1, Y=2, K=10,
A=11, E=12, T=6, C=7); the existing `pmc_rdpmcrng` fixture seeds the
defaults (zeros + a few canned bits per the dl205-style profile fixtures).
### `pmc_wrpmcrng` request decode
| Offset | Width | Field |
| --- | --- | --- |
| 0 | int16 LE | `addr_type` |
| 2 | int16 LE | `data_type` (must be `0` = byte; the driver only emits byte writes) |
| 4 | uint16 LE | `datano_s` |
| 6 | uint16 LE | `datano_e` |
| 8 | bytes | `data[]``(datano_e - datano_s + 1)` bytes |
Handler steps:
1. Look up the per-profile bytearray for `addr_type` (allocate on first
write, default 256 zeros).
2. **Validate** `0 <= datano_s <= datano_e < len(bytearray)` — otherwise
return `EW_NUMBER` (`4`).
3. **Validate** `len(data) == datano_e - datano_s + 1` — otherwise
return `EW_LENGTH` (`14`).
4. **Validate** `data_type == 0` — otherwise return `EW_DATA` (`9`)
because the driver only ever emits byte writes (bit writes wrap with
driver-side RMW so they reach the simulator as 1-byte writes).
5. Copy `data[]` into `bytearray[datano_s:datano_e+1]`. Other bytes
in the array are untouched.
6. Update `last_write` admin-endpoint state (kind=`pmc`, address-type,
start byte, length, bytes).
7. Return `ew_status = 0`.
### Round-trip invariant
The simulator MUST satisfy:
```
write(R, [10..12], [0xAA, 0xBB, 0xCC]); read(R, [10..12]) == [0xAA, 0xBB, 0xCC]
```
and the **byte-isolation invariant**:
```
write(R, [11], [0xFF]); bytes[10] == prior bytes[10] && bytes[12] == prior bytes[12]
```
The integration tests `Series/PmcRangeWriteTests.cs` and
`Series/PmcBitRmwIntegrationTests.cs` assert both shapes.
### Admin endpoint — `GET /admin/mock_get_last_write` extension
The `last_write` payload gains a `kind: "pmc"` variant:
```
{
"kind": "pmc",
"addr_type": 5, // R
"datano_s": 100,
"datano_e": 100,
"bytes": "0x08", // hex-encoded
"writtenAt": "2026-04-25T13:30:00Z"
}
```
Bit-level writes never appear here as a separate kind — they reach the
simulator as 1-byte writes after the driver's RMW wrapper, so the audit
shape is identical to a byte write at the same address.
### 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`.
focas-mock simulator has not landed yet (tracked separately from F4-b /
F4-c). F4-b + F4-c land the .NET-side wire encoders + dispatch + status
mapping unconditionally; the integration-test scaffolds at
`tests/.../IntegrationTests/Series/ParameterWriteTests.cs`,
`MacroWriteTests.cs`, `PmcRangeWriteTests.cs`, and
`PmcBitRmwIntegrationTests.cs` are deferred until the simulator +
integration-test project land. Until then unit-test coverage in
`FocasWriteParameterTests` / `FocasWriteMacroTests` /
`FocasWritePmcTests` exercises every same-process invariant against the
in-memory `FakeFocasClient`.

View File

@@ -21,6 +21,7 @@ Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are
| ... | ... | ... |
| **`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)** |
| **`0x0104`** | **`pmc_wrpmcrng`** | **IODBPMC PMC range-write packet (issue #270, plan PR F4-c)** |
| `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`)
@@ -154,3 +155,62 @@ 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.
## IODBPMC — PMC range write (`pmc_wrpmcrng`, command `0x0104`)
Issue #270, plan PR F4-c. The write-side payload is the read-side
`pmc_rdpmcrng` IODBPMC packet with the data direction inverted: the
caller fills the `data[]` byte run and the simulator / Fwlib32 stores
it; the response is the small status envelope rather than the populated
data buffer the read side returns.
### Request
| Offset | Width | Field |
| --- | --- | --- |
| 0 | `int16 LE` | `type_a` — PMC address-type code (R=5, G=4, F=3, D=8, X=1, Y=2, K=10, A=11, E=12, T=6, C=7) |
| 2 | `int16 LE` | `type_d` — data type (`0` = byte; only byte writes are issued — bit writes wrap the byte path with a read-modify-write helper) |
| 4 | `uint16 LE` | `datano_s` — first byte address (inclusive) |
| 6 | `uint16 LE` | `datano_e` — last byte address (inclusive) — `(datano_e - datano_s + 1)` is the byte count |
| 8 | `bytes` | `data[]` — payload, exactly `(datano_e - datano_s + 1)` bytes |
The header is 8 bytes; the FWLIB `IODBPMC.data` field caps at 32 bytes
(40-byte total per call), so larger ranges are chunked into 32-byte
sub-calls by the wire client. The simulator MUST honour the same chunk
ceiling so chunked-vs-single round-trips produce the same final bytes.
### Response
Same single-int16 envelope as `cnc_wrparam` / `cnc_wrmacro`:
| Offset | Width | Field |
| --- | --- | --- |
| 0 | `int16 LE` | `ew_status``0` = success, non-zero = FANUC `EW_*` |
`EW_NOOPT` (option not installed), `EW_NUMBER` (out-of-range address),
`EW_LENGTH` (chunk size mismatch) are the typical failures the simulator
reproduces; the mapper translates them to OPC UA status codes the same
way the read-side does.
### Bit-level RMW (driver-side, no extra wire op)
`pmc_wrpmcrng` is **byte-addressed** — there is no sub-byte write op on
the wire. Bit writes go through `IFocasClient.WritePmcBitAsync` which:
1. Issues a 1-byte `pmc_rdpmcrng` to fetch the parent byte.
2. Masks the target bit (set: OR; clear: AND-NOT).
3. Issues a 1-byte `pmc_wrpmcrng` with the modified byte.
A per-byte semaphore in `FwlibFocasClient` serialises concurrent bit
writes against the same byte so two updates that race never lose one
another's bit. The simulator's handler implements the same byte-aligned
semantics — bit writes never reach it as a separate frame.
### Symmetry note
The encoder is the `pmc_rdpmcrng` decoder reversed: the read side parses
`(type_a, type_d, datano_s, datano_e)` from the request and emits the
data buffer in the response; the write side parses all five fields plus
the data buffer from the request and emits a status int16 in the
response. Tests `FocasWritePmcTests.PMC_*` exercise the round-trip on
the fake wire client.