Commit Graph

131 Commits

Author SHA1 Message Date
raphael d4d1a411d7 secsi: T3 / T4 enforcement moved into the FSM
The SECS-I Protocol FSM now enforces T3 (reply timeout) and T4
(inter-block timeout) directly, instead of leaving them as
upper-layer hooks.

T3: on complete_send, if the block we just acked had W=1, record its
system_bytes in awaiting_reply_sys_ and emit ActionStartTimer{T3}.
deliver_recv cancels T3 when a block arrives whose system_bytes
match the outstanding request.  EventTimeout{T3} aborts the FSM with
"T3 reply timeout".

T4: deliver_recv emits ActionStartTimer{T4} whenever the delivered
block has end_block=false.  The next block's deliver_recv cancels
the timer; EventTimeout{T4} aborts with "T4 inter-block timeout".

abort() now also cancels T3/T4 and clears the tracking state.

Test changes:
  - Old "T3/T4 are FSM-level no-ops" test → REPLACED by four new
    tests: T3 arm+expire, T3 arm+matching-reply cancels, T4
    arm+expire, T4 arm+next-block cancels.
  - Two new observer accessors on Protocol (awaiting_reply,
    awaiting_next_block) so the tests can assert tracking state
    without poking internals.

COMPLIANCE.md §1a: T3 + T4 rows go .

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 11:52:43 +02:00
raphael 77197b9c1e e84: per-port FSM via E84PortStore
E84 (Parallel I/O) is fundamentally per-load-port: each port has its
own ten-wire handshake with the AMHS.  Earlier revisions modeled it
as a single equipment-wide FSM; this commit refactors to a per-port
store, so multi-LP tools can run independent handshakes in parallel.

Public API change in EquipmentDataModel:
  E84StateMachine e84;   -> removed
  E84PortStore    e84_ports;  // create(port_id), get(port_id), ...

Convenience pass-throughs: E84PortStore::on_signal_change auto-creates
the port on first use (ergonomic for demos); applications should call
create() explicitly with their full port set.

The two existing callsites (test_gem300_scenario, test_e87_wire_scenarios)
are updated.  The multi-LP test now demonstrates the actual win:
interleaved LP1 load + LP2 unload handshakes that reach their
respective Ready states without sequencing, and an ES on LP1 that
does NOT affect LP2 — exactly the failure mode the previous design
couldn't catch.

Five new dedicated tests in test_e84_ports.cpp for the store itself.

COMPLIANCE.md §4i updated: row now reflects per-port design.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 11:50:18 +02:00
raphael 2f0a4ba339 e30: S10F7 broadcast terminal display
Adds the last terminal-services message: a multi-line broadcast push
to all terminals, no reply.  Same TID+lines body as S10F5, W=0.

Generated via the catalog: data/messages.yaml schema entry +
auto-generated s10f7_terminal_display_broadcast / parse_s10f7.

Test round-trips TID and a 3-line broadcast through the builder
and parser, confirms W=0.

