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
2026-06-08 08:41:09 +02:00
2026-06-08 08:41:09 +02:00

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.

S
Description
No description provided
Readme 2.4 MiB
Languages
C++ 84.8%
Python 10.8%
Java 1.9%
Shell 1.4%
CMake 0.9%
Other 0.2%