Commit Graph

38 Commits

Author SHA1 Message Date
raphael 06f287b415 conformance: standalone secs_conformance harness binary
The closest thing to an in-repo "RTS" — a runnable executable that
points at any HSMS-SS equipment and walks through every E30
fundamental + additional capability, reporting pass/fail per check
and exiting with the right code for CI / canary use.

  build/secs_conformance --host <ip> --port 5000 --device 0

Each check sends a host-initiated primary and asserts the equipment
replies with the expected stream/function within T3.  Checks chain
forward through async callbacks (each reply handler kicks off the
next check) so the conformance run stays inside one io.run().

Initial check set (mirrors COMPLIANCE.md §3 fundamentals):
  E37 §7.2  SELECT handshake
  E30 §6.5  S1F13/F14 Establish Comms
  E30 §6.7  S1F1/F2 Are You There
  E30 §6.13 S1F11/F12 SVID Namelist
  E30 §6.16 S2F29/F30 ECID Namelist
  E30 §6.20 S2F17/F18 Clock
  E30 §6.14 S5F5/F6 List Alarms
  E30 §6.17 S7F19/F20 PP List
  E30 §6.10 S1F19/F20 GEM Compliance

Validated against the demo server: 9/9 PASS.

README.md §8 (Compliance + certification) updated to point at the
harness as the suggested first-line conformance check.  Tool
vendors fork apps/secs_conformance.cpp and add their own
capability-specific checks alongside.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 12:57:37 +02:00
raphael 78fb0c3826 e42: enhanced (formatted) process programs S7F23-F26
E42 was an explicit out-of-scope item in the prior COMPLIANCE.md.
This commit closes it.

Wire messages added via the catalog:
  S7F23  Formatted PP Send       (H↔E, W=1)
  S7F24  Formatted PP Ack        (ProcessProgramAck)
  S7F25  Formatted PP Request    (PPID, W=1)
  S7F26  Formatted PP Data       (E→H, no reply)

Body shape: <L,4 PPID MDLN SOFTREV <L,n <L,2 CCODE <L,m <L,2
PNAME PVAL>>>>>.  PVAL is declared ITEM so any SECS-II Item type
round-trips — proven by a test that mixes ASCII, BOOLEAN, U4, F8,
Binary, and nested List values in one step.

RecipeStore extension:
  add_formatted(ppid, FormattedRecipe{mdln, softrev, steps})
  get_formatted(ppid) -> optional<FormattedRecipe>
  has_formatted(ppid) -> bool

Formatted + opaque views live alongside each other: a PPID can carry
both, size() counts unique PPIDs.  remove() kills both views.

Six new tests cover wire round-trip per function, every
ProcessProgramAck code, ITEM passthrough, and the store's dual-view
semantics.

COMPLIANCE.md updated: E30 §6.17 row mentions S7F23-F26, S5 message
table grows two rows, §8 "out of scope" entry for E42 removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 11:58:03 +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 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 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 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 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 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 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 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 7668ceaae4 D3: CarrierStore + LoadPortStore
Per-CARRIERID and per-PORTID stores wrap the D1 FSMs, mirroring the
ProcessJobStore / ExceptionStore pattern: heap-allocated state machines
keyed in a std::map, non-movable to keep this-capture lambdas safe,
synthetic create() that wires per-row change handlers into the store's
top-level callbacks.

CarrierStore:
  create(carrierid, port_id, capacity)  — default 25-slot map
  fire_id_event / fire_slot_map_event / fire_access_event
  set_id_handler / set_slot_map_handler / set_access_handler

LoadPortStore:
  create(port_id)
  associate(pid, carrierid) / disassociate(pid)
  fire_transfer_event / fire_reservation_event
  set_transfer_handler / set_reservation_handler / set_association_handler

Both join EquipmentDataModel alongside process_jobs / control_jobs /
exceptions.  Six test cases cover create-dedup, ID-status change
observation, slot-map / access independence, port association,
transfer lifecycle, and reservation handler firing.

Server-side dispatch (S3F17 -> CarrierStore::fire_id_event, S3F25 ->
LoadPortStore transfer) lands in D4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 00:14:50 +02:00
raphael 94c26c0771 D1: E87 carrier and load-port state machines
Per-carrier triple FSM: CIDS (id verification), CSMS (slot-map), CAS
(access).  Per-port triple FSM: LPTS (transfer), LRS (reservation), LAS
(association).  Wire-byte enum values pinned via static_assert to match
E87-0716 §10.3.

