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

@@ -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.