Commit Graph

34 Commits

Author SHA1 Message Date
raphael 1da56f973f feat(daemon): alarms by name + RequestControlState + WatchHealth (Phase A complete)
A2 — alarms: optional 'name:' on alarm config (a LOCAL key — SEMI only
defines numeric ALID + freetext ALTX; field appended last so existing
{id, text, category} brace-inits compile unchanged), parsed by the loader,
checked by the validator, shipped in equipment.yaml. SetAlarm/ClearAlarm
RPCs resolve config name OR stringified ALID via a constructor snapshot.

A3 — control state + health: RequestControlState fires operator events on
the io thread (read_sync) and reports what the E30 table actually did —
ACCEPT iff the equipment landed in the requested state, CANNOT_DO_NOW naming
the actual state otherwise (the shipped table has no operator path to
EquipmentOffline; the test pins that honesty). ATTEMPT_ONLINE is rejected as
transient. WatchHealth streams an immediate snapshot then pushes on link/
control-state changes via service observers (add_link_observer +
add_control_state_observer — the HandlerSlot work paying off), spool depth
sampled at the 500ms poll; ends on cancel or engine stop.

Tests: daemon suite 61 -> 101 assertions (alarm lifecycle by name/id/unknown,
WatchHealth initial + change push, all four RequestControlState semantics);
loader test for the alarm name (present + absent fallback); core 467/3055.
Interop now 15 checks incl. gRPC SetAlarm -> host receives S5F1 ALCD=0x84
ALID=1, and RequestControlState(HOST_OFFLINE) -> GetControlState confirms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:47:31 +02:00
raphael 8a48ffeed4 feat(gem): multi-observer state-change handlers via HandlerSlot
The single-slot set_*_handler pattern was a structural blocker, hit twice:
the daemon could not observe control-state changes because
register_default_handlers owns the slot, forcing GetControlState to read the
FSM cross-thread (a data race), and blocking WatchHealth and the Subscribe
stream's ControlStateChange variant.

HandlerSlot<Args...> keeps a primary slot with exact legacy semantics
(set_ replaces — one existing test depends on replacement) plus an
append-only observer list (add_) that survives set_ calls. Fire sites are
textually unchanged (operator bool / operator() / assign-from-function).

Applied to ControlStateMachine + ProcessJobStore + ControlJobStore (the
roadmap-critical three; the remaining single-slot classes follow the same
3-line pattern as needed). EquipmentRuntime gains an atomic control-state
mirror registered as an observer — control_state() is now safe from any
thread, retiring the GetControlState race — plus add_control_state_observer
and add_link_observer (selected/closed fan-out), the hooks WatchHealth and
Subscribe need.

Tests: observer ordering, set-replaces-primary-but-observers-survive,
observers-without-primary, PJ-store coexistence, and the runtime scenario
that was previously impossible (mirror + observer + default-handlers set_).
Core 464/464 (2816 assertions), daemon 16/16, live GEM300 demo passes with
single-fire control-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:57:53 +02:00
raphael fc898f8410 feat: EquipmentRuntime engine owner + secs_gemd gRPC daemon
Extract the SECS/GEM engine wiring out of the secs_server app into a
reusable class, and stand up a language-agnostic gRPC daemon on top so a
tool's software (any language) can drive the equipment without linking C++
or knowing SEMI. Foundation for replacing a vendor's SECS/GEM server.

Engine reuse:
- EquipmentRuntime (include/secsgem/gem/runtime.hpp, src/gem/runtime.cpp):
  owns io_context, passive Server, model, control-state machine, Router;
  thread-safe outbound API (set_variable/emit_event/set_alarm/clear_alarm),
  on_command hook, deliver_or_spool, run()/run_async()/poll()/stop().
- register_default_handlers (src/gem/default_handlers.cpp): the 56 GEM
  handlers + domain emitters, relocated from secs_server so the app and the
  daemon speak byte-identical GEM. secs_server.cpp reduced ~1270 -> 113 lines.
- name_index.hpp: resolve_variable(name) -> VID (the name->id binding layer).