CarrierStateMachine combines the three carrier-side FSMs because they
are independent but always observed together; same for LoadPortState-
Machine.  Generic CarrierTransitionTable<State, Event> template is
reused across all six tables — same row shape as the PJ/CJ/Exception
tables that already exist.

Default tables cover the spec's documented transitions:
  CIDS: NotConfirmed <-> Confirmed/Mismatched/Unknown, Cancel returns
        to NotConfirmed from any state, Bind force-confirms.
  CSMS: NotRead -> Read -> {Mismatched, Reset}.
  CAS:  NotAccessed -> InAccess -> Complete (terminal).
  LPTS: OutOfService <-> InService <-> Loading/Unloading.
  LRS / LAS: simple boolean toggle pairs.

15 test cases assert the happy-path lifecycles, cross-state cancels,
and that change handlers fire only on real transitions (Read in
NotConfirmed is a no-op, not a handler call).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 00:09:42 +02:00
raphael a1f7da4a7d C1: ExceptionStateMachine FSM + ExceptionStore
Per-EXID exception lifecycle for E5 §9.  States mirror the wire flow:

  Posted          equipment sent S5F9, awaiting host or autonomous clear
  Recovering      host's S5F13 accepted; equipment running recovery
  RecoverFailed   S5F15 reported a failed result; host may retry
  Cleared         terminal — store removes the row

Events:
  Created          synthetic NoState->Posted observer signal
  Recover          host's S5F13 (Posted/RecoverFailed -> Recovering)
  RecoveryComplete equipment internal (Recovering -> Cleared)
  RecoveryFailed   equipment internal (Recovering -> RecoverFailed)
  RecoveryAbort    host's S5F17 (Recovering -> Posted)
  Clear            equipment internal (Posted/RecoverFailed -> Cleared)

ExceptionStore mirrors ProcessJobStore: per-EXID FSMs heap-allocated via
unique_ptr, non-movable to keep `this`-captures safe, synthetic Created
fires after the row lands so observers can decide whether to emit S5F9
out of band.  on_recover validates EXRECVRA against the candidates the
post advertised.

The store joins EquipmentDataModel alongside process_jobs / control_jobs.
S5F9-F18 server-side dispatch lands in C2.

Tests (12 cases) cover FSM transitions including retry, abort, and
autonomous clear, plus store-level duplicate-rejection, EXRECVRA
validation, and Cleared-removes-the-row semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 23:41:51 +02:00
raphael 4fec4297e2 B4: Host-mode integration tests
Five end-to-end tests wire a real HostHandler against a real passive
HSMS Connection over a TCP loopback pair and assert wire-level
behaviour matches expectations:

  - establish_communication + go_remote sequence S1F13 then S1F17
  - send_remote_command produces a wire-correct S2F41 the equipment
    can re-parse with parse_s2f41 and recover CPNAME/CPVAL
  - send_terminal_display round-trips through S10F1/F2
  - E40/E94 create+command sequence (S16F11, S14F9, S16F5)
  - Inbound S5F1 alarm fires the host's alarm observer + auto-acks

Each test uses the existing pump_until / SocketPair harness pattern
from test_hsms_connection.cpp.  The recorder pattern keeps the
equipment-side dispatch table small — every test installs the same
canned reply handler.

This closes Tranche B (host mode).  HostHandler now has the inbound
+ outbound surface secsgem-py's GemHostHandler exposes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:58:51 +02:00
raphael 63bb0cf933 B1: HostHandler base class
GEM host-side counterpart to the existing equipment server: wraps an
HSMS Connection (Active mode), installs an inbound dispatch table that
auto-acks the messages a host is expected to passively accept, and
exposes the GEM workflow primitives.

Inbound dispatch:
  S5F1  Alarm Report          observe (alarm handler) + S5F2 Accept
  S6F11 Event Report          observe (event handler) + S6F12 Accept
  S6F25 Spool Data Ready      S6F26 Accept (host policy: pull on demand)
  S10F1 Terminal Display      observe + S10F2 Accepted
  S9F*  Equipment errors      observe (s9 handler); no ack (one-way)

Workflow shortcuts:
  establish_communication()   S1F13 -> S1F14
  go_remote()                 S1F17 -> S1F18
  go_offline()                S1F15 -> S1F16

Plus a low-level send_request() escape hatch so the senders coming in
B2/B3 don't have to friend the connection internals.

