# FOCAS simulator (focas-mock) plan Notes on the focas-mock simulator that the FOCAS driver's integration tests will eventually talk to. Today there is no FOCAS integration-test project; this doc is the contract the future fixture will be built against. Keeping the contract tracked in repo means the wire-protocol command ids (and their request/response payloads) don't drift between the .NET wire client and a future Python implementation. ## Ground rules - Append-only command ids. Mirror [`focas-wire-protocol.md`](./focas-wire-protocol.md) verbatim. - Per-profile state. The simulator hosts N CNC profiles concurrently (`Series0i`, `Series30i`, `PowerMotion`, ...). Each profile has its own alarm-history ring buffer + its own override map. - Admin endpoints under `POST /admin/...` mutate state without going through the wire protocol; integration tests use these to seed canned inputs. ## Protocol surface (current scope) | Cmd | API | State impact | | --- | --- | --- | | `0x0001` | `cnc_rdcncstat` | reads cached ODBST per profile | | `0x0002` | `cnc_rdparam` | reads parameter map per profile | | `0x0003` | `cnc_rdmacro` | reads macro variables per profile | | `0x0004` | `cnc_rddiag` | reads diagnostic map per profile | | `0x0010` | `pmc_rdpmcrng` | reads PMC byte ranges | | `0x0020` | `cnc_modal` | reads cached modal MSTB per profile | | ... | ... | ... | | **`0x0102`** | **`cnc_wrparam`** | **mutates per-profile parameter map; returns `EW_PASSWD` (`11`) when the profile's `unlock_state` is off (sets up F4-d's unlock workflow) — issue #269, plan PR F4-b** | | **`0x0103`** | **`cnc_wrmacro`** | **mutates per-profile macro map; integer-only writes for now (decimalPointCount=0) — issue #269, plan PR F4-b** | | **`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 The simulator keeps a per-profile ring buffer of alarm-history entries. Default fixture seeds 5 profiles with 10 canned entries each (per the F3-a plan). ### Request decode ``` [int16 LE depth] ``` ### Response encode Use `FocasAlarmHistoryDecoder.Encode` semantics in reverse: emit the count followed by `ALMHIS_data` blocks padded to 4-byte boundaries. The .NET-side decoder consumes the same format verbatim, so a Python encoder written against the table in [`focas-wire-protocol.md`](./focas-wire-protocol.md) interoperates without extra glue. ### Admin endpoint — `POST /admin/mock_patch_alarmhistory` Replaces the alarm-history ring buffer for a profile. ``` POST /admin/mock_patch_alarmhistory { "profile": "Series30i", "entries": [ { "occurrenceTime": "2025-04-01T09:30:00Z", "axisNo": 1, "alarmType": 2, "alarmNumber": 100, "message": "Spindle overload" }, ... ] } ``` `entries` order is interpreted as ring-buffer order (most-recent first to match FANUC's natural surface). ### `FocasSimFixture.SeedAlarmHistoryAsync` The future test-support helper wraps the admin endpoint: ```csharp await fixture.SeedAlarmHistoryAsync( profile: "Series30i", entries: new [] { new FocasAlarmHistoryEntry( new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero), AxisNo: 1, AlarmType: 2, AlarmNumber: 100, Message: "Spindle overload"), }); ``` Integration test `Series/AlarmHistoryProjectionTests.cs` will assert: - historic events fire once with the seeded timestamps - second poll yields zero new events (dedup honoured end-to-end) - active-alarm raise/clear still works alongside the history poll These tests are blocked on the focas-mock + integration-test project landing; the unit-test coverage in `FocasAlarmProjectionTests` already exercises every same-process invariant. ## `cnc_wrparam` / `cnc_wrmacro` mock behaviour — issue #269, plan PR F4-b When the focas-mock fixture lands, it MUST implement the contract below. The .NET side already ships against this contract (`FwlibFocasClient.cs` write helpers, `FakeFocasClient` round-trip support); writing the simulator to the same shape lets the existing integration-test scaffolds at `tests/.../IntegrationTests/Series/ParameterWriteTests.cs` and `MacroWriteTests.cs` (when they materialise) light up without driver changes. ### Per-profile state Each profile owns: - `parameters: Dict[int, int]` — map from parameter number to current value. - `macros: Dict[int, int]` — map from macro number to current scaled-int value (decimal-point count fixed at 0 for F4-b). - `unlock_state: bool` — defaults `False`. When `False`, every `cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of parameter. Macro writes are NOT gated by `unlock_state`. - `last_write: Optional[LastWrite]` — most-recent successful `(kind, number, value, ts)` tuple, surfaced via the admin endpoint below for audit-log assertions. ### `cnc_wrparam` request decode ``` [int16 LE datano][int16 LE axis][int8|int16|int32 LE value] ``` Width of the value field is determined by the request frame trailer length per the table in [`focas-wire-protocol.md`](./focas-wire-protocol.md). On `unlock_state == False` short-circuit to `[int16 LE 11]` (`EW_PASSWD`). Otherwise mutate `parameters[datano] = value`, set `last_write`, return `[int16 LE 0]`. ### `cnc_wrmacro` request decode ``` [int16 LE number][int16 LE length=8][int32 LE mcr_val][int16 LE dec_val] ``` Always accept (no `unlock_state` gate). Mutate `macros[number] = mcr_val` (we ignore `dec_val` for F4-b — integer-only). Return `[int16 LE 0]`. Round-trip: a subsequent `cnc_rdmacro(number)` returns `(mcr_val, 0)`. ### Admin endpoint — `POST /admin/mock_set_unlock_state` Toggles `unlock_state` for the F4-d unlock workflow tests. Without this, F4-b parameter-write integration tests can't reproduce the `EW_PASSWD` → `BadUserAccessDenied` mapping. ``` POST /admin/mock_set_unlock_state { "profile": "Series30i", "unlocked": true } ``` ### Admin endpoint — `GET /admin/mock_get_last_write` Returns the simulator's view of the most-recent successful write, used by F4-b audit-log integration assertions ("did the write actually reach the fixture, and is the audit log capturing the right kind/number/value?"). ``` GET /admin/mock_get_last_write?profile=Series30i -> { "kind": "param", // "param" | "macro" "number": 1815, "value": 100, "writtenAt": "2026-04-25T13:30:00Z" } ``` When no write has happened the endpoint returns `null` rather than 404 so the test helper can assert "no writes since fixture reset" without exception handling. ## `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-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`.