# 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** | | **`0x0105`** | **`cnc_wrunlockparam`** | **flips the per-profile `unlock_state` to true when the supplied 4-byte password buffer matches the profile's `unlock_password`; otherwise returns `EW_PASSWD`. State persists for the connection lifetime (per-session). — issue #271, plan PR F4-d** | | **`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`. - `unlock_password: bytes` (4-byte buffer) — defaults to the profile's fixture default (e.g. `b"1234"` for Series30i). Compared byte-for-byte by the `cnc_wrunlockparam` handler; flips `unlock_state = True` on match, leaves it untouched on mismatch (and returns `EW_PASSWD`). Mutable via `POST /admin/mock_set_password` for tests that exercise rotation. Issue #271, plan PR F4-d. - `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 } ``` ### `cnc_wrunlockparam` request decode — issue #271, plan PR F4-d ``` [byte[4] password] ``` Match `password == profile.unlock_password` byte-for-byte. On match: flip `unlock_state = True`, return `[int16 LE 0]`. On mismatch: leave `unlock_state` untouched, return `[int16 LE 11]` (`EW_PASSWD`). The simulator deliberately keeps unlock state per-session (per OpenSession handle) so a reconnect drops back to `unlock_state = False` — matching the FWLIB lifetime semantics described in [`focas-wire-protocol.md`](./focas-wire-protocol.md) § "cnc_wrunlockparam". ### Admin endpoint — `POST /admin/mock_set_password` Rotates the per-profile `unlock_password` for tests that exercise the F4-d password-rotation runbook (`docs/v2/focas-deployment.md` § "FOCAS password handling"). Idempotent — call again to revert. ``` POST /admin/mock_set_password { "profile": "Series30i", "password": "5678" } ``` The endpoint accepts the password as a UTF-8/ASCII string and applies the same right-pad-to-4-bytes / truncate-to-4-bytes normalisation the driver does, so simulator-side matching is byte-symmetric with the production wire encoder. ### 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. ## Cycle-time per part / last cycle delta — F5-a (issue #272) Plan PR F5-a derives `Production/LastCycleSeconds` + `Production/LastCycleStartUtc` from the existing `cnc_rdparam(6711)` + `cnc_rdtimer` snapshot stream — **pure derivation, no new wire calls**. The simulator does NOT need new wire commands; the existing `cnc_rdparam` + `cnc_rdtimer` handlers already cover the read surface. What focas-mock DOES need is an admin endpoint + test-fixture helper that lets integration tests atomically increment the parts-count counter alongside the cycle-time timer so the driver sees a clean "cycle completed" transition on the next probe tick. ### Per-profile state Already covered by the existing F1-b state map: - `parameters: Dict[int, int]` (entry `6711` is the parts-count counter). - `timers: Dict[int, int]` (entry `0` is the live cycle-time counter, in seconds). ### Admin endpoint — `POST /admin/mock_simulate_cycle_completion` Atomically advances both values to model "the CNC just finished a cycle". Atomicity matters: the F5-a derivation samples both fields on every probe tick, so if the simulator updated parts-count and the timer in two separate writes the test could observe an intermediate state where parts-count incremented but the timer hasn't updated yet (producing a misleading `LastCycleSeconds`). ``` POST /admin/mock_simulate_cycle_completion { "profile": "Series30i", "partsDelta": 1, // default 1; tests asserting backfill use 3+ "newCycleTimerSeconds": 18 // absolute value, NOT a delta } ``` Handler steps: 1. `parameters[6711] += partsDelta` (under the per-profile lock). 2. `timers[0] = newCycleTimerSeconds`. 3. Return `200 OK` with the new values for verification. The endpoint MUST hold the profile's update lock for the full read-modify-write so a concurrent `cnc_rdparam` + `cnc_rdtimer` poll sees both fields in their pre-update OR post-update state — never half-applied. ### `FocasSimFixture.SimulateCycleCompletionAsync` The future test-support helper wraps the admin endpoint: ```csharp await fixture.SimulateCycleCompletionAsync( profile: "Series30i", partsDelta: 1, newCycleTimerSeconds: 18); ``` Integration test `Series/CycleDeltaTests.cs` will assert: - After a 5 -> 6 transition with `newCycleTimerSeconds=18`, the driver's `Production/LastCycleSeconds` settles to `currentTimer - prevTimer`. - `Production/LastCycleStartUtc` is within driver-tolerance of `nowUtc - LastCycleSeconds` (allow a small window for probe-tick jitter). - Counter reset (parts -> 0) preserves the last published values. - Cycle-timer rollover does not publish a negative delta. These tests are blocked on the focas-mock + integration-test project landing; the unit-test coverage in `FocasCycleDeltaTests` already exercises every same-process invariant of the derivation. ### 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`.