#pragma once #include #include #include #include #include #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); // ---- reading mutable engine state from outside the io thread ------------- // THE standard pattern for every read of mutable state (variable values, // alarm activity, spool depth, ...) from gRPC/binding threads: post the // read onto the io thread — the model's single owner — and wait with a // deadline. Always truthful (no cache invalidation), and the milliseconds // of latency are irrelevant at SECS message rates. Returns nullopt if the // io thread doesn't service the post in time (not running, or stalled). // Requires run()/run_async(); in poll() mode there is nobody to serve the // post, so a same-thread caller should read the model directly instead. template auto read_sync(Fn&& fn, std::chrono::milliseconds timeout = std::chrono::milliseconds(2000)) -> std::optional&>> { using R = std::invoke_result_t&>; auto prom = std::make_shared>(); auto fut = prom->get_future(); asio::post(io_, [prom, fn = std::forward(fn)]() mutable { prom->set_value(fn()); }); if (fut.wait_for(timeout) != std::future_status::ready) return std::nullopt; return fut.get(); } // ---- 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 ------------------------------------------------------- // Safe from any thread: reads an atomic mirror updated by a state-machine // observer, so gRPC threads never touch the FSM the io thread owns. ControlState control_state() const { return control_state_cache_.load(std::memory_order_relaxed); } // Observe control-state transitions (fires on the io thread). Survives the // primary set_state_change_handler that register_default_handlers installs. // Register before run()/run_async(). void add_control_state_observer(ControlStateMachine::StateChangeHandler h) { sm_->add_state_change_handler(std::move(h)); } // Observe HSMS link state: fires on the io thread with true when a session // reaches SELECTED, false when the connection closes. Register before // run()/run_async(). Foundation for the daemon's WatchHealth stream. using LinkObserver = std::function; void add_link_observer(LinkObserver h) { link_observers_.push_back(std::move(h)); } // 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::atomic control_state_cache_{ControlState::HostOffline}; std::vector link_observers_; std::optional> work_; std::thread io_thread_; }; } // namespace secsgem::gem