STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
actuator_testing_gui.py
Go to the documentation of this file.
1import sys
2import socket
3import struct
4import time
5from collections import deque
6from typing import Dict, List, Tuple, Optional
7
8from PyQt6 import QtCore, QtGui, QtWidgets
9import pyqtgraph as pg
10
11
12# ---------------------- Protocol constants (DAQv2-Comms) ----------------------
13
14DIABLO_COMMS_VERSION = 0
15MAX_PACKET_SIZE = 512
16
17
19 BOARD_HEARTBEAT = 1
20 SERVER_HEARTBEAT = 2
21 SENSOR_DATA = 3
22 ACTUATOR_COMMAND = 4
23 ACTUATOR_CONFIG = 6
24
25
26PACKET_HEADER_FORMAT = "<BBI"
27PACKET_HEADER_SIZE = 6
28
29BOARD_HEARTBEAT_BODY_FORMAT = (
30 "<32sBBB" # firmware_hash[32], board_id, engine_state, board_state
31)
32BOARD_HEARTBEAT_BODY_SIZE = 35
33
34SENSOR_DATA_PACKET_FORMAT = "<BB"
35SENSOR_DATA_PACKET_SIZE = 2
36SENSOR_DATA_CHUNK_FORMAT = "<I"
37SENSOR_DATA_CHUNK_SIZE = 4
38SENSOR_DATAPOINT_FORMAT = "<BI"
39SENSOR_DATAPOINT_SIZE = 5
40
41# Actuator current sense: raw 12-bit ESP32 ADC counts, 3.3 V full scale.
42ACT_ADC_MAX = 4095
43ACT_ADC_VREF = 3.3
44
45
46def actuator_counts_to_volts(data_u32: int) -> float:
47 """Actuator current-sense datapoint -> volts.
48
49 Boards send raw 12-bit counts (<= 4095). Firmware from before that sent
50 float volts as bits; any nonzero voltage float encodes far above 4095, so
51 those are still read as floats until every board is reflashed.
52 """
53 if data_u32 <= ACT_ADC_MAX:
54 return data_u32 / ACT_ADC_MAX * ACT_ADC_VREF
55 return struct.unpack("<f", struct.pack("<I", data_u32 & 0xFFFFFFFF))[0]
56
57
58ACTUATOR_COMMAND_PACKET_FORMAT = "<B"
59ACTUATOR_COMMAND_PACKET_SIZE = 1
60ACTUATOR_COMMAND_FORMAT = "<BB"
61ACTUATOR_COMMAND_SIZE = 2
62
63NUM_ACTUATORS = 10
64
65DEFAULT_ACTUATOR_IP = "192.168.2.11"
66ACTUATOR_COMMAND_PORT = 5005 # Board listens here for commands
67SENSOR_RECEIVE_PORT = 5006 # Board sends current-sense packets here
68
69WINDOW_SECONDS = 10.0
70UPDATE_INTERVAL_MS = 100
71MAX_POINTS = 50000
72
73HEARTBEAT_TIMEOUT_SEC = 2.5
74HEARTBEAT_RATE_WINDOW = 20
75SERVER_HEARTBEAT_INTERVAL_MS = 500
76
77
78# Match sense_testing_gui BoardState
80 SETUP = 1
81 ACTIVE = 2
82 CONNECTION_LOSS_DETECTED = 3
83 NO_CONNECTION_ABORT = 4
84 NO_CONN_ABORT_FOLLOWER = 5
85 PT_ABORT = 6
86 NO_PT_ABORT = 7
87 ABORT_FINISHED = 8
88 STANDALONE_ABORT = 9
89 SELF_TEST = 10
90
91
92def board_state_name(state: int) -> str:
93 names = {
94 BoardState.SETUP: "Setup",
95 BoardState.ACTIVE: "Active",
96 BoardState.CONNECTION_LOSS_DETECTED: "Conn Loss",
97 BoardState.NO_CONNECTION_ABORT: "No Conn Abort",
98 BoardState.NO_CONN_ABORT_FOLLOWER: "No Conn Follower",
99 BoardState.PT_ABORT: "PT Abort",
100 BoardState.NO_PT_ABORT: "No PT Abort",
101 BoardState.ABORT_FINISHED: "Abort Finished",
102 BoardState.STANDALONE_ABORT: "Standalone Abort",
103 BoardState.SELF_TEST: "Self Test",
104 }
105 return names.get(state, f"Unknown ({state})")
106
107
108SENSOR_COLORS = [
109 (255, 80, 80),
110 (80, 255, 80),
111 (80, 150, 255),
112 (255, 200, 80),
113 (200, 80, 255),
114 (80, 255, 255),
115 (255, 150, 150),
116 (150, 255, 150),
117 (150, 200, 255),
118 (255, 255, 80),
119]
120
121
122# ---------------------- Packet helpers ----------------------
123
124
125def parse_packet_header(data: bytes) -> Optional[Tuple[int, int, int]]:
126 if len(data) < PACKET_HEADER_SIZE:
127 return None
128 try:
129 return struct.unpack(PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE])
130 except struct.error:
131 return None
132
133
135 data: bytes,
136) -> Optional[Tuple[tuple, bytes, int, int, int]]:
137 if len(data) < PACKET_HEADER_SIZE + BOARD_HEARTBEAT_BODY_SIZE:
138 return None
139 header = parse_packet_header(data)
140 if header is None or header[0] != PacketType.BOARD_HEARTBEAT:
141 return None
142 try:
143 body = struct.unpack(
144 BOARD_HEARTBEAT_BODY_FORMAT,
145 data[PACKET_HEADER_SIZE : PACKET_HEADER_SIZE + BOARD_HEARTBEAT_BODY_SIZE],
146 )
147 except struct.error:
148 return None
149 return (header, body[0], body[1], body[2], body[3])
150
151
152def parse_sensor_data_packet(data: bytes) -> Optional[Tuple[dict, List[dict]]]:
153 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
154 return None
155
156 header = parse_packet_header(data)
157 if header is None or header[0] != PacketType.SENSOR_DATA:
158 return None
159
160 packet_type, version, timestamp = header
161 offset = PACKET_HEADER_SIZE
162 try:
163 num_chunks, num_sensors = struct.unpack(
164 SENSOR_DATA_PACKET_FORMAT,
165 data[offset : offset + SENSOR_DATA_PACKET_SIZE],
166 )
167 except struct.error:
168 return None
169
170 offset += SENSOR_DATA_PACKET_SIZE
171 per_chunk_size = SENSOR_DATA_CHUNK_SIZE + (num_sensors * SENSOR_DATAPOINT_SIZE)
172 expected_size = (
173 PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + (num_chunks * per_chunk_size)
174 )
175 if len(data) < expected_size:
176 return None
177
178 chunks: List[dict] = []
179 for _ in range(num_chunks):
180 try:
181 (chunk_ts,) = struct.unpack(
182 SENSOR_DATA_CHUNK_FORMAT,
183 data[offset : offset + SENSOR_DATA_CHUNK_SIZE],
184 )
185 except struct.error:
186 return None
187 offset += SENSOR_DATA_CHUNK_SIZE
188
189 dps: List[dict] = []
190 for _ in range(num_sensors):
191 try:
192 sensor_id, sensor_data = struct.unpack(
193 SENSOR_DATAPOINT_FORMAT,
194 data[offset : offset + SENSOR_DATAPOINT_SIZE],
195 )
196 except struct.error:
197 return None
198 offset += SENSOR_DATAPOINT_SIZE
199 dps.append({"sensor_id": sensor_id, "data": sensor_data})
200
201 chunks.append({"timestamp": chunk_ts, "datapoints": dps})
202
203 header_dict = {
204 "packet_type": packet_type,
205 "version": version,
206 "timestamp": timestamp,
207 }
208 return header_dict, chunks
209
210
211def _make_header(packet_type: int) -> bytes:
212 ts = int(time.time() * 1000) & 0xFFFFFFFF
213 return struct.pack(PACKET_HEADER_FORMAT, packet_type, DIABLO_COMMS_VERSION, ts)
214
215
217 """Server heartbeat: header + 1 byte engine_state (0 = SAFE)."""
218 return _make_header(PacketType.SERVER_HEARTBEAT) + struct.pack("<B", 0)
219
220
222 """DAQv2-Comms ACTUATOR_CONFIG: not abort controller, no actuators/PTs, serial printing on."""
223 header = _make_header(PacketType.ACTUATOR_CONFIG)
224 # ActuatorConfigPacket: is_abort_controller, num_abort_actuators
225 # AbortPTSectionHeader: num_abort_pts
226 # enable_serial_printing
227 body = struct.pack("<BB", 0, 0) + struct.pack("<B", 0) + struct.pack("<B", 1)
228 return header + body
229
230
231def create_actuator_command_packet(commands: List[Tuple[int, int]]) -> bytes:
232 """
233 commands: list of (actuator_id, state) where id is 1-10, state 0/1.
234 """
235 if not commands or len(commands) > 255:
236 return b""
237
238 header_size = PACKET_HEADER_SIZE
239 body_size = ACTUATOR_COMMAND_PACKET_SIZE
240 commands_size = len(commands) * ACTUATOR_COMMAND_SIZE
241 total_size = header_size + body_size + commands_size
242 if total_size > MAX_PACKET_SIZE:
243 return b""
244
245 pkt = bytearray(total_size)
246 offset = 0
247
248 packet_type = PacketType.ACTUATOR_COMMAND
249 version = DIABLO_COMMS_VERSION
250 timestamp = int(time.time() * 1000) & 0xFFFFFFFF
251 struct.pack_into(PACKET_HEADER_FORMAT, pkt, offset, packet_type, version, timestamp)
252 offset += PACKET_HEADER_SIZE
253
254 struct.pack_into(ACTUATOR_COMMAND_PACKET_FORMAT, pkt, offset, len(commands))
255 offset += ACTUATOR_COMMAND_PACKET_SIZE
256
257 for actuator_id, state in commands:
258 struct.pack_into(ACTUATOR_COMMAND_FORMAT, pkt, offset, actuator_id, state)
259 offset += ACTUATOR_COMMAND_SIZE
260
261 return bytes(pkt)
262
263
264# ---------------------- Receiver thread ----------------------
265
266
267class SensorReceiver(QtCore.QThread):
268 sensor_data_received = QtCore.pyqtSignal(dict, list, str)
269 board_heartbeat_received = QtCore.pyqtSignal(float, int, int, int, str, bytes)
270 status_update = QtCore.pyqtSignal(str)
271
273 self,
274 port: int,
275 bind_address: str = "0.0.0.0",
276 sock: Optional[socket.socket] = None,
277 ):
278 super().__init__()
279 self.port = port
280 self.bind_address = bind_address
281 self._external_sock = sock
282 self._owned_socket = sock is None
283 self._stop = False
284 self.sock: Optional[socket.socket] = None
285
286 def stop(self):
287 self._stop = True
288 if self._owned_socket and self.sock:
289 try:
290 self.sock.close()
291 except Exception:
292 pass
293
294 def run(self):
295 if self._external_sock is not None:
296 self.sock = self._external_sock
297 self.sock.settimeout(0.1)
298 self.status_update.emit(f"Listening on {self.bind_address}:{self.port}")
299 else:
300 self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
301 self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
302 self.sock.settimeout(0.1)
303 try:
304 self.sock.bind((self.bind_address, self.port))
305 self.status_update.emit(f"Listening on {self.bind_address}:{self.port}")
306 except OSError as e:
307 self.status_update.emit(f"Error binding: {e}")
308 return
309
310 while not self._stop:
311 try:
312 data, addr = self.sock.recvfrom(MAX_PACKET_SIZE)
313 except socket.timeout:
314 continue
315 except Exception as e:
316 if not self._stop:
317 self.status_update.emit(f"Error: {e}")
318 continue
319
320 src_ip = addr[0]
321 header = parse_packet_header(data)
322 if not header:
323 continue
324
325 if header[0] == PacketType.BOARD_HEARTBEAT:
326 result = parse_board_heartbeat_packet(data)
327 if result:
328 _h, firmware_hash, board_id, engine_state, board_state = result
330 time.time(),
331 board_id,
332 board_state,
333 engine_state,
334 src_ip,
335 firmware_hash,
336 )
337 continue
338
339 if header[0] != PacketType.SENSOR_DATA:
340 continue
341
342 result = parse_sensor_data_packet(data)
343 if not result:
344 continue
345 header_dict, chunks = result
346 self.sensor_data_received.emit(header_dict, chunks, src_ip)
347
348 if self._owned_socket and self.sock:
349 try:
350 self.sock.close()
351 except Exception:
352 pass
353 self.sock = None
354 self.status_update.emit("Stopped")
355
356
357# ---------------------- Main GUI ----------------------
358
359
360class ActuatorTestingWindow(QtWidgets.QMainWindow):
361 def __init__(self):
362 super().__init__()
363 self.setWindowTitle("Actuator Testing – Current Sense + Control")
364 self.resize(1100, 750)
365
366 self.device_ip = DEFAULT_ACTUATOR_IP
367 self.device_port = ACTUATOR_COMMAND_PORT
368
369 self.window_seconds = WINDOW_SECONDS
370 self.show_current = False
371
372 self.voltage_history: Dict[int, deque] = {
373 i: deque(maxlen=MAX_POINTS) for i in range(1, NUM_ACTUATORS + 1)
374 }
375 self.plot_enabled: Dict[int, bool] = {
376 i: True for i in range(1, NUM_ACTUATORS + 1)
377 }
378
379 self.heartbeat_timestamps: deque = deque(maxlen=HEARTBEAT_RATE_WINDOW)
380 self.last_heartbeat_time: Optional[float] = None
381 self.board_id: Optional[int] = None
382 self.board_state: Optional[int] = None
383 self.board_source_ip: Optional[str] = None
384 self.firmware_hash: Optional[bytes] = None
385
386 self._send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
387 self._send_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
388 self._send_sock.settimeout(0.5)
389 self._shared_sock: Optional[socket.socket] = None
390 try:
391 self._send_sock.bind(("", SENSOR_RECEIVE_PORT))
392 self._shared_sock = self._send_sock
393 except OSError:
394 self._send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
395
396 self._server_heartbeat_timer = QtCore.QTimer(self)
398
399 self._build_ui()
400
401 self.receiver = SensorReceiver(SENSOR_RECEIVE_PORT, sock=self._shared_sock)
402 self.receiver.sensor_data_received.connect(self.on_sensor_data)
403 self.receiver.board_heartbeat_received.connect(self._on_board_heartbeat)
404 self.receiver.status_update.connect(self.status_label.setText)
405 self.receiver.start()
406
407 self.update_timer = QtCore.QTimer(self)
408 self.update_timer.timeout.connect(self.update_plots)
409 self.update_timer.start(UPDATE_INTERVAL_MS)
410
411 # ----- UI -----
412
413 def _build_ui(self):
414 central = QtWidgets.QWidget()
415 self.setCentralWidget(central)
416 layout = QtWidgets.QHBoxLayout(central)
417
418 # Left: actuator controls
419 left = QtWidgets.QWidget()
420 left_layout = QtWidgets.QVBoxLayout(left)
421
422 net_row = QtWidgets.QHBoxLayout()
423 net_row.addWidget(QtWidgets.QLabel("Actuator IP:"))
424 self.ip_edit = QtWidgets.QLineEdit(self.device_ip)
425 self.ip_edit.setToolTip(
426 "Target for commands, server heartbeat, and actuator config. "
427 "Auto-filled from BOARD_HEARTBEAT source; last heartbeat wins if multiple boards."
428 )
429 net_row.addWidget(self.ip_edit)
430 net_row.addWidget(QtWidgets.QLabel("Cmd port:"))
431 self.port_spin = QtWidgets.QSpinBox()
432 self.port_spin.setRange(1, 65535)
433 self.port_spin.setValue(self.device_port)
434 net_row.addWidget(self.port_spin)
435 left_layout.addLayout(net_row)
436
437 hb_grp = QtWidgets.QGroupBox("Board (heartbeats)")
438 hb_layout = QtWidgets.QVBoxLayout(hb_grp)
439 self.connection_label = QtWidgets.QLabel("Connection: —")
440 self.connection_label.setStyleSheet("font-weight: bold;")
441 hb_layout.addWidget(self.connection_label)
442 self.heartbeat_rate_label = QtWidgets.QLabel("Heartbeat rate: — Hz")
443 self.heartbeat_rate_label.setStyleSheet("font-weight: bold;")
444 hb_layout.addWidget(self.heartbeat_rate_label)
445 self.board_state_label = QtWidgets.QLabel("Board state: —")
446 hb_layout.addWidget(self.board_state_label)
447 self.board_id_label = QtWidgets.QLabel("Board ID: —")
448 hb_layout.addWidget(self.board_id_label)
449 self.board_ip_label = QtWidgets.QLabel("Source: —")
450 hb_layout.addWidget(self.board_ip_label)
451 self.firmware_hash_label = QtWidgets.QLabel("Firmware hash: —")
452 hb_layout.addWidget(self.firmware_hash_label)
453
454 self.send_heartbeat_cb = QtWidgets.QCheckBox("Send server heartbeat")
455 self.send_heartbeat_cb.stateChanged.connect(self._on_send_heartbeat_changed)
456 hb_layout.addWidget(self.send_heartbeat_cb)
457
458 self.send_actuator_config_btn = QtWidgets.QPushButton(
459 "Send actuator config (minimal)"
460 )
461 self.send_actuator_config_btn.setToolTip(
462 "Sends DAQv2-Comms ACTUATOR_CONFIG: not abort controller, serial printing on, no abort tables."
463 )
464 self.send_actuator_config_btn.clicked.connect(
466 )
467 hb_layout.addWidget(self.send_actuator_config_btn)
468
469 left_layout.addWidget(hb_grp)
470
471 # Graph channel toggles (similar to sense_testing_gui)
472 chan_row = QtWidgets.QHBoxLayout()
473 chan_row.addWidget(QtWidgets.QLabel("Graph actuators:"))
474 self.channel_checkboxes: Dict[int, QtWidgets.QCheckBox] = {}
475 for aid in range(1, NUM_ACTUATORS + 1):
476 cb = QtWidgets.QCheckBox(f"A{aid}")
477 cb.setChecked(True)
478 r, g, b = SENSOR_COLORS[(aid - 1) % len(SENSOR_COLORS)]
479 cb.setStyleSheet(
480 f"QCheckBox {{ color: rgb({r},{g},{b}); font-weight: bold; }}"
481 )
482 cb.stateChanged.connect(
483 lambda state, a=aid: self.on_channel_toggled(a, state)
484 )
485 self.channel_checkboxes[aid] = cb
486 chan_row.addWidget(cb)
487 chan_row.addStretch()
488 left_layout.addLayout(chan_row)
489
490 # Voltage / current toggle
491 mode_row = QtWidgets.QHBoxLayout()
492 self.show_current_cb = QtWidgets.QCheckBox("Show current (A)")
493 self.show_current_cb.setToolTip(
494 "When checked, convert sense voltage to current using V/20/0.05."
495 )
496 self.show_current_cb.stateChanged.connect(self.on_show_current_toggled)
497 mode_row.addWidget(self.show_current_cb)
498 mode_row.addStretch()
499 left_layout.addLayout(mode_row)
500
501 grid = QtWidgets.QGridLayout()
502 self.button_map: Dict[int, QtWidgets.QPushButton] = {}
503 self.voltage_labels: Dict[int, QtWidgets.QLabel] = {}
504
505 for i in range(NUM_ACTUATORS):
506 actuator_id = i + 1
507 row = i // 2
508 col = (i % 2) * 2
509
510 btn = QtWidgets.QPushButton(f"Actuator {actuator_id}")
511 btn.setCheckable(True)
512 btn.clicked.connect(
513 lambda checked, aid=actuator_id: self.on_actuator_toggled(aid, checked)
514 )
515
516 vlabel = QtWidgets.QLabel("0.000 V")
517 vlabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
518 vlabel.setStyleSheet(
519 "font-size: 9pt; padding: 0 2px; color: white; background-color: transparent;"
520 )
521
522 self.button_map[actuator_id] = btn
523 self.voltage_labels[actuator_id] = vlabel
524
525 grid.addWidget(btn, row, col)
526 grid.addWidget(vlabel, row, col + 1)
527
528 left_layout.addLayout(grid)
529 left_layout.addStretch()
530
531 self.status_label = QtWidgets.QLabel("Ready")
532 self.status_label.setStyleSheet("color: #8ab; font-size: 11px;")
533 left_layout.addWidget(self.status_label)
534
535 # Right: plot
536 right = QtWidgets.QWidget()
537 right_layout = QtWidgets.QVBoxLayout(right)
538
539 self.plot_widget = pg.PlotWidget(background="#1e1e1e")
540 axis_style = {"color": "#FFFFFF", "font-size": "11pt"}
541 self.plot_widget.setLabel("left", "Current sense (V)", **axis_style)
542 self.plot_widget.setLabel("bottom", "Time (s)", **axis_style)
543 self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
544 self.plot_widget.enableAutoRange(axis="y")
545 self.plot_widget.addLegend(offset=(10, 10))
546
547 for axis_name in ("left", "bottom"):
548 ax = self.plot_widget.getPlotItem().getAxis(axis_name)
549 try:
550 if hasattr(ax, "setStyle"):
551 ax.setStyle(tickTextColor="#FFFFFF", tickFont=QtGui.QFont(None, 10))
552 except Exception:
553 pass
554
555 self.curves: Dict[int, pg.PlotDataItem] = {}
556 for actuator_id in range(1, NUM_ACTUATORS + 1):
557 r, g, b = SENSOR_COLORS[(actuator_id - 1) % len(SENSOR_COLORS)]
558 pen = pg.mkPen(color=(r, g, b), width=2)
559 curve = self.plot_widget.plot([], [], pen=pen, name=f"A{actuator_id}")
560 self.curves[actuator_id] = curve
561
562 right_layout.addWidget(self.plot_widget)
563
564 layout.addWidget(left, stretch=0)
565 layout.addWidget(right, stretch=1)
566
567 # ----- Networking helpers -----
568
570 self,
571 timestamp: float,
572 board_id: int,
573 board_state: int,
574 engine_state: int,
575 source_ip: str,
576 firmware_hash: bytes,
577 ):
578 self.heartbeat_timestamps.append(timestamp)
579 self.last_heartbeat_time = timestamp
580 self.board_id = board_id
581 self.board_state = board_state
582 self.board_source_ip = source_ip
583 self.firmware_hash = firmware_hash
584 full_hex = firmware_hash.hex()
585 short_hex = full_hex[:16] + "..."
586 self.firmware_hash_label.setText(f"Firmware hash: {short_hex}")
587 self.firmware_hash_label.setToolTip(f"SHA-256: {full_hex}")
588 self.ip_edit.setText(source_ip)
589
590 def _on_send_heartbeat_changed(self, state: int):
591 if state == QtCore.Qt.CheckState.Checked.value:
592 self._server_heartbeat_timer.start(SERVER_HEARTBEAT_INTERVAL_MS)
593 else:
595
597 ip = self.ip_edit.text().strip()
598 if not ip:
599 self.status_label.setText("Send status: No target IP")
600 return
601 try:
602 port = int(self.port_spin.value())
604 self._send_sock.sendto(pkt, (ip, port))
605 self.status_label.setText(f"Send status: Server HB to {ip}:{port}")
606 except Exception as e:
607 self.status_label.setText(f"Send status: Error — {e}")
608
610 ip = self.ip_edit.text().strip()
611 if not ip:
612 self.status_label.setText("Send status: No target IP")
613 return
614 try:
615 port = int(self.port_spin.value())
617 self._send_sock.sendto(pkt, (ip, port))
618 self.status_label.setText(f"Send status: Actuator config to {ip}:{port}")
619 except Exception as e:
620 self.status_label.setText(f"Send status: Error — {e}")
621
622 def on_actuator_toggled(self, actuator_id: int, checked: bool):
623 state = 1 if checked else 0
624 pkt = create_actuator_command_packet([(actuator_id, state)])
625 if not pkt:
626 return
627 ip = self.ip_edit.text().strip() or self.device_ip
628 port = int(self.port_spin.value())
629 try:
630 self._send_sock.sendto(pkt, (ip, port))
631 self.status_label.setText(
632 f"Sent command: actuator {actuator_id} -> {state}"
633 )
634 except OSError as e:
635 self.status_label.setText(f"Send error: {e}")
636
637 def on_sensor_data(self, header: dict, chunks: List[dict], source_ip: str):
638 if not chunks:
639 return
640
641 latest = chunks[-1]
642 now = time.time()
643 t0 = getattr(self, "_t0", None)
644 if t0 is None:
645 t0 = now
646 self._t0 = t0
647 t_rel = now - t0
648
649 for dp in latest["datapoints"]:
650 sid = int(dp.get("sensor_id", -1))
651 data_u32 = int(dp.get("data", 0))
652
653 if 1 <= sid <= NUM_ACTUATORS:
654 actuator_id = sid
655 elif 0 <= sid < NUM_ACTUATORS:
656 actuator_id = sid + 1
657 else:
658 continue
659
660 voltage = actuator_counts_to_volts(data_u32)
661
662 hist = self.voltage_history[actuator_id]
663 hist.append((t_rel, float(voltage)))
664
665 if self.show_current:
666 v_shunt = voltage / 20.0
667 current = v_shunt / 0.05
668 self.voltage_labels[actuator_id].setText(f"{current:.3f} A")
669 else:
670 self.voltage_labels[actuator_id].setText(f"{voltage:.3f} V")
671
672 def on_channel_toggled(self, actuator_id: int, state: int):
673 enabled = state == QtCore.Qt.CheckState.Checked.value
674 self.plot_enabled[actuator_id] = enabled
675 curve = self.curves.get(actuator_id)
676 if curve is not None:
677 curve.setVisible(enabled)
678
679 def on_show_current_toggled(self, state: int):
680 self.show_current = state == QtCore.Qt.CheckState.Checked.value
681 axis_style = {"color": "#FFFFFF", "font-size": "11pt"}
682 if self.show_current:
683 self.plot_widget.setLabel("left", "Current (A)", **axis_style)
684 else:
685 self.plot_widget.setLabel("left", "Current sense (V)", **axis_style)
686 self.update_plots()
687
689 now = time.time()
690 if len(self.heartbeat_timestamps) >= 2:
691 span = self.heartbeat_timestamps[-1] - self.heartbeat_timestamps[0]
692 rate = (len(self.heartbeat_timestamps) - 1) / span if span > 0 else 0
693 self.heartbeat_rate_label.setText(f"Heartbeat rate: {rate:.2f} Hz")
694 else:
695 self.heartbeat_rate_label.setText("Heartbeat rate: — Hz")
696
697 if self.board_state is not None:
698 self.board_state_label.setText(
699 f"Board state: {board_state_name(self.board_state)}"
700 )
701 else:
702 self.board_state_label.setText("Board state: —")
703
704 self.board_id_label.setText(
705 f"Board ID: {self.board_id if self.board_id is not None else '—'}"
706 )
707 self.board_ip_label.setText(f"Source: {self.board_source_ip or '—'}")
708
709 if self.last_heartbeat_time is not None:
710 if now - self.last_heartbeat_time > HEARTBEAT_TIMEOUT_SEC:
711 self.connection_label.setText("Connection: Lost")
712 self.connection_label.setStyleSheet(
713 "font-weight: bold; color: #e74c3c;"
714 )
715 else:
716 self.connection_label.setText("Connection: Connected")
717 self.connection_label.setStyleSheet(
718 "font-weight: bold; color: #2ecc71;"
719 )
720 else:
721 self.connection_label.setText("Connection: —")
722 self.connection_label.setStyleSheet("font-weight: bold;")
723
724 def update_plots(self):
726 for actuator_id, curve in self.curves.items():
727 if not self.plot_enabled.get(actuator_id, True):
728 continue
729 hist = self.voltage_history[actuator_id]
730 if not hist:
731 continue
732 xs, ys = zip(*hist)
733 t_max = xs[-1]
734 t_min = max(0.0, t_max - self.window_seconds)
735 xs_plot = []
736 ys_plot = []
737 for x, v in hist:
738 if x < t_min:
739 continue
740 if self.show_current:
741 v_shunt = v / 20.0
742 value = v_shunt / 0.05
743 else:
744 value = v
745 xs_plot.append(x)
746 ys_plot.append(value)
747 curve.setData(xs_plot, ys_plot)
748
749 def closeEvent(self, event: QtGui.QCloseEvent):
751 if self.receiver:
752 self.receiver.stop()
753 self.receiver.wait(2000)
754 try:
755 self._send_sock.close()
756 except Exception:
757 pass
758 self.update_timer.stop()
759 event.accept()
760
761
762def main():
763 app = QtWidgets.QApplication(sys.argv)
765 win.show()
766 sys.exit(app.exec())
767
768
769if __name__ == "__main__":
770 main()
#define max(a, b)
Definition Arduino.h:43
on_actuator_toggled(self, int actuator_id, bool checked)
on_channel_toggled(self, int actuator_id, int state)
__init__(self, int port, str bind_address="0.0.0.0", Optional[socket.socket] sock=None)
static void stop(emmc_log_t *log, emmc_disk_state_t why)
Definition emmc_log.c:262
static void start(gnss_frame_parser_t *p, uint8_t byte)
Definition gnss_frame.c:45
bytes create_actuator_command_packet(List[Tuple[int, int]] commands)
str board_state_name(int state)
Optional[Tuple[dict, List[dict]]] parse_sensor_data_packet(bytes data)
Optional[Tuple[int, int, int]] parse_packet_header(bytes data)
bytes _make_header(int packet_type)
Optional[Tuple[tuple, bytes, int, int, int]] parse_board_heartbeat_packet(bytes data)
float actuator_counts_to_volts(int data_u32)
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
int main()