COMPLIANCE.md updated: S10F7 row in §5 added; §8 "out of scope"
entry removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 11:47:02 +02:00
raphael d0c7fb71b6 hsms: HSMS-GS multi-session support (E37 §11)
Connection now supports both HSMS-SS (single session — the
constructor's behaviour, unchanged) and HSMS-GS (multi-session).
add_session(device_id) registers additional sessions; each one has
its own NotSelected/Selected state and its own message/selected
handlers.  In GS mode the Select.req carries session_id=device_id;
in SS mode it stays at 0xFFFF (legacy).  Linktest/Separate remain
connection-scope per spec.

Public API additions:
  add_session(device_id)
  set_session_message_handler(device_id, h)
  set_session_selected_handler(device_id, h)
  session_state(device_id) -> State
  is_session_selected(device_id) -> bool
  send_request(device_id, msg, cb)
  send_data(device_id, msg)

Internal refactor: state_/on_message_/on_selected_ folded into a
SessionSlot map keyed by device_id; SS-style getters/setters route
through the primary session.  T7 + linktest are connection-scope —
T7 fires only when no session is selected; linktest runs while at
least one is.

Five wire-level tests:
  - passive: two sessions selected independently via Select.req
    with their own session_id
  - GS Select.req for an unregistered session id is Rejected
    (EntityNotSelected)
  - data routed by session_id; data on a not-selected session is
    Rejected
  - active: two registered sessions both end up selected via
    serialized Select.req per session
  - SS legacy: existing single-session API still works (session_id
    0xFFFF in Select.req)

COMPLIANCE.md §1 updated: HSMS-GS row goes .

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 11:40:38 +02:00
raphael 998e81b3d8 persistence: substrate history journaling in v2 record
Per-substrate transition history now survives restart.  Each entry's
steady_clock timestamp is written as a system_clock-millis snapshot;
on replay the steady_clock time_point is reconstructed relative to
the current (steady_now, system_now) pair, so inter-event spacing
is preserved across restarts even if the FSM is in a different
process.  Absolute wall-clock accuracy degrades by any NTP step
that happened between write and read; that's a documented caveat.

Record format goes v1 → v2.  v1 (history-less) records still load,
just with empty history.

Test updates:
  - the old "history is NOT journaled" test is REPLACED with one
    that asserts every axis + event + label round-trips.
  - hand-crafted v1 record on disk still loads (proves backwards
    compat).
  - 15 ms-spaced events restore with their spacing intact (±slop
    for scheduler jitter).

Closes the "substrate history persistence" caveat from the post-#1-13
status writeup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 11:34:54 +02:00
raphael d9f23d6db8 persistence: PJ rcpvars + prprocessparams in v2 record format
Closes the v1 caveat: the optional E40-0705 trailers on S16F11 —
recipe variables (RcpVar) and process parameters (ProcessParam),
each carrying a secs2::Item value of arbitrary type — now survive
restart.

Record format bumps to v2:
  v2 header = v1 header
  + [u16 rcpvar_count][repeat: u16 name_len, name, u32 enc_len,
                       secs2::encode(value)]
  + [u16 ppparam_count][...same shape]

v1 records are still accepted by load_record_ (no extras come back).

Two new tests:
  - round-trip mixed F4 / ASCII / U4 / nested-list values through
    rcpvars + prprocessparams
  - hand-crafted v1 record on disk still loads cleanly, just with
    empty extras (proves backwards compat)

Closes the "PJ rcpvars / prprocessparams persistence" caveat from
the post-#1-13 status writeup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 11:31:58 +02:00
raphael f206df763e docs: customer integration tutorial (INTEGRATION.md)
End-to-end guide for an equipment vendor integrating the library
into a real semiconductor tool:

  1. Architecture: what the runtime provides vs what the application
     contributes — three boundary classes (EquipmentDataModel,
     Router, hsms::Connection).
  2. 30-minute first connection: YAML + minimal main() + run.
  3. Wiring real sensors to SVIDs.
  4. Plugging the FSMs into the tool: EPT, carriers, substrates,
     E40 PJ / E94 CJ, alarms, recoverable exceptions.
  5. Persistence: enable_persistence(dir) per store, storage budget,
     replay semantics, current caveats.
  6. Monitoring + observability: connection lifecycle hooks,
     state-change handlers, S9 protocol errors.
  7. Recommended deployment layout (/opt/acme-secsgem/...).
  8. Integration testing checklist.
  9. When to extend the runtime.
 10. The honest gap between "this stack runs" and "this is a
     certified GEM tool".

Cross-referenced from COMPLIANCE.md §9 distinction (stack vs tool).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:58:42 +02:00
raphael 7213ddfbf1 tests: HSMS connection concurrency / interleaved transactions
Real GEM sessions don't serialize requests — the host can have many
primaries outstanding, replies may arrive in any order, and both
peers can talk at once.  Connection demuxes via system_bytes per
E37 §8.3; this commit pins the behaviour with four wire tests:

  - 5 in-flight requests; equipment buffers all primaries before
    replying — proves Connection holds the pending map correctly
    even when no replies are coming.
  - 7 pipelined primaries with synchronous in-handler replies;
    every host callback fires with the correct function and stream.
  - Bidirectional in-flight: host issues 3 primaries while equipment
    issues 3 of its own; all 6 callbacks resolve with the right
    replies.
  - 100-burst sequential cycle; confirms the pending_requests_ map
    doesn't leak entries (every reply delivered ⇒ map drained).

Closes #13 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:56:00 +02:00
raphael 158ebed5c8 tests: identifier-width wildcard matrix
SEMI E5 allows identifier fields (DATAID, RPTID, VID, CEID, ALID,
EXID, OBJID, …) to be encoded as U1, U2, U4, or U8.  Our parsers
route through any_unsigned_first<T> in messages_helpers.hpp.  The
existing per-message round-trip tests prove the U4 path; this
commit adds the cross-width matrix that the interop incident with
secsgem-py demanded:

  - as_u4_scalar accepts U1/U2/U4/U8 inputs for the same value
  - as_u8_scalar accepts every narrower width
  - as_u1_scalar accepts wider widths when the value fits
  - as_u1_scalar / as_u2_scalar REJECT out-of-range values rather
    than silently truncating
  - codec round-trip preserves the format byte AND the value
  - signed counterparts (as_i4_scalar) follow the same rule for I1/I2

If a future code-gen change hard-codes a single width on any
identifier field, the rejection case here breaks loudly.

Closes #12 in the test-gap backlog (renumbered: this is gap entry
"identifier wildcard matrix").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:54:45 +02:00
raphael ef3a07b2d5 tests: E87 slot-map mismatch + multi-LP wire scenarios
Four new test cases:

  * S3F19 verify with matching map → SlotMapVerifyAck::Accept and
    CSMS lands in Read on the equipment side.
  * S3F19 verify with disagreeing map → Mismatch ack and CSMS lands
    in Mismatched.
  * 4 LPs + 4 carriers, host verifies CAR-1 (mismatch) and CAR-3
    (match) — only those two carriers move on the CSMS axis;
    CAR-2/CAR-4 stay NotRead.  Confirms per-carrier independence.
  * Multi-LP E84 handshake sequencing (load then unload) round-trips
    through Idle.  Documents that the current E84StateMachine is
    per-equipment, not per-port — a future per-port FSM would
    update this test alongside.

Closes #11 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:53:40 +02:00
raphael cd22b51377 tests: live-HSMS GEM 300 lifecycle scenario via emulator pair
test_gem300_scenario.cpp drives EquipmentDataModel in-memory.  This
companion test does the same lifecycle through actual hsms::Connection
frames on a loopback socket pair:

  S1F13/F14   establish comm
  S3F17/F18   carrier action ProceedWithCarrier (E87)
  S16F11/F12  process job create (E40)
  S14F9/F10   control job create (E94)
  S16F27/F28  CJSTART → CJ → Executing
  S6F11       ControlJobExecuting CEID auto-emitted on transition
  CJ → Completed via internal AllJobsComplete

EquipmentEmulator owns the data model + a passive Connection,
registers state-change handlers that synthesize S6F11/S16F9 on
transitions, and dispatches the inbound primaries above.  HostEmulator
wraps the active Connection and captures everything the equipment
sends unsolicited.

This is the wire-level equivalent of the existing in-memory scenario,
which closes the gap between "FSM works" and "full GEM 300 stack
works on a wire".

Closes #10 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:51:47 +02:00
raphael 72da1dc77f tests: CEID/S6F11 + PRJobAlert S16F9 on-the-wire emission
FSM unit tests already verified state transitions fire the change
handler — but they don't prove the frame leaves the socket with the
right CEID and linked report payload. This commit wires a passive
equipment Connection to an EquipmentDataModel via a small emitter,
drives transitions, and asserts on what the host peer receives.

Six new tests:
  EPT → Productive  ⇒ S6F11(kCeidProductive) with the linked report
  EPT (no subscription) ⇒ no S6F11 (proves disable gate)
  PJ Queued→SettingUp  ⇒ S16F9 PRJobAlert with PRJOBID + state byte
  PJ alert_enabled=false  ⇒ no S16F9 (per-PJ gate works)
  CJ → Executing  ⇒ S6F11(ControlJobExecuting) on the wire
  Substrate StartProcessing  ⇒ S6F11(SubstrateInProcess) on the wire

All use the generated parse_s6f11 / parse_s16f9 to decode the
incoming frame and assert against typed fields (CEID, PRJOBID, etc.)
rather than poking variant internals — that ties the test to the
schema-as-data rather than to wire byte offsets.

