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); +}