Drive-by: event_reports.hpp was missing `<optional>` (worked transitively
through the equipment-side include chain but not when included from the
host-side standalone).

secsgem-py has `gem/hosthandler.py`; this mirrors its surface for the
inbound-ack and lifecycle parts.  Outbound senders land in B2/B3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:49:52 +02:00
raphael 72fa73fee0 A5: SECS-I-over-TCP convenience layer
Wires the SECS-I Protocol FSM behind an asio TCP socket so the block
protocol can run over loopback without serial hardware.  Mirrors
secsgem-py's `secsitcp/` adapter — useful for back-to-back simulators
and CI without a serial device.

Adds:
  include/secsgem/secsi/tcp_transport.hpp
  src/secsi/tcp_transport.cpp
  tests/test_secsi_tcp.cpp

The transport:
- Splits outgoing SECS-II messages into blocks (transparent multi-block).
- Accumulates incoming blocks until end_block=true, then assembles and
  delivers as a single SECS-II message — same surface as the HSMS
  Connection's MessageHandler.
- Drives T1 / T2 timers from asio steady_timer; T3/T4 stay upper-layer
  per the FSM contract.
- Auto-allocates monotonic system bytes per send.

Tests cover single-block delivery, multi-block reassembly (700-byte
ASCII body spanning multiple SECS-I blocks), and bidirectional exchange.

This closes Tranche A (catch-up to secsgem-py wire/transport surface).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 21:36:17 +02:00
raphael a400ef3160 A4: SECS-I transport (block protocol + E4 retry FSM)
Adds a complete IO-free SECS-I implementation:

  include/secsgem/secsi/header.hpp   10-byte block header (R/W/E bits)
  include/secsgem/secsi/block.hpp    length + header + body + checksum
  include/secsgem/secsi/protocol.hpp half-duplex FSM (ENQ/EOT/ACK/NAK)
  src/secsi/*                         implementations
  tests/test_secsi.cpp                header, block, multi-block split,
                                      back-to-back FSM drive, RTY,
                                      contention, T2 timeout

The protocol is event-driven (`Event` → `Action` queue), so wiring it
to an asio serial_port is a thin adapter — that lands in the next
commit so this one stays reviewable.

Key design points:
- Master/slave contention: slave yields on simultaneous ENQ (E4 §7.1.4).
- RTY exhaustion raises ActionRaiseError, clears the send queue, resets
  to Idle (no zombie state).
- Multi-block assembler validates contiguous 1..N numbering and exclusive
  E-bit-on-last invariants — rejects malformed sequences with nullopt.
- Block::checksum is exposed publicly for the receive path's verification.

Tests cover the happy path (back-to-back delivery), error paths
(checksum mismatch, short input, oversize body), retries (NAK chain to
exhaustion), and protocol corner cases (contention, T2 timeout).

secsgem-py implements SECS-I block framing but lacks the explicit RTY
state machine; this commit puts the C++ port ahead on transport
correctness.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 21:34:09 +02:00
raphael 90c177b7ce E40 Process Jobs + E94 Control Jobs + E30 communication state
GEM300 layer: SEMI E40-0705 Process Job and E94-0705 Control Job
state machines, plus the E30 §6.1 communication-state machine that
sits between HSMS SELECT and full GEM communication. Data-driven
via data/process_job_state.yaml and data/control_job_state.yaml,
mirroring the existing control_state.yaml pattern.

Wire coverage:
  S14F9/F10   CreateObject (CJ)              host -> equipment
  S14F11/F12  DeleteObject (CJ)              host -> equipment
  S16F5/F6    PRJobCommand                   host -> equipment
  S16F9       PRJobAlert                     equipment -> host
  S16F11/F12  PRJobCreate (simplified body)  host -> equipment
  S16F13/F14  PRJobDequeue                   host -> equipment
  S16F27/F28  CJobCommand                    host -> equipment

Process Job FSM exposes 8 states matching PRJOBSTATE bytes (E40 §10.3.2);
HOQ is reorder-aware (move-to-head against an insertion-order vector);
Stop/Abort on a Queued PJ routes through ABORTING so the host observes
PRJOBSTATE=7 on the wire (§6.3); alert_enabled is settable per-PJ for
PRALERT control; FSM dispatches through ProcessJobStore::on_change_
dynamically so a late set_state_change_handler() reaches existing PJs.

Hardening: loader rejects NoState (sentinel) as initial/from/to and
rejects `on: created` rows; static_asserts pin enum values to wire
bytes; ProcessJobStore is non-movable to keep the per-PJ this-capture
safe.

Server simulator cascades the full CJ -> PJ lifecycle on CJSTART so
the wire trace exercises every legal state. CEIDs 400/401 fire on CJ
state changes via the existing event-report pipeline.

Tests: 60+ new assertions across test_process_jobs, test_control_jobs,
test_communication_state, test_hsms_connection, plus loader and
messages round-trip coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 21:00:32 +02:00
raphael 711ee1b40f #4 Split EquipmentDataModel into focused stores
The god-class is gone.  Each capability is now its own focused store:
StatusVariableStore, DataVariableStore, EquipmentConstantStore (with EAC
range validation), EventReportSubscriptions, AlarmRegistry, RecipeStore,
Clock, HostCommandRegistry.  Each is independently testable.

EquipmentDataModel becomes a small composite that holds one of each store
as a public member, plus three convenience methods (vid_value, vid_exists,
compose_reports_for) that span SVIDs+DVIDs and inject the right callbacks
into the EventReportSubscriptions.

New under include/secsgem/gem/store/:

  status_variables.hpp   StatusVariable, StatusVariableStore,
                         DataVariable, DataVariableStore
  equipment_constants.hpp EquipmentConstant, EquipmentConstantStore,
                          EquipmentAck. set_value() now validates
                          numeric values against min_str/max_str and
                          returns EAC=4 on out-of-range — closes the
                          COMPLIANCE.md gap about EC range validation.
  event_reports.hpp      CollectionEvent, Report, ReportData,
                         EventReportSubscriptions + DefineReportAck,
                         LinkEventAck, EnableEventAck. The store is
                         pure data; VidLookup / VidExists callbacks
                         are injected at define / emit time so the
                         service doesn't back-reference the SVID
                         store.
  alarms.hpp             Alarm, AlarmAck, AlarmRegistry.
                         Encapsulates the (enabled, active) sets and
                         ALCD byte computation.
  recipes.hpp            ProcessProgramAck, RecipeStore.
  clock.hpp              TimeAck, Clock. set_time_string applies an
                         offset so subsequent reads reflect the host
                         time without mutating system clock.
  host_commands.hpp      HostCmdAck, CommandParameter,
                         HostCommandRegistry with Spec/Result types.

include/secsgem/gem/data_model.hpp shrinks to a 50-line composite:

  struct EquipmentDataModel {
    StatusVariableStore       svids;
    DataVariableStore         dvids;
    EquipmentConstantStore    ecids;
    EventReportSubscriptions  events;
    AlarmRegistry             alarms;
    RecipeStore               recipes;
    Clock                     clock;
    HostCommandRegistry       commands;
    /* + vid_value, vid_exists, compose_reports_for sugar */
  };