Closes #9 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:49:28 +02:00
raphael c527caccc5 tests: structured fuzz suite for secs2 / hsms / secsi decoders
Deterministic-seed fuzz coverage of the byte-decoding surface:

  - secs2::decode on 2000 random buffers
  - secs2::decode on every truncation of a real encoding + 500
    one-byte flips of the full encoding
  - hsms::Frame::decode on 1000 random payloads
  - hsms::Header::decode on 2000 random 10-byte buffers
  - secsi::Block::decode on 2000 random buffers
  - secs2 encode/decode round-trip identity across a battery of every
    Item factory (List, ASCII, Binary, Boolean, U1..U8, I1..I8, F4/F8,
    nested List)
  - oversize <A 3 length-bytes> length-prefix doesn't allocate GBs
  - 64-level nested List round-trip doesn't blow the stack

Contract is binary: no crash, no UB. Each decoder is allowed to throw
or return whatever; we deliberately don't assert *what* result comes
back, only that control returns. Fixed PRNG seeds make any failure
reproducible from the CI log alone.

Closes #8 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:44:42 +02:00
raphael 31677d9d91 tests: SECS-I T1 / T2-recv timer firings; T3/T4 no-op assertions
test_secsi.cpp covered T2 on the send side (retry) and a tick-based
back-to-back exchange.  This commit fills in the rest of the timer
matrix at FSM level:

  T1 in RecvBlock → abort, reason mentions "T1"
  T1 outside RecvBlock → ignored
  T2 in RecvEotSent → abort
  T2 in RecvBlock → abort (mid-block stall)
  T3 / T4 → FSM-level no-op (documented as upper-layer driven)
  T2 contrast → send-side retries, recv-side aborts (same timer,
                different recovery, both demonstrated in one test)

If a future commit moves T3 or T4 enforcement into the FSM, the
no-op test breaks loudly so protocol.hpp can be updated alongside.

Closes #7 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:42:46 +02:00
raphael 82f9794655 tests: S9F7 wire emission for malformed primaries
S9F3/F5 are covered by test_s9_fallback (router path); S9F9/F11 by
test_hsms_timers (timer/over-length). This commit adds S9F7 wire-level
tests for the third path — a primary whose body fails secs2::decode.

Three new cases:
  - hand-built primary with truncated <B> body provokes S9F7
    carrying the original 10-byte MHEAD (sys + stream + function)
  - emission is non-fatal: the next well-formed primary still routes
    to the registered handler
  - data-while-NOT-SELECTED still echoes Reject(EntityNotSelected)
    (sanity copy of the test_hsms_connection case so the "what does
    the equipment say when a peer sends garbage" family lives together)

Closes #6 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:41:45 +02:00
raphael 2d73abcd27 tests: HSMS T3/T6/T7/T8 wire-level enforcement
Real-socket tests for the timer family in E37 §10 — these replace
the "the timer fires somewhere" implicit assumption with
end-to-end observations on a loopback pair:

  T3: send_request that gets no reply emits S9F9 with the original
      MHEAD echoed in the body and surfaces Timeout to the caller.
  T6: active mode whose Select.req goes unanswered self-closes
      with a "T6 timeout on Select" reason.
  T7: passive mode that never receives Select.req self-closes
      with a "T7 not-selected timeout" reason.
  T8: peer sends only the 4-byte length prefix; T8 expires mid-read
      and closes with "T8 intercharacter timeout".

Plus S9F11 emission for an over-length frame (length prefix of
1 GiB+1) — body's <B 10> echoes the offending bytes verbatim.

Per-test timer profiles (only the timer under test is short, the
rest are 5s) so the FSM isn't racing against unrelated timers.

Closes #5 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:40:28 +02:00
raphael b3bde7f087 persistence: ExceptionStore enable_persistence(dir)
Per-EXID binary record (.ex), magic + version + atomic .tmp+rename.
Records full E5 §9 lifecycle: state, EXID, EXTYPE, EXMESSAGE, and
the candidate EXRECVRA list.

Cleared exceptions are terminal — the FSM transitions through
Cleared remove the in-memory entry AND delete the journal file
(matching the existing in-memory semantics).  Recovering /
RecoverFailed states survive restart: the application can decide
on replay whether to retry recovery or abort.

Five new tests cover post+replay, Recovering-survives-restart,
autonomous-clear cleanup, RecoverFailed retry post-restart, and
corrupt-record drop.

This completes #12 in the test-gap backlog (persistence for the four
in-memory stores beyond Spool).

Closes #4 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:37:36 +02:00
raphael 1189ffc994 persistence: ProcessJobStore + ControlJobStore enable_persistence(dir)
Per-job binary record (.pj / .cj) with magic+version, atomic
.tmp+rename. PJ store additionally writes an order.idx index file
that preserves HOQ-aware queue position across restarts.

Rcpvars / prprocessparams (secs2::Item variants) are intentionally
out of scope for v1 — they're optional E40 trailers and need a body
codec round-trip; callers re-populate via set_e40_extras() after
restart.

Five new tests cover full lifecycle replay (Processing mid-run +
HOQ-reordered queue), dequeue-deletes-file, corrupt-record drop,
CJ state + PJ-list replay, and CJ remove cleanup.

Closes #3 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:35:26 +02:00
raphael 1548b49afd persistence: SubstrateStore enable_persistence(dir)
Same pattern as carriers: per-substrate binary record (.sub) with
atomic .tmp+rename, replay on enable, delete on remove. Records
current state across all three E90 axes (location / processing /
ID-status), plus substid / carrierid / slot / free-form location
label. History is deliberately NOT journaled — it's an in-memory
ring buffer and rebuilding from replayed state would mislead.

Five new tests cover full-axis replay, every terminal processing
state, remove-deletes-journal, corrupt-record drop, and the
history-is-transient invariant.

Closes #2 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:31:54 +02:00
raphael f56639ba17 persistence: CarrierStore + LoadPortStore enable_persistence(dir)
Mirrors SpoolStore: per-record file with atomic .tmp+rename, magic+
version-prefixed binary layout, replay on enable, delete on remove.
FSMs gain a restore_state() that bypasses the transition table and
handlers since a replay isn't a transition.

Six new tests cover write+restart+replay across every CIDS/CSMS/CAS
axis, remove-deletes-journal, malformed-record drop-not-poison, and
the persistence-disabled no-op path.

