Files
lmxopcua/docs/v2/implementation/focas-simulator-plan.md
2026-04-26 05:45:13 -04:00

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.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 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 — 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. 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_PASSWDBadUserAccessDenied 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:

  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.