Daemon (apps/secs_gemd.cpp, proto/secsgem/v1/equipment.proto):
- runs the engine + HSMS link on a background thread; serves the gRPC
  Equipment service. Increment 1: SetVariables (name-resolved, plain
  value->Item) and GetControlState. proto carries the full v1 surface
  (universal + carrier/recipe/job tiers); remaining RPCs + the Subscribe
  command stream are next (docs/DAEMON_ROADMAP.md).
- CMake: opt-in SECSGEM_DAEMON, protoc/grpc_cpp_plugin codegen, gracefully
  skipped where protobuf/grpc++ are absent. Dockerfile gains the grpc deps.

Tests (proof): test_runtime, test_default_handlers (S1F1->S1F2, S2F41->hook),
test_name_index. Full suite 458/458, 2795 assertions; live server<->client
GEM300 demo still passes on the refactored server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:01:16 +02:00
raphael a4599b3b9d config: multi-error YAML validator + --validate-config CLI flag
The existing loader throws ConfigError on the first problem it hits.
A customer with a tool-specific equipment.yaml that has six issues
sees one, fixes, restarts, sees the next, fixes, restarts — six
edit-restart cycles before the server even binds.  Day-1 friction
is the top support ticket source in fab integrations.

This commit adds a parallel validator that does a separate read-only
pass and surfaces *every* issue at once:

  $ secs_server --validate-config \
      --config equipment.yaml \
      --state-table control_state.yaml
  [error] equipment.yaml:5  svids[0].type — unknown SECS-II type `WTF`
  [error] equipment.yaml:7  alarms[0].category — value 200 out of range [0, 127]
  [error] equipment.yaml:9  host_commands[0].emit_ceid — CEID 999 not declared in `ceids` section
  3 error(s), 0 warning(s) across 4 files

What it catches:
- Missing required fields (device.model_name, .software_rev, …)
- Range violations (alarm category must be 0–127, spool streams 1–127,
  device.id fits u16, etc.)
- Unknown enum values (SECS-II types, HCACK values, control/PJ/CJ
  state and event names — using the right case + snake convention
  the runtime parsers enforce)
- Duplicate IDs within svids / dvids / ecids / ceids / alarms,
  duplicate PPIDs in recipes, duplicate command names in host_commands
- Referential integrity: host_commands[*].emit_ceid must exist in
  ceids; host_commands[*].set_alarm must exist in alarms;
  emit_on_control_change must exist in ceids
