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 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. 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 writes may require the CNC to be in MDI mode with the
parameter-write switch enabled. 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 **Writes are non-idempotent by default** — a timeout after the CNC already
applied the write will NOT auto-retry (plan decisions #44 + #45). 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 The OtOpcUa server gates every FOCAS write behind multiple independent
opt-ins: `FocasDriverOptions.Writes.Enabled` (driver-level master switch), opt-ins: `FocasDriverOptions.Writes.Enabled` (driver-level master switch),
`Writes.AllowParameter` (PARAM kill switch — F4-b), `Writes.AllowMacro` `Writes.AllowParameter` (PARAM kill switch — F4-b), `Writes.AllowMacro`
(MACRO kill switch — F4-b), and `FocasTagDefinition.Writable` (per-tag). (MACRO kill switch — F4-b), `Writes.AllowPmc` (PMC kill switch — F4-c),
All default `false`; any one off short-circuits the server-side and `FocasTagDefinition.Writable` (per-tag). All default `false`; any one
`WriteAsync` to `BadNotWritable` before the wire client is touched. See off short-circuits the server-side `WriteAsync` to `BadNotWritable` before
[`docs/drivers/FOCAS.md`](drivers/FOCAS.md) "Writes (opt-in, off by the wire client is touched. See [`docs/drivers/FOCAS.md`](drivers/FOCAS.md)
default)" subsection + [`docs/v2/decisions.md`](v2/decisions.md) for the "Writes (opt-in, off by default)" subsection +
decision record. [`docs/v2/decisions.md`](v2/decisions.md) for the decision record.
**The CLI bypasses the server-side flag.** `otopcua-focas-cli write` is a **The CLI bypasses the server-side flag.** `otopcua-focas-cli write` is a
per-invocation operator tool — it sets `Writes.Enabled = true` locally for 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 surfacing the FWLIB struct fields directly into
`FocasAlarmHistoryEntry`. `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 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 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.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.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.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. | | `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 ```jsonc
{ {
"Writes": { "Writes": {
"Enabled": true, "Enabled": true,
"AllowParameter": true, // F4-b — opt into cnc_wrparam "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": [ "Tags": [
{ "Name": "RPM", "Address": "PARAM:1815", "DataType": "Int32", { "Name": "RPM", "Address": "PARAM:1815", "DataType": "Int32",
"Writable": true, "WriteIdempotent": false }, "Writable": true, "WriteIdempotent": false },
{ "Name": "Recipe", "Address": "MACRO:500", "DataType": "Int32", { "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 - `BadNotWritable` — one of: driver-level `Writes.Enabled = false`; per-tag
`Writable = false`; **`Writes.AllowParameter = false` for a `PARAM:` tag `Writable = false`; **`Writes.AllowParameter = false` for a `PARAM:` tag
(F4-b)**; **`Writes.AllowMacro = false` for a `MACRO:` tag (F4-b)**. Same (F4-b)**; **`Writes.AllowMacro = false` for a `MACRO:` tag (F4-b)**;
status code, four distinct paths — operators distinguish by checking the **`Writes.AllowPmc = false` for a PMC tag (F4-c)**. Same status code,
knobs. five distinct paths — operators distinguish by checking the knobs.
- `BadUserAccessDenied`**F4-b** — the CNC reported `EW_PASSWD` - `BadUserAccessDenied`**F4-b** — the CNC reported `EW_PASSWD`
(parameter-write switch off / unlock required). F4-d will land the (parameter-write switch off / unlock required). F4-d will land the
unlock workflow on top of this surface; today the deployment instructs 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 `(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across DST
transitions. 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 The FOCAS driver supports `cnc_wrparam`, `cnc_wrmacro`, and `pmc_wrpmcrng`
multiple independent opt-ins. A misdirected parameter write can put the writes behind multiple independent opt-ins. A misdirected parameter write
CNC in a bad state, so the runbook below MUST be followed before flipping can put the CNC in a bad state; a misdirected PMC write can move motion or
the granular kill switches on. 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) ### 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 `BadNotWritable` until you flip the granular flag, so you can confirm
the tag list before any wire write fires. 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 ### LDAP group requirements
Per [`docs/security.md`](../security.md) the server-layer ACL maps 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 post-incident reviewer sees the same shape regardless of whether the write
succeeded. 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 ### Granular config example
```jsonc ```jsonc
@@ -120,7 +158,8 @@ succeeded.
"Writes": { "Writes": {
"Enabled": true, "Enabled": true,
"AllowMacro": true, // recipe / setpoint writes — operator role "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": [ "Tags": [
{ "Name": "Recipe.PartCount", "DeviceHostAddress": "focas://10.0.0.5:8193", { "Name": "Recipe.PartCount", "DeviceHostAddress": "focas://10.0.0.5:8193",
@@ -128,13 +167,19 @@ succeeded.
"Writable": true, "WriteIdempotent": true }, "Writable": true, "WriteIdempotent": true },
{ "Name": "MaxFeedrate", "DeviceHostAddress": "focas://10.0.0.5:8193", { "Name": "MaxFeedrate", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "PARAM:1815", "DataType": "Int32", "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 Flipping `AllowParameter` / `AllowPmc` on for the commissioning window
afterward) is the recommended deployment cadence — the granular kill (and back off afterward) is the recommended deployment cadence — the
switch is a lightweight runtime toggle, not a config-DB redeploy. 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** | | **`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** | | **`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)** | | **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
## `cnc_rdalmhistry` mock behaviour ## `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 the test helper can assert "no writes since fixture reset" without
exception handling. 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 ### Status
focas-mock simulator has not landed yet (tracked separately from F4-b). focas-mock simulator has not landed yet (tracked separately from F4-b /
F4-b lands the .NET-side wire encoders + dispatch + status mapping F4-c). F4-b + F4-c land the .NET-side wire encoders + dispatch + status
unconditionally; the integration-test scaffolds at mapping unconditionally; the integration-test scaffolds at
`tests/.../IntegrationTests/Series/ParameterWriteTests.cs` and `tests/.../IntegrationTests/Series/ParameterWriteTests.cs`,
`MacroWriteTests.cs` are deferred until the simulator + integration-test `MacroWriteTests.cs`, `PmcRangeWriteTests.cs`, and
project land. Until then unit-test coverage in `PmcBitRmwIntegrationTests.cs` are deferred until the simulator +
`FocasWriteParameterTests` / `FocasWriteMacroTests` exercises every integration-test project land. Until then unit-test coverage in
same-process invariant against the in-memory `FakeFocasClient`. `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)** | | **`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)** | | **`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)** | | `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`) ## 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` `FocasWriteParameterTests.ParameterWrite_round_trip_stores_value_visible_to_subsequent_read`
exercises encode → store → decode with the fake wire client and is the exercises encode → store → decode with the fake wire client and is the
canary for symmetry regressions. 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.

View File

@@ -47,6 +47,15 @@
writes are the lowest-risk write surface (no parameter-write switch writes are the lowest-risk write surface (no parameter-write switch
needed, no MDI mode required) so this stage runs whenever -Write is needed, no MDI mode required) so this stage runs whenever -Write is
supplied. supplied.
.PARAMETER PmcBitAddress
PMC bit address for the F4-c bit-write round-trip stage (default
R100.3). Only fires when -Write is supplied AND the operator
double-opts in via FOCAS_PMC_WRITE=1, mirroring the FOCAS_PARAM_WRITE
gate. PMC writes have a higher blast radius than PARAM/MACRO (a
mistargeted bit can move motion or latch a feedhold) so the gate is
off by default — see docs/v2/focas-deployment.md "Write safety / PMC
pre-checks".
#> #>
param( param(
@@ -57,7 +66,8 @@ param(
[Parameter(Mandatory)] [string]$BridgeNodeId, [Parameter(Mandatory)] [string]$BridgeNodeId,
[switch]$Write, [switch]$Write,
[string]$ParamAddress = "PARAM:1815", [string]$ParamAddress = "PARAM:1815",
[string]$MacroAddress = "MACRO:500" [string]$MacroAddress = "MACRO:500",
[string]$PmcBitAddress = "R100.3"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -146,6 +156,27 @@ if ($Write) {
} else { } else {
Write-Host "[skip] FOCAS_PARAM_WRITE not set — parameter-write stage requires the CNC to be in MDI mode + parameter-write switch enabled (see docs/v2/focas-deployment.md 'Write safety')." Write-Host "[skip] FOCAS_PARAM_WRITE not set — parameter-write stage requires the CNC to be in MDI mode + parameter-write switch enabled (see docs/v2/focas-deployment.md 'Write safety')."
} }
# F4-c — PMC bit round-trip. PMC writes have a higher blast radius
# than PARAM/MACRO (a mistargeted bit can move motion or latch a
# feedhold) so the stage is gated on a separate FOCAS_PMC_WRITE=1
# opt-in. The bit write exercises the driver's read-modify-write
# path: write 'on' -> read returns 'on'; write 'off' -> read returns
# 'off'. Both halves run so a regression in either branch is caught.
if ($env:FOCAS_PMC_WRITE -eq "1" -or $env:FOCAS_PMC_WRITE -eq "true") {
$results += Test-DriverLoopback `
-Cli $focasCli `
-WriteArgs (@("write") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit", "-v", "on")) `
-ReadArgs (@("read") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit")) `
-ExpectedValue "True"
$results += Test-DriverLoopback `
-Cli $focasCli `
-WriteArgs (@("write") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit", "-v", "off")) `
-ReadArgs (@("read") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit")) `
-ExpectedValue "False"
} else {
Write-Host "[skip] FOCAS_PMC_WRITE not set — PMC bit-write round-trip is off by default because a mistargeted PMC bit can move motion or latch a feedhold (see docs/v2/focas-deployment.md 'PMC pre-checks')."
}
} }
Write-Summary -Title "FOCAS e2e" -Results $results Write-Summary -Title "FOCAS e2e" -Results $results

View File

@@ -540,6 +540,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable); results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue; continue;
} }
// PR F4-c (issue #270) — granular gate for PMC writes. PMC is ladder
// working memory; a mistargeted bit can move motion or latch a feedhold
// so the operator team must explicitly opt in via Writes.AllowPmc on
// top of Writes.Enabled + per-tag Writable. Defaults to false so a
// deployment that flips the master switch on without touching the PMC
// gate still gets BadNotWritable for every PMC tag. ACL note: PMC tags
// surface SecurityClassification.Operate (server-layer requires
// WriteOperate) — see ClassifyTag.
if (parsed.Kind == FocasAreaKind.Pmc && !_options.Writes.AllowPmc)
{
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount) if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount)
@@ -553,18 +566,47 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
device.LastSetPath = parsed.PathId; device.LastSetPath = parsed.PathId;
} }
// Dispatch through the typed entry points for PARAM/MACRO so the // Dispatch through the typed entry points for PARAM/MACRO/PMC so the
// wire-client surface mirrors the per-kind opt-in shape; PMC and other // wire-client surface mirrors the per-kind opt-in shape. PMC bit
// kinds fall back to the generic WriteAsync path. // writes route through the WritePmcBitAsync RMW helper so the wire
var status = parsed.Kind switch // client only ever sees byte-aligned pmc_wrpmcrng calls (PR F4-c,
// issue #270). The fallback generic WriteAsync path is preserved for
// kinds that don't have a typed entry point yet, plus the unit-test
// FakeFocasClient that overrides WriteAsync directly.
uint status;
if (parsed.Kind == FocasAreaKind.Parameter)
{ {
FocasAreaKind.Parameter => await client.WriteParameterAsync( status = await client.WriteParameterAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false), parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
FocasAreaKind.Macro => await client.WriteMacroAsync( }
parsed, w.Value, cancellationToken).ConfigureAwait(false), else if (parsed.Kind == FocasAreaKind.Macro)
_ => await client.WriteAsync( {
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false), status = await client.WriteMacroAsync(
}; parsed, w.Value, cancellationToken).ConfigureAwait(false);
}
else if (parsed.Kind == FocasAreaKind.Pmc
&& def.DataType == FocasDataType.Bit
&& parsed.BitIndex is int bit
&& parsed.PmcLetter is string letter)
{
status = await client.WritePmcBitAsync(
letter, parsed.PathId, parsed.Number, bit,
Convert.ToBoolean(w.Value), cancellationToken).ConfigureAwait(false);
}
else if (parsed.Kind == FocasAreaKind.Pmc
&& def.DataType == FocasDataType.Byte
&& parsed.PmcLetter is string byteLetter)
{
var b = unchecked((byte)Convert.ToSByte(w.Value));
status = await client.WritePmcRangeAsync(
byteLetter, parsed.PathId, parsed.Number, new[] { b },
cancellationToken).ConfigureAwait(false);
}
else
{
status = await client.WriteAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
}
results[i] = new WriteResult(status); results[i] = new WriteResult(status);
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }

