af1a159c59
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>
339 lines
11 KiB
Markdown
339 lines
11 KiB
Markdown
# 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<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
|
|
|
|
```cpp
|
|
// 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
|
|
|
|
```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 <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`](../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<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
|
|
|
|
```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<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:
|
|
|
|
```cpp
|
|
// 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`:
|
|
|
|
```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(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:
|
|
|
|
```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)
|