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 ( +
+
+
+ Red +
+
+
+ Yellow +
+
+
+ Green +
+
+
🔊
+ Buzzer +
+
+ ); + })()} +
+
{/* 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; +}