View File

@@ -98,6 +98,11 @@ public static class FocasDriverFactoryExtensions
// just { Enabled: true } keeps PARAM/MACRO writes locked. // just { Enabled: true } keeps PARAM/MACRO writes locked.
AllowParameter = dto.Writes?.AllowParameter ?? false, AllowParameter = dto.Writes?.AllowParameter ?? false,
AllowMacro = dto.Writes?.AllowMacro ?? false, AllowMacro = dto.Writes?.AllowMacro ?? false,
// Plan PR F4-c (issue #270) — granular kill-switch for pmc_wrpmcrng.
// Default false: PMC is ladder working memory; a mistargeted bit can
// move motion or latch a feedhold so the operator team must explicitly
// opt in even with Enabled=true.
AllowPmc = dto.Writes?.AllowPmc ?? false,
}, },
}; };
@@ -204,6 +209,12 @@ public static class FocasDriverFactoryExtensions
/// <see cref="FocasWritesOptions.AllowMacro"/>. /// <see cref="FocasWritesOptions.AllowMacro"/>.
/// </summary> /// </summary>
public bool? AllowMacro { get; init; } public bool? AllowMacro { get; init; }
/// <summary>
/// Plan PR F4-c (issue #270). Default false — see
/// <see cref="FocasWritesOptions.AllowPmc"/>.
/// </summary>
public bool? AllowPmc { get; init; }
} }
internal sealed class FocasDeviceDto internal sealed class FocasDeviceDto

