Files
secs-gem/docs/35_state_machines_and_dispatch.md
T
raphael af1a159c59 docs: bring the documentation up to the daemon/client era
A large gap had opened between the docs and the code: the README and
INTEGRATION guide did not mention the gRPC daemon or the Python client at
all (the entire vendor surface), ARCHITECTURE still described secs_server
as the ~1200-line canonical wiring example (it is a ~110-line thin main
over EquipmentRuntime), and test counts across six files were stale
(445/2753 -> 473/3087 core + the separate 125-assertion daemon suite).

- README: new "Integrating your tool (pick a tier)" section — Python
  client / any-language gRPC / embedded C++ — plus daemon tests and
  tools/run_interop.sh in the Testing section.
- ARCHITECTURE: layer diagram gains the vendor-surface and
  EquipmentRuntime/default_handlers tiers; stale wiring row fixed.
- INTEGRATION: three-tier chooser up front (this guide = the C++ tier).
- ch30 tour: secs_gemd + secs_gemd_tests in the binaries table.
- ch31: example alarm used a nonexistent `alcd:` field with bit 7 set
  (which the validator forbids) -> real `category:`/`name:` fields, and
  the roles: block documented.
- ch35: handler-location note now points at default_handlers.cpp's 15
  per-capability register_* functions.
- ch40: built-artifacts list + sample output counts.
- ch50: secsgem::gem runtime/default_handlers/handler_slot/name_index
  includes + new secsgem::daemon namespace section.
- PROOFS: test-count table gains the runtime/handlers/daemon row so the
  tally adds up; daemon suite noted. VERIFICATION/COMPLIANCE counts.
- interop/README: the one-command runner + the two daemon-track harnesses
  (daemon_interop, pyclient_interop).

Audited via a docs-vs-code sweep (the audit itself under-reported: it
validated counts textually; reality was 473/3087).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:18:31 +02:00

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, 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

// 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.  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

// 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:

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_messageRouter::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::encodehsms::Frame::encodeasync_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:

  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:

// 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.

Next: → 36 Persistence, validation, metrics