STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
SequencerService.hpp
Go to the documentation of this file.
1#pragma once
2
3#include <atomic>
4#include <condition_variable>
5#include <cstdint>
6#include <deque>
7#include <functional>
8#include <future>
9#include <map>
10#include <memory>
11#include <mutex>
12#include <string>
13#include <thread>
14#include <vector>
15
16#include "config/Config.hpp"
19#include "control/HoldTimer.hpp"
26
27// Only referenced by const&, so a forward declaration keeps the whole config header out of every
28// translation unit that includes this one.
29namespace fsw {
30namespace config {
31struct Config;
32}
33} // namespace fsw
34
35namespace sequencer {
36
37/**
38 * Top-level sequencer service.
39 *
40 * Owns and wires together:
41 * - StateMachine — transition validation
42 * - ActuatorCommander — UDP actuator commanding
43 * - AbortBroadcaster — abort UDP broadcast
44 * - HoldTimer — the timed hold (FIRE countdown) + controller notifications
45 * - ElodinClient — publishes state + allowed transitions to Elodin DB
46 *
47 * All external commands arrive via the TCP server in sequencer_main.cpp
48 * and are dispatched to the methods below.
49 *
50 * ── Threading contract ──────────────────────────────────────────────────────
51 * Every public command method is a thin wrapper that puts work on cmd_queue_ and waits for the
52 * single worker thread (commandLoop) to run it. **Exactly one thread is ever inside the state
53 * machine.** Callers may come from anywhere: the TCP command path, and the FireManager timer
54 * thread when a burn expires.
55 *
56 * This replaced a design where nothing was serialized at all. Individual fields were atomic,
57 * but a transition is a seven-step sequence built out of them — stop the republish loop, apply
58 * actuators, restart the loop, assign the state, arm the burn — and making each step atomic does
59 * nothing for the sequence. Two threads interleaving it could move-assign onto a still-joinable
60 * std::thread, which is a std::terminate: the sequencer died outright if an operator aborted at
61 * the moment the fire timer expired.
62 *
63 * A mutex was the obvious fix and does not work here. An operator thread holding it calls
64 * fire_manager_.stop(), which joins the timer thread — while the timer thread is inside its
65 * expiry callback waiting for that same mutex. A recursive_mutex does not help; the re-entry is
66 * cross-thread. A queue sidesteps it entirely: the timer thread never *enters* the state
67 * machine, it posts a request and returns immediately (see kDetached below).
68 */
70public:
71 SequencerService() = default;
73
74 /**
75 * Load config, CSVs, connect to Elodin.
76 * @param config_path Path to config.toml (falls back to standard locations).
77 * @return true on success.
78 */
79 bool init(const std::string& config_path = "config/config.toml");
80
81 /**
82 * Request a state transition by name (e.g. "Fuel Fill").
83 * Validates via StateMachine unless debug mode is on.
84 * Applies actuators, fires abort/FIRE lifecycle handlers, publishes to Elodin.
85 * @return true if transition was accepted and executed.
86 */
87 bool transitionTo(const std::string& state_name);
88 bool transitionTo(const std::string& state_name, uint32_t requested_hold_ms,
89 std::string* refusal_reason = nullptr);
90 /** Resolve-free overload. Internal callers use this so a state never round-trips through its
91 * own name, which is what let a rename refuse the fire timer's expiry transition. */
92 bool transitionTo(State to);
93
94 /**
95 * Transition, optionally overriding the target state's hold with a client-supplied window.
96 *
97 * `requested_hold_ms` of 0 means "none supplied" — the state's configured default is used, and
98 * this is the form every internal caller uses. A non-zero value is only honoured for a state
99 * whose hold rule is marked gui_settable, which the burn state never is; anything else is
100 * REFUSED WITHOUT TRANSITIONING. That last part is the point: a client that queues a command,
101 * disconnects and has it replayed on reconnect must end up where it started, not in a hold it
102 * cannot be talked out of.
103 *
104 * @param refusal_reason optional; on a false return, set to a short phrase suitable for a
105 * protocol reply. One validation, one wording — the TCP layer does not get its own copy
106 * of these rules to drift out of step with.
107 */
108 bool transitionTo(State to, uint32_t requested_hold_ms, std::string* refusal_reason = nullptr);
109
110 /**
111 * Enable or disable debug mode.
112 * Debug mode: all state transitions allowed, manual actuator commands accepted.
113 */
114 bool setDebugMode(bool enabled);
115
116 /**
117 * Debug mode only: manual OPEN/CLOSE for one role. Overrides persist until a state
118 * transition (which clears overrides and applies the new state's CSV).
119 *
120 * Allowed in a dynamic state too, where it outranks the running script's position for that
121 * valve. The script cannot notice, and the two will fight over it — but the operator with
122 * their hand on the panel is the last line, and taking that away from them to protect a script
123 * is the wrong trade.
124 *
125 * @param refusal_reason optional; on a false return, set to a short phrase for a protocol
126 * reply. One validation, one wording — the TCP layer does not get its own copy of these
127 * rules to drift out of step with.
128 */
129 bool manualActuator(const std::string& name, int pos, std::string* refusal_reason = nullptr);
130
131 /**
132 * Extend the FIRE window (only valid while in FIRE state).
133 */
134 bool extendFire();
135
136 /**
137 * What this process made of its dynamic states at startup, as newline-terminated text.
138 *
139 * One line per state that declares a script:
140 * SCRIPT:<id>:<name>:OK:<statements>:<timeout_ms>
141 * SCRIPT:<id>:<name>:REFUSED:<reason>
142 * terminated by "END\n".
143 *
144 * Read-only and answerable from any thread — the answer was decided once, at config load, and
145 * is fixed for the life of the run. It exists so the panel can show a refused state as dead
146 * with a reason on hover rather than as a button that errors when pressed.
147 */
148 std::string scriptStatusReport() const;
149
151 return current_state_.load();
152 }
153 bool isDebugMode() const {
154 return debug_mode_.load();
155 }
156
157private:
158 // ── Command queue ─────────────────────────────────────────────────────────
159 struct Command {
160 std::function<bool()> fn;
161 // Null for a detached (fire-and-forget) command. shared_ptr so the worker can still
162 // fulfil the promise after a caller has given up waiting on its future.
163 std::shared_ptr<std::promise<bool>> result;
164 };
165
166 std::deque<Command> cmd_queue_;
167 std::mutex cmd_mutex_;
168 std::condition_variable cmd_cv_;
169 std::thread cmd_thread_;
170 std::atomic<bool> cmd_stop_{false};
171
172 /** How long a caller waits for its command to run before giving up and reporting failure.
173 * Generous: the point is that a wedged worker cannot pin every TCP thread forever, not to
174 * impose a deadline on normal work (a transition is milliseconds). */
175 static constexpr int kCommandTimeoutSeconds = 10;
176
177 void commandLoop();
178 /** Post work and block until the worker has run it. Returns false if the service is shutting
179 * down or the worker did not get to it within kCommandTimeoutSeconds. */
180 bool enqueueAndWait(std::function<bool()> fn);
181 /** Post work and return immediately. Used by the fire-expiry callback: waiting there would
182 * reintroduce the join deadlock described above. */
183 void enqueueDetached(std::function<bool()> fn);
184
185 // Worker-thread bodies. These hold the real logic and must only ever run on cmd_thread_ —
186 // they assume single-threaded access to the state machine and the actuator tables.
187 bool doTransitionTo(State to, uint32_t requested_hold_ms = 0,
188 std::string* refusal_reason = nullptr);
189 bool doSetDebugMode(bool enabled);
190 bool doManualActuator(const std::string& name, int pos, std::string* refusal_reason = nullptr);
191 bool doExtendFire();
192
193 /**
194 * One state's timed hold: how long, where it lands, and whether a client may set the length.
195 *
196 * Assembled from config at load. Exactly two things create entries — [fire] and [flow] — and
197 * `gui_settable` is written as a literal in both places rather than read from config, so no
198 * config edit can make a burn window client-settable.
199 */
200 struct HoldRule {
201 uint32_t default_ms = 0;
202 uint32_t max_ms = 0;
203 /** What EXTEND_FIRE restarts at. 0 makes the hold a fixed window. */
204 uint32_t extended_ms = 0;
206 bool gui_settable = false;
207 /**
208 * Milliseconds the gate actuator waits after the transition before it opens, from the
209 * delays CSV. The hold runs for (this + the requested duration), so the requested duration
210 * is what the GATE VALVE is open for rather than what the STATE is held for. 0 when the
211 * state opens everything at once.
212 */
213 uint32_t gate_open_delay_ms = 0;
214 /**
215 * The same actuator's delay in the RETURN state's column. The valve does not close at the
216 * expiry transition either — it closes that much after it, so a stagger on the way out
217 * lengthens the open window exactly as a stagger on the way in shortens it.
218 */
220 std::string gate_actuator;
221 };
222
228 /** Live calibrated pressures for pressure(), on its own Elodin connection. */
231
232 /** Build the callbacks a script is allowed to reach the world through. */
234
235 std::atomic<State> current_state_{State::IDLE};
236 std::atomic<bool> debug_mode_{false};
237
238 /** Holdable states, by target. Rebuilt on every config load; read on the transition path.
239 * RELOAD_CONFIG runs on a TCP handler thread while a transition may be running on another,
240 * hence the mutex. */
241 std::map<State, HoldRule> hold_rules_;
242
243 /** Dynamic states whose scripts loaded and validated. Guarded by config_mutex_. */
244 std::map<State, DynamicState> dynamic_states_;
245
246 /**
247 * States that declare a script which did NOT hold up, with the reason.
248 *
249 * These are not enterable. doTransitionTo refuses them in its validation block, before the
250 * abort broadcast and before any valve moves, and allowedBitmask() omits them so nothing ever
251 * advertises one as reachable in the first place. Guarded by config_mutex_.
252 */
253 std::map<State, std::string> refused_states_;
254
255 mutable std::mutex config_mutex_;
256
257 std::string config_path_;
258 std::string config_content_;
259
260 /** Directory containing config_path_, or "." — where scripts/ is resolved against. */
261 std::string configDir() const;
262
263 /** Local address board-facing UDP leaves from, resolved once in init(). "0.0.0.0" means the
264 * host has no NIC on the board subnet (a dev box) and traffic stays unpinned. */
265 std::string daq_bind_address_{"0.0.0.0"};
266
267 /** Delay between ABORT and ABORT_DONE. Was an AbortBroadcaster ctor default; kept here beside
268 * the other fire/abort timings until a config key is wanted for it. */
269 static constexpr uint32_t kAbortDoneDelayMs = 3000;
270
271 // Actuator boards as they were at init(). Held so an Elodin reconnect can re-register the
272 // VTables without re-reading config.toml — config is frozen for the life of a run.
273 std::vector<fsw::elodin::BoardChannels> actuator_boards_;
274 std::vector<fsw::elodin::BoardChannels> ball_valve_boards_; // BV_CMD [0x35] tables
275
276 // Elodin connection retry. sensor-actuator.service is started alongside sensor-elodin, so the
277 // first connect can lose the race by milliseconds; without this the service runs forever with
278 // no db and every valve reads "undefined".
279 static constexpr int kElodinRetrySeconds = 5;
280 std::string elodin_host_ = "127.0.0.1";
281 uint16_t elodin_port_ = 2240;
283 std::atomic<bool> elodin_retry_stop_{false};
284
285 // Abort states where AbortBroadcaster should fire
286 static bool isAbortState(State s);
287
288 // Publish current state + allowed transitions to Elodin [0x50, 0x00]
289 void publishState();
290 // Publish raw state transition to Elodin [0x43, 0x00] (legacy VTable)
291 void publishStateTransition(State from, State to);
292
293 /** Connect to Elodin and do everything that depends on the connection: register the ACT_CMD
294 * VTables, hand the client to the commander, publish initial state. Safe to call repeatedly —
295 * a reconnect must re-register, since the VTables live in the db process. */
296 bool tryConnectElodin();
297 void startElodinRetry();
298 void stopElodinRetry();
299
300 bool loadConfig(const std::string& path);
301
302 /**
303 * Everything a config load has to apply, in the one order that works.
304 *
305 * Separate from init() so that order is stated once. It has to be exact: [[states]] is adopted
306 * before anything resolves a state name, both CSVs are parsed against that table, and [fire] is
307 * resolved last, against the same table rather than the compiled enum.
308 */
309 bool applyConfig(const fsw::config::Config& cfg);
310
311 /** Send FIRE_START / FIRE_STOP to controller_service. The single place anything tells the
312 * controller the burn gate changed. Retries once if the first attempt is not acknowledged. */
313 void notifyControllerFire(bool active);
314
315 /** One attempt: connect, send, wait for the controller's "OK". Returns whether it was
316 * acknowledged — a successful send() is not evidence of delivery, since the kernel buffers
317 * for an application that may never read. */
319
320 // ── Fire semantics, from config ───────────────────────────────────────────
321 // Which state fires, and where the timer lands when it expires, are config rather than
322 // enumerators. `state_val == 16` in ControllerService and a stringified State::ARMED here were
323 // the two places a rename or renumber silently broke ignition.
324 // Default to UNKNOWN, not a compiled Fire/Armed: an absent or misnamed [fire] entry leaves the
325 // fire timer disabled (UNKNOWN never matches a real state in transitionTo's `to ==
326 // fire_state_`) rather than silently attaching the burn to whatever state now holds the old
327 // literal id.
330 // The burn window, from [fire]. Held here rather than on the timer because a duration that
331 // lives on the timer behind a setter is one a reload can forget to refresh — which is exactly
332 // what happened. HoldTimer takes it per start() instead.
333 uint32_t fire_duration_ms_{6000};
334 uint32_t fire_extended_ms_{10000};
335 std::string controller_host_{"127.0.0.1"};
336 uint16_t controller_port_{8000};
337 /** [controller_service].sequencer_owns_valves. When true the fire hold is given no notifier at
338 * all, so there is no FIRE_START to suppress and no flag to forget to check. */
340};
341
342} // namespace sequencer
Standalone Elodin database client.
Sends ABORT / ABORT_DONE / CLEAR_ABORT UDP broadcast packets (PacketHeader only — 6 bytes) to the con...
Sends UDP actuator commands based on state transitions.
A cancellable hold of an exact length, and nothing else.
Definition HoldTimer.hpp:60
Live calibrated pressures, by ROLE NAME, for the sequencer.
Runs one dynamic state's script on its own thread.
bool setDebugMode(bool enabled)
Enable or disable debug mode.
std::map< State, DynamicState > dynamic_states_
Dynamic states whose scripts loaded and validated.
bool sequencer_owns_valves_
[controller_service].sequencer_owns_valves.
bool tryConnectElodin()
Connect to Elodin and do everything that depends on the connection: register the ACT_CMD VTables,...
bool applyConfig(const fsw::config::Config &cfg)
Everything a config load has to apply, in the one order that works.
bool init(const std::string &config_path="config/config.toml")
Load config, CSVs, connect to Elodin.
void notifyControllerFire(bool active)
Send FIRE_START / FIRE_STOP to controller_service.
static constexpr int kElodinRetrySeconds
static constexpr uint32_t kAbortDoneDelayMs
Delay between ABORT and ABORT_DONE.
std::condition_variable cmd_cv_
std::string daq_bind_address_
Local address board-facing UDP leaves from, resolved once in init().
void enqueueDetached(std::function< bool()> fn)
Post work and return immediately.
fsw::elodin::ElodinClient elodin_
std::deque< Command > cmd_queue_
bool transitionTo(const std::string &state_name)
Request a state transition by name (e.g.
std::string scriptStatusReport() const
What this process made of its dynamic states at startup, as newline-terminated text.
bool manualActuator(const std::string &name, int pos, std::string *refusal_reason=nullptr)
Debug mode only: manual OPEN/CLOSE for one role.
PressureFeed pressure_feed_
Live calibrated pressures for pressure(), on its own Elodin connection.
bool doTransitionTo(State to, uint32_t requested_hold_ms=0, std::string *refusal_reason=nullptr)
ScriptEnv makeScriptEnv(const DynamicState &ds)
Build the callbacks a script is allowed to reach the world through.
ActuatorCommander actuator_commander_
static constexpr int kCommandTimeoutSeconds
How long a caller waits for its command to run before giving up and reporting failure.
std::string configDir() const
Directory containing config_path_, or "." — where scripts/ is resolved against.
bool notifyControllerFireOnce(bool active)
One attempt: connect, send, wait for the controller's "OK".
bool enqueueAndWait(std::function< bool()> fn)
Post work and block until the worker has run it.
std::atomic< State > current_state_
void publishStateTransition(State from, State to)
std::atomic< bool > elodin_retry_stop_
std::vector< fsw::elodin::BoardChannels > ball_valve_boards_
static bool isAbortState(State s)
std::map< State, std::string > refused_states_
States that declare a script which did NOT hold up, with the reason.
bool doManualActuator(const std::string &name, int pos, std::string *refusal_reason=nullptr)
std::map< State, HoldRule > hold_rules_
Holdable states, by target.
std::vector< fsw::elodin::BoardChannels > actuator_boards_
bool loadConfig(const std::string &path)
bool extendFire()
Extend the FIRE window (only valid while in FIRE state).
Parses state_transitions.csv and validates state transitions.
Motorized ball valve position: raw ADC counts -> % open.
State
System state IDs — must match the TypeScript SystemState enum in web-gui/shared/types....
The whole parsed config.toml, restricted to what C++ consumes.
Definition Config.hpp:264
One dynamic state, resolved and proven usable.
What a running script is allowed to do to the world.
std::shared_ptr< std::promise< bool > > result
One state's timed hold: how long, where it lands, and whether a client may set the length.
uint32_t gate_close_delay_ms
The same actuator's delay in the RETURN state's column.
uint32_t extended_ms
What EXTEND_FIRE restarts at.
uint32_t gate_open_delay_ms
Milliseconds the gate actuator waits after the transition before it opens, from the delays CSV.