Files
secs-gem/include/secsgem/gem/control_state.hpp
raphael 8a48ffeed4 feat(gem): multi-observer state-change handlers via HandlerSlot
The single-slot set_*_handler pattern was a structural blocker, hit twice:
the daemon could not observe control-state changes because
register_default_handlers owns the slot, forcing GetControlState to read the
FSM cross-thread (a data race), and blocking WatchHealth and the Subscribe
stream's ControlStateChange variant.

HandlerSlot<Args...> keeps a primary slot with exact legacy semantics
(set_ replaces — one existing test depends on replacement) plus an
append-only observer list (add_) that survives set_ calls. Fire sites are
textually unchanged (operator bool / operator() / assign-from-function).

Applied to ControlStateMachine + ProcessJobStore + ControlJobStore (the
roadmap-critical three; the remaining single-slot classes follow the same
3-line pattern as needed). EquipmentRuntime gains an atomic control-state
mirror registered as an observer — control_state() is now safe from any
thread, retiring the GetControlState race — plus add_control_state_observer
and add_link_observer (selected/closed fan-out), the hooks WatchHealth and
Subscribe need.

Tests: observer ordering, set-replaces-primary-but-observers-survive,
observers-without-primary, PJ-store coexistence, and the runtime scenario
that was previously impossible (mirror + observer + default-handlers set_).
Core 464/464 (2816 assertions), daemon 16/16, live GEM300 demo passes with
single-fire control-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:57:53 +02:00

125 lines
3.7 KiB
C++

#pragma once
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>
#include "secsgem/gem/handler_slot.hpp"
namespace secsgem::gem {
// E30 §6.2 Control State Model.
enum class ControlState {
EquipmentOffline,
AttemptOnline,
HostOffline,
OnlineLocal,
OnlineRemote,
};
const char* control_state_name(ControlState s);
std::optional<ControlState> parse_control_state(const std::string& s);
bool is_online(ControlState s);
enum class ControlEvent {
OperatorSwitchOnline,
OperatorSwitchOffline,
OperatorSwitchLocal,
OperatorSwitchRemote,
AttemptComplete,
AttemptFailed,
HostRequestOnline,
HostRequestOffline,
};
const char* control_event_name(ControlEvent e);
std::optional<ControlEvent> parse_control_event(const std::string& s);
enum class OnlineAck : uint8_t {
Accept = 0,
NotAccept = 1,
AlreadyOnline = 2,
};
enum class OfflineAck : uint8_t {
Accept = 0,
};
enum class CommAck : uint8_t {
Accept = 0,
Denied = 1,
};
// One row of the control-state transition table (E30 §6.2 + extensions).
// `to` and `then` are both optional: a row may produce no transition (e.g.
// host_request_online while already OnlineRemote — ack only); a row may also
// chain straight through the transient AttemptOnline state via `then`.
// `ack_code` is the raw uint8_t carried by S1F18 (ONLACK) or S1F16 (OFLACK);
// its interpretation depends on the triggering event.
struct ControlTransition {
ControlState from;
ControlEvent on;
std::optional<ControlState> to;
std::optional<ControlState> then;
std::optional<uint8_t> ack_code;
};
// Pure data table — no behaviour. Lookup is first-match on (from, on).
class ControlTransitionTable {
public:
void add(ControlTransition row);
const ControlTransition* find(ControlState from, ControlEvent on) const;
std::size_t size() const { return rows_.size(); }
const std::vector<ControlTransition>& rows() const { return rows_; }
// Built-in default table, matching data/control_state.yaml exactly.
// Used by tests so they don't depend on the YAML file being present.
static ControlTransitionTable default_table();
private:
std::vector<ControlTransition> rows_;
};
// The E30 control state machine, driven by a transition table. All
// behavioural rules live in the table; this class is just the engine.
class ControlStateMachine {
public:
using StateChangeHandler =
std::function<void(ControlState from, ControlState to, ControlEvent trigger)>;
ControlStateMachine();
explicit ControlStateMachine(ControlTransitionTable table,
ControlState initial = ControlState::HostOffline);
ControlState state() const { return state_; }
bool online() const { return is_online(state_); }
// Replaces the primary handler (legacy single-slot semantics).
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
// Appends an observer that survives set_state_change_handler calls.
void add_state_change_handler(StateChangeHandler h) { on_change_.add(std::move(h)); }
// Operator actions. Return true if a transition (or self-ack) was found.
bool operator_online();
bool operator_offline();
bool operator_local();
bool operator_remote();
// Host requests. Return the ack code from the matching table row.
OnlineAck on_host_request_online();
OfflineAck on_host_request_offline();
private:
// Apply the matching row (if any) for (state_, event); returns the row.
const ControlTransition* fire(ControlEvent on);
void transition(ControlState next, ControlEvent trigger);
ControlTransitionTable table_;
ControlState state_;
HandlerSlot<ControlState, ControlState, ControlEvent> on_change_;
};
} // namespace secsgem::gem