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>
secs-gem
A C++20 SECS-II / HSMS / GEM client + server toolchain, fully containerized.
The long-term vision is the "spec-as-data" stack described in
implementation_plan.md: the SEMI E30 behavioral
spec encoded as transition tables, from which a runtime, passive analyzer,
simulator, and conformance test generator are derived. The current code is
the Layer 0 + Layer 1 base — a working HSMS client/server, the SECS-II
codec, the E30 control state machine, and the GEM core capabilities (status
variables, equipment constants, dynamic event reporting, alarms, remote
control, clock, process programs, terminal services).
For a per-capability E5/E30/E37 audit see COMPLIANCE.md.
Quick start
Everything runs in Docker — no compiler or build tools on the host.
docker compose run --rm builder # configure + compile
docker compose run --rm tests # run unit tests (63 cases / 278 assertions)
docker compose up --no-deps server client # live two-container demo
Architecture
The "spec-as-data" first step: equipment capabilities and the E30 control
state machine are loaded from YAML at startup; the SECS dispatch is a
(stream, function) -> handler Router rather than an if-ladder.
┌──────────────────────────────────────────────────────────────┐
│ data/equipment.yaml data/control_state.yaml │
│ SVIDs, ECIDs, CEIDs, alarms, recipes, host commands; │
│ E30 control state transition table │
└──────────────────────┬───────────────────────────────────────┘
│ (loaded at startup)
▼
┌──────────────────────────────────────────────────────────────┐
│ app / demo │
│ apps/secs_server.cpp apps/secs_client.cpp │
│ uses gem::Router for SECS dispatch │
└────────────┬───────────────────────────┬─────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ secsgem::config loader.hpp YAML -> tables + data model │
│ secsgem::gem control_state (table-driven), data_model, │
│ messages (SxFy builders), router │
│ secsgem::hsms Connection (Asio), Header, Frame, Timers │
│ secsgem::secs2 Item, codec (encode/decode), Message │
└──────────────────────────────────────────────────────────────┘
Tree
secs-gem/
├── Dockerfile, docker-compose.yml # toolchain + demo
├── CMakeLists.txt
├── implementation_plan.md # the 7-layer spec-as-data roadmap
├── COMPLIANCE.md # per-capability E5/E30/E37 audit
├── data/
│ ├── equipment.yaml # SVIDs/ECIDs/CEIDs/alarms/recipes/commands
│ └── control_state.yaml # E30 transition table
├── include/secsgem/
│ ├── secs2/{item,codec,message}.hpp
│ ├── hsms/{header,connection}.hpp # header.hpp also holds Frame + Timers
│ ├── gem/{control_state,data_model,messages,router}.hpp
│ ├── config/loader.hpp
│ └── endpoint.hpp
├── src/{secs2,hsms,gem,config}/*.cpp + endpoint.cpp
├── apps/{secs_server,secs_client}.cpp
└── tests/test_*.cpp
Adding a capability without recompiling the server
# data/equipment.yaml — append a new SVID
svids:
- {id: 4, name: ChamberTemp, units: "C", type: U4, value: 25}
# data/equipment.yaml — append a new host command + side effect
host_commands:
- {name: VENT, ack: Accept, emit_ceid: 400, set_alarm: 2}
# data/control_state.yaml — append a new transition
transitions:
- {from: OnlineRemote, on: host_request_offline, to: EquipmentOffline, ack: Accept}
Restart the server; the new behaviour is live. The C++ code unchanged.
What's implemented
HSMS (E37)
SELECT/DESELECT/LINKTEST/SEPARATE/REJECT control messages, the 4-byte length-prefixed frame, the 10-byte header, the NOT-CONNECTED → SELECTED state machine, and the T3/T5/T6/T7/T8 timers. Active (client) and passive (server) modes. System-bytes based reply correlation.
SECS-II (E5)
A variant-based Item covering every standard format (List, ASCII,
Binary, Boolean, I1–I8, U1–U8, F4, F8), big-endian numerics, 1/2/3-byte
length encoding, and an to_sml() rendering for readable logs.
GEM (E30)
- Control state machine — 5 states (EquipmentOffline, AttemptOnline, HostOffline, OnlineLocal, OnlineRemote), operator actions, host requests with SEMI-mandated ACK codes, change-handler callback.
- Status variables — S1F3/F4, S1F11/F12.
- Equipment constants — S2F13/F14, S2F15/F16, S2F29/F30 + EAC enum.
- Clock — S2F17/F18, S2F31/F32 (16-char
YYYYMMDDhhmmsscc). - Establish Comms — S1F13/F14 +
CommAck. - Online/Offline — S1F15/F16 +
OfflineAck, S1F17/F18 +OnlineAck. - Remote control — S2F41/F42 + 7-value
HostCmdAck+ per-param CPACKs. - Dynamic event reporting — S2F33 Define Report (DRACK), S2F35 Link Event (LRACK), S2F37 Enable (ERACK), S6F11 emission, S6F12 ack.
- Alarms — S5F1/F2 emit + ACKC5, S5F3/F4 enable/disable, S5F5/F6 list, ALCD bit-7 set/cleared, lower-7 category.
- Process programs — S7F3/F4 send, S7F5/F6 request, S7F19/F20 list.
- Terminal services — S10F1/F2 host→equipment, S10F3/F4 equipment→host.
Not (yet) implemented
Spooling, S9 error stream, S1F19/F20 + S1F21/F22 documentation, multi-block, trace data collection (S2F23/F24, S6F1/F2), limits monitoring, material movement (E40), EC range validation, S5F7/F8 list-enabled-alarms, S6F15/F16 event-report request. See COMPLIANCE.md for the honest accounting.
Demo session
The demo client walks 17 distinct steps end-to-end. From the live log (condensed):
[host] -> Select.req [equip] <- Select.req
[host] <- Select.rsp (Ok) [equip] -> Select.rsp (Ok)
[host] == SELECTED == [equip] == SELECTED ==
[host] -> S1F13 W [equip] S1F13; replying S1F14 (COMMACK=0)
[host] -> S1F17 W [equip] control: HostOffline -> AttemptOnline
[equip] control: AttemptOnline -> OnlineRemote
[host] S1F18 ONLACK=0 [equip] -> S10F3 W (welcome)
[host] TERMINAL[0] Welcome from SECSGEM-SIM
[host] -> S1F11 W [equip] S1F11; replying S1F12 (status namelist)
[host] SVID 1 ControlState
[host] SVID 2 Clock
[host] SVID 3 EventsEnabled
[host] -> S1F3 W [equip] S1F3; replying S1F4 (3 values)
[host] -> S2F29 W [equip] -> S2F30 (2 EC entries)
[host] -> S2F33 W [equip] -> S2F34 (DRACK=0)
[host] -> S2F35 W [equip] -> S2F36 (LRACK=0)
[host] -> S2F37 W [equip] -> S2F38 (ERACK=0)
[host] -> S2F41 W START [equip] RCMD START; emit S6F11 CEID=300
[host] EVENT CEID=300 (1 reports)
[host] RPTID 1000: <A "OnlineRemote"> <A "2026060122162336"> <BOOLEAN T>
[host] -> S5F5 W [equip] -> S5F6 (2 alarms)
[host] -> S5F3 W [equip] -> S5F4 (ACKC5=0)
[host] -> S2F41 W FAULT [equip] RCMD FAULT triggers alarm 1
[equip] emit S5F1 alarm set ALID=1
[equip] emit S6F11 CEID=200
[host] ALARM SET ALID=1 cat=4 "Chiller Temp High"
[host] EVENT CEID=200 (1 reports)
[host] -> S7F19 W [equip] -> S7F20 (2 PPIDs)
[host] -> S7F5 W [equip] -> S7F6 RECIPE-A
[host] -> S10F1 W [equip] TERMINAL[0] Hello equipment!
[host] -> S1F15 W [equip] control: OnlineRemote -> HostOffline
[host] -> Separate.req [equip] <- Separate.req
[host] exiting
Build details
The toolchain image (Dockerfile) is Ubuntu 24.04 with g++-13, CMake,
Ninja, and libasio-dev. doctest is fetched via CMake FetchContent.
Build artifacts live in a named Docker volume (build:) so the host
filesystem stays clean.
Standalone Asio is used in header-only mode (ASIO_STANDALONE). No Boost
dependency.