Closes #1 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:25:50 +02:00
raphael 29f646c7ca HostHandler: senders for the AA tranche messages
tests / build-and-test (push) Failing after 34s
A host couldn't drive the new messages through the HostHandler class —
only the server side knew how to dispatch them.  Adds six new senders
plus a unit test that walks each through a real loopback connection:

  * send_legacy_remote_command  -> S2F21
  * send_event_report_request   -> S6F15
  * send_individual_report_request -> S6F19
  * send_annotated_report_request  -> S6F21
  * send_pp_load_inquire        -> S7F1
  * send_delete_pp              -> S7F17

Suite: 296 cases / 1571 assertions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:20:23 +02:00
raphael 7c28e2589c interop: extend host_vs_cpp_server.py for the AA tranche messages
Adds round-trip checks for the SECS-II messages added in the AA
catalog-growth commit but never cross-validated against secsgem-py:

  * S2F21/F22 — legacy remote command (no params).  secsgem-py's
    stock S2F21 sends with W=0; we register a W=1 override so the
    transaction awaits our S2F22 reply.  Also widens CMDA's allowed
    types to include Binary (secsgem-py 0.3.0 declares CMDA as
    Dynamic[U1, I1] only; SEMI E5 §10.18 says Binary, and our server
    emits it that way).
  * S6F15/F16 — event-report request by CEID.
  * S6F19/F20 — individual report request by RPTID.
  * S6F21/F22 — annotated individual report request.
  * S7F1/F2  — PP load inquire.
  * S7F17/F18 — PP delete.

Suite is now 32 named host-vs-server checks — all green in three
consecutive runs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:20:14 +02:00
raphael a1dc7937d4 test: live persistent-spool restart end-to-end
Adds a docker-compose service `server-spool` that runs secs_server
with --spool-dir pointed at a named volume.  Two-phase Python
harness (interop/spool_persistence_test.py):

  1. Enqueue phase: force-spool one S6F11(CEID=300) via the
     SPOOL_ON / START / SPOOL_OFF RCMD trio, then disconnect.
  2. Driver runs `docker compose restart server-spool` between
     the phases — the named volume preserves the journal files.
  3. Drain phase: reconnect, send S6F23(Transmit), verify the
     replayed S6F11 carries CEID 300.

Surfaces a real interop bug along the way: secsgem-py 0.3.0 encodes
RSDC (and other "single-byte status" fields) as <U1>, while SEMI E5
spells them as <B>.  Our `as_binary_first` was strict on Binary; now
accepts either (the byte semantics are identical, and the leniency is
symmetric with the U-type widening from the first interop commit).

Result: enqueue → docker restart → drain returns CEID 300 cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:04:49 +02:00
raphael df6060a9e9 test: round-trip S6F8 + S12F14/F16/F18 reply shapes
Closes the test gap for messages I added but whose reply parsers were
only generated, never exercised:

  * S6F8 — full nested DATAID/CEID/DS/DV structure.
  * S12F14 — row-format map reply (RSINF tuples).
  * S12F16 — array-format map reply.
  * S12F18 — coordinate-format map reply.

Suite: 295 cases / 1545 assertions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:04:36 +02:00
raphael 64faac73bb DD2: raw GEM 300 interop harness
Cross-validates the GEM 300 streams secsgem-py 0.3.0 doesn't ship
(S3 carriers, S14 control jobs, S16 process jobs) by minting custom
`SecsStreamFunction` subclasses on the fly and registering the
matching `DataItem` definitions (CARRIERID, CTLJOBID, PRJOBID, PRCMD,
CTLJOBCMD, MF, …) with `secsgem.secs.data_items`.

Drives the C++ passive server through:
  * S3F17/F18 (E87 carrier action) — server replies CarrierIDUnknown
    for the unregistered carrier.
  * S16F5/F6  (E40 PRJobCommand)   — server returns InvalidObject
    for the nonexistent PJ.
  * S16F27/F28 (E94 CJobCommand)   — server cascades CJSTART.

Scope cut: S16F11 full-body and S14F9 (both have variable-length
nested lists with named scalar elements) hit a quirk of secsgem-py's
SFDL tokenizer where `< L name > <SCALAR> >` parses as a fixed-1
list, not a variable-length list of SCALARs.  The full-body S16F11
is already round-tripped by the C++ unit tests (and via secsgem-py's
host driver in `host_vs_cpp_server.py`), so the raw harness focuses
on the no-variable-list messages where the SFDL grammar cooperates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:55:25 +02:00
raphael 0bbc7b7acf DD1: refresh COMPLIANCE.md for full GEM 300 + 291 tests
The doc was last updated when only E40 + E94 were in tree.  Brings it
up to date with everything actually implemented:

  * New §4a–§4k tables for each GEM 300 standard: E40, E94, E87, E90,
    E116, E120, E148, E157, E84, E5 §13 wafer maps, and the exception
    recovery extension (S5F13–F18 + ExceptionStateMachine).
  * Refreshed message coverage matrix to all 149 catalog entries
    (added S1F23/F24, S2F21/F22, S6F5–F8/F15–F22, S7F1/F2/F17/F18,
    S10F3/F4, S12F9–F18).
  * Updated test count to 291 cases / 1515 assertions.
  * New §7 documents the secsgem-py interop harness (24 host-side
    checks + raw-GEM300 round-trip).
  * §8 trimmed: persistent spool is no longer "out of scope" (CC1
    landed); E40/E87/E90 removed from "Layer 5 follow-on" list since
    they're done.
  * §9 honesty pass — every GEM 300 standard in scope now implemented
    end-to-end; the remaining gap is third-party RTS certification +
    per-vendor application wiring.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:54:46 +02:00
raphael d69f26b415 CC1: persistent file-backed spool
Adds opt-in disk persistence to SpoolStore.  `enable_persistence(dir)`
turns every enqueue into a single `<seq>.spool` file alongside the
in-memory queue; drain and clear delete the matching files; restart
replays the directory sorted by seq.

Writes are atomic: serialize the message via the SECS-II codec, write
to `.tmp`, then `std::filesystem::rename` to the final name.  Malformed
records are dropped silently so a single bad file can't poison the
whole spool.

`secs_server --spool-dir <path>` enables persistence at startup.
Without the flag the behaviour is identical to before (in-memory only).

Two new tests: enqueue → restart → replay → drain restores the wire
order, and clear deletes the journal files.

