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>
secs-gem
A C++20 SECS-II / HSMS / GEM client and server, fully containerised, with every behavioural rule encoded as YAML data (control state, equipment data dictionary, SECS-II message shapes, E40 process-job + E94 control-job state machines).
See COMPLIANCE.md for the per-capability E5/E30/E37/E40/E94 audit. Every GEM Fundamental and every GEM Additional capability that E30 binds to a concrete SECS-II message set is implemented and round-trip-tested; E40 PJ and E94 CJ lifecycle messages (S16F5/F6/F9/F11/F12/F13/F14/F27/F28 and S14F9/F10/F11/F12) are wired through the same data-driven runtime.
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 # 148 test cases / 794 assertions
docker compose up --no-deps server client # live two-container demo
Architecture
The project is "spec-as-data": the SEMI behavioural rules live in YAML; the C++ is the engine that reads them.
┌──────────────────────────────────────────────────────────────┐
│ data/ │
│ messages.yaml SECS-II message catalog │
│ control_state.yaml E30 §6.2 control transition table │
│ process_job_state.yaml E40 §6 PJ transition table │
│ control_job_state.yaml E94 §6 CJ transition table │
│ equipment.yaml SVIDs / DVIDs / ECIDs / CEIDs / │
│ alarms / recipes / commands / │
│ capabilities / spool / DVID list │
└──────────────────────┬───────────────────────────────────────┘
│ (loaded at startup, codegened at build)
▼
┌──────────────────────────────────────────────────────────────┐
│ tools/gen_messages.py │
│ reads messages.yaml -> emits generated/secsgem/gem/messages.hpp
└──────────────────────┬───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ apps/ │
│ secs_server.cpp passive equipment │
│ secs_client.cpp active host │
│ (both use gem::Router for dispatch) │
└────────────┬───────────────────────────┬─────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ secsgem::config loader.hpp: YAML -> tables + data model │
│ secsgem::gem ControlTransitionTable + ControlStateMachine,│
│ ProcessJobStateMachine (E40), │
│ ControlJobStateMachine (E94), │
│ EquipmentDataModel composing the stores: │
│ SVID, DVID, ECID, Event Subscriptions, │
│ Alarms, Recipes, Clock, Commands, Spool, │
│ Limits, Traces, ProcessJobs, ControlJobs │
│ Router (stream, function) -> handler │
│ generated messages.hpp (all 44 SxFy) │
│ secsgem::hsms Connection (Asio), Header, Frame, Timers │
│ Auto-emits S9F3/F5/F7/F9/F11 on protocol │
│ error conditions. │
│ secsgem::secs2 Item (variant), encode/decode, Message │
└──────────────────────────────────────────────────────────────┘
Tree
secs-gem/
├── Dockerfile, docker-compose.yml # toolchain + demo
├── CMakeLists.txt
├── implementation_plan.md # 7-layer spec-as-data roadmap
├── COMPLIANCE.md # per-capability E5/E30/E37 audit
├── data/
│ ├── messages.yaml # SECS-II message catalog
│ ├── control_state.yaml # E30 control state transitions
│ └── equipment.yaml # equipment data dictionary
├── tools/
│ └── gen_messages.py # codegen (messages.yaml -> .hpp)
├── include/secsgem/
│ ├── secs2/{item,codec,message}.hpp
│ ├── hsms/{header,connection}.hpp
│ ├── gem/{control_state,communication_state,data_model,messages_helpers,router}.hpp
│ ├── gem/{process_job_state,control_job_state}.hpp # E40 / E94 FSMs
│ ├── gem/store/ # one file per focused store:
│ │ ├── status_variables.hpp # SVIDs + DVIDs
│ │ ├── equipment_constants.hpp # ECIDs + EAC range validation
│ │ ├── event_reports.hpp # CEIDs + Reports + Links
│ │ ├── alarms.hpp # alarm registry
│ │ ├── recipes.hpp # process program store
│ │ ├── clock.hpp # 16-char TIME with offset
│ │ ├── host_commands.hpp # RCMD registry
│ │ ├── spool.hpp # spool queue + state
│ │ ├── limits.hpp # variable limit definitions
│ │ ├── trace.hpp # active trace configs
│ │ ├── process_jobs.hpp # E40 PJ collection
│ │ └── control_jobs.hpp # E94 CJ collection
│ ├── 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
The point of "spec-as-data" is that adding behaviour almost never requires a C++ change.
Add a new SVID
# data/equipment.yaml
svids:
- {id: 4, name: ChamberTemp, units: "C", type: U4, value: 25}
Restart the server. The host sees the new SVID via S1F11/F3 immediately.
Add a new host command with side effects
# data/equipment.yaml
host_commands:
- {name: VENT, ack: Accept, emit_ceid: 400, set_alarm: 2}
Add a new state transition
# data/control_state.yaml
transitions:
- {from: OnlineRemote, on: host_request_offline, to: EquipmentOffline, ack: Accept}
Add an E40 PJ transition (e.g. a tool-specific HOLD state)
# data/process_job_state.yaml
transitions:
- {from: Processing, on: hold, to: OnHold}
- {from: OnHold, on: resume, to: Processing}
(Adding a brand-new state requires bumping the ProcessJobState enum
in include/secsgem/gem/process_job_state.hpp too — it's the wire enum
that S16F9 carries.)
Add a new SECS-II message
# data/messages.yaml
- id: S6F30
stream: 6
function: 30
w: true
builder: s6f30_something
parser: parse_s6f30
body:
kind: list
struct_name: Something
fields:
- {name: field_a, shape: {kind: scalar, item_type: U4}}
- {name: field_b, shape: {kind: scalar, item_type: ASCII}}
docker compose run --rm builder regenerates messages.hpp. The
typed builder, parser, and struct definition appear automatically.
Demo
The two-container demo walks ~20 SECS transactions:
[host] -> Select.req [equip] <- Select.req
[host] == SELECTED == [equip] == SELECTED ==
[host] -> S1F13 W [equip] -> S1F14 (COMMACK=0)
[host] -> S1F17 W [equip] HostOffline -> AttemptOnline -> OnlineRemote
[host] -> S1F19 W [equip] -> S1F20 (12 capabilities)
[host] CCODE 1 Establish Communications
[host] CCODE 2 Dynamic Event Report Configuration
[host] ...
[host] CCODE 14 Spooling
[host] -> S1F21 W [equip] -> S1F22 (2 DVIDs)
[host] -> S1F11 W [equip] -> S1F12 (3 SVIDs)
[host] -> S1F3 W [equip] -> S1F4
[host] -> S2F29 W [equip] -> S2F30 (2 EC entries)
[host] -> S2F33/F35/F37 W [equip] subscribes CEIDs 200, 300
[host] -> S2F41 W START [equip] emit S6F11 CEID=300
[host] EVENT CEID=300 (1 reports)
[host] -> S5F5 W [equip] -> S5F6 (2 alarms)
[host] -> S5F3 W [equip] enables alarm 1
[host] -> S2F41 W FAULT [equip] emit S5F1 + S6F11 CEID=200
[host] ALARM SET ALID=1 cat=4 "Chiller Temp High"
[host] -> S2F41 W SPOOL_ON [equip] force_spool=true
[host] -> S2F41 W START spool: S6F11 CEID=300 queued
[host] -> S2F41 W SPOOL_OFF [equip] force_spool=false (depth=1)
[host] -> S6F23 W Transmit [equip] drains 1 spooled message
[host] EVENT CEID=300 (from spool, post-fact)
[host] -> S7F19 W [equip] -> S7F20 (2 PPIDs)
[host] -> S7F5 W RECIPE-A [equip] -> S7F6
[host] -> S16F11 W PJ-1 RECIPE-A [equip] -> S16F12 HCACK=0
[host] -> S14F9 W CJ-1 [PJ-1] [equip] -> S14F10 OBJACK=0
[host] -> S16F27 W CJ-1 CJSTART [equip] CJ Queued -> Executing
[host] <- S6F11 CEID=400 PJ Queued -> SettingUp
[host] <- S16F9 PJ-1 state=SettingUp PJ -> WaitingForStart
[host] <- S16F9 PJ-1 state=WaitingForStart PJ -> Processing
[host] <- S16F9 PJ-1 state=Processing PJ -> ProcessComplete
[host] <- S16F9 PJ-1 state=ProcessComplete CJ -> Completed
[host] <- S6F11 CEID=401
[host] -> S14F11 W CJ-1 [equip] -> S14F12 OBJACK=0
[host] -> S10F1 W [equip] TERMINAL[0] Hello equipment!
[host] -> S1F15 W [equip] OnlineRemote -> HostOffline
[host] -> Separate.req [equip] <- Separate.req
Build details
The toolchain image (Dockerfile) is Ubuntu 24.04 with g++-13, CMake,
Ninja, libasio-dev, libyaml-cpp-dev, and Python 3 for the codegen.
doctest is fetched via CMake FetchContent. Build artifacts live in a
named Docker volume so the host filesystem stays clean.
Standalone Asio is used in header-only mode (ASIO_STANDALONE). No
Boost dependency.