STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
controller_main.cpp
Go to the documentation of this file.
1/**
2 * @file controller_main.cpp
3 * @brief Standalone C++ controller service
4 *
5 * Reads config.toml for actuator board settings, initializes the
6 * RobustDDPController, and runs the control loop sending PWM commands
7 * to actuator boards over UDP.
8 *
9 * Usage:
10 * ./controller_service [--config /path/to/config.toml]
11 * [--elodin-host HOST] [--elodin-port PORT]
12 * [--thrust N]
13 *
14 * Default config path: ../../config/config.toml (relative to binary)
15 */
16
17#include <arpa/inet.h>
18#include <netinet/in.h>
19#include <sys/socket.h>
20#include <unistd.h>
21
22#include <atomic>
23#include <csignal>
24#include <cstdlib>
25#include <cstring>
26#include <fstream>
27#include <iostream>
28#include <map>
29#include <memory>
30#include <sstream>
31#include <string>
32#include <thread>
33#include <vector>
34
35#include "config/Config.hpp"
40#include "net/DaqInterface.hpp"
41
42/** Resolve path relative to config: paths like output/lut/... are relative to project root. */
43static std::string resolveConfigPath(const std::string& config_path, const std::string& path) {
44 if (path.empty() || (path.size() > 0 && path[0] == '/'))
45 return path;
46 size_t last = config_path.rfind('/');
47 std::string config_dir = (last != std::string::npos) ? config_path.substr(0, last) : ".";
48 last = config_dir.rfind('/');
49 std::string project_root = (last != std::string::npos) ? config_dir.substr(0, last) : ".";
50 return project_root + "/" + path;
51}
52
53// ── Signal handling ────────────────────────────────────────────────────
54static std::atomic<bool> g_running{true};
55
56static void signalHandler(int /*sig*/) {
57 std::cout << "\n[controller_service] Caught signal, shutting down…" << std::endl;
58 g_running = false;
59}
60
61// ── TCP control server (FIRE_START / FIRE_STOP) ─────────────────────────
62// The sequencer connects, sends "FIRE_START\n" or "FIRE_STOP\n", reads the reply, disconnects.
63//
64// Structure mirrors sequencer_main.cpp's accept loop, for the reason documented there: this loop
65// must keep calling accept(). It used to read the command inline, byte at a time, with a blocking
66// recv() and no SO_RCVTIMEO — so a peer that connected and never sent a newline parked it
67// permanently. A half-open connection (the sequencer's host dropping off the network without a
68// FIN), a port scan, or an operator running `nc` against the port to check it was up all did it.
69// Worse, it was silent at both ends: the kernel completes the handshake and buffers the bytes, so
70// the sequencer's send() succeeded and it logged the command as delivered while nothing read it.
71// (See also SequencerService::notifyControllerFire, which now checks the ACK for that reason.)
72constexpr size_t kMaxControlClients = 64;
73
74// One connection on its own thread: read a line, dispatch, reply, close.
75static void handleControlClient(int client_fd, fsw::control::ControllerService* svc) {
76 // A silent peer can pin this thread, but never the accept loop, and only for 5 s.
77 struct timeval tv{.tv_sec = 5, .tv_usec = 0};
78 setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
79
80 std::string buf;
81 bool got_line = false;
82 while (g_running && buf.size() < 64) {
83 char tmp[64];
84 ssize_t n = ::recv(client_fd, tmp, sizeof(tmp), 0);
85 if (n <= 0)
86 break; // peer closed, timed out, or shutdown() woke us
87 buf.append(tmp, static_cast<size_t>(n));
88 const size_t nl = buf.find('\n');
89 if (nl != std::string::npos) {
90 buf.resize(nl);
91 got_line = true;
92 break;
93 }
94 }
95
96 auto reply = [client_fd](const char* s) {
97 ::send(client_fd, s, std::strlen(s), MSG_NOSIGNAL);
98 };
99
100 if (!got_line) {
101 // Timed out or the peer vanished mid-command. One line, at the point of failure — this
102 // port is reachable from the site LAN and a scanner must not be able to flood the log.
103 std::cerr << "[ControllerService] ⚠️ control connection closed with no command "
104 "(timeout or peer went away)"
105 << std::endl;
106 } else if (!svc->isFireGateEnabled() && (buf == "FIRE_START" || buf == "FIRE_STOP")) {
107 // Answer rather than silently doing nothing: a sequencer built before the ownership
108 // switch existed would otherwise think it had handed over the gate.
109 std::cerr << "[ControllerService] ⚠️ " << buf
110 << " refused — fire gate disabled (sequencer owns the valves)" << std::endl;
111 reply("ERR:fire gate disabled (sequencer owns the valves)\n");
112 } else if (buf == "FIRE_START") {
113 svc->setFireActive(true);
114 std::cout << "[ControllerService] 🔥 FIRE_START received — PWM gate open" << std::endl;
115 reply("OK\n");
116 } else if (buf == "FIRE_STOP") {
117 svc->setFireActive(false);
118 std::cout << "[ControllerService] 🛑 FIRE_STOP received — PWM gate closed" << std::endl;
119 reply("OK\n");
120 } else {
121 std::cerr << "[ControllerService] ⚠️ Unknown control cmd: \"" << buf << "\"" << std::endl;
122 reply("ERR\n");
123 }
124 ::close(client_fd);
125}
126
128 int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0);
129 if (listen_fd < 0) {
130 std::cerr << "[ControllerService] ❌ Control socket failed: " << strerror(errno)
131 << std::endl;
132 return;
133 }
134 int opt = 1;
135 setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
136
137 struct sockaddr_in addr{};
138 addr.sin_family = AF_INET;
139 addr.sin_addr.s_addr = INADDR_ANY;
140 addr.sin_port = htons(port);
141 if (bind(listen_fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) < 0) {
142 std::cerr << "[ControllerService] ❌ Control bind(" << port
143 << ") failed: " << strerror(errno) << std::endl;
144 ::close(listen_fd);
145 return;
146 }
147 ::listen(listen_fd, 4);
148 std::cout << "[ControllerService] 🎮 Control server on TCP :" << port
149 << " (FIRE_START | FIRE_STOP)" << std::endl;
150
151 // Threads are counted and joined rather than detached, so none outlives `svc`.
152 struct Conn {
153 std::thread th;
154 int fd{-1};
155 std::atomic<bool> done{false};
156 };
157 std::vector<std::unique_ptr<Conn>> conns;
158
159 auto reap_finished = [&conns]() {
160 for (auto it = conns.begin(); it != conns.end();) {
161 if ((*it)->done.load()) {
162 if ((*it)->th.joinable())
163 (*it)->th.join();
164 it = conns.erase(it);
165 } else {
166 ++it;
167 }
168 }
169 };
170
171 while (g_running) {
172 reap_finished();
173
174 struct timeval tv{0, 10000}; // 10 ms — keeps the g_running check cheap and responsive
175 fd_set fds;
176 FD_ZERO(&fds);
177 FD_SET(listen_fd, &fds);
178 if (select(listen_fd + 1, &fds, nullptr, nullptr, &tv) <= 0)
179 continue;
180
181 int client = ::accept(listen_fd, nullptr, nullptr);
182 if (client < 0)
183 continue;
184
185 // Reap again before testing the cap, so a burst of short-lived connections inside one
186 // select() iteration is not counted against the limit after those threads have finished.
187 reap_finished();
188 if (conns.size() >= kMaxControlClients) {
189 // Say why rather than closing cold — a bare close() reaches the client as a TCP
190 // reset, indistinguishable from the service having crashed.
191 static const char kBusy[] = "ERR:too many connections\n";
192 ::send(client, kBusy, sizeof(kBusy) - 1, MSG_NOSIGNAL);
193 std::cerr << "[ControllerService] ⚠️ client limit (" << kMaxControlClients
194 << ") reached — refusing connection" << std::endl;
195 ::close(client);
196 continue;
197 }
198
199 auto conn = std::make_unique<Conn>();
200 conn->fd = client;
201 Conn* raw = conn.get();
202 conn->th = std::thread([raw, svc]() {
203 handleControlClient(raw->fd, svc);
204 raw->done = true;
205 });
206 conns.push_back(std::move(conn));
207 }
208
209 // Wake anything parked in recv() so shutdown does not wait out the 5 s timeout, then join.
210 for (const auto& c : conns)
211 ::shutdown(c->fd, SHUT_RDWR);
212 for (const auto& c : conns)
213 if (c->th.joinable())
214 c->th.join();
215
216 ::close(listen_fd);
217}
218
219// ═══════════════════════════════════════════════════════════════════════
220// MAIN
221// ═══════════════════════════════════════════════════════════════════════
222
223int main(int argc, char* argv[]) {
224 // ── Parse CLI args ─────────────────────────────────────────────────
225 std::string config_path = "../../config/config.toml";
226 std::string elodin_host = ""; // empty = use config.toml [database].host
227 uint16_t elodin_port = 0; // 0 = use config.toml [database].port
228 uint16_t control_port = 0; // 0 = use config.toml [controller_service].port
229 double thrust_desired = 1000.0;
230 bool elodin_host_from_cli = false;
231 bool elodin_port_from_cli = false;
232
233 // Optional pressure targets in psi
234 bool use_pressure_control = false;
235 double p_fuel_target_psi = 0.0;
236 double p_ox_target_psi = 0.0;
237 std::string lut_path_cli;
238
239 for (int i = 1; i < argc; ++i) {
240 std::string arg = argv[i];
241 if (arg == "--config" && i + 1 < argc) {
242 config_path = argv[++i];
243 } else if (arg == "--elodin-host" && i + 1 < argc) {
244 elodin_host = argv[++i];
245 elodin_host_from_cli = true;
246 } else if (arg == "--elodin-port" && i + 1 < argc) {
247 elodin_port = static_cast<uint16_t>(std::atoi(argv[++i]));
248 elodin_port_from_cli = true;
249 } else if (arg == "--control-port" && i + 1 < argc) {
250 control_port = static_cast<uint16_t>(std::atoi(argv[++i]));
251 } else if (arg == "--thrust" && i + 1 < argc) {
252 thrust_desired = std::atof(argv[++i]);
253 } else if (arg == "--p-fuel" && i + 1 < argc) {
254 p_fuel_target_psi = std::atof(argv[++i]);
255 use_pressure_control = true;
256 } else if (arg == "--p-ox" && i + 1 < argc) {
257 p_ox_target_psi = std::atof(argv[++i]);
258 use_pressure_control = true;
259 } else if (arg == "--lut-path" && i + 1 < argc) {
260 lut_path_cli = argv[++i];
261 } else if (arg == "--help" || arg == "-h") {
262 std::cout
263 << "Usage: " << argv[0] << " [OPTIONS]\n"
264 << " --config PATH config.toml path (default: ../../config/config.toml)\n"
265 << " --elodin-host HOST Elodin DB host (empty = no DB)\n"
266 << " --elodin-port PORT Elodin DB port (default: 2240)\n"
267 << " --thrust N Thrust demand [N] (default: 1000)\n"
268 << " --p-fuel PSI Fuel tank pressure target [psi]\n"
269 << " --p-ox PSI Ox tank pressure target [psi]\n"
270 << " --lut-path PATH LUT binary for boolean control (bypasses DDP)\n";
271 return 0;
272 }
273 }
274
275 // ── Read config.toml ───────────────────────────────────────────────
276 std::string config_content;
277 {
278 std::ifstream f(config_path);
279 if (!f.is_open()) {
280 std::cerr << "❌ Cannot open config: " << config_path << std::endl;
281 // Try fallback paths
282 for (const auto& fallback :
283 {"config/config.toml", "../config/config.toml", "../../config/config.toml"}) {
284 f.open(fallback);
285 if (f.is_open()) {
286 config_path = fallback;
287 std::cout << "ℹ️ Using fallback config: " << fallback << std::endl;
288 break;
289 }
290 }
291 if (!f.is_open()) {
292 std::cerr << "❌ No config.toml found. Using defaults." << std::endl;
293 }
294 }
295 if (f.is_open()) {
296 std::ostringstream ss;
297 ss << f.rdbuf();
298 config_content = ss.str();
299 std::cout << "📋 Loaded config: " << config_path << std::endl;
300 }
301 }
302
303 const fsw::config::Config cfg = fsw::config::load(config_path);
304
305 // Populate the config-declared [[states]] in THIS process before any name→id resolution below.
306 // The controller is a separate process from the sequencer and never called this; without it,
307 // stateId() resolved the fire state against the compiled enum (Fire→16) while the sequencer
308 // publishes ids from config — so on a renumbered rig the two disagreed on what "16" means and
309 // the PWM ignition parity gate watched the wrong id.
311
312 // ── Extract settings from config ───────────────────────────────────
314
315 pwm.actuator_port = cfg.network.actuator_cmd_port;
316
317 // Resolve [controller].pwm_*_actuator → [actuator_roles] → [boards.*] IP. See PWMTargets.hpp
318 // for why there is nothing to fall back to here. `unresolved` collects the reasons; the fire
319 // gate is disabled below if it is non-empty.
320 const auto pwm_resolution = fsw::control::resolvePWMTargets(cfg);
321 const std::vector<std::string>& unresolved = pwm_resolution.issues;
322 pwm.fuel = pwm_resolution.fuel;
323 pwm.ox = pwm_resolution.ox;
324
325 // Which NIC PWM commands leave from. The controller shares the apps box with the Docker
326 // stack and the site LAN, so "the route to the actuator board" is no longer a single answer.
327 {
328 const auto nic = fsw::net::resolveDaqBindAddress(cfg, "controller");
329 if (!nic.ok)
330 return 1;
331 pwm.bind_address = nic.address;
332 }
333
334 // Controller loop / PWM settings from [controller].
335 pwm.frequency_hz = static_cast<float>(cfg.controller.pwm_frequency_hz);
336 pwm.duration_ms = cfg.controller.pwm_duration_ms;
337 double loop_hz = cfg.controller.controller_loop_hz;
338
339 // Precedence: defaults < config < CLI (--elodin-host/--elodin-port).
340 if (!elodin_host_from_cli)
341 elodin_host = cfg.database.host;
342 if (!elodin_port_from_cli)
343 elodin_port = cfg.database.port;
344 if (elodin_host.empty())
345 elodin_host = "127.0.0.1";
346 if (elodin_port == 0)
347 elodin_port = 2240;
348
349 // Control port from [controller_service].port (FIRE_START / FIRE_STOP TCP gate); --control-port
350 // wins.
351 if (control_port == 0)
352 control_port = cfg.controller_service.port;
353
354 // Controller algorithm config (using defaults from RobustDDPController.hpp)
356 // Override safety constraint from config (0 = disabled, useful for simulation)
357 {
358 double pmin = cfg.controller.P_copv_min_pa;
359 ctrl_cfg.P_copv_min = pmin; // 0 disables the check; real hotfire sets >0
360 if (pmin == 0.0)
361 std::cout << " P_copv_min: disabled (0)" << std::endl;
362 else
363 std::cout << " P_copv_min: " << pmin << " Pa (" << (pmin / 6894.76) << " psi)"
364 << std::endl;
365 }
366
367 std::cout << "\n═══════════════════════════════════════════════════════════" << std::endl;
368 std::cout << " Robust DDP Controller Service" << std::endl;
369 std::cout << "═══════════════════════════════════════════════════════════" << std::endl;
370 // Name the actuator as well as the wire address, so an operator can tell at a glance which
371 // valve this process believes it is driving.
372 auto describe_target = [](const fsw::control::ControllerService::PWMTarget& t) {
373 if (!t.resolved())
374 return std::string("(unassigned)");
375 return "\"" + t.actuator_name + "\" → " + t.board_ip + " CH" +
376 std::to_string((int)t.channel);
377 };
378 std::cout << " Actuator port: " << pwm.actuator_port << std::endl;
379 std::cout << " PWM fuel: " << describe_target(pwm.fuel) << std::endl;
380 std::cout << " PWM ox: " << describe_target(pwm.ox) << std::endl;
381 std::cout << " PWM frequency: " << pwm.frequency_hz << " Hz" << std::endl;
382 std::cout << " PWM duration: " << pwm.duration_ms << " ms" << std::endl;
383 std::cout << " Control loop: " << loop_hz << " Hz" << std::endl;
384 std::cout << " Thrust demand: " << thrust_desired << " N" << std::endl;
385 if (use_pressure_control) {
386 std::cout << " Control Mode: PRESSURE TARGET" << std::endl;
387 std::cout << " Fuel Target: " << p_fuel_target_psi << " psi" << std::endl;
388 std::cout << " Ox Target: " << p_ox_target_psi << " psi" << std::endl;
389 } else {
390 std::cout << " Control Mode: THRUST DESIRED" << std::endl;
391 }
392 std::cout << " Elodin DB: " << (elodin_host.empty() ? "(disabled)" : elodin_host)
393 << std::endl;
394 std::cout << "═══════════════════════════════════════════════════════════\n" << std::endl;
395
396 // ── Initialize ─────────────────────────────────────────────────────
398
399 // Which sequencer state id means "firing", for the parity fallback that watches the sequencer
400 // state packet. Config, not a literal — see ControllerService::setFireStateId.
401 {
402 const std::string fire_state = cfg.fire.state;
403 if (fire_state.empty()) {
404 // No fire state configured → the PWM fire gate never activates (id 255 = UNKNOWN never
405 // matches a real sequencer state), matching the sequencer's disabled fire timer.
406 service.setFireStateId(255);
407 std::cout << " Fire state: (none) — PWM fire gate disabled" << std::endl;
408 } else {
409 const uint8_t id = sequencer::StateMachine::stateId(fire_state);
410 if (id != 255) {
411 service.setFireStateId(id);
412 std::cout << " Fire state: " << fire_state << " (id " << static_cast<int>(id)
413 << ")" << std::endl;
414 } else {
415 // Config declares states but not this name: disable the gate (255 never matches a
416 // real sequencer state) rather than leaving it on a compiled id, matching the
417 // sequencer's disabled fire timer. Fail safe and loud, not silent-wrong.
418 service.setFireStateId(255);
419 std::cerr << " ⚠️ [fire] state \"" << fire_state
420 << "\" is not a declared state — PWM fire gate DISABLED" << std::endl;
421 }
422 }
423 }
424
425 // Who drives the valves during the burn. When the sequencer owns them this service must not
426 // touch them at all: disabling the gate stops BOTH activation paths at once, since the TCP
427 // FIRE_START handler and the [0x50] parity fallback both funnel through setFireActive(). Also
428 // clear the parity id, so the state packet cannot arm anything even if the gate is later
429 // re-enabled by some future path.
430 if (cfg.controller_service.sequencer_owns_valves) {
431 service.setFireGateEnabled(false);
432 service.setFireStateId(255);
433 std::cout << " Valve owner: sequencer — PWM gate DISABLED, FIRE_START will be refused"
434 << std::endl;
435 } else if (!unresolved.empty()) {
436 // Same posture for the PWM targets: config did not say which hardware to drive, so drive
437 // none. 255 never matches a real sequencer state, so the gate cannot open by either the TCP
438 // command or the Elodin parity path. The service still starts — it is also the DDP /
439 // diagnostics service and publishes to Elodin, and a rig that legitimately has no press
440 // valves (digital-twin) would otherwise lose all of that to an exit(1).
441 service.setFireGateEnabled(false);
442 service.setFireStateId(255);
443 for (const auto& why : unresolved)
444 std::cerr << " ⚠️ [controller] " << why << std::endl;
445 std::cerr << " ⚠️ [controller] PWM fire gate DISABLED. Assign exactly one "
446 "[actuator_roles] entry \"pwm_fuel\" and one \"pwm_ox\" via the optional "
447 "4th element, e.g. \"Fuel Press\" = [\"NC\", 3, 12, \"pwm_fuel\"]."
448 << std::endl;
449 } else {
450 std::cout << " Valve owner: controller_service (PWM gate armed)" << std::endl;
451 }
452
453 std::string lut_path_raw = !lut_path_cli.empty() ? lut_path_cli : cfg.controller.lut_path;
454 std::string thrust_curve_path_raw = cfg.controller.thrust_curve_path;
455 std::string lut_path = resolveConfigPath(config_path, lut_path_raw);
456 std::string thrust_curve_path = resolveConfigPath(config_path, thrust_curve_path_raw);
457 if (!lut_path.empty())
458 std::cout << " LUT path: " << lut_path << " (boolean control)" << std::endl;
459 if (!thrust_curve_path.empty())
460 std::cout << " Thrust curve: " << thrust_curve_path << std::endl;
461
462 if (!service.initialize(pwm, ctrl_cfg, elodin_host, elodin_port, lut_path, thrust_curve_path)) {
463 std::cerr << "❌ Failed to initialize controller service" << std::endl;
464 return 1;
465 }
466
467 // ── Set default command ────────────────────────────────────────────
469 if (use_pressure_control) {
471 cmd.thrust_desired = 0.0;
472 cmd.P_fuel_target = p_fuel_target_psi * 6894.76; // psi -> Pa
473 cmd.P_ox_target = p_ox_target_psi * 6894.76; // psi -> Pa
474 } else {
476 cmd.thrust_desired = thrust_desired;
477 }
478 service.setCommand(cmd);
479
480 // ── Optional open-loop test duty (fallback_fuel/ox_duty_cycle from config) ──
481 // When non-zero this bypasses the DDP controller so you can validate UDP PWM delivery.
482 {
483 float td_f = static_cast<float>(cfg.controller.fallback_fuel_duty_cycle);
484 float td_o = static_cast<float>(cfg.controller.fallback_ox_duty_cycle);
485 if (td_f > 0.0f || td_o > 0.0f)
486 service.setTestDuty(td_f, td_o);
487 }
488
489 // ── Install signal handlers ────────────────────────────────────────
490 std::signal(SIGINT, signalHandler);
491 std::signal(SIGTERM, signalHandler);
492
493 // ── Start ──────────────────────────────────────────────────────────
494 if (!service.start(loop_hz)) {
495 std::cerr << "❌ Failed to start controller loop" << std::endl;
496 return 1;
497 }
498
499 // ── Start TCP control server (FIRE_START / FIRE_STOP gate) ────────────
500 std::thread control_thread(runControlServer, &service, control_port);
501 control_thread.detach();
502
503 std::cout << "\n🎯 Controller running. PWM gated to FIRE state (TCP :" << control_port << ").\n"
504 << std::endl;
505
506 // ── Wait for shutdown ──────────────────────────────────────────────
507 while (g_running && service.is_running()) {
508 std::this_thread::sleep_for(std::chrono::milliseconds(100));
509 }
510
511 service.stop();
512 std::cout << "✅ Controller service stopped." << std::endl;
513 return 0;
514}
Resolve which hardware the controller PWMs, from config alone.
static void signalHandler(int)
C++ controller service with UDP PWM output.
void stop()
Stop the controller loop.
void setFireStateId(uint8_t id)
Sequencer state id that means "firing", for the parity fallback that sniffs the sequencer state packe...
void setFireActive(bool active)
Enable or disable PWM output (FIRE state gate).
void setFireGateEnabled(bool enabled)
Master switch for the PWM ignition gate.
bool start(double loop_rate_hz=10.0)
Start the controller loop at the given rate.
void setCommand(const RobustDDPController::Command &cmd)
void setTestDuty(float fuel, float ox)
Override controller output with fixed duty cycles for open-loop validation.
bool initialize(const PWMConfig &pwm_config, const RobustDDPController::Config &controller_config, const std::string &elodin_host="", uint16_t elodin_port=2240, const std::string &lut_path="", const std::string &thrust_curve_path="")
Initialize the controller service.
TOML_PURE_INLINE_GETTER size_t size() const noexcept
Definition toml.hpp:3220
TOML_PURE_INLINE_GETTER bool empty() const noexcept
Definition toml.hpp:3232
static uint8_t stateId(const std::string &name)
Numeric id for a state name, or 255 if unknown.
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 void handleControlClient(int client_fd, fsw::control::ControllerService *svc)
static void runControlServer(fsw::control::ControllerService *svc, uint16_t port)
static std::atomic< bool > g_running
static std::string resolveConfigPath(const std::string &config_path, const std::string &path)
Resolve path relative to config: paths like output/lut/... are relative to project root.
constexpr size_t kMaxControlClients
static void signalHandler(int)
static const emmc_port_t port
Definition emmc_stm32.c:210
static void send(void *ctx, const uint8_t *bytes, uint16_t len)
Definition gnss_task.c:27
Config load(const std::string &path)
Parse config.toml.
Definition Config.cpp:442
PWMResolution resolvePWMTargets(const fsw::config::Config &cfg)
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 self_test_reply_t reply(self_test_result_t result, int32_t diagnostic)
The whole parsed config.toml, restricted to what C++ consumes.
Definition Config.hpp:264
std::string bind_address
Local address PWM commands leave from; "0.0.0.0" leaves the NIC to the kernel.
Where one propellant's PWM commands go.
int main()