STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
SequencerService.cpp
Go to the documentation of this file.
2
3#include <arpa/inet.h>
4#include <fcntl.h>
5#include <netinet/in.h>
6#include <poll.h>
7#include <sys/socket.h>
8#include <unistd.h>
9
10#include <array>
11#include <cerrno>
12#include <chrono>
13#include <cstring>
14#include <fstream>
15#include <iostream>
16#include <sstream>
17#include <thread>
18
20#include "config/Config.hpp"
23#include "net/DaqInterface.hpp"
24
25namespace sequencer {
26
27// ─────────────────────────────────────────────────────────────────────────────
28// Elodin VTable IDs
29// [0x50, 0x00] = 0x5000 — SequencerState (new: state + allowed transitions + debug_mode)
30// [0x43, 0x00] = 0x4300 — StateTransition (legacy: matches Node.js backend publication)
31// ─────────────────────────────────────────────────────────────────────────────
32static constexpr uint16_t VTABLE_SEQUENCER_STATE = 0x5000;
33static constexpr uint16_t VTABLE_STATE_TRANSITION = 0x4300;
34
35// How long notifyControllerFire() waits for the controller_service TCP connection before giving
36// up. Deliberately short: this is on the state-transition path, and a missed FIRE_START/FIRE_STOP
37// is recoverable (the controller also watches the sequencer state packet) while a stalled
38// transition is not.
39static constexpr int kControllerConnectTimeoutMs = 300;
40
41// SequencerState: u64 @0 | u8 @8 | pad[3] @9 (align u32) | allowed_bitmask u32 @12 | debug_mode u8
42// @16 — 17 bytes
45
46// StateTransition message: timestamp_ns(u64) | from_state(u8) | to_state(u8) | reason(u8)
48
49static uint64_t now_ns() {
50 using namespace std::chrono;
51 return static_cast<uint64_t>(
52 duration_cast<nanoseconds>(steady_clock::now().time_since_epoch()).count());
53}
54
55// ─────────────────────────────────────────────────────────────────────────────
57 // Drain order matters. Stop accepting work first, then let the worker finish what it holds,
58 // and only then tear down the things a command body touches — otherwise the worker can be
59 // mid-transition while fire_manager_ is being destroyed under it.
60 {
61 std::lock_guard<std::mutex> lk(cmd_mutex_);
62 cmd_stop_ = true;
63 }
64 cmd_cv_.notify_all();
65 if (cmd_thread_.joinable())
66 cmd_thread_.join();
67
69 actuator_commander_.stopContinuousLoop();
70 hold_timer_.stop();
71}
72
73// ─────────────────────────────────────────────────────────────────────────────
74// Command queue
75// ─────────────────────────────────────────────────────────────────────────────
77 for (;;) {
78 Command cmd;
79 {
80 std::unique_lock<std::mutex> lk(cmd_mutex_);
81 cmd_cv_.wait(lk, [this] {
82 return cmd_stop_ || !cmd_queue_.empty();
83 });
84 // Shutdown wins over draining: a queue full of transitions is not worth running while
85 // the process is going down, and the caller of each is already gone or giving up.
86 if (cmd_stop_ && cmd_queue_.empty())
87 return;
88 if (cmd_queue_.empty())
89 continue;
90 cmd = std::move(cmd_queue_.front());
91 cmd_queue_.pop_front();
92 }
93
94 bool ok = false;
95 try {
96 ok = cmd.fn();
97 } catch (const std::exception& e) {
98 std::cerr << "[SequencerService] command threw: " << e.what() << std::endl;
99 } catch (...) {
100 std::cerr << "[SequencerService] command threw an unknown exception" << std::endl;
101 }
102 // The promise must be fulfilled on every path including a throw, or a caller waiting on
103 // the future blocks for the full timeout on what was an immediate failure.
104 if (cmd.result)
105 cmd.result->set_value(ok);
106 }
107}
108
109bool SequencerService::enqueueAndWait(std::function<bool()> fn) {
110 auto result = std::make_shared<std::promise<bool>>();
111 auto future = result->get_future();
112 {
113 std::lock_guard<std::mutex> lk(cmd_mutex_);
114 if (cmd_stop_)
115 return false;
116 cmd_queue_.push_back(Command{std::move(fn), result});
117 }
118 cmd_cv_.notify_one();
119
120 if (future.wait_for(std::chrono::seconds(kCommandTimeoutSeconds)) !=
121 std::future_status::ready) {
122 std::cerr << "[SequencerService] command timed out after " << kCommandTimeoutSeconds
123 << "s waiting for the worker — reporting failure (it may still run)" << std::endl;
124 return false;
125 }
126 return future.get();
127}
128
129void SequencerService::enqueueDetached(std::function<bool()> fn) {
130 {
131 std::lock_guard<std::mutex> lk(cmd_mutex_);
132 if (cmd_stop_)
133 return;
134 cmd_queue_.push_back(Command{std::move(fn), nullptr});
135 }
136 cmd_cv_.notify_one();
137}
138
139// ─────────────────────────────────────────────────────────────────────────────
140static std::string readFile(const std::string& path) {
141 std::ifstream f(path);
142 if (!f.is_open())
143 return {};
144 std::ostringstream ss;
145 ss << f.rdbuf();
146 return ss.str();
147}
148
149bool SequencerService::loadConfig(const std::string& path) {
150 const char* fallbacks[] = {
151 "config/config.toml",
152 "../config/config.toml",
153 "../../config/config.toml",
154 };
155
158
159 if (config_content_.empty()) {
160 for (const char* fb : fallbacks) {
161 if (std::string(fb) == path)
162 continue;
164 if (!config_content_.empty()) {
165 config_path_ = fb;
166 break;
167 }
168 }
169 }
170
171 if (config_content_.empty())
172 std::cerr << "[SequencerService] config.toml not found; using defaults" << std::endl;
173 else
174 std::cout << "[SequencerService] Loaded config: " << config_path_ << std::endl;
175
176 return true; // non-fatal: service can start without config
177}
178
179std::string SequencerService::configDir() const {
180 // Scripts live beside the config that names them, so they follow it through whichever fallback
181 // path loadConfig() actually found — resolving them against the cwd instead would work from
182 // build/ and silently fail from anywhere else.
183 const size_t slash = config_path_.find_last_of('/');
184 if (slash == std::string::npos)
185 return ".";
186 return config_path_.substr(0, slash);
187}
188
189// ─────────────────────────────────────────────────────────────────────────────
190// Resolve a relative path against a list of candidate prefixes; returns first
191// existing match or the original path (so callers still get the error).
192static std::string resolveDataPath(const std::string& rel) {
193 const char* prefixes[] = {
194 "", // cwd = repo root (launched from repo root)
195 "../", // cwd = build/ (e.g. manual run)
196 "../../", // cwd = build/bin/
197 };
198 for (const char* pfx : prefixes) {
199 std::string candidate = std::string(pfx) + rel;
200 if (std::ifstream(candidate).good())
201 return candidate;
202 }
203 return rel; // original — caller will get the open error
204}
205
206// ─────────────────────────────────────────────────────────────────────────────
207// applyConfig — everything a config load has to apply, in the one order that works.
208// Kept separate from init() so the order is stated once, in one place, rather than inline.
209// ─────────────────────────────────────────────────────────────────────────────
211 // Adopt [[states]] BEFORE anything resolves a state name. Both CSVs are parsed by name through
212 // StateMachine::fromName(), which prefers the config's by_name map and falls back to the
213 // compiled enum. This call used to sit below both loads, so the transition table was built
214 // against the fallback numbering and every later fromName() answered from the config's — the
215 // table said Armed(2) -> Press Standby(20) while a command for Press Standby resolved to 3,
216 // and the transition was refused. Only ids identical in both numberings survived, which is why
217 // Idle <-> Armed worked and nothing else did.
219
220 // State machine CSV — path from config.toml (canonical: daq-server/config/).
221 std::string sm_csv = resolveDataPath(cfg.state_machine.transitions_csv);
222 if (!state_machine_.load(sm_csv)) {
223 std::cerr
224 << "[SequencerService] Failed to load state_transitions.csv (tried relative to cwd: "
225 << sm_csv << ")" << std::endl;
226 return false;
227 }
228
229 // Actuator commander — path from config.toml (canonical: daq-server/config/).
230 std::string act_csv = resolveDataPath(cfg.state_machine.actuator_csv);
231 // Pass the delays path explicitly. It was declared in config and read by nobody — the commander
232 // guessed it from the positions filename instead, so a renamed positions file silently disabled
233 // every staged delay on the stand.
234 std::string delay_csv = resolveDataPath(cfg.state_machine.actuator_delay_csv);
235 if (!actuator_commander_.load(config_content_, act_csv, delay_csv)) {
236 std::cerr << "[SequencerService] Failed to load state_machine_actuators.csv (tried: "
237 << act_csv << ")" << std::endl;
238 return false;
239 }
240
241 // Which NIC board traffic leaves from. Resolved once, here, and handed to both UDP senders:
242 // the actuator commander and the abort broadcaster. See net/DaqInterface.hpp for why the
243 // sequencer cannot simply let the kernel choose.
244 {
245 const auto nic = fsw::net::resolveDaqBindAddress(cfg, "Sequencer");
246 if (!nic.ok)
247 return false;
248 daq_bind_address_ = nic.address;
249 actuator_commander_.setDefaultBindAddress(daq_bind_address_);
250 }
251
252 // Abort destination and port from config. Previously the broadcaster was default-constructed
253 // and never told anything, so it sent to 255.255.255.255:5005 regardless of what the rig was
254 // configured for. [server_heartbeat] is the section that already names where board broadcasts
255 // go, and every shipped profile sets it.
256 if (!abort_broadcaster_.configure(cfg.server_heartbeat.broadcast_ip,
257 cfg.server_heartbeat.broadcast_port, kAbortDoneDelayMs,
259 std::cerr << "[SequencerService] [server_heartbeat].broadcast_ip is not a valid address"
260 << std::endl;
261 return false;
262 }
263
264 // ── [fire]: which state burns, and where its timer lands ─────────────────────────────────
265 // Names, not enumerators. `state_val == 16` in ControllerService and a stringified
266 // State::ARMED here were the two places a rename or renumber silently broke ignition.
267 State fire_state = State::UNKNOWN;
268 State fire_expiry = State::UNKNOWN;
269 {
270 const std::string fs = cfg.fire.state;
271 if (fs.empty()) {
272 // No fire state configured → the fire timer never arms (nothing to auto-transition out
273 // of). UNKNOWN never equals a real state in transitionTo's `to == fire_state_` check.
274 fire_state = State::UNKNOWN;
275 } else {
277 if (s == State::UNKNOWN) {
278 // Every branch assigns. This used to log "falling back to Fire" and then leave the
279 // member at its State::FIRE initializer — id 16, which a profile that renumbered
280 // its states need not even have, and which on another might be a completely
281 // different state that would then arm the burn timer. On a reload it additionally
282 // kept whatever the PREVIOUS config had resolved to. Disabling is the safe answer.
283 std::cerr << "[SequencerService] [fire] state \"" << fs
284 << "\" is not a known state — the fire timer is DISABLED" << std::endl;
285 } else {
286 fire_state = s;
287 }
288 }
289 const std::string ft = cfg.fire.expiry_target;
290 if (!ft.empty()) {
291 const State s = StateMachine::fromName(ft);
292 if (s == State::UNKNOWN)
293 // Same rule as the fire state: an undeclared name disables auto-expiry (leaves
294 // UNKNOWN, so the isAllowed check below warns) rather than falling back to a
295 // compiled Armed, which names a different state on a renumbered rig.
296 std::cerr << "[SequencerService] [fire] expiry_target \"" << ft
297 << "\" is not a declared state — fire auto-expiry disabled" << std::endl;
298 else
299 fire_expiry = s;
300 }
301 }
302 fire_state_ = fire_state;
303 fire_expiry_state_ = fire_expiry;
304 fire_duration_ms_ = cfg.fire.duration_ms;
305 fire_extended_ms_ = cfg.fire.extended_ms;
306 actuator_commander_.setFireState(fire_state);
307
308 if (fire_state == State::UNKNOWN) {
309 std::cout << "[SequencerService] Fire state: (none) — fire timer disabled" << std::endl;
310 } else {
311 std::cout << "[SequencerService] Fire state: " << StateMachine::name(fire_state)
312 << " → expires to " << StateMachine::name(fire_expiry) << " after "
313 << cfg.fire.duration_ms << " ms (extended " << cfg.fire.extended_ms << " ms)"
314 << std::endl;
315 // The expiry transition goes through the same isAllowed() gate as any other, so a
316 // target the fire state cannot reach leaves the system sitting in FIRE with a dead
317 // timer. Say so at startup rather than at T-0.
318 if (!state_machine_.isAllowed(fire_state, fire_expiry))
319 std::cerr << "[SequencerService] WARNING: " << StateMachine::name(fire_state) << " → "
320 << StateMachine::name(fire_expiry)
321 << " is not an allowed transition — the fire timer will expire into a "
322 "refused transition and the system will stay in fire."
323 << std::endl;
324 }
325
326 // ── Hold rules ───────────────────────────────────────────────────────────────────────────
327 // Exactly two things create entries, and both write `gui_settable` as a literal. It is never
328 // read from config, so no config edit — deliberate or accidental — can make a burn window
329 // settable by a client.
330 std::map<State, HoldRule> rules;
331
332 if (fire_state != State::UNKNOWN) {
333 HoldRule r;
334 r.default_ms = cfg.fire.duration_ms;
335 r.max_ms = cfg.fire.duration_ms;
336 r.extended_ms = cfg.fire.extended_ms;
337 r.return_state = fire_expiry;
338 r.gui_settable = false; // a burn length comes from config, reviewed and in git. Always.
339 r.gate_actuator = cfg.fire.gate_actuator;
340 if (!r.gate_actuator.empty()) {
341 const double din =
342 actuator_commander_.delayForRole(StateMachine::name(fire_state), r.gate_actuator);
343 const double dout =
344 actuator_commander_.delayForRole(StateMachine::name(fire_expiry), r.gate_actuator);
345 r.gate_open_delay_ms = static_cast<uint32_t>(din * 1000.0 + 0.5);
346 r.gate_close_delay_ms = static_cast<uint32_t>(dout * 1000.0 + 0.5);
347 std::cout << "[SequencerService] Fire gate: \"" << r.gate_actuator << "\" opens +"
348 << r.gate_open_delay_ms << " ms, closes +" << r.gate_close_delay_ms
349 << " ms; burn window = " << r.default_ms << " ms of THAT valve being open"
350 << std::endl;
351 } else {
352 // Catch the trap rather than let it burn short: a staggered fire column with the window
353 // measured from the transition burns for (duration - stagger), and equal values do not
354 // burn at all.
355 for (const auto& [role, pos] :
356 actuator_commander_.positionsForState(StateMachine::name(fire_state))) {
357 if (pos != 1)
358 continue;
359 const double d =
360 actuator_commander_.delayForRole(StateMachine::name(fire_state), role);
361 if (d > 0.0)
362 std::cerr << "[SequencerService] WARNING: fire opens \"" << role << "\" " << d
363 << " s after entry, but no [fire].gate_actuator is set — the "
364 "burn window is measured from the TRANSITION, so that valve is "
365 "open for "
366 << (static_cast<double>(r.default_ms) / 1000.0 - d) << " s, not "
367 << (static_cast<double>(r.default_ms) / 1000.0) << " s." << std::endl;
368 }
369 }
370 rules[fire_state] = r;
371 }
372
373 // ── [flow]: the characterization hold ────────────────────────────────────────────────────
374 // WHICH state this is comes from the is_flow flag on a [[states]] entry, never from a name in
375 // [flow]. That is the one thing [fire] gets wrong: `[fire] state = "Fire"` is resolved by
376 // string, so renaming the state orphans it. Here the operator can rename it, delete it, or
377 // move the flag to a different state and everything follows.
378 State flow_state = State::UNKNOWN;
379 for (const auto& sd : cfg.states) {
380 if (!sd.is_flow || sd.id < 0 || sd.id > 255 || sd.name.empty())
381 continue;
382 const State s = static_cast<State>(static_cast<uint8_t>(sd.id));
383 if (flow_state == State::UNKNOWN) {
384 flow_state = s; // first wins, as with is_boot in loadStatesFromConfig
385 } else {
386 std::cerr << "[SequencerService] More than one state carries is_flow — ignoring \""
387 << sd.name << "\", using \"" << StateMachine::name(flow_state) << "\""
388 << std::endl;
389 }
390 }
391
392 if (flow_state != State::UNKNOWN) {
393 // Every rejection below leaves the state NOT holdable, which makes the transition refuse
394 // up front so the valve never opens. That is deliberate and is stricter than the fire path
395 // above, which only warns: this hold exists solely to close a valve on a timer, so a hold
396 // that cannot close its valve has no safe degraded mode.
397 const std::string& rt = cfg.flow.return_target;
398 const State ret = rt.empty() ? State::UNKNOWN : StateMachine::fromName(rt);
399
400 if (flow_state == fire_state) {
401 std::cerr << "[SequencerService] ERROR: is_flow is set on the fire state — REFUSED. "
402 "A burn window must not become client-settable."
403 << std::endl;
404 } else if (cfg.flow.duration_ms == 0) {
405 std::cerr << "[SequencerService] ERROR: \"" << StateMachine::name(flow_state)
406 << "\" is marked is_flow but [flow].duration_ms is unset — NOT holdable."
407 << std::endl;
408 } else if (ret == flow_state) {
409 // The diagonal is 1 in most transition matrices, so this is reachable by a plausible
410 // config edit — and it would be a hold that expires into itself, re-arming forever with
411 // the valve held open. There is no "close" in that loop at all.
412 std::cerr
413 << "[SequencerService] ERROR: [flow].return_target is the flow state itself — "
414 "the hold would re-arm forever with its valves open. NOT holdable."
415 << std::endl;
416 } else if (ret == State::UNKNOWN) {
417 std::cerr << "[SequencerService] ERROR: [flow].return_target \"" << rt
418 << "\" is not a known state — \"" << StateMachine::name(flow_state)
419 << "\" is NOT holdable. Its valves cannot be stranded open because the "
420 "transition into it is now refused."
421 << std::endl;
422 } else if (!state_machine_.isAllowed(flow_state, ret)) {
423 std::cerr << "[SequencerService] ERROR: " << StateMachine::name(flow_state) << " → "
424 << StateMachine::name(ret)
425 << " is not an allowed transition — the hold would expire into a refused "
426 "transition and leave its valves open. NOT holdable."
427 << std::endl;
428 } else {
429 HoldRule r;
430 r.default_ms = cfg.flow.duration_ms;
431 r.max_ms = cfg.flow.max_ms ? cfg.flow.max_ms : cfg.flow.duration_ms;
432 r.extended_ms = 0; // a measured pulse is a fixed window; extend() must not stretch it
433 r.return_state = ret;
434 r.gui_settable = true;
435 r.gate_actuator = cfg.flow.gate_actuator;
436 if (!r.gate_actuator.empty()) {
437 // The delay this valve already waits, from the delays CSV. Read rather than
438 // configured twice: the stagger is declared in one place and the hold follows it,
439 // so editing the CSV cannot silently desynchronise the window from the valve.
440 const double din = actuator_commander_.delayForRole(StateMachine::name(flow_state),
441 r.gate_actuator);
442 const double dout =
443 actuator_commander_.delayForRole(StateMachine::name(ret), r.gate_actuator);
444 r.gate_open_delay_ms = static_cast<uint32_t>(din * 1000.0 + 0.5);
445 r.gate_close_delay_ms = static_cast<uint32_t>(dout * 1000.0 + 0.5);
446 }
447 rules[flow_state] = r;
448 std::cout << "[SequencerService] Flow hold: " << StateMachine::name(flow_state) << " "
449 << r.default_ms << " ms (max " << r.max_ms << ") → "
450 << StateMachine::name(ret) << std::endl;
451 if (!r.gate_actuator.empty()) {
452 std::cout << "[SequencerService] Flow gate: \"" << r.gate_actuator << "\" opens +"
453 << r.gate_open_delay_ms << " ms after entry, closes +"
454 << r.gate_close_delay_ms << " ms after expiry; hold = requested + "
455 << r.gate_open_delay_ms << " - " << r.gate_close_delay_ms
456 << " ms, so the requested duration is how long IT is open" << std::endl;
457 if (r.gate_close_delay_ms >= r.gate_open_delay_ms + r.default_ms)
458 std::cerr << "[SequencerService] WARNING: the gate closes later than the hold "
459 "would end — the window cannot be honoured and will be clamped."
460 << std::endl;
461 }
462
463 // A warning, not a refusal: the transition is legal, so the machine does leave the
464 // hold. What this catches is a return column that leaves one of the held valves
465 // energised — the hold closes its valves only because the return state's CSV says
466 // CLOSE, and nothing else is going to.
467 const auto opens =
468 actuator_commander_.positionsForState(StateMachine::name(flow_state));
469 const auto closes = actuator_commander_.positionsForState(StateMachine::name(ret));
470 for (const auto& [role, pos] : opens) {
471 if (pos != 1)
472 continue;
473 const auto it = closes.find(role);
474 if (it == closes.end() || it->second != 0)
475 std::cerr << "[SequencerService] WARNING: " << StateMachine::name(flow_state)
476 << " opens \"" << role << "\" but " << StateMachine::name(ret)
477 << " does not close it — the hold will end with that valve still open"
478 << std::endl;
479 }
480 }
481 }
482
483 // ── Dynamic states: scripts ───────────────────────────────────────────────────────────────
484 //
485 // Resolved last, because every refusal branch needs something the steps above established:
486 // [[states]] adopted (so a name resolves), the transitions CSV loaded (so a transition_to
487 // target can be checked), the actuator CSV loaded (so a state's column can be confirmed), and
488 // [fire] resolved (so a script cannot be attached to the burn).
489 //
490 // A refused state goes into refused_states_ and doTransitionTo rejects it up front, in the
491 // same validation block that rejects a disallowed transition — before the abort broadcast,
492 // before overrides are cleared, before a single valve is commanded. That is the whole point:
493 // a script that does not hold up produces an unavailable button, never an open valve.
494 {
496 sctx.config_dir = configDir();
498 sctx.fire_state = fire_state;
499 sctx.has_actuator_column = [this](const std::string& name) {
500 return !actuator_commander_.positionsForState(name).empty();
501 };
503
504 for (const auto& [st, why] : loaded.refused)
505 std::cerr << "[SequencerService] ERROR: dynamic state REFUSED — " << why
506 << ". It is not enterable." << std::endl;
507
508 for (auto& [st, ds] : loaded.dynamic) {
509 // The timeout reuses HoldTimer through hold_rules_ rather than a second timer object.
510 // Three properties fall out free: gui_settable=false refuses a client-supplied
511 // duration on this state, extended_ms=0 means EXTEND_FIRE cannot stretch it, and
512 // re-entering the state restarts the window rather than stacking a second one,
513 // because HoldTimer::start() stops first.
514 HoldRule r;
515 r.default_ms = ds.timeout_ms;
516 r.max_ms = ds.timeout_ms;
517 r.extended_ms = 0;
518 r.return_state = ds.timeout_target;
519 r.gui_settable = false;
520 rules[st] = r;
521
522 std::cout << "[SequencerService] Dynamic state: " << ds.name << " — "
523 << ds.program.stmts.size() << " statement(s), timeout " << ds.timeout_ms
524 << " ms -> " << StateMachine::name(ds.timeout_target) << ", ends -> "
525 << StateMachine::name(ds.return_target);
526 if (!ds.pressure_roles.empty()) {
527 std::cout << ", reads";
528 for (const auto& role : ds.pressure_roles)
529 std::cout << " \"" << role << "\"";
530 }
531 std::cout << std::endl;
532 }
533
534 // Subscribe to exactly the roles the loaded scripts read, and nothing else. Elodin has no
535 // wildcard, and deriving the list from the ASTs is what makes it structurally impossible
536 // to subscribe to the wrong table for a role — there is no id range to filter, the way
537 // ControllerService filters board 2 out of existence.
538 std::set<std::string> roles;
539 for (const auto& [st, ds] : loaded.dynamic)
540 roles.insert(ds.pressure_roles.begin(), ds.pressure_roles.end());
541 if (!roles.empty()) {
542 pressure_feed_.start(cfg, {roles.begin(), roles.end()}, elodin_host_,
543 cfg.database.port);
544 }
545
546 std::lock_guard<std::mutex> lk(config_mutex_);
547 dynamic_states_ = std::move(loaded.dynamic);
548 refused_states_ = std::move(loaded.refused);
549 }
550
551 {
552 std::lock_guard<std::mutex> lk(config_mutex_);
553 hold_rules_ = std::move(rules);
554 }
555
556 // Controller service endpoint for FIRE_START / FIRE_STOP.
557 // Read from config; defaults to 127.0.0.1:8000
558 controller_host_ = cfg.controller_service.host;
559 controller_port_ = cfg.controller_service.port;
560 sequencer_owns_valves_ = cfg.controller_service.sequencer_owns_valves;
561 std::cout << "[SequencerService] Fire valve ownership: "
563 ? "sequencer (controller_service is never told a burn started)"
564 : "controller_service (FIRE_START / FIRE_STOP sent)")
565 << std::endl;
566 return true;
567}
568
569// ─────────────────────────────────────────────────────────────────────────────
570bool SequencerService::init(const std::string& config_path) {
571 loadConfig(config_path);
573
574 if (!applyConfig(cfg))
575 return false;
576
577 // Snapshot the actuator board list now, while we are reading config for the first and only
578 // time. tryConnectElodin() re-registers VTables on every reconnect and must not go back to
579 // disk for this (see the comment there).
580 {
581 const auto boards_map = fsw::config::load_active_boards(config_path_);
582 const auto it_act = boards_map.find(fsw::config::ActiveBoardKind::ACTUATOR);
583 actuator_boards_ = (it_act != boards_map.end()) ? it_act->second
584 : std::vector<fsw::elodin::BoardChannels>{};
585 const auto it_bv = boards_map.find(fsw::config::ActiveBoardKind::BALL_VALVE);
587 (it_bv != boards_map.end()) ? it_bv->second : std::vector<fsw::elodin::BoardChannels>{};
588 }
589
590 // Elodin — connection is best-effort; service runs without it
591 elodin_host_ = "127.0.0.1";
592 elodin_port_ = cfg.database.port;
593 if (!tryConnectElodin()) {
594 std::cerr << "[SequencerService] Cannot connect to Elodin yet — retrying every "
595 << kElodinRetrySeconds << "s in the background" << std::endl;
596 }
597 // The connect above used to be one-shot. Losing the startup race with elodin-db (systemd
598 // starts the units together) meant the ACT_CMD VTables were never registered and
599 // publishCommandedState() early-returned forever, so every valve read "undefined" in the GUI
600 // while UDP commands still went out — data flowing, dots grey. Retry until it takes.
602
604 // Publish initial state so any already-connected backend/GUI knows we started at IDLE.
605 publishState();
606 // Command IDLE actuators and keep resending so manual debug clicks cannot stick vs CSV.
607 // is_transition=true because this IS the entry into the boot state; the loop that follows is
608 // only the republisher. See the note in transitionTo — the two must not both claim the entry.
609 actuator_commander_.applyForState(current_state_.load(), /*is_transition=*/true);
610 actuator_commander_.startContinuousLoop(current_state_.load(), /*allow_delays=*/false);
611
612 // Start the command worker last. Everything above runs on the caller's thread before any
613 // command can be accepted, so init() needs no serialization of its own — and starting the
614 // worker earlier would let a command run against a half-initialized service.
615 cmd_stop_ = false;
616 cmd_thread_ = std::thread([this]() {
617 commandLoop();
618 });
619
620 std::cout << "[SequencerService] Initialized. Current state: "
621 << StateMachine::name(current_state_.load()) << std::endl;
622 return true;
623}
624
625// ─────────────────────────────────────────────────────────────────────────────
627 // Config-declared (`is_abort`), falling back to the built-in trio when a config omits them.
628 // This used to be a hardcoded three-way enum comparison, which meant a stand that renamed or
629 // added an abort state got no physical abort broadcast for it.
630 return StateMachine::isAbort(s);
631}
632
633// ─────────────────────────────────────────────────────────────────────────────
634bool SequencerService::transitionTo(const std::string& state_name) {
635 return transitionTo(state_name, 0);
636}
637
638bool SequencerService::transitionTo(const std::string& state_name, uint32_t requested_hold_ms,
639 std::string* refusal_reason) {
640 // Resolve on the worker, not here. fromName() reads the config-declared state table, which
641 // the worker owns — resolving on the caller's thread would read it from an arbitrary thread.
642 // enqueueAndWait blocks until the lambda has run, so refusal_reason stays alive throughout.
643 return enqueueAndWait([this, state_name, requested_hold_ms, refusal_reason]() {
644 State to = StateMachine::fromName(state_name);
645 if (to == State::UNKNOWN) {
646 std::cerr << "[SequencerService] Unknown state: " << state_name << std::endl;
647 if (refusal_reason)
648 *refusal_reason = "unknown state";
649 return false;
650 }
651 return doTransitionTo(to, requested_hold_ms, refusal_reason);
652 });
653}
654
656 return transitionTo(to, 0);
657}
658
659bool SequencerService::transitionTo(State to, uint32_t requested_hold_ms,
660 std::string* refusal_reason) {
661 return enqueueAndWait([this, to, requested_hold_ms, refusal_reason]() {
662 return doTransitionTo(to, requested_hold_ms, refusal_reason);
663 });
664}
665
666bool SequencerService::doTransitionTo(State to, uint32_t requested_hold_ms,
667 std::string* refusal_reason) {
668 State from = current_state_.load();
669
670 HoldRule rule;
671 bool has_rule = false;
672 {
673 std::lock_guard<std::mutex> lk(config_mutex_);
674 const auto it = hold_rules_.find(to);
675 if (it != hold_rules_.end()) {
676 rule = it->second;
677 has_rule = true;
678 }
679 }
680
681 // Validate a client-supplied hold BEFORE anything moves — before the overrides are cleared,
682 // before the resend loop stops, before a single actuator is commanded. A refusal has to leave
683 // the rig exactly where it was: the WebSocket client queues commands while its socket is down
684 // and replays them on reconnect, so a stale "burn for 60 s" must be a no-op, not a transition
685 // that then runs for some other length.
686 if (requested_hold_ms != 0) {
687 const char* why = nullptr;
688 if (!has_rule)
689 why = "state is not holdable";
690 else if (!rule.gui_settable)
691 why = "duration not permitted for this state";
692 else if (requested_hold_ms > rule.max_ms)
693 why = "duration exceeds max";
694
695 if (why) {
696 std::cerr << "[SequencerService] Refused " << requested_hold_ms << " ms hold on "
697 << StateMachine::name(to) << ": " << why << std::endl;
698 if (refusal_reason) {
699 *refusal_reason = why;
700 if (std::string(why) == "duration exceeds max")
701 *refusal_reason += " (" + std::to_string(rule.max_ms) + " ms)";
702 }
703 // Republish so a client that moved its own display in anticipation is corrected —
704 // the same courtesy the ordinary refusal path below extends.
705 publishState();
706 return false;
707 }
708 }
709
710 // A state whose script did not load is not enterable — checked here, beside the other
711 // validations, so a refusal leaves the rig exactly where it was: before the abort broadcast,
712 // before overrides are cleared, before the resend loop stops, before a single valve moves.
713 //
714 // Deliberately NOT gated on debug_mode_. Debug mode relaxes which transitions are allowed; it
715 // does not conjure a script that failed to parse. There is nothing for the sequencer to run.
716 {
717 std::lock_guard<std::mutex> lk(config_mutex_);
718 const auto it = refused_states_.find(to);
719 if (it != refused_states_.end()) {
720 std::cerr << "[SequencerService] Refused entry to " << StateMachine::name(to) << ": "
721 << it->second << std::endl;
722 // The stored reason, not a generic one. The caller's copy is what reaches the
723 // operator's notification, and "state script rejected at load" told them nothing
724 // they could act on while the specific fault sat here unused.
725 if (refusal_reason)
726 *refusal_reason = it->second;
727 publishState();
728 return false;
729 }
730 }
731
732 // A script that reads a sensor is checked for a fresh, calibrated reading BEFORE the
733 // transition commits, not when the script first evaluates pressure(). The operator is then
734 // told at the moment they press the button — naming the sensor — instead of twenty-five
735 // seconds into a press that was never going to work. The interpreter's own check stays as the
736 // backstop for a feed that dies while a script is already running.
737 //
738 // This is the house "refuse up front so the valve never opens" pattern, applied to sensors.
739 {
740 std::unique_lock<std::mutex> lk(config_mutex_);
741 const auto it = dynamic_states_.find(to);
742 if (it != dynamic_states_.end()) {
743 const std::vector<std::string> roles = it->second.pressure_roles;
744 const std::string state_name = it->second.name;
745 lk.unlock();
746 for (const auto& role : roles) {
747 double psi = 0.0;
748 const auto status = pressure_feed_.read(role, psi);
749 if (status == PressureFeed::Status::Ok)
750 continue;
751 std::cerr << "[SequencerService] Refused entry to " << state_name << ": "
752 << PressureFeed::explain(role, status) << std::endl;
753 // The same sentence the log gets. This used to flatten all four outcomes —
754 // NoReading, Stale, Uncalibrated, NotSubscribed — into one string, so the
755 // operator could not tell a sensor that never arrived from one that went stale
756 // or was never calibrated, which are three different things to go and fix.
757 if (refusal_reason)
758 *refusal_reason = PressureFeed::explain(role, status);
759 publishState();
760 return false;
761 }
762 }
763 }
764
765 if (!debug_mode_) {
766 if (!state_machine_.isAllowed(from, to)) {
767 if (refusal_reason)
768 *refusal_reason = "transition rejected";
769 std::cerr << "[SequencerService] Transition " << StateMachine::name(from) << " → "
770 << StateMachine::name(to) << " is not allowed" << std::endl;
771 // Republish the state we are actually in. A refusal used to publish nothing, so a
772 // client that had moved its own display in anticipation was never corrected and went
773 // on showing a state the rig had refused to enter — the failure was invisible, which
774 // is worse than the failure. Publishing on the way out also resyncs any client that
775 // drifted for some other reason.
776 publishState();
777 return false;
778 }
779 }
780
781 // Physical abort broadcast FIRST — before any of the bookkeeping below.
782 //
783 // This used to sit after stopContinuousLoop(), fire_manager_.stop() and applyForState(), which
784 // meant the boards' abort packet queued behind: a join of the republish thread (up to 100 ms),
785 // a TCP round-trip to controller_service (previously unbounded — see notifyControllerFire),
786 // and a full actuator batch. Every one of those is the sequencer's own housekeeping, and none
787 // of it is a precondition for telling the boards to abort. The boards' independent abort logic
788 // is the last line of defence on this rig; it must not wait on the process that is, by
789 // definition, in the middle of something going wrong.
790 //
791 // triggerAbort() sends immediately and schedules ABORT_DONE on its own thread, so this does
792 // not block the transition either.
793 const bool entering_abort = isAbortState(to);
794 if (entering_abort)
795 abort_broadcaster_.triggerAbort();
796
797 // Silence the strongest writer first.
798 //
799 // A script is a source of valve commands running on its own thread. If it were still alive
800 // when applyForState() below commands the new state's column, its next open_valve would land
801 // AFTER those positions and strand a valve open in a state whose CSV says CLOSE. Joining here
802 // makes "no script statement can execute past this point" a structural fact rather than a
803 // timing hope.
804 //
805 // After the abort broadcast, though — stop() joins a thread, and the boards' abort packet must
806 // not queue behind the sequencer's own housekeeping (test_abort_ordering asserts < 200 ms).
807 // And after the two validation gates above, which return early: a REFUSED transition must not
808 // kill a script that is running perfectly well.
809 script_runner_.stop();
810
811 // New state wins over debug manual actuator overrides, and over the positions a script had
812 // taken ownership of. Both are cleared here rather than when the script stops, so a script
813 // that ended on its own still has its valves handed back to the incoming state's column.
814 actuator_commander_.clearAllManualOverrides();
815 actuator_commander_.clearScriptPositions();
816
817 // Stop current continuous loop before applying the new state
818 actuator_commander_.stopContinuousLoop();
819
820 // A hold belongs to the state it was armed in, so any move ends it. Unconditional because
821 // stop() is a no-op when nothing is running, and because start() below stops first anyway —
822 // which is what makes re-entering a held state restart its window rather than stack a second.
823 hold_timer_.stop();
824
825 // Apply actuator commands for new state.
826 //
827 // is_transition=true is what makes this run the delays CSV as a staged schedule instead of
828 // dumping every actuator at t=0. It used to be omitted, and the parameter defaults to false —
829 // "this is the 1 Hz republish, send settled positions". So this call shipped the whole state
830 // at once, and the staged schedule that startContinuousLoop kicked off below then re-commanded
831 // valves that were already in position. Every configured delay was inert: parsed, logged as
832 // "Sent 1 commands (+1s)", and completely without effect on any state on any profile, the
833 // 80 ms fuel lead in default/server's Fire column included. The default argument is why it was
834 // invisible — omitting it compiled clean and read exactly like the pre-delay call it once was.
835 actuator_commander_.applyForState(to, /*is_transition=*/!isAbortState(to));
836
837 // Arm the hold HERE, immediately after the valves are commanded, rather than at the end of the
838 // transition: the window the operator asked for is the one between the open and the close, and
839 // everything below (the resend thread spawn, the Elodin publishes) would otherwise be counted
840 // inside it.
841 if (has_rule && rule.return_state != State::UNKNOWN) {
842 // The requested duration is what the GATE valve should be open for, so the state is held
843 // for the gate's stagger plus that. Without this, a 1 s request on a state whose main opens
844 // 1 s late would close everything exactly as the main opened — zero flow.
845 const uint32_t requested = requested_hold_ms != 0 ? requested_hold_ms : rule.default_ms;
846 // Both staggers. The entry delay pushes the valve's opening later, so the state must be
847 // held longer; the return state's delay keeps it open past the expiry transition, so the
848 // state must be held that much less. Net: hold = requested + open - close.
849 const int64_t want = static_cast<int64_t>(requested) +
850 static_cast<int64_t>(rule.gate_open_delay_ms) -
851 static_cast<int64_t>(rule.gate_close_delay_ms);
852 // Clamp rather than wrap: unsigned underflow here would become a ~49-day hold, valves open.
853 const uint32_t hold_ms = want > 0 ? static_cast<uint32_t>(want) : 1;
854 if (want <= 0)
855 std::cerr << "[SequencerService] Gate close delay (" << rule.gate_close_delay_ms
856 << " ms) swallows the requested " << requested
857 << " ms window — holding 1 ms. Fix the delays CSV." << std::endl;
858 const State return_state = rule.return_state;
859 // The controller is told a burn started only when it owns the valves. When the sequencer
860 // owns them there is no notifier at all rather than a suppressed one — nothing to forget
861 // to check — and a flow hold never has one regardless.
862 const bool notify_controller = (to == fire_state_) && !sequencer_owns_valves_;
863 hold_timer_.start(HoldSpec{hold_ms, rule.extended_ms,
864 [this, return_state]() {
865 // Timer thread. Resolve to a State rather than a name: the
866 // old code round-tripped through
867 // StateMachine::name(State::ARMED) → fromName(), so renaming
868 // the state made fromName() return UNKNOWN and the
869 // transition was refused — stranding the system in fire with
870 // the timer already stopped.
871 //
872 // Detached, never enqueueAndWait: this runs on the hold
873 // timer's own thread, and the worker handling the expiry
874 // transition calls hold_timer_.stop(), which joins this very
875 // thread. Waiting here would be a guaranteed deadlock — the
876 // worker waiting on the join, this thread waiting on the
877 // worker — and it would strand the rig in the held state
878 // with the gate valve open.
879 enqueueDetached([this, return_state]() {
880 return doTransitionTo(return_state);
881 });
882 },
883 notify_controller
884 ? std::function<void(bool)>([this](bool active) {
886 })
887 : nullptr,
888 StateMachine::name(to)});
889 }
890
891 // Start continuous re-send loop for new state.
892 // allow_delays=false: the entry above already ran the schedule, so this loop is purely the
893 // 1 Hz republish. Letting it claim the entry as well would bump the schedule generation and
894 // cancel the stages the line above just armed. It still skips a role whose delay is pending,
895 // so the board holds its pre-transition position until that role's stage fires.
896 // (Abort states got their immediate apply above via isAbortState; an abort must not sit behind
897 // a timer. The physical UDP abort broadcast already went out at the top of this function.)
898 // defer_first_pass for a dynamic state: its script begins commanding valves a moment from now,
899 // and an immediate (redundant) republish pass would race it — losing, because it resolves
900 // positions and only then spends milliseconds on retransmits. The column was already applied
901 // above, so deferring costs nothing and closes the window.
902 bool to_is_dynamic = false;
903 {
904 std::lock_guard<std::mutex> lk(config_mutex_);
905 to_is_dynamic = dynamic_states_.count(to) > 0;
906 }
907 actuator_commander_.startContinuousLoop(to, /*allow_delays=*/false,
908 /*defer_first_pass=*/to_is_dynamic);
909
910 // Update current state
911 current_state_ = to;
912
913 // Abort lifecycle. The burn/flow lifecycle is not here: both are hold rules now, armed by the
914 // hold_timer_.start() above, so the fire window no longer needs a branch of its own.
915 if (isAbortState(to)) {
916 abort_broadcaster_.triggerAbort();
917 }
918
919 // The script starts AFTER current_state_ = to, and that ordering is load-bearing. A script
920 // whose first statement is transition_to(X) posts that move immediately; doTransitionTo then
921 // validates it with isAllowed(from, X), and if current_state_ were still the PREVIOUS state,
922 // it would be checking a transition from somewhere the rig is no longer. Starting here also
923 // makes elapsed() measure from actual state entry rather than from part-way through one.
924 {
925 std::lock_guard<std::mutex> lk(config_mutex_);
926 const auto it = dynamic_states_.find(to);
927 if (it != dynamic_states_.end())
928 script_runner_.start(it->second, makeScriptEnv(it->second));
929 }
930
931 // Elodin publishing
932 publishStateTransition(from, to);
933 publishState();
934
935 std::cout << "[SequencerService] " << StateMachine::name(from) << " → "
936 << StateMachine::name(to) << std::endl;
937 return true;
938}
939
940// ─────────────────────────────────────────────────────────────────────────────
942 return enqueueAndWait([this, enabled]() {
943 return doSetDebugMode(enabled);
944 });
945}
946
948 debug_mode_ = enabled;
949 if (!enabled)
950 actuator_commander_.clearAllManualOverrides();
951 std::cout << "[SequencerService] Debug mode: " << (enabled ? "ON" : "OFF") << std::endl;
952 publishState(); // push updated debug_mode flag to GUI
953 return true;
954}
955
956// ─────────────────────────────────────────────────────────────────────────────
957bool SequencerService::manualActuator(const std::string& name, int pos,
958 std::string* refusal_reason) {
959 return enqueueAndWait([this, name, pos, refusal_reason]() {
960 return doManualActuator(name, pos, refusal_reason);
961 });
962}
963
964bool SequencerService::doManualActuator(const std::string& name, int pos,
965 std::string* refusal_reason) {
966 if (!debug_mode_) {
967 std::cerr << "[SequencerService] Manual actuator commands require debug mode" << std::endl;
968 if (refusal_reason)
969 *refusal_reason = "debug mode required";
970 return false;
971 }
972
973 // Deliberately NOT blocked in a scripted state. A hand command there fights the script for a
974 // valve, and the operator wins (see ActuatorCommander's precedence) — which is the right
975 // answer for the person standing at the panel, even though the script has no way to notice.
976 actuator_commander_.setManualOverride(name, pos);
977 return actuator_commander_.sendSingleActuator(name, pos);
978}
979
980// ─────────────────────────────────────────────────────────────────────────────
982 return enqueueAndWait([this]() {
983 return doExtendFire();
984 });
985}
986
989 std::cerr << "[SequencerService] EXTEND_FIRE ignored: not in FIRE state" << std::endl;
990 return false;
991 }
992 return hold_timer_.extend();
993}
994
995// ─────────────────────────────────────────────────────────────────────────────
996// Elodin publishing
997// ─────────────────────────────────────────────────────────────────────────────
998/**
999 * Tell controller_service the burn gate changed. One TCP connection per message, 1 s send timeout.
1000 *
1001 * This lives here rather than in the hold timer so exactly one component talks to the
1002 * controller — previously the timer opened its own socket AND the backend independently saw the
1003 * FIRE edge and sent the same messages, so a safety-critical gate had two writers in two processes.
1004 */
1007 return;
1008 // One retry, driven by evidence rather than blind repetition: we know the first attempt was
1009 // not acted on, because the controller did not answer. Repeating a command that *was* acted
1010 // on is harmless here — both commands are idempotent level settings, not edges.
1011 std::cerr << "[SequencerService] retrying " << (active ? "FIRE_START" : "FIRE_STOP")
1012 << " to controller_service" << std::endl;
1014 return;
1015 std::cerr << "[SequencerService] ❌ controller_service did not acknowledge "
1016 << (active ? "FIRE_START" : "FIRE_STOP") << " at " << controller_host_ << ":"
1018 << " after 2 attempts — the PWM gate is NOT under sequencer control. The controller "
1019 "falls back to the Elodin sequencer-state parity path; verify the burn ended."
1020 << std::endl;
1021}
1022
1024 const std::string msg = active ? "FIRE_START\n" : "FIRE_STOP\n";
1025 int sock = socket(AF_INET, SOCK_STREAM, 0);
1026 if (sock < 0)
1027 return false;
1028 struct timeval tv{.tv_sec = 1, .tv_usec = 0};
1029 setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
1030 struct sockaddr_in dest{};
1031 dest.sin_family = AF_INET;
1032 dest.sin_port = htons(controller_port_);
1033 if (inet_pton(AF_INET, controller_host_.c_str(), &dest.sin_addr) != 1) {
1034 close(sock);
1035 return false;
1036 }
1037
1038 // SO_SNDTIMEO above does NOT bound connection establishment. On a blocking socket, connect()
1039 // to a host that drops SYNs rather than refusing them (powered off but still routable, or a
1040 // network partition) sits in the kernel's SYN retry budget for ~2 minutes. This runs on
1041 // whichever thread is performing the state transition — including an operator aborting out of
1042 // FIRE — so an unreachable controller used to stall the abort itself. Bound it explicitly:
1043 // non-blocking connect, wait with a deadline, then restore blocking mode for the send.
1044 const int flags = fcntl(sock, F_GETFL, 0);
1045 if (flags < 0 || fcntl(sock, F_SETFL, flags | O_NONBLOCK) < 0) {
1046 close(sock);
1047 return false;
1048 }
1049
1050 bool connected = false;
1051 if (connect(sock, reinterpret_cast<struct sockaddr*>(&dest), sizeof(dest)) == 0) {
1052 connected = true; // immediate (loopback)
1053 } else if (errno == EINPROGRESS) {
1054 struct pollfd pfd{.fd = sock, .events = POLLOUT, .revents = 0};
1055 // Any readiness at all means the attempt resolved — consult SO_ERROR rather than the
1056 // revents bits. A refused connection can surface as POLLERR/POLLHUP without POLLOUT, and
1057 // testing for POLLOUT alone would report that as a timeout and lose the real reason in
1058 // the log. poll() returning 0 is the only genuine timeout.
1059 if (poll(&pfd, 1, kControllerConnectTimeoutMs) > 0) {
1060 int err = 0;
1061 socklen_t len = sizeof(err);
1062 if (getsockopt(sock, SOL_SOCKET, SO_ERROR, &err, &len) != 0)
1063 err = errno;
1064 if (err == 0)
1065 connected = true;
1066 else
1067 errno = err;
1068 } else {
1069 errno = ETIMEDOUT;
1070 }
1071 }
1072
1073 bool acked = false;
1074 if (connected && fcntl(sock, F_SETFL, flags) == 0) {
1075 ssize_t n = send(sock, msg.c_str(), msg.size(), 0);
1076 if (n != static_cast<ssize_t>(msg.size())) {
1077 std::cerr << "[SequencerService] short send to controller_service (" << n << "/"
1078 << msg.size() << "): " << strerror(errno) << std::endl;
1079 } else {
1080 // Read the ACK. A successful send() proves nothing: the kernel completes the
1081 // handshake and buffers the bytes whether or not the controller's accept loop is
1082 // alive to read them, so a wedged controller used to look identical to a healthy one
1083 // from here — this function logged the command as delivered either way. The
1084 // controller answers "OK\n" (controller_main.cpp), and that reply is the only
1085 // evidence the command actually reached the application.
1086 struct timeval rtv{.tv_sec = 0, .tv_usec = kControllerConnectTimeoutMs * 1000};
1087 setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &rtv, sizeof(rtv));
1088 char reply[16] = {0};
1089 ssize_t r = recv(sock, reply, sizeof(reply) - 1, 0);
1090 if (r > 0 && std::string(reply, static_cast<size_t>(r)).rfind("OK", 0) == 0) {
1091 acked = true;
1092 } else if (r > 0) {
1093 std::cerr << "[SequencerService] controller_service refused "
1094 << (active ? "FIRE_START" : "FIRE_STOP") << ": "
1095 << std::string(reply, static_cast<size_t>(r)) << std::endl;
1096 } else {
1097 std::cerr << "[SequencerService] no ACK from controller_service for "
1098 << (active ? "FIRE_START" : "FIRE_STOP") << " ("
1099 << (r == 0 ? "connection closed" : strerror(errno))
1100 << ") — the command may not have been acted on" << std::endl;
1101 }
1102 }
1103 } else {
1104 std::cerr << "[SequencerService] could not reach controller_service at " << controller_host_
1105 << ":" << controller_port_ << " for " << (active ? "FIRE_START" : "FIRE_STOP")
1106 << " (" << strerror(errno) << ")" << std::endl;
1107 }
1108 close(sock);
1109
1110 if (acked)
1111 std::cout << "[SequencerService] → controller: " << (active ? "FIRE_START" : "FIRE_STOP")
1112 << " (acked)" << std::endl;
1113 return acked;
1114}
1115
1117 if (!elodin_.is_connected())
1118 return;
1119
1120 const State s = current_state_.load();
1121 uint32_t mask = state_machine_.allowedBitmask(s);
1122
1123 // Clear every refused state out of the advertised set. A state whose script did not load can
1124 // never be entered for the life of this run, so nothing should ever offer it as reachable —
1125 // the refusal is carried in data the GUI already receives rather than by a UI check that can
1126 // be forgotten. The reason text reaches the panel separately, over the SCRIPTS command, so an
1127 // operator can tell "broken" from "not reachable from here".
1128 std::vector<std::pair<State, std::vector<std::string>>> sensor_states;
1129 {
1130 std::lock_guard<std::mutex> lk(config_mutex_);
1131 for (const auto& [st, why] : refused_states_) {
1132 const uint8_t id = static_cast<uint8_t>(st);
1133 if (id < 32)
1134 mask &= ~(1u << id);
1135 }
1136 for (const auto& [st, ds] : dynamic_states_)
1137 if (!ds.pressure_roles.empty())
1138 sensor_states.emplace_back(st, ds.pressure_roles);
1139 }
1140
1141 // Also clear any state the sensor gate would refuse RIGHT NOW. Unlike a load refusal this is
1142 // not fixed for the run — a feed can arrive late or go stale — so the mask has to be
1143 // recomputed on every publish, which is what makes the button grey out and come back on its
1144 // own. Without this the one refusal an operator actually hits was invisible until they
1145 // pressed it: the gate is evaluated per press, so nothing advertised it in advance.
1146 //
1147 // Same check doTransitionTo runs, deliberately: two different notions of "enterable" would
1148 // give a button that is grey and works, or lit and refuses.
1149 for (const auto& [st, roles] : sensor_states) {
1150 const uint8_t id = static_cast<uint8_t>(st);
1151 if (id >= 32)
1152 continue;
1153 for (const auto& role : roles) {
1154 double psi = 0.0;
1155 if (pressure_feed_.read(role, psi) != PressureFeed::Status::Ok) {
1156 mask &= ~(1u << id);
1157 break;
1158 }
1159 }
1160 }
1161
1162 const uint8_t dbg = debug_mode_ ? 1u : 0u;
1163
1164 SequencerStateMsg msg(now_ns(), static_cast<uint8_t>(s), std::array<uint8_t, 3>{0, 0, 0}, mask,
1165 dbg);
1166 if (!elodin_.publish(VTABLE_SEQUENCER_STATE, msg))
1167 std::cerr << "[SequencerService] Failed to publish sequencer state to Elodin" << std::endl;
1168}
1169
1171 ScriptEnv env;
1172 env.state_name = ds.name;
1173
1174 // Pair intent with immediacy, exactly as debug manual control does: the script position is
1175 // what survives the 1 Hz republish, and the single send is what makes the valve move now
1176 // rather than up to a second from now.
1177 env.set_valve = [this](const std::string& role, int pos) {
1178 actuator_commander_.setScriptPosition(role, pos);
1179 actuator_commander_.sendSingleActuator(role, pos);
1180 };
1181
1182 // enqueueDetached, never enqueueAndWait. The worker that handles this transition calls
1183 // script_runner_.stop(), which joins the very thread making this call — waiting here would be
1184 // a guaranteed deadlock, and it would strand the rig in the dynamic state with the script's
1185 // valves wherever it left them. This is the same trap documented for the fire timer's expiry.
1186 env.transition = [this](State target) {
1187 enqueueDetached([this, target]() {
1188 return doTransitionTo(target);
1189 });
1190 };
1191
1192 // Refusing is the whole point of the boolean. An uncalibrated PT publishes a smooth, plausible
1193 // 0.0 PSI, and a script that takes 90% of that for a target computes zero and then does
1194 // nothing at all — successfully, invisibly, while the operator believes the tank came up.
1195 env.read_pressure = [this](const std::string& role, double& psi) {
1196 const auto status = pressure_feed_.read(role, psi);
1197 if (status == PressureFeed::Status::Ok)
1198 return true;
1199 std::cerr << "[SequencerService] " << PressureFeed::explain(role, status) << std::endl;
1200 return false;
1201 };
1202
1203 return env;
1204}
1205
1207 // Colons separate the fields, so a state name containing one would split a line. State names
1208 // come from [[states]] and the config editor; none of the shipped profiles contain a colon.
1209 // Substituted rather than rejected because a broken report must not be able to hide a refusal.
1210 auto sanitize = [](std::string s) {
1211 std::replace(s.begin(), s.end(), ':', ' ');
1212 std::replace(s.begin(), s.end(), '\n', ' ');
1213 return s;
1214 };
1215
1216 std::string out;
1217 std::lock_guard<std::mutex> lk(config_mutex_);
1218 for (const auto& [st, ds] : dynamic_states_) {
1219 out += "SCRIPT:" + std::to_string(static_cast<int>(st)) + ":" + sanitize(ds.name) +
1220 ":OK:" + std::to_string(ds.program.stmts.size()) + ":" +
1221 std::to_string(ds.timeout_ms) + "\n";
1222 }
1223 for (const auto& [st, why] : refused_states_) {
1224 out += "SCRIPT:" + std::to_string(static_cast<int>(st)) + ":" +
1225 sanitize(StateMachine::name(st)) + ":REFUSED:" + sanitize(why) + "\n";
1226 }
1227 // BLOCKED is the other half of the story, and the half an operator meets far more often.
1228 // REFUSED is fixed at load; this is the live sensor gate, so it can appear and clear while
1229 // the rig is running and the reason has to be re-read rather than cached.
1230 for (const auto& [st, ds] : dynamic_states_) {
1231 for (const auto& role : ds.pressure_roles) {
1232 double psi = 0.0;
1233 const auto status = pressure_feed_.read(role, psi);
1234 if (status == PressureFeed::Status::Ok)
1235 continue;
1236 out += "SCRIPT:" + std::to_string(static_cast<int>(st)) + ":" + sanitize(ds.name) +
1237 ":BLOCKED:" + sanitize(PressureFeed::explain(role, status)) + "\n";
1238 break; // one reason is enough to explain a greyed button
1239 }
1240 }
1241 out += "END\n";
1242 return out;
1243}
1244
1246 if (!elodin_.is_connected())
1247 return;
1248
1249 StateTransitionMsg msg(now_ns(), static_cast<uint8_t>(from), static_cast<uint8_t>(to), 0);
1250 elodin_.publish(VTABLE_STATE_TRANSITION, msg);
1251}
1252
1254 if (!elodin_.connect(elodin_host_, elodin_port_))
1255 return false;
1256 std::cout << "[SequencerService] Connected to Elodin at " << elodin_host_ << ":" << elodin_port_
1257 << std::endl;
1258 // Every one of these must run on a RECONNECT too, not just the first connect — the VTables
1259 // live in the db process, so a db restart loses them.
1260 //
1261 // The board list comes from the snapshot taken at init(), NOT from a fresh read of
1262 // config_path_. This used to re-parse config.toml on every reconnect, and the retry thread
1263 // reconnects on any db restart — so a db blip mid-run would rebuild the actuator tables from
1264 // whatever happened to be on disk at that moment. Config is applied once, at session start;
1265 // a process that has already booted keeps the config it booted with.
1267 if (!ball_valve_boards_.empty())
1269 actuator_commander_.setElodinClient(&elodin_);
1270 actuator_commander_.publishInitialState();
1271 return true;
1272}
1273
1275 if (elodin_retry_thread_.joinable())
1276 return;
1277 elodin_retry_stop_ = false;
1278 elodin_retry_thread_ = std::thread([this]() {
1279 while (!elodin_retry_stop_) {
1280 for (int i = 0; i < kElodinRetrySeconds * 10 && !elodin_retry_stop_; ++i)
1281 std::this_thread::sleep_for(std::chrono::milliseconds(100));
1282 if (elodin_retry_stop_ || elodin_.is_connected())
1283 continue;
1285 }
1286 });
1287}
1288
1290 elodin_retry_stop_ = true;
1291 if (elodin_retry_thread_.joinable())
1292 elodin_retry_thread_.join();
1293}
1294
1295} // namespace sequencer
static bool register_non_sensor_tables(ElodinClient &client, const std::vector< BoardChannels > &act_boards={})
Register sequencer/controller and ACT_CMD tables.
static bool register_ball_valve_cmd_tables(ElodinClient &client, const std::vector< BoardChannels > &bv_boards)
Register ball valve commanded-state VTables [0x35] (sequencer_service).
static std::string explain(const std::string &role, Status s)
Human phrasing for a refusal, e.g.
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).
static State fromName(const std::string &name)
Translate a CSV name (e.g.
static std::string name(State s)
Human-readable name for a State (e.g.
static State bootState()
The state to boot into (config is_boot, else Idle).
static void loadStatesFromConfig(const std::string &config_content)
Adopt the [[states]] entries from a config as the source of truth for state names and semantics.
static bool isAbort(State s)
True if the state carries the abort semantics (config is_abort, else the built-in trio).
long target
Definition main.cpp:38
static bool ok
Definition flight_e2e.c:435
static void send(void *ctx, const uint8_t *bytes, uint16_t len)
Definition gnss_task.c:27
json_pointer result
Definition json.hpp:14064
std::map< ActiveBoardKind, std::vector< elodin::BoardChannels > > load_active_boards(const std::string &config_path)
Parse [boards.
Config load_from_string(const std::string &text)
Parse config from an in-memory TOML string (SequencerService / ActuatorCommander hold it so).
Definition Config.cpp:458
DaqBindResult resolveDaqBindAddress(const ::fsw::config::Config &cfg, const char *who)
Decide which local address board traffic should leave from, and log the decision.
*brief user defined string literal for JSON values *sa std::size_t n
Definition json.hpp:24474
static constexpr uint16_t VTABLE_STATE_TRANSITION
static constexpr int kControllerConnectTimeoutMs
static std::string readFile(const std::string &path)
comms::CommsMessage< uint64_t, uint8_t, std::array< uint8_t, 3 >, uint32_t, uint8_t > SequencerStateMsg
static uint64_t now_ns()
static std::string resolveDataPath(const std::string &rel)
ScriptLoadResult load_dynamic_states(const fsw::config::Config &cfg, const std::string &config_text, const ScriptLoadContext &ctx)
Resolve, load and validate every [[states]] entry carrying a script_file.
comms::CommsMessage< uint64_t, uint8_t, uint8_t, uint8_t > StateTransitionMsg
static constexpr uint16_t VTABLE_SEQUENCER_STATE
State
System state IDs — must match the TypeScript SystemState enum in web-gui/shared/types....
static self_test_reply_t reply(self_test_result_t result, int32_t diagnostic)
CommsMessage - Template-based message serialization.
The whole parsed config.toml, restricted to what C++ consumes.
Definition Config.hpp:264
One dynamic state, resolved and proven usable.
What one hold is: how long, and who to tell.
Definition HoldTimer.hpp:22
What a running script is allowed to do to the world.
std::function< void(const std::string &role, int pos)> set_valve
Command one valve by canonical config name.
std::function< bool(const std::string &role, double &psi)> read_pressure
Read a calibrated pressure by canonical role name.
std::function< void(State target)> transition
Leave the state.
std::string state_name
Where the script's log lines are attributed.
Everything load_dynamic_states needs that it cannot get from Config alone.
const StateMachine * state_machine
Transition matrix, already loaded.
State fire_state
The [fire] state, so a script cannot be attached to the burn.
std::function< bool(const std::string &state_name)> has_actuator_column
True when the named state has a column in state_machine_actuators.csv.
std::string config_dir
Directory holding config.toml; scripts resolve to <config_dir>/scripts/<script_file>.
What a config load made of its dynamic states.
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.
static boot_report_t r