Test suite: 291 cases / 1515 assertions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:51:46 +02:00
raphael cfa2d1e531 BB1: full E40 S16F11 body — MF, PRRECIPEMETHOD, RCPVARS, PRPROCESSPARAMS
Replaces the simplified <L,3 PRJOBID PPID MTRLOUTSPEC> demo body with
the full SEMI E40-0705 §10.2 shape:

  <L,5 PRJOBID MF PRRECIPEMETHOD
       <L,2 PPID <L,n <L,2 RCPPARNM RCPPARVAL>>>
       <L,n MTRLOUTSPEC>
       <L,n <L,2 PARAMNAME PARAMVAL>>>

ProcessJob now carries the extra fields (MaterialFlag, ProcessRecipeMethod,
RcpVar[], ProcessParam[]) so a tool's recipe engine can later consume
the recipe-variable overrides and per-job process parameters.  Server
S16F11 dispatch populates them via the new ProcessJobStore::set_e40_extras
helper after a successful create.

MaterialFlag + ProcessRecipeMethod enums live in their own tiny header
(`e40_constants.hpp`) so process_jobs.hpp (the store) can use them
without dragging in messages_helpers.hpp (which would create a circular
include via data_model.hpp).

The simplified 3-arg HostHandler::send_create_process_job convenience
remains; it constructs a sensible-default PRJobCreateRequest internally.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:44:05 +02:00
raphael 4197cdfb25 AA: catalog growth — S7F1/F17, S6F5-F22, S2F21, S12F9-F18
Adds the SECS-II messages secsgem-py 0.3.0 ships but our C++ catalog
didn't have, plus the alternative wafer-map formats from E5 §13.
None of these were strictly required for GEM core compliance, but
they're the messages a host might send to a conformant equipment.

  * S7F1/F2 — Process Program Load Inquire / Grant.  Equipment-side
    space-and-policy check before a host commits to S7F3.
  * S7F17/F18 — Delete Process Program.  Empty list = delete-all.
  * S6F5/F6 — Multi-block Data Send Inquire / Grant (with MultiBlockGrant
    enum: Ok/Busy/NoSpace/DuplicateMsg/BadMsg).
  * S6F7/F8 — Data Transfer Request / Send.  Host pulls a DATAID;
    equipment replies with the nested DS/DV structure.
  * S6F15/F16 — Event Report Request (host-initiated).  Reply mirrors
    the unsolicited S6F11.
  * S6F19/F20 — Individual Report Request (RPTID -> values).
  * S6F21/F22 — Annotated Individual Report Request (RPTID -> (VID, value)).
  * S2F21/F22 — Legacy Remote Command (no parameter list).  Delegates
    to the same HostCommandRegistry as S2F41.
  * S12F9/F10 — Map Data Send (array format, MAPFT=1).
  * S12F11/F12 — Map Data Send (coordinate format, MAPFT=2).
  * S12F13/F14, F15/F16, F17/F18 — Map Data Request variants for the
    row, array, and coordinate formats.

11 new round-trip tests; suite at 289 cases / 1495 assertions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:30:51 +02:00
raphael 2d60571a9c interop: secsgem-py cross-validation harness + lenient identifier parsing
Adds a Docker-based interop harness that drives the C++ server with
secsgem-py 0.3.0 as the active host and probes a secsgem-py-passive
equipment from a minimal C++ active client.  Surfaces and fixes four
interoperability bugs uncovered by cross-testing:

  * SEMI E5 identifier formatcodes are a U1|U2|U4|U8 wildcard;
    secsgem-py picks the narrowest fitting width while our parsers
    only accepted U4.  `as_uN_scalar` / `as_iN_scalar` now accept
    any unsigned/signed width and range-check the downcast.
  * PPBODY (S7F3/F6) is "ASCII | Binary | List" per the spec;
    secsgem-py defaults to ASCII.  Added BINARY_OR_ASCII codegen
    item type with `as_text_or_binary` accessor.
  * S1F23/F24 Collection Event Namelist was unimplemented; added
    schema + `vids_for(ceid)` accessor on EventReportSubscriptions
    plus the dispatch handler.
  * S10F1 was registered as a host->equipment handler, but per
    SEMI E5 §12 S10F1 is equipment->host; S10F3 is the actual
    host->equipment Terminal Display Single.  Added an S10F3
    handler alongside (we keep S10F1 too for backward compat).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:17:18 +02:00
raphael 9fbab92106 Q: GEM300 end-to-end conformance scenario harness
tests / build-and-test (push) Failing after 33s
A single integration test that drives every top-level FSM through
the coordinated lifecycle a real fab acceptance test exercises:

  1.  EPT NonScheduledTime -> Standby -> Productive (E116)
  2.  E84 load handshake at LP1 (CS_0 -> VALID -> L_REQ -> BUSY -> COMPT)
  3.  LoadPort transfer Loading -> InService; Carrier created + associated
  4.  CIDS NotConfirmed -> Confirmed via host ProceedWithCarrier (E87)
  5.  Slot map populated + CSMS NotRead -> Read
  6.  Two substrates created from carrier slots 1 and 2
  7.  Per-substrate IDS NotConfirmed -> WaitingForHost -> Confirmed
  8.  PJ + CJ created (E40 + E94, with PPID validator + PJ-membership)
  9.  CJ Queued -> Selected -> WaitingForStart -> Executing (E94)
  10. PJ Queued -> SettingUp -> WaitingForStart -> Processing (E40)
  11. Each substrate Acquire -> StartProcessing -> EndProcessing -> Release
  12. Module StartGeneral -> StartStep -> CompleteStep (E157)
  13. PJ ProcessComplete; CJ AllJobsComplete -> Completed
  14. Substrate locations AtDestination + processing Processed
  15. E84 unload handshake (CS_0 -> VALID -> U_REQ -> BUSY -> COMPT)
  16. LoadPort Unloading -> InService; disassociate
  17. EPT Productive -> Standby

Total: 278 test cases, 1436 assertions — all green.  Any regression
in a single FSM that breaks cross-FSM coordination surfaces here.

Closes the J-Q tranche set.  Repository now exercises the full
GEM300 stack from physical I/O (E84) through SECS-II messaging (E5),
the equipment model (E30/E120), job management (E40/E94), substrate
tracking (E90), carrier/port management (E87), performance tracking
(E116), and module process tracking (E157).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 09:19:04 +02:00
raphael 0e832d6ff7 P: E84 Parallel I/O handoff signaling
The biggest single gap I called out in the GEM300 audit — closed.
E84 is the digital handshake between AMHS (Automated Material
Handling System) and the equipment for carrier load/unload.  Unlike
the rest of GEM300, this isn't SECS messaging; it's a fixed set of
ten parallel boolean wires that follow a strict sequencing protocol
(E84-0710 §6.3).