- PJ-table-specific: `NoState` sentinel rejected as `initial`,
  `from`, or `to` (matches loader's existing runtime check)
- yaml-cpp Mark → 1-based line numbers when available

What it doesn't catch (out of scope this round):
- JSON Schema for editor red-squigglies (future)
- Deep semantic checks across state-table reachability
- ECID min/max value parsing (would need numeric type coupling)

Tests cover: clean file passes; multi-error YAML surfaces every issue
on a single pass; line numbers populate; control_state /
process_job_state / control_job_state casing conventions;
format_issues_to renders both severities; the shipped
data/equipment.yaml etc. validate cleanly (regression tripwire if
anyone breaks the demo configs).

INTEGRATION.md §2.3 calls out the flag and suggests CI use.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 14:32:09 +02:00
raphael 2ea3ab796a e84: SEMI §6 handshake timers TA1/TA2/TA3
E84StateMachine had the full signal-level handshake but no timer
enforcement.  In a real AMHS that's a deadlock: if equipment is slow to
assert L_REQ / U_REQ, or AMHS is slow to assert BUSY / COMPT, neither
side notices — the wires just sit stuck.  SEMI E84 §6 mandates three
timers that bound each leg of the dance.

TA1 — armed in ValidAsserted, cancelled in Load/UnloadReady.
      AMHS bounds how long equipment takes to acknowledge VALID.
TA2 — armed in Load/UnloadReady, cancelled in Transferring.
      Equipment bounds how long AMHS takes to start the transfer.
TA3 — armed in Transferring, cancelled on Complete.
      Equipment bounds the BUSY-phase duration.

The FSM stays I/O-free (it's the design invariant): arm/cancel are
delivered via callbacks, the application owns the asio::steady_timer,
and the application calls `fsm.on_timeout(id)` when its real clock
fires.  Stale on_timeout calls (post-cancel race) are no-ops.

On expiry, the FSM transitions to a new `HandoffFault` state, records
the `E84Fault` reason, fires the optional fault_handler, and latches
the fault until `reset()`.  Signal jitter on the wires cannot silently
clear a recorded handshake timeout — once you've crossed the timer,
you stop.

Defaults are all-zero, which disables arming.  This is what every
existing test relies on, and what back-to-back simulation (no
wall-clock) needs.  Production tools call `set_timeouts({2s, 2s, 60s})`
or whatever their port spec dictates.

12 new test cases / 59 assertions: arming per state, cancelling per
exit, expiry-to-fault for all three timers, ES cancels everything,
stale-expiry no-op, fault latching across signal jitter, and a
full-cycle arm/cancel trace.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 14:03:10 +02:00
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 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 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 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 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 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 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 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 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 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 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 6ff3104591 B3: HostHandler RCMD/alarm/recipe/terminal/job senders
Fleshes out the host-side message surface so the demo client app no
longer has to inline message construction.  Senders added (each is a
one-line wrapper over the codegen builders + Connection::send_request):

  Remote command:    S2F41/F42, S2F49/F50
  Alarm management:  S5F3/F4 enable, S5F5/F6 list, S5F7/F8 list-enabled,
                     S5F13/F14 recover, S5F17/F18 recover-abort
  Process programs:  S7F3/F4 send, S7F5/F6 request, S7F19/F20 EPPD
  Spool:             S6F23/F24
  Terminal:          S10F1/F2 single, S10F5/F6 multi
  E40 Process Jobs:  S16F11/F12 create, S16F5/F6 command, S16F13/F14 dequeue
  E94 Control Jobs:  S14F9/F10 create, S14F11/F12 delete, S16F27/F28 command

CommandParameter is reused from store/host_commands.hpp rather than
inventing a parallel ParamPair — host and equipment talk in the same
struct now.

Closes the outbound side of the host-mode menu.  The remaining piece
is an integration test that drives this against the equipment server
end-to-end (B4).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:53:17 +02:00
raphael 95ebcc3aac B2: HostHandler status + subscription senders
Adds the five GEM event-subscription primitives the host needs to drive
the equipment's data-collection lifecycle (E30 §6.11):

  S1F3/F4    selected status data
  S1F11/F12  status variable namelist
  S2F33/F34  define reports
  S2F35/F36  link event reports
  S2F37/F38  enable/disable events

Each is a one-line wrapper over the codegen builders + Connection's
send_request, surfacing the codegen-generated DefineReportEntry /
LinkEventEntry structs to callers behind a {id, [vids]} pair API.

This is the minimum surface a host needs to walk a fresh equipment
through "define report -> link CEID -> enable" and start receiving
S6F11 event reports — the same pattern the existing demo client does
inline.  B3 lands the RCMD / recipe / job / terminal senders that
build on top.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:50:37 +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 65db38d9f2 100%/B: S9F3/F5/F11 emission + Router fallback
tests / build-and-test (push) Failing after 31s
Closes the S9 stream.  Every documented protocol-error condition is now
auto-emitted by Connection (with the assist of one Router predicate),
without involving the application.

Router (include/secsgem/gem/router.hpp)
  Adds two predicates: has_handler(stream, function) and
  has_handler_for_stream(stream).  Lets the wrapping message handler
  decide whether an unhandled message is "unknown stream" (S9F3) or
  "unknown function in a known stream" (S9F5).

Connection (include/secsgem/hsms/{connection.hpp, connection.cpp})
  - emit_s9() goes public so the message_handler can call it.
  - New current_header() accessor returns the HSMS header of the
    primary currently being dispatched.  Non-null only inside the
    on_message_ call; cleared on the way out.
  - handle_data sets current_header_ before invoking on_message_.
  - on_length on oversized frame: synthesizes a 10-byte MHEAD whose
    first 4 bytes are the offending length prefix, emits S9F11, and
    sets close_after_flush so the S9F11 goes out before the socket
    closes.

Server (apps/secs_server.cpp)
  The conn->set_message_handler lambda now wraps router.dispatch.  For
  any inbound primary without a registered handler, it captures the
  MHEAD via current_header() and emits either S9F3 (stream unknown) or
  S9F5 (function unknown).  The wrapper still returns the Router's
  reply (SxF0 for primaries with W) so transactional semantics are
  preserved.

COMPLIANCE.md
  Error Messages row flips from 🟡 to .  S9F3/F5/F11 rows in the
  coverage matrix flip from 🟡 to .  Each row in the matrix now
  states its trigger condition explicitly.  Drops the
  "Finish S9 wiring" bullet from the "what would 100% take" list.

Verified
  - Tests: 78 cases / 454 assertions still pass (no behavioural change
    on the happy path; new emission paths fire only on protocol errors
    that the demo doesn't induce).
  - Build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 01:57:35 +02:00
raphael 813e011409 Close COMPLIANCE.md gap: Documentation (S1F19-F22)
tests / build-and-test (push) Failing after 32s
Adds the GEM "Documentation" Fundamental capability: the equipment now
self-reports which GEM capabilities it supports, and the host can
discover the DVID namelist with the same shape used for SVIDs.

Catalog (data/messages.yaml -> generated messages.hpp)

  S1F19 W   header-only                Get GEM Compliance Request
  S1F20     <L,3 <A SOFTREV>
                 <A EQPTYP>
                 <L,a <L,2 <U1 CCODE> <A CDESC>>>>
                                       Get GEM Compliance Data
  S1F21 W   <L,n <U4 VID>>             DVID Namelist Request (n=0 = all)
  S1F22     <L,n <L,3 <U4 VID>
                       <A VNAME>
                       <A UNITS>>>     DVID Namelist Data

  Codegen emits CapabilityEntry and GemCompliance structs.  S1F22 reuses
  S1F12's StatusName struct (same wire shape; dedup avoids redefinition).

Equipment data dictionary (data/equipment.yaml)

  device:                              Adds `equipment_type: "EQUIPMENT"`
                                       for the S1F20 EQPTYP field.

  capabilities:                        New section.  List of
    - {code, name}                     (CCODE, CDESC) pairs honestly
                                       reflecting what the codebase
                                       implements: 1, 2, 3, 5, 6, 7, 8,
                                       9, 11, 12, 14 (partial), 15.

  dvids:                               New section, same schema as
                                       svids:.  Demo populates two:
    - WaferCounter      (U4, units wafer)
    - ChamberPressure   (F4, units Torr)

Loader (src/config/loader.cpp + include/secsgem/config/loader.hpp)

  EquipmentDescriptor gains equipment_type and capabilities (vector of
  (uint8_t, string) pairs).  load_equipment now reads `capabilities:`
  into the descriptor and `dvids:` into model.dvids.

Server (apps/secs_server.cpp)

  router.on(1, 19) returns S1F20 with desc.software_rev,
  desc.equipment_type, and desc.capabilities converted to
  vector<CapabilityEntry>.
  router.on(1, 21) returns S1F22 built from model.dvids.all().

Client (apps/secs_client.cpp)

  Two new demo steps after Request Online and before SVID discovery:
    S1F19 -> S1F20: logs SOFTREV, EQPTYP, and every (CCODE, CDESC)
                    the equipment claims.
    S1F21 -> S1F22: logs each DVID with units.

Tests

  tests/test_messages.cpp   Round-trip S1F19/F20 with a 3-entry
                            capability list; round-trip S1F22 with two
                            DVIDs.

  tests/test_loader.cpp     Asserts equipment_type, the capabilities
                            list contains CCODE 14 (Spooling), and the
                            two DVIDs land in model.dvids.

COMPLIANCE.md

  "Documentation" Fundamental moves from  to .
  S1F19/F20 + S1F21/F22 rows in the coverage matrix flip to .
  The "what would it take" list drops the documentation-messages bullet.

Verified

  - Tests: 77 cases / 444 assertions pass.
  - Demo: client logs the full capability list received from the
    equipment, including CCODE 14 "Spooling (partial; S2F43/F44 +
    S6F23/F24)" — the equipment honestly reports its partial
    implementation rather than overclaiming.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 00:30:43 +02:00
raphael 547fd2116b Close COMPLIANCE.md gap: S9 error stream
tests / build-and-test (push) Failing after 43s
Adds S9F1, F3, F5, F7, F9, F11, F13 to the message catalog and wires
the two emission paths that the Connection layer can drive without help
from the Router or the application: S9F7 on a body-decode failure and
S9F9 on a T3 transaction-timer timeout.

Catalog (data/messages.yaml -> generated messages.hpp)

  All six MHEAD-carrying messages (F1/F3/F5/F7/F9/F11) use the same
  shape — a single <B 10> body with the offending 10-byte HSMS header.
  S9F13 (conversation timeout) carries <L,2 <A MEXP> <A EDID>>.

Connection-side emissions (src/hsms/connection.cpp)

  emit_s9(function, mhead)   New private helper.  Builds a 9/function/W=0
                              data message whose body is <B 10> with the
                              MHEAD bytes, allocates a fresh sys_bytes,
                              and queues it onto the write path.  No
                              reply is tracked.

  S9F7 on body decode        handle_data wraps Message::from_body in a
                              try/catch.  Previously any decode error
                              closed the connection; now it emits S9F7
                              with the offending header and continues
                              reading.  Reply-side decode failure also
                              emits S9F7 and surfaces the new
                              Error::IllegalData to the waiting
                              ReplyHandler (rather than making the
                              caller wait out T3).

  S9F9 on T3 timeout         The send_request T3 callback rebuilds the
                              original outgoing MHEAD from
                              (device_id, expected_stream,
                              expected_function-1, sys, W=1) and emits
                              S9F9 before invoking the callback with
                              Error::Timeout (unchanged).

What's intentionally not yet wired (logged in COMPLIANCE.md)

  - S9F3 / S9F5 — "unknown stream / function".  These need to live in
    the Router's fallback path, which would require either the Router
    knowing about a Connection-shaped sender or the Connection's
    message wrapper learning which streams the Router has handlers
    for.  Deferred — today the fallback returns SxF0 only.
  - S9F11 — "Data Too Long".  Currently we close on oversized frames;
    we'd need to also build a synthetic 10-byte MHEAD substitute (the
    real header isn't yet available at the point of detection) and
    flush it through close_after_flush.

Tests + docs

  tests/test_messages.cpp  Round-trip every S9F* using a representative
                           10-byte MHEAD literal; check S9F13 carries
                           MEXP + EDID.  +2 cases / +37 assertions.

  COMPLIANCE.md            Error Messages row moved from "no S9 stream"
                           to a detailed status describing what's
                           emitted vs catalog-only.  Coverage matrix
                           expanded per-message (F1/F7/F9/F13 ;
                           F3/F5/F11 🟡 catalog-only).

Build/demo unaffected: 75 cases / 420 assertions pass; the happy-path
demo never trips a decode error or T3, so the S9 path isn't exercised
end-to-end (but unit tests prove the wire shape).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 19:14:41 +02:00
raphael 0721db9542 Close COMPLIANCE.md gap: spooling (E30 §6.22)
tests / build-and-test (push) Failing after 42s
Implements the largest functional gap from the compliance audit. The
equipment now queues events the host can't immediately receive (either
because there's no SELECTED session or because the demo's force-spool
flag is on) and transmits the queue on host request.

What's new

  include/secsgem/gem/store/spool.hpp
    SpoolStore: a deque queue with a configurable per-stream whitelist
    (so only streams 5+6 spool by default), a max_size cap with FIFO
    eviction on overflow, and a `force_spool` test flag.  Enqueue
    returns one of Queued / Dropped_NotSpoolable / Dropped_Full so the
    caller can fall back to live delivery when appropriate.  Drain
    pops the entire queue in FIFO order.  Two new ack enums:
    ResetSpoolAck (S2F44 RSPACK) and SpoolRequestAck (S6F24 RSDA), plus
    SpoolRequestCode (S6F23 RSDC, Transmit/Purge).

  data/messages.yaml + auto-regenerated messages.hpp
    S2F43 W   <L,n <B stream>>            Reset Spooling
    S2F44     <L,2 <B RSPACK> <L,a ...>>  Reset Spooling Ack
    S6F23 W   <B RSDC>                    Request Spooled Data
    S6F24     <B RSDA>                    Request Spooled Data Ack

  data/equipment.yaml
    `spool:` section: max_size + spoolable_streams list.  Two new host
    commands SPOOL_ON / SPOOL_OFF that flip the force-spool flag (these
    stand in for "host link down" in the demo without dropping TCP).

  include/secsgem/gem/store/host_commands.hpp
    Spec/Result gain an optional<bool> force_spool field.  S2F41
    dispatch returns the result, the server applies it after S2F42 is
    queued.

  src/config/loader.cpp
    Reads `spool:` from equipment.yaml; reads `force_spool` from each
    host_commands entry; populates SpoolStore + CommandSpec.

  apps/secs_server.cpp
    New `deliver_or_spool(msg, what)` helper.  emit_event and
    emit_alarm_set funnel through it: if force_spool is on (or there's
    no active session), msg.stream is checked against the spoolable
    list and the message is enqueued; otherwise it's sent live.
    Two new handlers:
      S2F43  parses the stream list, updates SpoolStore, replies S2F44
      S6F23  RSDC=Transmit drains and re-sends each as a fresh primary
             (posted on the executor so the S6F24 ack flushes first);
             RSDC=Purge clears the queue and acks.
    The S2F41 handler now also propagates result.force_spool into the
    SpoolStore.

  apps/secs_client.cpp
    Demo extended with 4 new steps after the FAULT branch:
      SPOOL_ON  -> S2F42 Accept
      START     -> S2F42 Accept; CEID 300 emission spooled (no live S6F11)
      SPOOL_OFF -> S2F42 Accept; queue still has the message
      S6F23(Transmit) -> S6F24 Accept; spooled S6F11 arrives next
    Then the existing S7F19/S7F5/S10F1/S1F15/Separate flow continues.

  tests/test_data_model.cpp
    Four new TEST_CASEs for SpoolStore (whitelist, FIFO eviction at
    max_size, drain ordering, force flag).

  tests/test_loader.cpp
    Confirms equipment.yaml's `spool:` section populates the store and
    `force_spool: true/false` flows through to dispatch results.

  COMPLIANCE.md
    Spooling moves from  to 🟡.  Adds S2F43/F44 + S6F23/F24 as  in
    the message coverage matrix; calls out what's still missing
    (S6F25/F26 notification, automatic activation on HSMS NOT-SELECTED,
    persistent on-disk spool).

Verified

  - Tests: 73 cases / 383 assertions pass (+4 spool cases).
  - Demo (docker compose up server client) walks the full happy path
    and the spool path, observed in the server log as:
        spool: force_spool=true (depth=0)
        spool: S6F11 CEID=300 queued (depth=1)
        spool: force_spool=false (depth=1)
        S6F23 transmit: draining 1 messages
    and on the host side as the queued S6F11 arriving in the correct
    order after S6F24.

Known limitations (logged in COMPLIANCE.md)

  - Spool activation is manual via SPOOL_ON/OFF rather than
    automatically triggered by HSMS NOT-SELECTED.
  - No S6F25/F26 spooled-data-ready notification on re-SELECT.
  - In-memory only; an equipment restart loses queued events.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 22:06:55 +02:00
raphael 41accc3263 #2 Tighten reply correlation: match (sys_bytes, stream, function) exactly
The previous heuristic ("function % 2 == 0 && pending_requests_.count(sys)")
worked in practice but was wrong in principle — SECS-II doesn't enforce
function parity, and a peer protocol violation (replying with the wrong
SxFy) would have been silently treated as a primary message.

Now PendingRequest carries the expected reply stream + function (computed
from request.stream / request.function+1 per SECS-II convention) at
send_request time.  handle_data matches on all three:

  it->second.expected_stream == h.stream() &&
  it->second.expected_function == h.function()

If sys_bytes matches but stream/function doesn't, the Connection logs
a diagnostic ("!! unexpected SxFy for pending sys=N (expected ...)")
and treats the message as a primary so the application handler can
still respond.  The pending request stays open until T3.

No behaviour change on the happy path; the demo and all 69 tests still
pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:53:37 +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 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