diff --git a/CMakeLists.txt b/CMakeLists.txt index 943b615..a38387a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,7 @@ add_library(secsgem src/gem/communication_state.cpp src/gem/process_job_state.cpp src/gem/control_job_state.cpp + src/gem/exception_state.cpp src/gem/host_handler.cpp src/config/loader.cpp src/endpoint.cpp @@ -96,6 +97,7 @@ add_executable(secsgem_tests tests/test_loader.cpp tests/test_process_jobs.cpp tests/test_control_jobs.cpp + tests/test_exceptions.cpp ) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) target_compile_definitions(secsgem_tests PRIVATE diff --git a/include/secsgem/gem/data_model.hpp b/include/secsgem/gem/data_model.hpp index fd3e90a..d5b5f87 100644 --- a/include/secsgem/gem/data_model.hpp +++ b/include/secsgem/gem/data_model.hpp @@ -5,6 +5,7 @@ #include "secsgem/gem/store/control_jobs.hpp" #include "secsgem/gem/store/equipment_constants.hpp" #include "secsgem/gem/store/event_reports.hpp" +#include "secsgem/gem/store/exceptions.hpp" #include "secsgem/gem/store/host_commands.hpp" #include "secsgem/gem/store/limits.hpp" #include "secsgem/gem/store/process_jobs.hpp" @@ -34,6 +35,7 @@ struct EquipmentDataModel { TraceStore traces; ProcessJobStore process_jobs; ControlJobStore control_jobs; + ExceptionStore exceptions; // Convenience: VID -> value lookup spanning SVIDs and DVIDs. std::optional vid_value(uint32_t vid) const { diff --git a/include/secsgem/gem/exception_state.hpp b/include/secsgem/gem/exception_state.hpp new file mode 100644 index 0000000..86117fe --- /dev/null +++ b/include/secsgem/gem/exception_state.hpp @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "secsgem/gem/store/alarms.hpp" // AlarmAck (ACKC5) + +// E5 §9 Exception lifecycle. +// +// An *exception* (distinct from a plain *alarm*) is a recoverable +// equipment fault whose handling involves a host/equipment dialogue: +// the equipment Posts an S5F9 with one or more candidate EXRECVRA +// recovery actions; the host picks one via S5F13 to drive equipment +// recovery; equipment Notifies completion via S5F15 (with EXRESULT +// signalling success or failure); the host may abort an in-progress +// recovery via S5F17. +// +// This state machine lives per-EXID inside an `ExceptionStore`; the +// transitions mirror the request/response cadence on the wire so the +// store can be wired into the server's S5F13 / S5F17 / internal +// recovery hooks with a single dispatch through `on_host_command` +// (host-initiated) or `on_internal` (equipment-initiated). +// +// The Posted/Cleared distinction matches E5 §9.3 figure 9 — when an +// exception's underlying condition clears autonomously without ever +// being recovered (rare but legal), the FSM transitions Posted → +// Cleared directly via the `Clear` event. +namespace secsgem::gem { + +enum class ExceptionState : uint8_t { + Posted = 0, // S5F9 sent, awaiting host action or autonomous clear + Recovering = 1, // S5F13 accepted; equipment running the recovery + RecoverFailed = 2, // S5F15 reported a failed recovery; retry possible + Cleared = 3, // exception resolved (terminal; store removes the row) + NoState = 255, +}; + +const char* exception_state_name(ExceptionState s); +std::optional parse_exception_state(const std::string& s); + +enum class ExceptionEvent { + Created, // synthetic NoState -> Posted (observer signal) + Recover, // host's S5F13 — Posted/RecoverFailed -> Recovering + RecoveryComplete, // internal — Recovering -> Cleared + RecoveryFailed, // internal — Recovering -> RecoverFailed + RecoveryAbort, // host's S5F17 — Recovering -> Posted + Clear, // internal — Posted/RecoverFailed -> Cleared +}; + +const char* exception_event_name(ExceptionEvent e); +std::optional parse_exception_event(const std::string& s); + +struct ExceptionTransition { + ExceptionState from; + ExceptionEvent on; + std::optional to; + std::optional ack_code; // ACKC5 (AlarmAck) when applicable +}; + +class ExceptionTransitionTable { + public: + void add(ExceptionTransition row); + const ExceptionTransition* find(ExceptionState from, ExceptionEvent on) const; + std::size_t size() const { return rows_.size(); } + const std::vector& rows() const { return rows_; } + + // The default table matches data/exception_state.yaml (when present); + // kept in code so tests don't depend on the YAML being readable. + static ExceptionTransitionTable default_table(); + + private: + std::vector rows_; +}; + +class ExceptionStateMachine { + public: + using StateChangeHandler = + std::function; + + ExceptionStateMachine(); + explicit ExceptionStateMachine(ExceptionTransitionTable table, + ExceptionState initial = ExceptionState::Posted); + + ExceptionState state() const { return state_; } + void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); } + + // Host-initiated event (S5F13 / S5F17). Returns AlarmAck::Accept on a + // valid transition, AlarmAck::Error when no row matches. The default + // table is permissive — every host event either advances state or is + // silently accepted. + AlarmAck on_host_command(ExceptionEvent event); + + // Equipment-internal event (RecoveryComplete / RecoveryFailed / Clear). + // Returns true if a row fired. + bool on_internal(ExceptionEvent event); + + private: + const ExceptionTransition* fire(ExceptionEvent on); + void transition(ExceptionState next, ExceptionEvent trigger); + + ExceptionTransitionTable table_; + ExceptionState state_; + StateChangeHandler on_change_; +}; + +} // namespace secsgem::gem diff --git a/include/secsgem/gem/store/exceptions.hpp b/include/secsgem/gem/store/exceptions.hpp new file mode 100644 index 0000000..7c956b0 --- /dev/null +++ b/include/secsgem/gem/store/exceptions.hpp @@ -0,0 +1,133 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "secsgem/gem/exception_state.hpp" + +namespace secsgem::gem { + +// One Exception record. EXID keys the row; EXTYPE / EXMESSAGE are E5 +// §9 metadata; EXRECVRA lists the recovery-action names the host may +// pick from on S5F13. The FSM is heap-allocated so the per-exception +// state-change handler can capture a stable pointer. +struct Exception { + uint32_t exid; + std::string extype; + std::string exmessage; + std::vector exrecvra; + std::unique_ptr fsm; +}; + +class ExceptionStore { + public: + using TransitionTableFactory = + std::function; + using StateChangeHandler = + std::function; + + ExceptionStore() + : factory_([] { return ExceptionTransitionTable::default_table(); }) {} + + // Same `this`-capture caveat as ProcessJobStore: keep a stable address. + ExceptionStore(const ExceptionStore&) = delete; + ExceptionStore& operator=(const ExceptionStore&) = delete; + ExceptionStore(ExceptionStore&&) = delete; + ExceptionStore& operator=(ExceptionStore&&) = delete; + + void set_table_factory(TransitionTableFactory f) { factory_ = std::move(f); } + void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); } + + enum class PostResult { + Posted, + Denied_AlreadyExists, + }; + + // Equipment posts a new exception (S5F9). Returns Denied_AlreadyExists + // if an entry for `exid` is already live. Fires a synthetic NoState -> + // Posted change so the dispatcher can decide whether to actually emit + // the S5F9 (skip if alerts disabled, etc.). + PostResult post(uint32_t exid, std::string extype, std::string exmessage, + std::vector exrecvra) { + if (by_id_.count(exid)) return PostResult::Denied_AlreadyExists; + auto fsm = std::make_unique(factory_(), + ExceptionState::Posted); + fsm->set_state_change_handler( + [this, exid](ExceptionState from, ExceptionState to, ExceptionEvent trig) { + if (on_change_) on_change_(exid, from, to, trig); + }); + by_id_.emplace(exid, Exception{exid, std::move(extype), std::move(exmessage), + std::move(exrecvra), std::move(fsm)}); + if (on_change_) { + on_change_(exid, ExceptionState::NoState, ExceptionState::Posted, + ExceptionEvent::Created); + } + return PostResult::Posted; + } + + bool has(uint32_t exid) const { return by_id_.count(exid) > 0; } + + const Exception* get(uint32_t exid) const { + auto it = by_id_.find(exid); + return it == by_id_.end() ? nullptr : &it->second; + } + Exception* get(uint32_t exid) { + auto it = by_id_.find(exid); + return it == by_id_.end() ? nullptr : &it->second; + } + + ExceptionState state(uint32_t exid) const { + auto it = by_id_.find(exid); + return it == by_id_.end() ? ExceptionState::NoState : it->second.fsm->state(); + } + + // S5F13 recovery dispatch. Validates EXRECVRA matches one the post + // advertised; returns AlarmAck::Error on unknown EXID or unknown action. + AlarmAck on_recover(uint32_t exid, const std::string& exrecvra) { + auto* ex = get(exid); + if (!ex) return AlarmAck::Error; + bool known = false; + for (const auto& a : ex->exrecvra) if (a == exrecvra) { known = true; break; } + if (!known) return AlarmAck::Error; + return ex->fsm->on_host_command(ExceptionEvent::Recover); + } + + // S5F17 recovery-abort dispatch. + AlarmAck on_recover_abort(uint32_t exid) { + auto* ex = get(exid); + if (!ex) return AlarmAck::Error; + return ex->fsm->on_host_command(ExceptionEvent::RecoveryAbort); + } + + // Equipment-internal lifecycle events. Cleared transitions remove the + // entry — exceptions are not retained past clearance, matching the + // E5 §9.3 model where Cleared is the lifecycle's terminus. + bool fire_internal(uint32_t exid, ExceptionEvent event) { + auto* ex = get(exid); + if (!ex) return false; + const bool fired = ex->fsm->on_internal(event); + if (fired && ex->fsm->state() == ExceptionState::Cleared) { + by_id_.erase(exid); + } + return fired; + } + + std::size_t size() const { return by_id_.size(); } + std::vector ids() const { + std::vector out; + out.reserve(by_id_.size()); + for (const auto& kv : by_id_) out.push_back(kv.first); + return out; + } + + private: + std::map by_id_; + TransitionTableFactory factory_; + StateChangeHandler on_change_; +}; + +} // namespace secsgem::gem diff --git a/src/gem/exception_state.cpp b/src/gem/exception_state.cpp new file mode 100644 index 0000000..06d4794 --- /dev/null +++ b/src/gem/exception_state.cpp @@ -0,0 +1,114 @@ +#include "secsgem/gem/exception_state.hpp" + +namespace secsgem::gem { + +const char* exception_state_name(ExceptionState s) { + switch (s) { + case ExceptionState::Posted: return "Posted"; + case ExceptionState::Recovering: return "Recovering"; + case ExceptionState::RecoverFailed: return "RecoverFailed"; + case ExceptionState::Cleared: return "Cleared"; + case ExceptionState::NoState: return "NoState"; + } + return "?"; +} + +std::optional parse_exception_state(const std::string& s) { + if (s == "Posted") return ExceptionState::Posted; + if (s == "Recovering") return ExceptionState::Recovering; + if (s == "RecoverFailed") return ExceptionState::RecoverFailed; + if (s == "Cleared") return ExceptionState::Cleared; + if (s == "NoState") return ExceptionState::NoState; + return std::nullopt; +} + +const char* exception_event_name(ExceptionEvent e) { + switch (e) { + case ExceptionEvent::Created: return "Created"; + case ExceptionEvent::Recover: return "Recover"; + case ExceptionEvent::RecoveryComplete: return "RecoveryComplete"; + case ExceptionEvent::RecoveryFailed: return "RecoveryFailed"; + case ExceptionEvent::RecoveryAbort: return "RecoveryAbort"; + case ExceptionEvent::Clear: return "Clear"; + } + return "?"; +} + +std::optional parse_exception_event(const std::string& s) { + // `created` is synthetic — never legal in a table row. + if (s == "recover") return ExceptionEvent::Recover; + if (s == "recovery_complete") return ExceptionEvent::RecoveryComplete; + if (s == "recovery_failed") return ExceptionEvent::RecoveryFailed; + if (s == "recovery_abort") return ExceptionEvent::RecoveryAbort; + if (s == "clear") return ExceptionEvent::Clear; + return std::nullopt; +} + +void ExceptionTransitionTable::add(ExceptionTransition row) { + rows_.push_back(row); +} + +const ExceptionTransition* ExceptionTransitionTable::find( + ExceptionState from, ExceptionEvent on) const { + for (const auto& r : rows_) { + if (r.from == from && r.on == on) return &r; + } + return nullptr; +} + +ExceptionTransitionTable ExceptionTransitionTable::default_table() { + using S = ExceptionState; + using E = ExceptionEvent; + ExceptionTransitionTable t; + + // POSTED ---------------------------------------------------------- + t.add({S::Posted, E::Recover, S::Recovering, std::nullopt}); + t.add({S::Posted, E::Clear, S::Cleared, std::nullopt}); + + // RECOVERING ------------------------------------------------------ + t.add({S::Recovering, E::RecoveryComplete, S::Cleared, std::nullopt}); + t.add({S::Recovering, E::RecoveryFailed, S::RecoverFailed, std::nullopt}); + t.add({S::Recovering, E::RecoveryAbort, S::Posted, std::nullopt}); + + // RECOVER-FAILED -------------------------------------------------- + t.add({S::RecoverFailed, E::Recover, S::Recovering, std::nullopt}); + t.add({S::RecoverFailed, E::Clear, S::Cleared, std::nullopt}); + + // CLEARED is terminal — the store removes the exception entry. + return t; +} + +ExceptionStateMachine::ExceptionStateMachine() + : ExceptionStateMachine(ExceptionTransitionTable::default_table(), + ExceptionState::Posted) {} + +ExceptionStateMachine::ExceptionStateMachine(ExceptionTransitionTable table, + ExceptionState initial) + : table_(std::move(table)), state_(initial) {} + +void ExceptionStateMachine::transition(ExceptionState next, ExceptionEvent trig) { + if (state_ == next) return; + const ExceptionState prev = state_; + state_ = next; + if (on_change_) on_change_(prev, next, trig); +} + +const ExceptionTransition* ExceptionStateMachine::fire(ExceptionEvent on) { + const ExceptionTransition* row = table_.find(state_, on); + if (!row) return nullptr; + if (row->to) transition(*row->to, on); + return row; +} + +AlarmAck ExceptionStateMachine::on_host_command(ExceptionEvent event) { + const auto* row = fire(event); + if (!row) return AlarmAck::Error; + if (row->ack_code) return static_cast(*row->ack_code); + return AlarmAck::Accept; +} + +bool ExceptionStateMachine::on_internal(ExceptionEvent event) { + return fire(event) != nullptr; +} + +} // namespace secsgem::gem diff --git a/tests/test_exceptions.cpp b/tests/test_exceptions.cpp new file mode 100644 index 0000000..29a1815 --- /dev/null +++ b/tests/test_exceptions.cpp @@ -0,0 +1,104 @@ +#include + +#include + +#include "secsgem/gem/exception_state.hpp" +#include "secsgem/gem/store/exceptions.hpp" + +using namespace secsgem::gem; + +TEST_CASE("ExceptionStateMachine: default starts Posted") { + ExceptionStateMachine fsm; + CHECK(fsm.state() == ExceptionState::Posted); +} + +TEST_CASE("ExceptionStateMachine: Posted -> Recovering via host Recover") { + ExceptionStateMachine fsm; + CHECK(fsm.on_host_command(ExceptionEvent::Recover) == AlarmAck::Accept); + CHECK(fsm.state() == ExceptionState::Recovering); +} + +TEST_CASE("ExceptionStateMachine: Recovering -> Cleared on RecoveryComplete") { + ExceptionStateMachine fsm; + fsm.on_host_command(ExceptionEvent::Recover); + REQUIRE(fsm.state() == ExceptionState::Recovering); + CHECK(fsm.on_internal(ExceptionEvent::RecoveryComplete)); + CHECK(fsm.state() == ExceptionState::Cleared); +} + +TEST_CASE("ExceptionStateMachine: Recovering -> RecoverFailed -> Recovering retry") { + ExceptionStateMachine fsm; + fsm.on_host_command(ExceptionEvent::Recover); + CHECK(fsm.on_internal(ExceptionEvent::RecoveryFailed)); + CHECK(fsm.state() == ExceptionState::RecoverFailed); + CHECK(fsm.on_host_command(ExceptionEvent::Recover) == AlarmAck::Accept); + CHECK(fsm.state() == ExceptionState::Recovering); +} + +TEST_CASE("ExceptionStateMachine: RecoveryAbort returns to Posted") { + ExceptionStateMachine fsm; + fsm.on_host_command(ExceptionEvent::Recover); + REQUIRE(fsm.state() == ExceptionState::Recovering); + CHECK(fsm.on_host_command(ExceptionEvent::RecoveryAbort) == AlarmAck::Accept); + CHECK(fsm.state() == ExceptionState::Posted); +} + +TEST_CASE("ExceptionStateMachine: autonomous Clear from Posted -> Cleared") { + ExceptionStateMachine fsm; + CHECK(fsm.on_internal(ExceptionEvent::Clear)); + CHECK(fsm.state() == ExceptionState::Cleared); +} + +TEST_CASE("ExceptionStateMachine: illegal host event from Cleared returns Error") { + ExceptionStateMachine fsm(ExceptionTransitionTable::default_table(), + ExceptionState::Cleared); + CHECK(fsm.on_host_command(ExceptionEvent::Recover) == AlarmAck::Error); +} + +// ---- ExceptionStore ----------------------------------------------------- + +TEST_CASE("ExceptionStore: post rejects duplicate EXID") { + ExceptionStore store; + CHECK(store.post(42, "FATAL", "vacuum lost", {"RETRY"}) == + ExceptionStore::PostResult::Posted); + CHECK(store.post(42, "FATAL", "vacuum lost", {"RETRY"}) == + ExceptionStore::PostResult::Denied_AlreadyExists); + CHECK(store.size() == 1); +} + +TEST_CASE("ExceptionStore: on_recover validates EXRECVRA against post list") { + ExceptionStore store; + store.post(1, "WARN", "fan stalled", {"RETRY", "RESTART"}); + CHECK(store.on_recover(1, "RETRY") == AlarmAck::Accept); + // Same EXID, different action — must come from the original list. + store.post(2, "WARN", "x", {"ONLY"}); + CHECK(store.on_recover(2, "BOGUS") == AlarmAck::Error); + CHECK(store.on_recover(99, "RETRY") == AlarmAck::Error); // unknown EXID +} + +TEST_CASE("ExceptionStore: Cleared removes the entry") { + ExceptionStore store; + store.post(7, "INFO", "x", {"OK"}); + REQUIRE(store.has(7)); + store.on_recover(7, "OK"); + store.fire_internal(7, ExceptionEvent::RecoveryComplete); + CHECK_FALSE(store.has(7)); + CHECK(store.size() == 0); +} + +TEST_CASE("ExceptionStore: state-change handler observes Created + transitions") { + ExceptionStore store; + std::vector> log; + store.set_state_change_handler( + [&](uint32_t exid, ExceptionState f, ExceptionState t, ExceptionEvent) { + log.emplace_back(exid, f, t); + }); + store.post(42, "FATAL", "x", {"R"}); + store.on_recover(42, "R"); + store.fire_internal(42, ExceptionEvent::RecoveryComplete); + REQUIRE(log.size() == 3); + CHECK(std::get<1>(log[0]) == ExceptionState::NoState); + CHECK(std::get<2>(log[0]) == ExceptionState::Posted); + CHECK(std::get<2>(log[1]) == ExceptionState::Recovering); + CHECK(std::get<2>(log[2]) == ExceptionState::Cleared); +}