View File

@@ -83,6 +83,20 @@ public sealed record FocasWritesOptions
/// gate requires <c>WriteOperate</c> group membership.</para> /// gate requires <c>WriteOperate</c> group membership.</para>
/// </summary> /// </summary>
public bool AllowMacro { get; init; } = false; public bool AllowMacro { get; init; } = false;
/// <summary>
/// Issue #270, plan PR F4-c — granular kill-switch for <c>pmc_wrpmcrng</c> PMC
/// range writes (and the bit-level read-modify-write that wraps it). Default
/// <c>false</c>: PMC is ladder working memory — a mistargeted bit can move
/// motion, latch a feedhold, or flip a safety interlock. Even with
/// <see cref="Enabled"/> on and a tag's <see cref="FocasTagDefinition.Writable"/>
/// flag flipped on, PMC writes stay locked until this third opt-in fires.
/// <para>Server-layer ACL: PMC tags surface
/// <see cref="Core.Abstractions.SecurityClassification.Operate"/> so the OPC UA
/// gate requires <c>WriteOperate</c> group membership; this flag is the driver-
/// level kill switch the operator team can flip without a redeploy.</para>
/// </summary>
public bool AllowPmc { get; init; } = false;
} }
/// <summary> /// <summary>

View File

@@ -123,8 +123,10 @@ internal sealed class FwlibFocasClient : IFocasClient
} }
/// <summary> /// <summary>
/// Read-modify-write one bit within a PMC byte. Acquires a per-byte semaphore so /// Read-modify-write one bit within a PMC byte (Plan PR F4-c, issue #270).
/// concurrent bit writes against the same byte serialise and neither loses its update. /// Acquires a per-byte semaphore so concurrent bit writes against the same
/// byte serialise and neither loses its update. The wire call is byte-addressed
/// so we read the parent byte, mask the target bit, then write the byte back.
/// </summary> /// </summary>
private async Task<uint> WritePmcBitAsync( private async Task<uint> WritePmcBitAsync(
FocasAddress address, bool newValue, CancellationToken cancellationToken) FocasAddress address, bool newValue, CancellationToken cancellationToken)
@@ -151,19 +153,8 @@ internal sealed class FwlibFocasClient : IFocasClient
? (byte)(current | (1 << bit)) ? (byte)(current | (1 << bit))
: (byte)(current & ~(1 << bit)); : (byte)(current & ~(1 << bit));
// Write the updated byte. // Write the updated byte via pmc_wrpmcrng (1-byte range).
var writeBuf = new FwlibNative.IODBPMC return WritePmcRange(addrType, address.Number, new[] { updated });
{
TypeA = addrType,
TypeD = FocasPmcDataType.Byte,
DatanoS = (ushort)address.Number,
DatanoE = (ushort)address.Number,
Data = new byte[40],
};
writeBuf.Data[0] = updated;
var writeRet = FwlibNative.PmcWrPmcRng(_handle, 8 + 1, ref writeBuf);
return writeRet == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(writeRet);
} }
finally finally
{ {
@@ -171,6 +162,52 @@ internal sealed class FwlibFocasClient : IFocasClient
} }
} }
/// <summary>
/// Plan PR F4-c (issue #270) — typed PMC-range write entry point. Writes a
/// contiguous run of bytes via <c>pmc_wrpmcrng</c>. The FWLIB <c>IODBPMC.Data</c>
/// payload caps at ~40 bytes so larger ranges are chunked into 32-byte
/// sub-calls, mirroring the read-side <see cref="ReadPmcRangeAsync"/> shape.
/// </summary>
public Task<uint> WritePmcRangeAsync(
string letter, int pathId, int startByte, byte[] bytes, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError);
cancellationToken.ThrowIfCancellationRequested();
if (bytes is null || bytes.Length == 0) return Task.FromResult(FocasStatusMapper.Good);
var addrType = FocasPmcAddrType.FromLetter(letter)
?? throw new InvalidOperationException($"Unknown PMC letter '{letter}'.");
return Task.FromResult(WritePmcRange(addrType, startByte, bytes));
}
/// <summary>
/// Synchronous PMC range write helper — chunked at 32 bytes so each
/// <c>pmc_wrpmcrng</c> call fits inside the FWLIB <c>IODBPMC.Data</c> 40-byte
/// window (8-byte header + 32-byte payload). Stops on the first non-zero
/// EW_* return so a partial write doesn't claim Good.
/// </summary>
private uint WritePmcRange(short addrType, int startByte, byte[] bytes)
{
const int chunkBytes = 32;
var offset = 0;
while (offset < bytes.Length)
{
var thisChunk = Math.Min(chunkBytes, bytes.Length - offset);
var writeBuf = new FwlibNative.IODBPMC
{
TypeA = addrType,
TypeD = FocasPmcDataType.Byte,
DatanoS = (ushort)(startByte + offset),
DatanoE = (ushort)(startByte + offset + thisChunk - 1),
Data = new byte[40],
};
Array.Copy(bytes, offset, writeBuf.Data, 0, thisChunk);
var ret = FwlibNative.PmcWrPmcRng(_handle, (ushort)(8 + thisChunk), ref writeBuf);
if (ret != 0) return FocasStatusMapper.MapFocasReturn(ret);
offset += thisChunk;
}
return FocasStatusMapper.Good;
}
public Task<int> GetPathCountAsync(CancellationToken cancellationToken) public Task<int> GetPathCountAsync(CancellationToken cancellationToken)
{ {
if (!_connected) return Task.FromResult(1); if (!_connected) return Task.FromResult(1);

View File

@@ -232,6 +232,49 @@ public interface IFocasClient : IDisposable
int depth, CancellationToken cancellationToken) int depth, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<FocasAlarmHistoryEntry>>(Array.Empty<FocasAlarmHistoryEntry>()); => Task.FromResult<IReadOnlyList<FocasAlarmHistoryEntry>>(Array.Empty<FocasAlarmHistoryEntry>());
/// <summary>
/// Write a contiguous range of PMC bytes in a single wire call (FOCAS
/// <c>pmc_wrpmcrng</c>) for the given <paramref name="letter"/> starting at
/// <paramref name="startByte"/>, copying every byte from <paramref name="bytes"/>.
/// Plan PR F4-c (issue #270). The wire call is byte-addressed; bit-level writes
/// are handled upstream by the <see cref="WritePmcBitAsync"/> read-modify-write
/// wrapper which performs <c>pmc_rdpmcrng</c> + bit mask + this method on a
/// per-byte semaphore (so two concurrent bit writes against the same byte don't
/// lose one another's update).
/// <para>Default impl returns <see cref="FocasStatusMapper.BadNotSupported"/> so
/// transport variants that haven't yet routed the write keep compiling — those
/// variants surface BadNotSupported on PMC writes until the wire client is
/// extended.</para>
/// </summary>
Task<uint> WritePmcRangeAsync(
string letter, int pathId, int startByte, byte[] bytes, CancellationToken cancellationToken)
=> Task.FromResult(FocasStatusMapper.BadNotSupported);
/// <summary>
/// Read-modify-write one bit within a PMC byte (Plan PR F4-c, issue #270). The
/// wire call <c>pmc_wrpmcrng</c> is byte-addressed, so the driver reads the
/// parent byte first, masks the target bit, then writes the byte back. Default
/// impl uses <see cref="ReadPmcRangeAsync"/> + <see cref="WritePmcRangeAsync"/>
/// so transport variants get correct RMW semantics for free; the FWLIB-backed
/// client overrides this with a per-byte semaphore so two concurrent bit writes
/// against the same byte serialise.
/// </summary>
async Task<uint> WritePmcBitAsync(
string letter, int pathId, int byteAddress, int bitIndex, bool newValue,
CancellationToken cancellationToken)
{
if (bitIndex is < 0 or > 7) return FocasStatusMapper.BadOutOfRange;
var (buf, status) = await ReadPmcRangeAsync(letter, pathId, byteAddress, 1, cancellationToken)
.ConfigureAwait(false);
if (status != FocasStatusMapper.Good || buf is null || buf.Length < 1) return status;
var current = buf[0];
var updated = newValue
? (byte)(current | (1 << bitIndex))
: (byte)(current & ~(1 << bitIndex));
return await WritePmcRangeAsync(letter, pathId, byteAddress, new[] { updated }, cancellationToken)
.ConfigureAwait(false);
}
/// <summary> /// <summary>
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS /// Read a contiguous range of PMC bytes in a single wire call (FOCAS
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/> /// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>

View File

@@ -91,6 +91,44 @@ internal class FakeFocasClient : IFocasClient
return Task.FromResult(status); return Task.FromResult(status);
} }
/// <summary>
/// Plan PR F4-c (issue #270) — typed PMC range-write entry point. Records
/// the call in <see cref="PmcRangeWriteLog"/> and applies the bytes to
/// <see cref="PmcByteRanges"/> at <c>(letter, pathId)</c> so a subsequent
/// <see cref="ReadPmcRangeAsync"/> sees the updated bytes (round-trip
/// shape). Status looked up by the canonical PMC address (e.g. <c>R100</c>)
/// of the first byte if seeded; otherwise Good.
/// </summary>
public List<(string Letter, int PathId, int StartByte, byte[] Bytes)> PmcRangeWriteLog { get; } = new();
public virtual Task<uint> WritePmcRangeAsync(
string letter, int pathId, int startByte, byte[] bytes, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
var copy = bytes.ToArray();
PmcRangeWriteLog.Add((letter, pathId, startByte, copy));
// Persist into PmcByteRanges so subsequent range reads see the write — this
// mirrors the simulator round-trip the integration tests check.
var key = (letter.ToUpperInvariant(), pathId);
if (!PmcByteRanges.TryGetValue(key, out var src))
{
src = new byte[startByte + copy.Length];
PmcByteRanges[key] = src;
}
else if (src.Length < startByte + copy.Length)
{
var grown = new byte[startByte + copy.Length];
Array.Copy(src, 0, grown, 0, src.Length);
src = grown;
PmcByteRanges[key] = src;
}
Array.Copy(copy, 0, src, startByte, copy.Length);
// Status seeded by canonical PMC address of the first byte (no bit index).
var canonical = $"{letter.ToUpperInvariant()}{startByte}";
var status = WriteStatuses.TryGetValue(canonical, out var sx) ? sx : FocasStatusMapper.Good;
return Task.FromResult(status);
}
public List<(int number, int axis, FocasDataType type)> DiagnosticReads { get; } = new(); public List<(int number, int axis, FocasDataType type)> DiagnosticReads { get; } = new();
public virtual Task<(object? value, uint status)> ReadDiagnosticAsync( public virtual Task<(object? value, uint status)> ReadDiagnosticAsync(

View File

@@ -9,43 +9,32 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
public sealed class FocasPmcBitRmwTests public sealed class FocasPmcBitRmwTests
{ {
/// <summary> /// <summary>
/// Fake client simulating PMC byte storage + exposing it as a sbyte so RMW callers can /// Fake client simulating PMC byte storage as a single 1024-byte buffer. Post-F4-c
/// observe the read-modify-write round-trip. ReadAsync for a Bit with bitIndex surfaces /// (issue #270) the FOCAS driver routes PMC writes through the typed
/// the current bit; WriteAsync stores the full byte the driver issues. /// <see cref="IFocasClient.WritePmcRangeAsync"/> + <see cref="IFocasClient.WritePmcBitAsync"/>
/// entry points — the bit path performs RMW via <c>ReadPmcRangeAsync</c> +
/// <c>WritePmcRangeAsync</c>, so this fake overrides those to drive a shared
/// <see cref="PmcBytes"/> buffer the tests can assert against. <see cref="PmcBytes"/>
/// is the unit-test surface; we mirror writes to <see cref="FakeFocasClient.PmcByteRanges"/>
/// too so any helper that reads from there sees the same source of truth.
/// </summary> /// </summary>
private sealed class PmcRmwFake : FakeFocasClient private sealed class PmcRmwFake : FakeFocasClient
{ {
public byte[] PmcBytes { get; } = new byte[1024]; public byte[] PmcBytes { get; } = new byte[1024];
public override Task<(object? value, uint status)> ReadAsync( public override Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
FocasAddress address, FocasDataType type, CancellationToken ct) string letter, int pathId, int startByte, int byteCount, CancellationToken ct)
{ {
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte) var buf = new byte[byteCount];
return Task.FromResult(((object?)(sbyte)PmcBytes[address.Number], FocasStatusMapper.Good)); Array.Copy(PmcBytes, startByte, buf, 0, byteCount);
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit) return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good));
return Task.FromResult(((object?)((PmcBytes[address.Number] & (1 << bit)) != 0), FocasStatusMapper.Good));
return base.ReadAsync(address, type, ct);
} }
public override Task<uint> WriteAsync( public override Task<uint> WritePmcRangeAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct) string letter, int pathId, int startByte, byte[] bytes, CancellationToken ct)
{ {
// Driver writes the full byte after RMW (type==Byte with full byte value), OR a raw Array.Copy(bytes, 0, PmcBytes, startByte, bytes.Length);
// bit write (type==Bit, bitIndex non-null) — depending on how the driver routes it. return Task.FromResult(FocasStatusMapper.Good);
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
{
PmcBytes[address.Number] = (byte)Convert.ToSByte(value);
return Task.FromResult(FocasStatusMapper.Good);
}
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
{
var current = PmcBytes[address.Number];
PmcBytes[address.Number] = Convert.ToBoolean(value)
? (byte)(current | (1 << bit))
: (byte)(current & ~(1 << bit));
return Task.FromResult(FocasStatusMapper.Good);
}
return base.WriteAsync(address, type, value, ct);
} }
} }
@@ -64,7 +53,7 @@ public sealed class FocasPmcBitRmwTests
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = writableTags, Tags = writableTags,
Probe = new FocasProbeOptions { Enabled = false }, Probe = new FocasProbeOptions { Enabled = false },
Writes = new FocasWritesOptions { Enabled = true }, Writes = new FocasWritesOptions { Enabled = true, AllowPmc = true },
}, "drv-1", factory); }, "drv-1", factory);
return (drv, fake); return (drv, fake);
} }

