# 35 — State machines and dispatch ← [34 Codec and SML](34_codec_and_sml.md) | [Back to index](00_index.md) | Next: [36 Persistence, validation, metrics](36_persistence_validation_metrics.md) → We have transport (chapter 33) delivering `secs2::Message`s (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`](../include/secsgem/gem/router.hpp): ```cpp class Router { public: using Handler = std::function(const s2::Message&)>; Router& on(uint8_t stream, uint8_t function, Handler h); Router& fallback(Handler h); std::optional 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` (the reply, or `nullopt` for fire-and-forget primaries). That's it. No middleware, no decorator chain, no pre/post hooks. Just a typed dispatch. ### Registering handlers ```cpp // apps/secs_server.cpp — sketch auto router = std::make_shared(); 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. The default GEM set (56) lives in // src/gem/default_handlers.cpp, decomposed into 15 per-capability // register_* functions; register_default_handlers(runtime) wires them all. ``` 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 ```cpp // 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: 1. **Registered**: handler runs. 2. **Fallback installed**: fallback runs. 3. **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: ```cpp 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: ```cpp template std::optional 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`](../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 ```cpp // build/generated/secsgem/gem/messages.hpp (auto-generated) struct RemoteCommand { std::string rcmd; std::vector> params; }; inline std::optional 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 ```cpp // 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 ```cpp // 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 emit_ceid, set_alarm; /* … */ }; struct Result { HostCmdAck ack; std::optional emit_ceid, set_alarm; /* … */ }; // Application behaviour: runs real work and decides the ack (see §4). using Handler = std::function&)>; 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& 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: ```cpp // Set up at startup, alongside register_command: model->commands.set_handler("START", [&](const std::string&, const std::vector& 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`: ```cpp // 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 set_state_change_handler(StateChangeHandler h); }; ``` Three properties: 1. **Pure data table** (`ControlTransitionTable`) decides what transitions exist. 2. **Pure FSM** (`ControlStateMachine`) applies events against the table, updates state, emits the change. 3. **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: ```cpp // 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`](../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. Next: [→ 36 Persistence, validation, metrics](36_persistence_validation_metrics.md)