From fc898f84102fd3dcd3ecf9103b600c37d8ee75e7 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Wed, 10 Jun 2026 18:01:16 +0200 Subject: [PATCH] feat: EquipmentRuntime engine owner + secs_gemd gRPC daemon Extract the SECS/GEM engine wiring out of the secs_server app into a reusable class, and stand up a language-agnostic gRPC daemon on top so a tool's software (any language) can drive the equipment without linking C++ or knowing SEMI. Foundation for replacing a vendor's SECS/GEM server. Engine reuse: - EquipmentRuntime (include/secsgem/gem/runtime.hpp, src/gem/runtime.cpp): owns io_context, passive Server, model, control-state machine, Router; thread-safe outbound API (set_variable/emit_event/set_alarm/clear_alarm), on_command hook, deliver_or_spool, run()/run_async()/poll()/stop(). - register_default_handlers (src/gem/default_handlers.cpp): the 56 GEM handlers + domain emitters, relocated from secs_server so the app and the daemon speak byte-identical GEM. secs_server.cpp reduced ~1270 -> 113 lines. - name_index.hpp: resolve_variable(name) -> VID (the name->id binding layer). Daemon (apps/secs_gemd.cpp, proto/secsgem/v1/equipment.proto): - runs the engine + HSMS link on a background thread; serves the gRPC Equipment service. Increment 1: SetVariables (name-resolved, plain value->Item) and GetControlState. proto carries the full v1 surface (universal + carrier/recipe/job tiers); remaining RPCs + the Subscribe command stream are next (docs/DAEMON_ROADMAP.md). - CMake: opt-in SECSGEM_DAEMON, protoc/grpc_cpp_plugin codegen, gracefully skipped where protobuf/grpc++ are absent. Dockerfile gains the grpc deps. Tests (proof): test_runtime, test_default_handlers (S1F1->S1F2, S2F41->hook), test_name_index. Full suite 458/458, 2795 assertions; live server<->client GEM300 demo still passes on the refactored server. Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 48 + Dockerfile | 4 + apps/secs_gemd.cpp | 128 +++ apps/secs_server.cpp | 1209 +--------------------- docs/DAEMON_ROADMAP.md | 94 ++ include/secsgem/gem/default_handlers.hpp | 18 + include/secsgem/gem/name_index.hpp | 27 + include/secsgem/gem/runtime.hpp | 107 ++ proto/secsgem/v1/equipment.proto | 264 +++++ src/gem/default_handlers.cpp | 1086 +++++++++++++++++++ src/gem/runtime.cpp | 171 +++ tests/test_default_handlers.cpp | 73 ++ tests/test_name_index.cpp | 23 + tests/test_runtime.cpp | 66 ++ 14 files changed, 2135 insertions(+), 1183 deletions(-) create mode 100644 apps/secs_gemd.cpp create mode 100644 docs/DAEMON_ROADMAP.md create mode 100644 include/secsgem/gem/default_handlers.hpp create mode 100644 include/secsgem/gem/name_index.hpp create mode 100644 include/secsgem/gem/runtime.hpp create mode 100644 proto/secsgem/v1/equipment.proto create mode 100644 src/gem/default_handlers.cpp create mode 100644 src/gem/runtime.cpp create mode 100644 tests/test_default_handlers.cpp create mode 100644 tests/test_name_index.cpp create mode 100644 tests/test_runtime.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 809ee48..0f91ee1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,6 +98,8 @@ add_library(secsgem src/gem/module_state.cpp src/gem/e84_state.cpp src/gem/host_handler.cpp + src/gem/runtime.cpp + src/gem/default_handlers.cpp src/config/loader.cpp src/config/validate.cpp src/endpoint.cpp @@ -131,6 +133,49 @@ target_link_libraries(secs_bench PRIVATE secsgem) add_executable(pvd_tool examples/pvd_tool/main.cpp) target_link_libraries(pvd_tool PRIVATE secsgem) +# --- gRPC daemon: secs_gemd ----------------------------------------------- +# Runs the engine on a background thread and exposes proto/secsgem/v1 over +# gRPC. Opt-in and gracefully skipped where protobuf/grpc++ aren't installed, +# so the core library + tests build without them. +option(SECSGEM_DAEMON "Build the secs_gemd gRPC daemon" ON) +if(SECSGEM_DAEMON) + find_package(Protobuf QUIET) + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + pkg_check_modules(GRPCPP QUIET IMPORTED_TARGET grpc++) + endif() + find_program(GRPC_CPP_PLUGIN grpc_cpp_plugin) + if(Protobuf_FOUND AND GRPCPP_FOUND AND GRPC_CPP_PLUGIN) + set(PROTO_DIR ${CMAKE_SOURCE_DIR}/proto) + set(PROTO_FILE ${PROTO_DIR}/secsgem/v1/equipment.proto) + set(PROTO_OUT ${CMAKE_BINARY_DIR}/generated) + add_custom_command( + OUTPUT ${PROTO_OUT}/secsgem/v1/equipment.pb.cc + ${PROTO_OUT}/secsgem/v1/equipment.pb.h + ${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc + ${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.h + COMMAND ${CMAKE_COMMAND} -E make_directory ${PROTO_OUT} + COMMAND ${Protobuf_PROTOC_EXECUTABLE} + --proto_path=${PROTO_DIR} + --cpp_out=${PROTO_OUT} + --grpc_out=${PROTO_OUT} + --plugin=protoc-gen-grpc=${GRPC_CPP_PLUGIN} + ${PROTO_FILE} + DEPENDS ${PROTO_FILE} + COMMENT "Generating gRPC C++ from equipment.proto" + VERBATIM) + add_executable(secs_gemd + apps/secs_gemd.cpp + ${PROTO_OUT}/secsgem/v1/equipment.pb.cc + ${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc) + target_include_directories(secs_gemd PRIVATE ${PROTO_OUT} ${Protobuf_INCLUDE_DIRS}) + target_link_libraries(secs_gemd PRIVATE secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES}) + message(STATUS "secs_gemd daemon enabled (grpc++ ${GRPCPP_VERSION})") + else() + message(STATUS "secs_gemd skipped (need protobuf + grpc++ + grpc_cpp_plugin)") + endif() +endif() + if(SECSGEM_FUZZ) # libFuzzer entry-point targets. Each owns its own `main` via the # `-fsanitize=fuzzer` link flag; do NOT also link them into the @@ -170,6 +215,9 @@ add_executable(secsgem_tests tests/test_control_state.cpp tests/test_communication_state.cpp tests/test_data_model.cpp + tests/test_runtime.cpp + tests/test_default_handlers.cpp + tests/test_name_index.cpp tests/test_messages.cpp tests/test_loader.cpp tests/test_process_jobs.cpp diff --git a/Dockerfile b/Dockerfile index 0bf8ea2..c7b2586 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git \ libasio-dev \ libyaml-cpp-dev \ + libprotobuf-dev \ + protobuf-compiler \ + protobuf-compiler-grpc \ + libgrpc++-dev \ python3 \ python3-yaml \ ca-certificates \ diff --git a/apps/secs_gemd.cpp b/apps/secs_gemd.cpp new file mode 100644 index 0000000..3f4a21d --- /dev/null +++ b/apps/secs_gemd.cpp @@ -0,0 +1,128 @@ +// secs_gemd — the SECS/GEM daemon. +// +// Runs the GEM engine (EquipmentRuntime + the default handlers) on a background +// thread, owning the HSMS link to the host, and exposes a small name-based gRPC +// API (proto/secsgem/v1/equipment.proto) so a tool's software — in any language +// — can drive the equipment without linking C++ or knowing SEMI. +// +// This is increment 1: the universal "report state to the host" RPCs plus +// control-state visibility. Events, alarms, and the host->tool Subscribe stream +// follow (see docs/DAEMON_ROADMAP.md). + +#include + +#include +#include +#include +#include + +#include "secsgem/gem/default_handlers.hpp" +#include "secsgem/gem/name_index.hpp" +#include "secsgem/gem/runtime.hpp" +#include "secsgem/secs2/item.hpp" +#include "secsgem/v1/equipment.grpc.pb.h" +#include "secsgem/v1/equipment.pb.h" + +namespace gem = secsgem::gem; +namespace s2 = secsgem::secs2; +namespace pb = secsgem::v1; + +namespace { + +// proto Value -> SECS-II Item. Increment 1 uses a direct mapping; format-aware +// conversion (honouring the variable's declared wire format) is a refinement. +s2::Item to_item(const pb::Value& v) { + switch (v.kind_case()) { + case pb::Value::kText: return s2::Item::ascii(v.text()); + case pb::Value::kInteger: return s2::Item::i8(static_cast(v.integer())); + case pb::Value::kReal: return s2::Item::f8(v.real()); + case pb::Value::kBoolean: return s2::Item::boolean(v.boolean()); + default: return s2::Item::ascii(""); + } +} + +pb::ControlState::State to_proto_state(gem::ControlState s) { + switch (s) { + case gem::ControlState::EquipmentOffline: return pb::ControlState::EQUIPMENT_OFFLINE; + case gem::ControlState::AttemptOnline: return pb::ControlState::ATTEMPT_ONLINE; + case gem::ControlState::HostOffline: return pb::ControlState::HOST_OFFLINE; + case gem::ControlState::OnlineLocal: return pb::ControlState::ONLINE_LOCAL; + case gem::ControlState::OnlineRemote: return pb::ControlState::ONLINE_REMOTE; + } + return pb::ControlState::EQUIPMENT_OFFLINE; +} + +class EquipmentService final : public pb::Equipment::Service { + public: + explicit EquipmentService(gem::EquipmentRuntime& rt) : rt_(rt) {} + + grpc::Status SetVariables(grpc::ServerContext*, const pb::VariableUpdate* req, + pb::Ack* resp) override { + for (const auto& kv : req->values()) { + auto vid = gem::resolve_variable(rt_.model(), kv.first); + if (!vid) { + resp->set_code(pb::Ack::PARAMETER_INVALID); + resp->set_message("no variable named '" + kv.first + "'"); + return grpc::Status::OK; + } + rt_.set_variable(*vid, to_item(kv.second)); + } + resp->set_code(pb::Ack::ACCEPT); + return grpc::Status::OK; + } + + grpc::Status GetControlState(grpc::ServerContext*, const pb::Empty*, + pb::ControlState* resp) override { + resp->set_state(to_proto_state(rt_.control_state())); + return grpc::Status::OK; + } + + private: + gem::EquipmentRuntime& rt_; +}; + +std::string arg(int argc, char** argv, const std::string& key, const std::string& def) { + for (int i = 1; i + 1 < argc; ++i) + if (key == argv[i]) return argv[i + 1]; + return def; +} + +} // namespace + +int main(int argc, char** argv) { + const std::string hsms_port = arg(argc, argv, "--port", "5000"); + const std::string grpc_addr = arg(argc, argv, "--grpc", "0.0.0.0:50051"); + const std::string cfgdir = arg(argc, argv, "--config-dir", "data"); + + gem::EquipmentRuntime::Config cfg; + cfg.equipment_yaml = cfgdir + "/equipment.yaml"; + cfg.control_state_yaml = cfgdir + "/control_state.yaml"; + cfg.process_job_yaml = cfgdir + "/process_job_state.yaml"; + cfg.control_job_yaml = cfgdir + "/control_job_state.yaml"; + cfg.port = static_cast(std::stoi(hsms_port)); + cfg.log = [](const std::string& m) { std::cout << "[gemd] " << m << std::endl; }; + + std::unique_ptr rt; + try { + rt = std::make_unique(cfg); + } catch (const std::exception& e) { + std::cerr << "[gemd] config error: " << e.what() << std::endl; + return 1; + } + gem::register_default_handlers(*rt); + rt->run_async(); // engine + HSMS link on a background thread + + EquipmentService service(*rt); + grpc::ServerBuilder builder; + builder.AddListeningPort(grpc_addr, grpc::InsecureServerCredentials()); + builder.RegisterService(&service); + std::unique_ptr server(builder.BuildAndStart()); + if (!server) { + std::cerr << "[gemd] failed to start gRPC server on " << grpc_addr << std::endl; + return 1; + } + std::cout << "[gemd] gRPC on " << grpc_addr + << "; HSMS equipment on :" << hsms_port << std::endl; + server->Wait(); + return 0; +} diff --git a/apps/secs_server.cpp b/apps/secs_server.cpp index cea7c80..c60119e 100644 --- a/apps/secs_server.cpp +++ b/apps/secs_server.cpp @@ -22,6 +22,8 @@ #include "secsgem/gem/e90_constants.hpp" #include "secsgem/gem/messages.hpp" #include "secsgem/gem/router.hpp" +#include "secsgem/gem/runtime.hpp" +#include "secsgem/gem/default_handlers.hpp" #include "secsgem/secs2/message.hpp" using namespace secsgem; @@ -42,14 +44,6 @@ bool has_flag(int argc, char** argv, const std::string& key) { return false; } -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())); -} - } // namespace int main(int argc, char** argv) { @@ -84,1187 +78,36 @@ int main(int argc, char** argv) { return v.has_errors() ? 1 : 0; } - auto model = std::make_shared(); - if (!spool_dir.empty()) { - model->spool.enable_persistence(spool_dir); - logfn("spool: persisting to " + spool_dir + - " (replayed " + std::to_string(model->spool.size()) + " messages)"); - } - config::EquipmentDescriptor desc; - config::ControlStateConfig sm_cfg; + // The engine — io_context, passive Server, model, control-state machine, + // Router, emit/spool plumbing — now lives in EquipmentRuntime. Construct it + // from the YAML config; the handler-registration block below wires GEM + // behaviour onto it through the aliases that follow. + gem::EquipmentRuntime::Config rt_cfg; + rt_cfg.equipment_yaml = equipment_yaml; + rt_cfg.control_state_yaml = state_yaml; + rt_cfg.process_job_yaml = pj_state_yaml; + rt_cfg.control_job_yaml = cj_state_yaml; + rt_cfg.port = port; + rt_cfg.spool_dir = spool_dir; + rt_cfg.log = logfn; + + std::unique_ptr runtime; try { - desc = config::load_equipment(equipment_yaml, *model); - sm_cfg = config::load_control_state(state_yaml); + runtime = std::make_unique(rt_cfg); } catch (const std::exception& e) { std::cerr << "[equip] config error: " << e.what() << std::endl; return 1; } - logfn("loaded " + std::to_string(model->svids.size()) + " SVIDs, " + - std::to_string(model->ecids.all().size()) + " ECIDs, " + - std::to_string(model->events.all_events().size()) + " CEIDs, " + - std::to_string(model->alarms.all().size()) + " alarms"); + auto& R = *runtime; + logfn("loaded " + std::to_string(R.model().svids.size()) + " SVIDs, " + + std::to_string(R.model().ecids.all().size()) + " ECIDs, " + + std::to_string(R.model().events.all_events().size()) + " CEIDs, " + + std::to_string(R.model().alarms.all().size()) + " alarms"); - auto sm = std::make_shared(sm_cfg.table, sm_cfg.initial); + gem::register_default_handlers(R); - asio::io_context io; - Server::Config server_cfg{port, desc.device_id, {}}; - Server server(io, server_cfg); - server.on_log(logfn); - - auto active_conn = std::make_shared>(); - - // Deliver a primary message to the host, or spool it if we can't (either - // there is no SELECTED session or the spool's force-flag is on). Returns - // true if the message was delivered or queued; false if dropped because - // the stream isn't spoolable and there is no live session. - auto deliver_or_spool = [active_conn, model, logfn](s2::Message msg, - const std::string& what) -> bool { - auto conn = active_conn->lock(); - const bool spooling = model->spool.force_spool() || !conn; - if (spooling) { - auto r = model->spool.enqueue(msg); - if (r == gem::SpoolStore::EnqueueResult::Queued) { - logfn("spool: " + what + " queued (depth=" + - std::to_string(model->spool.size()) + ")"); - return true; - } - logfn("spool: " + what + " dropped (stream not spoolable, no host)"); - return false; - } - // W=1 primaries use send_request (transaction tracking); W=0 primaries - // (e.g. S16F9 PRJobAlert) go via send_data so we don't register a - // never-arriving "reply" and time out on T3. - if (msg.reply_expected) { - conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {}); - } else { - conn->send_data(std::move(msg)); - } - return true; - }; - - auto emit_event = [&io, model, logfn, deliver_or_spool](uint32_t ceid) { - asio::post(io, [model, logfn, deliver_or_spool, ceid]() { - if (!model->is_event_enabled(ceid)) { - logfn("CEID " + std::to_string(ceid) + " not enabled; suppressed"); - return; - } - auto reports = model->compose_reports_for(ceid); - logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" + - std::to_string(reports.size()) + " reports)"); - deliver_or_spool(gem::s6f11_event_report(0, ceid, reports), - "S6F11 CEID=" + std::to_string(ceid)); - }); - }; - - auto emit_alarm_set = [&io, model, logfn, emit_event, deliver_or_spool](uint32_t alid) { - asio::post(io, [model, logfn, emit_event, deliver_or_spool, alid]() { - auto alarm = model->alarms.get(alid); - if (!alarm) return; - auto alcd = model->alarms.set_active(alid); - if (!alcd) return; - if (model->alarms.enabled(alid)) { - logfn("emit S5F1 alarm set ALID=" + std::to_string(alid)); - deliver_or_spool(gem::s5f1_alarm_report(*alcd, alid, alarm->text), - "S5F1 ALID=" + std::to_string(alid)); - } else { - logfn("alarm " + std::to_string(alid) + " not enabled; suppressed"); - } - // E30: an AlarmSetEvent CEID also fires (if linked + enabled). - }); - }; - - sm->set_state_change_handler( - [logfn, emit_event, desc](gem::ControlState from, gem::ControlState to, gem::ControlEvent ev) { - logfn(std::string("control: ") + gem::control_state_name(from) + " -> " + - gem::control_state_name(to) + " (" + gem::control_event_name(ev) + ")"); - if (desc.emit_on_control_change) emit_event(*desc.emit_on_control_change); - }); - - // ---- E40/E94: load the PJ/CJ transition tables and wire emitters ----- - // (pj_state_yaml / cj_state_yaml already parsed at the top so - // --validate-config sees them.) - config::ProcessJobStateConfig pj_cfg; - config::ControlJobStateConfig cj_cfg; - try { - pj_cfg = config::load_process_job_state(pj_state_yaml); - cj_cfg = config::load_control_job_state(cj_state_yaml); - } catch (const std::exception& e) { - std::cerr << "[equip] E40/E94 config error: " << e.what() << std::endl; - return 1; - } - // Each new PJ/CJ gets a fresh copy of the loaded transition table. - model->process_jobs.set_table_factory([t = pj_cfg.table]() { return t; }); - model->control_jobs.set_table_factory([t = cj_cfg.table]() { return t; }); - - // 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); - } - // All PJs done -> CJ Completed. - model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::AllJobsComplete); - logfn("CJ " + ctljobid + " lifecycle complete"); - }); - }; - - // ---- E5 exception state-change emitters ------------------------------- - // Translate ExceptionStore state changes into S5F9/F11/F15 on the wire, - // mirroring how PJ state changes become S16F9. The synthetic - // NoState->Posted event is the trigger for emitting S5F9 (we look up - // the metadata from the store so we can build the full body). - model->exceptions.set_state_change_handler( - [model, logfn, deliver_or_spool](uint32_t exid, - gem::ExceptionState from, - gem::ExceptionState to, - gem::ExceptionEvent trig) { - logfn(std::string("EX ") + std::to_string(exid) + ": " + - gem::exception_state_name(from) + " -> " + - gem::exception_state_name(to) + " (" + - gem::exception_event_name(trig) + ")"); - - if (from == gem::ExceptionState::NoState && - to == gem::ExceptionState::Posted) { - const auto* ex = model->exceptions.get(exid); - if (!ex) return; - deliver_or_spool( - gem::s5f9_exception_post_notify(exid, ex->extype, ex->exmessage, - ex->exrecvra), - "S5F9 EXID=" + std::to_string(exid)); - return; - } - - if (to == gem::ExceptionState::Cleared) { - if (from == gem::ExceptionState::Recovering) { - // RecoveryComplete -> emit S5F15 with success EXRESULT. - deliver_or_spool( - gem::s5f15_exception_recover_complete_notify(exid, "OK"), - "S5F15 EXID=" + std::to_string(exid)); - } - // S5F11 for any Cleared transition that didn't go through - // Recovering (autonomous clear or post-failure clear). - if (from == gem::ExceptionState::Posted || - from == gem::ExceptionState::RecoverFailed) { - deliver_or_spool( - gem::s5f11_exception_clear_notify(exid, "", ""), - "S5F11 EXID=" + std::to_string(exid)); - } - return; - } - - if (from == gem::ExceptionState::Recovering && - to == gem::ExceptionState::RecoverFailed) { - deliver_or_spool( - gem::s5f15_exception_recover_complete_notify(exid, "FAILED"), - "S5F15 EXID=" + std::to_string(exid)); - } - }); - - // ---- E90 substrate state-change emitters ----------------------------- - // Map SubstrateState / SubstrateProcessingState transitions to the - // standard E90 CEIDs. Only the post-transition state matters; the - // event-report machinery already gates on enabled CEIDs so emitting - // for every transition is safe and gives the host a complete trace. - model->substrates.set_location_handler( - [logfn, emit_event](const std::string& substid, - gem::SubstrateState from, - gem::SubstrateState to, - gem::SubstrateEvent ev) { - logfn(std::string("SUB ") + substid + ": " + - gem::substrate_state_name(from) + " -> " + - gem::substrate_state_name(to) + " (" + - gem::substrate_event_name(ev) + ")"); - switch (to) { - case gem::SubstrateState::AtSource: - emit_event(gem::e90::kCeidSubstrateAtSource); break; - case gem::SubstrateState::AtWork: - emit_event(gem::e90::kCeidSubstrateAtWork); break; - case gem::SubstrateState::AtDestination: - emit_event(gem::e90::kCeidSubstrateAtDestination); break; - case gem::SubstrateState::NoState: break; - } - }); - - model->substrates.set_processing_handler( - [logfn, emit_event](const std::string& substid, - gem::SubstrateProcessingState from, - gem::SubstrateProcessingState to, - gem::SubstrateProcessingEvent ev) { - logfn(std::string("SUB ") + substid + " proc: " + - gem::substrate_processing_state_name(from) + " -> " + - gem::substrate_processing_state_name(to) + " (" + - gem::substrate_processing_event_name(ev) + ")"); - switch (to) { - case gem::SubstrateProcessingState::NeedsProcessing: - emit_event(gem::e90::kCeidSubstrateNeedsProcessing); break; - case gem::SubstrateProcessingState::InProcess: - emit_event(gem::e90::kCeidSubstrateInProcess); break; - case gem::SubstrateProcessingState::Processed: - emit_event(gem::e90::kCeidSubstrateProcessed); break; - case gem::SubstrateProcessingState::Aborted: - emit_event(gem::e90::kCeidSubstrateAborted); break; - case gem::SubstrateProcessingState::Stopped: - emit_event(gem::e90::kCeidSubstrateStopped); break; - case gem::SubstrateProcessingState::Rejected: - emit_event(gem::e90::kCeidSubstrateRejected); break; - case gem::SubstrateProcessingState::Lost: - emit_event(gem::e90::kCeidSubstrateLost); break; - case gem::SubstrateProcessingState::Skipped: - emit_event(gem::e90::kCeidSubstrateSkipped); break; - case gem::SubstrateProcessingState::NoState: break; - } - }); - - // ---- E116 EPT state-change emitter ----------------------------------- - model->ept.set_state_change_handler( - [logfn, emit_event](gem::EptState from, gem::EptState to, - gem::EptEvent ev, std::chrono::milliseconds dwell) { - logfn(std::string("EPT: ") + gem::ept_state_name(from) + " -> " + - gem::ept_state_name(to) + " (" + gem::ept_event_name(ev) + - ", dwelt " + std::to_string(dwell.count()) + "ms)"); - switch (to) { - case gem::EptState::NonScheduledTime: - emit_event(gem::e116::kCeidNonScheduledTime); break; - case gem::EptState::ScheduledDowntime: - emit_event(gem::e116::kCeidScheduledDowntime); break; - case gem::EptState::UnscheduledDowntime: - emit_event(gem::e116::kCeidUnscheduledDowntime); break; - case gem::EptState::Engineering: - emit_event(gem::e116::kCeidEngineering); break; - case gem::EptState::Standby: - emit_event(gem::e116::kCeidStandby); break; - case gem::EptState::Productive: - emit_event(gem::e116::kCeidProductive); break; - case gem::EptState::NoState: break; - } - }); - - // ---- E157 module state-change emitter -------------------------------- - // Every module transition fires the generic ModuleProcessStateChange - // CEID plus the state-specific one, mirroring secsgem-py's per-module - // CEID granularity. Hosts that don't subscribe to the specific CEID - // can still listen on the generic one to drive an internal FSM. - model->modules.set_state_change_handler( - [logfn, emit_event](const std::string& mod, gem::ModuleState from, - gem::ModuleState to, gem::ModuleEvent ev) { - logfn(std::string("MOD ") + mod + ": " + - gem::module_state_name(from) + " -> " + - gem::module_state_name(to) + " (" + - gem::module_event_name(ev) + ")"); - emit_event(gem::e157::kCeidModuleProcessStateChange); - switch (to) { - case gem::ModuleState::NotExecuting: - emit_event(gem::e157::kCeidModuleNotExecuting); break; - case gem::ModuleState::GeneralExecuting: - emit_event(gem::e157::kCeidModuleGeneralExecuting); break; - case gem::ModuleState::StepExecuting: - emit_event(gem::e157::kCeidModuleStepExecuting); break; - case gem::ModuleState::StepCompleted: - emit_event(gem::e157::kCeidModuleStepCompleted); break; - case gem::ModuleState::NoState: break; - } - }); - - // ---- Build the SECS dispatch table once ------------------------------- - gem::Router router; - - 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 dispatch --------------------------------- - // S3F17 maps the textual CARRIERACTION string onto a CarrierIDEvent - // and fires it against the matching carrier. Unknown actions return - // CarrierActionInvalid; unknown carriers return CarrierIDUnknown. - router.on(3, 17, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s3f17(msg); - if (!req) return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::ParameterInvalid); - if (!model->carriers.has(req->carrierid)) - return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::CarrierIDUnknown); - - auto ack = gem::CarrierActionAck::Accept; - if (req->carrieraction == "ProceedWithCarrier") { - model->carriers.fire_id_event(req->carrierid, - gem::CarrierIDEvent::ProceedWithCarrier); - } else if (req->carrieraction == "CancelCarrier") { - model->carriers.fire_id_event(req->carrierid, - gem::CarrierIDEvent::CancelCarrier); - } else if (req->carrieraction == "BindCarrierID") { - model->carriers.fire_id_event(req->carrierid, gem::CarrierIDEvent::Bind); - } else { - ack = gem::CarrierActionAck::CarrierActionInvalid; - } - logfn("S3F17 CARRIER=" + req->carrierid + " action=" + req->carrieraction + - " -> S3F18 CAACK=" + std::to_string(static_cast(ack))); - return gem::s3f18_carrier_action_ack(ack); - }); - - // S3F25 — host instructs equipment to move a carrier between ports. - // We record the new port binding on the Carrier and fire the source - // port's StartUnloading + target port's StartLoading transfer events; - // application code is responsible for completing them. - router.on(3, 25, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s3f25(msg); - if (!req) - return gem::s3f26_carrier_transfer_ack(gem::CarrierActionAck::ParameterInvalid); - auto* c = model->carriers.get(req->carrierid); - if (!c) - return gem::s3f26_carrier_transfer_ack(gem::CarrierActionAck::CarrierIDUnknown); - model->load_ports.fire_transfer_event(req->source_portid, - gem::LoadPortTransferEvent::StartUnloading); - model->load_ports.fire_transfer_event(req->target_portid, - gem::LoadPortTransferEvent::StartLoading); - c->port_id = req->target_portid; - logfn("S3F25 CARRIER=" + req->carrierid + - " " + std::to_string(req->source_portid) + "->" + - std::to_string(req->target_portid)); - return gem::s3f26_carrier_transfer_ack(gem::CarrierActionAck::Accept); - }); - - // S3F19 — Slot Map Verify. Host sends its expected slot map for - // CARRIERID; equipment compares against locally-stored slots and - // drives CSMS (NotRead -> Read on Accept, -> Mismatched on Mismatch). - router.on(3, 19, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s3f19(msg); - if (!req) - return gem::s3f20_slot_map_verify_ack(gem::SlotMapVerifyAck::Error); - auto* c = model->carriers.get(req->carrierid); - if (!c) - return gem::s3f20_slot_map_verify_ack(gem::SlotMapVerifyAck::CarrierUnknown); - bool match = c->slots.size() == req->slots.size(); - if (match) { - for (std::size_t i = 0; i < req->slots.size(); ++i) { - if (static_cast(c->slots[i].state) != - static_cast(req->slots[i])) { match = false; break; } - } - } - if (match) { - model->carriers.fire_slot_map_event(req->carrierid, gem::SlotMapEvent::Read); - logfn("S3F19 CARRIER=" + req->carrierid + " -> S3F20 Accept"); - return gem::s3f20_slot_map_verify_ack(gem::SlotMapVerifyAck::Accept); - } - model->carriers.fire_slot_map_event(req->carrierid, gem::SlotMapEvent::Mismatch); - logfn("S3F19 CARRIER=" + req->carrierid + " -> S3F20 Mismatch"); - return gem::s3f20_slot_map_verify_ack(gem::SlotMapVerifyAck::Mismatch); - }); - - // S3F27 — Cancel Carrier (single-EXID form). - router.on(3, 27, [model, logfn](const s2::Message& msg) { - auto cid = gem::parse_s3f27(msg); - if (!cid || !model->carriers.has(*cid)) - return gem::s3f28_cancel_carrier_ack(gem::CarrierActionAck::CarrierIDUnknown); - model->carriers.fire_id_event(*cid, gem::CarrierIDEvent::CancelCarrier); - model->carriers.fire_access_event(*cid, gem::CarrierAccessEvent::Cancel); - logfn("S3F27 CARRIER=" + *cid + " cancelled"); - return gem::s3f28_cancel_carrier_ack(gem::CarrierActionAck::Accept); - }); - - // S7F1 — Process Program Load Inquire. Host asks permission to send - // LENGTH bytes for PPID; equipment responds with PPGNT. Policy here: - // 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. - router.on(7, 1, [logfn](const s2::Message& msg) { - auto req = gem::parse_s7f1(msg); - auto ack = gem::ProcessProgramAck::Accept; - if (!req || req->ppid.empty()) ack = gem::ProcessProgramAck::PpidNotFound; - else if (req->length > 16u * 1024u * 1024u) ack = gem::ProcessProgramAck::MatrixOverflow; - logfn("S7F1 PPID=" + (req ? req->ppid : std::string{"?"}) + - " LEN=" + std::to_string(req ? req->length : 0) + - " -> S7F2 PPGNT=" + std::to_string(static_cast(ack))); - return gem::s7f2_pp_load_grant(ack); - }); - router.on(7, 3, [model, logfn](const s2::Message& msg) { - auto pp = gem::parse_s7f3(msg); - if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError); - model->recipes.add(pp->ppid, pp->ppbody); - logfn("S7F3 PPID=" + pp->ppid + " -> S7F4 (Accept)"); - return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept); - }); - // S7F17 — Delete Process Program. Empty PPID list deletes all; - // otherwise we remove each PPID and aggregate the worst ack. - router.on(7, 17, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s7f17(msg); - if (!req) return gem::s7f18_delete_pp_ack(gem::ProcessProgramAck::LengthError); - auto ack = gem::ProcessProgramAck::Accept; - if (req->empty()) { - const auto all = model->recipes.list(); - for (const auto& id : all) model->recipes.remove(id); - logfn("S7F17 delete-all (" + std::to_string(all.size()) + ") -> S7F18 Accept"); - } else { - for (const auto& id : *req) { - auto r = model->recipes.remove(id); - if (r != gem::ProcessProgramAck::Accept) ack = r; - } - logfn("S7F17 delete " + std::to_string(req->size()) + - " PPIDs -> S7F18 ACKC7=" + std::to_string(static_cast(ack))); - } - return gem::s7f18_delete_pp_ack(ack); - }); - router.on(7, 5, [model, logfn](const s2::Message& msg) { - auto ppid = gem::parse_s7f5(msg); - if (!ppid) return gem::s7f6_process_program_data("", ""); - auto body = model->recipes.get(*ppid); - logfn("S7F5 PPID=" + *ppid + " -> S7F6"); - return gem::s7f6_process_program_data(*ppid, body ? *body : ""); - }); - router.on(7, 19, [model, logfn](const s2::Message&) { - auto list = model->recipes.list(); - logfn("S7F19 -> S7F20 (" + std::to_string(list.size()) + " PPIDs)"); - return gem::s7f20_current_eppd_data(list); - }); - - // ---- E39 generic ObjectService ---------------------------------------- - // S14F1 GetAttr / S14F3 SetAttr against the CemObjectStore. OBJTYPE - // is validated against the stored object's type name (case-sensitive). - router.on(14, 1, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s14f1(msg); - if (!req) return gem::s14f2_get_attr_data({}, gem::ObjectAck::Error); - auto* obj = model->cem.get(req->objspec); - if (!obj) - return gem::s14f2_get_attr_data({}, gem::ObjectAck::Denied_UnknownObject); - if (gem::cem_object_type_name(obj->objtype) != req->objtype) - return gem::s14f2_get_attr_data({}, gem::ObjectAck::Denied_InvalidAttribute); - std::vector attrs; - attrs.reserve(req->attrids.size()); - for (const auto& id : req->attrids) { - auto v = model->cem.get_attr(req->objspec, id); - attrs.push_back({id, v.value_or(s2::Item::ascii(""))}); - } - logfn("S14F1 " + req->objspec + " (" + - std::to_string(req->attrids.size()) + " attrs) -> S14F2"); - return gem::s14f2_get_attr_data(attrs, gem::ObjectAck::Success); - }); - router.on(14, 3, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s14f3(msg); - if (!req) - return gem::s14f4_set_attr_ack({}, gem::ObjectAck::Error); - auto* obj = model->cem.get(req->objspec); - if (!obj) - return gem::s14f4_set_attr_ack({}, gem::ObjectAck::Denied_UnknownObject); - if (gem::cem_object_type_name(obj->objtype) != req->objtype) - return gem::s14f4_set_attr_ack({}, gem::ObjectAck::Denied_InvalidAttribute); - for (const auto& a : req->attrs) { - model->cem.set_attr(req->objspec, a.attrid, a.value); - } - // Reply echoes back the now-stored values. - std::vector reply; - reply.reserve(req->attrs.size()); - for (const auto& a : req->attrs) { - auto v = model->cem.get_attr(req->objspec, a.attrid); - reply.push_back({a.attrid, v.value_or(s2::Item::ascii(""))}); - } - logfn("S14F3 " + req->objspec + " (" + - std::to_string(req->attrs.size()) + " attrs) -> S14F4"); - return gem::s14f4_set_attr_ack(reply, gem::ObjectAck::Success); - }); - - // ---- E40 / E94 ------------------------------------------------------- - router.on(14, 9, [model, logfn, run_cj_lifecycle](const s2::Message& msg) { - (void)run_cj_lifecycle; - auto req = gem::parse_s14f9(msg); - if (!req) { - logfn("S14F9 -> S14F10 Error (malformed body)"); - return gem::s14f10_create_control_job_ack("", gem::ObjectAck::Error); - } - auto r = model->control_jobs.create( - req->ctljobid, req->prjobids, - [model](const std::string& id) { return model->process_jobs.has(id); }); - gem::ObjectAck ack = gem::ObjectAck::Success; - switch (r) { - case gem::ControlJobStore::CreateResult::Created: ack = gem::ObjectAck::Success; break; - case gem::ControlJobStore::CreateResult::Denied_AlreadyExists: - ack = gem::ObjectAck::Denied_AlreadyExists; break; - case gem::ControlJobStore::CreateResult::Denied_UnknownPRJob: - ack = gem::ObjectAck::Denied_UnknownObject; break; - case gem::ControlJobStore::CreateResult::Denied_Empty: - ack = gem::ObjectAck::Denied_InvalidAttribute; break; - } - logfn("S14F9 CJ=" + req->ctljobid + " -> S14F10 OBJACK=" + - std::to_string(static_cast(ack))); - return gem::s14f10_create_control_job_ack(req->ctljobid, ack); - }); - router.on(14, 11, [model, logfn](const s2::Message& msg) { - auto id = gem::parse_s14f11(msg); - if (!id) return gem::s14f12_delete_control_job_ack(gem::ObjectAck::Error); - const auto removed = model->control_jobs.remove(*id); - logfn("S14F11 delete CJ=" + *id + " -> S14F12 " + - (removed ? "Success" : "UnknownObject")); - return gem::s14f12_delete_control_job_ack( - removed ? gem::ObjectAck::Success : gem::ObjectAck::Denied_UnknownObject); - }); - - router.on(16, 11, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s16f11(msg); - if (!req) return gem::s16f12_pr_job_create_ack(gem::HostCmdAck::ParameterInvalid); - auto r = model->process_jobs.create( - req->prjobid, req->rcpspec.ppid, req->mtrloutspec, - [model](const std::string& ppid) { - return model->recipes.get(ppid).has_value(); - }); - gem::HostCmdAck ack = gem::HostCmdAck::Accept; - switch (r) { - case gem::ProcessJobStore::CreateResult::Created: ack = gem::HostCmdAck::Accept; break; - case gem::ProcessJobStore::CreateResult::Denied_AlreadyExists: - ack = gem::HostCmdAck::Rejected; break; - case gem::ProcessJobStore::CreateResult::Denied_InvalidPpid: - ack = gem::HostCmdAck::ParameterInvalid; break; - } - if (ack == gem::HostCmdAck::Accept) { - // Persist the optional E40-0705 trailers (MF / recipe-method / - // recipe variables / process parameters) on the freshly created PJ. - std::vector rcpvars; - rcpvars.reserve(req->rcpspec.rcpvars.size()); - for (auto& v : req->rcpspec.rcpvars) rcpvars.push_back({v.name, v.value}); - std::vector params; - params.reserve(req->prprocessparams.size()); - for (auto& p : req->prprocessparams) params.push_back({p.name, p.value}); - model->process_jobs.set_e40_extras(req->prjobid, req->mf, - req->prrecipemethod, - std::move(rcpvars), - std::move(params)); - } - logfn("S16F11 PJ=" + req->prjobid + " PPID=" + req->rcpspec.ppid + - " MF=" + std::to_string(static_cast(req->mf)) + - " RM=" + std::to_string(static_cast(req->prrecipemethod)) + - " rcpvars=" + std::to_string(req->rcpspec.rcpvars.size()) + - " params=" + std::to_string(req->prprocessparams.size()) + - " -> S16F12 HCACK=" + std::to_string(static_cast(ack))); - return gem::s16f12_pr_job_create_ack(ack); - }); - router.on(16, 13, [model, logfn](const s2::Message& msg) { - auto id = gem::parse_s16f13(msg); - auto ack = id ? model->process_jobs.dequeue(*id) : gem::HostCmdAck::ParameterInvalid; - logfn("S16F13 PJ=" + (id ? *id : std::string{"?"}) + - " -> S16F14 HCACK=" + std::to_string(static_cast(ack))); - return gem::s16f14_pr_job_dequeue_ack(ack); - }); - router.on(16, 7, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s16f7(msg); - if (!req) return gem::s16f8_pr_job_monitor_ack(gem::HostCmdAck::ParameterInvalid); - bool any_bad = false; - for (const auto& e : req->entries) { - const bool enable = (e.pralert & 0x80) != 0; - if (!model->process_jobs.set_alert(e.prjobid, enable)) any_bad = true; - } - const auto ack = any_bad ? gem::HostCmdAck::InvalidObject : gem::HostCmdAck::Accept; - logfn("S16F7 monitor " + std::to_string(req->entries.size()) + - " jobs -> S16F8 HCACK=" + std::to_string(static_cast(ack))); - return gem::s16f8_pr_job_monitor_ack(ack); - }); - router.on(16, 15, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s16f15(msg); - if (!req) - return gem::s16f16_pr_job_create_multi_ack(std::vector{}); - std::vector results; - results.reserve(req->jobs.size()); - for (const auto& job : req->jobs) { - auto r = model->process_jobs.create( - job.prjobid, job.ppid, job.mtrloutspec, - [model](const std::string& ppid) { - return model->recipes.get(ppid).has_value(); - }); - gem::HostCmdAck ack = gem::HostCmdAck::Accept; - switch (r) { - case gem::ProcessJobStore::CreateResult::Created: break; - case gem::ProcessJobStore::CreateResult::Denied_AlreadyExists: - ack = gem::HostCmdAck::Rejected; break; - case gem::ProcessJobStore::CreateResult::Denied_InvalidPpid: - ack = gem::HostCmdAck::ParameterInvalid; break; - } - results.push_back({job.prjobid, ack}); - } - logfn("S16F15 multi-create " + std::to_string(req->jobs.size()) + - " jobs -> S16F16"); - return gem::s16f16_pr_job_create_multi_ack(results); - }); - router.on(16, 5, [model, logfn](const s2::Message& msg) { - auto req = gem::parse_s16f5(msg); - if (!req) return gem::s16f6_pr_job_command_ack(gem::HostCmdAck::ParameterInvalid); - auto ev = gem::pr_cmd_to_event(req->prcmd); - if (!ev) return gem::s16f6_pr_job_command_ack(gem::HostCmdAck::InvalidCommand); - auto ack = model->process_jobs.on_host_command(req->prjobid, *ev); - logfn("S16F5 PJ=" + req->prjobid + " " + req->prcmd + - " -> S16F6 HCACK=" + std::to_string(static_cast(ack))); - return gem::s16f6_pr_job_command_ack(ack); - }); - router.on(16, 27, [model, logfn, run_cj_lifecycle](const s2::Message& msg) { - auto req = gem::parse_s16f27(msg); - if (!req) return gem::s16f28_cj_command_ack(gem::HostCmdAck::ParameterInvalid); - auto ev = gem::ctl_cmd_to_event(req->ctljobcmd); - if (!ev) return gem::s16f28_cj_command_ack(gem::HostCmdAck::InvalidCommand); - // CJSTART semantics: implicit Select -> SetupComplete -> Start - // cascade so a Queued CJ can be started in one host action. The - // cascade is the equipment policy; the FSM rules still gate every - // step. - auto ack = gem::HostCmdAck::Accept; - if (*ev == gem::ControlJobEvent::Start) { - run_cj_lifecycle(req->ctljobid); - } else { - ack = model->control_jobs.on_host_command(req->ctljobid, *ev); - } - logfn("S16F27 CJ=" + req->ctljobid + " " + req->ctljobcmd + - " -> S16F28 HCACK=" + std::to_string(static_cast(ack))); - return gem::s16f28_cj_command_ack(ack); - }); - - 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); - return gem::s10f2_terminal_display_ack(gem::TerminalAck::Accepted); - }); - // S10F3 is the canonical SEMI E5 host→equipment "Terminal Display Single" - // (S10F1 is documented in the spec as equipment→host); secsgem-py and - // other reference libraries use F3. We accept both for compatibility. - router.on(10, 3, [logfn](const s2::Message& msg) { - auto td = gem::parse_s10f3(msg); - if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text); - return gem::s10f4_terminal_display_ack(gem::TerminalAck::Accepted); - }); - router.on(10, 5, [logfn](const s2::Message& msg) { - auto td = gem::parse_s10f5(msg); - if (td) { - logfn("TERMINAL[" + std::to_string(td->tid) + "] (" + - std::to_string(td->lines.size()) + " lines)"); - for (const auto& l : td->lines) logfn(" | " + l); - } - return gem::s10f6_terminal_display_multi_ack(gem::TerminalAck::Accepted); - }); - - logfn("registered " + std::to_string(router.size()) + " (stream,function) handlers"); - - // ---- Wire the router into accepted connections ----------------------- - server.on_connection([&io, sm, model, logfn, active_conn, &router, desc]( - std::shared_ptr conn) { - *active_conn = conn; - conn->set_closed_handler([active_conn](const std::string&) { active_conn->reset(); }); - - // E30 §6.22: on entering SELECTED, if there's spooled data, notify - // the host via S6F25 so it can decide (S6F23 Transmit vs Purge). - // Our happy-path demo never drops the link so this branch doesn't - // fire there — but the wiring is the canonical re-SELECT trigger. - conn->set_selected_handler([logfn, sm, model, &io, active_conn]() { - logfn(std::string("host is online; control=") + gem::control_state_name(sm->state())); - if (model->spool.size() == 0) return; - asio::post(io, [active_conn, model, logfn]() { - auto c = active_conn->lock(); - if (!c) return; - const uint32_t n = static_cast(model->spool.size()); - logfn("spool: notifying host of " + std::to_string(n) + " queued messages"); - c->send_request(gem::s6f25_spool_data_ready(n), - [](std::error_code, const s2::Message&) {}); - }); - }); - - // Wrap router.dispatch so we can emit S9F3 / S9F5 when an inbound - // primary has no registered handler. We use Connection's - // current_header() accessor to capture the offending MHEAD; without - // it we'd be left fabricating a synthetic header. - std::weak_ptr wconn = conn; - conn->set_message_handler([&router, wconn, logfn](const s2::Message& msg) - -> std::optional { - if (!router.has_handler(msg.stream, msg.function)) { - auto c = wconn.lock(); - if (c && c->current_header()) { - const uint8_t s9_function = - router.has_handler_for_stream(msg.stream) ? 5 : 3; - logfn("unhandled S" + std::to_string(msg.stream) + "F" + - std::to_string(msg.function) + "; emitting S9F" + - std::to_string(s9_function)); - c->emit_s9(s9_function, c->current_header()->encode()); - } - } - return router.dispatch(msg); - }); - }); - - server.start(); - io.run(); + // Accepting connections, the SELECTED spool-notify handler, and the + // S9-on-unhandled message dispatch are all wired by the runtime; run it. + R.run(); return 0; } diff --git a/docs/DAEMON_ROADMAP.md b/docs/DAEMON_ROADMAP.md new file mode 100644 index 0000000..cb1b7ae --- /dev/null +++ b/docs/DAEMON_ROADMAP.md @@ -0,0 +1,94 @@ +# Vendor Daemon & gRPC API — Status and Roadmap to Fab-Readiness + +> **This is a forward-looking roadmap, not a description of shipped behaviour.** +> Every item carries a status marker. Do not read an item as "done" unless it +> says ✅. (Written 2026-06-10.) +> +> Status legend: ✅ done · 🚧 in progress · ⬜ planned · ⚠️ risk/unknown + +## What this is + +A vendor-facing **daemon** that runs the SECS/GEM engine as its own process and +exposes a small, name-based, language-agnostic API over gRPC, so a tool's +control software (in any language) can drive the equipment without linking C++ +or knowing SEMI. See `proto/secsgem/v1/equipment.proto` for the API. + +The point of the daemon model: it owns the durable HSMS relationship with the +host and stays conformant while the tool software restarts/upgrades/crashes. + +## Current status (2026-06-10) + +| Piece | Status | Notes | +|---|---|---| +| `proto/secsgem/v1/equipment.proto` | ✅ | v1 API surface designed (universal + carrier/recipe/job tiers) | +| `HostCommandRegistry::set_handler` behaviour hook | ✅ | the engine seam the daemon binds to; tested | +| `EquipmentRuntime` (engine owner) | ✅ | infra + outbound API built & tested (`tests/test_runtime.cpp`); **`secs_server` now runs entirely on it** (verified by the live server↔client GEM300 demo — full job/spool/control-state flow, client exit 0). | +| `register_default_handlers` in the library (so the daemon reuses the 56 handlers) | ✅ | relocated into `src/gem/default_handlers.cpp` (programmatic move, zero retype); `secs_server` reduced to ~113 lines and calls it. Tested (`tests/test_default_handlers.cpp`: S1F1→S1F2, S2F41→on_command hook) + live GEM300 demo still passes. | +| gRPC/protobuf in toolchain (Dockerfile + CMake) | 🚧 | apt deps added to Dockerfile (`libgrpc++-dev libprotobuf-dev protobuf-compiler-grpc`); **image rebuild + CMake proto codegen still TODO**. | +| `secs_gemd` daemon implementing the service | ⬜ | translate RPCs ↔ runtime; stream host requests | +| Reference client library (Python) | ⬜ | thin wrapper over generated stubs | + +**Nothing in the proto is wired to the engine yet.** The engine itself is broad +(56 wire handlers across S1/2/3/5/6/7/10/14/16; all GEM300 stores) — the daemon +is about *exposing* that, not building it. + +## Gaps to fab-readiness + +### Layer 0 — Make it run at all (blocks everything) +- ⬜ Extract `EquipmentRuntime` from `apps/secs_server.cpp` (io_context, Server, + model, router, emit lambdas, `set_handler`). Reduce `secs_server` to a thin + `main()` over it. Verify against the existing test suite. +- ⬜ Add gRPC/protobuf to the Dockerfile + CMake codegen for the proto. +- ⬜ Implement `secs_gemd`: construct the runtime, `io.run()` on a background + thread, map each RPC to a runtime call, route host requests onto the + `Subscribe` stream via `set_handler` + the FSM change handlers. +- ⬜ One reference client (Python) proving the end-to-end loop. + +### Layer 1 — API completeness (engine supports these; surface them) +- ⬜ **Job/carrier in-the-loop semantics.** The proto has `ProcessJob`/ + `CarrierAction` + report RPCs, but the exact contract is unspecified: who acks + the host's S16F5/S3Fxx, whether the tool *gates* a job start or only observes, + and timing vs. T3. **Design this before implementing the daemon for process tools.** +- ⬜ Trace data collection (engine: `TraceStore`, S2F23/S6F1). +- ⬜ Limits monitoring (engine: `LimitMonitorStore`, S2F45). +- ⬜ Substrate/E90 + module/E157 tracking (engine: `SubstrateStore`, `ModuleStore`). +- ⬜ Terminal services / operator messages (engine: S10F1–F6) — host↔tool HMI text. +- ⬜ Spool depth + force-flush API (engine: `SpoolStore`). +- ⬜ `Describe` RPC: enumerate configured variables/events/alarms/commands at + runtime (diagnostics & tooling). + +### Layer 2 — Production hardening +- ⬜ **gRPC auth / exposure.** No auth today. Bind to a Unix domain socket or + localhost-only, or add credentials. Never expose the API on the equipment LAN + unauthenticated. +- ⬜ **`Subscribe` reconnect/replay semantics.** Define what happens to host + requests (commands, jobs) if the tool client disconnects and reconnects: are + they buffered/replayed, or dropped? Required for a 24/7 tool. (Correctness gap.) +- ⬜ Supervised deployment (systemd unit / container), auto-restart; rely on the + existing spool persistence so queued host events survive a daemon restart. +- ⬜ Expose the existing Prometheus metrics + structured logs from the daemon. +- ⬜ Decide multi-host (HSMS-GS) story — engine supports it; v1 assumes one + equipment/session. Probably fine; document the assumption. + +### Layer 3 — Actual fab acceptance (the hard gate) +- ⚠️ **Standards correctness is unverified.** The SECS/GEM behaviour in this repo + was substantially reconstructed without access to the SEMI standard texts. + Interop tests (secsgem-py, secs4java8, Wireshark) mitigate but do not prove + conformance. Subtle wire/state-machine deviations could fail a real host. This + is the #1 fab-readiness risk and it is *verification*, not features. +- ⬜ Pass a specific fab's **MES qualification suite** against their real host + (see `docs/MES_INTEROP.md` for the punch-list). Fab acceptance is empirical + and per-fab. +- ⬜ Produce the GEM **compliance statement** (S1F19/F20) + written GEM manual + matching the tool's actual data dictionary. +- ⬜ Finish the **SECS-I serial driver** (FSM done; asio `serial_port` adapter + missing) — only if a target tool uses RS-232 rather than HSMS/TCP. +- ⬜ Per-tool `equipment.yaml` authored to match the tool's real SVIDs/CEIDs/ + ECIDs/alarms/recipes and the fab's spec (vendor work; the config validator helps). + +## Sequencing recommendation + +Layer 0 in order (runtime → deps → daemon → client), then Layer 1's job/carrier +semantics, then Layer 2 hardening. Layer 3 runs in parallel and is gated by +access to real standards and a real host — treat it as the thing that decides +whether any of this is truly fab-ready. diff --git a/include/secsgem/gem/default_handlers.hpp b/include/secsgem/gem/default_handlers.hpp new file mode 100644 index 0000000..a9f7e22 --- /dev/null +++ b/include/secsgem/gem/default_handlers.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "secsgem/gem/runtime.hpp" + +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. +// +// 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. +void register_default_handlers(EquipmentRuntime& R); + +} // namespace secsgem::gem diff --git a/include/secsgem/gem/name_index.hpp b/include/secsgem/gem/name_index.hpp new file mode 100644 index 0000000..0f34122 --- /dev/null +++ b/include/secsgem/gem/name_index.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +#include "secsgem/gem/data_model.hpp" + +namespace secsgem::gem { + +// Resolve a human variable name to its numeric VID, spanning SVIDs and DVIDs +// (one VID space). SVIDs win on a name collision. nullopt if unknown. +// +// This is the name->id half of the vendor-facing binding layer: the daemon and +// any language client address items by the names from equipment.yaml, and the +// engine stays numeric. Best-effort by design — duplicate names simply resolve +// to the first match rather than erroring (see secsgem-vendor-accessibility). +inline std::optional resolve_variable(const EquipmentDataModel& m, + const std::string& name) { + for (const auto& sv : m.svids.all()) + if (sv.name == name) return sv.id; + for (const auto& dv : m.dvids.all()) + if (dv.name == name) return dv.id; + return std::nullopt; +} + +} // namespace secsgem::gem diff --git a/include/secsgem/gem/runtime.hpp b/include/secsgem/gem/runtime.hpp new file mode 100644 index 0000000..51005de --- /dev/null +++ b/include/secsgem/gem/runtime.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "secsgem/config/loader.hpp" +#include "secsgem/endpoint.hpp" +#include "secsgem/gem/control_state.hpp" +#include "secsgem/gem/data_model.hpp" +#include "secsgem/gem/router.hpp" +#include "secsgem/gem/store/host_commands.hpp" +#include "secsgem/secs2/item.hpp" +#include "secsgem/secs2/message.hpp" + +namespace secsgem::gem { + +// Owns the SECS/GEM engine — io_context, the passive HSMS Server, the data +// model, the E30 control-state machine, and the Router — as one reusable +// object. It is the foundation both the demo `secs_server` app and the gRPC +// daemon build on, replacing a hand-wired main() with a class. +// +// Threading contract (unchanged from the engine's existing design): a single +// io_context thread owns the model and the connection. The outbound methods +// (set_variable / emit_event / set_alarm / clear_alarm) are safe to call from +// any thread — each posts onto the io_context, exactly like the established +// emit_* pattern. Router handlers and the command hook run on the io thread. +class EquipmentRuntime { + public: + struct Config { + std::string equipment_yaml; + std::string control_state_yaml; + std::string process_job_yaml; + std::string control_job_yaml; + uint16_t port = 5000; + std::string spool_dir; // empty = no persistence + std::function log; // empty = silent + }; + + explicit EquipmentRuntime(const Config& cfg); + ~EquipmentRuntime(); + + EquipmentRuntime(const EquipmentRuntime&) = delete; + EquipmentRuntime& operator=(const EquipmentRuntime&) = delete; + + // ---- access -------------------------------------------------------------- + EquipmentDataModel& model() { return *model_; } + const EquipmentDataModel& model() const { return *model_; } + Router& router() { return router_; } + ControlStateMachine& control() { return *sm_; } + asio::io_context& io() { return io_; } + uint16_t device_id() const { return descriptor_.device_id; } + void log(const std::string& m) const { if (log_) log_(m); } + + // Shared-pointer / reference accessors used when wiring handlers and + // emitters that capture the engine pieces by value (see register handlers). + std::shared_ptr model_ptr() { return model_; } + std::shared_ptr control_ptr() { return sm_; } + const config::EquipmentDescriptor& descriptor() const { return descriptor_; } + std::shared_ptr> active_conn() { return active_conn_; } + + // ---- outbound: report state to the host (thread-safe; each posts) -------- + void set_variable(uint32_t vid, secs2::Item value); + void emit_event(uint32_t ceid); + void set_alarm(uint32_t alid); + void clear_alarm(uint32_t alid); + + // ---- host-command behaviour hook ----------------------------------------- + void on_command(std::string rcmd, HostCommandRegistry::Handler h) { + model_->commands.set_handler(std::move(rcmd), std::move(h)); + } + + // ---- control state ------------------------------------------------------- + ControlState control_state() const { return sm_->state(); } + + // Deliver a primary to the host, or spool it if there's no SELECTED session. + // Call on the io thread (e.g. from a router handler or a posted emitter). + // Returns false if dropped (stream not spoolable and no host). + bool deliver_or_spool(secs2::Message msg, const std::string& what); + + // ---- lifecycle ----------------------------------------------------------- + void run(); // start accepting + run the io_context (blocks) + void run_async(); // run the io_context on a background thread + void poll(); // drain ready handlers without blocking (tests) + void stop(); + + private: + void wire_connection(); + + asio::io_context io_; + std::shared_ptr model_; + std::function log_; + std::shared_ptr> active_conn_; + std::shared_ptr sm_; + config::EquipmentDescriptor descriptor_; + std::unique_ptr server_; + Router router_; + std::optional> work_; + std::thread io_thread_; +}; + +} // namespace secsgem::gem diff --git a/proto/secsgem/v1/equipment.proto b/proto/secsgem/v1/equipment.proto new file mode 100644 index 0000000..5333440 --- /dev/null +++ b/proto/secsgem/v1/equipment.proto @@ -0,0 +1,264 @@ +syntax = "proto3"; + +package secsgem.v1; + +// ============================================================================= +// SECS/GEM Equipment API +// +// The daemon *is* one piece of SEMI equipment: it owns the HSMS link to the +// host (MES) and speaks GEM on your behalf. Your tool software connects as a +// client and only ever does two things: +// +// • tell the equipment about itself — set variables, fire events, raise alarms +// • react to what the host asks for — receive commands/jobs, answer them +// +// Everything SECS lives inside the daemon: message framing, report definitions, +// the GEM state machines, timers, spooling. You need no SEMI knowledge to use +// this API. Items are addressed by the human names from your equipment config +// (e.g. "chamber_pressure"), never by numeric SVID / CEID / ALID. +// +// CAPABILITY TIERS — wire up only what your equipment is: +// • Universal — variables, events, alarms, control state, commands. Every tool. +// • Carriers — E87 carrier/load-port flows. Only carrier-based equipment. +// • Recipes — S7 process-program transfer. Only recipe-driven equipment. +// • Jobs — E40 process jobs. Only job-based process/front-end equipment. +// If a tier doesn't apply, you simply never receive its HostRequest variants and +// never call its report RPCs. +// ============================================================================= + +service Equipment { + // ---- Universal: report state to the host -------------------------------- + + // Update one or more status/data variables by name. The daemon remembers the + // values, so the host always sees the latest when it polls (S1F3). + rpc SetVariables(VariableUpdate) returns (Ack); + + // Read back what the daemon currently holds (useful on tool restart/reconnect). + rpc GetVariables(VariableQuery) returns (VariableSnapshot); + + // Fire a collection event by name. The daemon assembles the configured report + // and sends S6F11. Values in `data` override current values for this one event. + rpc FireEvent(Event) returns (Ack); + + // Raise (S5F1 set) or clear an alarm by name. + rpc SetAlarm(Alarm) returns (Ack); + rpc ClearAlarm(Alarm) returns (Ack); + + // ---- Universal: control state ------------------------------------------- + + // Current GEM control state (ONLINE/LOCAL/REMOTE/OFFLINE). + rpc GetControlState(Empty) returns (ControlState); + + // Request a transition — e.g. an operator panel taking the tool OFFLINE for + // maintenance, or back ONLINE. The daemon applies E30 rules and may decline. + rpc RequestControlState(ControlStateRequest) returns (Ack); + + // ---- Universal: react to the host --------------------------------------- + + // Subscribe to everything the host asks of this equipment. The daemon streams + // HostRequest messages for as long as the call stays open. + rpc Subscribe(SubscribeRequest) returns (stream HostRequest); + + // Answer a remote Command delivered on the stream, quoting its `id`. Until you + // call this (or the reply window elapses) the host's transaction stays open. + rpc CompleteCommand(CommandResult) returns (Ack); + + // ---- Jobs / Carriers: report progress of work the host asked for -------- + // Keyed by the durable id (job_id / carrier_id), not a per-message id — these + // are long-lived objects you report against as the physical work proceeds. + + rpc ReportProcessJob(ProcessJobState) returns (Ack); // E40 — job-based tools + rpc ReportCarrier(CarrierState) returns (Ack); // E87 — carrier-based tools + + // ---- Diagnostics -------------------------------------------------------- + + // Live daemon/link status: distinguishes "host went offline" from "cable + // unplugged" from "spool filling up". Streams a snapshot on every change. + rpc WatchHealth(Empty) returns (stream Health); +} + +// ---- Values ---------------------------------------------------------------- + +// A SECS-II value in plain terms. The daemon converts it to the wire format +// declared for the target item in your config — you never choose U4/F4/ASCII. +message Value { + oneof kind { + string text = 1; + sint64 integer = 2; + double real = 3; + bool boolean = 4; + bytes binary = 5; + List list = 6; // SECS-II nested list + } +} +message List { repeated Value items = 1; } + +message Empty {} + +// ---- Universal: tool -> equipment ------------------------------------------ + +message VariableUpdate { + map values = 1; // name -> value +} + +message VariableQuery { + repeated string names = 1; // empty = all configured variables +} +message VariableSnapshot { + map values = 1; +} + +message Event { + string name = 1; // collection-event name + map data = 2; // optional per-fire variable values +} + +message Alarm { + string name = 1; // alarm name +} + +message SubscribeRequest { + string client = 1; // optional label for logging/diagnostics +} + +// ---- Control state --------------------------------------------------------- + +message ControlState { + State state = 1; + enum State { + EQUIPMENT_OFFLINE = 0; + ATTEMPT_ONLINE = 1; + HOST_OFFLINE = 2; + ONLINE_LOCAL = 3; + ONLINE_REMOTE = 4; + } +} +message ControlStateRequest { + ControlState.State desired = 1; +} + +// ---- Host -> tool stream --------------------------------------------------- + +// Everything the host initiates arrives here. Match on the populated variant; +// ignore the variants for tiers your equipment doesn't implement. +message HostRequest { + oneof request { + Command command = 1; // S2F41/F21/F49 — universal + ControlStateChange control_state = 2; // control state transitioned — universal + ConstantChange constant = 3; // host set an equipment constant — universal + ProcessProgram process_program = 4; // S7 recipe downloaded — recipe tools + CarrierAction carrier = 5; // E87 carrier at a port — carrier tools + ProcessJob process_job = 6; // E40 job to run/stop — job tools + } +} + +// A remote command (S2F41 / S2F21 / S2F49). Answer with CompleteCommand. +message Command { + string id = 1; // correlation id — echo in CommandResult + string name = 2; // RCMD, e.g. "START" + map params = 3; // CPNAME -> CPVAL +} + +// Control state changed (host went online/offline, operator toggled local/remote). +message ControlStateChange { + ControlState.State state = 1; +} + +// The host wrote an equipment constant (S2F15). React if it tunes a process +// parameter you care about; the daemon already stored the new value. +message ConstantChange { + string name = 1; + Value value = 2; +} + +// The host downloaded a recipe (S7F3). Load it into your process engine. +message ProcessProgram { + string ppid = 1; // recipe id + bytes body = 2; // recipe contents (opaque to the daemon) +} + +// E87: a carrier needs attention at a load port. Reply with ReportCarrier. +message CarrierAction { + string carrier_id = 1; // CARRIERID + uint32 port = 2; // load-port number + Action action = 3; + enum Action { + VERIFY_ID = 0; // read & confirm the carrier's identity + PROCEED = 1; // begin access (open / map slots) + CANCEL = 2; + } +} + +// E40: the host wants this process job run (or stopped). Report progress with +// ReportProcessJob. `recipe` + `carriers` tell you what to run and on what. +message ProcessJob { + string job_id = 1; // PRJobID + string recipe = 2; // PPID to run + Action action = 3; + repeated string carriers = 4; // material: carrier ids bound to this job + enum Action { START = 0; STOP = 1; PAUSE = 2; RESUME = 3; ABORT = 4; } +} + +// ---- Tool -> equipment: replies & progress reports ------------------------- + +message CommandResult { + string id = 1; // the Command.id you are answering + Ack ack = 2; // outcome (maps to HCACK on the wire) +} + +// Advance an E40 process job as the physical work proceeds; the daemon drives +// the E40 FSM and emits the matching S6F11 / S16F9 to the host. +message ProcessJobState { + string job_id = 1; + State state = 2; + enum State { + SETTING_UP = 0; + PROCESSING = 1; + COMPLETE = 2; + ABORTED = 3; + } +} + +// Report a carrier's identity, slot map, and state as the tool reads them. +message CarrierState { + string carrier_id = 1; + uint32 port = 2; + State state = 3; + repeated bool slots = 4; // slot map: true = wafer present + enum State { + WAITING = 0; + IN_ACCESS = 1; + COMPLETE = 2; + } +} + +// ---- Diagnostics ----------------------------------------------------------- + +message Health { + LinkState link = 1; + uint32 spool_depth = 2; // queued messages waiting for the host + ControlState.State control_state = 3; + enum LinkState { + DISCONNECTED = 0; // no TCP + CONNECTED = 1; // TCP up, not yet SELECTED + SELECTED = 2; // HSMS selected — actively talking to the host + } +} + +// ---- Acknowledgement ------------------------------------------------------- + +// Mirrors SEMI HCACK exactly. For non-command RPCs, only ACCEPT vs an error +// code matters; `message` carries human detail ("no variable named 'presure'"). +message Ack { + Code code = 1; + string message = 2; + enum Code { + ACCEPT = 0; // HCACK 0 + INVALID_COMMAND = 1; // HCACK 1 + CANNOT_DO_NOW = 2; // HCACK 2 + PARAMETER_INVALID = 3; // HCACK 3 + ACCEPTED_WILL_FINISH_LATER = 4; // HCACK 4 + REJECTED = 5; // HCACK 5 + INVALID_OBJECT = 6; // HCACK 6 + } +} diff --git a/src/gem/default_handlers.cpp b/src/gem/default_handlers.cpp new file mode 100644 index 0000000..1e1732c --- /dev/null +++ b/src/gem/default_handlers.cpp @@ -0,0 +1,1086 @@ +#include "secsgem/gem/default_handlers.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "secsgem/config/loader.hpp" +#include "secsgem/endpoint.hpp" +#include "secsgem/gem/control_state.hpp" +#include "secsgem/gem/data_model.hpp" +#include "secsgem/gem/e116_constants.hpp" +#include "secsgem/gem/e157_constants.hpp" +#include "secsgem/gem/e90_constants.hpp" +#include "secsgem/gem/messages.hpp" +#include "secsgem/gem/router.hpp" +#include "secsgem/gem/runtime.hpp" +#include "secsgem/secs2/message.hpp" + +using namespace secsgem; +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())); +} + +} // namespace + +void gem::register_default_handlers(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) { + logfn(std::string("control: ") + gem::control_state_name(from) + " -> " + + gem::control_state_name(to) + " (" + gem::control_event_name(ev) + ")"); + if (desc.emit_on_control_change) emit_event(*desc.emit_on_control_change); + }); + + // (The E40/E94 PJ/CJ transition tables are loaded and their table + // factories wired by the EquipmentRuntime constructor.) + + // 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); + } + // All PJs done -> CJ Completed. + model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::AllJobsComplete); + logfn("CJ " + ctljobid + " lifecycle complete"); + }); + }; + + // ---- E5 exception state-change emitters ------------------------------- + // Translate ExceptionStore state changes into S5F9/F11/F15 on the wire, + // mirroring how PJ state changes become S16F9. The synthetic + // NoState->Posted event is the trigger for emitting S5F9 (we look up + // the metadata from the store so we can build the full body). + model->exceptions.set_state_change_handler( + [model, logfn, deliver_or_spool](uint32_t exid, + gem::ExceptionState from, + gem::ExceptionState to, + gem::ExceptionEvent trig) { + logfn(std::string("EX ") + std::to_string(exid) + ": " + + gem::exception_state_name(from) + " -> " + + gem::exception_state_name(to) + " (" + + gem::exception_event_name(trig) + ")"); + + if (from == gem::ExceptionState::NoState && + to == gem::ExceptionState::Posted) { + const auto* ex = model->exceptions.get(exid); + if (!ex) return; + deliver_or_spool( + gem::s5f9_exception_post_notify(exid, ex->extype, ex->exmessage, + ex->exrecvra), + "S5F9 EXID=" + std::to_string(exid)); + return; + } + + if (to == gem::ExceptionState::Cleared) { + if (from == gem::ExceptionState::Recovering) { + // RecoveryComplete -> emit S5F15 with success EXRESULT. + deliver_or_spool( + gem::s5f15_exception_recover_complete_notify(exid, "OK"), + "S5F15 EXID=" + std::to_string(exid)); + } + // S5F11 for any Cleared transition that didn't go through + // Recovering (autonomous clear or post-failure clear). + if (from == gem::ExceptionState::Posted || + from == gem::ExceptionState::RecoverFailed) { + deliver_or_spool( + gem::s5f11_exception_clear_notify(exid, "", ""), + "S5F11 EXID=" + std::to_string(exid)); + } + return; + } + + if (from == gem::ExceptionState::Recovering && + to == gem::ExceptionState::RecoverFailed) { + deliver_or_spool( + gem::s5f15_exception_recover_complete_notify(exid, "FAILED"), + "S5F15 EXID=" + std::to_string(exid)); + } + }); + + // ---- E90 substrate state-change emitters ----------------------------- + // Map SubstrateState / SubstrateProcessingState transitions to the + // standard E90 CEIDs. Only the post-transition state matters; the + // event-report machinery already gates on enabled CEIDs so emitting + // for every transition is safe and gives the host a complete trace. + model->substrates.set_location_handler( + [logfn, emit_event](const std::string& substid, + gem::SubstrateState from, + gem::SubstrateState to, + gem::SubstrateEvent ev) { + logfn(std::string("SUB ") + substid + ": " + + gem::substrate_state_name(from) + " -> " + + gem::substrate_state_name(to) + " (" + + gem::substrate_event_name(ev) + ")"); + switch (to) { + case gem::SubstrateState::AtSource: + emit_event(gem::e90::kCeidSubstrateAtSource); break; + case gem::SubstrateState::AtWork: + emit_event(gem::e90::kCeidSubstrateAtWork); break; + case gem::SubstrateState::AtDestination: + emit_event(gem::e90::kCeidSubstrateAtDestination); break; + case gem::SubstrateState::NoState: break; + } + }); + + model->substrates.set_processing_handler( + [logfn, emit_event](const std::string& substid, + gem::SubstrateProcessingState from, + gem::SubstrateProcessingState to, + gem::SubstrateProcessingEvent ev) { + logfn(std::string("SUB ") + substid + " proc: " + + gem::substrate_processing_state_name(from) + " -> " + + gem::substrate_processing_state_name(to) + " (" + + gem::substrate_processing_event_name(ev) + ")"); + switch (to) { + case gem::SubstrateProcessingState::NeedsProcessing: + emit_event(gem::e90::kCeidSubstrateNeedsProcessing); break; + case gem::SubstrateProcessingState::InProcess: + emit_event(gem::e90::kCeidSubstrateInProcess); break; + case gem::SubstrateProcessingState::Processed: + emit_event(gem::e90::kCeidSubstrateProcessed); break; + case gem::SubstrateProcessingState::Aborted: + emit_event(gem::e90::kCeidSubstrateAborted); break; + case gem::SubstrateProcessingState::Stopped: + emit_event(gem::e90::kCeidSubstrateStopped); break; + case gem::SubstrateProcessingState::Rejected: + emit_event(gem::e90::kCeidSubstrateRejected); break; + case gem::SubstrateProcessingState::Lost: + emit_event(gem::e90::kCeidSubstrateLost); break; + case gem::SubstrateProcessingState::Skipped: + emit_event(gem::e90::kCeidSubstrateSkipped); break; + case gem::SubstrateProcessingState::NoState: break; + } + }); + + // ---- E116 EPT state-change emitter ----------------------------------- + model->ept.set_state_change_handler( + [logfn, emit_event](gem::EptState from, gem::EptState to, + gem::EptEvent ev, std::chrono::milliseconds dwell) { + logfn(std::string("EPT: ") + gem::ept_state_name(from) + " -> " + + gem::ept_state_name(to) + " (" + gem::ept_event_name(ev) + + ", dwelt " + std::to_string(dwell.count()) + "ms)"); + switch (to) { + case gem::EptState::NonScheduledTime: + emit_event(gem::e116::kCeidNonScheduledTime); break; + case gem::EptState::ScheduledDowntime: + emit_event(gem::e116::kCeidScheduledDowntime); break; + case gem::EptState::UnscheduledDowntime: + emit_event(gem::e116::kCeidUnscheduledDowntime); break; + case gem::EptState::Engineering: + emit_event(gem::e116::kCeidEngineering); break; + case gem::EptState::Standby: + emit_event(gem::e116::kCeidStandby); break; + case gem::EptState::Productive: + emit_event(gem::e116::kCeidProductive); break; + case gem::EptState::NoState: break; + } + }); + + // ---- E157 module state-change emitter -------------------------------- + // Every module transition fires the generic ModuleProcessStateChange + // CEID plus the state-specific one, mirroring secsgem-py's per-module + // CEID granularity. Hosts that don't subscribe to the specific CEID + // can still listen on the generic one to drive an internal FSM. + model->modules.set_state_change_handler( + [logfn, emit_event](const std::string& mod, gem::ModuleState from, + gem::ModuleState to, gem::ModuleEvent ev) { + logfn(std::string("MOD ") + mod + ": " + + gem::module_state_name(from) + " -> " + + gem::module_state_name(to) + " (" + + gem::module_event_name(ev) + ")"); + emit_event(gem::e157::kCeidModuleProcessStateChange); + switch (to) { + case gem::ModuleState::NotExecuting: + emit_event(gem::e157::kCeidModuleNotExecuting); break; + case gem::ModuleState::GeneralExecuting: + emit_event(gem::e157::kCeidModuleGeneralExecuting); break; + case gem::ModuleState::StepExecuting: + emit_event(gem::e157::kCeidModuleStepExecuting); break; + case gem::ModuleState::StepCompleted: + emit_event(gem::e157::kCeidModuleStepCompleted); break; + case gem::ModuleState::NoState: break; + } + }); + + // ---- 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 dispatch --------------------------------- + // S3F17 maps the textual CARRIERACTION string onto a CarrierIDEvent + // and fires it against the matching carrier. Unknown actions return + // CarrierActionInvalid; unknown carriers return CarrierIDUnknown. + router.on(3, 17, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s3f17(msg); + if (!req) return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::ParameterInvalid); + if (!model->carriers.has(req->carrierid)) + return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::CarrierIDUnknown); + + auto ack = gem::CarrierActionAck::Accept; + if (req->carrieraction == "ProceedWithCarrier") { + model->carriers.fire_id_event(req->carrierid, + gem::CarrierIDEvent::ProceedWithCarrier); + } else if (req->carrieraction == "CancelCarrier") { + model->carriers.fire_id_event(req->carrierid, + gem::CarrierIDEvent::CancelCarrier); + } else if (req->carrieraction == "BindCarrierID") { + model->carriers.fire_id_event(req->carrierid, gem::CarrierIDEvent::Bind); + } else { + ack = gem::CarrierActionAck::CarrierActionInvalid; + } + logfn("S3F17 CARRIER=" + req->carrierid + " action=" + req->carrieraction + + " -> S3F18 CAACK=" + std::to_string(static_cast(ack))); + return gem::s3f18_carrier_action_ack(ack); + }); + + // S3F25 — host instructs equipment to move a carrier between ports. + // We record the new port binding on the Carrier and fire the source + // port's StartUnloading + target port's StartLoading transfer events; + // application code is responsible for completing them. + router.on(3, 25, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s3f25(msg); + if (!req) + return gem::s3f26_carrier_transfer_ack(gem::CarrierActionAck::ParameterInvalid); + auto* c = model->carriers.get(req->carrierid); + if (!c) + return gem::s3f26_carrier_transfer_ack(gem::CarrierActionAck::CarrierIDUnknown); + model->load_ports.fire_transfer_event(req->source_portid, + gem::LoadPortTransferEvent::StartUnloading); + model->load_ports.fire_transfer_event(req->target_portid, + gem::LoadPortTransferEvent::StartLoading); + c->port_id = req->target_portid; + logfn("S3F25 CARRIER=" + req->carrierid + + " " + std::to_string(req->source_portid) + "->" + + std::to_string(req->target_portid)); + return gem::s3f26_carrier_transfer_ack(gem::CarrierActionAck::Accept); + }); + + // S3F19 — Slot Map Verify. Host sends its expected slot map for + // CARRIERID; equipment compares against locally-stored slots and + // drives CSMS (NotRead -> Read on Accept, -> Mismatched on Mismatch). + router.on(3, 19, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s3f19(msg); + if (!req) + return gem::s3f20_slot_map_verify_ack(gem::SlotMapVerifyAck::Error); + auto* c = model->carriers.get(req->carrierid); + if (!c) + return gem::s3f20_slot_map_verify_ack(gem::SlotMapVerifyAck::CarrierUnknown); + bool match = c->slots.size() == req->slots.size(); + if (match) { + for (std::size_t i = 0; i < req->slots.size(); ++i) { + if (static_cast(c->slots[i].state) != + static_cast(req->slots[i])) { match = false; break; } + } + } + if (match) { + model->carriers.fire_slot_map_event(req->carrierid, gem::SlotMapEvent::Read); + logfn("S3F19 CARRIER=" + req->carrierid + " -> S3F20 Accept"); + return gem::s3f20_slot_map_verify_ack(gem::SlotMapVerifyAck::Accept); + } + model->carriers.fire_slot_map_event(req->carrierid, gem::SlotMapEvent::Mismatch); + logfn("S3F19 CARRIER=" + req->carrierid + " -> S3F20 Mismatch"); + return gem::s3f20_slot_map_verify_ack(gem::SlotMapVerifyAck::Mismatch); + }); + + // S3F27 — Cancel Carrier (single-EXID form). + router.on(3, 27, [model, logfn](const s2::Message& msg) { + auto cid = gem::parse_s3f27(msg); + if (!cid || !model->carriers.has(*cid)) + return gem::s3f28_cancel_carrier_ack(gem::CarrierActionAck::CarrierIDUnknown); + model->carriers.fire_id_event(*cid, gem::CarrierIDEvent::CancelCarrier); + model->carriers.fire_access_event(*cid, gem::CarrierAccessEvent::Cancel); + logfn("S3F27 CARRIER=" + *cid + " cancelled"); + return gem::s3f28_cancel_carrier_ack(gem::CarrierActionAck::Accept); + }); + + // S7F1 — Process Program Load Inquire. Host asks permission to send + // LENGTH bytes for PPID; equipment responds with PPGNT. Policy here: + // 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. + router.on(7, 1, [logfn](const s2::Message& msg) { + auto req = gem::parse_s7f1(msg); + auto ack = gem::ProcessProgramAck::Accept; + if (!req || req->ppid.empty()) ack = gem::ProcessProgramAck::PpidNotFound; + else if (req->length > 16u * 1024u * 1024u) ack = gem::ProcessProgramAck::MatrixOverflow; + logfn("S7F1 PPID=" + (req ? req->ppid : std::string{"?"}) + + " LEN=" + std::to_string(req ? req->length : 0) + + " -> S7F2 PPGNT=" + std::to_string(static_cast(ack))); + return gem::s7f2_pp_load_grant(ack); + }); + router.on(7, 3, [model, logfn](const s2::Message& msg) { + auto pp = gem::parse_s7f3(msg); + if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError); + model->recipes.add(pp->ppid, pp->ppbody); + logfn("S7F3 PPID=" + pp->ppid + " -> S7F4 (Accept)"); + return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept); + }); + // S7F17 — Delete Process Program. Empty PPID list deletes all; + // otherwise we remove each PPID and aggregate the worst ack. + router.on(7, 17, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s7f17(msg); + if (!req) return gem::s7f18_delete_pp_ack(gem::ProcessProgramAck::LengthError); + auto ack = gem::ProcessProgramAck::Accept; + if (req->empty()) { + const auto all = model->recipes.list(); + for (const auto& id : all) model->recipes.remove(id); + logfn("S7F17 delete-all (" + std::to_string(all.size()) + ") -> S7F18 Accept"); + } else { + for (const auto& id : *req) { + auto r = model->recipes.remove(id); + if (r != gem::ProcessProgramAck::Accept) ack = r; + } + logfn("S7F17 delete " + std::to_string(req->size()) + + " PPIDs -> S7F18 ACKC7=" + std::to_string(static_cast(ack))); + } + return gem::s7f18_delete_pp_ack(ack); + }); + router.on(7, 5, [model, logfn](const s2::Message& msg) { + auto ppid = gem::parse_s7f5(msg); + if (!ppid) return gem::s7f6_process_program_data("", ""); + auto body = model->recipes.get(*ppid); + logfn("S7F5 PPID=" + *ppid + " -> S7F6"); + return gem::s7f6_process_program_data(*ppid, body ? *body : ""); + }); + router.on(7, 19, [model, logfn](const s2::Message&) { + auto list = model->recipes.list(); + logfn("S7F19 -> S7F20 (" + std::to_string(list.size()) + " PPIDs)"); + return gem::s7f20_current_eppd_data(list); + }); + + // ---- E39 generic ObjectService ---------------------------------------- + // S14F1 GetAttr / S14F3 SetAttr against the CemObjectStore. OBJTYPE + // is validated against the stored object's type name (case-sensitive). + router.on(14, 1, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s14f1(msg); + if (!req) return gem::s14f2_get_attr_data({}, gem::ObjectAck::Error); + auto* obj = model->cem.get(req->objspec); + if (!obj) + return gem::s14f2_get_attr_data({}, gem::ObjectAck::Denied_UnknownObject); + if (gem::cem_object_type_name(obj->objtype) != req->objtype) + return gem::s14f2_get_attr_data({}, gem::ObjectAck::Denied_InvalidAttribute); + std::vector attrs; + attrs.reserve(req->attrids.size()); + for (const auto& id : req->attrids) { + auto v = model->cem.get_attr(req->objspec, id); + attrs.push_back({id, v.value_or(s2::Item::ascii(""))}); + } + logfn("S14F1 " + req->objspec + " (" + + std::to_string(req->attrids.size()) + " attrs) -> S14F2"); + return gem::s14f2_get_attr_data(attrs, gem::ObjectAck::Success); + }); + router.on(14, 3, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s14f3(msg); + if (!req) + return gem::s14f4_set_attr_ack({}, gem::ObjectAck::Error); + auto* obj = model->cem.get(req->objspec); + if (!obj) + return gem::s14f4_set_attr_ack({}, gem::ObjectAck::Denied_UnknownObject); + if (gem::cem_object_type_name(obj->objtype) != req->objtype) + return gem::s14f4_set_attr_ack({}, gem::ObjectAck::Denied_InvalidAttribute); + for (const auto& a : req->attrs) { + model->cem.set_attr(req->objspec, a.attrid, a.value); + } + // Reply echoes back the now-stored values. + std::vector reply; + reply.reserve(req->attrs.size()); + for (const auto& a : req->attrs) { + auto v = model->cem.get_attr(req->objspec, a.attrid); + reply.push_back({a.attrid, v.value_or(s2::Item::ascii(""))}); + } + logfn("S14F3 " + req->objspec + " (" + + std::to_string(req->attrs.size()) + " attrs) -> S14F4"); + return gem::s14f4_set_attr_ack(reply, gem::ObjectAck::Success); + }); + + // ---- E40 / E94 ------------------------------------------------------- + router.on(14, 9, [model, logfn, run_cj_lifecycle](const s2::Message& msg) { + (void)run_cj_lifecycle; + auto req = gem::parse_s14f9(msg); + if (!req) { + logfn("S14F9 -> S14F10 Error (malformed body)"); + return gem::s14f10_create_control_job_ack("", gem::ObjectAck::Error); + } + auto r = model->control_jobs.create( + req->ctljobid, req->prjobids, + [model](const std::string& id) { return model->process_jobs.has(id); }); + gem::ObjectAck ack = gem::ObjectAck::Success; + switch (r) { + case gem::ControlJobStore::CreateResult::Created: ack = gem::ObjectAck::Success; break; + case gem::ControlJobStore::CreateResult::Denied_AlreadyExists: + ack = gem::ObjectAck::Denied_AlreadyExists; break; + case gem::ControlJobStore::CreateResult::Denied_UnknownPRJob: + ack = gem::ObjectAck::Denied_UnknownObject; break; + case gem::ControlJobStore::CreateResult::Denied_Empty: + ack = gem::ObjectAck::Denied_InvalidAttribute; break; + } + logfn("S14F9 CJ=" + req->ctljobid + " -> S14F10 OBJACK=" + + std::to_string(static_cast(ack))); + return gem::s14f10_create_control_job_ack(req->ctljobid, ack); + }); + router.on(14, 11, [model, logfn](const s2::Message& msg) { + auto id = gem::parse_s14f11(msg); + if (!id) return gem::s14f12_delete_control_job_ack(gem::ObjectAck::Error); + const auto removed = model->control_jobs.remove(*id); + logfn("S14F11 delete CJ=" + *id + " -> S14F12 " + + (removed ? "Success" : "UnknownObject")); + return gem::s14f12_delete_control_job_ack( + removed ? gem::ObjectAck::Success : gem::ObjectAck::Denied_UnknownObject); + }); + + router.on(16, 11, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s16f11(msg); + if (!req) return gem::s16f12_pr_job_create_ack(gem::HostCmdAck::ParameterInvalid); + auto r = model->process_jobs.create( + req->prjobid, req->rcpspec.ppid, req->mtrloutspec, + [model](const std::string& ppid) { + return model->recipes.get(ppid).has_value(); + }); + gem::HostCmdAck ack = gem::HostCmdAck::Accept; + switch (r) { + case gem::ProcessJobStore::CreateResult::Created: ack = gem::HostCmdAck::Accept; break; + case gem::ProcessJobStore::CreateResult::Denied_AlreadyExists: + ack = gem::HostCmdAck::Rejected; break; + case gem::ProcessJobStore::CreateResult::Denied_InvalidPpid: + ack = gem::HostCmdAck::ParameterInvalid; break; + } + if (ack == gem::HostCmdAck::Accept) { + // Persist the optional E40-0705 trailers (MF / recipe-method / + // recipe variables / process parameters) on the freshly created PJ. + std::vector rcpvars; + rcpvars.reserve(req->rcpspec.rcpvars.size()); + for (auto& v : req->rcpspec.rcpvars) rcpvars.push_back({v.name, v.value}); + std::vector params; + params.reserve(req->prprocessparams.size()); + for (auto& p : req->prprocessparams) params.push_back({p.name, p.value}); + model->process_jobs.set_e40_extras(req->prjobid, req->mf, + req->prrecipemethod, + std::move(rcpvars), + std::move(params)); + } + logfn("S16F11 PJ=" + req->prjobid + " PPID=" + req->rcpspec.ppid + + " MF=" + std::to_string(static_cast(req->mf)) + + " RM=" + std::to_string(static_cast(req->prrecipemethod)) + + " rcpvars=" + std::to_string(req->rcpspec.rcpvars.size()) + + " params=" + std::to_string(req->prprocessparams.size()) + + " -> S16F12 HCACK=" + std::to_string(static_cast(ack))); + return gem::s16f12_pr_job_create_ack(ack); + }); + router.on(16, 13, [model, logfn](const s2::Message& msg) { + auto id = gem::parse_s16f13(msg); + auto ack = id ? model->process_jobs.dequeue(*id) : gem::HostCmdAck::ParameterInvalid; + logfn("S16F13 PJ=" + (id ? *id : std::string{"?"}) + + " -> S16F14 HCACK=" + std::to_string(static_cast(ack))); + return gem::s16f14_pr_job_dequeue_ack(ack); + }); + router.on(16, 7, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s16f7(msg); + if (!req) return gem::s16f8_pr_job_monitor_ack(gem::HostCmdAck::ParameterInvalid); + bool any_bad = false; + for (const auto& e : req->entries) { + const bool enable = (e.pralert & 0x80) != 0; + if (!model->process_jobs.set_alert(e.prjobid, enable)) any_bad = true; + } + const auto ack = any_bad ? gem::HostCmdAck::InvalidObject : gem::HostCmdAck::Accept; + logfn("S16F7 monitor " + std::to_string(req->entries.size()) + + " jobs -> S16F8 HCACK=" + std::to_string(static_cast(ack))); + return gem::s16f8_pr_job_monitor_ack(ack); + }); + router.on(16, 15, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s16f15(msg); + if (!req) + return gem::s16f16_pr_job_create_multi_ack(std::vector{}); + std::vector results; + results.reserve(req->jobs.size()); + for (const auto& job : req->jobs) { + auto r = model->process_jobs.create( + job.prjobid, job.ppid, job.mtrloutspec, + [model](const std::string& ppid) { + return model->recipes.get(ppid).has_value(); + }); + gem::HostCmdAck ack = gem::HostCmdAck::Accept; + switch (r) { + case gem::ProcessJobStore::CreateResult::Created: break; + case gem::ProcessJobStore::CreateResult::Denied_AlreadyExists: + ack = gem::HostCmdAck::Rejected; break; + case gem::ProcessJobStore::CreateResult::Denied_InvalidPpid: + ack = gem::HostCmdAck::ParameterInvalid; break; + } + results.push_back({job.prjobid, ack}); + } + logfn("S16F15 multi-create " + std::to_string(req->jobs.size()) + + " jobs -> S16F16"); + return gem::s16f16_pr_job_create_multi_ack(results); + }); + router.on(16, 5, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s16f5(msg); + if (!req) return gem::s16f6_pr_job_command_ack(gem::HostCmdAck::ParameterInvalid); + auto ev = gem::pr_cmd_to_event(req->prcmd); + if (!ev) return gem::s16f6_pr_job_command_ack(gem::HostCmdAck::InvalidCommand); + auto ack = model->process_jobs.on_host_command(req->prjobid, *ev); + logfn("S16F5 PJ=" + req->prjobid + " " + req->prcmd + + " -> S16F6 HCACK=" + std::to_string(static_cast(ack))); + return gem::s16f6_pr_job_command_ack(ack); + }); + router.on(16, 27, [model, logfn, run_cj_lifecycle](const s2::Message& msg) { + auto req = gem::parse_s16f27(msg); + if (!req) return gem::s16f28_cj_command_ack(gem::HostCmdAck::ParameterInvalid); + auto ev = gem::ctl_cmd_to_event(req->ctljobcmd); + if (!ev) return gem::s16f28_cj_command_ack(gem::HostCmdAck::InvalidCommand); + // CJSTART semantics: implicit Select -> SetupComplete -> Start + // cascade so a Queued CJ can be started in one host action. The + // cascade is the equipment policy; the FSM rules still gate every + // step. + auto ack = gem::HostCmdAck::Accept; + if (*ev == gem::ControlJobEvent::Start) { + run_cj_lifecycle(req->ctljobid); + } else { + ack = model->control_jobs.on_host_command(req->ctljobid, *ev); + } + logfn("S16F27 CJ=" + req->ctljobid + " " + req->ctljobcmd + + " -> S16F28 HCACK=" + std::to_string(static_cast(ack))); + return gem::s16f28_cj_command_ack(ack); + }); + + 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); + return gem::s10f2_terminal_display_ack(gem::TerminalAck::Accepted); + }); + // S10F3 is the canonical SEMI E5 host→equipment "Terminal Display Single" + // (S10F1 is documented in the spec as equipment→host); secsgem-py and + // other reference libraries use F3. We accept both for compatibility. + router.on(10, 3, [logfn](const s2::Message& msg) { + auto td = gem::parse_s10f3(msg); + if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text); + return gem::s10f4_terminal_display_ack(gem::TerminalAck::Accepted); + }); + router.on(10, 5, [logfn](const s2::Message& msg) { + auto td = gem::parse_s10f5(msg); + if (td) { + logfn("TERMINAL[" + std::to_string(td->tid) + "] (" + + std::to_string(td->lines.size()) + " lines)"); + for (const auto& l : td->lines) logfn(" | " + l); + } + return gem::s10f6_terminal_display_multi_ack(gem::TerminalAck::Accepted); + }); + + logfn("registered " + std::to_string(router.size()) + " (stream,function) handlers"); +} diff --git a/src/gem/runtime.cpp b/src/gem/runtime.cpp new file mode 100644 index 0000000..7fe2fa9 --- /dev/null +++ b/src/gem/runtime.cpp @@ -0,0 +1,171 @@ +#include "secsgem/gem/runtime.hpp" + +#include "secsgem/gem/messages.hpp" + +namespace secsgem::gem { + +EquipmentRuntime::EquipmentRuntime(const Config& cfg) + : model_(std::make_shared()), + log_(cfg.log), + active_conn_(std::make_shared>()) { + if (!cfg.spool_dir.empty()) { + model_->spool.enable_persistence(cfg.spool_dir); + log("spool: persisting to " + cfg.spool_dir + " (replayed " + + std::to_string(model_->spool.size()) + " messages)"); + } + + descriptor_ = config::load_equipment(cfg.equipment_yaml, *model_); + auto sm_cfg = config::load_control_state(cfg.control_state_yaml); + sm_ = std::make_shared(sm_cfg.table, sm_cfg.initial); + + auto pj = config::load_process_job_state(cfg.process_job_yaml); + auto cj = config::load_control_job_state(cfg.control_job_yaml); + model_->process_jobs.set_table_factory([t = pj.table]() { return t; }); + model_->control_jobs.set_table_factory([t = cj.table]() { return t; }); + + server_ = std::make_unique( + io_, Server::Config{cfg.port, descriptor_.device_id, {}}); + server_->on_log([this](const std::string& m) { log(m); }); + wire_connection(); +} + +EquipmentRuntime::~EquipmentRuntime() { stop(); } + +bool EquipmentRuntime::deliver_or_spool(secs2::Message msg, + const std::string& what) { + auto conn = active_conn_->lock(); + const bool spooling = model_->spool.force_spool() || !conn; + if (spooling) { + auto r = model_->spool.enqueue(msg); + if (r == SpoolStore::EnqueueResult::Queued) { + log("spool: " + what + " queued (depth=" + + std::to_string(model_->spool.size()) + ")"); + return true; + } + log("spool: " + what + " dropped (stream not spoolable, no host)"); + return false; + } + if (msg.reply_expected) { + conn->send_request(std::move(msg), [](std::error_code, const secs2::Message&) {}); + } else { + conn->send_data(std::move(msg)); + } + return true; +} + +void EquipmentRuntime::set_variable(uint32_t vid, secs2::Item value) { + asio::post(io_, [this, vid, value = std::move(value)]() mutable { + if (model_->svids.has(vid)) model_->svids.set_value(vid, std::move(value)); + else if (model_->dvids.has(vid)) model_->dvids.set_value(vid, std::move(value)); + }); +} + +void EquipmentRuntime::emit_event(uint32_t ceid) { + asio::post(io_, [this, ceid]() { + if (!model_->is_event_enabled(ceid)) { + log("CEID " + std::to_string(ceid) + " not enabled; suppressed"); + return; + } + auto reports = model_->compose_reports_for(ceid); + log("emit S6F11 CEID=" + std::to_string(ceid) + " (" + + std::to_string(reports.size()) + " reports)"); + deliver_or_spool(s6f11_event_report(0, ceid, reports), + "S6F11 CEID=" + std::to_string(ceid)); + }); +} + +void EquipmentRuntime::set_alarm(uint32_t alid) { + asio::post(io_, [this, alid]() { + auto alarm = model_->alarms.get(alid); + if (!alarm) return; + auto alcd = model_->alarms.set_active(alid); + if (!alcd) return; + if (model_->alarms.enabled(alid)) { + log("emit S5F1 alarm set ALID=" + std::to_string(alid)); + deliver_or_spool(s5f1_alarm_report(*alcd, alid, alarm->text), + "S5F1 ALID=" + std::to_string(alid)); + } else { + log("alarm " + std::to_string(alid) + " not enabled; suppressed"); + } + }); +} + +void EquipmentRuntime::clear_alarm(uint32_t alid) { + asio::post(io_, [this, alid]() { + auto alarm = model_->alarms.get(alid); + if (!alarm) return; + auto alcd = model_->alarms.clear_active(alid); + if (!alcd) return; + if (model_->alarms.enabled(alid)) { + log("emit S5F1 alarm clear ALID=" + std::to_string(alid)); + deliver_or_spool(s5f1_alarm_report(*alcd, alid, alarm->text), + "S5F1 clear ALID=" + std::to_string(alid)); + } + }); +} + +void EquipmentRuntime::wire_connection() { + server_->on_connection([this](std::shared_ptr conn) { + *active_conn_ = conn; + conn->set_closed_handler([this](const std::string&) { active_conn_->reset(); }); + + conn->set_selected_handler([this]() { + log(std::string("host is online; control=") + control_state_name(sm_->state())); + if (model_->spool.size() == 0) return; + asio::post(io_, [this]() { + auto c = active_conn_->lock(); + if (!c) return; + const uint32_t n = static_cast(model_->spool.size()); + log("spool: notifying host of " + std::to_string(n) + " queued messages"); + c->send_request(s6f25_spool_data_ready(n), + [](std::error_code, const secs2::Message&) {}); + }); + }); + + std::weak_ptr wconn = conn; + conn->set_message_handler([this, wconn](const secs2::Message& msg) + -> std::optional { + if (!router_.has_handler(msg.stream, msg.function)) { + auto c = wconn.lock(); + if (c && c->current_header()) { + const uint8_t s9_function = + router_.has_handler_for_stream(msg.stream) ? 5 : 3; + log("unhandled S" + std::to_string(msg.stream) + "F" + + std::to_string(msg.function) + "; emitting S9F" + + std::to_string(s9_function)); + c->emit_s9(s9_function, c->current_header()->encode()); + } + } + return router_.dispatch(msg); + }); + }); +} + +void EquipmentRuntime::run() { + server_->start(); + io_.run(); +} + +void EquipmentRuntime::run_async() { + work_.emplace(asio::make_work_guard(io_)); + server_->start(); + io_thread_ = std::thread([this]() { io_.run(); }); +} + +void EquipmentRuntime::poll() { + // restart() clears the "stopped" state a prior drain leaves behind, so + // repeated poll() calls each run their freshly-posted handlers. + io_.restart(); + io_.poll(); +} + +void EquipmentRuntime::stop() { + if (work_) { + work_->reset(); + work_.reset(); + } + io_.stop(); + if (io_thread_.joinable()) io_thread_.join(); +} + +} // namespace secsgem::gem diff --git a/tests/test_default_handlers.cpp b/tests/test_default_handlers.cpp new file mode 100644 index 0000000..ec3d4a2 --- /dev/null +++ b/tests/test_default_handlers.cpp @@ -0,0 +1,73 @@ +#include + +#include + +#include "secsgem/gem/default_handlers.hpp" +#include "secsgem/gem/messages.hpp" +#include "secsgem/gem/runtime.hpp" + +using namespace secsgem; +namespace gem = secsgem::gem; +namespace s2 = secsgem::secs2; + +#ifndef SECSGEM_DATA_DIR +#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt" +#endif + +static gem::EquipmentRuntime::Config test_config() { + gem::EquipmentRuntime::Config c; + c.equipment_yaml = SECSGEM_DATA_DIR "/equipment.yaml"; + c.control_state_yaml = SECSGEM_DATA_DIR "/control_state.yaml"; + c.process_job_yaml = SECSGEM_DATA_DIR "/process_job_state.yaml"; + c.control_job_yaml = SECSGEM_DATA_DIR "/control_job_state.yaml"; + c.port = 0; + return c; +} + +TEST_CASE("register_default_handlers populates the runtime's Router") { + gem::EquipmentRuntime rt(test_config()); + CHECK(rt.router().size() == 0); // nothing registered yet + gem::register_default_handlers(rt); + CHECK(rt.router().size() > 40); // the full GEM handler set (~56) +} + +TEST_CASE("register_default_handlers: S1F1 dispatches to S1F2 On-Line Data") { + gem::EquipmentRuntime rt(test_config()); + gem::register_default_handlers(rt); + + auto reply = rt.router().dispatch(s2::Message(1, 1, true)); // Are You There + REQUIRE(reply.has_value()); + CHECK(reply->stream == 1); + CHECK(reply->function == 2); +} + +TEST_CASE("register_default_handlers: S2F41 reaches the on_command behaviour hook") { + gem::EquipmentRuntime rt(test_config()); + gem::register_default_handlers(rt); + + bool ran = false; + std::string seen; + rt.on_command("START", [&](const std::string& rcmd, + const std::vector&) { + ran = true; + seen = rcmd; + return gem::HostCmdAck::Accept; + }); + + auto reply = rt.router().dispatch(gem::s2f41_host_command("START", {})); + REQUIRE(reply.has_value()); + CHECK(reply->stream == 2); + CHECK(reply->function == 42); // S2F42 Host Command Ack + CHECK(ran); // the handler ran inside the S2F41 dispatch + CHECK(seen == "START"); +} + +TEST_CASE("register_default_handlers: unknown command is rejected, hook not invoked") { + gem::EquipmentRuntime rt(test_config()); + gem::register_default_handlers(rt); + + auto reply = rt.router().dispatch(gem::s2f41_host_command("NO_SUCH_CMD", {})); + REQUIRE(reply.has_value()); + CHECK(reply->stream == 2); + CHECK(reply->function == 42); // still an S2F42, carrying an error HCACK +} diff --git a/tests/test_name_index.cpp b/tests/test_name_index.cpp new file mode 100644 index 0000000..b16daad --- /dev/null +++ b/tests/test_name_index.cpp @@ -0,0 +1,23 @@ +#include + +#include "secsgem/config/loader.hpp" +#include "secsgem/gem/name_index.hpp" + +using namespace secsgem; +namespace gem = secsgem::gem; + +#ifndef SECSGEM_DATA_DIR +#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt" +#endif + +TEST_CASE("resolve_variable maps config names to VIDs across SVIDs and DVIDs") { + gem::EquipmentDataModel m; + config::load_equipment(SECSGEM_DATA_DIR "/equipment.yaml", m); + + CHECK(gem::resolve_variable(m, "ControlState") == 1); // SVID + CHECK(gem::resolve_variable(m, "Clock") == 2); // SVID + CHECK(gem::resolve_variable(m, "WaferCounter") == 100); // DVID + CHECK(gem::resolve_variable(m, "ChamberPressure") == 101);// DVID + CHECK_FALSE(gem::resolve_variable(m, "nonexistent").has_value()); + CHECK_FALSE(gem::resolve_variable(m, "").has_value()); +} diff --git a/tests/test_runtime.cpp b/tests/test_runtime.cpp new file mode 100644 index 0000000..17b553c --- /dev/null +++ b/tests/test_runtime.cpp @@ -0,0 +1,66 @@ +#include + +#include + +#include "secsgem/gem/runtime.hpp" +#include "secsgem/secs2/item.hpp" + +using namespace secsgem; +namespace gem = secsgem::gem; +namespace s2 = secsgem::secs2; + +#ifndef SECSGEM_DATA_DIR +#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt" +#endif + +// Port 0 binds an OS-chosen ephemeral port. These tests never call run()/ +// run_async(), so the acceptor is opened but never armed — we exercise the +// outbound API by posting and draining with poll(), no host involved. +static gem::EquipmentRuntime::Config test_config() { + gem::EquipmentRuntime::Config c; + c.equipment_yaml = SECSGEM_DATA_DIR "/equipment.yaml"; + c.control_state_yaml = SECSGEM_DATA_DIR "/control_state.yaml"; + c.process_job_yaml = SECSGEM_DATA_DIR "/process_job_state.yaml"; + c.control_job_yaml = SECSGEM_DATA_DIR "/control_job_state.yaml"; + c.port = 0; + return c; +} + +TEST_CASE("EquipmentRuntime loads the data dictionary from config") { + gem::EquipmentRuntime rt(test_config()); + CHECK(rt.model().svids.all().size() == 3); + CHECK(rt.model().alarms.all().size() == 2); + CHECK(rt.control_state() == gem::ControlState::HostOffline); +} + +TEST_CASE("EquipmentRuntime.set_variable posts onto the io thread and updates the model") { + gem::EquipmentRuntime rt(test_config()); + rt.set_variable(1, s2::Item::ascii("OnlineRemote")); + CHECK(rt.model().svids.value(1) != s2::Item::ascii("OnlineRemote")); // not yet — posted + rt.poll(); + CHECK(rt.model().svids.value(1) == s2::Item::ascii("OnlineRemote")); +} + +TEST_CASE("EquipmentRuntime.set_alarm / clear_alarm toggle the active flag") { + gem::EquipmentRuntime rt(test_config()); + rt.set_alarm(1); + rt.poll(); + CHECK(rt.model().alarms.active(1)); + rt.clear_alarm(1); + rt.poll(); + CHECK_FALSE(rt.model().alarms.active(1)); +} + +TEST_CASE("EquipmentRuntime.on_command registers the behaviour hook on the model") { + gem::EquipmentRuntime rt(test_config()); + bool ran = false; + rt.on_command("START", [&](const std::string& rcmd, + const std::vector&) { + ran = (rcmd == "START"); + return gem::HostCmdAck::Accept; + }); + CHECK(rt.model().commands.has_handler("START")); + auto res = rt.model().commands.dispatch("START", {}); + CHECK(ran); + CHECK(res.ack == gem::HostCmdAck::Accept); +}