STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
ActuatorCommander.cpp
Go to the documentation of this file.
2
3#include <arpa/inet.h>
4#include <netinet/in.h>
5#include <sys/socket.h>
6#include <unistd.h>
7
8#include <algorithm>
9#include <cerrno>
10#include <chrono>
11#include <cstring>
12#include <fstream>
13#include <iostream>
14#include <mutex>
15#include <sstream>
16#include <thread>
17#include <tuple>
18
19#include "config/Config.hpp"
21
22// daqv2comms — all packet construction goes through here
23#include "DiabloPacketUtils.h"
25
26namespace sequencer {
27
28// ── Actuator commanded state [0x32 or 0x35, ch] — published to Elodin DB ─────
29// Layout: u64 timestamp_ns | u8 channel_id | u8 actuator_state = 10 bytes
30// No alignment issue: all post-u64 fields are u8. The table byte is per role
31// (ActuatorRole::cmd_table_hi): 0x32 ACT_CMD, 0x35 BV_CMD for a ball valve.
33
34/** Elodin low byte for [0x32, lo]: (board_slot - 1) * 0x20 + local_channel; slot = board_id % 10, 0
35 * → 10. */
36static uint8_t actuator_elodin_low_byte(uint32_t board_id, uint8_t local_channel) {
37 int bn = static_cast<int>(board_id % 10);
38 if (bn == 0)
39 bn = 10;
40 return static_cast<uint8_t>((bn - 1) * 0x20 + local_channel);
41}
42
43// ─────────────────────────────────────────────────────────────────────────────
44// Helpers
45// ─────────────────────────────────────────────────────────────────────────────
46static std::string trimVal(const std::string& s) {
47 size_t a = s.find_first_not_of(" \t\r\n\"");
48 size_t b = s.find_last_not_of(" \t\r\n\"");
49 return (a == std::string::npos) ? "" : s.substr(a, b - a + 1);
50}
51
52static std::string toLower(std::string s) {
53 std::transform(s.begin(), s.end(), s.begin(), ::tolower);
54 return s;
55}
56
57// ─────────────────────────────────────────────────────────────────────────────
58// load()
59// ─────────────────────────────────────────────────────────────────────────────
60void ActuatorCommander::setDefaultBindAddress(const std::string& address) {
62 return;
63 bind_addr_ = address.empty() ? "0.0.0.0" : address;
64}
65
66bool ActuatorCommander::load(const std::string& config_content, const std::string& csv_path,
67 const std::string& delay_csv_path) {
68 roles_.clear();
69 state_actuators_.clear();
70
71 const fsw::config::Config cfg = fsw::config::load_from_string(config_content);
72
73 // -- Config: bind address and actuator port --
74 // An explicit [actuator_service].bind_address wins; 0.0.0.0 (the shipped value everywhere)
75 // means "not specified", and setDefaultBindAddress() fills it from the resolved DAQ NIC.
76 bind_addr_ = cfg.actuator_service.bind_address;
77 if (bind_addr_.empty())
78 bind_addr_ = "0.0.0.0";
79 bind_addr_explicit_ = bind_addr_ != "0.0.0.0";
80 actuator_port_ = cfg.network.actuator_cmd_port;
81
82 // -- Board IP map: board_id → IP (from [boards.*]); canonical 192.168.2.N fallback --
83 std::map<int, std::string> board_id_to_ip;
84 for (const auto& b : cfg.boards)
85 if (!b.ip.empty() && b.board_id > 0)
86 board_id_to_ip[b.board_id] = b.ip;
87 if (board_id_to_ip.empty())
88 for (int i = 11; i <= 14; ++i)
89 board_id_to_ip[i] = "192.168.2." + std::to_string(i);
90
91 // -- Actuator roles from [actuator_roles] (channels 1..10 only) --
92 for (const auto& [name, r] : cfg.actuator_roles) {
93 if (r.channel < 1 || r.channel > 10)
94 continue;
95 ActuatorRole role;
96 role.is_no = r.is_no;
97 // Assigned to controller_service via the 4th element of the [actuator_roles] entry.
98 // This used to read `kind == "PWM"`, but kind is also where NC/NO polarity lives, so
99 // an actuator could not be both PWM-driven and normally open — and since the shipped
100 // configs needed NC/NO, nothing was ever marked "PWM" and this handoff never engaged
101 // on a real rig. Same field the controller resolves its targets from (PWMTargets.hpp).
102 role.is_pwm = !r.controller_role.empty();
103 role.channel = r.channel;
104 role.board_id = r.board_id;
105 // A ball valve role is already pass-through (Config forces is_no off); it only differs in
106 // where its commanded state is recorded.
107 if (r.ball_valve)
109 auto it = board_id_to_ip.find(r.board_id);
110 role.board_ip =
111 (it != board_id_to_ip.end()) ? it->second : "192.168.2." + std::to_string(r.board_id);
112 roles_[name] = role;
113 }
114 std::cout << "[ActuatorCommander] Loaded " << roles_.size() << " actuator roles from config"
115 << std::endl;
116
117 // -- State→actuator CSV -- canonical source: daq-server/config/.
118 // The deployed config/*.csv are generated artifacts (gitignored), written when a profile
119 // is deployed. On a fresh checkout — CI, or a clone that has never started the backend —
120 // they do not exist yet, so fall back to the profile that owns them. Mirrors what
121 // readConfig() does for config.toml on the TypeScript side.
122 const char* fallbacks[] = {
123 "config/state_machine_actuators.csv",
124 "../config/state_machine_actuators.csv",
125 "../../config/state_machine_actuators.csv",
126 "config/profiles/default/state_machine_actuators.csv",
127 "../config/profiles/default/state_machine_actuators.csv",
128 "../../config/profiles/default/state_machine_actuators.csv",
129 };
130
131 std::ifstream f(csv_path);
132 std::string used_csv = csv_path;
133 if (!f.is_open()) {
134 for (const char* fb : fallbacks) {
135 if (std::string(fb) == csv_path)
136 continue;
137 f.open(fb);
138 if (f.is_open()) {
139 used_csv = fb;
140 break;
141 }
142 }
143 }
144 if (!f.is_open()) {
145 std::cerr << "[ActuatorCommander] Cannot open state_machine_actuators.csv" << std::endl;
146 return false;
147 }
148
149 std::string line;
150 if (!std::getline(f, line))
151 return false;
152
153 // Header: actuator_name, State1, State2, ...
154 std::vector<std::string> headers;
155 {
156 std::istringstream iss(line);
157 std::string cell;
158 while (std::getline(iss, cell, ','))
159 headers.push_back(trimVal(cell));
160 }
161
162 while (std::getline(f, line)) {
163 std::vector<std::string> cells;
164 std::istringstream iss(line);
165 std::string cell;
166 while (std::getline(iss, cell, ','))
167 cells.push_back(trimVal(cell));
168 if (cells.empty() || cells[0].empty())
169 continue;
170
171 const std::string& act_name = cells[0];
172 for (size_t col = 1; col < headers.size() && col < cells.size(); ++col) {
173 if (headers[col].empty())
174 continue;
175 std::string val = cells[col];
176 std::transform(val.begin(), val.end(), val.begin(), ::toupper);
177 int pos = -1;
178 if (val == "OPEN")
179 pos = 1;
180 else if (val == "CLOSE" || val == "CLOSED")
181 pos = 0;
182 if (pos < 0)
183 continue;
184 state_actuators_[headers[col]][act_name] = pos;
185 }
186 }
187
188 // ── Per-actuator delays (same rows x columns as the positions table) ────────
189 // Optional: a stand with no delays configured simply has every cell 0, and a missing file
190 // leaves the map empty, which means "everything at t=0" — the behaviour before delays existed.
192 {
193 // Config first; the suffix swap is only the fallback for a caller that passes nothing.
194 std::string delay_csv = delay_csv_path;
195 if (delay_csv.empty()) {
196 delay_csv = used_csv;
197 const std::string suffix = "state_machine_actuators.csv";
198 if (delay_csv.size() >= suffix.size() &&
199 delay_csv.compare(delay_csv.size() - suffix.size(), suffix.size(), suffix) == 0) {
200 delay_csv.replace(delay_csv.size() - suffix.size(), suffix.size(),
201 "state_machine_actuator_delays.csv");
202 } else {
203 // The swap did not fire, so `delay_csv` is still the POSITIONS file. Opening that
204 // as a delays table parses OPEN/CLOSE as numbers, finds none, and leaves every
205 // stagger at zero — silently. Say so instead.
206 std::cerr << "[ActuatorCommander] No delay CSV given and \"" << used_csv
207 << "\" is not named state_machine_actuators.csv — running with NO "
208 "actuator delays. Set [state_machine].actuator_delay_csv."
209 << std::endl;
210 delay_csv.clear();
211 }
212 }
213 std::ifstream df(delay_csv);
214 if (df.is_open()) {
215 std::string dline;
216 std::vector<std::string> dheaders;
217 if (std::getline(df, dline)) {
218 std::istringstream iss(dline);
219 std::string cell;
220 while (std::getline(iss, cell, ','))
221 dheaders.push_back(trimVal(cell));
222 }
223 size_t nonzero = 0;
224 while (std::getline(df, dline)) {
225 std::vector<std::string> cells;
226 std::istringstream iss(dline);
227 std::string cell;
228 while (std::getline(iss, cell, ','))
229 cells.push_back(trimVal(cell));
230 if (cells.empty() || cells[0].empty())
231 continue;
232 for (size_t col = 1; col < dheaders.size() && col < cells.size(); ++col) {
233 if (dheaders[col].empty() || cells[col].empty())
234 continue;
235 try {
236 double d = std::stod(cells[col]);
237 if (d > 0.0) {
238 state_actuator_delays_[dheaders[col]][cells[0]] = d;
239 nonzero++;
240 }
241 } catch (...) {
242 // A non-numeric cell means "no delay" rather than an error — same
243 // tolerance the positions table gives a cell that is not OPEN/CLOSE.
244 }
245 }
246 }
247 if (nonzero)
248 std::cout << "[ActuatorCommander] Loaded " << nonzero
249 << " non-zero actuator delay(s) from " << delay_csv << std::endl;
250 }
251 }
252
253 loaded_ = true;
254 std::cout << "[ActuatorCommander] Loaded " << state_actuators_.size() << " states from "
255 << used_csv << std::endl;
256 return true;
257}
258
259// ─────────────────────────────────────────────────────────────────────────────
260// findStateActuators (case-insensitive)
261// ─────────────────────────────────────────────────────────────────────────────
262std::map<std::string, std::map<std::string, int>>::const_iterator
263ActuatorCommander::findStateActuators(const std::string& state_name) const {
264 auto it = state_actuators_.find(state_name);
265 if (it != state_actuators_.end())
266 return it;
267
268 const std::string lower = toLower(state_name);
269 for (auto jt = state_actuators_.cbegin(); jt != state_actuators_.cend(); ++jt) {
270 if (toLower(jt->first) == lower)
271 return jt;
272 }
273 return state_actuators_.cend();
274}
275
276// ─────────────────────────────────────────────────────────────────────────────
277// sendUDP — build an ActuatorCommand packet and send to board_ip:actuator_port_
278// ─────────────────────────────────────────────────────────────────────────────
279bool ActuatorCommander::sendUDP(const std::string& board_ip,
280 const std::vector<std::pair<uint8_t, uint8_t>>& id_state_pairs) {
281 return sendBatch({{board_ip, id_state_pairs}});
282}
283
284/**
285 * Send one command batch to every board, interleaving the retransmits.
286 *
287 * Each packet still goes out 3x ~1 ms apart so it lands in the board's UDP receive window whatever
288 * its loop() rate. What changed is the ordering: round 1 to ALL boards, then round 2, then round 3,
289 * instead of finishing all three rounds for board A before starting board B. The old order meant
290 * every extra board added ~2 ms of skew to a state change -- with two actuator boards, board 2's
291 * first packet left ~2 ms after board 1's, on every transition. Now the boards are within
292 * microseconds of each other and the batch takes ~2 ms total instead of ~2 ms per board.
293 *
294 * One socket for the whole batch rather than one per board, since we now revisit each board 3x.
295 */
297 const std::map<std::string, std::vector<std::pair<uint8_t, uint8_t>>>& by_board) {
298 if (by_board.empty())
299 return true;
300
301 // Serialize every board's packet up front so the timed rounds below do nothing but sendto().
302 struct Outgoing {
303 struct sockaddr_in dest;
304 std::vector<uint8_t> buf;
305 };
306 std::vector<Outgoing> outgoing;
307 outgoing.reserve(by_board.size());
308
309 const uint32_t ts_ms =
310 static_cast<uint32_t>(std::chrono::duration_cast<std::chrono::milliseconds>(
311 std::chrono::steady_clock::now().time_since_epoch())
312 .count() &
313 0xFFFFFFFFu);
314
315 for (const auto& [board_ip, id_state_pairs] : by_board) {
316 if (id_state_pairs.empty())
317 continue;
318 std::vector<daq::ActuatorCommand> cmds;
319 cmds.reserve(id_state_pairs.size());
320 for (const auto& [id, st] : id_state_pairs)
321 cmds.push_back({id, st});
322
323 uint8_t buf[512];
324 size_t len = daq::create_actuator_command_packet(cmds, ts_ms, buf, sizeof(buf));
325 if (len == 0) {
326 std::cerr << "[ActuatorCommander] create_actuator_command_packet returned 0 for "
327 << board_ip << std::endl;
328 continue;
329 }
330 Outgoing o{};
331 o.dest.sin_family = AF_INET;
332 o.dest.sin_port = htons(actuator_port_);
333 if (inet_pton(AF_INET, board_ip.c_str(), &o.dest.sin_addr) != 1) {
334 std::cerr << "[ActuatorCommander] bad board IP " << board_ip << std::endl;
335 continue;
336 }
337 o.buf.assign(buf, buf + len);
338 outgoing.push_back(std::move(o));
339 }
340 if (outgoing.empty())
341 return false;
342
343 int sock = socket(AF_INET, SOCK_DGRAM, 0);
344 if (sock < 0) {
345 std::cerr << "[ActuatorCommander] socket() failed: " << strerror(errno) << std::endl;
346 return false;
347 }
348
349 // Without this a sendto() on a saturated interface blocks the calling thread indefinitely —
350 // and the caller is either the 1 Hz republish loop or a command thread mid-transition. A
351 // command that cannot be queued in 100 ms is a failure to report, not something to wait on.
352 {
353 struct timeval tv{.tv_sec = 0, .tv_usec = 100000};
354 setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
355 }
356
357 struct sockaddr_in local{};
358 local.sin_family = AF_INET;
359 inet_pton(AF_INET, bind_addr_.c_str(), &local.sin_addr);
360 if (bind(sock, reinterpret_cast<struct sockaddr*>(&local), sizeof(local)) < 0) {
361 std::cerr << "[ActuatorCommander] bind(" << bind_addr_ << ") failed: " << strerror(errno)
362 << std::endl;
363 close(sock);
364 return false;
365 }
366
367 bool all_ok = true;
368 for (int round = 0; round < 3; ++round) {
369 for (const auto& o : outgoing) {
370 ssize_t sent =
371 sendto(sock, o.buf.data(), o.buf.size(), 0,
372 reinterpret_cast<const struct sockaddr*>(&o.dest), sizeof(o.dest));
373 if (sent != static_cast<ssize_t>(o.buf.size())) {
374 // A short send used to only flip this boolean — errno was never read, so a
375 // partially delivered state change left no trace beyond a return value most
376 // callers ignore. Name the board and the reason.
377 char ip[INET_ADDRSTRLEN] = {0};
378 inet_ntop(AF_INET, &o.dest.sin_addr, ip, sizeof(ip));
379 std::cerr << "[ActuatorCommander] sendto(" << ip << ":" << actuator_port_
380 << ") round " << (round + 1) << " failed: " << strerror(errno)
381 << std::endl;
382 all_ok = false;
383 }
384 }
385 if (round < 2)
386 usleep(1000);
387 }
388 close(sock);
389 return all_ok;
390}
391
392// ─────────────────────────────────────────────────────────────────────────────
393// applyForState — send all actuator commands for a state (one shot, batched by board)
394// ─────────────────────────────────────────────────────────────────────────────
396 const std::string state_name = StateMachine::name(state);
397 auto it = findStateActuators(state_name);
398 if (it == state_actuators_.end()) {
399 std::cerr << "[ActuatorCommander] No CSV entry for state: " << state_name << std::endl;
400 return;
401 }
402
403 const bool is_fire = (state == fire_state_);
404
405 // Collected first, then split into delay stages below — a command needs its role name to look
406 // up its delay, which the by-board packing throws away.
407 struct PendingCmd {
408 std::string role_name;
409 std::string board_ip;
410 uint8_t channel;
411 uint8_t hw_state;
412 uint8_t global_ch;
413 uint8_t logical_pos;
414 uint8_t cmd_table_hi;
415 };
416 std::vector<PendingCmd> pending;
417
418 // Snapshot the script's positions BEFORE taking overrides_mutex_, and never hold both. The two
419 // maps have different writers — a script runs on its own thread while the command worker and
420 // this republish thread both come through here — and nesting the locks in one order here while
421 // ScriptRunner takes them in the other is a deadlock waiting for the right interleaving.
422 std::map<std::string, int> script_positions;
423 {
424 std::lock_guard<std::mutex> lk(script_positions_mutex_);
425 script_positions = script_positions_;
426 }
427
428 std::unique_lock lock(overrides_mutex_);
429 for (const auto& [act_name, logical_pos] : it->second) {
430 auto role_it = roles_.find(act_name);
431 if (role_it == roles_.end())
432 continue;
433 const ActuatorRole& role = role_it->second;
434 if (is_fire && role.is_pwm)
435 continue;
436
437 // Precedence: a debug manual override beats a running script, which beats the state's CSV
438 // column. The operator with their hand on the panel is the last line — and a script that
439 // is being overridden is worth saying out loud, since the two are fighting over one valve
440 // and the script has no way to notice.
441 int pos = logical_pos;
442 auto sp = script_positions.find(act_name);
443 if (sp != script_positions.end())
444 pos = sp->second;
445 auto ov = manual_overrides_.find(act_name);
446 if (ov != manual_overrides_.end()) {
447 if (sp != script_positions.end() && ov->second != sp->second)
449 pos = ov->second;
450 }
451
452 uint8_t hw_state = static_cast<uint8_t>(role.is_no ? (1 - pos) : pos);
453 uint8_t global_ch =
454 actuator_elodin_low_byte(role.board_id, static_cast<uint8_t>(role.channel));
455 pending.push_back({act_name, role.board_ip, static_cast<uint8_t>(role.channel), hw_state,
456 global_ch, static_cast<uint8_t>(pos), role.cmd_table_hi});
457 }
458 lock.unlock();
459
460 // Only an actual entry supersedes a pending schedule — the 1 Hz republish must not, or it
461 // would cancel the very stages it is meant to leave alone.
462 const uint64_t gen = is_transition ? ++schedule_gen_ : schedule_gen_.load();
463 if (is_transition) {
464 std::lock_guard<std::mutex> lk(pending_roles_mutex_);
465 pending_roles_.clear();
466 }
467
468 // Split into stages by delay. On a republish (is_transition false) everything is stage 0, so
469 // the settled positions go out together.
470 //
471 // Stages keep their ROLE NAMES rather than collapsing straight to (board, channel) pairs. A
472 // stage can be armed now and fire seconds later, and in between a script may take ownership of
473 // one of its valves — so the decision "is this command still wanted?" has to be made when the
474 // stage fires, and that needs the role name to still be there.
475 std::map<double, std::vector<PendingCmd>> staged;
476 for (size_t i = 0; i < pending.size(); ++i) {
477 const auto& pc = pending[i];
478 // A role the script has taken ownership of is never staged behind the delays CSV: the
479 // script has its own delay(), and a stagger the operator never wrote into the script would
480 // be a second, invisible schedule fighting it.
481 const bool script_owned = script_positions.count(pc.role_name) > 0;
482
483 double d = 0.0;
484 if (is_transition && !script_owned) {
485 auto ds = state_actuator_delays_.find(state_name);
486 if (ds != state_actuator_delays_.end()) {
487 auto it = ds->second.find(pc.role_name);
488 if (it != ds->second.end() && it->second > 0.0)
489 d = it->second;
490 }
491 }
492 if (!is_transition && !script_owned) {
493 std::lock_guard<std::mutex> lk(pending_roles_mutex_);
494 if (pending_roles_.count(pc.role_name))
495 continue; // still waiting on its delay — leave the board holding its last position
496 }
497 staged[d].push_back(pc);
498 if (d > 0.0) {
499 std::lock_guard<std::mutex> lk(pending_roles_mutex_);
500 pending_roles_.insert(pc.role_name);
501 }
502 }
503
504 auto emit = [this, state_name](const std::vector<PendingCmd>& cmds, double delay_s) {
505 // Re-resolve against the script's CURRENT positions, because a stage can be armed now and
506 // fire seconds later. In between, a script may have taken one of these valves: the column
507 // staggers it by two seconds, the script sets it at 50 ms, and without this the stale
508 // stage lands last and leaves the valve opposite to what the script said — silently.
509 //
510 // Corrected rather than dropped. Dropping would also silence stage 0 of every republish,
511 // which is precisely where a script's position has to keep going out.
512 // Both snapshotted, never held together — see the lock-order note in applyForState.
513 std::map<std::string, int> script_positions;
514 {
515 std::lock_guard<std::mutex> lk(script_positions_mutex_);
516 script_positions = script_positions_;
517 }
518 std::map<std::string, int> overrides;
519 {
520 std::lock_guard<std::mutex> lk(overrides_mutex_);
521 overrides = manual_overrides_;
522 }
523
524 std::map<std::string, std::vector<std::pair<uint8_t, uint8_t>>> by_ip;
525 std::vector<std::tuple<uint8_t, uint8_t, uint8_t>> logical; // table_hi, channel, pos
526 for (const auto& pc : cmds) {
527 uint8_t hw = pc.hw_state;
528 uint8_t pos = pc.logical_pos;
529 auto sp = script_positions.find(pc.role_name);
530 // An operator's manual override outranks the script, so a role they hold is left
531 // exactly as applyForState resolved it.
532 if (overrides.count(pc.role_name))
533 sp = script_positions.end();
534 if (sp != script_positions.end() && sp->second != pc.logical_pos) {
535 auto role_it = roles_.find(pc.role_name);
536 if (role_it != roles_.end()) {
537 pos = static_cast<uint8_t>(sp->second);
538 hw =
539 static_cast<uint8_t>(role_it->second.is_no ? (1 - sp->second) : sp->second);
540 std::cout << "[ActuatorCommander] Staged command for \"" << pc.role_name
541 << "\" superseded by the running script's position" << std::endl;
542 }
543 }
544 by_ip[pc.board_ip].emplace_back(pc.channel, hw);
545 logical.emplace_back(pc.cmd_table_hi, pc.global_ch, pos);
546 }
547 if (by_ip.empty())
548 return;
549
550 if (sendBatch(by_ip)) {
551 size_t n = 0;
552 for (const auto& [ip, c] : by_ip)
553 n += c.size();
554 std::cout << "[ActuatorCommander] Sent " << n << " commands for state " << state_name;
555 if (delay_s > 0.0)
556 std::cout << " (+" << delay_s << "s)";
557 std::cout << std::endl;
558 } else {
559 std::cerr << "[ActuatorCommander] UDP send failed for state " << state_name
560 << std::endl;
561 }
562 for (const auto& [hi, ch, pos] : logical)
563 publishCommandedState(hi, ch, pos);
564 };
565
566 // Stage 0 goes out on this thread, immediately.
567 auto zero = staged.find(0.0);
568 if (zero != staged.end())
569 emit(zero->second, 0.0);
570
571 if (staged.size() <= (zero != staged.end() ? 1u : 0u))
572 return;
573
574 // Later stages run on their own thread: a send blocks ~2 ms for the retransmits, so a pending
575 // delay must not queue behind another stage's send. Detached + generation-checked, so a new
576 // transition abandons it rather than landing a stale command seconds later.
577 std::vector<std::pair<double, size_t>> order;
578 for (const auto& [d, _] : staged)
579 if (d > 0.0)
580 order.emplace_back(d, 0);
581
582 std::map<double, std::vector<std::string>> roles_by_delay;
583 for (const auto& pc : pending) {
584 auto ds = state_actuator_delays_.find(state_name);
585 if (ds == state_actuator_delays_.end())
586 continue;
587 auto it = ds->second.find(pc.role_name);
588 if (it != ds->second.end() && it->second > 0.0)
589 roles_by_delay[it->second].push_back(pc.role_name);
590 }
591
592 std::thread([this, gen, staged, order, emit, roles_by_delay]() mutable {
593 auto t0 = std::chrono::steady_clock::now();
594 for (const auto& [delay_s, _] : order) {
595 const auto due = t0 + std::chrono::microseconds(static_cast<long long>(delay_s * 1e6));
596 while (std::chrono::steady_clock::now() < due) {
597 if (schedule_gen_.load() != gen)
598 return; // superseded by a newer transition
599 std::this_thread::sleep_for(std::chrono::milliseconds(1));
600 }
601 if (schedule_gen_.load() != gen)
602 return;
603 emit(staged[delay_s], delay_s);
604 {
605 std::lock_guard<std::mutex> lk(pending_roles_mutex_);
606 for (const auto& r : roles_by_delay[delay_s])
607 pending_roles_.erase(r);
608 }
609 }
610 }).detach();
611}
612
613// ─────────────────────────────────────────────────────────────────────────────
614// Continuous loop
615// ─────────────────────────────────────────────────────────────────────────────
616void ActuatorCommander::startContinuousLoop(State state, bool allow_delays, bool defer_first_pass) {
618
619 loop_running_ = true;
621 loop_thread_ = std::thread([this, state, allow_delays, defer_first_pass]() {
622 std::cout << "[ActuatorCommander] Continuous loop started for state "
623 << StateMachine::name(state) << std::endl;
624 // Only the FIRST pass is the state entry — that one runs the delay schedule. Every pass
625 // after it is the 1 Hz republish and must send settled positions, or each tick would
626 // re-arm the pending delays and nothing would ever settle.
627 bool entry = allow_delays;
628 bool skip = defer_first_pass;
629 while (loop_running_) {
630 if (skip) {
631 // A dynamic state's script starts commanding valves microseconds after this loop
632 // does, and this pass is redundant anyway — the caller already applied the column.
633 // Running it here is a read-then-send race the script loses: the pass resolves
634 // positions, spends ~2 ms on retransmits, and a stale CLOSE lands on top of the
635 // OPEN the script issued in between. The valve then stays shut for a full
636 // republish period, which is longer than a seating pulse lasts.
637 skip = false;
638 entry = false;
639 } else {
641 entry = false;
642 }
643 // Sleep in short slices rather than one long one, because stopContinuousLoop() joins
644 // this thread and transitionTo() joins BEFORE it commands the new state's valves — so
645 // this slice length is a floor on how fast any state change reaches the hardware.
646 //
647 // It was 100 ms, which put an up-to-100 ms wait between a timed hold expiring and its
648 // valve actually being told to close, swamping a countdown accurate to under a
649 // millisecond. It also delayed every other transition, aborts included, by the same
650 // amount. 10 ms costs 100 wakeups/second doing nothing but checking a flag.
651 //
652 // The right fix is a condition_variable so the join returns at once instead of at the
653 // next slice; that is deliberately not done here because it means restructuring this
654 // predicate, which is being changed elsewhere. Until then the slice is the bound.
655 for (int i = 0; i < 100 && loop_running_; ++i)
656 std::this_thread::sleep_for(std::chrono::milliseconds(10));
657 }
658 std::cout << "[ActuatorCommander] Continuous loop stopped" << std::endl;
659 });
660}
661
663 loop_running_ = false;
664 if (loop_thread_.joinable())
665 loop_thread_.join();
666}
667
668// ─────────────────────────────────────────────────────────────────────────────
669// sendSingleActuator
670// ─────────────────────────────────────────────────────────────────────────────
671double ActuatorCommander::delayForRole(const std::string& state_name,
672 const std::string& role) const {
673 auto ds = state_actuator_delays_.find(state_name);
674 if (ds == state_actuator_delays_.end())
675 return 0.0;
676 auto it = ds->second.find(role);
677 return (it != ds->second.end() && it->second > 0.0) ? it->second : 0.0;
678}
679
680// ─────────────────────────────────────────────────────────────────────────────
681std::map<std::string, int> ActuatorCommander::positionsForState(
682 const std::string& state_name) const {
683 auto it = findStateActuators(state_name);
684 return it == state_actuators_.end() ? std::map<std::string, int>{} : it->second;
685}
686
687// ─────────────────────────────────────────────────────────────────────────────
688bool ActuatorCommander::sendSingleActuator(const std::string& name, int pos) {
689 // Case-insensitive lookup
690 auto it = roles_.find(name);
691 if (it == roles_.end()) {
692 const std::string lower = toLower(name);
693 for (auto jt = roles_.begin(); jt != roles_.end(); ++jt) {
694 if (toLower(jt->first) == lower) {
695 it = jt;
696 break;
697 }
698 }
699 }
700 if (it == roles_.end()) {
701 std::cerr << "[ActuatorCommander] Unknown actuator role: " << name << std::endl;
702 return false;
703 }
704
705 const ActuatorRole& role = it->second;
706 uint8_t hw_state = static_cast<uint8_t>(role.is_no ? (1 - pos) : pos);
707 bool ok = sendUDP(role.board_ip, {{static_cast<uint8_t>(role.channel), hw_state}});
708 if (ok) {
709 std::cout << "[ActuatorCommander] Manual: " << name << " -> "
710 << (pos == 1 ? "OPEN" : "CLOSED") << std::endl;
711 uint8_t global_ch =
712 actuator_elodin_low_byte(role.board_id, static_cast<uint8_t>(role.channel));
713 publishCommandedState(role.cmd_table_hi, global_ch, static_cast<uint8_t>(pos));
714 }
715 return ok;
716}
717
718// ─────────────────────────────────────────────────────────────────────────────
719// Manual overrides (debug)
720// ─────────────────────────────────────────────────────────────────────────────
721void ActuatorCommander::setManualOverride(const std::string& name, int pos) {
722 // Use canonical role key from config (CSV / applyForState use the same spelling).
723 std::string key = name;
724 auto it = roles_.find(name);
725 if (it == roles_.end()) {
726 const std::string lower = toLower(name);
727 for (auto jt = roles_.begin(); jt != roles_.end(); ++jt) {
728 if (toLower(jt->first) == lower) {
729 key = jt->first;
730 break;
731 }
732 }
733 }
734 std::lock_guard lock(overrides_mutex_);
735 manual_overrides_[key] = pos;
736}
737
739 // Once per role per process. This is a real condition an operator can create deliberately
740 // (taking a valve back off a running script in debug mode), so it must be visible — but it is
741 // re-evaluated on every 1 Hz republish, and saying it every second would bury everything else.
742 static std::mutex mtx;
743 static std::set<std::string> said;
744 std::lock_guard<std::mutex> lk(mtx);
745 if (said.insert(role).second)
746 std::cerr << "[ActuatorCommander] Manual override on \"" << role
747 << "\" is overriding a running script's position for it" << std::endl;
748}
749
750void ActuatorCommander::setScriptPosition(const std::string& name, int pos) {
751 // Canonicalise against roles_ so the key matches the CSV spelling, exactly as
752 // setManualOverride does — otherwise applyForState's lookup misses and the position is
753 // silently inert.
754 std::string canonical = name;
755 auto it = roles_.find(name);
756 if (it == roles_.end()) {
757 for (const auto& [rname, r] : roles_) {
758 if (rname.size() == name.size() &&
759 std::equal(rname.begin(), rname.end(), name.begin(), [](char a, char b) {
760 return std::tolower(static_cast<unsigned char>(a)) ==
761 std::tolower(static_cast<unsigned char>(b));
762 })) {
763 canonical = rname;
764 break;
765 }
766 }
767 }
768
769 {
770 std::lock_guard<std::mutex> lk(script_positions_mutex_);
771 script_positions_[canonical] = pos;
772 }
773
774 // Taking ownership cancels any staged command still in flight for this valve. Without this,
775 // the state's own delays column can schedule the valve to move seconds AFTER the script has
776 // already commanded it, and the stage lands last — so the valve ends up opposite to what the
777 // script said, with nothing logged. Dropping it from pending_roles_ also lets the 1 Hz
778 // republish carry the script's position instead of skipping the role, which is the same
779 // silent-drop hazard from the other direction.
780 std::lock_guard<std::mutex> lk(pending_roles_mutex_);
781 pending_roles_.erase(canonical);
782}
783
785 std::lock_guard<std::mutex> lk(script_positions_mutex_);
786 script_positions_.clear();
787}
788
793
794// ─────────────────────────────────────────────────────────────────────────────
795// publishCommandedState — write [0x32 or 0x35, channel_id] to Elodin DB
796// ─────────────────────────────────────────────────────────────────────────────
797void ActuatorCommander::publishCommandedState(uint8_t table_hi, uint8_t channel_id,
798 uint8_t logical_pos) {
799 if (!elodin_ || !elodin_->is_connected())
800 return;
801
802 auto ts_ns = static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
803 std::chrono::steady_clock::now().time_since_epoch())
804 .count());
805
806 ActuatorCommandedMsg msg(ts_ns, channel_id, logical_pos);
807 std::array<uint8_t, 2> table_id = {table_hi, channel_id};
808 elodin_->publish(table_id, msg);
809}
810
811// ─────────────────────────────────────────────────────────────────────────────
812// publishInitialState — seed Elodin DB with de-energized positions for all actuators
813// ─────────────────────────────────────────────────────────────────────────────
815 if (!elodin_ || !elodin_->is_connected())
816 return;
817
818 for (const auto& [name, role] : roles_) {
819 // De-energized: NC → closed (0), NO → open (1)
820 uint8_t logical_pos = role.is_no ? 1 : 0;
821 uint8_t global_ch =
822 actuator_elodin_low_byte(role.board_id, static_cast<uint8_t>(role.channel));
823 publishCommandedState(role.cmd_table_hi, global_ch, logical_pos);
824 }
825 std::cout << "[ActuatorCommander] Published initial state for " << roles_.size()
826 << " actuators to Elodin DB" << std::endl;
827}
828
829} // namespace sequencer
void clearAllManualOverrides()
Clear all manual overrides (e.g.
static void logOverrideShadowingScript(const std::string &role)
Warn once per role that a manual override is fighting a running script for a valve.
void publishCommandedState(uint8_t table_hi, uint8_t global_channel, uint8_t logical_pos)
void setManualOverride(const std::string &name, int pos)
Debug: hold this logical position for the role until cleared or state transition.
void setScriptPosition(const std::string &name, int pos)
A running script's position for one valve — the script layering on top of its state's CSV column.
void clearScriptPositions()
Drop every script position.
void publishInitialState()
Publish initial de-energized state for all actuators to Elodin DB [0x32].
std::map< std::string, int > manual_overrides_
void stopContinuousLoop()
Stop the continuous re-send loop (blocks until the thread exits).
static void logOverrideShadowingScript(const std::string &role)
Warn once per role that a manual override is fighting a running script for a valve.
State fire_state_
Bumped on every transition so an in-flight delayed stage from the previous state aborts instead of la...
std::map< std::string, std::map< std::string, int > >::const_iterator findStateActuators(const std::string &state_name) const
void applyForState(State state, bool is_transition=false)
Send actuator commands for the given state (one shot).
void publishCommandedState(uint8_t table_hi, uint8_t global_channel, uint8_t logical_pos)
fsw::elodin::ElodinClient * elodin_
bool sendBatch(const std::map< std::string, std::vector< std::pair< uint8_t, uint8_t > > > &by_board)
Send to every board with the retransmits interleaved, so boards stay in step.
void setDefaultBindAddress(const std::string &address)
Pin outgoing actuator commands to a NIC, unless [actuator_service].bind_address already named one exp...
bool sendUDP(const std::string &board_ip, const std::vector< std::pair< uint8_t, uint8_t > > &id_state_pairs)
std::map< std::string, int > positionsForState(const std::string &state_name) const
The logical positions the CSV declares for state_name (role -> 0 CLOSED / 1 OPEN).
std::set< std::string > pending_roles_
Roles whose delayed stage has not fired yet.
void startContinuousLoop(State state, bool allow_delays=true, bool defer_first_pass=false)
Start the 1 Hz continuous re-send loop for the given state.
double delayForRole(const std::string &state_name, const std::string &role) const
The staged delay, in seconds, that role waits before moving on entry to state_name.
bool load(const std::string &config_content, const std::string &csv_path, const std::string &delay_csv_path="")
Load actuator roles from config content and state→actuator map from CSV.
std::map< std::string, int > script_positions_
Valves a running script has taken over, role -> logical position.
std::map< std::string, std::map< std::string, int > > state_actuators_
std::atomic< uint64_t > schedule_gen_
bool sendSingleActuator(const std::string &name, int pos)
Send one UDP packet for a single role (debug manual command).
std::map< std::string, ActuatorRole > roles_
std::map< std::string, std::map< std::string, double > > state_actuator_delays_
state -> actuator -> delay in seconds, from state_machine_actuator_delays.csv.
static std::string name(State s)
Human-readable name for a State (e.g.
static uint8_t board_id
Definition main.cpp:30
static ActuatorControllerState state
Definition main.cpp:57
static bool ok
Definition flight_e2e.c:435
*brief create JSON pointer *sa reference_tokens const string_t const string_t & b
Definition json.hpp:13878
typename Container::const_iterator const_iterator
Definition json.hpp:19039
*brief create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
return o
Definition json.hpp:13897
static osMutexId_t lock
Definition mode.c:11
size_t create_actuator_command_packet(const std::vector< ActuatorCommand > &commands, uint32_t timestamp_ms, uint8_t *buffer, size_t buffer_size)
Creates a complete Actuator Command packet in the provided buffer.
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
constexpr uint8_t kBallValveCmdHi
*brief user defined string literal for JSON values *sa std::size_t n
Definition json.hpp:24474
static std::string toLower(std::string s)
static uint8_t actuator_elodin_low_byte(uint32_t board_id, uint8_t local_channel)
Elodin low byte for [0x32, lo]: (board_slot - 1) * 0x20 + local_channel; slot = board_id % 10,...
comms::CommsMessage< uint64_t, uint8_t, uint8_t > ActuatorCommandedMsg
static std::string trimVal(const std::string &s)
State
System state IDs — must match the TypeScript SystemState enum in web-gui/shared/types....
static void emit(sensors_core_t *c, uint8_t type, const void *data, uint8_t len)
CommsMessage - Template-based message serialization.
The whole parsed config.toml, restricted to what C++ consumes.
Definition Config.hpp:264
Config entry for one named actuator role loaded from config.toml [actuator_roles].
static boot_report_t r
static std::string ch(int ain, const std::string &kind, const std::string &extra="", const std::string &board="lj")
Definition test_t7.cpp:98