diff --git a/daq-server/config/config.toml b/daq-server/config/config.toml
index 9dd88ead7..d1b190d9d 100644
--- a/daq-server/config/config.toml
+++ b/daq-server/config/config.toml
@@ -37,6 +37,17 @@ interval_ms = 200 # Same as server_heartbeat
broadcast_ip = "192.168.2.255"
broadcast_port = 5005
+# =============================================================================
+# Stacklight Service - STACKLIGHT_COMMAND to stacklight board (C++)
+# =============================================================================
+[stacklight_service]
+enabled = true
+interval_ms = 1000 # Keepalive resend interval (state changes send immediately)
+target_ip = "192.168.2.70" # Should match boards.stacklight_board.ip
+target_port = 5005 # Should match boards.stacklight_board.listen_port
+elodin_host = "127.0.0.1"
+elodin_port = 2240
+
# =============================================================================
# Config Broadcast Service - ACTUATOR_CONFIG / SENSOR_CONFIG to boards (C++ preferred for flight)
# =============================================================================
@@ -339,6 +350,13 @@ necessary_for_abort = false
designated_survivor = false
voltage_reference = 1
active_connectors = [1, 2]
+[boards.stacklight_board]
+type = "STACKLIGHT"
+ip = "192.168.2.70" # PLACEHOLDER — update once team assigns a real IP
+listen_port = 5005 # Board listens for STACKLIGHT_COMMAND here
+board_id = 70 # PLACEHOLDER — update once team confirms
+enabled = true
+enable_serial_printing = false
# =============================================================================
# Multiple Boards Example (commented out - uncomment to use)
diff --git a/daq-server/diablo_server/frontend/app/status/page.tsx b/daq-server/diablo_server/frontend/app/status/page.tsx
index dae19c49f..312df3ba3 100644
--- a/daq-server/diablo_server/frontend/app/status/page.tsx
+++ b/daq-server/diablo_server/frontend/app/status/page.tsx
@@ -26,6 +26,45 @@ const HP_PT_SENSORS = PRESSURE_SENSORS.filter((s) =>
['PT_Cal.GSE_Mid', 'PT_Cal.GSE_High', 'PT_Cal.GN2_High'].includes(s.entity)
).map(({ label, entity, color }) => ({ label, entity, color }));
+interface StacklightState {
+ red: boolean;
+ yellow: boolean;
+ green: boolean;
+ buzzer: boolean;
+}
+
+/**
+ * Mirrors stateToStacklight() in
+ * daq-server/diablo_server/services/stacklight/stacklight_service_main.cpp
+ * Keep these two in sync manually — no shared source of truth yet.
+ */
+function sequencerStateToStacklight(state: number | null): StacklightState {
+ const off: StacklightState = { red: false, yellow: false, green: false, buzzer: false };
+ if (state === null) return off;
+
+ switch (state) {
+ case 1: // IDLE
+ return { ...off, green: true };
+ case 0: // DEBUG
+ case 14: // CALIBRATE
+ case 6: case 8: case 10: case 12: case 13: // VENT states
+ return { ...off, yellow: true };
+ case 2: // ARMED
+ case 15: // READY
+ case 20: // PRESS_STANDBY
+ return { ...off, red: true, yellow: true };
+ case 3: case 4: // FUEL_FILL, OX_FILL
+ case 5: case 7: case 9: case 11: // PRESS states
+ return { ...off, red: true };
+ case 16: // FIRE
+ return { ...off, red: true, buzzer: true };
+ case 17: case 18: case 19: // ABORT states
+ return { ...off, red: true, buzzer: true };
+ default: // UNKNOWN
+ return { red: true, yellow: true, green: true, buzzer: true };
+ }
+}
+
function fmtValue(v: number | null): string {
if (v === null || !isFinite(v)) return '---';
const abs = Math.abs(v);
@@ -69,7 +108,7 @@ export default function StatusPage() {
-
+
{/* Pressure Sensors */}
Pressure Sensors
@@ -194,7 +233,37 @@ export default function StatusPage() {
)}
-
+
+ {/* Stacklight */}
+
+
Stacklight
+ {(() => {
+ const light = sequencerStateToStacklight(currentState);
+ const dot = (on: boolean, color: string) =>
+ `w-8 h-8 rounded-full border-2 border-gray-700 ${on ? color : 'bg-gray-900'}`;
+ return (
+
+ );
+ })()}
+
+
{/* High Pressure PT Sensors Section */}
diff --git a/daq-server/diablo_server/frontend/package-lock.json b/daq-server/diablo_server/frontend/package-lock.json
index e96513731..6cd528872 100644
--- a/daq-server/diablo_server/frontend/package-lock.json
+++ b/daq-server/diablo_server/frontend/package-lock.json
@@ -3502,6 +3502,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
diff --git a/daq-server/diablo_server/services/CMakeLists.txt b/daq-server/diablo_server/services/CMakeLists.txt
index 7f0531734..1a8874e86 100644
--- a/daq-server/diablo_server/services/CMakeLists.txt
+++ b/daq-server/diablo_server/services/CMakeLists.txt
@@ -48,6 +48,14 @@ target_link_libraries(heartbeat_service
fsw_daq_lib daq_comms_lib Threads::Threads pthread ${RT_LIBRARY}
)
+# ── Stacklight Service ────────────────────────────────────────────────────────
+# STACKLIGHT_COMMAND UDP unicast; reads sequencer state from Elodin [0x5000]
+add_executable(stacklight_service stacklight/stacklight_service_main.cpp)
+target_include_directories(stacklight_service PRIVATE ${COMMON_INCLUDES})
+target_link_libraries(stacklight_service
+ fsw_daq_lib daq_comms_lib daqv2_comms Threads::Threads pthread ${RT_LIBRARY}
+)
+
# ── Config Broadcast Service ──────────────────────────────────────────────────
# ACTUATOR_CONFIG / SENSOR_CONFIG UDP broadcast
add_executable(config_broadcast_service config_broadcast/config_broadcast_service_main.cpp)
@@ -86,5 +94,6 @@ endif()
install(TARGETS
sequencer_service controller_service calibration_service
heartbeat_service config_broadcast_service ota_service data_logger_service
+ stacklight_service
RUNTIME DESTINATION bin
)
diff --git a/daq-server/diablo_server/services/stacklight/stacklight_service_main.cpp b/daq-server/diablo_server/services/stacklight/stacklight_service_main.cpp
new file mode 100644
index 000000000..7e5edc287
--- /dev/null
+++ b/daq-server/diablo_server/services/stacklight/stacklight_service_main.cpp
@@ -0,0 +1,294 @@
+/**
+ * Stacklight Service — C++ STACKLIGHT_COMMAND sender.
+ *
+ * Subscribes to Elodin [0x5000] (SequencerState) for current state, maps it to
+ * a StacklightCommandPacket (red/yellow/green/buzzer), and sends it via UDP
+ * unicast to the stacklight board whenever the state changes (plus a periodic
+ * keepalive resend).
+ */
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "elodin/ElodinClient.hpp"
+#include "DAQv2-Comms.h"
+#include "control/StateMachine.hpp"
+
+namespace {
+std::atomic g_running{true};
+std::atomic g_seq_state{static_cast(sequencer::State::UNKNOWN)};
+
+void signalHandler(int /*sig*/) {
+ std::cout << "\n[StacklightService] Shutting down..." << std::endl;
+ g_running = false;
+}
+
+// ── sequencer::State → StacklightCommandPacket ──────────────────────────────
+// Standard industrial convention: red = danger, yellow = caution,
+// green = safe, buzzer = most urgent states (fire, abort).
+Diablo::StacklightCommandPacket stateToStacklight(uint8_t s) {
+ using sequencer::State;
+ Diablo::StacklightCommandPacket cmd{0, 0, 0, 0};
+
+ switch (static_cast(s)) {
+ case State::IDLE:
+ cmd.green = 1;
+ break;
+ case State::DEBUG:
+ case State::CALIBRATE:
+ case State::GN2_VENT:
+ case State::FUEL_VENT:
+ case State::OX_VENT:
+ case State::GN2_HIGH_VENT:
+ case State::VENT:
+ cmd.yellow = 1;
+ break;
+ case State::ARMED:
+ case State::READY:
+ case State::PRESS_STANDBY:
+ cmd.red = 1;
+ cmd.yellow = 1;
+ break;
+ case State::FUEL_FILL:
+ case State::OX_FILL:
+ case State::GN2_LOW_PRESS:
+ case State::FUEL_PRESS:
+ case State::OX_PRESS:
+ case State::GN2_HIGH_PRESS:
+ cmd.red = 1;
+ break;
+ case State::FIRE:
+ cmd.red = 1;
+ cmd.buzzer = 1;
+ break;
+ case State::ENGINE_ABORT:
+ case State::GSE_ABORT:
+ case State::EMERGENCY_ABORT:
+ cmd.red = 1;
+ cmd.buzzer = 1;
+ break;
+ case State::UNKNOWN:
+ default:
+ cmd.red = 1;
+ cmd.yellow = 1;
+ cmd.green = 1;
+ cmd.buzzer = 1;
+ break;
+ }
+ return cmd;
+}
+
+// ── Elodin subscriber thread ────────────────────────────────────────────────
+void elodinThread(std::string host, uint16_t port) {
+ fsw::elodin::ElodinClient client;
+
+ while (g_running) {
+ if (!client.is_connected()) {
+ if (!client.connect(host, port)) {
+ std::this_thread::sleep_for(std::chrono::seconds(2));
+ continue;
+ }
+ client.subscribe_stream();
+ std::cout << "[StacklightService] Elodin connected, subscribed" << std::endl;
+ }
+
+ uint8_t buf[256];
+ ssize_t n = client.read_packet(buf, sizeof(buf));
+ if (n < 0) continue; // reconnect next loop
+
+ if (n < 8) continue;
+
+ // SequencerState VTable: [0x50, 0x00]
+ if (buf[5] == 0x50 && buf[6] == 0x00 && n >= 8 + 9) {
+ const uint8_t seq_state = buf[8 + 8];
+ g_seq_state.store(seq_state);
+ }
+ }
+}
+
+}
+
+// ── Minimal TOML reader (mirrors heartbeat_service_main.cpp) ────────────────
+std::string trim(const std::string& s) {
+ size_t a = s.find_first_not_of(" \t\r\n\"");
+ size_t b = s.find_last_not_of(" \t\r\n\"");
+ return (a == std::string::npos) ? "" : s.substr(a, b - a + 1);
+}
+
+std::string getTomlValue(const std::string& content, const std::string& section,
+ const std::string& key, const std::string& fallback = "") {
+ std::string sec_header = "[" + section + "]";
+ auto sec_pos = content.find(sec_header);
+ if (sec_pos == std::string::npos) return fallback;
+
+ auto search_start = sec_pos + sec_header.size();
+ auto next_sec = content.find("\n[", search_start);
+ std::string sec_content = (next_sec == std::string::npos)
+ ? content.substr(search_start)
+ : content.substr(search_start, next_sec - search_start);
+
+ std::istringstream iss(sec_content);
+ std::string line;
+ while (std::getline(iss, line)) {
+ auto c = line.find('#');
+ if (c != std::string::npos) line = line.substr(0, c);
+ auto eq = line.find('=');
+ if (eq == std::string::npos) continue;
+ std::string k = trim(line.substr(0, eq));
+ std::string v = trim(line.substr(eq + 1));
+ if (k == key) return v;
+ }
+ return fallback;
+}
+
+int main(int argc, char* argv[]) {
+ std::string config_path = "config/config.toml";
+ std::string elodin_host = "127.0.0.1";
+ uint16_t elodin_port = 2240;
+ int interval_ms = 1000; // keepalive resend interval
+ std::string target_ip = "192.168.2.70"; // PLACEHOLDER — update once team assigns a real IP
+ uint16_t target_port = 5006; // PLACEHOLDER — update once team confirms
+
+ for (int i = 1; i < argc; ++i) {
+ std::string arg = argv[i];
+ if (arg == "--config" && i + 1 < argc) {
+ config_path = argv[++i];
+ } else if (arg == "--elodin-host" && i + 1 < argc) {
+ elodin_host = argv[++i];
+ } else if (arg == "--elodin-port" && i + 1 < argc) {
+ elodin_port = static_cast(std::atoi(argv[++i]));
+ } else if (arg == "--interval-ms" && i + 1 < argc) {
+ interval_ms = std::max(100, std::atoi(argv[++i]));
+ } else if (arg == "--target-ip" && i + 1 < argc) {
+ target_ip = argv[++i];
+ } else if (arg == "--target-port" && i + 1 < argc) {
+ target_port = static_cast(std::atoi(argv[++i]));
+ } else if (arg == "--help" || arg == "-h") {
+ std::cout << "Usage: " << argv[0]
+ << " [--config PATH] [--elodin-host HOST] [--elodin-port PORT]\n"
+ " [--interval-ms MS] [--target-ip IP] [--target-port PORT]\n";
+ return 0;
+ }
+ }
+
+ // Load config file, falling back to a couple of common relative paths
+ std::string config_content;
+ {
+ std::ifstream f(config_path);
+ if (!f.is_open()) {
+ for (const auto& fp : {"config/config.toml", "../config/config.toml"}) {
+ f.open(fp);
+ if (f.is_open()) { config_path = fp; break; }
+ }
+ }
+ if (f.is_open()) {
+ std::ostringstream ss;
+ ss << f.rdbuf();
+ config_content = ss.str();
+ }
+ }
+
+ if (!config_content.empty()) {
+ auto val = getTomlValue(config_content, "stacklight_service", "interval_ms", "");
+ if (!val.empty()) { try { interval_ms = std::max(100, std::stoi(val)); } catch (...) {} }
+
+ val = getTomlValue(config_content, "stacklight_service", "target_ip", "");
+ if (!val.empty()) target_ip = val;
+
+ val = getTomlValue(config_content, "stacklight_service", "target_port", "");
+ if (!val.empty()) { try { target_port = static_cast(std::stoi(val)); } catch (...) {} }
+
+ val = getTomlValue(config_content, "stacklight_service", "elodin_host", "");
+ if (!val.empty()) elodin_host = val;
+
+ val = getTomlValue(config_content, "stacklight_service", "elodin_port", "");
+ if (!val.empty()) { try { elodin_port = static_cast(std::stoi(val)); } catch (...) {} }
+ }
+
+ signal(SIGINT, signalHandler);
+ signal(SIGTERM, signalHandler);
+
+ // UDP unicast socket
+ int sock = socket(AF_INET, SOCK_DGRAM, 0);
+ if (sock < 0) {
+ std::cerr << "[StacklightService] socket() failed" << std::endl;
+ return 1;
+ }
+
+ struct sockaddr_in dest;
+ memset(&dest, 0, sizeof(dest));
+ dest.sin_family = AF_INET;
+ dest.sin_port = htons(target_port);
+ if (inet_pton(AF_INET, target_ip.c_str(), &dest.sin_addr) != 1) {
+ std::cerr << "[StacklightService] Invalid target IP: " << target_ip << std::endl;
+ close(sock);
+ return 1;
+ }
+
+ std::cout << "[StacklightService] Started — interval=" << interval_ms
+ << "ms target=" << target_ip << ":" << target_port << std::endl;
+ std::cout << "[StacklightService] State from Elodin at " << elodin_host << ":" << elodin_port
+ << " [0x5000]" << std::endl;
+
+ std::thread elodin_thread(elodinThread, elodin_host, elodin_port);
+
+ uint8_t last_sent_state = 0xFF; // sentinel value, forces the very first send
+ unsigned long count = 0;
+ auto last_log = std::chrono::steady_clock::now();
+ auto last_send = std::chrono::steady_clock::now() - std::chrono::milliseconds(interval_ms);
+
+ uint8_t buf[MAX_PACKET_SIZE];
+
+ while (g_running) {
+ uint8_t seq_state = g_seq_state.load();
+ auto now = std::chrono::steady_clock::now();
+ bool state_changed = (seq_state != last_sent_state);
+ bool keepalive_due =
+ std::chrono::duration_cast(now - last_send).count() >= interval_ms;
+
+ if (state_changed || keepalive_due) {
+ Diablo::StacklightCommandPacket cmd = stateToStacklight(seq_state);
+ auto ts = std::chrono::duration_cast(
+ std::chrono::system_clock::now().time_since_epoch())
+ .count() & 0xFFFFFFFFu;
+
+ size_t len = Diablo::create_stacklight_command_packet(cmd, static_cast(ts),
+ buf, sizeof(buf));
+ if (len > 0) {
+ ssize_t sent = sendto(sock, buf, len, 0,
+ reinterpret_cast(&dest), sizeof(dest));
+ if (sent == static_cast(len)) {
+ count++;
+ last_sent_state = seq_state;
+ last_send = now;
+ }
+ }
+ }
+
+ auto since_log = std::chrono::duration(now - last_log).count();
+ if (since_log >= 10.0) {
+ std::cout << "[StacklightService] Sent " << count
+ << " commands (seq_state=" << (int)seq_state << ")" << std::endl;
+ last_log = now;
+ }
+
+ usleep(100000); // check 10 times per second
+ }
+
+ close(sock);
+ elodin_thread.join();
+ std::cout << "[StacklightService] Stopped." << std::endl;
+ return 0;
+}