src/gem/data_model.cpp is gone — every store is inline header-only.

include/secsgem/gem/messages_helpers.hpp picks up EventReportAck and
TerminalAck (S6F12 / S10F2-F4 ack enums that aren't tied to any one
store).

Call-site updates:

  apps/secs_server.cpp   model->status_variable(id) -> model->svids.get(id),
                         model->equipment_constant(id) -> model->ecids.get(id),
                         model->alarm_set(id) -> model->alarms.set_active(id),
                         model->dispatch_command(...) -> model->commands.dispatch(...),
                         and similar across every handler.  Plus
                         model->current_time_string() -> model->clock....

  src/config/loader.cpp  model.add_status_variable(sv) -> model.svids.add(sv),
                         and similar.  HostCommandRegistry::Spec replaces
                         EquipmentDataModel::CommandSpec.

  apps/secs_client.cpp   std::vector<EquipmentDataModel::CommandParam> ->
                         std::vector<CommandParameter>.

  tests/test_data_model.cpp  Rewritten around the individual stores;
                         each gets its own TEST_CASE block.  Adds three
                         new cases covering EC range validation (in
                         range / out of range / non-numeric skipped).

  tests/test_loader.cpp  m.has_event(100) -> m.events.has_event(100),
                         etc.

Verified:

  - Tests: 69 cases / 370 assertions pass (was 67 / 384; -14 stale
    composite-API assertions + 16 new store-level assertions covering
    EC range validation and the per-store add/get/list/delete paths).
  - Demo: byte-identical behaviour across the full 17-step flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:51:54 +02:00
raphael 29db1caedb #6 SxFy codegen from YAML message catalog
The bulk of the per-SxFy boilerplate — ~90 hand-written builders and parsers
across 30+ message pairs — is now generated at build time from a single YAML
catalog. Adding a new SECS-II message becomes a YAML edit; the C++ code is
generated, not maintained.