Adds:
  E84Signal enum     CS_0/CS_1/VALID/TR_REQ/BUSY/COMPT/L_REQ/U_REQ/
                     READY/ES
  E84SignalSet       10-bit bitmap with bool get/set
  E84State           Idle / CarrierPresent / ValidAsserted /
                     LoadReady / UnloadReady / Transferring /
                     Complete / EmergencyStop
  E84StateMachine    re-evaluates state on every signal change,
                     observable via set_state_change_handler

Joins EquipmentDataModel as `e84` (top-level — there's one per tool,
not per port).  ES (emergency stop) dominates regardless of other
signals; COMPT and BUSY override the VALID-handshake states.  Same
FSM drives real opto-isolated I/O lines (when wired through an
asio digital input adapter) and the back-to-back test simulation.

Six test cases cover the full load handshake trace (six transitions,
including the transient LoadReady-after-BUSY-drops state), the
unload variant via U_REQ, ES dominance + recovery, reset(), and
no-op suppression for idempotent signal writes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 09:17:17 +02:00
raphael 564bd47132 O: E148 time-sync drift tracking + quality metric
Extends the existing Clock with the metrics a host needs to gate
time-sensitive data against the equipment's sync state (E148 §6.3):

  offset_seconds()      current applied offset vs system clock
  last_drift_seconds()  signed drift observed at the most recent sync
  sync_count()          how many successful syncs have happened
  sync_quality()        Synchronized (|drift|<=1s) /
                        Drifting (<=60s) / Unsynchronized (>60s or
                        never synced)

The thresholds are tuneable per call; the defaults match typical fab
practice but the application can pass tighter bounds for tracelog-
sensitive flows.  set_time_string() now snapshots the apparent delta
between the previously-applied offset and the new one as
last_drift_seconds_ at the moment of resync; no background timer.

Three new test cases cover the initial Unsynchronized state, a large
forward drift registering as Unsynchronized, and a same-value resync
landing as Synchronized.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 09:13:16 +02:00
raphael af2c60663e N: E39 generic ObjectService — S14F1/F2 GetAttr + S14F3/F4 SetAttr
The catalog had S14F9/F10 / F11/F12 specialized for E94 ControlJob;
this commit adds the generic E14 attribute access pair, the most-
queried half of the E39 surface area, backed by the CemObjectStore.

  S14F1 / F2  GetAttr  — OBJSPEC + OBJTYPE + ATTRID list ->
                         (ATTRID, VALUE) pairs + OBJACK
  S14F3 / F4  SetAttr  — same addressing, applies ATTRID/VALUE pairs,
                         reply echoes stored values + OBJACK

Server dispatches both into the CemObjectStore added in tranche G.
OBJTYPE validation is case-sensitive against the CemObjectType name
(Equipment / Subsystem / IODevice / Module / MaterialLocation).
Unknown objects return Denied_UnknownObject; type mismatches return
Denied_InvalidAttribute.

The shared AttrValue struct is declared external_struct: true on
F3/F4 so both directions share the same C++ type.

Two round-trip tests cover both pairs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 09:09:51 +02:00
raphael 3b45bade8f M: S16F7/F8 PRJobMonitor + S16F15/F16 PRJobCreateMultiple
Closes the two E40 bulk/control gaps the COMPLIANCE doc had flagged
as out-of-scope:

  S16F7 / F8   PRJobMonitor — host enables/disables S16F9 alerts
               per PJ.  PRALERT bit 7 is the enable flag (matches the
               ALED convention from S5F3).  Server dispatches into the
               existing set_alert() store API.

  S16F15 / F16 PRJobCreateMultiple — bulk create variant.  Host posts
               a list of (PRJOBID, PPID, MTRLOUTSPEC) entries; the
               equipment processes them in order and returns a
               per-PJ HCACK list so the host can identify which
               subset failed.  Same validators as S16F11.

Catalog now has three new structs: PRJobMonitorEntry,
PRJobCreateEntry, PRJobCreateMultiResult.  Two round-trip tests cover
the new wire shapes; server-side correctness is exercised through the
existing PJ store invariants (dedup, validator) which both new paths
delegate to.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 08:51:39 +02:00
raphael 5a3f5ca6da L: E87 slot-map verification wire (S3F19/F20 + F21/F22)
Closes the slot-map verification gap I called out:

  S3F19 / F20  host -> equip: verify expected slot map against what
               the equipment has scanned. Equipment compares element-
               wise; on match drives CSMS NotRead -> Read and replies
               SVACK=Accept; on mismatch drives CSMS -> Mismatched and
               replies SVACK=Mismatch.

  S3F21 / F22  equip -> host: equipment-initiated slot map report
               (typically pushed after CARRIERID is confirmed).

New SVACK enum: SlotMapVerifyAck { Accept, Mismatch, CarrierUnknown,
Error }.  Server dispatch on S3F19 wires the actual CSMS transition
through the CarrierStore from D3.

