An audit of doc code blocks against the real headers found APIs that do not exist in the codebase, presented as authoritative walkthroughs: - ch35 (dispatch): an entirely fabricated callback architecture — HostCommandRegistry::set_emit_ceid_handler, CommandOutcome, emit_ceids. Rewritten to the real Spec/Result/dispatch + the new set_handler hook. - ch13 (E30): wrong store names — EventStore/ReportStore -> EventReportSubscriptions, SvidStore -> StatusVariableStore, AlarmStore/AlarmDispatcher -> AlarmRegistry, ClockStore -> Clock, TerminalServiceStore -> (no store), in both the capability tables and the worked S2F33 example. - ch17 (E116): EptStore/seconds/bucket_ -> EptStateMachine/milliseconds/buckets_. - ch51 (extending): stale host-command handler -> the real set_handler signature. Verified clean by grep: no fabricated symbols remain in docs/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
35 — State machines and dispatch
← 34 Codec and SML | Back to index | Next: 36 Persistence, validation, metrics →
We have transport (chapter 33) delivering secs2::Messages
(chapter 34) into the application. We have stores (chapter 32)
holding the data. This chapter is the glue: how the Router
dispatches by (stream, function), how state machines compose with
stores, and how a Router handler typically looks.
gem::Router — the dispatch table
include/secsgem/gem/router.hpp:
class Router {
public:
using Handler = std::function<std::optional<s2::Message>(const s2::Message&)>;
Router& on(uint8_t stream, uint8_t function, Handler h);
Router& fallback(Handler h);
std::optional<s2::Message> dispatch(const s2::Message& msg) const;
};
A std::map<{stream, function}, Handler>. Each handler:
- Takes a
const s2::Message&(the inbound primary). - Returns a
std::optional<s2::Message>(the reply, ornulloptfor fire-and-forget primaries).
That's it. No middleware, no decorator chain, no pre/post hooks. Just a typed dispatch.
Registering handlers
// apps/secs_server.cpp — sketch
auto router = std::make_shared<gem::Router>();
router->on(1, 1, [model](const auto& m) {
// S1F1 Are You There — reply with model name + softrev.
return messages::s1f2(model->device.mdln, model->device.softrev);
});
router->on(1, 3, [model](const auto& m) {
// S1F3 — read SVID values.
auto svid_list = messages::parse_s1f3(m.body());
return messages::s1f4(model->svids.values(svid_list.value_or({})));
});
router->on(2, 41, [model](const auto& m) {
// S2F41 Host Command.
auto cmd = messages::parse_s2f41(m.body());
auto ack = model->commands.dispatch(cmd->rcmd, cmd->params);
return messages::s2f42(ack);
});
// ...one per S/F pair. apps/secs_server.cpp registers ~50.
The examples/pvd_tool/main.cpp §6 register 51 handlers in ~460
lines. Each handler is a few lines: parse the body, mutate or read
a store, build the reply.
What happens for unhandled primaries
// router.hpp dispatch()
auto it = handlers_.find({msg.stream, msg.function});
if (it != handlers_.end()) return it->second(msg);
if (fallback_) return fallback_(msg);
if (msg.reply_expected) return s2::Message(msg.stream, 0, false); // SxF0 Abort
return std::nullopt;
Three cases:
- Registered: handler runs.
- Fallback installed: fallback runs.
- Neither: if the message expects a reply, send
SxF0(Abort) per E5 convention. Otherwise silently drop.
S9 wiring
The transport layer (chapter 11) emits S9F3 / S9F5 for unhandled primaries. Router exposes the introspection:
bool has_handler(uint8_t stream, uint8_t function) const;
bool has_handler_for_stream(uint8_t stream) const;
And the wrapper that combines them with S9 emission:
template <typename EmitFn, typename HeaderProvider>
std::optional<s2::Message> dispatch_with_s9(
EmitFn emit_s9, HeaderProvider header_provider,
const s2::Message& msg) const {
if (!has_handler(msg.stream, msg.function)) {
if (auto mhead = header_provider()) {
const uint8_t f = has_handler_for_stream(msg.stream) ? 5 : 3;
emit_s9(f, *mhead);
}
}
return dispatch(msg);
}
Used by Connection::on_data_message → Router::dispatch_with_s9,
which calls back into Connection::emit_s9 for the actual S9
emission. Tested by
tests/test_s9_fallback.cpp (2
cases — unknown stream → S9F3, unknown function in known stream
→ S9F5).
How a typical handler looks end-to-end
S2F41 Host Command is the most-touched message in production —
worth tracing in full:
1. The codegen'd parser/builder
// build/generated/secsgem/gem/messages.hpp (auto-generated)
struct RemoteCommand {
std::string rcmd;
std::vector<std::pair<std::string, secs2::Item>> params;
};
inline std::optional<RemoteCommand> parse_s2f41(const secs2::Item& body) {
// ...auto-generated body walker...
}
inline secs2::Message s2f42(uint8_t hcack, /* per-param acks */) {
// ...auto-generated builder...
}
2. The Router registration
// apps/secs_server.cpp
router.on(2, 41, [model, emit_event, emit_alarm_set](const s2::Message& msg) {
auto cmd = gem::parse_s2f41(msg);
if (!cmd) // body didn't parse
return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid, {});
auto result = model->commands.dispatch(cmd->rcmd, cmd->params);
if (result.ack == gem::HostCmdAck::Accept) { // apply declared side effects
if (result.emit_ceid) emit_event(*result.emit_ceid);
if (result.set_alarm) emit_alarm_set(*result.set_alarm);
}
return gem::s2f42_host_command_ack(result.ack, {});
});
3. The store dispatch
// include/secsgem/gem/store/host_commands.hpp
class HostCommandRegistry {
public:
// Declarative default + optional side effects, loaded from equipment.yaml.
struct Spec { HostCmdAck ack; std::optional<uint32_t> emit_ceid, set_alarm; /* … */ };
struct Result { HostCmdAck ack; std::optional<uint32_t> emit_ceid, set_alarm; /* … */ };
// Application behaviour: runs real work and decides the ack (see §4).
using Handler = std::function<HostCmdAck(const std::string& rcmd,
const std::vector<CommandParameter>&)>;
void register_command(std::string rcmd, Spec spec); // wired from YAML
void set_handler(std::string rcmd, Handler h); // wired from application code
Result dispatch(const std::string& rcmd,
const std::vector<CommandParameter>& params) const {
auto it = by_rcmd_.find(rcmd);
if (it == by_rcmd_.end())
return {HostCmdAck::InvalidCommand, {}, {}};
HostCmdAck ack = it->second.ack; // declarative default
if (auto h = handlers_.find(rcmd); h != handlers_.end() && h->second)
ack = h->second(rcmd, params); // application code overrides it
return {ack, it->second.emit_ceid, it->second.set_alarm};
}
};
4. Behaviour: declarative default vs. application code
dispatch settles two things — what ack the host gets and what side
effects fire. They come from two layers:
Declarative (YAML). A row in equipment.yaml gives a command a static
ack plus optional emit_ceid / set_alarm. Those ride back on the
Result, and the Router handler (§2) applies them by calling the
emit_event / emit_alarm_set lambdas — which asio::post onto the
io_context and then build the S6F11 / S5F1. Fine for a fixed mapping
("FAULT always raises alarm 1").
Application behaviour (the hook). A static ack can't do anything — start a recipe, read the command's parameters, decide based on tool state. For that, register a handler. Its return value becomes the ack:
// Set up at startup, alongside register_command:
model->commands.set_handler("START",
[&](const std::string&, const std::vector<gem::CommandParameter>& params) {
if (tool.busy()) return gem::HostCmdAck::CannotDoNow; // reject
tool.run_recipe(find_param(params, "PPID")); // real work
return gem::HostCmdAck::Accept;
});
The same hook covers S2F41, S2F21, and S2F49, since all three call
dispatch. Because the Router applies declared side effects only on
Accept, a rejecting handler suppresses them for free; with no handler
the command stays purely declarative. This is the seam application code
— and the planned Python binding's @on("START") — uses to put real
behaviour behind a host command.
5. The wire
conn->send_data(s6f11) walks through secs2::encode →
hsms::Frame::encode → async_write to the socket. Host sees
the unsolicited S6F11.
The state-machine pattern
Every state machine in the codebase follows the same shape. Pick
ControlStateMachine:
// include/secsgem/gem/control_state.hpp
class ControlStateMachine {
public:
ControlStateMachine(ControlTransitionTable table);
ControlState state() const;
// Apply an event; returns the transition row (if any).
const ControlTransition* on_event(ControlEvent e);
// Observer for transitions.
using StateChangeHandler =
std::function<void(ControlState from, ControlState to, ControlEvent)>;
void set_state_change_handler(StateChangeHandler h);
};
Three properties:
- Pure data table (
ControlTransitionTable) decides what transitions exist. - Pure FSM (
ControlStateMachine) applies events against the table, updates state, emits the change. - Observer pattern — the EAP registers a change handler that does the wire-level work (fire CEID, emit S6F11, log to metrics).
This pattern repeats for:
ProcessJobStateMachine(E40)ControlJobStateMachine(E94)EptStateMachine(E116)ExceptionStateMachine(E5 §13)CarrierStateMachine(E87 — actually composes 3 sub-FSMs)LoadPortStateMachine(E87 — same)SubstrateStateMachine(E90 — same)ModuleStateMachine(E157)E84StateMachine(E84)CommunicationStateMachine(E30 §6.5)
Eleven FSMs. All follow the same shape. All testable in isolation without IO.
How FSM transitions cascade into CEIDs
A common need: "when PJ-1 transitions to Processing, fire CEID=ProcessStarted, which fires an S6F11 with linked reports."
The wiring is set up at startup in the EAP:
// apps/secs_server.cpp — sketch
model->process_jobs.set_state_change_handler(
[conn, model](const std::string& pjid,
ProcessJobState from, ProcessJobState to,
ProcessJobEvent ev) {
// Configured per-state CEID from data/equipment.yaml.
auto ceid = ceid_for_pj_state(to);
if (!ceid || !model->is_event_enabled(*ceid)) return;
auto reports = model->compose_reports_for(*ceid);
auto msg = build_s6f11(*ceid, reports);
deliver_or_spool(*conn, *model, std::move(msg));
});
deliver_or_spool is the spool-aware send: if the connection isn't
SELECTED (or the spool is in transmit-disabled mode), the message
queues into SpoolStore; otherwise it goes straight to the wire.
So the chain for "PJ-1 starts processing":
ProcessJobStore.apply(pjid, Start)
→ ProcessJobStateMachine.on_event(Start)
→ State: WaitingForStart → Processing
→ on_change handler fires
→ looks up CEID ProcessStarted
→ composes reports
→ builds S6F11 message
→ deliver_or_spool → connection or SpoolStore
→ on the wire (eventually)
Every step is independently testable. Tests at the wire level:
tests/test_wire_ceid_emission.cpp
(6 cases — every cascade from store mutation to socket bytes).
Where to go next
You've now seen everything that makes the runtime work. One chapter left in Part 3: the operational concerns — persistence, config validation, and metrics.