STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
combined_fsw_gui.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3FSW Combined Sensor & Actuator Control GUI
4Similar to DiabloAvionics combined_gui.py but integrated with FSW stack
5
6Features:
7- Real-time sensor data visualization (pressure plots)
8- Actuator control (ON/OFF buttons)
9- State machine control (Idle, Armed, Fire, Vent, etc.)
10- Top bar with pressure gauges and abort buttons
11- Integration with Elodin database
12"""
13
14import json
15import socket
16import struct
17import sys
18import time
19import csv
20import re
21import os
22from collections import deque
23from pathlib import Path
24from typing import Dict, List, Optional, Tuple
25
26from PyQt6 import QtCore, QtGui, QtWidgets
27import pyqtgraph as pg
28import numpy as np
29
30# Elodin client is implemented in this file
31# No external import needed
32
33# Configuration
34MAX_PACKET_SIZE = 512
35# NUM_ACTUATORS is now dynamic - use get_num_actuators() function
36NUM_SENSORS = 10
37UPDATE_INTERVAL_MS = 50
38DEFAULT_WINDOW_SECONDS = 40.0
39MAX_POINTS = 10000
40
41# Default network configuration
42DEFAULT_ACTUATOR_IP = "192.168.2.201"
43DEFAULT_SENSOR_IP = "192.168.2.101" # PT board IP
44DEFAULT_ACTUATOR_PORT = 5005
45DEFAULT_RECEIVE_PORT = 5006
46DEFAULT_BIND_ADDRESS = "0.0.0.0"
47
48# PT Calibration paths
49PT_CALIBRATION_CSV = os.path.join(
50 os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
51 "external",
52 "DiabloAvionics",
53 "PT_Board",
54 "Calibration",
55 "PT Calibration Attempt 2026-02-04_test2.csv",
56)
57CALIBRATION_JSON_DIR = Path(__file__).parent.parent / "calibration" / "calibrations"
58
59# Packet format (DiabloAvionics)
60PACKET_HEADER_FORMAT = "<BBI" # packet_type, version, timestamp
61PACKET_HEADER_SIZE = 6
62SENSOR_DATA_PACKET_FORMAT = "<BB" # num_chunks, num_sensors
63SENSOR_DATA_PACKET_SIZE = 2
64SENSOR_DATA_CHUNK_FORMAT = "<I" # chunk_timestamp
65SENSOR_DATA_CHUNK_SIZE = 4
66SENSOR_DATAPOINT_FORMAT = "<BI" # sensor_id, data
67SENSOR_DATAPOINT_SIZE = 5
68
69
70# Packet types
72 BOARD_HEARTBEAT = 1
73 SERVER_HEARTBEAT = 2
74 SENSOR_DATA = 3
75 ACTUATOR_COMMAND = 4
76 SENSOR_CONFIG = 5
77 ACTUATOR_CONFIG = 6
78 ABORT = 7
79 ABORT_DONE = 8
80 CLEAR_ABORT = 9
81
82
83# Sensor colors
84SENSOR_COLORS = [
85 (255, 80, 80), # Red
86 (80, 255, 80), # Green
87 (80, 150, 255), # Blue
88 (255, 200, 80), # Orange
89 (200, 80, 255), # Purple
90 (80, 255, 255), # Cyan
91 (255, 150, 150), # Light Red
92 (150, 255, 150), # Light Green
93 (150, 200, 255), # Light Blue
94 (255, 255, 80), # Yellow
95]
96
97
98# State machine states (matching FSW PressureStateMachine)
100 IDLE = "Idle"
101 ARMED = "Armed"
102 FUEL_FILL = "Fuel Fill"
103 OX_FILL = "Ox Fill"
104 GN2_LOW_PRESS = "GN2 Low Press"
105 GN2_VENT = "GN2 Vent"
106 FUEL_PRESS = "Fuel Press"
107 FUEL_VENT = "Fuel Vent"
108 OX_PRESS = "Ox Press"
109 OX_VENT = "Ox Vent"
110 GN2_HIGH_PRESS = "GN2 High Press"
111 GN2_HIGH_VENT = "GN2 High Vent"
112 VENT = "Vent"
113 CALIBRATE = "Calibrate"
114 READY = "Ready"
115 FIRE = "Fire"
116 ABORT = "Abort"
117 QUICK_FIRE = "Quick Fire"
118 HIGH_PRESS = "High Press"
119
120
121CONFIG_FILE = Path(__file__).parent / "fsw_gui_config.json"
122
123# State machine CSV path
124_STATE_MACHINE_CSV = (
125 Path(__file__).parent.parent.parent
126 / "config"
127 / "state_machine_actuators.csv"
128)
129
130
132 """Get number of actuators dynamically from CSV."""
133 if _STATE_MACHINE_CSV.exists():
134 try:
135 with open(_STATE_MACHINE_CSV, "r") as f:
136 reader = csv.reader(f)
137 rows = list(reader)
138 if len(rows) >= 2:
139 # Count non-empty actuator rows (skip header)
140 count = sum(
141 1 for row in rows[1:] if len(row) > 0 and row[0].strip()
142 )
143 if count > 0:
144 return count
145 except Exception as e:
146 print(f"Warning: Could not determine actuator count from CSV: {e}")
147 # Fallback to default
148 return 10
149
150
152 """Manages GUI configuration"""
153
154 def __init__(self):
155 num_actuators = get_num_actuators()
156 self.config = {
157 "actuators": {str(i): "" for i in range(1, num_actuators + 1)},
158 "sensors": {str(i): "" for i in range(1, NUM_SENSORS + 1)},
159 "network": {
160 "actuator_ip": DEFAULT_ACTUATOR_IP,
161 "sensor_ip_filter": DEFAULT_SENSOR_IP, # PT board IP for filtering
162 "actuator_port": DEFAULT_ACTUATOR_PORT,
163 "receive_port": DEFAULT_RECEIVE_PORT,
164 "bind_address": DEFAULT_BIND_ADDRESS,
165 },
166 "display": {
167 "window_seconds": DEFAULT_WINDOW_SECONDS,
168 "y_axis_min": 0.0,
169 "y_axis_max": 200.0,
170 "y_axis_autoscale": True,
171 },
172 "mappings": {"GN2": 0, "ETH": 0, "LOX": 0},
173 }
174 self.load()
175
176 def load(self):
177 if CONFIG_FILE.exists():
178 try:
179 with open(CONFIG_FILE, "r") as f:
180 loaded = json.load(f)
181 self._update_dict(self.config, loaded)
182 except Exception as e:
183 print(f"Error loading config: {e}")
184 else:
185 self.save()
186
187 def save(self):
188 try:
189 with open(CONFIG_FILE, "w") as f:
190 json.dump(self.config, f, indent=4)
191 except Exception as e:
192 print(f"Error saving config: {e}")
193
194 def _update_dict(self, target, source):
195 for k, v in source.items():
196 if isinstance(v, dict) and k in target:
197 self._update_dict(target[k], v)
198 else:
199 target[k] = v
200
201 def get_actuator_label(self, actuator_id: int) -> str:
202 return self.config["actuators"].get(str(actuator_id), f"Actuator {actuator_id}")
203
204 def set_actuator_label(self, actuator_id: int, label: str):
205 self.config["actuators"][str(actuator_id)] = label
206 self.save()
207
208 def get_sensor_label(self, sensor_id: int) -> str:
209 return self.config["sensors"].get(str(sensor_id), f"Sensor {sensor_id}")
210
211 def set_sensor_label(self, sensor_id: int, label: str):
212 self.config["sensors"][str(sensor_id)] = label
213 self.save()
214
215
217
218# ============================================================================
219# PT Calibration Functions
220# ============================================================================
221
222
224 adc_code: float, PT_A: float, PT_B: float, PT_C: float, PT_D: float
225) -> float:
226 """Compute pressure (psi) from ADC code using cubic polynomial."""
227 return (PT_A * (adc_code**3)) + (PT_B * (adc_code**2)) + (PT_C * adc_code) + PT_D
228
229
231 csv_path: str,
232) -> Dict[int, Tuple[float, float, float, float]]:
233 """
234 Load PT calibration coefficients from CSV.
235 Returns dict: connector_id -> (PT_A, PT_B, PT_C, PT_D)
236 """
237 result = {}
238 if not os.path.isfile(csv_path):
239 return result
240 try:
241 with open(csv_path, newline="", encoding="utf-8") as f:
242 reader = csv.DictReader(f)
243 rows = list(reader)
244 fieldnames = reader.fieldnames or []
245 if not rows:
246 return result
247
248 # Discover PT numbers from column names
249 pt_nums = set()
250 for col in fieldnames:
251 m = re.match(r"PT(\d+)\s+Coefficient\s+0", col, re.IGNORECASE)
252 if m:
253 pt_nums.add(int(m.group(1)))
254
255 # Use last row for coefficients
256 last = rows[-1]
257 for pt_num in sorted(pt_nums):
258 a = float(last.get(f"PT{pt_num} Coefficient 0", 0))
259 b = float(last.get(f"PT{pt_num} Coefficient 1", 0))
260 c = float(last.get(f"PT{pt_num} Coefficient 2", 0))
261 d = float(last.get(f"PT{pt_num} Coefficient 3", 0))
262 result[pt_num] = (a, b, c, d)
263 return result
264 except Exception as e:
265 print(f"Error loading PT calibration CSV: {e}")
266 return result
267
268
270 json_path: str,
271) -> Dict[int, Tuple[float, float, float, float]]:
272 """
273 Load PT calibration coefficients from JSON (from calibration GUI).
274 Returns dict: sensor_id -> (A, B, C, D) polynomial coefficients
275 """
276 result = {}
277 if not os.path.isfile(json_path):
278 return result
279 try:
280 with open(json_path, "r") as f:
281 data = json.load(f)
282
283 # Check if it's the calibration GUI format
284 if "calibration_polynomials" in data:
285 for sensor_id_str, coeffs in data["calibration_polynomials"].items():
286 sensor_id = int(sensor_id_str)
287 if len(coeffs) >= 4:
288 # Polynomial coefficients [a, b, c, d] for ax^3 + bx^2 + cx + d
289 result[sensor_id] = (
290 float(coeffs[0]),
291 float(coeffs[1]),
292 float(coeffs[2]),
293 float(coeffs[3]),
294 )
295 return result
296 except Exception as e:
297 print(f"Error loading PT calibration JSON: {e}")
298 return result
299
300
301def load_pt_calibration() -> Dict[int, Tuple[float, float, float, float]]:
302 """Load PT calibration from CSV or JSON, preferring JSON from calibration framework"""
303 # Try JSON first (from calibration GUI)
304 if CALIBRATION_JSON_DIR.exists():
305 json_files = list(CALIBRATION_JSON_DIR.glob("*.json"))
306 if json_files:
307 # Use most recent calibration file
308 latest = max(json_files, key=lambda p: p.stat().st_mtime)
309 cal = load_pt_calibration_json(str(latest))
310 if cal:
311 print(f"✅ Loaded PT calibration from {latest}")
312 return cal
313
314 # Fall back to CSV
315 cal = load_pt_calibration_csv(PT_CALIBRATION_CSV)
316 if cal:
317 print(f"✅ Loaded PT calibration from CSV: {len(cal)} sensors")
318 else:
319 print("⚠️ No PT calibration found - pressures will be uncalibrated")
320 return cal
321
322
323# ============================================================================
324# UDP Receiver Thread
325# ============================================================================
326
327
328class UDPReceiver(QtCore.QThread):
329 """Thread that receives UDP packets and emits decoded sensor data"""
330
331 sensor_data_received = QtCore.pyqtSignal(
332 dict, list, str
333 ) # header, chunks, source_ip
334 status_update = QtCore.pyqtSignal(str)
335 packet_received = QtCore.pyqtSignal(int, int) # packet_size, packet_type
336
338 self, port: int = DEFAULT_RECEIVE_PORT, bind_address: str = DEFAULT_BIND_ADDRESS
339 ):
340 super().__init__()
341 self.port = port
342 self.bind_address = bind_address
343 self._stop = False
344 self.sock = None
346 self.total_bytes = 0
347 self.start_time = None
348
349 def stop(self):
350 """Stop the receiver thread"""
351 self._stop = True
352 if self.sock:
353 try:
354 self.sock.close()
355 except:
356 pass
357
358 def get_stats(self) -> Dict:
359 """Get current statistics"""
360 if self.start_time is None:
361 return {
362 "packets": 0,
363 "bytes": 0,
364 "packets_per_sec": 0.0,
365 "bytes_per_sec": 0.0,
366 }
367
368 elapsed = time.time() - self.start_time
369 if elapsed > 0:
370 pps = self.total_packets / elapsed
371 bps = self.total_bytes / elapsed
372 else:
373 pps = 0.0
374 bps = 0.0
375
376 return {
377 "packets": self.total_packets,
378 "bytes": self.total_bytes,
379 "packets_per_sec": pps,
380 "bytes_per_sec": bps,
381 "elapsed": elapsed,
382 }
383
384 def run(self):
385 """Main receiver loop"""
386 self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
387 self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
388 self.sock.settimeout(0.1)
389
390 try:
391 self.sock.bind((self.bind_address, self.port))
392 self.status_update.emit(f"Listening on {self.bind_address}:{self.port}")
393 self.start_time = time.time()
394 except OSError as e:
395 self.status_update.emit(f"Error binding: {e}")
396 return
397
398 while not self._stop:
399 try:
400 data, addr = self.sock.recvfrom(MAX_PACKET_SIZE)
401 self.total_packets += 1
402 self.total_bytes += len(data)
403
404 header = self.parse_packet_header(data)
405 if header is None:
406 continue
407
408 packet_type, version, timestamp = header
409 self.packet_received.emit(len(data), packet_type)
410
411 if packet_type == PacketType.SENSOR_DATA:
412 result = self.parse_sensor_data_packet(data)
413 if result:
414 header_dict, chunks = result
415 source_ip = addr[0]
416 self.sensor_data_received.emit(header_dict, chunks, source_ip)
417
418 except socket.timeout:
419 continue
420 except Exception as e:
421 if not self._stop:
422 self.status_update.emit(f"Error: {e}")
423 continue
424
425 if self.sock:
426 try:
427 self.sock.close()
428 except:
429 pass
430 self.status_update.emit("Stopped")
431
432 def parse_packet_header(self, data: bytes) -> Optional[Tuple[int, int, int]]:
433 """Parse the packet header"""
434 if len(data) < PACKET_HEADER_SIZE:
435 return None
436 try:
437 packet_type, version, timestamp = struct.unpack(
438 PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE]
439 )
440 return (packet_type, version, timestamp)
441 except struct.error:
442 return None
443
444 def parse_sensor_data_packet(
445 self, data: bytes
446 ) -> Optional[Tuple[dict, List[dict]]]:
447 """Parse sensor data packet"""
448 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
449 return None
450
451 header = self.parse_packet_header(data)
452 if header is None or header[0] != PacketType.SENSOR_DATA:
453 return None
454
455 packet_type, version, timestamp = header
456
457 offset = PACKET_HEADER_SIZE
458 try:
459 num_chunks, num_sensors = struct.unpack(
460 SENSOR_DATA_PACKET_FORMAT,
461 data[offset : offset + SENSOR_DATA_PACKET_SIZE],
462 )
463 except struct.error:
464 return None
465
466 offset += SENSOR_DATA_PACKET_SIZE
467
468 per_chunk_size = SENSOR_DATA_CHUNK_SIZE + (num_sensors * SENSOR_DATAPOINT_SIZE)
469 expected_size = (
470 PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + (num_chunks * per_chunk_size)
471 )
472
473 if len(data) < expected_size:
474 return None
475
476 chunks = []
477 for chunk_idx in range(num_chunks):
478 try:
479 (chunk_timestamp,) = struct.unpack(
480 SENSOR_DATA_CHUNK_FORMAT,
481 data[offset : offset + SENSOR_DATA_CHUNK_SIZE],
482 )
483 except struct.error:
484 return None
485
486 offset += SENSOR_DATA_CHUNK_SIZE
487
488 datapoints = []
489 for sensor_idx in range(num_sensors):
490 try:
491 sensor_id, sensor_data = struct.unpack(
492 SENSOR_DATAPOINT_FORMAT,
493 data[offset : offset + SENSOR_DATAPOINT_SIZE],
494 )
495 datapoints.append({"sensor_id": sensor_id, "data": sensor_data})
496 offset += SENSOR_DATAPOINT_SIZE
497 except struct.error:
498 return None
499
500 chunks.append({"timestamp": chunk_timestamp, "datapoints": datapoints})
501
502 header_dict = {
503 "packet_type": packet_type,
504 "version": version,
505 "timestamp": timestamp,
506 }
507
508 return (header_dict, chunks)
509
510
511# ============================================================================
512# Elodin Client (from groundstation implementation)
513# ============================================================================
514
515from enum import Enum
516from dataclasses import dataclass
517import threading
518
519
520class ElodinPacketType(Enum):
521 TABLE = 0
522 QUERY = 1
523 RESPONSE = 2
524 COMMAND = 3
525
526
527@dataclass
528class ElodinPacketHeader:
529 len: int
530 ty: ElodinPacketType
531 packet_id: List[int]
532 request_id: int
533
534
535class ElodinClient:
536 """Client for communicating with Elodin database"""
537
538 def __init__(self, host: str = "127.0.0.1", port: int = 2240):
539 self.host = host
540 self.port = port
541 self.socket: Optional[socket.socket] = None
542 self.connected = False
543
544 # Packet IDs for different message types
545 self.PACKET_IDS = {
546 "COMMAND": [0xFF, 0x01],
547 "PT_DATA": [0x01, 0x00],
548 "TC_DATA": [0x02, 0x00],
549 "IMU_DATA": [0x03, 0x00],
550 "ENGINE_STATUS": [0x10, 0x00],
551 "SYSTEM_HEALTH": [0x11, 0x00],
552 "VALVE_STATUS": [0x12, 0x00],
553 "STATE_MACHINE": [0x20, 0x00], # State machine commands
554 }
555
556 def connect(self) -> bool:
557 """Connect to Elodin database"""
558 try:
559 self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
560 self.socket.settimeout(1.0)
561 self.socket.connect((self.host, self.port))
562 self.connected = True
563 print(f"✅ Connected to Elodin DB at {self.host}:{self.port}")
564 return True
565 except Exception as e:
566 print(f"❌ Failed to connect to Elodin: {e}")
567 self.connected = False
568 return False
569
570 def disconnect(self):
571 """Disconnect from Elodin"""
572 if self.socket:
573 self.socket.close()
574 self.socket = None
575 self.connected = False
576
577 def send_state_transition(self, target_state: str) -> bool:
578 """Send state transition command to Elodin"""
579 if not self.connected:
580 return False
581
582 try:
583 command_data = {
584 "type": "STATE_TRANSITION",
585 "target_state": target_state,
586 "timestamp": time.time(),
587 "source": "fsw_gui",
588 }
589
590 payload = json.dumps(command_data).encode("utf-8")
591 header = self._create_header(
592 ElodinPacketType.COMMAND, self.PACKET_IDS["STATE_MACHINE"], len(payload)
593 )
594
595 self.socket.sendall(header + payload)
596 return True
597 except Exception as e:
598 print(f"❌ Failed to send state transition: {e}")
599 return False
600
601 def send_actuator_command(self, actuator_id: int, state: int) -> bool:
602 """Send actuator command to Elodin"""
603 if not self.connected:
604 return False
605
606 try:
607 command_data = {
608 "type": "ACTUATOR_COMMAND",
609 "actuator_id": actuator_id,
610 "state": state,
611 "timestamp": time.time(),
612 "source": "fsw_gui",
613 }
614
615 payload = json.dumps(command_data).encode("utf-8")
616 header = self._create_header(
617 ElodinPacketType.COMMAND, self.PACKET_IDS["COMMAND"], len(payload)
618 )
619
620 self.socket.sendall(header + payload)
621 return True
622 except Exception as e:
623 print(f"❌ Failed to send actuator command: {e}")
624 return False
625
626 def query_telemetry(
627 self,
628 packet_type: str,
629 start_time: Optional[float] = None,
630 end_time: Optional[float] = None,
631 limit: int = 100,
632 ) -> List[Dict]:
633 """Query telemetry data from Elodin"""
634 if not self.connected:
635 return []
636
637 try:
638 query_data = {
639 "packet_id": self.PACKET_IDS.get(packet_type, [0, 0]),
640 "start_time": start_time or (time.time() - 60),
641 "end_time": end_time or time.time(),
642 "limit": limit,
643 }
644
645 payload = json.dumps(query_data).encode("utf-8")
646 header = self._create_header(ElodinPacketType.QUERY, [0, 0], len(payload))
647
648 self.socket.sendall(header + payload)
649
650 # Receive response (non-blocking, simplified)
651 return []
652 except Exception as e:
653 print(f"❌ Failed to query telemetry: {e}")
654 return []
655
656 def _create_header(
657 self, packet_type: ElodinPacketType, packet_id: List[int], payload_length: int
658 ) -> bytes:
659 """Create Elodin packet header"""
660 total_length = 12 + payload_length
661 header = struct.pack("<I", total_length)
662 header += struct.pack("<B", packet_type.value)
663 header += struct.pack("<BB", packet_id[0], packet_id[1])
664 header += struct.pack("<I", 0) # padding
665 header += struct.pack("<B", 0) # request_id
666 return header
667
668
669# ============================================================================
670# Pressure Bar Widget (Top Bar)
671# ============================================================================
672
673
674class PressureBarWidget(QtWidgets.QWidget):
675 """Vertical bar gauge showing pressure"""
676
677 def __init__(
678 self,
679 title: str,
680 nop: float = 500.0,
681 meop: float = 700.0,
682 fixed_color: Optional[QtGui.QColor] = None,
683 parent=None,
684 ):
685 super().__init__(parent)
686 self.title = title
687 self.nop = nop
688 self.meop = meop
689 self.fixed_color = fixed_color
690 self.current_value = 0.0
691 self.setMinimumWidth(60)
692 self.setMinimumHeight(100)
693
694 def set_value(self, value: float):
695 self.current_value = value
696 self.update()
697
698 def paintEvent(self, event):
699 painter = QtGui.QPainter(self)
700 painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
701
702 w, h = self.width(), self.height()
703 top_margin, bottom_margin = 20, 20
704 bar_w, bar_x = w - 20, 10
705 bar_h = h - top_margin - bottom_margin
706 bar_y = top_margin
707
708 # Draw title
709 painter.setPen(QtCore.Qt.GlobalColor.white)
710 painter.drawText(
711 QtCore.QRect(0, 0, w, top_margin),
712 QtCore.Qt.AlignmentFlag.AlignCenter,
713 self.title,
714 )
715
716 # Draw background
717 painter.setPen(QtCore.Qt.GlobalColor.gray)
718 painter.setBrush(QtGui.QColor(50, 50, 50))
719 painter.drawRect(bar_x, bar_y, bar_w, bar_h)
720
721 # Draw fill
722 max_val = 1.2 * self.meop if self.meop > 0 else 1.0
723 fill_ratio = min(max(self.current_value / max_val, 0.0), 1.0)
724 fill_h = int(fill_ratio * bar_h)
725 fill_y = bar_y + bar_h - fill_h
726
727 if self.fixed_color:
728 painter.setBrush(self.fixed_color)
729 else:
730 if self.current_value > self.meop:
731 painter.setBrush(QtGui.QColor(255, 0, 0))
732 elif self.current_value > self.nop:
733 painter.setBrush(QtGui.QColor(255, 165, 0))
734 else:
735 painter.setBrush(QtGui.QColor(0, 255, 0))
736
737 painter.setPen(QtCore.Qt.PenStyle.NoPen)
738 painter.drawRect(bar_x, fill_y, bar_w, fill_h)
739
740 # Draw value
741 painter.setPen(QtCore.Qt.GlobalColor.white)
742 val_str = f"{self.current_value:.0f}"
743 painter.drawText(
744 QtCore.QRect(0, h - bottom_margin, w, bottom_margin),
745 QtCore.Qt.AlignmentFlag.AlignCenter,
746 val_str,
747 )
748
749
750# ============================================================================
751# Top Bar Widget
752# ============================================================================
753
754
755class TopBarWidget(QtWidgets.QWidget):
756 """Top bar with pressure gauges, abort buttons, and state display"""
757
758 navigation_requested = QtCore.pyqtSignal(str)
759 abort_requested = QtCore.pyqtSignal()
760 emergency_abort_requested = QtCore.pyqtSignal()
761
762 def __init__(self, parent=None):
763 super().__init__(parent)
764 self.setFixedHeight(120)
765 layout = QtWidgets.QHBoxLayout(self)
766 layout.setContentsMargins(10, 5, 10, 5)
767
768 # Pressure bars
769 self.bar_gn2 = PressureBarWidget("GN2", fixed_color=QtGui.QColor(0, 255, 0))
770 self.bar_eth = PressureBarWidget("ETH", fixed_color=QtGui.QColor(255, 0, 0))
771 self.bar_lox = PressureBarWidget("LOX", fixed_color=QtGui.QColor(0, 0, 255))
772
773 layout.addWidget(self.bar_gn2)
774 layout.addWidget(self.bar_eth)
775 layout.addWidget(self.bar_lox)
776
777 layout.addStretch()
778
779 # Current state display
780 self.state_label = QtWidgets.QLabel("CURRENT STATE: ---")
781 self.state_label.setStyleSheet(
782 "font-size: 14pt; font-weight: bold; color: white; padding: 10px;"
783 )
784 layout.addWidget(self.state_label)
785
786 layout.addStretch()
787
788 # Buttons
789 btn_layout = QtWidgets.QVBoxLayout()
790
791 # Abort buttons
792 abort_row = QtWidgets.QHBoxLayout()
793 self.btn_abort = QtWidgets.QPushButton("ABORT")
794 self.btn_abort.setMinimumSize(120, 40)
795 self.btn_abort.setStyleSheet(
796 "background-color: orange; font-weight: bold; color: black;"
797 )
798 self.btn_abort.clicked.connect(self.abort)
799
800 self.btn_emergency = QtWidgets.QPushButton("EMERGENCY")
801 self.btn_emergency.setMinimumSize(120, 40)
802 self.btn_emergency.setStyleSheet(
803 "background-color: red; font-weight: bold; color: white;"
804 )
805 self.btn_emergency.clicked.connect(self.emergency_abort)
806
807 abort_row.addWidget(self.btn_abort)
808 abort_row.addWidget(self.btn_emergency)
809 btn_layout.addLayout(abort_row)
810
811 # Settings button
812 self.nav_btn = QtWidgets.QPushButton("SETTINGS")
813 self.nav_btn.setMinimumHeight(30)
814 self.nav_btn.clicked.connect(self.toggle_view)
815 btn_layout.addWidget(self.nav_btn)
816
817 layout.addLayout(btn_layout)
818
819 def set_state(self, state: str):
820 """Update current state display"""
821 self.state_label.setText(f"CURRENT STATE: {state}")
822
823 def abort(self):
824 """Handle abort button"""
825 self.abort_requested.emit()
826
827 def emergency_abort(self):
828 """Handle emergency abort button"""
829 self.emergency_abort_requested.emit()
830
831 def toggle_view(self):
832 """Toggle settings view"""
833 text = self.nav_btn.text()
834 if text == "SETTINGS":
835 self.navigation_requested.emit("settings")
836 self.nav_btn.setText("DASHBOARD")
837 else:
838 self.navigation_requested.emit("dashboard")
839 self.nav_btn.setText("SETTINGS")
840
841
842# ============================================================================
843# State Machine Control Widget
844# ============================================================================
845
846
847class StateMachineWidget(QtWidgets.QWidget):
848 """State machine control buttons"""
849
850 state_transition_requested = QtCore.pyqtSignal(str)
851
852 def __init__(self, parent=None):
853 super().__init__(parent)
854 self.current_state = SystemState.IDLE
855 self.init_ui()
856
857 def init_ui(self):
858 """Initialize state machine buttons"""
859 layout = QtWidgets.QVBoxLayout(self)
860
861 # Title
862 title = QtWidgets.QLabel("State Machine Control")
863 title.setStyleSheet("font-size: 12pt; font-weight: bold; padding: 5px;")
864 layout.addWidget(title)
865
866 # Button rows
867 row1 = QtWidgets.QHBoxLayout()
868 row2 = QtWidgets.QHBoxLayout()
869
870 # Row 1 buttons
871 states_row1 = [
872 SystemState.IDLE,
873 SystemState.ARMED,
874 SystemState.FUEL_FILL,
875 SystemState.OX_FILL,
876 SystemState.QUICK_FIRE,
877 SystemState.GN2_LOW_PRESS,
878 SystemState.FUEL_PRESS,
879 ]
880
881 # Row 2 buttons
882 states_row2 = [
883 SystemState.FUEL_VENT,
884 SystemState.OX_PRESS,
885 SystemState.OX_VENT,
886 SystemState.HIGH_PRESS,
887 SystemState.GN2_HIGH_VENT,
888 SystemState.FIRE,
889 SystemState.VENT,
890 ]
891
892 self.state_buttons = {}
893
894 for state in states_row1:
895 btn = QtWidgets.QPushButton(state)
896 btn.setMinimumHeight(35)
897 btn.clicked.connect(lambda checked, s=state: self.request_transition(s))
898 row1.addWidget(btn)
899 self.state_buttons[state] = btn
900
901 for state in states_row2:
902 btn = QtWidgets.QPushButton(state)
903 btn.setMinimumHeight(35)
904 btn.clicked.connect(lambda checked, s=state: self.request_transition(s))
905 row2.addWidget(btn)
906 self.state_buttons[state] = btn
907
908 layout.addLayout(row1)
909 layout.addLayout(row2)
910 layout.addStretch()
911
912 def request_transition(self, target_state: str):
913 """Request state transition"""
914 self.state_transition_requested.emit(target_state)
915
916 def set_current_state(self, state: str):
917 """Update current state and highlight button"""
918 self.current_state = state
919 for state_name, btn in self.state_buttons.items():
920 if state_name == state:
921 btn.setStyleSheet("background-color: #4CAF50; font-weight: bold;")
922 else:
923 btn.setStyleSheet("")
924
925
926# ============================================================================
927# Sensor Plot Widget
928# ============================================================================
929
930
931class SensorPlotWidget(QtWidgets.QWidget):
932 """Real-time sensor data visualization"""
933
934 def __init__(
935 self,
936 receiver,
937 elodin_client: Optional[ElodinClient] = None,
938 bind_address: str = "0.0.0.0",
939 parent=None,
940 ):
941 super().__init__(parent)
942 self.receiver = receiver
943 self.elodin_client = elodin_client
944 self.bind_address = bind_address
945
946 # Data storage
947 self.sensor_data: Dict[int, deque] = {}
948 self.sensor_adc_codes: Dict[int, deque] = (
949 {}
950 ) # Store raw ADC codes for calibration
951 self.sensor_psi_data: Dict[int, deque] = {}
952 self.sensor_plots: Dict[int, pg.PlotDataItem] = {}
953 self.sensor_labels: Dict[int, str] = {
954 i: CONFIG.get_sensor_label(i) for i in range(1, NUM_SENSORS + 1)
955 }
956
957 # PT Calibration
958 self.pt_calibration = load_pt_calibration()
959
960 # Filter by source IP (only accept PT board data, not actuator board)
961 self.filter_source_ip = CONFIG.config["network"].get(
962 "sensor_ip_filter", DEFAULT_SENSOR_IP
963 )
964
965 # ADC conversion settings
966 self.adc_bits = 32
967 self.reference_voltage = 2.5
968
969 # Settings
970 self.window_seconds = CONFIG.config["display"]["window_seconds"]
971 self.y_axis_auto_scale = CONFIG.config["display"]["y_axis_autoscale"]
972 self.y_axis_min = CONFIG.config["display"]["y_axis_min"]
973 self.y_axis_max = CONFIG.config["display"]["y_axis_max"]
974
975 self.stats_start_time = time.time()
976
977 self.init_ui()
978
979 # Connect signals
980 if receiver:
981 self.receiver.sensor_data_received.connect(self.on_sensor_data)
982 self.receiver.status_update.connect(self.on_status_update)
983
984 # Timer for updates
985 self.update_timer = QtCore.QTimer(self)
986 self.update_timer.timeout.connect(self.update_plots)
987 self.update_timer.start(UPDATE_INTERVAL_MS)
988
989 # Elodin polling if available
990 if self.elodin_client and self.elodin_client.connected:
991 self.elodin_timer = QtCore.QTimer(self)
992 self.elodin_timer.timeout.connect(self.poll_elodin_data)
993 self.elodin_timer.start(100) # 10 Hz
994
995 def code_to_voltage(self, code_uint32: int) -> float:
996 """Convert ADC code to voltage"""
997 if code_uint32 >= 0x80000000:
998 code_int32 = code_uint32 - 0x100000000
999 else:
1000 code_int32 = code_uint32
1001 return (code_int32 * self.reference_voltage) / (2 ** (self.adc_bits - 1))
1002
1003 def init_ui(self):
1004 """Initialize UI"""
1005 layout = QtWidgets.QVBoxLayout(self)
1006
1007 # Top panel
1008 top_panel = QtWidgets.QHBoxLayout()
1009 self.status_label = QtWidgets.QLabel("Starting...")
1010 self.status_label.setStyleSheet("font-weight: bold; padding: 5px;")
1011 top_panel.addWidget(self.status_label)
1012 top_panel.addStretch()
1013
1014 self.auto_scale_checkbox = QtWidgets.QCheckBox("Auto-scale Y-axis")
1015 self.auto_scale_checkbox.setChecked(self.y_axis_auto_scale)
1016 self.auto_scale_checkbox.stateChanged.connect(self.on_auto_scale_toggled)
1017 top_panel.addWidget(self.auto_scale_checkbox)
1018
1019 layout.addLayout(top_panel)
1020
1021 # Plot and stats
1022 plot_stats_layout = QtWidgets.QHBoxLayout()
1023
1024 # Plot widget
1025 self.plot_widget = pg.GraphicsLayoutWidget()
1026 self.plot_widget.setBackground("k")
1027 plot_stats_layout.addWidget(self.plot_widget, 1)
1028
1029 # Statistics panel
1030 stats_widget = QtWidgets.QWidget()
1031 stats_layout = QtWidgets.QVBoxLayout(stats_widget)
1032
1033 # Network stats
1034 network_group = QtWidgets.QGroupBox("Network")
1035 network_layout = QtWidgets.QVBoxLayout()
1036 self.packets_label = QtWidgets.QLabel("Packets: 0")
1037 self.pps_label = QtWidgets.QLabel("Packets/sec: 0.0")
1038 network_layout.addWidget(self.packets_label)
1039 network_layout.addWidget(self.pps_label)
1040 network_group.setLayout(network_layout)
1041 stats_layout.addWidget(network_group)
1042
1043 # Sensor stats
1044 sensor_group = QtWidgets.QGroupBox("Sensors")
1045 sensor_layout = QtWidgets.QVBoxLayout()
1046 self.connector_labels = {}
1047 for i in range(1, NUM_SENSORS + 1):
1048 label = QtWidgets.QLabel(f"C{i}: --- V")
1049 color_idx = i % len(SENSOR_COLORS)
1050 color = SENSOR_COLORS[color_idx]
1051 label.setStyleSheet(f"color: rgb{color}; padding: 2px;")
1052 sensor_layout.addWidget(label)
1053 self.connector_labels[i] = label
1054 sensor_group.setLayout(sensor_layout)
1055 stats_layout.addWidget(sensor_group, 1)
1056
1057 stats_widget.setFixedWidth(200)
1058 plot_stats_layout.addWidget(stats_widget)
1059 layout.addLayout(plot_stats_layout, 1)
1060
1061 # Create plot
1062 self.plot_item = self.plot_widget.addPlot(title="Pressure Data Over Time")
1063 self.plot_item.setTitle("Pressure Data Over Time", color="w", size="14pt")
1064 self.plot_item.setLabel("left", "Pressure (psi)", color="w")
1065 self.plot_item.setLabel("bottom", "Time (seconds)", color="w")
1066 self.plot_item.addLegend()
1067 self.plot_item.showGrid(x=True, y=True, alpha=0.5)
1068 self.plot_item.getViewBox().setBackgroundColor("k")
1069
1070 # Style axes
1071 font = QtGui.QFont()
1072 font.setPointSize(12)
1073 left_axis = self.plot_item.getAxis("left")
1074 bottom_axis = self.plot_item.getAxis("bottom")
1075 left_axis.setStyle(tickFont=font)
1076 bottom_axis.setStyle(tickFont=font)
1077 left_axis.setPen("w")
1078 bottom_axis.setPen("w")
1079 left_axis.setTextPen("w")
1080 bottom_axis.setTextPen("w")
1081
1082 def on_sensor_data(self, header: dict, chunks: List[dict], source_ip: str):
1083 """Handle sensor data from UDP or Elodin - ONLY from PT board, not actuator board"""
1084 # Filter by source IP - only accept PT board data
1085 if source_ip != self.filter_source_ip:
1086 return # Ignore actuator board data (handled separately)
1087
1088 current_time = time.time()
1089
1090 for chunk in chunks:
1091 chunk_timestamp_ms = chunk["timestamp"]
1092 relative_time = current_time - self.stats_start_time
1093
1094 for dp in chunk["datapoints"]:
1095 sensor_id = dp["sensor_id"]
1096 code_uint32 = dp["data"]
1097
1098 # Convert to voltage
1099 voltage = self.code_to_voltage(code_uint32)
1100
1101 # Initialize if needed
1102 if sensor_id not in self.sensor_data:
1103 self.sensor_data[sensor_id] = deque(maxlen=MAX_POINTS)
1104 self.sensor_adc_codes[sensor_id] = deque(maxlen=MAX_POINTS)
1105 # Only create PSI storage and plot for calibrated sensors
1106 if sensor_id in self.pt_calibration:
1107 self.sensor_psi_data[sensor_id] = deque(maxlen=MAX_POINTS)
1108 self.add_sensor_plot(sensor_id)
1109
1110 # Store data
1111 self.sensor_data[sensor_id].append((relative_time, voltage))
1112 self.sensor_adc_codes[sensor_id].append((relative_time, code_uint32))
1113
1114 # Calculate and store PSI if calibration exists
1115 if sensor_id in self.pt_calibration:
1116 a, b, c, d = self.pt_calibration[sensor_id]
1117 psi = calculate_pressure(code_uint32, a, b, c, d)
1118 self.sensor_psi_data[sensor_id].append((relative_time, psi))
1119
1120 def add_sensor_plot(self, sensor_id: int):
1121 """Add plot line for sensor"""
1122 color_idx = sensor_id % len(SENSOR_COLORS)
1123 color = SENSOR_COLORS[color_idx]
1124 pen = pg.mkPen(color=color, width=2)
1125
1126 label = self.sensor_labels.get(sensor_id, f"Sensor {sensor_id}")
1127 plot = self.plot_item.plot([], [], pen=pen, name=f"PT {sensor_id}: {label}")
1128 self.sensor_plots[sensor_id] = plot
1129
1130 def update_plots(self):
1131 """Update plot data"""
1132 current_time = time.time()
1133 relative_time = current_time - self.stats_start_time
1134
1135 # Update each sensor plot
1136 for sensor_id, plot in self.sensor_plots.items():
1137 if sensor_id in self.sensor_psi_data:
1138 data = self.sensor_psi_data[sensor_id]
1139 if len(data) > 0:
1140 # Filter to window
1141 cutoff_time = relative_time - self.window_seconds
1142 filtered_data = [(t, v) for t, v in data if t >= cutoff_time]
1143
1144 if filtered_data:
1145 times = [t for t, v in filtered_data]
1146 values = [v for t, v in filtered_data]
1147 plot.setData(times, values)
1148
1149 # Update Y-axis
1150 if self.y_axis_auto_scale:
1151 self.plot_item.enableAutoRange(axis="y")
1152 else:
1153 self.plot_item.setYRange(self.y_axis_min, self.y_axis_max)
1154
1155 # Update statistics
1156 if self.receiver:
1157 stats = self.receiver.get_stats()
1158 self.packets_label.setText(f"Packets: {stats['packets']}")
1159 self.pps_label.setText(f"Packets/sec: {stats['packets_per_sec']:.1f}")
1160
1161 # Update sensor labels
1162 for sensor_id, label_widget in self.connector_labels.items():
1163 if sensor_id in self.sensor_data and len(self.sensor_data[sensor_id]) > 0:
1164 voltage = self.sensor_data[sensor_id][-1][1]
1165 if (
1166 sensor_id in self.sensor_psi_data
1167 and len(self.sensor_psi_data[sensor_id]) > 0
1168 ):
1169 psi = self.sensor_psi_data[sensor_id][-1][1]
1170 label_text = self.sensor_labels.get(sensor_id, "")
1171 if label_text:
1172 label_widget.setText(
1173 f"C{sensor_id}: {label_text}<br/>{voltage:.3f} V<br/>{psi:.2f} psi"
1174 )
1175 else:
1176 label_widget.setText(
1177 f"C{sensor_id}: {voltage:.3f} V<br/>{psi:.2f} psi"
1178 )
1179 else:
1180 label_widget.setText(f"C{sensor_id}: {voltage:.3f} V")
1181
1182 def poll_elodin_data(self):
1183 """Poll Elodin for sensor data"""
1184 if not self.elodin_client or not self.elodin_client.connected:
1185 return
1186
1187 # Query PT data from Elodin
1188 data = self.elodin_client.query_telemetry("PT_DATA", limit=10)
1189 for record in data:
1190 # Parse Elodin record and convert to sensor data format
1191 # This would need to match your Elodin message format
1192 pass
1193
1194 def on_status_update(self, status: str):
1195 """Handle status updates"""
1196 self.status_label.setText(status)
1197
1198 def on_auto_scale_toggled(self, state):
1199 """Handle auto-scale toggle"""
1200 self.y_axis_auto_scale = state == QtCore.Qt.CheckState.Checked.value
1201
1202
1203# ============================================================================
1204# Actuator Control Widget
1205# ============================================================================
1206
1207
1208class ActuatorControlWidget(QtWidgets.QWidget):
1209 """Actuator control with ON/OFF buttons"""
1210
1211 actuator_command_sent = QtCore.pyqtSignal(int, int) # actuator_id, state
1212
1213 def __init__(
1214 self,
1215 receiver,
1216 elodin_client: Optional[ElodinClient] = None,
1217 device_ip: str = None,
1218 device_port: int = None,
1219 parent=None,
1220 ):
1221 super().__init__(parent)
1222 self.receiver = receiver
1223 self.elodin_client = elodin_client
1224 self.device_ip = device_ip or CONFIG.config["network"]["actuator_ip"]
1225 self.device_port = device_port or CONFIG.config["network"]["actuator_port"]
1226
1227 # Actuator state tracking
1228 num_actuators = get_num_actuators()
1229 self.actuator_states = [0] * num_actuators
1230 self.voltage_readings = [0.0] * num_actuators
1231 self.actuator_labels = {
1232 i: CONFIG.get_actuator_label(i) for i in range(1, num_actuators + 1)
1233 }
1234
1235 # UDP socket for direct commands
1236 self.command_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
1237
1238 self.init_ui()
1239
1240 # Connect signals
1241 if receiver:
1242 self.receiver.sensor_data_received.connect(self.on_sensor_data)
1243
1244 # Update timer
1245 self.update_timer = QtCore.QTimer(self)
1246 self.update_timer.timeout.connect(self.update_display)
1247 self.update_timer.start(100)
1248
1249 def init_ui(self):
1250 """Initialize UI"""
1251 layout = QtWidgets.QVBoxLayout(self)
1252
1253 # Title
1254 title = QtWidgets.QLabel("Actuator Control")
1255 title.setStyleSheet("font-size: 12pt; font-weight: bold; padding: 5px;")
1256 layout.addWidget(title)
1257
1258 # Grid for actuators - dynamic layout based on count
1259 grid = QtWidgets.QGridLayout()
1260 grid.setSpacing(10)
1261
1262 # Calculate optimal grid layout: use 2-4 columns based on number of actuators
1263 num_actuators = get_num_actuators()
1264 if num_actuators <= 8:
1265 cols = 2
1266 elif num_actuators <= 16:
1267 cols = 4
1268 else:
1269 cols = 4 # Default to 4 columns for larger numbers
1270
1271 self.actuator_widgets = []
1272 for i in range(num_actuators):
1273 actuator_id = i + 1
1274 row, col = i // cols, i % cols
1275
1276 # Frame for each actuator
1277 frame = QtWidgets.QFrame()
1278 frame.setFrameShape(QtWidgets.QFrame.Shape.Box)
1279 frame_layout = QtWidgets.QVBoxLayout(frame)
1280
1281 # Label
1282 label_text = self.actuator_labels.get(
1283 actuator_id, f"Actuator {actuator_id}"
1284 )
1285 label = QtWidgets.QLabel(label_text)
1286 label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
1287 label.setStyleSheet("font-weight: bold; font-size: 11pt; padding: 5px;")
1288 frame_layout.addWidget(label)
1289
1290 # Buttons
1291 btn_layout = QtWidgets.QHBoxLayout()
1292
1293 on_btn = QtWidgets.QPushButton("OPEN")
1294 on_btn.setMinimumHeight(40)
1295 on_btn.clicked.connect(
1296 lambda checked, aid=actuator_id: self.set_actuator_state(aid, 1)
1297 )
1298
1299 off_btn = QtWidgets.QPushButton("CLOSED")
1300 off_btn.setMinimumHeight(40)
1301 off_btn.clicked.connect(
1302 lambda checked, aid=actuator_id: self.set_actuator_state(aid, 0)
1303 )
1304
1305 btn_layout.addWidget(on_btn)
1306 btn_layout.addWidget(off_btn)
1307 frame_layout.addLayout(btn_layout)
1308
1309 # Voltage display
1310 voltage_label = QtWidgets.QLabel("1.234 V")
1311 voltage_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
1312 voltage_label.setStyleSheet("font-size: 10pt; padding: 2px;")
1313 frame_layout.addWidget(voltage_label)
1314
1315 self.actuator_widgets.append(
1316 {
1317 "frame": frame,
1318 "label": label,
1319 "on_btn": on_btn,
1320 "off_btn": off_btn,
1321 "voltage_label": voltage_label,
1322 }
1323 )
1324
1325 grid.addWidget(frame, row, col)
1326
1327 layout.addLayout(grid)
1328 layout.addStretch()
1329
1330 def set_actuator_state(self, actuator_id: int, state: int):
1331 """Set actuator state and send command"""
1332 array_idx = actuator_id - 1
1333 self.actuator_states[array_idx] = state
1334 self.update_button_highlight(array_idx, state)
1335
1336 # Send command via Elodin or UDP
1337 if self.elodin_client and self.elodin_client.connected:
1338 self.elodin_client.send_actuator_command(actuator_id, state)
1339 else:
1340 # Send UDP command directly
1341 self.send_udp_command(actuator_id, state)
1342
1343 self.actuator_command_sent.emit(actuator_id, state)
1344
1345 def send_actuator_commands_batch(self, commands: List[Tuple[int, int]]):
1346 """Send multiple actuator commands in a single packet"""
1347 if not commands:
1348 return
1349
1350 # Send via UDP (batch packet)
1351 try:
1352 packet = self.create_actuator_command_packet(commands)
1353 if len(packet) > 0:
1354 self.command_sock.sendto(packet, (self.device_ip, self.device_port))
1355 print(f"📤 Sent batch: {len(commands)} actuator commands")
1356 else:
1357 print(f"❌ Failed to create batch packet")
1358 except Exception as e:
1359 print(f"Failed to send batch commands: {e}")
1360
1361 # Also send via Elodin if connected
1362 if self.elodin_client and self.elodin_client.connected:
1363 for actuator_id, state in commands:
1364 self.elodin_client.send_actuator_command(actuator_id, state)
1365
1366 # Emit signals for each command
1367 for actuator_id, state in commands:
1368 self.actuator_command_sent.emit(actuator_id, state)
1369
1370 def send_udp_command(self, actuator_id: int, state: int):
1371 """Send UDP command to actuator board (exact DiabloAvionics format)"""
1372 try:
1373 # Use exact format from combined_gui.py
1374 commands = [(actuator_id, state)]
1375 packet = self.create_actuator_command_packet(commands)
1376 if len(packet) > 0:
1377 self.command_sock.sendto(packet, (self.device_ip, self.device_port))
1378 else:
1379 print(f"Failed to create packet for actuator {actuator_id}")
1380 except Exception as e:
1381 print(f"Failed to send UDP command: {e}")
1382
1383 def create_actuator_command_packet(self, commands: List[Tuple[int, int]]) -> bytes:
1384 """
1385 Create an actuator command packet (exact format from DiabloAvionics combined_gui.py).
1386 commands: List of (actuator_id, actuator_state) tuples
1387 actuator_id: 1-10 (1-indexed)
1388 actuator_state: 0 = OFF, non-zero = ON
1389 """
1390 if len(commands) == 0 or len(commands) > 255:
1391 return b""
1392
1393 # Calculate packet size
1394 header_size = PACKET_HEADER_SIZE
1395 body_size = 1 # ACTUATOR_COMMAND_PACKET_SIZE (num_commands byte)
1396 commands_size = len(commands) * 2 # ACTUATOR_COMMAND_SIZE (2 bytes each)
1397 total_size = header_size + body_size + commands_size
1398
1399 if total_size > MAX_PACKET_SIZE:
1400 return b""
1401
1402 # Create packet buffer
1403 packet = bytearray(total_size)
1404 offset = 0
1405
1406 # Packet header: <BBI> = packet_type, version, timestamp
1407 packet_type = PacketType.ACTUATOR_COMMAND
1408 version = 0
1409 timestamp = (
1410 int(time.time() * 1000) & 0xFFFFFFFF
1411 ) # 32-bit timestamp in milliseconds
1412
1413 struct.pack_into(
1414 PACKET_HEADER_FORMAT, packet, offset, packet_type, version, timestamp
1415 )
1416 offset += PACKET_HEADER_SIZE
1417
1418 # Actuator command packet body: num_commands (1 byte)
1419 num_commands = len(commands)
1420 struct.pack_into("<B", packet, offset, num_commands)
1421 offset += 1
1422
1423 # Actuator commands: <BB> = actuator_id, actuator_state (2 bytes each)
1424 for actuator_id, actuator_state in commands:
1425 struct.pack_into("<BB", packet, offset, actuator_id, actuator_state)
1426 offset += 2
1427
1428 return bytes(packet)
1429
1430 def update_button_highlight(self, array_idx: int, state: int):
1431 """Update button highlighting"""
1432 widget = self.actuator_widgets[array_idx]
1433 bg_color = self.palette().color(QtGui.QPalette.ColorRole.Window).name()
1434
1435 inactive_style = f"QPushButton {{ background-color: {bg_color}; color: #FFFFFF; border: none; border-radius: 5px; padding: 5px; }}"
1436 active_style = "QPushButton { background-color: #FFFFFF; color: #000000; border: 2px solid #000000; border-radius: 5px; padding: 5px; }"
1437
1438 if state == 1:
1439 widget["on_btn"].setStyleSheet(active_style)
1440 widget["off_btn"].setStyleSheet(inactive_style)
1441 else:
1442 widget["on_btn"].setStyleSheet(inactive_style)
1443 widget["off_btn"].setStyleSheet(active_style)
1444
1445 def on_sensor_data(self, header: dict, chunks: List[dict], source_ip: str):
1446 """Update voltage readings from actuator board current sense data"""
1447 # ONLY process data from actuator board (192.168.2.201)
1448 if source_ip != self.device_ip:
1449 return # Ignore PT board data
1450
1451 for chunk in chunks:
1452 for dp in chunk["datapoints"]:
1453 sensor_id = dp["sensor_id"] # 0-indexed (0-9) from actuator board
1454 code_uint32 = dp["data"]
1455
1456 # Convert to voltage (32-bit ADC, 2.5V reference)
1457 if code_uint32 >= 0x80000000:
1458 code_int32 = code_uint32 - 0x100000000
1459 else:
1460 code_int32 = code_uint32
1461 voltage = (code_int32 * 2.5) / 2147483648.0
1462
1463 # Map sensor_id (0-9) to actuator (1-10)
1464 if 0 <= sensor_id < get_num_actuators():
1465 array_idx = sensor_id # Already 0-indexed
1466 self.voltage_readings[array_idx] = voltage
1467
1468 def update_display(self):
1469 """Update voltage displays"""
1470 for i, widget in enumerate(self.actuator_widgets):
1471 voltage = self.voltage_readings[i]
1472 widget["voltage_label"].setText(f"{voltage:.3f} V")
1473
1474
1475# ============================================================================
1476# Main Window
1477# ============================================================================
1478
1479
1480class CombinedFSWMainWindow(QtWidgets.QMainWindow):
1481 """Main window combining all widgets"""
1482
1483 def __init__(
1484 self,
1485 elodin_host: str = "127.0.0.1",
1486 elodin_port: int = 2240,
1487 actuator_ip: str = None,
1488 actuator_port: int = None,
1489 receive_port: int = None,
1490 bind_address: str = None,
1491 ):
1492 super().__init__()
1493
1494 # Elodin client
1495 self.elodin_client = ElodinClient(elodin_host, elodin_port)
1496 self.elodin_connected = self.elodin_client.connect()
1497
1498 # UDP receiver (fallback if Elodin not available)
1499 receive_port = receive_port or CONFIG.config["network"]["receive_port"]
1500 bind_address = bind_address or CONFIG.config["network"]["bind_address"]
1501 self.receiver = UDPReceiver(receive_port, bind_address)
1502
1503 # Current state
1504 self.current_state = SystemState.IDLE
1505
1506 self.init_ui(actuator_ip, actuator_port)
1507
1508 # Start receiver
1509 self.receiver.start()
1510
1511 # State update timer
1512 self.state_timer = QtCore.QTimer(self)
1513 self.state_timer.timeout.connect(self.update_state_from_elodin)
1514 self.state_timer.start(500) # 0.5 Hz
1515
1516 def init_ui(self, actuator_ip: str = None, actuator_port: int = None):
1517 """Initialize UI"""
1518 self.setWindowTitle("FSW - Sensor & Actuator Control")
1519 self.setGeometry(100, 100, 1800, 1000)
1520
1521 # Central widget
1522 central_widget = QtWidgets.QWidget()
1523 self.setCentralWidget(central_widget)
1524 main_layout = QtWidgets.QVBoxLayout(central_widget)
1525 main_layout.setContentsMargins(0, 0, 0, 0)
1526 main_layout.setSpacing(0)
1527
1528 # Top bar
1529 self.top_bar = TopBarWidget(self)
1530 self.top_bar.navigation_requested.connect(self.on_navigation)
1531 self.top_bar.abort_requested.connect(self.on_abort)
1532 self.top_bar.emergency_abort_requested.connect(self.on_emergency_abort)
1533 main_layout.addWidget(self.top_bar)
1534
1535 # Stacked widget for dashboard/settings
1536 self.stack = QtWidgets.QStackedWidget()
1537
1538 # Dashboard page
1539 dashboard_widget = QtWidgets.QWidget()
1540 dashboard_layout = QtWidgets.QVBoxLayout(dashboard_widget)
1541
1542 # State machine widget
1543 self.state_machine_widget = StateMachineWidget(self)
1544 self.state_machine_widget.state_transition_requested.connect(
1545 self.on_state_transition
1546 )
1547 dashboard_layout.addWidget(self.state_machine_widget)
1548
1549 # Splitter for sensor plot and actuator control
1550 splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal)
1551
1552 # Sensor plot widget
1553 self.sensor_widget = SensorPlotWidget(
1554 self.receiver,
1555 self.elodin_client,
1556 CONFIG.config["network"]["bind_address"],
1557 self,
1558 )
1559
1560 # Actuator control widget
1561 self.actuator_widget = ActuatorControlWidget(
1562 self.receiver, self.elodin_client, actuator_ip, actuator_port, self
1563 )
1564
1565 splitter.addWidget(self.sensor_widget)
1566 splitter.addWidget(self.actuator_widget)
1567 splitter.setStretchFactor(0, 2) # Left 66%
1568 splitter.setStretchFactor(1, 1) # Right 33%
1569
1570 dashboard_layout.addWidget(splitter)
1571 self.stack.addWidget(dashboard_widget)
1572
1573 main_layout.addWidget(self.stack, 1)
1574
1575 # Top bar update timer
1576 self.top_bar_timer = QtCore.QTimer(self)
1577 self.top_bar_timer.timeout.connect(self.update_top_bar)
1578 self.top_bar_timer.start(100)
1579
1580 def on_state_transition(self, target_state: str):
1581 """Handle state transition request and set actuators accordingly"""
1582 if self.elodin_client and self.elodin_client.connected:
1583 self.elodin_client.send_state_transition(target_state)
1584
1585 # Set actuators based on state machine CSV
1586 self._set_actuators_for_state(target_state)
1587
1588 self.current_state = target_state
1589 self.top_bar.set_state(target_state)
1590 self.state_machine_widget.set_current_state(target_state)
1591
1592 def _set_actuators_for_state(self, state: str):
1593 """Set actuator states based on state machine CSV"""
1594 if not hasattr(self, "_state_actuator_map"):
1595 self._load_state_actuator_map()
1596
1597 # Get actuator commands for this state
1598 actuator_commands = self._state_actuator_map.get(state, {})
1599
1600 # Send commands for all actuators
1601 commands_to_send = []
1602 for abbrev, gui_state in actuator_commands.items():
1603 actuator_id = self._abbrev_to_actuator_id(abbrev)
1604 if actuator_id is None:
1605 continue
1606
1607 # Convert GUI state (OPEN/CLOSED) to hardware state (0/1)
1608 # Account for NC/NO actuator types
1609 hardware_state = self._gui_state_to_hardware(abbrev, gui_state)
1610
1611 # Update GUI button state
1612 if self.actuator_widget:
1613 array_idx = actuator_id - 1
1614 if 0 <= array_idx < len(self.actuator_widget.actuator_states):
1615 self.actuator_widget.actuator_states[array_idx] = (
1616 1 if gui_state == "OPEN" else 0
1617 )
1618 self.actuator_widget.update_button_highlight(
1619 array_idx, self.actuator_widget.actuator_states[array_idx]
1620 )
1621
1622 commands_to_send.append((actuator_id, hardware_state))
1623
1624 # Send all commands in batch
1625 if commands_to_send and self.actuator_widget:
1626 self.actuator_widget.send_actuator_commands_batch(commands_to_send)
1627
1628 def _load_state_actuator_map(self):
1629 """Load state machine actuator mapping from CSV"""
1630 csv_path = (
1631 Path(__file__).parent.parent.parent
1632 / "config"
1633 / "state_machine_actuators.csv"
1634 )
1635 self._state_actuator_map = {}
1636
1637 if not csv_path.exists():
1638 print(f"⚠️ State machine CSV not found: {csv_path}")
1639 return
1640
1641 try:
1642 with open(csv_path, "r") as f:
1643 reader = csv.reader(f)
1644 header = next(reader) # Skip header row
1645 state_names = [
1646 s.strip() for s in header[1:] if s.strip()
1647 ] # Skip first column
1648
1649 # Normalize state names to match SystemState enum
1650 state_name_map = {
1651 "Debug": "Debug",
1652 "Idle": "Idle",
1653 "Armed": "Armed",
1654 "Fuel Fill": "Fuel Fill",
1655 "Ox Fill": "Ox Fill",
1656 "Quick Fire": "Quick Fire",
1657 "GN2 Press": "GN2 Low Press", # Map to SystemState name
1658 "Fuel Press": "Fuel Press",
1659 "Fuel Vent": "Fuel Vent",
1660 "Ox Press": "Ox Press",
1661 "Ox Vent": "Ox Vent",
1662 "High Press": "High Press",
1663 "GN2 Vent": "GN2 Vent",
1664 "Fire": "Fire",
1665 "Vent": "Vent",
1666 "Abort": "Abort",
1667 }
1668
1669 # Create normalized state names list
1670 normalized_state_names = [
1671 state_name_map.get(name, name) for name in state_names
1672 ]
1673
1674 for row in reader:
1675 if not row or row[0].strip() == "":
1676 continue
1677 abbrev = row[0].strip()
1678 for i, state_name in enumerate(normalized_state_names):
1679 if i + 1 >= len(row):
1680 break
1681 gui_state = row[i + 1].strip().upper()
1682 if gui_state in ("OPEN", "CLOSE", "CLOSED"):
1683 if state_name not in self._state_actuator_map:
1684 self._state_actuator_map[state_name] = {}
1685 # Normalize: "CLOSE" or "CLOSED" -> "CLOSED"
1686 normalized_state = (
1687 "OPEN" if gui_state == "OPEN" else "CLOSED"
1688 )
1689 self._state_actuator_map[state_name][
1690 abbrev
1691 ] = normalized_state
1692 except Exception as e:
1693 print(f"Error loading state machine CSV: {e}")
1694 self._state_actuator_map = {}
1695
1696 def _abbrev_to_actuator_id(self, abbrev: str) -> Optional[int]:
1697 """Map actuator abbreviation to actuator ID from config.toml"""
1698 # Map from config.toml actuator_abbrev and actuator_roles
1699 abbrev_map = {
1700 "FV": "Fuel Vent", # NC, actuator_id = 2
1701 "OV": "LOX Vent", # NC, actuator_id = 6
1702 "FP": "Fuel Press", # NC, actuator_id = 3
1703 "OP": "LOX Press", # NO, actuator_id = 8
1704 "FM": "Fuel Main", # NO, actuator_id = 7
1705 "OM": "LOX Main", # NO, actuator_id = 1
1706 }
1707
1708 role_name = abbrev_map.get(abbrev)
1709 if not role_name:
1710 return None
1711
1712 # Map role name to actuator ID (from config.toml actuator_roles)
1713 role_to_id = {
1714 "LOX Main": 1,
1715 "Fuel Vent": 2,
1716 "Fuel Press": 3,
1717 "GSE Low Vent": 5,
1718 "LOX Vent": 6,
1719 "Fuel Main": 7,
1720 "LOX Press": 8,
1721 "Fuel Fill Vent": 9,
1722 "Fuel Fill Press": 10,
1723 }
1724
1725 return role_to_id.get(role_name)
1726
1727 def _gui_state_to_hardware(self, abbrev: str, gui_state: str) -> int:
1728 """Convert GUI state (OPEN/CLOSED) to hardware state (0/1) accounting for NC/NO"""
1729 # Map actuator type (NC/NO) from config.toml
1730 abbrev_to_type = {
1731 "FV": "NC", # Fuel Vent - NC
1732 "OV": "NC", # LOX Vent - NC
1733 "FP": "NC", # Fuel Press - NC
1734 "OP": "NO", # LOX Press - NO
1735 "FM": "NO", # Fuel Main - NO
1736 "OM": "NO", # LOX Main - NO
1737 }
1738
1739 actuator_type = abbrev_to_type.get(abbrev, "NC")
1740 is_open = gui_state == "OPEN"
1741
1742 if actuator_type == "NO":
1743 # NO: OPEN (1) -> hardware OFF (0), CLOSED (0) -> hardware ON (1)
1744 return 0 if is_open else 1
1745 else:
1746 # NC: OPEN (1) -> hardware ON (1), CLOSED (0) -> hardware OFF (0)
1747 return 1 if is_open else 0
1748
1749 def on_abort(self):
1750 """Handle abort"""
1751 self.on_state_transition(SystemState.ABORT)
1752
1753 def on_emergency_abort(self):
1754 """Handle emergency abort"""
1755 self.on_state_transition(SystemState.ABORT)
1756 # Could add additional emergency actions here
1757
1758 def on_navigation(self, view_name: str):
1759 """Handle navigation"""
1760 if view_name == "settings":
1761 # TODO: Add settings widget
1762 pass
1763 else:
1764 self.stack.setCurrentIndex(0)
1765
1766 def update_state_from_elodin(self):
1767 """Query current state from Elodin"""
1768 if self.elodin_client and self.elodin_client.connected:
1769 # Query state machine status from Elodin
1770 # This would need to match your Elodin message format
1771 pass
1772
1773 def update_top_bar(self):
1774 """Update top bar pressure gauges"""
1775 gauge_map = CONFIG.config["mappings"]
1776 for gauge, sensor_id in gauge_map.items():
1777 if sensor_id > 0 and sensor_id in self.sensor_widget.sensor_psi_data:
1778 data = self.sensor_widget.sensor_psi_data[sensor_id]
1779 if len(data) > 0:
1780 val = data[-1][1] # Latest PSI value
1781 if gauge == "GN2":
1782 self.top_bar.bar_gn2.set_value(val)
1783 elif gauge == "ETH":
1784 self.top_bar.bar_eth.set_value(val)
1785 elif gauge == "LOX":
1786 self.top_bar.bar_lox.set_value(val)
1787
1788 def closeEvent(self, event):
1789 """Handle window close"""
1790 self.receiver.stop()
1791 if self.elodin_client:
1792 self.elodin_client.disconnect()
1793 event.accept()
1794
1795
1796# ============================================================================
1797# Main Entry Point
1798# ============================================================================
1799
1800
1801def main():
1802 """Main entry point"""
1803 app = QtWidgets.QApplication(sys.argv)
1804
1805 # Force Fusion style and dark palette
1806 app.setStyle("Fusion")
1807 palette = QtGui.QPalette()
1808 palette.setColor(
1809 QtGui.QPalette.ColorGroup.All,
1810 QtGui.QPalette.ColorRole.Window,
1811 QtGui.QColor(53, 53, 53),
1812 )
1813 palette.setColor(
1814 QtGui.QPalette.ColorGroup.All,
1815 QtGui.QPalette.ColorRole.WindowText,
1816 QtCore.Qt.GlobalColor.white,
1817 )
1818 palette.setColor(
1819 QtGui.QPalette.ColorGroup.All,
1820 QtGui.QPalette.ColorRole.Base,
1821 QtGui.QColor(25, 25, 25),
1822 )
1823 palette.setColor(
1824 QtGui.QPalette.ColorGroup.All,
1825 QtGui.QPalette.ColorRole.Text,
1826 QtCore.Qt.GlobalColor.white,
1827 )
1828 palette.setColor(
1829 QtGui.QPalette.ColorGroup.All,
1830 QtGui.QPalette.ColorRole.Button,
1831 QtGui.QColor(53, 53, 53),
1832 )
1833 palette.setColor(
1834 QtGui.QPalette.ColorGroup.All,
1835 QtGui.QPalette.ColorRole.ButtonText,
1836 QtCore.Qt.GlobalColor.white,
1837 )
1838 app.setPalette(palette)
1839
1840 # Create main window
1841 window = CombinedFSWMainWindow()
1842 window.showMaximized()
1843
1844 sys.exit(app.exec())
1845
1846
1847if __name__ == "__main__":
1848 main()
#define max(a, b)
Definition Arduino.h:43
_update_dict(self, target, source)
str get_sensor_label(self, int sensor_id)
set_actuator_label(self, int actuator_id, str label)
set_sensor_label(self, int sensor_id, str label)
str get_actuator_label(self, int actuator_id)
Optional[Tuple[dict, List[dict]]] parse_sensor_data_packet(self, bytes data)
__init__(self, int port=DEFAULT_RECEIVE_PORT, str bind_address=DEFAULT_BIND_ADDRESS)
Optional[Tuple[int, int, int]] parse_packet_header(self, bytes data)
Dict[int, Tuple[float, float, float, float]] load_pt_calibration()
Dict[int, Tuple[float, float, float, float]] load_pt_calibration_csv(str csv_path)
Dict[int, Tuple[float, float, float, float]] load_pt_calibration_json(str json_path)
float calculate_pressure(float adc_code, float PT_A, float PT_B, float PT_C, float PT_D)
Definition run.py:1
static void emit(sensors_core_t *c, uint8_t type, const void *data, uint8_t len)
TOML_NODISCARD_CTOR time() noexcept=default