View File

@@ -17,9 +17,10 @@ public sealed class FocasReadWriteTests
Tags = tags, Tags = tags,
Probe = new FocasProbeOptions { Enabled = false }, Probe = new FocasProbeOptions { Enabled = false },
// F4-a flipped Writable + Writes.Enabled defaults to false for safer-by-default // F4-a flipped Writable + Writes.Enabled defaults to false for safer-by-default
// posture (issue #268). The legacy read-write test fixture opts both back on so // posture (issue #268). F4-c added AllowPmc on the same shape (issue #270). The
// existing assertions exercise the same wire path the original tests covered. // legacy read-write test fixture opts everything back on so existing assertions
Writes = new FocasWritesOptions { Enabled = true }, // exercise the same wire path the original tests covered.
Writes = new FocasWritesOptions { Enabled = true, AllowPmc = true },
}, "drv-1", factory); }, "drv-1", factory);
return (drv, factory); return (drv, factory);
} }
@@ -218,7 +219,7 @@ public sealed class FocasReadWriteTests
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false), new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
], ],
Probe = new FocasProbeOptions { Enabled = false }, Probe = new FocasProbeOptions { Enabled = false },
Writes = new FocasWritesOptions { Enabled = true }, Writes = new FocasWritesOptions { Enabled = true, AllowPmc = true },
}, "drv-1", factory); }, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);