What changed
------------

data/messages.yaml
  The catalog. Describes every SxFy currently supported: stream, function,
  W-bit, builder name, optional parser name, and a recursive body shape
  grammar (scalar / list / list_of).  Shapes carry SECS-II item types
  (ASCII, BINARY_BYTE, U4, F8, ITEM, ...) and optional C++ enum types for
  typed ack codes.  Inner-most fields can be marked external_struct: true
  so structs already defined elsewhere (ReportData, CommandParameter) are
  referenced rather than redefined.

tools/gen_messages.py
  Python codegen.  Reads the catalog and emits one inline header.  Handles
  nested shapes via depth-unique variable names in the generated IIFEs, so
  S6F11's three-level nesting compiles without lambda capture conflicts.
  Post-order traversal ensures inner structs are emitted before outer ones
  that reference them.  Generates positional and (where applicable) struct
  builder overloads, plus struct-returning parsers for messages with a
  `parser:` entry.

CMakeLists.txt
  Custom command runs gen_messages.py at configure/build time and emits
  ${CMAKE_BINARY_DIR}/generated/secsgem/gem/messages.hpp.  Added to the
  secsgem target's include path so `#include "secsgem/gem/messages.hpp"`
  resolves to the generated file.  Depends on the YAML + the script, so
  edits trigger regen automatically.

Dockerfile
  Added python3 + python3-yaml to the toolchain image.

include/secsgem/gem/messages_helpers.hpp  (new)
  The small set of hand-written helpers the generated header relies on:
  scalar accessors (as_ascii / as_u4_scalar / ...), parse_u4_list_body,
  u4_list_item, ack_byte, ALED byte constants, and the two special-case
  messages whose shape doesn't fit the codegen schema (S1F4 needs
  per-row std::optional<Item> semantics; S5F6 needs a per-row ALCD
  callback).

include/secsgem/gem/messages.hpp  (deleted)
  The hand-written builder/parser file is gone. Its content now flows
  through the catalog + codegen.

include/secsgem/gem/data_model.hpp
  Moved CommandParameter to namespace scope so it can be shared between
  the data model and the messages.yaml's external_struct entry.  Added
  `using CommandParam = CommandParameter` for back-compat.

apps/secs_server.cpp + apps/secs_client.cpp
  Updated the call sites that the codegen renamed or restructured:
  - parse_terminal_display() split into parse_s10f1 / parse_s10f3.
  - s1f14_establish_comms_ack now takes a McAck struct for the nested
    identity (mdln, softrev) — call site uses brace init.
  - S2F33/S2F35 parsers return strongly-typed entries (DefineReportEntry,
    LinkEventEntry); the server adapts these to the model's pair-based
    API at the call site.
  - S2F15 parser returns vector<EcSet>; iterate by .ecid/.value.
  - S5F3 parser returns EnableAlarmRequest{aled, alid}; bool comes from
    (aled & 0x80) != 0.
  - AlarmReport's is_set()/category() methods removed; callers use the
    raw alcd byte with bit math (alcd & 0x80, alcd & 0x7F).
  - s2f42_host_command_ack and s2f41_host_command always take their
    second list argument explicitly (no defaulted arg from codegen).

tests/test_messages.cpp
  Updated to construct the generated typed structs (EcSet, StatusName,
  EnableAlarmRequest, CommandParameter, CommandParameterAck) and to read
  the new field names (.ecid/.value, .rptid/.vids, .ceid/.rptids,
  .name/.code).

Coverage
--------

Generated by codegen (44 SxFy in catalog):

  S1F1, S1F2, S1F3, S1F11, S1F12, S1F13, S1F14, S1F15, S1F16, S1F17, S1F18
  S2F13, S2F14, S2F15, S2F16, S2F17, S2F18, S2F29, S2F30, S2F31, S2F32
  S2F33, S2F34, S2F35, S2F36, S2F37, S2F38, S2F41, S2F42
  S5F1, S5F2, S5F3, S5F4, S5F5
  S6F11, S6F12
  S7F3, S7F4, S7F5, S7F6, S7F19, S7F20
  S10F1, S10F2, S10F3, S10F4

Hand-written (in messages_helpers.hpp):

  S1F4   list-of-optional-items shape (nullopt -> <L,0>)
  S5F6   per-row ALCD via callback

