@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user