View File

@@ -69,10 +69,11 @@ public sealed class FocasWriteInfrastructureTests
public async Task DriverLevel_Writes_enabled_per_tag_Writable_true_dispatches_to_wire_client() public async Task DriverLevel_Writes_enabled_per_tag_Writable_true_dispatches_to_wire_client()
{ {
// F4-a's wire dispatch surface is unchanged — when both flags are flipped, the call // F4-a's wire dispatch surface is unchanged — when both flags are flipped, the call
// reaches the (fake) wire client, which by default returns Good. F4-b will introduce // reaches the (fake) wire client, which by default returns Good. F4-b/F4-c add per-kind
// BadNotSupported branches for kinds the wire layer hasn't implemented yet. // gates (AllowParameter / AllowMacro / AllowPmc); PMC byte writes route through the
// typed WritePmcRangeAsync entry point post-F4-c so we assert on PmcRangeWriteLog.
var drv = NewDriver( var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true }, writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
tags: tags:
[ [
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true), new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
@@ -84,7 +85,7 @@ public sealed class FocasWriteInfrastructureTests
[new WriteRequest("X", (sbyte)1)], CancellationToken.None); [new WriteRequest("X", (sbyte)1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
factory.Clients[0].WriteLog.Count.ShouldBe(1); factory.Clients[0].PmcRangeWriteLog.Count.ShouldBe(1);
} }
[Fact] [Fact]

View File

@@ -170,17 +170,20 @@ public sealed class FocasWriteMacroTests
} }
[Fact] [Fact]
public async Task Per_kind_gate_does_not_affect_PMC_writes() public async Task Per_kind_gate_does_not_cross_contaminate_PMC_writes()
{ {
// Defense in depth: AllowParameter / AllowMacro stay locked but PMC writes // Defense in depth: AllowParameter / AllowMacro stay locked but PMC writes
// (which already worked in F4-a) keep flowing through Writes.Enabled + // (gated by F4-c's AllowPmc) keep flowing when the operator opted in to PMC
// per-tag Writable. This guards against regressing the F4-a surface. // alone. Pre-F4-c this test asserted PMC needed no per-kind gate; post-F4-c
// it asserts AllowPmc is the gate that matters for PMC, independent of the
// PARAM/MACRO gates.
var drv = NewDriver( var drv = NewDriver(
writes: new FocasWritesOptions writes: new FocasWritesOptions
{ {
Enabled = true, Enabled = true,
AllowParameter = false, AllowParameter = false,
AllowMacro = false, AllowMacro = false,
AllowPmc = true,
}, },
tags: tags:
[ [
@@ -193,8 +196,8 @@ public sealed class FocasWriteMacroTests
[new WriteRequest("R100", (sbyte)1)], CancellationToken.None); [new WriteRequest("R100", (sbyte)1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
// PMC routes through the generic WriteAsync, not the typed entry points. // PMC routes through the typed WritePmcRangeAsync entry point post-F4-c.
factory.Clients[0].WriteLog.Count.ShouldBe(1); factory.Clients[0].PmcRangeWriteLog.Count.ShouldBe(1);
factory.Clients[0].ParameterWriteLog.ShouldBeEmpty(); factory.Clients[0].ParameterWriteLog.ShouldBeEmpty();
factory.Clients[0].MacroWriteLog.ShouldBeEmpty(); factory.Clients[0].MacroWriteLog.ShouldBeEmpty();
} }

View File

@@ -0,0 +1,292 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Issue #270, plan PR F4-c — <c>pmc_wrpmcrng</c> coverage. The driver-level
/// <c>Writes.AllowPmc</c> kill switch sits on top of the F4-a
/// <c>Writes.Enabled</c> + per-tag <c>Writable</c> opt-ins. PMC bit writes
/// additionally exercise the read-modify-write helper (<c>pmc_wrpmcrng</c> is
/// byte-addressed; the wire never sees a sub-byte write). PMC tags surface
/// <see cref="SecurityClassification.Operate"/> for the server-layer ACL gate.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasWritePmcTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver(
FocasWritesOptions writes,
FocasTagDefinition[] tags,
out FakeFocasClientFactory factory)
{
factory = new FakeFocasClientFactory();
return new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = tags,
Probe = new FocasProbeOptions { Enabled = false },
Writes = writes,
}, "drv-1", factory);
}
/// <summary>
/// Variant that pre-seeds a fake's PMC byte storage (so an RMW test can verify
/// the read-side picked up the prior byte before the bit mask). The customiser
/// fires once per device — sufficient for the single-device tests below.
/// </summary>
private static FocasDriver NewDriverWithSeededPmc(
FocasWritesOptions writes,
FocasTagDefinition[] tags,
string letter,
int pathId,
byte[] seed,
out FakeFocasClientFactory factory)
{
factory = new FakeFocasClientFactory
{
Customise = () =>
{
var c = new FakeFocasClient();
c.PmcByteRanges[(letter.ToUpperInvariant(), pathId)] = (byte[])seed.Clone();
return c;
},
};
return new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = tags,
Probe = new FocasProbeOptions { Enabled = false },
Writes = writes,
}, "drv-1", factory);
}
[Fact]
public async Task AllowPmc_false_returns_BadNotWritable_even_with_Enabled_and_Writable()
{
// F4-c — the granular kill switch defaults off so a redeployed driver with
// Writes.Enabled=true still keeps PMC writes locked until the operator team
// explicitly opts in. PMC is ladder working memory; defense-in-depth is
// critical because a mistargeted bit can move motion or latch a feedhold.
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true, AllowPmc = false },
tags:
[
new FocasTagDefinition("Coil", Host, "R100", FocasDataType.Byte, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Coil", (sbyte)42)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
// Wire client was never even constructed because the gate short-circuited
// before EnsureConnectedAsync — defense in depth + lower blast radius.
factory.Clients.ShouldBeEmpty();
}
[Fact]
public async Task AllowPmc_true_byte_write_dispatches_to_typed_WritePmcRangeAsync()
{
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
tags:
[
new FocasTagDefinition("Coil", Host, "R100", FocasDataType.Byte, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Coil", (sbyte)42)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
var log = factory.Clients[0].PmcRangeWriteLog;
log.Count.ShouldBe(1);
log[0].Letter.ShouldBe("R");
log[0].StartByte.ShouldBe(100);
log[0].Bytes.ShouldBe(new byte[] { 42 });
// Generic WriteAsync path is untouched — PMC byte goes through the typed entry point.
factory.Clients[0].WriteLog.ShouldBeEmpty();
}
[Fact]
public async Task PMC_bit_write_set_RMW_preserves_zero_byte_writes_only_target_bit()
{
// Prior byte = 0b0000_0000; set bit 3 → write byte = 0b0000_1000.
var drv = NewDriverWithSeededPmc(
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
tags: [new FocasTagDefinition("G50_3", Host, "G50.3", FocasDataType.Bit, Writable: true)],
letter: "G", pathId: 1,
seed: PmcBuffer(byteAddr: 50, value: 0b0000_0000),
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("G50_3", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
var log = factory.Clients[0].PmcRangeWriteLog.Single();
log.Letter.ShouldBe("G");
log.StartByte.ShouldBe(50);
log.Bytes.ShouldBe(new byte[] { 0b0000_1000 });
}
[Fact]
public async Task PMC_bit_write_set_preserves_other_bits_already_set()
{
// Prior byte = 0b1111_0000; set bit 0 → write byte = 0b1111_0001.
var drv = NewDriverWithSeededPmc(
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
tags: [new FocasTagDefinition("R50_0", Host, "R50.0", FocasDataType.Bit, Writable: true)],
letter: "R", pathId: 1,
seed: PmcBuffer(byteAddr: 50, value: 0b1111_0000),
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("R50_0", true)], CancellationToken.None);
var log = factory.Clients[0].PmcRangeWriteLog.Single();
log.Bytes.ShouldBe(new byte[] { 0b1111_0001 });
}
[Fact]
public async Task PMC_bit_write_clear_preserves_other_bits()
{
// Prior byte = 0b1111_1111; clear bit 0 → write byte = 0b1111_1110.
var drv = NewDriverWithSeededPmc(
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
tags: [new FocasTagDefinition("R50_0", Host, "R50.0", FocasDataType.Bit, Writable: true)],
letter: "R", pathId: 1,
seed: PmcBuffer(byteAddr: 50, value: 0xFF),
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("R50_0", false)], CancellationToken.None);
var log = factory.Clients[0].PmcRangeWriteLog.Single();
log.Bytes.ShouldBe(new byte[] { 0b1111_1110 });
}
[Fact]
public async Task Multiple_consecutive_bit_writes_in_same_byte_serialise()
{
// Each bit write does its own RMW (Read range -> mask -> Write range). Eight
// consecutive bit-set writes on R100 starting from 0 must compose to 0xFF.
var tags = Enumerable.Range(0, 8)
.Select(b => new FocasTagDefinition($"Bit{b}", Host, $"R100.{b}", FocasDataType.Bit, Writable: true))
.ToArray();
var drv = NewDriverWithSeededPmc(
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
tags: tags,
letter: "R", pathId: 1,
seed: PmcBuffer(byteAddr: 100, value: 0),
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
for (var b = 0; b < 8; b++)
await drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None);
var fake = factory.Clients[0];
// 8 RMW round-trips, each writing the cumulative byte back.
fake.PmcRangeWriteLog.Count.ShouldBe(8);
fake.PmcByteRanges[("R", 1)][100].ShouldBe((byte)0xFF);
// Every write hit the same byte address 100.
fake.PmcRangeWriteLog.ShouldAllBe(e => e.StartByte == 100 && e.Bytes.Length == 1);
}
[Fact]
public void Tag_classification_PMC_writable_yields_Operate()
{
// Server-layer ACL key — PMC tags require WriteOperate group membership
// (mirrors MACRO; PARAM is the higher-friction Configure tier).
var tag = new FocasTagDefinition(
"Coil", Host, "R100.3", FocasDataType.Bit, Writable: true);
FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.Operate);
}
[Fact]
public void Tag_classification_PMC_non_writable_yields_ViewOnly()
{
var tag = new FocasTagDefinition(
"Coil", Host, "R100.3", FocasDataType.Bit, Writable: false);
FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public void FocasWritesOptions_default_AllowPmc_is_false()
{
// Defense in depth: a fresh FocasWritesOptions has every granular kill
// switch off. A config row that omits AllowPmc must NOT silently flip PMC
// writes on.
new FocasWritesOptions().AllowPmc.ShouldBeFalse();
new FocasDriverOptions().Writes.AllowPmc.ShouldBeFalse();
}
[Fact]
public void Dto_round_trip_preserves_AllowPmc()
{
// JSON config -> FocasDriverOptions; the Writes.AllowPmc flag must survive
// the bootstrapper's deserialize step. Sentinel: a write at a configured
// PMC tag should NOT short-circuit to BadNotWritable when AllowPmc=true is
// set in the JSON (the unimplemented backend will surface BadCommunicationError
// instead).
const string jsonAllowed = """
{
"Backend": "unimplemented",
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
"Tags": [{
"Name": "P", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "R100", "DataType": "Byte", "Writable": true
}],
"Writes": { "Enabled": true, "AllowPmc": true }
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", jsonAllowed);
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
var results = drv.WriteAsync(
[new WriteRequest("P", (sbyte)1)], CancellationToken.None).GetAwaiter().GetResult();
// Key assertion: NOT BadNotWritable — that proves the AllowPmc gate didn't short-circuit.
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNotWritable);
}
[Fact]
public void Dto_default_omitted_AllowPmc_keeps_safer_default()
{
// A Writes section with just { Enabled: true } must NOT silently flip the
// granular kill switch on. PMC writes should still get BadNotWritable.
const string json = """
{
"Backend": "unimplemented",
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
"Tags": [{
"Name": "P", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "R100", "DataType": "Byte", "Writable": true
}],
"Writes": { "Enabled": true }
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
var results = drv.WriteAsync(
[new WriteRequest("P", (sbyte)1)], CancellationToken.None).GetAwaiter().GetResult();
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
}
private static byte[] PmcBuffer(int byteAddr, byte value)
{
// Allocate enough buffer to hold the byteAddr index, fill the chosen byte.
var buf = new byte[byteAddr + 1];
buf[byteAddr] = value;
return buf;
}
}