Adding a new SxFy
-----------------

Append a single entry to data/messages.yaml describing the body shape.
The builder + parser appear in messages.hpp after the next build.  The
host command above for S2F41 (or any other added SxFy) requires no C++
changes if the body fits the recursive scalar/list/list_of grammar.

Tests: 67 cases / 384 assertions still passing.
Demo: byte-for-byte identical behaviour (Select, Establish, Online,
S1F11/F3 namelist+values, S2F29 EC namelist, S2F33/F35/F37 dynamic event
subscription, S2F41 START -> S6F11 emission, S5F5/F3 alarm directory +
enable, S2F41 FAULT -> S5F1 alarm + S6F11, S7F19/F5 recipe ops, S10F1
terminal, S1F15 offline, Separate).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:43:36 +02:00
raphael b871cd9da2 Table/YAML-driven refactor (Layer 1 start)
Move equipment capabilities and the E30 control state machine out of C++
code and into YAML data files; introduce a Router for SECS dispatch;
consolidate small files.

Behavioural changes: none.  Demo identical (15 SxFy transactions +
3 equipment-initiated primaries), 67 test cases / 384 assertions still
all green.  Structural changes only.

Why
---

The previous server.cpp held the equipment data dictionary (3 SVIDs,
2 ECIDs, 3 CEIDs, 2 alarms, 2 recipes, 4 host commands) as imperative
C++ in a 50-line `populate()` function, and routed inbound messages
through a 150-line if-ladder.  Adding a new SVID required a recompile.
Adding a new state transition required editing two switch statements
(`operator_*` and `on_host_request_*`).  The control state machine's
behavioural rules were spread across imperative code in two methods.

This is exactly what implementation_plan.md calls out as the wrong
shape: behavioural rules should live in versioned data, and every
runtime/test/analyzer should read from that data rather than re-encode
it.  This commit starts that move.

What's new
----------

data/equipment.yaml
  Equipment data dictionary.  Declarative SVIDs / ECIDs / CEIDs /
  alarms / recipes / host commands.  Host commands carry their HCACK
  ack code plus optional `emit_ceid` and `set_alarm` side-effects.
  Adding a new SVID or command is a YAML edit, no recompile.

data/control_state.yaml
  The E30 §6.2 control state transition table as data.  Each row is
  (from, on) -> (to [, then] [, ack]).  `then` chains an auto-advance
  through the transient AttemptOnline state.  The previous
  imperative switch is gone.

include/secsgem/config/loader.hpp + src/config/loader.cpp
  yaml-cpp-backed loader.  `load_control_state(path)` returns a
  ControlTransitionTable + initial state; `load_equipment(path, model)`
  populates the EquipmentDataModel and returns the device descriptor
  (id, MDLN, SOFTREV, optional auto-emit CEID).  Surfaces config
  errors with file path + field name via ConfigError.

include/secsgem/gem/router.hpp  (header-only)
  Small (stream, function) -> handler map.  Server registers all
  handlers once at startup, then the Connection's message handler is
  just `router.dispatch(msg)`.  Unhandled primaries with W set get
  SxF0 by default.  Replaces the if-ladder in secs_server.cpp.

include/secsgem/gem/control_state.hpp + .cpp
  ControlTransitionTable is the new pure data type.  ControlStateMachine
  is now a thin engine over the table: `fire(event)` looks up the row,
  optionally transitions, optionally chains a `then` transition, returns
  the ack code.  Behaviour rules no longer live in C++ switches.
  The default in-code table matches data/control_state.yaml row for row;
  tests rely on it so they don't need the YAML file.

include/secsgem/gem/data_model.hpp + .cpp
  `register_command(rcmd, CommandSpec)` replaces the function-handler
  signature.  CommandSpec = (HostCmdAck, optional emit_ceid, optional
  set_alarm).  `dispatch_command` returns a CommandResult so the server
  can fire the side-effects after S2F42 is sent.

apps/secs_server.cpp
  No populate(), no if-ladder.  Loads equipment.yaml + control_state.yaml
  at startup (clean error on bad config), wires the Router once,
  delegates dispatch.  Sm change handler reads emit_on_control_change
  from the YAML.  Welcome S10F3 removed for parity with config (a future
  YAML rule could re-introduce it declaratively).

tests/test_loader.cpp  (new)
  Verifies the YAML loader produces the same shape as the in-code
  default table, and that equipment.yaml populates every section
  (SVIDs/ECIDs/CEIDs/alarms/recipes/commands).  SECSGEM_DATA_DIR
  CMake define points at ${CMAKE_SOURCE_DIR}/data so tests don't
  depend on cwd.