Two round-trip tests cover both pairs; the FSM-driving behaviour is
exercised through the in-process tests because secs_server.cpp is
the dispatch entry point (no separate integration test needed beyond
the wire round-trip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 08:48:56 +02:00
raphael a52d44ade5 K2: SubstrateIDStatus (third E90 axis)
Adds the substrate-ID verification FSM that E90 §6.4.6 calls for:

  NotConfirmed   initial; equipment hasn't read the ID yet
  WaitingForHost ID has been read; awaiting host accept/reject
  Confirmed      host confirmed (or force-bound)
  Mismatched     host rejected — recoverable via Bind

Events:
  Read     NotConfirmed -> WaitingForHost
  Confirm  WaitingForHost -> Confirmed
  Mismatch WaitingForHost -> Mismatched
  Bind     any -> Confirmed (force-bind)
  Reset    any -> NotConfirmed

Wire-byte values pinned via static_assert.  The third axis is now
exposed on SubstrateStateMachine alongside location_state() and
processing_state(); set_id_handler() observes transitions.  Existing
two-axis API is unchanged.

4 new test cases cover the happy path, Mismatch+Bind recovery, Reset
from any state, and same-state event handler suppression.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 08:46:10 +02:00
raphael a28a8b5982 K1: SubstrateHistory ring buffer per substrate
Each Substrate now retains an append-only history of state transitions
(both location and processing axes), the triggering event captured as
a std::variant<SubstrateEvent, SubstrateProcessingEvent>, the location
label at the time, and a steady_clock timestamp.

E90 §6.6 requires the equipment to be able to report a wafer's
processing history — typically queried via S6F11 batched reports or
SVID reads.  This commit lays the runtime substrate; wire query
plumbing is the natural follow-up.

set_history_limit(n) caps per-substrate retention (default 256, 0 =
unbounded).  Oldest entries are dropped when the cap is reached;
vector-erase is fine at this scale (typical wafer lifecycle is a few
dozen transitions).

Two new test cases cover the recording invariants (every fire results
in one history entry on the right axis) and history_limit eviction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 08:43:24 +02:00
raphael 06f664dfab J: E116 time-bucket accounting
EptStateMachine now retains per-state cumulative dwell time so the
host can read it as SVIDs.  The implementation is zero-overhead while
the FSM is idle (no timers, no background work) — on every transition
we add the prior state's dwell to its bucket and reset the entered_
timestamp.  Live dwell in the current state is included in
accumulated() via a now-vs-entered_ delta at read time.

New public API:
  accumulated(EptState)   per-state cumulative ms (incl. live dwell)
  total_elapsed()         denominator for utilization ratios
  reset_history()         S2F43-style history clear

This closes the gap I called out: previously we emitted CEIDs on
transition but didn't accumulate the bucket the host actually queries
for utilization metrics.  Wiring these into specific SVIDs is the
application's job (equipment.yaml declares SVIDs against any read
callable); the runtime data is now there.

4 new test cases cover accumulation, live-dwell inclusion, and reset.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 08:41:09 +02:00
raphael 28dac8e9c8 I2: Router::dispatch_with_s9 helper + end-to-end S9F3/F5 tests
The S9F3/F5 fallback was previously inlined in apps/secs_server.cpp;
this commit lifts it onto Router as a template helper and adds two
focused tests asserting the wire behaviour against a real back-to-
back HSMS Connection pair.

  template <typename EmitFn, typename HeaderProvider>
  std::optional<Message> dispatch_with_s9(emit, header, msg);

The helper does the has_handler / has_handler_for_stream check and
calls the supplied emit function with S9F3 (unknown stream) or S9F5
(unknown function in known stream).  The header_provider returns the
optional MHEAD bytes — keeping the helper free of any direct
Connection coupling.

Tests:
  - SUT registered only for S1F1; peer sends S1F5 -> SUT replies
    S9F5 to the peer.
  - SUT registered only for S1F1; peer sends S7F19 -> SUT replies
    S9F3 to the peer.

Closes Tranche I — SML parser and the auto-S9F* fallback closeout
both verified end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 03:58:03 +02:00
raphael e2348db082 I1: SML parser — inverse of to_sml()
Adds parse_sml(text) -> Item / try_parse_sml(text) -> optional<Item>
in secs2/sml.hpp.  Round-trips with the existing to_sml() emitter for
every Item shape the codec produces: lists with nesting, ASCII / JIS8,
Binary (decimal and 0xHH literals), Boolean (T/F or 1/0, scalar and
multi-element), U1-U8 / I1-I8 / F4 / F8 vectors, and the optional
`[n]` count syntax (accepted but not enforced).

The parser is whitespace-insensitive outside quoted strings and uses
a small Cursor type for read_word / read_quoted / skip_ws.  Numeric
literals go through strtoul/strtoll/strtod so SML can carry hex,
octal, and decimal interchangeably (the emitter writes hex for Binary
and decimal everywhere else).

11 test cases cover the full round-trip surface, the whitespace
invariant, unknown-tag rejection, the try_parse error-swallowing
variant, and the optional `[n]` count.

secsgem-py has secs/sml.py for the same purpose; this brings the C++
port to parity on the tooling side.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 03:56:13 +02:00
raphael 90fdf45df5 H2: E157 module CEIDs + server-side emission
Adds the canonical E157 collection-event identifiers in the 1570+ block:

  1570 ModuleProcessStateChange  (generic; fired on any transition)
  1571 ModuleNotExecuting
  1572 ModuleGeneralExecuting
  1573 ModuleStepExecuting
  1574 ModuleStepCompleted

Server installs a state-change handler that fires both the generic
CEID and the state-specific one for each transition.  Hosts that
prefer "wake me on any module change and I'll fan it out myself" can
subscribe to only the generic CEID; hosts that want narrower
notifications subscribe to specific states.

Closes Tranche H — E157 Module Process Tracking end-to-end (FSM +
Store + CEIDs + server emission).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 03:38:11 +02:00
raphael 82fac6fd17 H1: ModuleStateMachine + ModuleStore (E157 §6)
Per-module process-tracking state machine.  An E157 instance models a
single recipe step at a single module, with the canonical lifecycle:

  NotExecuting -> GeneralExecuting (StartGeneral)
                -> StepExecuting   (StartStep)
                -> StepCompleted   (CompleteStep)

Plus universal escape hatches: Reset returns any state to
NotExecuting; Abort terminates from any state to StepCompleted.

ModuleStore wraps the FSM with the now-standard pattern:
  - non-movable (this-capture lambdas)
  - per-module bind() carries current_substid + recipe_step
  - fire(module_id, event) delegates to the FSM
  - set_state_change_handler observes every transition with module_id

Joins EquipmentDataModel.  5 test cases cover happy path, Reset from
each interior state, Abort, store-level create dedup + bind, and the
multi-module change handler keying.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 03:37:20 +02:00
raphael d159bd39d7 G: CemObjectStore (E120 Common Equipment Model)
Hierarchical object tree for equipment self-description.  Each object
carries a CemObjectType (Equipment / Subsystem / IODevice / Module /
MaterialLocation / Other), an optional parent_objid, and a flat
attribute map keyed by name (the wire shape S14F1 / F3 returns).

Operations covered:
  add(CemObject)        - dedup'd, validates parent exists
  get / has             - lookup by objid
  get_attr / set_attr   - E14 GetAttr / SetAttr semantics
  children(parent)      - tree traversal; empty parent = roots

The flat-map representation matches how E14 ObjectService traffic
addresses nodes (by OBJSPEC string).  Wiring S14F1/F2 GetAttr and
S14F3/F4 SetAttr to this store is a downstream commit; the data model
is what was missing.

