One ordered in-process scenario drives 53 of the 56 registered handlers
through Router::dispatch — S1 identification/comms/control, S2 ECs/clock/
event-config/commands/trace/limits/spool, S5 alarms+exceptions, S6 reports,
S7 recipes, S10 terminal, S14/S16 E39+E40/E94 jobs, S3 carriers — asserting
every reply is the paired (stream, function+1) with a body, plus targeted
state checks (OnlineRemote after S1F17, PJ exists after S16F11, HostOffline
after S1F15) and the Router's SxF0 abort fallback for unregistered W=1
primaries. Same flow secs_conformance runs over a live socket, but cheap
enough for every build; closes the '56 handlers, 4 direct tests' gap from
the design review.
Also seeds message-level golden frames: S1F13's body pinned to bytes
hand-computed from the E5 encoding rules — an external check on message
composition, not our codec validating itself (TODO: S5F1, composed S6F11).
Suite: 466 cases / 3052 assertions (+236), all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- name_index: add resolve_event(name) -> CEID (unit-tested).
- equipment_service.hpp: extract the gRPC service + value/state conversion
into a shared header; add FireEvent (optional per-fire variable values,
then trigger the collection event by name). secs_gemd slims to main().
- test_daemon_service: real in-process gRPC integration test (client stub ->
service -> EquipmentRuntime) proving SetVariables lands in the model,
GetControlState reports the state, FireEvent and unknown-name paths behave.
Separate secs_gemd_tests target (links grpc++/proto), gated on the daemon.
Core suite 459/459 (2799 assertions); daemon gRPC tests 15/15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
A fictional Physical Vapor Deposition tool wired end-to-end.
examples/pvd_tool/ is the template a real customer should fork.
Files:
- equipment.yaml: 32 SVIDs (chamber pressure, temperature, source
power, gas flows, cooling water, wafer counters, recipe step
state, EPT name, 4 load ports), 5 DVIDs, 7 ECIDs (setpoints
+ T_CRA/T_DELAY + cleaning interval + retry count), 17 CEIDs
(control state, alarms, process lifecycle, material movement,
EPT), 12 alarms with realistic categories (safety, error,
warning, attention), 3 multi-step recipes (Al / Ti / Cu),
9 host commands.
- main.cpp (~860 lines): the vendor-side application:
§1 helpers + constants
§2 sensor simulator — 4 sensors at 10 Hz + 1 Hz cadences,
random-walk around step-targeted setpoints, asio::post-on-strand
thread-safety pattern
§3 recipe runner — parses recipe body (STEP NAME duration=120s
power=2500W gas=Argon flow=50sccm), walks each step at 1s
per declared-second, fires step-started/completed CEIDs,
drives PJ FSM through ProcessComplete
§4 alarm threshold monitor — chamber-pressure-over-setpoint and
cleaning-interval logic, continuous evaluation, set/clear
emission gated on alarm-enable
§5 EPT cycler — Standby ↔ Productive ↔ UnscheduledDowntime
based on PJ activity + safety alarms
§6 Prometheus exporter on :9090 (pvd_messages_total,
pvd_chamber_pressure_torr, pvd_spool_depth, pvd_events_total,
pvd_alarm_set_total)
§7 Router handlers — full E30 set (~40 handlers) so a host can
do real work
§8 main() — YAML validation, model construction, server wiring,
periodic gauge updates
- README.md: section-by-section walkthrough, what's the same as
apps/secs_server.cpp, what this adds (simulator + recipe runner
+ alarm monitor + EPT cycler + metrics), what's not here
(persistence + E84 + real I/O), and what to change for your tool.
Verification: 47/47 conformance harness checks PASS against the
PVD tool — same as the demo server.
CMakeLists.txt adds the pvd_tool target.
README's documentation map points at examples/pvd_tool/.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Coverage-guided structural search for crashes and undefined behaviour
on arbitrary input to our two parsers.
What's wired:
- -DSECSGEM_FUZZ=ON CMake option, clang-only. Adds
-fsanitize=fuzzer-no-link,address,undefined to all targets +
-fsanitize=fuzzer to the two fuzz executables.
- apps/fuzz_secs2_decode.cpp — feeds raw bytes to secs2::decode.
Catches secs2::CodecError (expected) but traps on anything else
leaking (would be a hardening bug).
- apps/fuzz_sml_parse.cpp — feeds string to try_parse_sml, which is
contractually nothrow-equivalent; traps on any exception.
- .gitea/workflows/ci.yml — `libfuzzer` job builds with clang and
runs each fuzzer for 60s in CI. Any crash / ASan / UBSan flag
fails the job.
- Dockerfile gains clang + libclang-rt-18-dev so devs can run
locally with the same toolchain.
Result on a fresh 30-second local run:
fuzz_secs2_decode: 70 727 random inputs, 0 crashes
fuzz_sml_parse: 284 950 random inputs, 0 crashes
The coverage-guided search found and synthesized inputs that
exercise: zero-byte, single-byte format tags, all length-byte
counts (1/2/3), nested lists, format bytes with reserved bits, the
"BOOLEAN" SML token, malformed quoted strings, etc. libFuzzer's
recommended dictionary at the end of each run shows what bytes /
substrings the coverage feedback discovered as discriminating —
useful signals if we ever want a hand-curated corpus.
README proof table grows to 8 commands. After this:
- 426 unit tests (internal)
- 47 conformance harness checks (internal)
- 24 secsgem-py interop checks (external — Python ref impl)
- 20 secs4j interop checks (external — independent Java impl)
- 69 frames dissected by Wireshark HSMS dissector (external)
- 196 SEMI E5 KAT assertions (standards body's encoding rules)
- **~70k + ~285k random inputs, 0 crashes (external)**
- 100k random tool ops with all invariants holding (internal)
- YAML validation (internal)
- TSan clean on 2 557 assertions (internal correctness aid)
Five distinct external proofs now, each covering a different angle.
Plan: VERIFICATION.md §4.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hex-string fixtures constructed directly from the SEMI E5 §9
format-byte encoding rules:
format_byte = (format_code << 2) | length_byte_count
length_byte_count ∈ {1, 2, 3}
Coverage:
- Every format code (L, B, BOOLEAN, A, J, C, U1-U8, I1-I8, F4, F8)
- Every length-byte-count variant (1, 2, 3 bytes — exercises the
255 → 256 → 65 536 transitions)
- Numeric edges: 0, ±1, MIN, MAX, ±Inf, NaN, -0.0, multi-element vectors
- Empty and single-element variants
- Nested lists
- A "format byte layout per format code" regression tripwire that
pins every code → byte mapping
19 test cases, 196 assertions. Every fixture round-trips
byte-identical against the codec.
Why this is the strongest single codec test: every other validator
(secsgem-py interop, conformance harness, in-house unit tests) is
one implementer's interpretation. KAT is the standard's own
arithmetic. If our encoder matches these canonical bytes and our
decoder reverses them to the same Item, our SECS-II layer is wire-
compatible with anything else that obeys E5 §9.
NaN / signed-zero / Inf use a bit-pattern compare (IEEE NaN != NaN
breaks the default Item == path) — decode the canonical, re-encode
the decoded, assert byte-identical.
The 3-byte-length fixture (ASCII 65 536 × 'X') generates a ~200 KB
expected-bytes string in the test — slow to write but trivial to
check and forces the 3-byte length-prefix path that 99 % of real
traffic doesn't exercise.
Plan: VERIFICATION.md §1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a -DSECSGEM_TSAN=ON CMake option that builds every target with
-fsanitize=thread + debug symbols + -O1 + frame pointers. Wires a
dedicated thread-sanitizer job into .gitea/workflows/ci.yml that
builds and runs the full test suite under TSan with
TSAN_OPTIONS=halt_on_error=1 (any flagged race fails the job, not
just warns).
Result against the full 426-case / 2557-assertion suite: 0 warnings,
all green. That converts the existing test_thread_safety.cpp (which
exercised the asio::post-onto-strand pattern) and test_concurrency
(in-flight transaction interleaving) and test_robustness_fuzz (28
random action types × thousands of ticks) from "pattern smoke-tests"
into actual race detection.
The first TSan run caught a real bug in test_robustness_fuzz's
act_exception_complete: it held a pointer to an ExceptionStore
entry across fire_internal(RecoveryComplete), which deletes the
entry. The subsequent state() read was a use-after-free. TSan
flagged it 8 times (4 reads × 2 stack-frame variants). Fix is
scoped lookup + re-check via has() after the mutation; matches the
contract any reasonable caller would follow.
The asio std_fenced_block atomic_thread_fence path generates TSan
"not supported" warnings during compile — those are asio's, not
ours, and don't affect runtime detection.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Property-based robustness test that drives long sequences of random
tool operations against EquipmentDataModel and verifies invariants +
persistence round-trip after every action. Replaces hand-written
state-pinning tests with a generative approach that explores
combinations no human author would think to write.
Action menu (28 weighted actions covering the full standard surface):
- PJ create / event / dequeue (E40)
- CJ create / event / delete (E94)
- Carrier create / id / slot (E87)
- Substrate create / location / proc (E90)
- Alarm set / clear / enable toggle (E5 §13)
- SVID updates (E30 §6.13)
- Define-report / link-event / enable (E30 §6.6)
- Exception post / recover / complete (E5 §9, S5F9-F18)
- Module event (E157)
- EPT event (E116)
- Spool enqueue / drain / force-toggle (E30 §6.22)
Every action is "adjusted": it picks a verb at random, then checks
state-machine legality before applying. A Pause is only fired on a
Processing PJ; a Recover only on a Posted exception; pj_dequeue
skips PJs bound to active CJs (mirrors E94's "can't dequeue
CJ-bound PJ" rule the fuzz itself discovered when the first run
flagged a CJ→missing-PJ reference).
Invariants checked every 64 ticks:
- Every tracked PJ exists in the store (size matches)
- Every CJ's prjobids all exist in PJ store
- No FSM in NoState sentinel
- EPT bucket total monotonically non-decreasing
- Defined reports' VIDs all exist
- Substrate / carrier counts match enumeration
Persistence round-trip every 500 ticks:
- Fresh shadow EquipmentDataModel loads from the same journal dir
- Diffs PJ + CJ states one-by-one + carrier/substrate/exception
counts against the live model
- Catches any "mutation didn't reach disk" or
"replay didn't reconstruct state correctly" bugs
Reproducibility:
- Each TEST_CASE uses a fixed seed (0x1, 0xdeadbeef, 0xfeedface,
0xc0ffee — 8000 ops total in the fast suite)
- World keeps a rolling 20-action trace, printed on invariant
violation so the failing sequence can be pasted into a targeted
regression test
- SECSGEM_ROBUSTNESS_SOAK=1 enables a 100k-tick soak case
(~3-5 minutes in Docker; not run by default)
The very first run found a real edge case: act_pj_dequeue removed
PJs that were bound to active CJs, leaving dangling refs. Fixed
the fuzz to filter; the underlying behavior is intentional (store
trusts the application to gate), but the fuzz now mirrors the
correct E94 contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The codebase has supported HSMS-GS since the original landing
(test_hsms_gs.cpp covers the wire-level Select.req-per-session
walk-list, the per-session Reject(EntityNotSelected) behaviour,
and session-routed data dispatch). But the documentation said
exactly one line about it ("Connection::add_session(device_id)
registers extra sessions on one TCP socket") and there was no
end-to-end test using the Server/Client API customers actually
build against.
INTEGRATION.md §7 is a new section showing the realistic pattern:
- Server-side: register the primary session via Server::Config,
then `add_session` for the second MES in the on_connection
callback. Per-session message handler + selected handler so
each MES gets its own router (or its own per-session data view
over a shared EquipmentDataModel).
- Active-mode: same `add_session` on the host-side Connection
for multi-tool fleet controllers.
- Equipment-initiated push: pick the session_id when sending
unsolicited primaries (S5F1, S6F11, S10F1).
- Pointer to the wire tests + the new integration test for
customers who want to see the failure modes.
tests/test_hsms_gs_integration.cpp drives two MES sessions
(device_id 1 + 2) through the Server/Client API end to end:
- Both sessions complete Select.req independently
- S1F1 sent on each session returns a distinct MDLN
("EQUIP-SESS-1" vs "EQUIP-SESS-2"), proving per-session
dispatch routes correctly
- Per-session router fires exactly once per session, no
cross-talk
Pre-existing §§8-10 in INTEGRATION.md got bumped to §§9-11 to
make room.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
README §3 promised a monitoring story ("aggregate into Prometheus via
a sidecar that polls the data model"). Nothing shipped. Customers
running a real fab without a metrics pipeline find out about T7
storms, spool blowups, and stalled CJs after their MES does — not
the position you want SRE in.
This commit ships:
- include/secsgem/metrics/prometheus.hpp: header-only. A Registry
(counters + gauges + HELP/TYPE descriptions, label-keyed,
mutex-guarded so updates from the io thread and scrape renders from
the same io serialize cleanly) plus a PrometheusServer (asio
acceptor, replies to any GET with the text-exposition rendering,
no auth — drop nginx in front for that).
- tests/test_metrics_prometheus.cpp: 3 cases / 19 assertions.
Render counter+gauge with labels, scrape via raw TCP and parse the
HTTP body, verify live updates land on subsequent scrapes.
- INTEGRATION.md §6.4: worked example that pairs the exporter with the
Connection + EquipmentDataModel hooks documented in §6.1/§6.2.
Shows the wrap-around-handler trick for message counters, a 5s
polling timer for gauges (spool depth, active alarms), and the
expected /metrics output.
Deliberately *not* shipped:
- A StandardMetrics helper that auto-wires everything — would force
a single hook owner per store, breaking customers who want
composable observers. Customers wire what they need; the registry
gives them counters + gauges + an HTTP endpoint, no policy.
- TLS / auth on the HTTP endpoint. Reverse-proxy territory.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Customer SREs and capacity planners had nothing to point at.
INTEGRATION.md asked the right questions ("how many tx/sec?"
"how much memory per active CJ?") but had no numbers.
secs_bench spins up an in-process passive equipment + active host
on an OS-allocated port, runs three canned workloads, and emits a
markdown table customers can capture and diff across commits:
- S1F1/F2 header-only round-trip — dispatch + framing baseline
- S1F3/F4 with N SVIDs — encode + decode throughput
- S6F11 push (W=0) — one-way emission ceiling
- PJ + CJ pair memory footprint — bytes per active job
Latency reports p50/p95/p99/max via std::nth_element over the
sample vector. RSS is read from /proc/self/statm on Linux,
mach_task_basic_info on macOS.
CLI: --requests / --concurrency / --svid-count / --store-pairs.
Default 20k req @ 16 concurrent.
BENCHMARKS.md checks in a reference run (Docker on M-series
macOS): ~140k req/s S1F1, ~79k req/s S1F3 with 32-SVID list,
~572k S6F11/s push, ~450 bytes per PJ+CJ pair. Three orders of
magnitude headroom over typical fab tool load.
The doc is explicit about what the bench does NOT measure (real
network, persistence I/O, TLS tunnel overhead, multi-session GS
dispatch) — customers should re-run on their target hardware.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
README §6 claimed bidirectional forward-compat for journal records.
Reality is narrower:
- ProcessJobStore (kVersion=2) and SubstrateStore (kVersion=2) accept
v1 records on replay — their loaders explicitly switch on the version
byte and treat the v2 trailer fields as empty when absent. This is
the actual upgrade path the README half-described.
- ControlJobStore, CarrierStore, LoadPortStore, ExceptionStore, and
SpoolStore use strict `header[1] != kVersion` rejection. A future
kVersion bump there without a matching loader-side dispatch would
silently nuke every replayed record. The README sold this as a
feature; it isn't yet.
This commit adds:
- tests/test_persistence_upgrade.cpp: five cases that craft journal
records byte-by-byte so format drift is caught (no codec round-trip
hiding the field layout). PJ v1 -> v2 read; PJ v1 rewrite stamps
current kVersion=2; PJ unknown future version rejected; Substrate
v1 read with empty history trailer; CJ + Carrier reject unknown
versions (tripwire for the strict-version stores).
- README §6: replaces the rosy "newer versions ignore unknown
trailers" claim with what's actually implemented — multi-version
reads on PJ + Substrate, strict equality elsewhere — and points
at the test as the contract anchor.
When the strict-version stores grow their own v2, the rejection
tests will need to flip to acceptance; the layout is right there in
the test so the edit is mechanical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
INTEGRATION.md §3 used to show a sensor-poll thread calling
model->svids.set_value() directly while the io_context thread reads
the same SVID for an inbound S1F3. That's a data race — there are
zero locks anywhere in EquipmentDataModel and there's no intention
to add them. The library is single-threaded by design; the doc was
just inviting trouble.
This commit makes the actual contract explicit:
- INTEGRATION.md §3: thread-safety callout box. All access must run
on the io_context that drives the HSMS connection. Sensor updates
from other threads marshal via asio::post(io.get_executor(), ...).
Same applies to set_*_change_handler callbacks (they fire on the
io_context thread; observers must be thread-safe or hand work off).
- README.md §3 (Monitoring & observability): added a paragraph noting
that hooks fire on the io_context thread, blocking I/O inside a
handler stalls the dispatcher, and metrics exporters must respect
the same contract.
- tests/test_thread_safety.cpp: two scenarios that exercise the
canonical pattern — N producer threads asio::post sensor updates
onto a worker-driven io_context; reads marshal back through the
io. Catches obvious regressions (e.g. someone adding a
"convenience" cross-thread mutator that bypasses the strand).
A passing run isn't proof of race-freedom under ThreadSanitizer —
it pins down the *pattern* customers should follow. TSan integration
is a separate workstream.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The E84StateMachine timers landed last commit but stayed theoretical —
arming was delivered via abstract callbacks the application had to
glue to a real clock. This commit ships the canonical glue:
- include/secsgem/gem/e84_asio_timers.hpp: header-only
E84AsioTimers wraps three asio::steady_timers, wires set_timer_handlers
on attach(), routes async_wait expiry back into fsm.on_timeout().
detach() cancels everything cleanly.
- tests/test_e84_asio_timers.cpp: four scenarios exercised through a
real asio::io_context with wall-clock timers — TA1 expiry,
signal-driven cancel before TA1 fires, TA3 expiry from the
Transferring state, and detach() halting further transitions.
These cover the integration the synthetic unit tests in
test_e84_timers.cpp can't reach.
- INTEGRATION.md §4.6: the vendor-side recipe — create the port,
set timeouts, make_shared<E84AsioTimers>(...)::attach(), feed signals
from your I/O bridge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>