CMakeLists.txt, Dockerfile
  find_package(yaml-cpp) and link.  libyaml-cpp-dev added to the
  Ubuntu base image (yaml-cpp 0.8 ships the modern target name).

File consolidation
------------------

Five small files removed; their content lives in fewer headers:

  - secs2/item.cpp        -> inline in secs2/item.hpp
  - secs2/message.cpp     -> inline in secs2/message.hpp
  - hsms/types.hpp        -> merged into hsms/header.hpp
  - hsms/frame.hpp        -> merged into hsms/header.hpp
  - hsms/frame.cpp        -> merged into hsms/header.cpp

hsms/header.hpp is now "the HSMS wire format" in one place: SType + status
enums + Timers + Header + Frame + constants.  All includers updated.

Net effect
----------

Before: equipment data dictionary lived in 50 lines of imperative
populate() inside secs_server.cpp; dispatch in a 20-branch if-ladder.

After: equipment data dictionary lives in 47 lines of YAML; dispatch
is a Router built once.  Adding a new capability is now a YAML edit
in the common case.

Test count up to 67 cases / 384 assertions (+4 cases / +106 assertions)
covering the loader and the new table-driven SM paths.

What's NOT changed
------------------

The per-SxFy reply construction still lives in C++ (each message has a
unique body shape).  Moving those into YAML/JSON message-shape
definitions is the next refactor step but requires a generic typed
encoder/decoder driven by shape descriptors; out of scope here.

Spooling, the S9 error stream, S1F19/F20, and the other gaps in
COMPLIANCE.md remain unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 08:57:38 +02:00
raphael 96b02f8b50 Initial commit: C++20 SECS-II / HSMS / GEM client + server
A fully containerised SECS/GEM toolchain. Single docker compose project,
no host build tools. 63 unit-test cases / 278 assertions, two demo
executables, end-to-end two-container demo exercising every implemented
capability.

Architecture (bottom-up):

  secs2/   E5 SECS-II codec
    Item        variant over L/A/B/BOOLEAN/I1-8/U1-8/F4/F8
    encode/decode  big-endian, 1/2/3-byte length encoding
    Message     SxFy + W-bit + optional root item
    to_sml      human-readable text rendering

  hsms/    E37 HSMS transport (TCP)
    Header      10-byte header + SType enum (Data/Select/Deselect/
                Linktest/Reject/Separate)
    Frame       4-byte length prefix + payload encode/decode
    Connection  async Asio TCP, NOT-SELECTED -> SELECTED state machine,
                T3/T5/T6/T7/T8 timers, system-bytes reply correlation,
                graceful close-after-flush separation

  endpoint  active Client (connect with T5 retry) and passive Server
            (accept loop) wrappers over Connection

  gem/     E30 GEM logic
    ControlStateMachine  5-state E30 control model with operator
                         actions, host requests, SEMI-mandated ack
                         codes (OnlineAck, OfflineAck, CommAck), and
                         a state-change handler
    EquipmentDataModel   in-memory dictionary: SVIDs, DVIDs, ECIDs
                         (with EAC), CEIDs, report defs, CEID->report
                         links, enabled-events set, alarm table
                         (ALCD, enabled, active), process programs,
                         host command registry, clock (16-char
                         YYYYMMDDhhmmsscc with offset)
    messages.hpp         builders + parsers for every SxFy below

GEM message coverage (full list):

  S1F1/F2    Are You There / On Line Data
  S1F3/F4    Selected Equipment Status Request / Data
  S1F11/F12  Status Variable Namelist Request / Data
  S1F13/F14  Establish Communications (+ CommAck)
  S1F15/F16  Request OFFLINE (+ OfflineAck)
  S1F17/F18  Request ONLINE (+ OnlineAck)
  S2F13/F14  Equipment Constant Request / Data
  S2F15/F16  EC Send + EquipmentAck (Accept/UnknownEcid/Busy/OutOfRange)
  S2F17/F18  Date and Time Request / Data
  S2F29/F30  Equipment Constant Namelist Request / Data
  S2F31/F32  Date and Time Set Request / TimeAck
  S2F33/F34  Define Report + DefineReportAck (5 enum values)
  S2F35/F36  Link Event Report + LinkEventAck
  S2F37/F38  Enable / Disable Event Report + EnableEventAck
  S2F41/F42  Host Command + HostCmdAck (7 values) + per-param CPACKs
  S5F1/F2    Alarm Report Send + AlarmAck (ALCD bit-7 set/cleared
             + lower-7 category)
  S5F3/F4    Enable/Disable Alarm Send + AlarmAck
  S5F5/F6    List Alarms Request / Data (active alarms tagged in ALCD)
  S6F11/F12  Event Report Send (equipment-initiated CEID emission
             with full report data) + EventReportAck
  S7F3/F4    Process Program Send + ProcessProgramAck (7 values)
  S7F5/F6    Process Program Request / Data
  S7F19/F20  Current EPPD List Request / Data
  S10F1/F2   Terminal Display Single (host->equipment) + TerminalAck
  S10F3/F4   Terminal Display Single (equipment->host)

