refactor(gem): decompose default handlers per GEM capability + YAML role bindings

register_default_handlers was a relocated app main(): one 1086-line function,
all-or-nothing. It is now 15 per-capability registration functions along the
lines GEM itself defines (S1F19): identification, equipment constants, clock,
event reports, remote commands, trace/limits, spooling, alarms, exceptions,
material tracking (E90/E116/E157), carriers (E87), recipes, object services
(E39), jobs (E40/E94), terminal services. A sensor-class tool registers three
functions instead of carrying carrier/job handlers it doesn't have;
register_default_handlers composes all 15. Each function derives exactly the
runtime aliases its handlers use (generated programmatically from the moved
bodies with boundary/substitution guards — zero hand-retyping).

Magic constants are gone: the control-state/clock SVIDs (were hardcoded 1/2)
and the CJ Executing/Completed CEIDs (were 400/401) now come from a "roles:"
block in equipment.yaml via EquipmentDescriptor, with historical defaults
when absent, loader parsing, and validation (CEID roles must name declared
events). The coupling is now visible in ONE file instead of silently split
between YAML and C++ — the exact drift class this repo's spec-as-data
philosophy exists to kill.

Tests: capability subsetting, role-driven SVID refresh via S1F3, roles
loader (shipped/custom/absent). Battery: core 473/3087 incl. the 53-handler
conformance sweep, daemon 125/125, live GEM300 demo (client exit 0), daemon
interop 20/20 vs secsgem-py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:43:41 +02:00
parent cf230d4119
commit 912304966f
9 changed files with 822 additions and 527 deletions
+9
View File
@@ -93,3 +93,12 @@ spool:
# CEID emitted automatically whenever the control state machine transitions
# (i.e. on every change-handler call). Set to null to disable.
emit_on_control_change: 100
# Role bindings: which of the ids declared above the engine's built-in
# behaviours target. Explicit here so the coupling is visible in ONE file
# instead of constants in C++ that silently had to match.
roles:
control_state_svid: 1 # refreshed with the control-state name on S1F3
clock_svid: 2 # refreshed with the clock string on S1F3
cj_executing_ceid: 400 # fired when a control job enters Executing
cj_completed_ceid: 401 # fired when a control job enters Completed
+16 -8
View File
@@ -42,9 +42,11 @@ host and stays conformant while the tool software restarts/upgrades/crashes.
-~~**Alarms have no name key.**~~ Optional `name:` added to the alarm
config (loader + validator + shipped equipment.yaml); daemon RPCs accept
the name or the stringified ALID.
-**`pvd_tool` predates the behaviour hook.** It still hard-codes
`if (rcmd=="START") recipe->start(...)` in a router handler. Migrate it to
`commands.set_handler` so the flagship example showcases the intended seam.
-**`pvd_tool` predates the behaviour hook AND the runtime.** It still
hard-codes START behaviour in a router handler and hand-wires its own
main(). Migrate it to EquipmentRuntime + per-capability registration +
`commands.set_handler` so the flagship example showcases the intended
integration shape. (Phase C item 9.)
-~~**Interop harnesses are manual.**~~ `tools/run_interop.sh` runs all nine
validation steps with one command (verified green); CI lanes added, pending
first-push verification (Phase 0 item 2).
@@ -106,11 +108,17 @@ debts tax every later phase, and the most valuable tests aren't automated.
drives 53 of the 56 handlers through `router.dispatch` (236 assertions).
Golden frames: S1F13, S5F1, and a composed S6F11, all hand-computed from
E5 rules (external pins, not codec-derived).
5. **Decompose `register_default_handlers` per GEM capability** (it is a
relocated main(), not a designed component) and replace magic constants
(SVIDs 1/2 `refresh()`, CEIDs 400/401) with YAML role bindings
(`control_state_svid:`, `cj_executing_ceid:` …). Gradual; aligns with the
capability structure GEM itself defines (S1F19) and enables vendor subsetting.
5. **Decomposed `register_default_handlers` into 15 per-capability
functions** (identification, ECs, clock, event reports, remote commands,
trace/limits, spooling, alarms, exceptions, material tracking, carriers,
recipes, object services, jobs, terminal) — vendors register only what
their equipment is; `register_default_handlers` = all 15. Magic constants
replaced by YAML **role bindings** (`roles:` block — control_state_svid,
clock_svid, cj_executing_ceid, cj_completed_ceid) parsed into the
descriptor with historical defaults, validated (CEID roles must be
declared). Tested: subset registration, role-driven SVID refresh, roles
loader (present/custom/absent); full battery green (473/3087 core incl.
the 53-handler sweep, live GEM300 demo, 20-check daemon interop).
6.**Standardize the mutable-read pattern**`EquipmentRuntime::read_sync`
(post-to-io + future with deadline; nullopt => UNAVAILABLE at the RPC edge).
Precedent set by `GetVariables`; every future mutable read copies it.
+9
View File
@@ -37,6 +37,15 @@ struct EquipmentDescriptor {
std::string equipment_type; // S1F20 EQPTYP
std::vector<std::pair<uint8_t, std::string>> capabilities; // (CCODE, CDESC)
std::optional<uint32_t> emit_on_control_change;
// Role bindings (`roles:` in equipment.yaml): which configured ids the
// engine's built-in behaviours are wired to. Replaces magic constants that
// silently had to match the YAML. Defaults preserve the historical wiring
// for configs without a roles block.
uint32_t control_state_svid = 1; // SVID refreshed with the control state
uint32_t clock_svid = 2; // SVID refreshed with the clock string
uint32_t cj_executing_ceid = 400; // CEID fired when a CJ enters Executing
uint32_t cj_completed_ceid = 401; // CEID fired when a CJ enters Completed
};
// Loads data/equipment.yaml into the given data model and returns the
+30 -8
View File
@@ -4,15 +4,37 @@
namespace secsgem::gem {
// Registers the full default GEM behaviour onto a runtime: every SECS message
// handler (S1/S2/S3/S5/S6/S7/S10/S14/S16) on its Router, plus the state-change
// emitters (control state, process/control jobs, exceptions, substrates,
// modules) on its stores. Shared by the `secs_server` app and the gRPC daemon
// so both speak byte-identical GEM — the protocol behaviour lives here, once.
// The default GEM behaviour, decomposed along the capability lines GEM
// itself defines (the S1F19 compliance list): each function registers one
// capability's message handlers and/or state-change emitters onto a runtime.
// Equipment implementing a subset of GEM (a sensor with no carriers, a
// recipe-less tool) registers only what it is; register_default_handlers
// registers everything and is what `secs_server` and `secs_gemd` use.
//
// Call once after constructing the runtime and before run(). Application
// behaviour (host-command callbacks via on_command, sensor value updates) is
// layered on top by the caller.
// All functions are idempotent-per-slot (later registration replaces the
// Router entry / primary handler slot) and must be called before run().
// Application behaviour (host-command callbacks via on_command, sensor value
// updates) is layered on top by the caller.
//
// The ids the built-ins target (control-state/clock SVIDs, CJ state CEIDs)
// come from the config's `roles:` block — see EquipmentDescriptor.
void register_identification(EquipmentRuntime& R); // S1 + E30 control state
void register_equipment_constants(EquipmentRuntime& R); // S2F13/15/29
void register_clock(EquipmentRuntime& R); // S2F17/31
void register_event_reports(EquipmentRuntime& R); // S2F33/35/37, S6F5/15/19/21
void register_remote_commands(EquipmentRuntime& R); // S2F21/41/49
void register_trace_and_limits(EquipmentRuntime& R); // S2F23/45/47
void register_spooling(EquipmentRuntime& R); // S2F43, S6F23
void register_alarms(EquipmentRuntime& R); // S5F3/5/7
void register_exceptions(EquipmentRuntime& R); // S5F9/11/15 emitters, S5F13/17
void register_material_tracking(EquipmentRuntime& R); // E90/E116/E157 emitters
void register_carriers(EquipmentRuntime& R); // E87: S3F17/19/25/27
void register_recipes(EquipmentRuntime& R); // S7F1/3/5/17/19
void register_object_services(EquipmentRuntime& R); // E39: S14F1/3
void register_jobs(EquipmentRuntime& R); // E40/E94: S14F9/11, S16Fxx
void register_terminal_services(EquipmentRuntime& R); // S10F1/3/5
// Everything above, in one call.
void register_default_handlers(EquipmentRuntime& R);
} // namespace secsgem::gem
+12
View File
@@ -147,6 +147,18 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
if (!e.IsNull()) desc.emit_on_control_change = static_cast<uint32_t>(e.as<int>());
}
// Role bindings: which configured ids the built-in behaviours target.
if (auto roles = root["roles"]) {
if (auto n = roles["control_state_svid"])
desc.control_state_svid = static_cast<uint32_t>(n.as<int>());
if (auto n = roles["clock_svid"])
desc.clock_svid = static_cast<uint32_t>(n.as<int>());
if (auto n = roles["cj_executing_ceid"])
desc.cj_executing_ceid = static_cast<uint32_t>(n.as<int>());
if (auto n = roles["cj_completed_ceid"])
desc.cj_completed_ceid = static_cast<uint32_t>(n.as<int>());
}
if (auto caps = root["capabilities"]) {
for (const auto& c : caps) {
desc.capabilities.emplace_back(static_cast<uint8_t>(c["code"].as<int>()),
+24
View File
@@ -353,6 +353,30 @@ void validate_equipment_block(YAML::Node& root, Sink& sink) {
}
}
}
// Role bindings: optional; each must be an in-range id, and the CEID
// roles must point at declared collection events (the SVID roles are
// checked loosely — built-ins write them, so a missing SVID is a
// warning-grade misconfig surfaced at runtime by set_value's no-op).
if (auto roles = root["roles"]) {
for (const char* key : {"control_state_svid", "clock_svid"}) {
if (auto n = roles[key])
as_int_in_range<uint32_t>(n, sink, std::string("roles.") + key, 0,
UINT32_MAX);
}
for (const char* key : {"cj_executing_ceid", "cj_completed_ceid"}) {
if (auto n = roles[key]) {
auto v = as_int_in_range<uint32_t>(n, sink, std::string("roles.") + key,
0, UINT32_MAX);
if (v && !ceids.count(*v)) {
sink.error(std::string("roles.") + key,
"CEID " + std::to_string(*v) +
" not declared in `ceids` section",
n);
}
}
}
}
}
YAML::Node try_load(const std::string& path, Sink& sink) {
File diff suppressed because it is too large Load Diff
+28
View File
@@ -71,3 +71,31 @@ TEST_CASE("register_default_handlers: unknown command is rejected, hook not invo
CHECK(reply->stream == 2);
CHECK(reply->function == 42); // still an S2F42, carrying an error HCACK
}
TEST_CASE("capability registration is subsettable (the point of the decomposition)") {
gem::EquipmentRuntime rt(test_config());
gem::register_identification(rt);
gem::register_terminal_services(rt);
// What we registered answers...
CHECK(rt.router().has_handler(1, 1)); // S1F1
CHECK(rt.router().has_handler(1, 13)); // S1F13
CHECK(rt.router().has_handler(10, 1)); // S10F1
// ...and what we didn't, doesn't: a sensor-class tool with no remote
// commands, jobs, or carriers simply never registers them.
CHECK_FALSE(rt.router().has_handler(2, 41)); // remote commands
CHECK_FALSE(rt.router().has_handler(16, 11)); // E40 jobs
CHECK_FALSE(rt.router().has_handler(3, 17)); // E87 carriers
}
TEST_CASE("role bindings drive the CJ state-change CEIDs and SVID refresh") {
gem::EquipmentRuntime rt(test_config());
gem::register_default_handlers(rt);
// The shipped roles block binds control_state_svid: 1 — S1F3 refreshes it.
auto reply = rt.router().dispatch(gem::s1f3_selected_status_request({1}));
REQUIRE(reply.has_value());
auto sv = rt.model().svids.value(1);
REQUIRE(sv.has_value());
CHECK(sv->as_ascii() == std::string("HostOffline")); // initial control state
}
+35
View File
@@ -166,3 +166,38 @@ TEST_CASE("alarm optional name: parsed when present, empty when absent") {
CHECK(m2.alarms.get(9)->name.empty());
std::filesystem::remove(path);
}
TEST_CASE("roles block: parsed when present, defaults when absent") {
gem::EquipmentDataModel m;
auto desc = config::load_equipment(SECSGEM_DATA_DIR "/equipment.yaml", m);
CHECK(desc.control_state_svid == 1);
CHECK(desc.clock_svid == 2);
CHECK(desc.cj_executing_ceid == 400);
CHECK(desc.cj_completed_ceid == 401);
const auto path = std::filesystem::temp_directory_path() / "roles_custom.yaml";
{
std::ofstream f(path);
f << "device: {id: 0, model_name: M, software_rev: R}\n"
"roles: {control_state_svid: 11, clock_svid: 12,\n"
" cj_executing_ceid: 910, cj_completed_ceid: 911}\n";
}
gem::EquipmentDataModel m2;
auto d2 = config::load_equipment(path.string(), m2);
CHECK(d2.control_state_svid == 11);
CHECK(d2.clock_svid == 12);
CHECK(d2.cj_executing_ceid == 910);
CHECK(d2.cj_completed_ceid == 911);
std::filesystem::remove(path);
const auto path2 = std::filesystem::temp_directory_path() / "roles_absent.yaml";
{
std::ofstream f(path2);
f << "device: {id: 0, model_name: M, software_rev: R}\n";
}
gem::EquipmentDataModel m3;
auto d3 = config::load_equipment(path2.string(), m3);
CHECK(d3.control_state_svid == 1); // historical defaults preserved
CHECK(d3.cj_completed_ceid == 401);
std::filesystem::remove(path2);
}