12 KiB
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.mdverbatim. - 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 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:
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— defaultsFalse. WhenFalse, everycnc_wrparamreturnsEW_PASSWD(numeric11) regardless of parameter. Macro writes are NOT gated byunlock_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 thecnc_wrunlockparamhandler; flipsunlock_state = Trueon match, leaves it untouched on mismatch (and returnsEW_PASSWD). Mutable viaPOST /admin/mock_set_passwordfor 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. 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 § "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:
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:
- Look up the per-profile bytearray for
addr_type(allocate on first write, default 256 zeros). - Validate
0 <= datano_s <= datano_e < len(bytearray)— otherwise returnEW_NUMBER(4). - Validate
len(data) == datano_e - datano_s + 1— otherwise returnEW_LENGTH(14). - Validate
data_type == 0— otherwise returnEW_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). - Copy
data[]intobytearray[datano_s:datano_e+1]. Other bytes in the array are untouched. - Update
last_writeadmin-endpoint state (kind=pmc, address-type, start byte, length, bytes). - 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.