Demo apps:

  apps/secs_server.cpp   passive equipment. Populates the data model
                         with 3 SVIDs (ControlState, Clock,
                         EventsEnabled), 2 ECIDs, 3 CEIDs
                         (ControlStateChanged, AlarmSetEvent,
                         ProcessStarted), 2 alarms (Chiller Temp High
                         cat 4, Door Open cat 1), 2 recipes
                         (RECIPE-A, RECIPE-B), and 4 host commands
                         (START, STOP, PAUSE, FAULT). Emits S6F11 on
                         every control state transition + on START;
                         emits S5F1 + the AlarmSetEvent CEID on FAULT.
                         Pushes an S10F3 welcome message when the host
                         comes online.

  apps/secs_client.cpp   active host. Walks 17 steps: Establish ->
                         Online -> S1F11 SVID namelist -> S1F3 read ->
                         S2F29 EC namelist -> S2F13 read ->
                         S2F17 clock -> S2F33/S2F35/S2F37 dynamic
                         event subscription -> S2F41 START
                         (-> receives S6F11) -> S5F5 alarm list ->
                         S5F3 enable alarm 1 -> S2F41 FAULT
                         (-> receives S5F1 + S6F11) -> S7F19/S7F5
                         recipe list + body -> S10F1 terminal ->
                         S1F15 Offline -> Separate. Handles inbound
                         S6F11, S5F1, S10F3 primaries.

Testing:

  tests/test_secs2.cpp         codec round-trip for every format,
                               byte-layout assertions for known values,
                               truncation/trailing-byte rejection,
                               nested list round-trip, SML rendering
  tests/test_hsms.cpp          header byte layout, data + control
                               header round-trip, full frame round-
                               trip with length prefix, short-payload
                               rejection
  tests/test_control_state.cpp every (state, event) pair in the E30
                               control state machine, including
                               AlreadyOnline / NotAccept rejections
                               and idempotent offline-while-offline
  tests/test_data_model.cpp    SVID/ECID/Alarm/Recipe CRUD, clock
                               format + parse, host command registry,
                               full event-report pipeline (define ->
                               link -> enable -> compose) with
                               every error path (InvalidVid,
                               UnknownCeid, UnknownRptid), alarm
                               set/clear with ALCD bit-7 semantics
  tests/test_messages.cpp      round-trip + byte-layout for every
                               builder/parser pair, including S6F11
                               event reports with mixed item types

Toolchain:

  Dockerfile          Ubuntu 24.04, g++-13, CMake, Ninja, libasio-dev
  docker-compose.yml  builder / tests / server / client services,
                      source bind-mounted, build artifacts in a
                      named volume so the host tree stays clean
  CMakeLists.txt      C++20, -Wall -Wextra -Wpedantic, standalone
                      Asio (ASIO_STANDALONE), doctest via FetchContent

Documentation:

  README.md           architecture, quick start, demo log
  COMPLIANCE.md       honest per-capability E5/E30/E37 audit with
                      spec section refs. Calls out what's implemented,
                      what's partial (Reject.req, Alarms missing F7/F8,
                      EC range validation, PP without verify, terminal
                      single-line only), and what's intentionally not
                      yet implemented (spooling, S9 error stream,
                      Documentation S1F19/F20+F21/F22, limits monitoring,
                      trace data collection, multi-block, material
                      movement). Does NOT claim "100% GEM-compliant" and
                      lists the work required to honestly make that claim.

This is Layer 0 + the start of Layer 1 from implementation_plan.md.
The transition-table-driven "spec-as-data" architecture (Layer 1
proper) is not yet implemented; the current code uses imperative
state machines that are structurally ready to be refactored onto
tables.

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