Joins EquipmentDataModel alongside the other top-level stores.  Three
test cases cover hierarchical add+dedup, children() traversal, and
get/set/missing attribute semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 03:35:05 +02:00
raphael cdf4049016 F2: EPT joined to EquipmentDataModel + server-side CEID emission
EquipmentDataModel now carries an EptStateMachine as a value member
alongside the other top-level state machines.  Server installs a
state-change handler that maps every EPT transition to a CEID emission
through the existing emit_event path:

  1100 NonScheduledTime    1103 Engineering
  1101 ScheduledDowntime   1104 Standby
  1102 UnscheduledDowntime 1105 Productive

CEIDs land in the 1100+ block to keep clear of the demo equipment.yaml
(100s/200s/400s) and E90 (900s).  Log lines include the dwell time of
the previous state so trace-level diagnostics show utilization without
extra tooling.

Application code drives transitions by calling model->ept.on_event(...);
the existing event-report machinery (subscription state, S6F11
batching, spool) gates wire emission so EPT events spool on offline
hosts like every other CEID.

Closes Tranche F — E116 Equipment Performance Tracking end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 03:16:56 +02:00
raphael 7bff01c363 F1: EptStateMachine (E116 Equipment Performance Tracking)
Adds the six E116-0712 §6.2 buckets for classifying equipment time:

  NonScheduledTime (0)   not scheduled to operate
  ScheduledDowntime (1)  planned maintenance window
  UnscheduledDowntime (2) faults / unplanned stoppage
  Engineering (3)        engineering / qualification time
  Standby (4)            idle but available
  Productive (5)         actively producing

Wire-byte values pinned via static_assert to E116 §10.3.

The FSM is a classifier rather than a strict lifecycle — every
(state, event) pair is legal — but it remains data-driven through the
shared CarrierTransitionTable template so the default cross-product is
expressible declaratively.

The state-change handler also surfaces dwell time (how long the
previous state was held) computed off std::chrono::steady_clock, so
accounting code can compute MTBF / availability / utilization from a
single source without maintaining a parallel timestamp log.

4 test cases cover the initial state, every event firing, dwell-time
reporting, and the no-op same-state event (no handler call).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 02:17:16 +02:00
raphael 777fa5e9f9 E3: Host-side E90 substrate event observer
Adds a typed substrate-event callback to HostHandler that decodes the
canonical E90 CEIDs from incoming S6F11 messages into the matching
SubstrateState / SubstrateProcessingState enum values.  Host
applications now get strongly-typed substrate observability without
having to maintain their own CEID-to-state lookup.

  using SubstrateEventHandler =
      std::function<void(uint32_t ceid, SubstrateState location,
                         SubstrateProcessingState processing)>;

  void set_substrate_event_handler(SubstrateEventHandler);

Axes not addressed by a given CEID stay at NoState — the handler
distinguishes "this CEID updates the location axis" from "this CEID
updates the processing axis" so the host can keep its own per-
substrate FSM in sync.

Closes Tranche E — E90 Substrate Tracking end-to-end (FSM + Store +
CEIDs + server emission + host observer).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 01:23:11 +02:00
raphael 511950aa4a E2: E90 standard CEIDs + server-side substrate event emission
Adds the canonical E90-0716 §6.5 collection-event identifiers as a
single header of inline constants (gem/e90_constants.hpp) keyed off
SubstrateState / SubstrateProcessingState transitions:

  901 AtSource           910 NeedsProcessing
  902 AtWork             911 InProcess
  903 AtDestination      912 Processed
                         913 Aborted
                         914 Stopped
                         915 Rejected
                         916 Lost
                         917 Skipped

Values use the 901+ block to avoid collision with the demo CEIDs in
data/equipment.yaml (100s/200s/400s).

Server installs location + processing change handlers on
model->substrates that map every transition to emit_event() with the
matching CEID.  The existing event-report machinery (subscription
state, S6F11 batching, spool) gates the actual wire emission, so this
plays nicely with hosts that subscribe to only a subset of substrate
events.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 00:50:53 +02:00
raphael 7c726ed9ba E1: SubstrateStateMachine + SubstrateStore (E90 §6)
Per-substrate dual FSM with two orthogonal axes:

  Location (STS):
    AtSource -> AtWork (Acquire) -> AtDestination (Release)
    AtWork  -> AtSource (Return; processing aborted before completion)

  Processing:
    NeedsProcessing -> InProcess (Start) -> Processed (End)
    InProcess -> {Aborted, Stopped, Rejected, Lost} terminal
    NeedsProcessing -> {Skipped, Lost} terminal

Wire-byte values pinned via static_assert to E90-0716 §10.3.

SubstrateStore mirrors the CarrierStore pattern: non-movable, per-row
SubstrateStateMachine heap-allocated with handlers dispatching through
the store's location/processing callbacks; fire_location_event accepts
an optional new_location string so the application can carry
equipment-specific module names alongside the FSM state.

Joins EquipmentDataModel alongside carriers / load_ports.  9 test
cases cover initial state, full location lifecycle, all five
processing exits, and store-level dual-axis observer firing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 00:49:40 +02:00
raphael ff1a6b3726 D4: E87 server dispatch for S3F17/F25/F27
Hooks the CarrierStore + LoadPortStore from D3 onto the wire:

  S3F17 CarrierAction:
        ProceedWithCarrier -> CarrierIDEvent::ProceedWithCarrier
        CancelCarrier      -> CarrierIDEvent::CancelCarrier
        BindCarrierID      -> CarrierIDEvent::Bind
        unknown action     -> CAACK=CarrierActionInvalid
        unknown carrier    -> CAACK=CarrierIDUnknown

  S3F25 CarrierTransfer:
        fires source.StartUnloading + target.StartLoading transfer
        events, updates Carrier::port_id, returns CAACK=Accept.

  S3F27 CancelCarrier:
        fires CIDS CancelCarrier + CAS Cancel against the carrier.

The state-change handlers wired into the stores in D3 are the right
place to emit S6F11 CEIDs for carrier-events; that hookup is left for
a follow-up commit (it depends on the equipment.yaml CEID catalog,
which doesn't yet enumerate E87 events).

Closes Tranche D — E87 Carrier Management end-to-end (FSMs + wire +
stores + dispatch).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 00:16:04 +02:00