STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
heartbeat_service_main.cpp
Go to the documentation of this file.
1/**
2 * Heartbeat Service — C++ SERVER_HEARTBEAT broadcaster.
3 *
4 * Subscribes to Elodin [0x5000] (SequencerState) for current state, maps it to
5 * the legacy EngineState enum (0-4), and broadcasts SERVER_HEARTBEAT (type 2)
6 * to all boards on the subnet every 1s.
7 *
8 * Packet: type(1)=2, version(1)=0, timestamp_ms(4 LE), engine_state(1)
9 *
10 * Usage: ./heartbeat_service [--config PATH] [--elodin-host HOST] [--elodin-port PORT]
11 * [--broadcast-ip IP] [--broadcast-port PORT] [--interval-ms MS]
12 */
13
14#include <arpa/inet.h>
15#include <netinet/in.h>
16#include <signal.h>
17#include <sys/socket.h>
18#include <unistd.h>
19
20#include <atomic>
21#include <chrono>
22#include <cstring>
23#include <fstream>
24#include <iostream>
25#include <optional>
26#include <sstream>
27#include <string>
28#include <thread>
29#include <vector>
30
31#include "config/Config.hpp"
34#include "net/DaqInterface.hpp"
35
36namespace {
37std::atomic<bool> g_running{true};
38std::atomic<uint8_t> g_engine_state{0};
39
40// How long read_packet() waits before yielding so the loop can re-check g_running.
41constexpr int kElodinRecvTimeoutMs = 3000;
42
43void signalHandler(int /*sig*/) {
44 std::cout << "\n[HeartbeatService] Shutting down..." << std::endl;
45 g_running = false;
46}
47
48constexpr uint8_t SERVER_HEARTBEAT_TYPE = 2;
49constexpr uint8_t DIABLO_VERSION = 0;
50
51// ── sequencer::State → EngineState (DiabloEnums.h) ───────────────────────────
52// EngineState: SAFE=0, PRESSURIZING=1, LOX_FILL=2, FIRING=3, POST_FIRE=4
53// sequencer::State enum values (StateMachine.hpp):
54// DEBUG=0, IDLE=1, ARMED=2, FUEL_FILL=3, OX_FILL=4, GN2_LOW_PRESS=5,
55// GN2_VENT=6, FUEL_PRESS=7, FUEL_VENT=8, OX_PRESS=9, OX_VENT=10,
56// GN2_HIGH_PRESS=11, GN2_HIGH_VENT=12, VENT=13, CALIBRATE=14,
57// READY=15, FIRE=16, ENGINE_ABORT=17, GSE_ABORT=18, EMERGENCY_ABORT=19,
58// PRESS_STANDBY=20
59static constexpr uint8_t stateToEngine(uint8_t s) {
60 switch (s) {
61 case 3:
62 return 1; // FUEL_FILL → PRESSURIZING
63 case 4:
64 return 2; // OX_FILL → LOX_FILL
65 case 5:
66 return 1; // GN2_LOW_PRESS → PRESSURIZING
67 case 6:
68 return 1; // GN2_VENT → PRESSURIZING
69 case 7:
70 return 1; // FUEL_PRESS → PRESSURIZING
71 case 8:
72 return 1; // FUEL_VENT → PRESSURIZING
73 case 9:
74 return 1; // OX_PRESS → PRESSURIZING
75 case 10:
76 return 1; // OX_VENT → PRESSURIZING
77 case 11:
78 return 1; // GN2_HIGH_PRESS → PRESSURIZING
79 case 12:
80 return 1; // GN2_HIGH_VENT → PRESSURIZING
81 case 13:
82 return 1; // VENT → PRESSURIZING
83 case 15:
84 return 1; // READY → PRESSURIZING
85 case 16:
86 return 3; // FIRE → FIRING
87 case 20:
88 return 1; // PRESS_STANDBY → PRESSURIZING
89 default:
90 return 0; // DEBUG, IDLE, ARMED, CALIBRATE, ABORT states → SAFE
91 }
92}
93
94// ── Elodin subscriber thread ──────────────────────────────────────────────────
95// Subscribes to [0x50,0x00] (SequencerState) — the only table this service reads — extracts
96// current_state and updates g_engine_state.
97//
98// This used to call subscribe_stream(), whose name and doc comment promised "all stream data" but
99// which actually sent the calibration service's list: 480 raw sensor tables plus the calibration
100// command table, and not [0x50,0x00]. The effect was silent and total — 481 ACKs for tables this
101// thread discards, then a permanent block in read_packet() waiting for a table nobody was sending,
102// with engine_state pinned at 0 through every state transition for the life of the process.
103void elodinThread(std::string host, uint16_t port) {
105
106 while (g_running) {
107 if (!client.is_connected()) {
108 if (!client.connect(host, port)) {
109 std::this_thread::sleep_for(std::chrono::seconds(2));
110 continue;
111 }
112 if (!client.subscribe_tables({fsw::elodin::kTableSequencerState})) {
113 std::cerr << "[HeartbeatService] Failed to subscribe to SequencerState "
114 "[0x50,0x00] — engine_state will not track the sequencer"
115 << std::endl;
116 client.disconnect();
117 continue;
118 }
119 // Without a receive timeout read_packet() blocks forever, so the g_running check
120 // above can never run and the thread cannot be shut down or re-subscribed.
121 client.set_recv_timeout_ms(kElodinRecvTimeoutMs);
122 std::cout << "[HeartbeatService] Elodin connected, subscribed to SequencerState"
123 << std::endl;
124 }
125
126 // Only [0x50,0x00] is subscribed, so the traffic here is one ~149-byte VTable definition
127 // and 25-byte state updates. Sized well clear of both: read_packet() skips a packet that
128 // does not fit rather than corrupting the stream, but a skipped state update is a stale
129 // engine_state until the next one.
130 uint8_t buf[4096];
131 ssize_t n = client.read_packet(buf, sizeof(buf));
132 if (n < 0) {
133 // A read error leaves nothing useful on this socket. Drop it explicitly rather than
134 // trusting is_connected(), which the read path does not always clear.
135 std::cerr << "[HeartbeatService] Elodin read failed (" << client.last_error()
136 << ") — reconnecting" << std::endl;
137 client.disconnect();
138 continue;
139 }
140 if (n < 8)
141 continue; // 0 = receive timeout, nothing to do this round
142
143 // SequencerState VTable: [0x50, 0x00] (fsw::elodin::kTableSequencerState)
144 // Payload: u64[0] ts | u8[8] current_state | pad[9..11] | u32[12] bitmask | u8[16]
145 // debug_mode
146 if (buf[5] == fsw::elodin::kTableSequencerState.first &&
147 buf[6] == fsw::elodin::kTableSequencerState.second && n >= 8 + 9) {
148 const uint8_t seq_state = buf[8 + 8]; // header(8) + payload[8]
149 g_engine_state.store(stateToEngine(seq_state));
150 }
151 }
152}
153
154// ─────────────────────────────────────────────────────────────────────────────
155
156std::vector<uint8_t> buildHeartbeatPacket(uint8_t engine_state) {
157 auto now = std::chrono::system_clock::now();
158 auto ms =
159 std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()).count() &
160 0xFFFFFFFFu;
161 return {
164 static_cast<uint8_t>(ms >> 0),
165 static_cast<uint8_t>(ms >> 8),
166 static_cast<uint8_t>(ms >> 16),
167 static_cast<uint8_t>(ms >> 24),
168 engine_state,
169 };
170}
171
172} // namespace
173
174int main(int argc, char* argv[]) {
175 std::string config_path = "config/config.toml";
176 std::optional<std::string> cli_elodin_host, cli_broadcast_ip;
177 std::optional<int> cli_elodin_port, cli_interval_ms, cli_broadcast_port;
178
179 for (int i = 1; i < argc; ++i) {
180 std::string arg = argv[i];
181 if (arg == "--config" && i + 1 < argc) {
182 config_path = argv[++i];
183 } else if (arg == "--elodin-host" && i + 1 < argc) {
184 cli_elodin_host = argv[++i];
185 } else if (arg == "--elodin-port" && i + 1 < argc) {
186 cli_elodin_port = std::atoi(argv[++i]);
187 } else if (arg == "--interval-ms" && i + 1 < argc) {
188 cli_interval_ms = std::atoi(argv[++i]);
189 } else if (arg == "--broadcast-ip" && i + 1 < argc) {
190 cli_broadcast_ip = argv[++i];
191 } else if (arg == "--broadcast-port" && i + 1 < argc) {
192 cli_broadcast_port = std::atoi(argv[++i]);
193 } else if (arg == "--help" || arg == "-h") {
194 std::cout << "Usage: " << argv[0]
195 << " [--config PATH] [--elodin-host HOST] [--elodin-port PORT]\n"
196 " [--interval-ms MS] [--broadcast-ip IP] [--broadcast-port PORT]\n";
197 return 0;
198 }
199 }
200
201 // Resolve the config path against the usual fallback locations, then parse once.
202 {
203 std::ifstream f(config_path);
204 if (!f.is_open()) {
205 for (const auto& fp : {"config/config.toml", "../config/config.toml"}) {
206 std::ifstream t(fp);
207 if (t.is_open()) {
208 config_path = fp;
209 break;
210 }
211 }
212 }
213 }
214
215 // Precedence: defaults < config file < CLI flags. (broadcast_ip/port fall back to
216 // [server_heartbeat] inside the parser.)
217 const fsw::config::Config cfg = fsw::config::load(config_path);
218 std::string elodin_host = cli_elodin_host.value_or(cfg.heartbeat_service.elodin_host);
219 uint16_t elodin_port = cli_elodin_port ? static_cast<uint16_t>(*cli_elodin_port)
220 : cfg.heartbeat_service.elodin_port;
221 int interval_ms =
222 std::max(100, cli_interval_ms ? *cli_interval_ms
223 : static_cast<int>(cfg.heartbeat_service.interval_ms));
224 std::string broadcast_ip = cli_broadcast_ip.value_or(cfg.heartbeat_service.broadcast_ip);
225 uint16_t broadcast_port = cli_broadcast_port ? static_cast<uint16_t>(*cli_broadcast_port)
226 : cfg.heartbeat_service.broadcast_port;
227
228 signal(SIGINT, signalHandler);
229 signal(SIGTERM, signalHandler);
230
231 // UDP broadcast socket
232 int sock = socket(AF_INET, SOCK_DGRAM, 0);
233 if (sock < 0) {
234 std::cerr << "[HeartbeatService] socket() failed" << std::endl;
235 return 1;
236 }
237 {
238 int opt = 1;
239 setsockopt(sock, SOL_SOCKET, SO_BROADCAST, &opt, sizeof(opt));
240 }
241
242 // Pin the egress NIC. The destination is already subnet-directed, so this is belt-and-braces
243 // against a host where the board subnet is reachable more than one way — but it also fixes
244 // the source address the boards see. Not fatal: a heartbeat on an unknown interface beats
245 // none.
246 {
247 const auto nic = fsw::net::resolveDaqBindAddress(cfg, "HeartbeatService");
248 if (!nic.ok) {
249 close(sock);
250 return 1;
251 }
252 fsw::net::bindToDaqInterface(sock, nic.address, "HeartbeatService");
253 }
254
255 struct sockaddr_in dest;
256 memset(&dest, 0, sizeof(dest));
257 dest.sin_family = AF_INET;
258 dest.sin_port = htons(broadcast_port);
259 if (inet_pton(AF_INET, broadcast_ip.c_str(), &dest.sin_addr) != 1) {
260 std::cerr << "[HeartbeatService] Invalid broadcast IP: " << broadcast_ip << std::endl;
261 close(sock);
262 return 1;
263 }
264
265 std::cout << "[HeartbeatService] Started — interval=" << interval_ms
266 << "ms broadcast=" << broadcast_ip << ":" << broadcast_port << std::endl;
267 std::cout << "[HeartbeatService] State from Elodin at " << elodin_host << ":" << elodin_port
268 << " [0x5000]" << std::endl;
269
270 // Start Elodin subscriber thread
271 std::thread elodin_thread(elodinThread, elodin_host, elodin_port);
272
273 unsigned long count = 0;
274 auto last_log = std::chrono::steady_clock::now();
275
276 while (g_running) {
277 uint8_t engine_state = g_engine_state.load();
278 auto pkt = buildHeartbeatPacket(engine_state);
279 ssize_t sent = sendto(sock, pkt.data(), pkt.size(), 0,
280 reinterpret_cast<struct sockaddr*>(&dest), sizeof(dest));
281 if (sent == static_cast<ssize_t>(pkt.size()))
282 count++;
283
284 auto now = std::chrono::steady_clock::now();
285 if (std::chrono::duration<double>(now - last_log).count() >= 10.0) {
286 std::cout << "[HeartbeatService] Sent " << count
287 << " heartbeats (engine_state=" << (int)engine_state << ")" << std::endl;
288 last_log = now;
289 }
290
291 for (int i = 0; g_running && i < interval_ms; i += 100)
292 usleep(100000);
293 }
294
295 close(sock);
296 elodin_thread.join();
297 std::cout << "[HeartbeatService] Stopped." << std::endl;
298 return 0;
299}
static void signalHandler(int)
Standalone Elodin database client.
static std::atomic< bool > g_running
static const emmc_port_t port
Definition emmc_stm32.c:210
size_type count(const key_type &key) const
Definition json.hpp:19264
void elodinThread(std::string host, uint16_t port)
std::vector< uint8_t > buildHeartbeatPacket(uint8_t engine_state)
Config load(const std::string &path)
Parse config.toml.
Definition Config.cpp:442
constexpr std::pair< uint8_t, uint8_t > kTableSequencerState
Non-sensor tables, named rather than spelled as literals at each use site.
bool bindToDaqInterface(int sock, const std::string &address, const char *who)
bind() an already-created socket to address on an ephemeral port.
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
The whole parsed config.toml, restricted to what C++ consumes.
Definition Config.hpp:264
int main()