From 912304966ff6d8f114bb58a4e9e351073e19896c Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Wed, 10 Jun 2026 22:43:41 +0200 Subject: [PATCH] refactor(gem): decompose default handlers per GEM capability + YAML role bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- data/equipment.yaml | 9 + docs/DAEMON_ROADMAP.md | 24 +- include/secsgem/config/loader.hpp | 9 + include/secsgem/gem/default_handlers.hpp | 38 +- src/config/loader.cpp | 12 + src/config/validate.cpp | 24 + src/gem/default_handlers.cpp | 1170 ++++++++++++---------- tests/test_default_handlers.cpp | 28 + tests/test_loader.cpp | 35 + 9 files changed, 822 insertions(+), 527 deletions(-) diff --git a/data/equipment.yaml b/data/equipment.yaml index 05e2721..429056d 100644 --- a/data/equipment.yaml +++ b/data/equipment.yaml @@ -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 diff --git a/docs/DAEMON_ROADMAP.md b/docs/DAEMON_ROADMAP.md index fbacba1..a47d137 100644 --- a/docs/DAEMON_ROADMAP.md +++ b/docs/DAEMON_ROADMAP.md @@ -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. diff --git a/include/secsgem/config/loader.hpp b/include/secsgem/config/loader.hpp index b31a245..3387fea 100644 --- a/include/secsgem/config/loader.hpp +++ b/include/secsgem/config/loader.hpp @@ -37,6 +37,15 @@ struct EquipmentDescriptor { std::string equipment_type; // S1F20 EQPTYP std::vector> capabilities; // (CCODE, CDESC) std::optional 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 diff --git a/include/secsgem/gem/default_handlers.hpp b/include/secsgem/gem/default_handlers.hpp index a9f7e22..14af8bf 100644 --- a/include/secsgem/gem/default_handlers.hpp +++ b/include/secsgem/gem/default_handlers.hpp @@ -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 diff --git a/src/config/loader.cpp b/src/config/loader.cpp index 6d51644..727a50b 100644 --- a/src/config/loader.cpp +++ b/src/config/loader.cpp @@ -147,6 +147,18 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo if (!e.IsNull()) desc.emit_on_control_change = static_cast(e.as()); } + // 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(n.as()); + if (auto n = roles["clock_svid"]) + desc.clock_svid = static_cast(n.as()); + if (auto n = roles["cj_executing_ceid"]) + desc.cj_executing_ceid = static_cast(n.as()); + if (auto n = roles["cj_completed_ceid"]) + desc.cj_completed_ceid = static_cast(n.as()); + } + if (auto caps = root["capabilities"]) { for (const auto& c : caps) { desc.capabilities.emplace_back(static_cast(c["code"].as()), diff --git a/src/config/validate.cpp b/src/config/validate.cpp index 92972bf..1258fef 100644 --- a/src/config/validate.cpp +++ b/src/config/validate.cpp @@ -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(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(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) { diff --git a/src/gem/default_handlers.cpp b/src/gem/default_handlers.cpp index 1e1732c..2253261 100644 --- a/src/gem/default_handlers.cpp +++ b/src/gem/default_handlers.cpp @@ -26,30 +26,30 @@ namespace s2 = secsgem::secs2; namespace gem = secsgem::gem; namespace { -constexpr uint32_t kSvidControlState = 1; -constexpr uint32_t kSvidClock = 2; -void refresh(gem::EquipmentDataModel& m, const gem::ControlStateMachine& sm) { - m.svids.set_value(kSvidControlState, s2::Item::ascii(gem::control_state_name(sm.state()))); - m.svids.set_value(kSvidClock, s2::Item::ascii(m.clock.current_time_string())); +// Refresh the role-bound SVIDs that mirror engine state (control state + +// clock). The ids come from the config's `roles:` block via the descriptor — +// no more constants in C++ that silently had to match equipment.yaml. +void refresh(gem::EquipmentDataModel& m, const gem::ControlStateMachine& sm, + uint32_t control_state_svid, uint32_t clock_svid) { + m.svids.set_value(control_state_svid, + s2::Item::ascii(gem::control_state_name(sm.state()))); + m.svids.set_value(clock_svid, + s2::Item::ascii(m.clock.current_time_string())); } } // namespace -void gem::register_default_handlers(gem::EquipmentRuntime& R) { +// S1 identification, comms, and the E30 control-state behaviour: +// S1F1/13/15/17/19 plus the status/CE namelists and value polls +// (S1F3/11/21/23), with the role-bound control-state/clock SVID refresh. +void gem::register_identification(gem::EquipmentRuntime& R) { auto logfn = [&R](const std::string& m) { R.log(m); }; - auto model = R.model_ptr(); auto sm = R.control_ptr(); const auto& desc = R.descriptor(); - auto& io = R.io(); auto& router = R.router(); - auto active_conn = R.active_conn(); - auto deliver_or_spool = [&R](s2::Message msg, const std::string& what) { - return R.deliver_or_spool(std::move(msg), what); - }; auto emit_event = [&R](uint32_t ceid) { R.emit_event(ceid); }; - auto emit_alarm_set = [&R](uint32_t alid) { R.set_alarm(alid); }; sm->set_state_change_handler( [logfn, emit_event, desc](gem::ControlState from, gem::ControlState to, gem::ControlEvent ev) { @@ -63,76 +63,486 @@ void gem::register_default_handlers(gem::EquipmentRuntime& R) { // Emit S16F9 PRJobAlert (E40-0705 §10.3). Equipment-initiated; the spec // is silent on reply expectation, so we send it as a primary one-way. - auto emit_pj_alert = [&io, model, logfn, deliver_or_spool]( - const std::string& prjobid, - gem::ProcessJobState state) { - asio::post(io, [model, logfn, deliver_or_spool, prjobid, state]() { - const auto* pj = model->process_jobs.get(prjobid); - if (pj && !pj->alert_enabled) return; - logfn("emit S16F9 PJ=" + prjobid + " state=" + - gem::process_job_state_name(state)); - deliver_or_spool(gem::s16f9_pr_job_alert(prjobid, state), - "S16F9 PJ=" + prjobid); - }); - }; - - // PJ state-change handler: log + emit S16F9 (skip the synthetic - // NoState->Queued so we don't alert on a freshly-created PJ that's - // still being acked). - model->process_jobs.set_state_change_handler( - [logfn, emit_pj_alert](const std::string& prjobid, - gem::ProcessJobState from, - gem::ProcessJobState to, - gem::ProcessJobEvent trig) { - logfn(std::string("PJ ") + prjobid + ": " + - gem::process_job_state_name(from) + " -> " + - gem::process_job_state_name(to) + " (" + - gem::process_job_event_name(trig) + ")"); - if (from != gem::ProcessJobState::NoState) emit_pj_alert(prjobid, to); - }); - - // CEIDs the equipment.yaml is expected to register for CJ state - // changes (best-effort: if missing they're silently no-ops via the - // existing CEID-not-enabled guard in emit_event). - constexpr uint32_t kCeidCJExecuting = 400; - constexpr uint32_t kCeidCJCompleted = 401; - - model->control_jobs.set_state_change_handler( - [logfn, emit_event](const std::string& ctljobid, - gem::ControlJobState from, - gem::ControlJobState to, - gem::ControlJobEvent trig) { - logfn(std::string("CJ ") + ctljobid + ": " + - gem::control_job_state_name(from) + " -> " + - gem::control_job_state_name(to) + " (" + - gem::control_job_event_name(trig) + ")"); - if (to == gem::ControlJobState::Executing) emit_event(kCeidCJExecuting); - if (to == gem::ControlJobState::Completed) emit_event(kCeidCJCompleted); - }); - - // Drive the contained PJs through the demo lifecycle when the host - // tells the CJ to start. Real equipment would step PJs one at a time - // as material arrives; our simulator cascades through every legal - // state so the wire trace exercises the whole FSM. - auto run_cj_lifecycle = [&io, model, logfn](const std::string& ctljobid) { - asio::post(io, [model, logfn, ctljobid]() { - auto* cj = model->control_jobs.get(ctljobid); - if (!cj) return; - // CJ promotion path: Queued -> Selected -> WaitingForStart -> Executing. - model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::Select); - model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::SetupComplete); - model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::Start); - // Cascade every contained PJ through to ProcessComplete. - for (const auto& pjid : cj->prjobids) { - model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Select); - model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::SetupComplete); - model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Start); - model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::ProcessComplete); + router.on(1, 1, [desc, logfn](const s2::Message&) { + logfn("S1F1 -> S1F2"); + return gem::s1f2_on_line_data(desc.model_name, desc.software_rev); + }); + router.on(1, 3, [model, sm, desc, logfn](const s2::Message& msg) -> std::optional { + refresh(*model, *sm, desc.control_state_svid, desc.clock_svid); + auto svids = gem::parse_s1f3(msg); + if (!svids) return s2::Message(1, 0, false); + std::vector> values; + if (svids->empty()) { + for (const auto& sv : model->svids.all()) values.push_back(sv.value); + } else { + for (auto id : *svids) { + auto sv = model->svids.get(id); + values.push_back(sv ? std::optional(sv->value) : std::nullopt); + } + } + logfn("S1F3 -> S1F4 (" + std::to_string(values.size()) + " values)"); + return gem::s1f4_selected_status_data(values); + }); + router.on(1, 11, [model, logfn](const s2::Message&) { + std::vector rows; + for (const auto& sv : model->svids.all()) + rows.push_back({sv.id, sv.name, sv.units}); + logfn("S1F11 -> S1F12 (namelist, " + std::to_string(rows.size()) + ")"); + return gem::s1f12_status_namelist_data(rows); + }); + router.on(1, 13, [desc, logfn](const s2::Message&) { + logfn("S1F13 -> S1F14"); + return gem::s1f14_establish_comms_ack(gem::CommAck::Accept, + {desc.model_name, desc.software_rev}); + }); + router.on(1, 15, [sm, logfn](const s2::Message&) { + auto ack = sm->on_host_request_offline(); + logfn("S1F15 -> S1F16 OFLACK=" + std::to_string(static_cast(ack))); + return gem::s1f16_offline_ack(ack); + }); + router.on(1, 17, [sm, logfn](const s2::Message&) { + auto ack = sm->on_host_request_online(); + logfn("S1F17 -> S1F18 ONLACK=" + std::to_string(static_cast(ack))); + return gem::s1f18_online_ack(ack); + }); + router.on(1, 19, [desc, logfn](const s2::Message&) { + std::vector caps; + for (const auto& c : desc.capabilities) caps.push_back({c.first, c.second}); + logfn("S1F19 -> S1F20 (" + std::to_string(caps.size()) + " capabilities)"); + return gem::s1f20_get_gem_compliance_data(desc.software_rev, desc.equipment_type, caps); + }); + router.on(1, 21, [model, logfn](const s2::Message&) { + std::vector rows; + for (const auto& dv : model->dvids.all()) + rows.push_back({dv.id, dv.name, dv.units}); + logfn("S1F21 -> S1F22 (" + std::to_string(rows.size()) + " DVIDs)"); + return gem::s1f22_data_variable_namelist_data(rows); + }); + // S1F23 — Collection Event Namelist Request. Empty CEID list means + // "every CEID in the catalog"; otherwise we filter to the requested + // set and silently skip unknown CEIDs (per SEMI E5: "all unidentified + // are returned in S1F24 with an empty VID list"). + router.on(1, 23, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s1f23(msg); + std::vector rows; + if (req && req->empty()) { + for (const auto& e : model->events.all_events()) + rows.push_back({e.id, e.name, model->events.vids_for(e.id)}); + } else if (req) { + for (auto id : *req) { + if (auto info = model->events.event_info(id)) { + rows.push_back({id, info->name, model->events.vids_for(id)}); + } else { + rows.push_back({id, "", {}}); + } + } + } + logfn("S1F23 -> S1F24 (" + std::to_string(rows.size()) + " CEIDs)"); + return gem::s1f24_collection_event_namelist_data(rows); + }); + +} + +// S2 equipment constants: S2F13/F15 read/write + S2F29 namelist. +void gem::register_equipment_constants(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& router = R.router(); + + router.on(2, 13, [model, logfn](const s2::Message& msg) -> std::optional { + auto ids = gem::parse_u4_list_body(msg); + if (!ids) return s2::Message(2, 0, false); + std::vector values; + for (auto id : *ids) { + auto ec = model->ecids.get(id); + values.push_back(ec ? ec->value : s2::Item::list({})); + } + logfn("S2F13 -> S2F14 (" + std::to_string(values.size()) + " values)"); + return gem::s2f14_ec_data(values); + }); + router.on(2, 15, [model, logfn](const s2::Message& msg) { + auto sets = gem::parse_s2f15(msg); + auto eac = gem::EquipmentAck::Accept; + if (!sets) eac = gem::EquipmentAck::Denied_OutOfRange; + else + for (const auto& s : *sets) { + auto r = model->ecids.set_value(s.ecid, s.value); + if (r != gem::EquipmentAck::Accept) eac = r; + } + logfn("S2F15 -> S2F16 EAC=" + std::to_string(static_cast(eac))); + return gem::s2f16_ec_ack(eac); + }); + router.on(2, 29, [model, logfn](const s2::Message& msg) { + auto ids = gem::parse_u4_list_body(msg); + std::vector ecs; + if (ids && ids->empty()) ecs = model->ecids.all(); + else if (ids) + for (auto id : *ids) { + auto ec = model->ecids.get(id); + if (ec) ecs.push_back(*ec); + } + std::vector rows; + for (const auto& ec : ecs) + rows.push_back({ec.id, ec.name, ec.min_str, ec.max_str, "", ec.units}); + logfn("S2F29 -> S2F30 (" + std::to_string(rows.size()) + " ECs)"); + return gem::s2f30_ec_namelist_data(rows); + }); +} + +// Clock: S2F17 date-time request + S2F31 date-time set. +void gem::register_clock(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& router = R.router(); + + router.on(2, 17, [model, logfn](const s2::Message&) { + logfn("S2F17 -> S2F18 (clock)"); + return gem::s2f18_date_time_data(model->clock.current_time_string()); + }); + router.on(2, 31, [model, logfn](const s2::Message& msg) { + auto t = gem::parse_s2f31(msg); + auto ack = t ? model->clock.set_time_string(*t) : gem::TimeAck::Error; + logfn("S2F31 -> S2F32 TIACK=" + std::to_string(static_cast(ack))); + return gem::s2f32_date_time_ack(ack); + }); +} + +// Dynamic event reports: S2F33/35/37 configuration plus the host-initiated +// readbacks S6F15/19/21 and the S6F5 multi-block inquire. +void gem::register_event_reports(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& router = R.router(); + + router.on(2, 33, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s2f33(msg); + auto ack = gem::DefineReportAck::InvalidFormat; + if (req) { + std::vector>> rows; + rows.reserve(req->reports.size()); + for (const auto& r : req->reports) rows.emplace_back(r.rptid, r.vids); + ack = model->define_reports(rows); + } + logfn("S2F33 -> S2F34 DRACK=" + std::to_string(static_cast(ack))); + return gem::s2f34_define_report_ack(ack); + }); + router.on(2, 35, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s2f35(msg); + auto ack = gem::LinkEventAck::InvalidFormat; + if (req) { + std::vector>> rows; + rows.reserve(req->links.size()); + for (const auto& l : req->links) rows.emplace_back(l.ceid, l.rptids); + ack = model->link_event_reports(rows); + } + logfn("S2F35 -> S2F36 LRACK=" + std::to_string(static_cast(ack))); + return gem::s2f36_link_event_report_ack(ack); + }); + router.on(2, 37, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s2f37(msg); + auto ack = req ? model->enable_events(req->enable, req->ceids) + : gem::EnableEventAck::UnknownCeid; + logfn(std::string("S2F37 ") + (req && req->enable ? "enable" : "disable") + + " -> S2F38 ERACK=" + std::to_string(static_cast(ack))); + return gem::s2f38_enable_event_ack(ack); + }); + router.on(6, 15, [model, logfn](const s2::Message& msg) { + auto ceid = gem::parse_s6f15(msg); + if (!ceid) + return gem::s6f16_event_report_data({0, 0, {}}); + auto reports = model->compose_reports_for(*ceid); + logfn("S6F15 CEID=" + std::to_string(*ceid) + " -> S6F16 (" + + std::to_string(reports.size()) + " reports)"); + return gem::s6f16_event_report_data({0, *ceid, reports}); + }); + + // S6F19 — Individual Report Request. Host pulls a specific RPTID; + // we return just that report's VID values (no annotation). + router.on(6, 19, [model, logfn](const s2::Message& msg) { + auto rptid = gem::parse_s6f19(msg); + std::vector values; + if (rptid) { + // Resolve each VID in the report against the current values. + for (const auto& r : model->events.all_reports()) { + if (r.id != *rptid) continue; + for (auto vid : r.vids) { + auto v = model->vid_value(vid); + values.push_back(v ? *v : s2::Item::list({})); + } + break; + } + } + logfn("S6F19 RPTID=" + std::to_string(rptid.value_or(0)) + + " -> S6F20 (" + std::to_string(values.size()) + " values)"); + return gem::s6f20_individual_report_data(values); + }); + + // S6F21 — Annotated Individual Report Request. Same lookup as F19 + // but the reply carries (VID, value) pairs so the host doesn't need + // to remember the report definition. + router.on(6, 21, [model, logfn](const s2::Message& msg) { + auto rptid = gem::parse_s6f21(msg); + std::vector rows; + if (rptid) { + for (const auto& r : model->events.all_reports()) { + if (r.id != *rptid) continue; + for (auto vid : r.vids) { + auto v = model->vid_value(vid); + rows.push_back({vid, v ? *v : s2::Item::list({})}); + } + break; + } + } + logfn("S6F21 RPTID=" + std::to_string(rptid.value_or(0)) + + " -> S6F22 (" + std::to_string(rows.size()) + " annotated values)"); + return gem::s6f22_annotated_report_data(rows); + }); + + // S6F5 — Multi-block Data Send Inquire. When the host plays this + // role we grant unconditionally (HSMS doesn't have the SECS-I + // 244-byte block ceiling that motivates the handshake). Real hosts + // would gate on storage or busy state. + router.on(6, 5, [logfn](const s2::Message& msg) { + auto req = gem::parse_s6f5(msg); + logfn("S6F5 DATAID=" + std::to_string(req ? req->dataid : 0) + + " LEN=" + std::to_string(req ? req->datalength : 0) + + " -> S6F6 GRANT6=0"); + return gem::s6f6_multi_block_grant(gem::MultiBlockGrant::Ok); + }); + +} + +// Remote control: S2F41 (host command), S2F21 (legacy), S2F49 (enhanced) — +// all through HostCommandRegistry::dispatch and the set_handler hook. +void gem::register_remote_commands(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& router = R.router(); + auto emit_event = [&R](uint32_t ceid) { R.emit_event(ceid); }; + auto emit_alarm_set = [&R](uint32_t alid) { R.set_alarm(alid); }; + + router.on(2, 41, [model, logfn, emit_event, emit_alarm_set](const s2::Message& msg) { + auto cmd = gem::parse_s2f41(msg); + if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid, {}); + auto result = model->commands.dispatch(cmd->rcmd, cmd->params); + logfn("S2F41 RCMD=" + cmd->rcmd + " -> S2F42 HCACK=" + + std::to_string(static_cast(result.ack))); + if (result.ack == gem::HostCmdAck::Accept) { + if (result.emit_ceid) emit_event(*result.emit_ceid); + if (result.set_alarm) emit_alarm_set(*result.set_alarm); + if (result.force_spool) { + model->spool.set_force_spool(*result.force_spool); + logfn(std::string("spool: force_spool=") + (*result.force_spool ? "true" : "false") + + " (depth=" + std::to_string(model->spool.size()) + ")"); + } + } + return gem::s2f42_host_command_ack(result.ack, {}); + }); + + // S2F21 — legacy Remote Command (no parameter list). Delegated to + // the same HostCommandRegistry as S2F41 so a single YAML row defines + // both behaviours; we just don't pass any parameters along. + router.on(2, 21, [model, logfn, emit_event, emit_alarm_set](const s2::Message& msg) { + auto rcmd = gem::parse_s2f21(msg); + if (!rcmd) return gem::s2f22_remote_command_ack(gem::HostCmdAck::ParameterInvalid); + auto result = model->commands.dispatch(*rcmd, {}); + logfn("S2F21 RCMD=" + *rcmd + " -> S2F22 CMDA=" + + std::to_string(static_cast(result.ack))); + if (result.ack == gem::HostCmdAck::Accept) { + if (result.emit_ceid) emit_event(*result.emit_ceid); + if (result.set_alarm) emit_alarm_set(*result.set_alarm); + if (result.force_spool) model->spool.set_force_spool(*result.force_spool); + } + return gem::s2f22_remote_command_ack(result.ack); + }); + + // S2F49 — Enhanced Remote Command. OBJSPEC scopes the command at a + // specific object instance (e.g. a CJ or PJ id); for now we delegate + // to the same command registry as S2F41 and surface OBJSPEC in the + // log so downstream tooling can audit it. The richer CPACK/CEPACK + // shape lets us return per-parameter outcomes; until a command in + // the registry produces per-CP failures we just reply with an empty + // cpacks list, matching the spec's "all OK" interpretation. + router.on(2, 49, [model, logfn, emit_event, emit_alarm_set](const s2::Message& msg) { + auto cmd = gem::parse_s2f49(msg); + if (!cmd) return gem::s2f50_enhanced_host_command_ack(gem::HostCmdAck::ParameterInvalid, {}); + auto result = model->commands.dispatch(cmd->rcmd, cmd->params); + logfn("S2F49 DATAID=" + std::to_string(cmd->dataid) + + " OBJSPEC=" + cmd->objspec + " RCMD=" + cmd->rcmd + + " -> S2F50 HCACK=" + std::to_string(static_cast(result.ack))); + if (result.ack == gem::HostCmdAck::Accept) { + if (result.emit_ceid) emit_event(*result.emit_ceid); + if (result.set_alarm) emit_alarm_set(*result.set_alarm); + if (result.force_spool) { + model->spool.set_force_spool(*result.force_spool); + } + } + return gem::s2f50_enhanced_host_command_ack(result.ack, {}); + }); + +} + +// Trace data collection (S2F23) and variable limits (S2F45/F47). +void gem::register_trace_and_limits(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& router = R.router(); + + router.on(2, 23, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s2f23(msg); + auto ack = gem::TraceAck::Accept; + if (!req) { + ack = gem::TraceAck::InvalidPeriod; + } else { + for (auto v : req->svids) { + if (!model->vid_exists(v)) { ack = gem::TraceAck::UnknownVid; break; } + } + if (ack == gem::TraceAck::Accept) { + model->traces.add({req->trid, req->dsper, req->totsmp, req->repgsz, req->svids}); + } + } + logfn("S2F23 -> S2F24 TIAACK=" + std::to_string(static_cast(ack))); + return gem::s2f24_trace_initialize_ack(ack); + }); + + router.on(2, 45, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s2f45(msg); + auto ack = gem::LimitMonitorAck::Accept; + if (!req) { + ack = gem::LimitMonitorAck::LimitValueError; + } else { + for (const auto& entry : req->entries) { + if (!model->vid_exists(entry.vid)) { ack = gem::LimitMonitorAck::VidNotExist; break; } + } + if (ack == gem::LimitMonitorAck::Accept) { + for (const auto& entry : req->entries) + model->limits.set_for_vid(entry.vid, entry.limits); + } + } + logfn("S2F45 -> S2F46 VLAACK=" + std::to_string(static_cast(ack))); + return gem::s2f46_define_variable_limits_ack(ack); + }); + router.on(2, 47, [model, logfn](const s2::Message& msg) { + auto vids = gem::parse_s2f47(msg); + std::vector rows; + if (vids) { + const auto target = vids->empty() ? model->limits.all_vids() : *vids; + for (auto v : target) rows.push_back({v, model->limits.get_for_vid(v)}); + } + logfn("S2F47 -> S2F48 (" + std::to_string(rows.size()) + " entries)"); + return gem::s2f48_variable_limit_attribute_data(rows); + }); + +} + +// Spooling: S2F43 reset + S6F23 request-spool-data (drain/purge). +void gem::register_spooling(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& io = R.io(); + auto& router = R.router(); + auto active_conn = R.active_conn(); + + router.on(2, 43, [model, logfn](const s2::Message& msg) { + auto streams = gem::parse_s2f43(msg); + auto ack = gem::ResetSpoolAck::Accept; + std::vector per; + if (!streams) { + ack = gem::ResetSpoolAck::Denied_NotAllowed; + } else { + model->spool.set_spoolable_streams(*streams); + logfn("S2F43 spoolable=" + std::to_string(streams->size()) + " streams"); + } + return gem::s2f44_reset_spooling_ack(ack, per); + }); + + // S6F15 — Event Report Request. Host pulls the current payload for + // a CEID without waiting for the equipment to emit it. Reply mirrors + // S6F11 (DATAID=0, the same CEID, and the latest report rows). + router.on(6, 23, [&io, active_conn, model, logfn](const s2::Message& msg) { + auto rsdc = gem::parse_s6f23(msg); + if (!rsdc) return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Denied); + if (*rsdc == gem::SpoolRequestCode::Purge) { + const auto n = model->spool.size(); + model->spool.clear(); + logfn("S6F23 purge: dropped " + std::to_string(n) + " messages"); + return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept); + } + // Transmit: drain the queue, fire each as a fresh primary. Defer to + // the executor so the S6F24 ack flushes before the drained primaries + // go out — the host should see ACK first, then the spooled traffic. + auto drained = model->spool.drain(); + logfn("S6F23 transmit: draining " + std::to_string(drained.size()) + + " messages"); + asio::post(io, [active_conn, drained = std::move(drained), logfn]() mutable { + auto conn = active_conn->lock(); + if (!conn) return; + for (auto& m : drained) { + const bool w = m.reply_expected; + if (w) + conn->send_request(std::move(m), [](std::error_code, const s2::Message&) {}); + else + conn->send_data(std::move(m)); } - // All PJs done -> CJ Completed. - model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::AllJobsComplete); - logfn("CJ " + ctljobid + " lifecycle complete"); }); + return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept); + }); + +} + +// Alarm management: S5F3 enable, S5F5 list, S5F7 enabled-list. +void gem::register_alarms(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& router = R.router(); + + router.on(5, 3, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s5f3(msg); + auto ack = req ? model->alarms.set_enabled(req->alid, (req->aled & 0x80) != 0) + : gem::AlarmAck::Error; + logfn(std::string("S5F3 -> S5F4 ACKC5=") + std::to_string(static_cast(ack))); + return gem::s5f4_enable_alarm_ack(ack); + }); + router.on(5, 7, [model, logfn](const s2::Message&) { + std::vector rows; + for (const auto& a : model->alarms.all()) { + if (!model->alarms.enabled(a.id)) continue; + const uint8_t alcd = (a.severity_category & 0x7F) | + static_cast(model->alarms.active(a.id) ? 0x80 : 0x00); + rows.push_back({alcd, a.id, a.text}); + } + logfn("S5F7 -> S5F8 (" + std::to_string(rows.size()) + " enabled)"); + return gem::s5f8_list_enabled_alarms_data(rows); + }); + + router.on(5, 5, [model, logfn](const s2::Message& msg) { + auto ids = gem::parse_u4_list_body(msg); + std::vector alarms; + if (ids && ids->empty()) alarms = model->alarms.all(); + else if (ids) + for (auto id : *ids) { + auto a = model->alarms.get(id); + if (a) alarms.push_back(*a); + } + logfn("S5F5 -> S5F6 (" + std::to_string(alarms.size()) + " alarms)"); + return gem::s5f6_list_alarms_data( + alarms, [model](uint32_t id) { return model->alarms.active(id); }); + }); + + // S5F13/F14 — Exception Recover Request. Validates EXRECVRA against + // the candidates the matching S5F9 advertised; on Accept the FSM + // transitions Posted/RecoverFailed -> Recovering. Equipment-side + // recovery progress is signalled by the application calling + // model->exceptions.fire_internal(exid, RecoveryComplete/Failed). +} + +// E5 exceptions: state-change emitters (S5F9/11/15) + S5F13/F17 recovery. +void gem::register_exceptions(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& router = R.router(); + auto deliver_or_spool = [&R](s2::Message msg, const std::string& what) { + return R.deliver_or_spool(std::move(msg), what); }; // ---- E5 exception state-change emitters ------------------------------- @@ -187,6 +597,33 @@ void gem::register_default_handlers(gem::EquipmentRuntime& R) { } }); + router.on(5, 13, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s5f13(msg); + auto ack = req ? model->exceptions.on_recover(req->exid, req->exrecvra) + : gem::AlarmAck::Error; + logfn("S5F13 EXID=" + std::to_string(req ? req->exid : 0) + + " action=" + (req ? req->exrecvra : std::string{"?"}) + + " -> S5F14 ACKC5=" + std::to_string(static_cast(ack))); + return gem::s5f14_exception_recover_ack(ack); + }); + + router.on(5, 17, [model, logfn](const s2::Message& msg) { + auto exid = gem::parse_s5f17(msg); + auto ack = exid ? model->exceptions.on_recover_abort(*exid) + : gem::AlarmAck::Error; + logfn("S5F17 EXID=" + std::to_string(exid.value_or(0)) + + " -> S5F18 ACKC5=" + std::to_string(static_cast(ack))); + return gem::s5f18_exception_recover_abort_ack(ack); + }); + +} + +// E90 substrate, E116 EPT, and E157 module state-change emitters. +void gem::register_material_tracking(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto emit_event = [&R](uint32_t ceid) { R.emit_event(ceid); }; + // ---- E90 substrate state-change emitters ----------------------------- // Map SubstrateState / SubstrateProcessingState transitions to the // standard E90 CEIDs. Only the post-transition state matters; the @@ -292,436 +729,13 @@ void gem::register_default_handlers(gem::EquipmentRuntime& R) { } }); - // ---- Build the SECS dispatch table once ------------------------------- - // (`router` is the runtime's Router, aliased above.) +} - router.on(1, 1, [desc, logfn](const s2::Message&) { - logfn("S1F1 -> S1F2"); - return gem::s1f2_on_line_data(desc.model_name, desc.software_rev); - }); - router.on(1, 3, [model, sm, logfn](const s2::Message& msg) -> std::optional { - refresh(*model, *sm); - auto svids = gem::parse_s1f3(msg); - if (!svids) return s2::Message(1, 0, false); - std::vector> values; - if (svids->empty()) { - for (const auto& sv : model->svids.all()) values.push_back(sv.value); - } else { - for (auto id : *svids) { - auto sv = model->svids.get(id); - values.push_back(sv ? std::optional(sv->value) : std::nullopt); - } - } - logfn("S1F3 -> S1F4 (" + std::to_string(values.size()) + " values)"); - return gem::s1f4_selected_status_data(values); - }); - router.on(1, 11, [model, logfn](const s2::Message&) { - std::vector rows; - for (const auto& sv : model->svids.all()) - rows.push_back({sv.id, sv.name, sv.units}); - logfn("S1F11 -> S1F12 (namelist, " + std::to_string(rows.size()) + ")"); - return gem::s1f12_status_namelist_data(rows); - }); - router.on(1, 13, [desc, logfn](const s2::Message&) { - logfn("S1F13 -> S1F14"); - return gem::s1f14_establish_comms_ack(gem::CommAck::Accept, - {desc.model_name, desc.software_rev}); - }); - router.on(1, 15, [sm, logfn](const s2::Message&) { - auto ack = sm->on_host_request_offline(); - logfn("S1F15 -> S1F16 OFLACK=" + std::to_string(static_cast(ack))); - return gem::s1f16_offline_ack(ack); - }); - router.on(1, 17, [sm, logfn](const s2::Message&) { - auto ack = sm->on_host_request_online(); - logfn("S1F17 -> S1F18 ONLACK=" + std::to_string(static_cast(ack))); - return gem::s1f18_online_ack(ack); - }); - router.on(1, 19, [desc, logfn](const s2::Message&) { - std::vector caps; - for (const auto& c : desc.capabilities) caps.push_back({c.first, c.second}); - logfn("S1F19 -> S1F20 (" + std::to_string(caps.size()) + " capabilities)"); - return gem::s1f20_get_gem_compliance_data(desc.software_rev, desc.equipment_type, caps); - }); - router.on(1, 21, [model, logfn](const s2::Message&) { - std::vector rows; - for (const auto& dv : model->dvids.all()) - rows.push_back({dv.id, dv.name, dv.units}); - logfn("S1F21 -> S1F22 (" + std::to_string(rows.size()) + " DVIDs)"); - return gem::s1f22_data_variable_namelist_data(rows); - }); - // S1F23 — Collection Event Namelist Request. Empty CEID list means - // "every CEID in the catalog"; otherwise we filter to the requested - // set and silently skip unknown CEIDs (per SEMI E5: "all unidentified - // are returned in S1F24 with an empty VID list"). - router.on(1, 23, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s1f23(msg); - std::vector rows; - if (req && req->empty()) { - for (const auto& e : model->events.all_events()) - rows.push_back({e.id, e.name, model->events.vids_for(e.id)}); - } else if (req) { - for (auto id : *req) { - if (auto info = model->events.event_info(id)) { - rows.push_back({id, info->name, model->events.vids_for(id)}); - } else { - rows.push_back({id, "", {}}); - } - } - } - logfn("S1F23 -> S1F24 (" + std::to_string(rows.size()) + " CEIDs)"); - return gem::s1f24_collection_event_namelist_data(rows); - }); - - router.on(2, 13, [model, logfn](const s2::Message& msg) -> std::optional { - auto ids = gem::parse_u4_list_body(msg); - if (!ids) return s2::Message(2, 0, false); - std::vector values; - for (auto id : *ids) { - auto ec = model->ecids.get(id); - values.push_back(ec ? ec->value : s2::Item::list({})); - } - logfn("S2F13 -> S2F14 (" + std::to_string(values.size()) + " values)"); - return gem::s2f14_ec_data(values); - }); - router.on(2, 15, [model, logfn](const s2::Message& msg) { - auto sets = gem::parse_s2f15(msg); - auto eac = gem::EquipmentAck::Accept; - if (!sets) eac = gem::EquipmentAck::Denied_OutOfRange; - else - for (const auto& s : *sets) { - auto r = model->ecids.set_value(s.ecid, s.value); - if (r != gem::EquipmentAck::Accept) eac = r; - } - logfn("S2F15 -> S2F16 EAC=" + std::to_string(static_cast(eac))); - return gem::s2f16_ec_ack(eac); - }); - router.on(2, 17, [model, logfn](const s2::Message&) { - logfn("S2F17 -> S2F18 (clock)"); - return gem::s2f18_date_time_data(model->clock.current_time_string()); - }); - router.on(2, 29, [model, logfn](const s2::Message& msg) { - auto ids = gem::parse_u4_list_body(msg); - std::vector ecs; - if (ids && ids->empty()) ecs = model->ecids.all(); - else if (ids) - for (auto id : *ids) { - auto ec = model->ecids.get(id); - if (ec) ecs.push_back(*ec); - } - std::vector rows; - for (const auto& ec : ecs) - rows.push_back({ec.id, ec.name, ec.min_str, ec.max_str, "", ec.units}); - logfn("S2F29 -> S2F30 (" + std::to_string(rows.size()) + " ECs)"); - return gem::s2f30_ec_namelist_data(rows); - }); - router.on(2, 31, [model, logfn](const s2::Message& msg) { - auto t = gem::parse_s2f31(msg); - auto ack = t ? model->clock.set_time_string(*t) : gem::TimeAck::Error; - logfn("S2F31 -> S2F32 TIACK=" + std::to_string(static_cast(ack))); - return gem::s2f32_date_time_ack(ack); - }); - router.on(2, 33, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s2f33(msg); - auto ack = gem::DefineReportAck::InvalidFormat; - if (req) { - std::vector>> rows; - rows.reserve(req->reports.size()); - for (const auto& r : req->reports) rows.emplace_back(r.rptid, r.vids); - ack = model->define_reports(rows); - } - logfn("S2F33 -> S2F34 DRACK=" + std::to_string(static_cast(ack))); - return gem::s2f34_define_report_ack(ack); - }); - router.on(2, 35, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s2f35(msg); - auto ack = gem::LinkEventAck::InvalidFormat; - if (req) { - std::vector>> rows; - rows.reserve(req->links.size()); - for (const auto& l : req->links) rows.emplace_back(l.ceid, l.rptids); - ack = model->link_event_reports(rows); - } - logfn("S2F35 -> S2F36 LRACK=" + std::to_string(static_cast(ack))); - return gem::s2f36_link_event_report_ack(ack); - }); - router.on(2, 37, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s2f37(msg); - auto ack = req ? model->enable_events(req->enable, req->ceids) - : gem::EnableEventAck::UnknownCeid; - logfn(std::string("S2F37 ") + (req && req->enable ? "enable" : "disable") + - " -> S2F38 ERACK=" + std::to_string(static_cast(ack))); - return gem::s2f38_enable_event_ack(ack); - }); - router.on(2, 41, [model, logfn, emit_event, emit_alarm_set](const s2::Message& msg) { - auto cmd = gem::parse_s2f41(msg); - if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid, {}); - auto result = model->commands.dispatch(cmd->rcmd, cmd->params); - logfn("S2F41 RCMD=" + cmd->rcmd + " -> S2F42 HCACK=" + - std::to_string(static_cast(result.ack))); - if (result.ack == gem::HostCmdAck::Accept) { - if (result.emit_ceid) emit_event(*result.emit_ceid); - if (result.set_alarm) emit_alarm_set(*result.set_alarm); - if (result.force_spool) { - model->spool.set_force_spool(*result.force_spool); - logfn(std::string("spool: force_spool=") + (*result.force_spool ? "true" : "false") + - " (depth=" + std::to_string(model->spool.size()) + ")"); - } - } - return gem::s2f42_host_command_ack(result.ack, {}); - }); - - // S2F21 — legacy Remote Command (no parameter list). Delegated to - // the same HostCommandRegistry as S2F41 so a single YAML row defines - // both behaviours; we just don't pass any parameters along. - router.on(2, 21, [model, logfn, emit_event, emit_alarm_set](const s2::Message& msg) { - auto rcmd = gem::parse_s2f21(msg); - if (!rcmd) return gem::s2f22_remote_command_ack(gem::HostCmdAck::ParameterInvalid); - auto result = model->commands.dispatch(*rcmd, {}); - logfn("S2F21 RCMD=" + *rcmd + " -> S2F22 CMDA=" + - std::to_string(static_cast(result.ack))); - if (result.ack == gem::HostCmdAck::Accept) { - if (result.emit_ceid) emit_event(*result.emit_ceid); - if (result.set_alarm) emit_alarm_set(*result.set_alarm); - if (result.force_spool) model->spool.set_force_spool(*result.force_spool); - } - return gem::s2f22_remote_command_ack(result.ack); - }); - - // S2F49 — Enhanced Remote Command. OBJSPEC scopes the command at a - // specific object instance (e.g. a CJ or PJ id); for now we delegate - // to the same command registry as S2F41 and surface OBJSPEC in the - // log so downstream tooling can audit it. The richer CPACK/CEPACK - // shape lets us return per-parameter outcomes; until a command in - // the registry produces per-CP failures we just reply with an empty - // cpacks list, matching the spec's "all OK" interpretation. - router.on(2, 49, [model, logfn, emit_event, emit_alarm_set](const s2::Message& msg) { - auto cmd = gem::parse_s2f49(msg); - if (!cmd) return gem::s2f50_enhanced_host_command_ack(gem::HostCmdAck::ParameterInvalid, {}); - auto result = model->commands.dispatch(cmd->rcmd, cmd->params); - logfn("S2F49 DATAID=" + std::to_string(cmd->dataid) + - " OBJSPEC=" + cmd->objspec + " RCMD=" + cmd->rcmd + - " -> S2F50 HCACK=" + std::to_string(static_cast(result.ack))); - if (result.ack == gem::HostCmdAck::Accept) { - if (result.emit_ceid) emit_event(*result.emit_ceid); - if (result.set_alarm) emit_alarm_set(*result.set_alarm); - if (result.force_spool) { - model->spool.set_force_spool(*result.force_spool); - } - } - return gem::s2f50_enhanced_host_command_ack(result.ack, {}); - }); - - router.on(2, 23, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s2f23(msg); - auto ack = gem::TraceAck::Accept; - if (!req) { - ack = gem::TraceAck::InvalidPeriod; - } else { - for (auto v : req->svids) { - if (!model->vid_exists(v)) { ack = gem::TraceAck::UnknownVid; break; } - } - if (ack == gem::TraceAck::Accept) { - model->traces.add({req->trid, req->dsper, req->totsmp, req->repgsz, req->svids}); - } - } - logfn("S2F23 -> S2F24 TIAACK=" + std::to_string(static_cast(ack))); - return gem::s2f24_trace_initialize_ack(ack); - }); - - router.on(2, 45, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s2f45(msg); - auto ack = gem::LimitMonitorAck::Accept; - if (!req) { - ack = gem::LimitMonitorAck::LimitValueError; - } else { - for (const auto& entry : req->entries) { - if (!model->vid_exists(entry.vid)) { ack = gem::LimitMonitorAck::VidNotExist; break; } - } - if (ack == gem::LimitMonitorAck::Accept) { - for (const auto& entry : req->entries) - model->limits.set_for_vid(entry.vid, entry.limits); - } - } - logfn("S2F45 -> S2F46 VLAACK=" + std::to_string(static_cast(ack))); - return gem::s2f46_define_variable_limits_ack(ack); - }); - router.on(2, 47, [model, logfn](const s2::Message& msg) { - auto vids = gem::parse_s2f47(msg); - std::vector rows; - if (vids) { - const auto target = vids->empty() ? model->limits.all_vids() : *vids; - for (auto v : target) rows.push_back({v, model->limits.get_for_vid(v)}); - } - logfn("S2F47 -> S2F48 (" + std::to_string(rows.size()) + " entries)"); - return gem::s2f48_variable_limit_attribute_data(rows); - }); - - router.on(2, 43, [model, logfn](const s2::Message& msg) { - auto streams = gem::parse_s2f43(msg); - auto ack = gem::ResetSpoolAck::Accept; - std::vector per; - if (!streams) { - ack = gem::ResetSpoolAck::Denied_NotAllowed; - } else { - model->spool.set_spoolable_streams(*streams); - logfn("S2F43 spoolable=" + std::to_string(streams->size()) + " streams"); - } - return gem::s2f44_reset_spooling_ack(ack, per); - }); - - // S6F15 — Event Report Request. Host pulls the current payload for - // a CEID without waiting for the equipment to emit it. Reply mirrors - // S6F11 (DATAID=0, the same CEID, and the latest report rows). - router.on(6, 15, [model, logfn](const s2::Message& msg) { - auto ceid = gem::parse_s6f15(msg); - if (!ceid) - return gem::s6f16_event_report_data({0, 0, {}}); - auto reports = model->compose_reports_for(*ceid); - logfn("S6F15 CEID=" + std::to_string(*ceid) + " -> S6F16 (" + - std::to_string(reports.size()) + " reports)"); - return gem::s6f16_event_report_data({0, *ceid, reports}); - }); - - // S6F19 — Individual Report Request. Host pulls a specific RPTID; - // we return just that report's VID values (no annotation). - router.on(6, 19, [model, logfn](const s2::Message& msg) { - auto rptid = gem::parse_s6f19(msg); - std::vector values; - if (rptid) { - // Resolve each VID in the report against the current values. - for (const auto& r : model->events.all_reports()) { - if (r.id != *rptid) continue; - for (auto vid : r.vids) { - auto v = model->vid_value(vid); - values.push_back(v ? *v : s2::Item::list({})); - } - break; - } - } - logfn("S6F19 RPTID=" + std::to_string(rptid.value_or(0)) + - " -> S6F20 (" + std::to_string(values.size()) + " values)"); - return gem::s6f20_individual_report_data(values); - }); - - // S6F21 — Annotated Individual Report Request. Same lookup as F19 - // but the reply carries (VID, value) pairs so the host doesn't need - // to remember the report definition. - router.on(6, 21, [model, logfn](const s2::Message& msg) { - auto rptid = gem::parse_s6f21(msg); - std::vector rows; - if (rptid) { - for (const auto& r : model->events.all_reports()) { - if (r.id != *rptid) continue; - for (auto vid : r.vids) { - auto v = model->vid_value(vid); - rows.push_back({vid, v ? *v : s2::Item::list({})}); - } - break; - } - } - logfn("S6F21 RPTID=" + std::to_string(rptid.value_or(0)) + - " -> S6F22 (" + std::to_string(rows.size()) + " annotated values)"); - return gem::s6f22_annotated_report_data(rows); - }); - - // S6F5 — Multi-block Data Send Inquire. When the host plays this - // role we grant unconditionally (HSMS doesn't have the SECS-I - // 244-byte block ceiling that motivates the handshake). Real hosts - // would gate on storage or busy state. - router.on(6, 5, [logfn](const s2::Message& msg) { - auto req = gem::parse_s6f5(msg); - logfn("S6F5 DATAID=" + std::to_string(req ? req->dataid : 0) + - " LEN=" + std::to_string(req ? req->datalength : 0) + - " -> S6F6 GRANT6=0"); - return gem::s6f6_multi_block_grant(gem::MultiBlockGrant::Ok); - }); - - router.on(6, 23, [&io, active_conn, model, logfn](const s2::Message& msg) { - auto rsdc = gem::parse_s6f23(msg); - if (!rsdc) return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Denied); - if (*rsdc == gem::SpoolRequestCode::Purge) { - const auto n = model->spool.size(); - model->spool.clear(); - logfn("S6F23 purge: dropped " + std::to_string(n) + " messages"); - return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept); - } - // Transmit: drain the queue, fire each as a fresh primary. Defer to - // the executor so the S6F24 ack flushes before the drained primaries - // go out — the host should see ACK first, then the spooled traffic. - auto drained = model->spool.drain(); - logfn("S6F23 transmit: draining " + std::to_string(drained.size()) + - " messages"); - asio::post(io, [active_conn, drained = std::move(drained), logfn]() mutable { - auto conn = active_conn->lock(); - if (!conn) return; - for (auto& m : drained) { - const bool w = m.reply_expected; - if (w) - conn->send_request(std::move(m), [](std::error_code, const s2::Message&) {}); - else - conn->send_data(std::move(m)); - } - }); - return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept); - }); - - router.on(5, 3, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s5f3(msg); - auto ack = req ? model->alarms.set_enabled(req->alid, (req->aled & 0x80) != 0) - : gem::AlarmAck::Error; - logfn(std::string("S5F3 -> S5F4 ACKC5=") + std::to_string(static_cast(ack))); - return gem::s5f4_enable_alarm_ack(ack); - }); - router.on(5, 7, [model, logfn](const s2::Message&) { - std::vector rows; - for (const auto& a : model->alarms.all()) { - if (!model->alarms.enabled(a.id)) continue; - const uint8_t alcd = (a.severity_category & 0x7F) | - static_cast(model->alarms.active(a.id) ? 0x80 : 0x00); - rows.push_back({alcd, a.id, a.text}); - } - logfn("S5F7 -> S5F8 (" + std::to_string(rows.size()) + " enabled)"); - return gem::s5f8_list_enabled_alarms_data(rows); - }); - - router.on(5, 5, [model, logfn](const s2::Message& msg) { - auto ids = gem::parse_u4_list_body(msg); - std::vector alarms; - if (ids && ids->empty()) alarms = model->alarms.all(); - else if (ids) - for (auto id : *ids) { - auto a = model->alarms.get(id); - if (a) alarms.push_back(*a); - } - logfn("S5F5 -> S5F6 (" + std::to_string(alarms.size()) + " alarms)"); - return gem::s5f6_list_alarms_data( - alarms, [model](uint32_t id) { return model->alarms.active(id); }); - }); - - // S5F13/F14 — Exception Recover Request. Validates EXRECVRA against - // the candidates the matching S5F9 advertised; on Accept the FSM - // transitions Posted/RecoverFailed -> Recovering. Equipment-side - // recovery progress is signalled by the application calling - // model->exceptions.fire_internal(exid, RecoveryComplete/Failed). - router.on(5, 13, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s5f13(msg); - auto ack = req ? model->exceptions.on_recover(req->exid, req->exrecvra) - : gem::AlarmAck::Error; - logfn("S5F13 EXID=" + std::to_string(req ? req->exid : 0) + - " action=" + (req ? req->exrecvra : std::string{"?"}) + - " -> S5F14 ACKC5=" + std::to_string(static_cast(ack))); - return gem::s5f14_exception_recover_ack(ack); - }); - - router.on(5, 17, [model, logfn](const s2::Message& msg) { - auto exid = gem::parse_s5f17(msg); - auto ack = exid ? model->exceptions.on_recover_abort(*exid) - : gem::AlarmAck::Error; - logfn("S5F17 EXID=" + std::to_string(exid.value_or(0)) + - " -> S5F18 ACKC5=" + std::to_string(static_cast(ack))); - return gem::s5f18_exception_recover_abort_ack(ack); - }); +// E87 carrier management: S3F17/19/25/27. +void gem::register_carriers(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& router = R.router(); // ---- E87 Carrier Management dispatch --------------------------------- // S3F17 maps the textual CARRIERACTION string onto a CarrierIDEvent @@ -815,6 +829,14 @@ void gem::register_default_handlers(gem::EquipmentRuntime& R) { // accept any reasonable size (< 16 MiB which is also our HSMS frame // cap) and reject empty PPIDs. Real equipment would gate on // available recipe storage. +} + +// Process programs: S7F1/3/5/17/19. +void gem::register_recipes(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& router = R.router(); + router.on(7, 1, [logfn](const s2::Message& msg) { auto req = gem::parse_s7f1(msg); auto ack = gem::ProcessProgramAck::Accept; @@ -865,6 +887,14 @@ void gem::register_default_handlers(gem::EquipmentRuntime& R) { return gem::s7f20_current_eppd_data(list); }); +} + +// E39 generic object services: S14F1 GetAttr + S14F3 SetAttr. +void gem::register_object_services(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + auto& router = R.router(); + // ---- E39 generic ObjectService ---------------------------------------- // S14F1 GetAttr / S14F3 SetAttr against the CemObjectStore. OBJTYPE // is validated against the stored object's type name (case-sensitive). @@ -910,6 +940,95 @@ void gem::register_default_handlers(gem::EquipmentRuntime& R) { return gem::s14f4_set_attr_ack(reply, gem::ObjectAck::Success); }); +} + +// E40 process jobs + E94 control jobs: S14F9/11, S16F5/7/11/13/15/27, +// the PJ/CJ state-change emitters (S16F9 alerts, role-bound CJ CEIDs) and +// the demo CJ lifecycle cascade. +void gem::register_jobs(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto model = R.model_ptr(); + const auto& desc = R.descriptor(); + auto& io = R.io(); + auto& router = R.router(); + auto deliver_or_spool = [&R](s2::Message msg, const std::string& what) { + return R.deliver_or_spool(std::move(msg), what); + }; + auto emit_event = [&R](uint32_t ceid) { R.emit_event(ceid); }; + + auto emit_pj_alert = [&io, model, logfn, deliver_or_spool]( + const std::string& prjobid, + gem::ProcessJobState state) { + asio::post(io, [model, logfn, deliver_or_spool, prjobid, state]() { + const auto* pj = model->process_jobs.get(prjobid); + if (pj && !pj->alert_enabled) return; + logfn("emit S16F9 PJ=" + prjobid + " state=" + + gem::process_job_state_name(state)); + deliver_or_spool(gem::s16f9_pr_job_alert(prjobid, state), + "S16F9 PJ=" + prjobid); + }); + }; + + // PJ state-change handler: log + emit S16F9 (skip the synthetic + // NoState->Queued so we don't alert on a freshly-created PJ that's + // still being acked). + model->process_jobs.set_state_change_handler( + [logfn, emit_pj_alert](const std::string& prjobid, + gem::ProcessJobState from, + gem::ProcessJobState to, + gem::ProcessJobEvent trig) { + logfn(std::string("PJ ") + prjobid + ": " + + gem::process_job_state_name(from) + " -> " + + gem::process_job_state_name(to) + " (" + + gem::process_job_event_name(trig) + ")"); + if (from != gem::ProcessJobState::NoState) emit_pj_alert(prjobid, to); + }); + + // CEIDs the equipment.yaml is expected to register for CJ state + // changes (best-effort: if missing they're silently no-ops via the + // existing CEID-not-enabled guard in emit_event). + const uint32_t kCeidCJExecuting = desc.cj_executing_ceid; + const uint32_t kCeidCJCompleted = desc.cj_completed_ceid; + + model->control_jobs.set_state_change_handler( + [logfn, emit_event, kCeidCJExecuting, kCeidCJCompleted]( + const std::string& ctljobid, + gem::ControlJobState from, + gem::ControlJobState to, + gem::ControlJobEvent trig) { + logfn(std::string("CJ ") + ctljobid + ": " + + gem::control_job_state_name(from) + " -> " + + gem::control_job_state_name(to) + " (" + + gem::control_job_event_name(trig) + ")"); + if (to == gem::ControlJobState::Executing) emit_event(kCeidCJExecuting); + if (to == gem::ControlJobState::Completed) emit_event(kCeidCJCompleted); + }); + + // Drive the contained PJs through the demo lifecycle when the host + // tells the CJ to start. Real equipment would step PJs one at a time + // as material arrives; our simulator cascades through every legal + // state so the wire trace exercises the whole FSM. + auto run_cj_lifecycle = [&io, model, logfn](const std::string& ctljobid) { + asio::post(io, [model, logfn, ctljobid]() { + auto* cj = model->control_jobs.get(ctljobid); + if (!cj) return; + // CJ promotion path: Queued -> Selected -> WaitingForStart -> Executing. + model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::Select); + model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::SetupComplete); + model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::Start); + // Cascade every contained PJ through to ProcessComplete. + for (const auto& pjid : cj->prjobids) { + model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Select); + model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::SetupComplete); + model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Start); + model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::ProcessComplete); + } + // All PJs done -> CJ Completed. + model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::AllJobsComplete); + logfn("CJ " + ctljobid + " lifecycle complete"); + }); + }; + // ---- E40 / E94 ------------------------------------------------------- router.on(14, 9, [model, logfn, run_cj_lifecycle](const s2::Message& msg) { (void)run_cj_lifecycle; @@ -1059,6 +1178,13 @@ void gem::register_default_handlers(gem::EquipmentRuntime& R) { return gem::s16f28_cj_command_ack(ack); }); +} + +// Terminal services: S10F1/3/5 operator-message display. +void gem::register_terminal_services(gem::EquipmentRuntime& R) { + auto logfn = [&R](const std::string& m) { R.log(m); }; + auto& router = R.router(); + router.on(10, 1, [logfn](const s2::Message& msg) { auto td = gem::parse_s10f1(msg); if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text); @@ -1082,5 +1208,27 @@ void gem::register_default_handlers(gem::EquipmentRuntime& R) { return gem::s10f6_terminal_display_multi_ack(gem::TerminalAck::Accepted); }); - logfn("registered " + std::to_string(router.size()) + " (stream,function) handlers"); +} + +// Registers the complete default GEM behaviour — every capability above. +// Equipment implementing a subset of GEM calls the per-capability functions +// it needs instead (mirrors how E30 itself is organised: see S1F19). +void gem::register_default_handlers(gem::EquipmentRuntime& R) { + register_identification(R); + register_equipment_constants(R); + register_clock(R); + register_event_reports(R); + register_remote_commands(R); + register_trace_and_limits(R); + register_spooling(R); + register_alarms(R); + register_exceptions(R); + register_material_tracking(R); + register_carriers(R); + register_recipes(R); + register_object_services(R); + register_jobs(R); + register_terminal_services(R); + R.log("registered " + std::to_string(R.router().size()) + + " (stream,function) handlers"); } diff --git a/tests/test_default_handlers.cpp b/tests/test_default_handlers.cpp index ec3d4a2..f615125 100644 --- a/tests/test_default_handlers.cpp +++ b/tests/test_default_handlers.cpp @@ -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 +} diff --git a/tests/test_loader.cpp b/tests/test_loader.cpp index 5a327fd..05ca46e 100644 --- a/tests/test_loader.cpp +++ b/tests/test_loader.cpp @@ -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); +}