STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
sense_testing_gui.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Sense Testing GUI
4Receives BOARD_HEARTBEAT and SENSOR_DATA UDP packets from DAQv2-Comms boards.
5- Plots sensor data as voltage over time.
6- Tracks board heartbeat rate, current board state, and connection status (lost when heartbeats stop).
7- Send server heartbeat, sensor config, and abort packets to boards.
8
9Requirements: pip install pyqt6 pyqtgraph numpy
10"""
11
12import json
13import os
14import socket
15import struct
16import subprocess
17import sys
18import time
19from collections import deque
20from datetime import datetime
21from pathlib import Path
22from typing import Optional, Tuple, List, Dict, Callable
23import shutil
24
25LEADERBOARD_JSON = Path(__file__).resolve().parent / "sense_testing_leaderboard.json"
26
27# Fix Qt platform on macOS
28if sys.platform == "darwin":
29 import os
30
31 _qt_plugins = os.environ.get("QT_QPA_PLATFORM_PLUGIN_PATH")
32 if not _qt_plugins or not os.path.isdir(_qt_plugins):
33 _candidates = [
34 "/opt/homebrew/share/qt/plugins/platforms",
35 "/opt/homebrew/opt/qt/share/qt/plugins/platforms",
36 "/usr/local/share/qt/plugins/platforms",
37 "/usr/local/opt/qt/share/qt/plugins/platforms",
38 "/opt/homebrew/Cellar/qtbase/6.10.1/share/qt/plugins/platforms",
39 ]
40 if os.path.isdir("/opt/homebrew/Cellar/qtbase"):
41 for _name in sorted(
42 os.listdir("/opt/homebrew/Cellar/qtbase"), reverse=True
43 ):
44 _p = os.path.join(
45 "/opt/homebrew/Cellar/qtbase",
46 _name,
47 "share",
48 "qt",
49 "plugins",
50 "platforms",
51 )
52 if os.path.isdir(_p):
53 _candidates.insert(1, _p)
54 break
55 for _p in _candidates:
56 if os.path.isdir(_p) and any(
57 _f.startswith("libqcocoa") for _f in os.listdir(_p)
58 ):
59 os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = _p
60 break
61
62from PyQt6 import QtCore, QtGui, QtWidgets
63import pyqtgraph as pg
64import numpy as np
65
66# Protocol constants (DAQv2-Comms)
67MAX_PACKET_SIZE = 512
68PACKET_HEADER_FORMAT = "<BBI"
69PACKET_HEADER_SIZE = 6
70BOARD_HEARTBEAT_BODY_FORMAT = (
71 "<32sBBB" # firmware_hash[32], board_id, engine_state, board_state
72)
73BOARD_HEARTBEAT_BODY_SIZE = 35
74PacketType = type(
75 "PacketType",
76 (),
77 {
78 "BOARD_HEARTBEAT": 1,
79 "SERVER_HEARTBEAT": 2,
80 "SENSOR_DATA": 3,
81 "SENSOR_CONFIG": 5,
82 "ABORT": 7,
83 "CLEAR_ABORT": 9,
84 "NO_CONNECTION_ABORT": 11,
85 "SELF_TEST": 12,
86 },
87)()
88BoardState = type(
89 "BoardState",
90 (),
91 {
92 "SETUP": 1,
93 "ACTIVE": 2,
94 "CONNECTION_LOSS_DETECTED": 3,
95 "NO_CONNECTION_ABORT": 4,
96 "NO_CONN_ABORT_FOLLOWER": 5,
97 "PT_ABORT": 6,
98 "NO_PT_ABORT": 7,
99 "ABORT_FINISHED": 8,
100 "STANDALONE_ABORT": 9,
101 "SELF_TEST": 10,
102 },
103)()
104DIABLO_COMMS_VERSION = 0
105SENSOR_DATA_PACKET_FORMAT = "<BB"
106SENSOR_DATA_PACKET_SIZE = 2
107SENSOR_DATA_CHUNK_FORMAT = "<I"
108SENSOR_DATA_CHUNK_SIZE = 4
109SENSOR_DATAPOINT_FORMAT = "<BI"
110SENSOR_DATAPOINT_SIZE = 5
111
112DEFAULT_PORT = 5006
113DEFAULT_REF_VOLTAGE = (
114 2.5 # Internal ref / PT typical; use 3.3 for TC_Board VDD reference
115)
116DEFAULT_WINDOW_SECONDS = 10.0
117MAX_POINTS = 10000
118MAX_DISPLAY_CHANNELS = 10 # checkboxes Ch 1 .. Ch 10
119UPDATE_INTERVAL_MS = 50
120HEARTBEAT_TIMEOUT_SEC = (
121 2.5 # Show "Connection: Lost" after this many seconds without a heartbeat
122)
123HEARTBEAT_RATE_WINDOW = 20 # number of heartbeats to compute rate
124SERVER_HEARTBEAT_INTERVAL_MS = 500 # send server heartbeat every 500 ms when enabled
125DEFAULT_SEND_PORT = 5005 # boards typically listen on 5005 for server packets
126DEFAULT_TARGET_IP = "192.168.2.101" # Stream_ADC_Data board default static IP
127
128SENSOR_COLORS = [
129 (255, 80, 80),
130 (80, 255, 80),
131 (80, 150, 255),
132 (255, 200, 80),
133 (200, 80, 255),
134 (80, 255, 255),
135 (255, 150, 150),
136 (150, 255, 150),
137 (150, 200, 255),
138 (255, 255, 80),
139]
140
141
142def parse_sensor_ids_from_text(text: str) -> List[int]:
143 """Parse a comma/space-separated list of sensor IDs into a cleaned uint8 list."""
144 if not text:
145 return []
146 ids: List[int] = []
147 for token in text.replace(",", " ").split():
148 try:
149 v = int(token, 10)
150 except ValueError:
151 continue
152 if 0 <= v <= 255:
153 ids.append(v)
154 # Deduplicate while preserving order
155 seen = set()
156 unique_ids: List[int] = []
157 for v in ids:
158 if v not in seen:
159 seen.add(v)
160 unique_ids.append(v)
161 return unique_ids
162
163
164class OTAWorker(QtCore.QThread):
165 progress = QtCore.pyqtSignal(str)
166 finished = QtCore.pyqtSignal(bool, str)
167
169 self,
170 project_dir: Path,
171 env_name: str,
172 ip: str,
173 port: int,
174 board_id: Optional[int] = None,
175 ):
176 super().__init__()
177 self._project_dir = project_dir
178 self._env_name = env_name
179 self._ip = ip
180 self._port = port
181 self._board_id = board_id
182
183 def run(self):
184 try:
185 if self.isInterruptionRequested():
186 self.finished.emit(False, "OTA cancelled.")
187 return
188 self.progress.emit("[OTA] Compiling firmware...")
189 bin_path = compile_firmware(
190 self._project_dir, self._env_name, board_id=self._board_id
191 )
192 if self.isInterruptionRequested():
193 self.finished.emit(False, "OTA cancelled.")
194 return
195 self.progress.emit(f"[OTA] Build OK: {bin_path}")
196 self.progress.emit("[OTA] Starting upload...")
198 bin_path, self._ip, self._port, progress_cb=self.progress.emit
199 )
200 if self.isInterruptionRequested():
201 self.finished.emit(False, "OTA cancelled.")
202 return
203 self.finished.emit(True, "OTA upload finished.")
204 except Exception as e:
205 self.finished.emit(False, f"OTA error: {e}")
206
207
208def parse_packet_header(data: bytes) -> Optional[Tuple[int, int, int]]:
209 if len(data) < PACKET_HEADER_SIZE:
210 return None
211 try:
212 return struct.unpack(PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE])
213 except struct.error:
214 return None
215
216
218 data: bytes,
219) -> Optional[Tuple[tuple, bytes, int, int, int]]:
220 """Returns (header, firmware_hash, board_id, engine_state, board_state) or None.
221
222 firmware_hash is the 32-byte SHA-256 of the running firmware binary.
223 """
224 if len(data) < PACKET_HEADER_SIZE + BOARD_HEARTBEAT_BODY_SIZE:
225 return None
226 header = parse_packet_header(data)
227 if header is None or header[0] != PacketType.BOARD_HEARTBEAT:
228 return None
229 try:
230 body = struct.unpack(
231 BOARD_HEARTBEAT_BODY_FORMAT,
232 data[PACKET_HEADER_SIZE : PACKET_HEADER_SIZE + BOARD_HEARTBEAT_BODY_SIZE],
233 )
234 except struct.error:
235 return None
236 # body: (firmware_hash_bytes, board_id, engine_state, board_state)
237 return (header, body[0], body[1], body[2], body[3])
238
239
240def parse_self_test_packet(data: bytes) -> Optional[Tuple[dict, List[dict]]]:
241 """
242 Parse SELF_TEST packet sent by Hotfire boards.
243
244 Wire format (after 6-byte header):
245 - uint8 adc_good
246 - uint8 num_sensors (N)
247 - N * (uint8 sensor_id, uint8 result)
248 """
249 if len(data) < PACKET_HEADER_SIZE + 2:
250 return None
251 header = parse_packet_header(data)
252 if header is None or header[0] != PacketType.SELF_TEST:
253 return None
254 offset = PACKET_HEADER_SIZE
255 try:
256 adc_good = data[offset]
257 num_sensors = data[offset + 1]
258 except IndexError:
259 return None
260 offset += 2
261 expected_size = PACKET_HEADER_SIZE + 2 + (num_sensors * 2)
262 if len(data) < expected_size:
263 return None
264 results: List[dict] = []
265 for _ in range(num_sensors):
266 if offset + 2 > len(data):
267 return None
268 sensor_id = data[offset]
269 result = data[offset + 1]
270 results.append({"sensor_id": sensor_id, "result": result})
271 offset += 2
272 header_dict = {
273 "packet_type": header[0],
274 "version": header[1],
275 "timestamp": header[2],
276 "adc_good": adc_good,
277 "num_sensors": num_sensors,
278 }
279 return (header_dict, results)
280
281
282def board_state_name(state: int) -> str:
283 names = {
284 BoardState.SETUP: "Setup",
285 BoardState.ACTIVE: "Active",
286 BoardState.CONNECTION_LOSS_DETECTED: "Conn Loss",
287 BoardState.NO_CONNECTION_ABORT: "No Conn Abort",
288 BoardState.NO_CONN_ABORT_FOLLOWER: "No Conn Follower",
289 BoardState.PT_ABORT: "PT Abort",
290 BoardState.NO_PT_ABORT: "No PT Abort",
291 BoardState.ABORT_FINISHED: "Abort Finished",
292 BoardState.STANDALONE_ABORT: "Standalone Abort",
293 BoardState.SELF_TEST: "Self Test",
294 }
295 return names.get(state, f"Unknown ({state})")
296
297
298def parse_sensor_data_packet(data: bytes) -> Optional[Tuple[dict, List[dict]]]:
299 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
300 return None
301 header = parse_packet_header(data)
302 if header is None or header[0] != PacketType.SENSOR_DATA:
303 return None
304 offset = PACKET_HEADER_SIZE
305 try:
306 num_chunks, num_sensors = struct.unpack(
307 SENSOR_DATA_PACKET_FORMAT, data[offset : offset + SENSOR_DATA_PACKET_SIZE]
308 )
309 except struct.error:
310 return None
311 offset += SENSOR_DATA_PACKET_SIZE
312 per_chunk_size = SENSOR_DATA_CHUNK_SIZE + (num_sensors * SENSOR_DATAPOINT_SIZE)
313 expected_size = (
314 PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + (num_chunks * per_chunk_size)
315 )
316 if len(data) < expected_size:
317 return None
318 chunks = []
319 for _ in range(num_chunks):
320 (chunk_ts,) = struct.unpack(
321 SENSOR_DATA_CHUNK_FORMAT, data[offset : offset + SENSOR_DATA_CHUNK_SIZE]
322 )
323 offset += SENSOR_DATA_CHUNK_SIZE
324 datapoints = []
325 for _ in range(num_sensors):
326 sensor_id, raw_data = struct.unpack(
327 SENSOR_DATAPOINT_FORMAT, data[offset : offset + SENSOR_DATAPOINT_SIZE]
328 )
329 offset += SENSOR_DATAPOINT_SIZE
330 datapoints.append({"sensor_id": sensor_id, "data": raw_data})
331 chunks.append({"timestamp": chunk_ts, "datapoints": datapoints})
332 return (header, chunks)
333
334
335def code_to_voltage(code_uint32: int, ref_voltage: float) -> float:
336 """Convert raw ADC code to voltage. Assumes 32-bit signed ADC."""
337 code_int32 = struct.unpack("i", struct.pack("I", code_uint32))[0]
338 return (code_int32 * ref_voltage) / 2147483648.0
339
340
341# K-type thermocouple: voltage (V) -> temperature (°C), ITS-90 rational polynomial (Mosaic/NIST-style)
342# Each row: (v_min_mV, v_max_mV, T0, V0, p1, p2, p3, p4, q1, q2, q3)
343_K_TYPE_INVERSE = (
344 (
345 -6.404,
346 -3.554,
347 -121.47164,
348 -4.1790858,
349 36.069513,
350 30.722076,
351 7.791386,
352 0.52593997,
353 0.93939547,
354 0.2779128,
355 0.02516334,
356 ),
357 (
358 -3.554,
359 4.096,
360 -8.7935962,
361 -0.34489914,
362 25.678719,
363 -0.49887904,
364 -0.44705222,
365 -0.044869202,
366 0.00023893439,
367 -0.02039775,
368 -0.0018424107,
369 ),
370 (
371 4.096,
372 16.397,
373 310.18976,
374 12.631386,
375 24.061949,
376 4.0158622,
377 0.26853917,
378 -0.0097188544,
379 0.16995872,
380 0.011413069,
381 -0.00039275155,
382 ),
383 (
384 16.397,
385 33.275,
386 605.72562,
387 25.148718,
388 23.539401,
389 0.046547228,
390 0.0134444,
391 0.0005923685,
392 0.00083445513,
393 0.0004612144,
394 0.00002548812,
395 ),
396 (
397 33.275,
398 69.553,
399 1018.4705,
400 41.99385,
401 25.783239,
402 -1.8363403,
403 0.05617666,
404 0.000185324,
405 -0.074803355,
406 0.002384186,
407 0.0,
408 ),
409)
410
411
412def k_type_voltage_to_temperature_c(v_volts: float) -> Optional[float]:
413 """Convert K-type thermocouple voltage (in V) to temperature (°C). Returns None if out of range."""
414 v_mv = v_volts * 1000.0
415 for row in _K_TYPE_INVERSE:
416 v_lo, v_hi, t0, v0, p1, p2, p3, p4, q1, q2, q3 = row
417 if v_lo <= v_mv <= v_hi:
418 x = v_mv - v0
419 num = p1 + x * (p2 + x * (p3 + x * p4))
420 den = 1.0 + x * (q1 + x * (q2 + x * q3))
421 if abs(den) < 1e-20:
422 return None
423 return t0 + x * num / den
424 return None
425
426
427# Pt1000 RTD: differential voltage (V) -> resistance -> temperature (°C), IEC 60751
428# RTD_Testing uses ADS126X_IDAC_MAG_1000 = 1000 µA (both IDAC1 and IDAC2 set to 1000 µA).
429# R = |V_diff| / I_excitation (use |V| so reversed wiring still gives valid R).
430# R(T) = R0*(1 + A*T + B*T^2) for T >= 0; solve for T
431PT1000_R0 = 1000.0 # ohms at 0°C
432PT1000_A = 3.9083e-3
433PT1000_B = -5.775e-7
434PT1000_EXCITATION_UA = 1000.0 # match RTD_Testing: ADS126X_IDAC_MAG_1000
435
436
438 v_volts: float, excitation_ua: float = 1000.0
439) -> Optional[float]:
440 """Convert Pt1000 RTD differential voltage (V) to temperature (°C). excitation_ua = IDAC current in µA (default 1000). Returns None if out of range."""
441 if excitation_ua <= 0:
442 return None
443 # Use abs so reversed differential polarity (e.g. one channel wired opposite) still gives valid R
444 r_ohm = (
445 abs(v_volts) * 1e6
446 ) / excitation_ua # R = V/I, I in A = excitation_ua * 1e-6
447 rr = r_ohm / PT1000_R0
448 d = PT1000_A * PT1000_A - 4 * PT1000_B * (1 - rr)
449 if d < 0:
450 return None
451 sqrt_d = d**0.5
452 # Use the root that lies in physical range: (-A + sqrt_d)/(2*B) gives 0–850°C for rr>=1, <0°C for rr<1
453 t = (-PT1000_A + sqrt_d) / (2 * PT1000_B)
454 # Allow wide range so both channels display (e.g. ch1 negative voltage -> cold reading)
455 if -400 <= t <= 1100:
456 return t
457 return None
458
459
460# 32-bit signed ADC full-scale magnitude (codes)
461ADC32_FULL_SCALE = 2147483648.0
462
463
465 code_uint32: int,
466 adc_ref_voltage: float,
467 excitation_voltage: float,
468 sensitivity_mv_per_v: float,
469 pga_gain: float,
470 full_scale_force: float,
471) -> Optional[float]:
472 """
473 Ratiometric load-cell force from raw 32-bit ADC code (no voltage conversion).
474 Assumes 32-bit signed ADC: code / 2^31 = (V_ADC_input / V_ref).
475 At full-scale force: V_bridge_fs = excitation * (sensitivity_mV_V / 1000),
476 V_ADC_fs = V_bridge_fs * pga_gain, so code_fs = (V_ADC_fs / V_ref) * 2^31.
477 Force = (code / code_fs) * full_scale_force.
478 """
479 if (
480 adc_ref_voltage <= 0
481 or pga_gain <= 0
482 or excitation_voltage <= 0
483 or sensitivity_mv_per_v <= 0
484 ):
485 return None
486 code_int32 = struct.unpack("i", struct.pack("I", code_uint32))[0]
487 # code_fs = (V_exc * sens/1000 * pga_gain / V_ref) * 2^31
488 code_fs = (
489 excitation_voltage
490 * (sensitivity_mv_per_v / 1000.0)
491 * pga_gain
492 / adc_ref_voltage
493 ) * ADC32_FULL_SCALE
494 if code_fs <= 0:
495 return None
496 proportion = code_int32 / code_fs
497 return proportion * full_scale_force
498
499
500def find_pio_command() -> str:
501 """Try to find the 'pio' command in PATH or common installation locations (Windows)."""
502 pio_path = shutil.which("pio")
503 if pio_path:
504 return pio_path
505
506 if sys.platform == "win32":
507 user_profile = os.environ.get("USERPROFILE", "")
508 paths_to_check = [
509 os.path.join(user_profile, ".platformio", "penv", "Scripts", "pio.exe"),
510 os.path.join(
511 user_profile,
512 "AppData",
513 "Local",
514 "Programs",
515 "Python",
516 "Python313",
517 "Scripts",
518 "pio.exe",
519 ),
520 "C:\\Python313\\Scripts\\pio.exe",
521 ]
522 for path in paths_to_check:
523 if os.path.isfile(path):
524 return path
525
526 return "pio"
527
528
530 project_dir: Path, env_name: str, board_id: Optional[int] = None
531) -> Path:
532 """Compile a PlatformIO project and return firmware.bin path.
533
534 If board_id is provided, append a BOARD_ID build flag so Hotfire
535 firmware uses that ID instead of the default. This is done via the
536 PLATFORMIO_BUILD_FLAGS environment variable so it doesn't require changes
537 to platformio.ini.
538 """
539 env = os.environ.copy()
540 if board_id is not None:
541 try:
542 bid = int(board_id)
543 except (TypeError, ValueError):
544 bid = None
545 if bid is not None and 0 <= bid <= 254:
546 extra_flag = f"-DBOARD_ID={bid}"
547 existing = env.get("PLATFORMIO_BUILD_FLAGS", "").strip()
548 env["PLATFORMIO_BUILD_FLAGS"] = (
549 f"{existing} {extra_flag}".strip() if existing else extra_flag
550 )
551 pio_cmd = find_pio_command()
552 cmd = [pio_cmd, "run"]
553 if env_name:
554 cmd += ["-e", env_name]
555 result = subprocess.run(
556 cmd,
557 cwd=str(project_dir),
558 env=env,
559 capture_output=True,
560 text=True,
561 )
562 if result.returncode != 0:
563 raise RuntimeError(f"PlatformIO build failed (exit {result.returncode}).")
564
565 build_dir = project_dir / ".pio" / "build"
566 if env_name:
567 fw_path = build_dir / env_name / "firmware.bin"
568 if fw_path.is_file():
569 return fw_path
570 # Fallback: first firmware.bin under .pio/build
571 for root, _dirs, files in os.walk(build_dir):
572 if "firmware.bin" in files:
573 return Path(root) / "firmware.bin"
574 raise FileNotFoundError(
575 "firmware.bin not found under .pio/build; check env name or PlatformIO config."
576 )
577
578
580 bin_path: Path,
581 ip: str,
582 port: int,
583 progress_cb: Optional[Callable[[str], None]] = None,
584) -> None:
585 """Send firmware binary to ESP32 over TCP using Ethernet OTA protocol."""
586
587 def emit(msg: str) -> None:
588 if progress_cb is not None:
589 progress_cb(msg)
590
591 file_size = bin_path.stat().st_size
592 emit(f"[UPLOAD] Target {ip}:{port}, size {file_size} bytes")
593
594 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
595 sock.settimeout(5.0)
596 try:
597 sock.connect((ip, port))
598 except Exception as e:
599 sock.close()
600 raise RuntimeError(f"Failed to connect to {ip}:{port}: {e}") from e
601
602 sock.settimeout(30.0)
603 try:
604 header = struct.pack(">I", file_size)
605 sock.sendall(header)
606 emit("[UPLOAD] Sent size header")
607
608 sent = 0
609 last_percent = -1
610 start_time = time.time()
611 with open(bin_path, "rb") as f:
612 while sent < file_size:
613 chunk = f.read(4096)
614 if not chunk:
615 break
616 sock.sendall(chunk)
617 sent += len(chunk)
618 percent = int(sent * 100 / file_size)
619 if percent // 5 != last_percent // 5:
620 last_percent = percent
621 elapsed = max(time.time() - start_time, 1e-6)
622 rate_kb = sent / elapsed / 1024.0
623 emit(
624 f"[UPLOAD] {percent}% ({sent}/{file_size} bytes, {rate_kb:.1f} KB/s)"
625 )
626
627 emit("[UPLOAD] Transfer complete, waiting for OK...")
628 try:
629 resp = sock.recv(1024).decode(errors="ignore").strip()
630 except socket.timeout:
631 resp = ""
632 if "OK" in resp:
633 emit("[UPLOAD] ESP32 reported OK, rebooting.")
634 else:
635 emit(f"[UPLOAD] No explicit OK from ESP32 (response='{resp}')")
636 finally:
637 sock.close()
638
639
640def _make_header(packet_type: int) -> bytes:
641 """Build 6-byte packet header: type, version, timestamp (little-endian)."""
642 ts = int(time.time() * 1000) & 0xFFFFFFFF
643 return struct.pack(PACKET_HEADER_FORMAT, packet_type, DIABLO_COMMS_VERSION, ts)
644
645
647 """Server heartbeat: header + 1 byte engine_state (0 = SAFE)."""
648 return _make_header(PacketType.SERVER_HEARTBEAT) + struct.pack("<B", 0)
649
650
652 sensor_ids: List[int],
653 necessary_for_abort: bool,
654 controller_ip: Optional[str],
655 reference_voltage: int = 0,
656 enable_serial_printing: int = 0,
657) -> bytes:
658 """Build SENSOR_CONFIG packet matching DAQv2-Comms SensorConfigPacket layout.
659
660 Wire format after standard 6-byte header:
661 - uint8 num_sensors (N)
662 - N x uint8 sensor_id
663 - uint8 reference_voltage (0 = 2.5V internal, 1 = VDD, 2 = ignore/board default)
664 - uint8 necessary_for_abort (0/1)
665 - if necessary_for_abort: uint32 controller_ip (big-endian)
666 - uint8 enable_serial_printing (0/1)
667 """
668 # Clamp/clean sensor IDs to uint8
669 clean_ids: List[int] = []
670 for sid in sensor_ids:
671 try:
672 v = int(sid)
673 except (TypeError, ValueError):
674 continue
675 if 0 <= v <= 255:
676 clean_ids.append(v)
677
678 if len(clean_ids) > 255:
679 clean_ids = clean_ids[:255]
680
681 header = _make_header(PacketType.SENSOR_CONFIG)
682 body = bytearray()
683
684 # num_sensors + sensor_ids
685 body.append(len(clean_ids) & 0xFF)
686 body.extend(bytes(clean_ids))
687
688 # reference_voltage and necessary_for_abort
689 body.append(reference_voltage & 0xFF)
690 body.append(1 if necessary_for_abort else 0)
691
692 # optional controller_ip (big-endian) if necessary_for_abort is set and IP provided
693 if necessary_for_abort and controller_ip:
694 parts = [int(x) for x in controller_ip.strip().split(".")]
695 if len(parts) == 4 and all(0 <= p <= 255 for p in parts):
696 ip_be = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]
697 body.extend(struct.pack(">I", ip_be))
698
699 # enable_serial_printing byte
700 body.append(1 if enable_serial_printing else 0)
701
702 pkt = header + bytes(body)
703 if len(pkt) > MAX_PACKET_SIZE:
704 # Truncate rather than crash; board will treat as malformed.
705 pkt = pkt[:MAX_PACKET_SIZE]
706 return pkt
707
708
709def build_header_only_packet(packet_type: int) -> bytes:
710 """Abort, Clear Abort, No Connection Abort: header only."""
711 return _make_header(packet_type)
712
713
714class UDPReceiver(QtCore.QThread):
715 sensor_data_received = QtCore.pyqtSignal(dict, list, str)
716 sensor_data_packet_status = QtCore.pyqtSignal(
717 str, bool
718 ) # source_ip, parsed_ok (True=ok, False=malformed)
719 board_heartbeat_received = QtCore.pyqtSignal(
720 float, int, int, int, str, bytes
721 ) # timestamp, board_id, board_state, engine_state, source_ip, firmware_hash
722 status_update = QtCore.pyqtSignal(str)
723 self_test_received = QtCore.pyqtSignal(
724 dict, list, str
725 ) # header_dict, results, source_ip
726
728 self,
729 port: int,
730 bind_address: str = "0.0.0.0",
731 sock: Optional[socket.socket] = None,
732 ):
733 super().__init__()
734 self.port = port
735 self.bind_address = bind_address
736 self.sock = (
737 sock # if provided, use shared socket (main thread uses it for sendto too)
738 )
739 self._owned_socket = sock is None
740 self._stop = False
742 self.total_bytes = 0
743 self.start_time = None
744
745 def stop(self):
746 self._stop = True
747 if self._owned_socket and self.sock:
748 try:
749 self.sock.close()
750 except Exception:
751 pass
752
753 def get_stats(self) -> Dict:
754 if self.start_time is None:
755 return {
756 "packets": 0,
757 "bytes": 0,
758 "packets_per_sec": 0.0,
759 "bytes_per_sec": 0.0,
760 }
761 elapsed = time.time() - self.start_time
762 return {
763 "packets": self.total_packets,
764 "bytes": self.total_bytes,
765 "packets_per_sec": self.total_packets / elapsed if elapsed > 0 else 0,
766 "bytes_per_sec": self.total_bytes / elapsed if elapsed > 0 else 0,
767 }
768
769 def run(self):
770 if self.sock is None:
771 self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
772 self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
773 self.sock.settimeout(0.1)
774 try:
775 self.sock.bind((self.bind_address, self.port))
776 self.status_update.emit(f"Listening on {self.bind_address}:{self.port}")
777 except OSError as e:
778 self.status_update.emit(f"Error binding: {e}")
779 return
780 else:
781 self.sock.settimeout(0.1)
782 self.status_update.emit(f"Listening on {self.bind_address}:{self.port}")
783 self.start_time = time.time()
784 while not self._stop:
785 try:
786 data, addr = self.sock.recvfrom(MAX_PACKET_SIZE)
787 self.total_packets += 1
788 self.total_bytes += len(data)
789 header = parse_packet_header(data)
790 if not header:
791 continue
792 if header[0] == PacketType.BOARD_HEARTBEAT:
793 result = parse_board_heartbeat_packet(data)
794 if result:
795 _h, firmware_hash, board_id, engine_state, board_state = result
797 time.time(),
798 board_id,
799 board_state,
800 engine_state,
801 addr[0],
802 firmware_hash,
803 )
804 elif header[0] == PacketType.SELF_TEST:
805 result = parse_self_test_packet(data)
806 if result:
807 header_dict, results = result
808 self.self_test_received.emit(header_dict, results, addr[0])
809 elif header[0] == PacketType.SENSOR_DATA:
810 result = parse_sensor_data_packet(data)
811 self.sensor_data_packet_status.emit(addr[0], result is not None)
812 if result:
813 header_tuple, chunks_list = result
814 header_dict = {
815 "packet_type": header_tuple[0],
816 "version": header_tuple[1],
817 "timestamp": header_tuple[2],
818 }
820 header_dict, chunks_list, addr[0]
821 )
822 except socket.timeout:
823 continue
824 except Exception as e:
825 if not self._stop:
826 self.status_update.emit(f"Error: {e}")
827 if self._owned_socket and self.sock:
828 try:
829 self.sock.close()
830 except Exception:
831 pass
832 self.sock = None
833 self.status_update.emit("Stopped")
834
835
836class SenseTestingGUIWindow(QtWidgets.QMainWindow):
838 self, port: int = DEFAULT_PORT, ref_voltage: float = DEFAULT_REF_VOLTAGE
839 ):
840 super().__init__()
841 self.setWindowTitle("Sense Testing GUI")
842 self.resize(1000, 620)
843
844 self.ref_voltage = ref_voltage
848 self.sensor_data: Dict[int, deque] = {}
849 self.sensor_plots: Dict[int, pg.PlotDataItem] = {}
850 self.plot_enabled: Dict[int, bool] = {
851 i: True for i in range(1, MAX_DISPLAY_CHANNELS + 1)
852 }
853 self.channel_checkboxes: Dict[int, QtWidgets.QCheckBox] = {}
855 "packets": "0",
856 "pps": "0.0",
857 "bytes": "0 B",
858 "bps": "0.0 B/s",
859 }
860 self.window_seconds = DEFAULT_WINDOW_SECONDS
861 self.force_leaderboard: List[Tuple[str, float]] = (
862 []
863 ) # (name, force) sorted by force desc
864
865 # Board status (heartbeats)
866 self.heartbeat_timestamps: deque = deque(maxlen=HEARTBEAT_RATE_WINDOW)
867 self.last_heartbeat_time: Optional[float] = None
868 self.board_id: Optional[int] = None
869 self.board_state: Optional[int] = None
870 self.board_source_ip: Optional[str] = None
871 self.firmware_hash: Optional[bytes] = None
872
873 # One UDP socket bound to port: receiver uses it for recvfrom, we use it for sendto.
874 # Board sends board heartbeats to (our IP, this port), so we must send from this port.
875 self._send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
876 self._send_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
877 self._send_sock.settimeout(0.5)
878 try:
879 self._send_sock.bind(("", port))
880 self._shared_sock = self._send_sock # pass to receiver
881 except OSError:
882 self._send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
883 self._shared_sock = None # receiver will create its own
884 self._server_heartbeat_timer = QtCore.QTimer(self)
886
887 central = QtWidgets.QWidget()
888 self.setCentralWidget(central)
889 layout = QtWidgets.QVBoxLayout(central)
890
891 # Toolbar
892 toolbar = QtWidgets.QHBoxLayout()
893 toolbar.addWidget(QtWidgets.QLabel("Port:"))
894 self.port_spin = QtWidgets.QSpinBox()
895 self.port_spin.setRange(1024, 65535)
896 self.port_spin.setValue(port)
897 self.port_spin.setEnabled(False)
898 toolbar.addWidget(self.port_spin)
899
900 toolbar.addSpacing(20)
901 toolbar.addWidget(QtWidgets.QLabel("Reference voltage (V):"))
902 self.ref_voltage_spin = QtWidgets.QDoubleSpinBox()
903 self.ref_voltage_spin.setRange(0.1, 10.0)
904 self.ref_voltage_spin.setSingleStep(0.1)
905 self.ref_voltage_spin.setValue(ref_voltage)
906 self.ref_voltage_spin.setToolTip(
907 "Internal ref / PT: 2.5 V (default); TC_Board VDD: 3.3 V"
908 )
909 self.ref_voltage_spin.valueChanged.connect(self._on_ref_voltage_changed)
910 toolbar.addWidget(self.ref_voltage_spin)
911
912 toolbar.addSpacing(20)
913 toolbar.addWidget(QtWidgets.QLabel("Time window (s):"))
914 self.window_seconds_spin = QtWidgets.QDoubleSpinBox()
915 self.window_seconds_spin.setRange(0.5, 3600.0)
916 self.window_seconds_spin.setSingleStep(1.0)
917 self.window_seconds_spin.setValue(DEFAULT_WINDOW_SECONDS)
918 self.window_seconds_spin.setDecimals(1)
919 self.window_seconds_spin.setToolTip("X-axis time span (seconds) to display.")
920 self.window_seconds_spin.valueChanged.connect(self._on_window_seconds_changed)
921 toolbar.addWidget(self.window_seconds_spin)
922
923 toolbar.addSpacing(20)
924 self.show_temp_k_cb = QtWidgets.QCheckBox("Show temperature (K-type)")
925 self.show_temp_k_cb.setToolTip(
926 "Convert voltage to °C using K-type thermocouple calibration."
927 )
928 self.show_temp_k_cb.stateChanged.connect(self._on_show_temperature_k_changed)
929 toolbar.addWidget(self.show_temp_k_cb)
930
931 self.show_temp_pt1000_cb = QtWidgets.QCheckBox("Show temperature (Pt1000 RTD)")
932 self.show_temp_pt1000_cb.setToolTip(
933 "Convert differential voltage to °C for Pt1000 RTD (1000 µA excitation)."
934 )
935 self.show_temp_pt1000_cb.stateChanged.connect(
937 )
938 toolbar.addWidget(self.show_temp_pt1000_cb)
939
940 toolbar.addSpacing(20)
941 self.show_force_lc_cb = QtWidgets.QCheckBox("Show force (load cell)")
942 self.show_force_lc_cb.setToolTip(
943 "Ratiometric: force from raw 32-bit ADC code (no voltage step). Uses Reference voltage (V) as ADC ref, plus excitation, sensitivity (mV/V), PGA gain, full scale force."
944 )
945 self.show_force_lc_cb.stateChanged.connect(self._on_show_force_lc_changed)
946 toolbar.addWidget(self.show_force_lc_cb)
947
948 self.show_encoder_angle_cb = QtWidgets.QCheckBox("Show angle (encoder)")
949 self.show_encoder_angle_cb.setToolTip(
950 "Interpret raw uint32 as AS5600 12-bit angle (0–4095) and display as degrees (0–360°)."
951 )
952 self.show_encoder_angle_cb.stateChanged.connect(
954 )
955 toolbar.addWidget(self.show_encoder_angle_cb)
956
957 toolbar.addSpacing(20)
958 self.status_label = QtWidgets.QLabel("Starting…")
959 self.status_label.setStyleSheet("color: #aaa;")
960 toolbar.addWidget(self.status_label)
961 toolbar.addStretch()
962
963 layout.addLayout(toolbar)
964
965 # Load cell force conversion inputs
966 force_row = QtWidgets.QHBoxLayout()
967 force_row.addWidget(QtWidgets.QLabel("Excitation (V):"))
968 self.lc_excitation_spin = QtWidgets.QDoubleSpinBox()
969 self.lc_excitation_spin.setRange(0.1, 24.0)
970 self.lc_excitation_spin.setValue(5.0)
971 self.lc_excitation_spin.setDecimals(2)
972 self.lc_excitation_spin.setToolTip("Load cell excitation voltage in volts.")
973 force_row.addWidget(self.lc_excitation_spin)
974 force_row.addWidget(QtWidgets.QLabel("Sensitivity (mV/V):"))
975 self.lc_sensitivity_spin = QtWidgets.QDoubleSpinBox()
976 self.lc_sensitivity_spin.setRange(0.01, 100.0)
977 self.lc_sensitivity_spin.setValue(2.0)
978 self.lc_sensitivity_spin.setDecimals(3)
979 self.lc_sensitivity_spin.setToolTip(
980 "Load cell sensitivity at full scale, mV per V of excitation."
981 )
982 force_row.addWidget(self.lc_sensitivity_spin)
983 force_row.addWidget(QtWidgets.QLabel("PGA gain:"))
984 self.lc_pga_gain_spin = QtWidgets.QDoubleSpinBox()
985 self.lc_pga_gain_spin.setRange(1.0, 128.0)
986 self.lc_pga_gain_spin.setValue(32.0)
987 self.lc_pga_gain_spin.setDecimals(1)
988 self.lc_pga_gain_spin.setToolTip(
989 "PGA gain used on the board (e.g. 32 for ADS126X_GAIN_32)."
990 )
991 force_row.addWidget(self.lc_pga_gain_spin)
992 force_row.addWidget(QtWidgets.QLabel("Full scale force:"))
993 self.lc_full_scale_spin = QtWidgets.QDoubleSpinBox()
994 self.lc_full_scale_spin.setRange(0.001, 1e9)
995 self.lc_full_scale_spin.setValue(300.0)
996 self.lc_full_scale_spin.setDecimals(2)
997 self.lc_full_scale_spin.setToolTip(
998 "Force at full scale (same unit as desired display, e.g. N or lbf)."
999 )
1000 force_row.addWidget(self.lc_full_scale_spin)
1001 force_row.addStretch()
1002 layout.addLayout(force_row)
1003
1004 # Network hint: board sends to 192.168.2.20:5006 — PC must be that IP to receive; board listens on 5005
1005 self.network_hint = QtWidgets.QLabel(
1006 "To receive data: set this PC's Ethernet adapter to 192.168.2.20, subnet 255.255.255.0. "
1007 "Boards send to 192.168.2.20:5006. Boards listen on port 5005 — set Target port to 5005 to send commands."
1008 )
1009 self.network_hint.setStyleSheet("color: #8ab; font-size: 11px;")
1010 self.network_hint.setWordWrap(True)
1011 layout.addWidget(self.network_hint)
1012
1013 # Channel checkboxes (enable/disable graphing per channel)
1014 channel_row = QtWidgets.QHBoxLayout()
1015 channel_row.addWidget(QtWidgets.QLabel("Graph channels:"))
1016 for ch in range(1, MAX_DISPLAY_CHANNELS + 1):
1017 cb = QtWidgets.QCheckBox(f"Ch {ch}")
1018 cb.setChecked(True)
1019 r, g, b = SENSOR_COLORS[(ch - 1) % len(SENSOR_COLORS)]
1020 cb.setStyleSheet(
1021 f"QCheckBox {{ color: rgb({r},{g},{b}); font-weight: bold; }}"
1022 )
1023 cb.stateChanged.connect(
1024 lambda state, c=ch: self._on_channel_toggled(c, state)
1025 )
1026 self.channel_checkboxes[ch] = cb
1027 channel_row.addWidget(cb)
1028 channel_row.addStretch()
1029 layout.addLayout(channel_row)
1030
1031 # Main content: plot + board status panel
1032 content = QtWidgets.QHBoxLayout()
1033 # Plot (dark background — make axis labels and ticks white so they stand out)
1034 self.plot_widget = pg.PlotWidget(background="#1e1e1e")
1035 _axis_style = {"color": "#FFFFFF", "font-size": "11pt"}
1036 self.plot_widget.setLabel("left", "Voltage (V)", **_axis_style)
1037 self.plot_widget.setLabel("bottom", "Time (s)", **_axis_style)
1038 for axis_name in ("left", "bottom"):
1039 ax = self.plot_widget.getPlotItem().getAxis(axis_name)
1040 try:
1041 if hasattr(ax, "setStyle"):
1042 ax.setStyle(tickTextColor="#FFFFFF", tickFont=QtGui.QFont(None, 10))
1043 except Exception:
1044 pass
1045 try:
1046 if hasattr(ax, "label") and hasattr(ax.label, "setColor"):
1047 ax.label.setColor("#FFFFFF")
1048 except Exception:
1049 pass
1050 self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
1051 self.plot_widget.enableAutoRange(axis="y")
1052 self.plot_widget.addLegend(offset=(10, 10))
1053 content.addWidget(self.plot_widget, stretch=1)
1054
1055 # Board status panel
1056 board_panel = QtWidgets.QGroupBox("Board status")
1057 board_panel.setMinimumWidth(220)
1058 board_layout = QtWidgets.QVBoxLayout(board_panel)
1059 self.heartbeat_rate_label = QtWidgets.QLabel("Heartbeat rate: — Hz")
1060 self.heartbeat_rate_label.setStyleSheet("font-weight: bold;")
1061 board_layout.addWidget(self.heartbeat_rate_label)
1062 self.board_state_label = QtWidgets.QLabel("Board state: —")
1063 board_layout.addWidget(self.board_state_label)
1064 self.connection_label = QtWidgets.QLabel("Connection: —")
1065 self.connection_label.setStyleSheet("font-weight: bold;")
1066 board_layout.addWidget(self.connection_label)
1067 self.board_id_label = QtWidgets.QLabel("Board ID: —")
1068 board_layout.addWidget(self.board_id_label)
1069 self.board_ip_label = QtWidgets.QLabel("Source: —")
1070 board_layout.addWidget(self.board_ip_label)
1071 self.firmware_hash_label = QtWidgets.QLabel("Firmware hash: —")
1072 self.firmware_hash_label.setStyleSheet(
1073 "color: #8ab; font-size: 11px; font-family: monospace;"
1074 )
1075 board_layout.addWidget(self.firmware_hash_label)
1076 self.sensor_data_status_label = QtWidgets.QLabel("Sensor data packets: —")
1077 self.sensor_data_status_label.setStyleSheet("color: #8ab; font-size: 11px;")
1078 board_layout.addWidget(self.sensor_data_status_label)
1079 # Self-test results (from SELF_TEST packets)
1080 self.self_test_label = QtWidgets.QLabel("Self-test: —")
1081 self.self_test_label.setStyleSheet("font-weight: bold;")
1082 board_layout.addWidget(self.self_test_label)
1083 self.self_test_adc_label = QtWidgets.QLabel("ADC: —")
1084 board_layout.addWidget(self.self_test_adc_label)
1085 self.self_test_results_list = QtWidgets.QListWidget()
1086 self.self_test_results_list.setMaximumHeight(80)
1087 self.self_test_results_list.setStyleSheet("font-size: 11px;")
1088 board_layout.addWidget(self.self_test_results_list)
1089 board_layout.addStretch()
1090 content.addWidget(board_panel)
1091
1092 # Server / Commands panel
1093 cmd_panel = QtWidgets.QGroupBox("Server & commands")
1094 cmd_panel.setMinimumWidth(260)
1095 cmd_layout = QtWidgets.QVBoxLayout(cmd_panel)
1096
1097 self.send_heartbeat_cb = QtWidgets.QCheckBox("Send server heartbeat")
1098 self.send_heartbeat_cb.stateChanged.connect(self._on_send_heartbeat_changed)
1099 cmd_layout.addWidget(self.send_heartbeat_cb)
1100
1101 target_row = QtWidgets.QHBoxLayout()
1102 target_row.addWidget(QtWidgets.QLabel("Target IP:"))
1103 self.target_ip_edit = QtWidgets.QLineEdit()
1104 self.target_ip_edit.setText(DEFAULT_TARGET_IP)
1105 self.target_ip_edit.setPlaceholderText("e.g. 192.168.2.101")
1106 target_row.addWidget(self.target_ip_edit)
1107 cmd_layout.addLayout(target_row)
1108
1109 port_row = QtWidgets.QHBoxLayout()
1110 port_row.addWidget(QtWidgets.QLabel("Target port:"))
1111 self.target_port_spin = QtWidgets.QSpinBox()
1112 self.target_port_spin.setRange(1, 65535)
1113 self.target_port_spin.setValue(DEFAULT_SEND_PORT)
1114 self.target_port_spin.setToolTip(
1115 "Board listens on 5005. Use 5005 to send server heartbeat and commands."
1116 )
1117 port_row.addWidget(self.target_port_spin)
1118 cmd_layout.addLayout(port_row)
1119
1120 self.send_status_label = QtWidgets.QLabel("Send status: —")
1121 self.send_status_label.setStyleSheet("color: #8ab; font-size: 11px;")
1122 cmd_layout.addWidget(self.send_status_label)
1123
1124 # Sensor config
1125 sens_grp = QtWidgets.QGroupBox("Sensor config packet")
1126 sens_layout = QtWidgets.QVBoxLayout(sens_grp)
1127 self.necessary_for_abort_cb = QtWidgets.QCheckBox("Necessary for abort")
1128 sens_layout.addWidget(self.necessary_for_abort_cb)
1129 self.enable_serial_printing_cb = QtWidgets.QCheckBox(
1130 "Enable serial printing on board"
1131 )
1132 self.enable_serial_printing_cb.setToolTip(
1133 "If checked, sensor config packet tells the board to enable Serial output (DAQv2-Comms field)."
1134 )
1135 sens_layout.addWidget(self.enable_serial_printing_cb)
1136 sens_layout.addWidget(QtWidgets.QLabel("Sensor IDs (comma-separated):"))
1137 self.sensor_ids_edit = QtWidgets.QLineEdit()
1138 self.sensor_ids_edit.setPlaceholderText(
1139 "e.g. 1,2,3,10 (leave empty for board default)"
1140 )
1141 self.sensor_ids_edit.setText("1,2,3,4,5,6,7,8,9,10")
1142 sens_layout.addWidget(self.sensor_ids_edit)
1143 sens_layout.addWidget(QtWidgets.QLabel("Reference voltage:"))
1144 self.ref_voltage_combo = QtWidgets.QComboBox()
1145 self.ref_voltage_combo.addItem("Internal 2.5 V", 0)
1146 self.ref_voltage_combo.addItem("VDD (e.g. 3.3 V)", 1)
1147 self.ref_voltage_combo.addItem("Ignore / board default", 2)
1148 self.ref_voltage_combo.setCurrentIndex(0)
1149 self.ref_voltage_combo.setToolTip(
1150 "Reference voltage enum sent in SENSOR_CONFIG:\n"
1151 " - Internal 2.5 V\n"
1152 " - VDD (e.g. 3.3 V)\n"
1153 " - Ignore / board default"
1154 )
1155 sens_layout.addWidget(self.ref_voltage_combo)
1156 sens_layout.addWidget(QtWidgets.QLabel("Actuator controller IP:"))
1157 self.actuator_ip_edit = QtWidgets.QLineEdit()
1158 self.actuator_ip_edit.setPlaceholderText("192.168.2.20")
1159 sens_layout.addWidget(self.actuator_ip_edit)
1160 self.send_sensor_config_btn = QtWidgets.QPushButton("Send sensor config")
1161 self.send_sensor_config_btn.setToolTip("Sends to the Target IP and port above.")
1163 sens_layout.addWidget(self.send_sensor_config_btn)
1164 cmd_layout.addWidget(sens_grp)
1165
1166 # Abort buttons
1167 abort_grp = QtWidgets.QGroupBox("Abort packets")
1168 abort_layout = QtWidgets.QVBoxLayout(abort_grp)
1169 self.send_abort_btn = QtWidgets.QPushButton("Send Abort")
1170 self.send_abort_btn.clicked.connect(
1171 lambda: self._send_header_only(PacketType.ABORT)
1172 )
1173 abort_layout.addWidget(self.send_abort_btn)
1174 self.send_clear_abort_btn = QtWidgets.QPushButton("Send Clear Abort")
1175 self.send_clear_abort_btn.clicked.connect(
1176 lambda: self._send_header_only(PacketType.CLEAR_ABORT)
1177 )
1178 abort_layout.addWidget(self.send_clear_abort_btn)
1179 self.send_no_conn_abort_btn = QtWidgets.QPushButton("Send No Conn Abort")
1180 self.send_no_conn_abort_btn.clicked.connect(
1181 lambda: self._send_header_only(PacketType.NO_CONNECTION_ABORT)
1182 )
1183 abort_layout.addWidget(self.send_no_conn_abort_btn)
1184 cmd_layout.addWidget(abort_grp)
1185
1186 cmd_layout.addStretch()
1187
1188 # Right column: Server & commands + compact leaderboard below
1189 right_column = QtWidgets.QWidget()
1190 right_column.setMinimumWidth(260)
1191 right_column_layout = QtWidgets.QVBoxLayout(right_column)
1192 right_column_layout.setContentsMargins(0, 0, 0, 0)
1193 right_column_layout.addWidget(cmd_panel)
1194
1195 # Ethernet OTA controls
1196 ota_grp = QtWidgets.QGroupBox("Ethernet OTA")
1197 ota_layout = QtWidgets.QVBoxLayout(ota_grp)
1198
1199 ota_proj_row = QtWidgets.QHBoxLayout()
1200 ota_proj_row.addWidget(QtWidgets.QLabel("PlatformIO project:"))
1201 self.ota_project_dir_edit = QtWidgets.QLineEdit()
1202 self.ota_project_dir_edit.setPlaceholderText(
1203 "Path to PlatformIO project folder"
1204 )
1205 ota_proj_row.addWidget(self.ota_project_dir_edit)
1206 self.ota_project_browse_btn = QtWidgets.QPushButton("Browse")
1208 ota_proj_row.addWidget(self.ota_project_browse_btn)
1209 ota_layout.addLayout(ota_proj_row)
1210
1211 ota_env_row = QtWidgets.QHBoxLayout()
1212 ota_env_row.addWidget(QtWidgets.QLabel("Env name:"))
1213 self.ota_env_edit = QtWidgets.QLineEdit()
1214 self.ota_env_edit.setPlaceholderText("e.g. adafruit_feather_esp32s3")
1215 self.ota_env_edit.setText("adafruit_feather_esp32s3")
1216 ota_env_row.addWidget(self.ota_env_edit)
1217 ota_layout.addLayout(ota_env_row)
1218
1219 ota_port_row = QtWidgets.QHBoxLayout()
1220 ota_port_row.addWidget(QtWidgets.QLabel("OTA TCP port:"))
1221 self.ota_port_spin = QtWidgets.QSpinBox()
1222 self.ota_port_spin.setRange(1, 65535)
1223 self.ota_port_spin.setValue(3232)
1224 ota_port_row.addWidget(self.ota_port_spin)
1225 ota_layout.addLayout(ota_port_row)
1226
1227 ota_board_id_row = QtWidgets.QHBoxLayout()
1228 ota_board_id_row.addWidget(QtWidgets.QLabel("Board ID for OTA:"))
1229 self.ota_board_id_spin = QtWidgets.QSpinBox()
1230 self.ota_board_id_spin.setRange(0, 254)
1231 self.ota_board_id_spin.setValue(0)
1232 self.ota_board_id_spin.setToolTip(
1233 "Board ID to compile into firmware via BOARD_ID.\n"
1234 "0 = use default ID from platformio.ini (no override)."
1235 )
1236 ota_board_id_row.addWidget(self.ota_board_id_spin)
1237 ota_layout.addLayout(ota_board_id_row)
1238
1239 self.ota_upload_btn = QtWidgets.QPushButton("Compile && Upload")
1240 self.ota_upload_btn.setToolTip(
1241 "Compile selected PlatformIO project and upload firmware via Ethernet OTA to the Target IP."
1242 )
1244 ota_layout.addWidget(self.ota_upload_btn)
1245
1246 self.ota_log = QtWidgets.QPlainTextEdit()
1247 self.ota_log.setReadOnly(True)
1248 self.ota_log.setFixedHeight(130)
1249 self.ota_log.setPlaceholderText("OTA output will appear here…")
1250 self.ota_log.setStyleSheet(
1251 "QPlainTextEdit {"
1252 " background: #1a1a1a; color: #c8c8c8;"
1253 " font-family: monospace; font-size: 11px;"
1254 " border: 1px solid #333;"
1255 "}"
1256 )
1257 ota_layout.addWidget(self.ota_log)
1258
1259 right_column_layout.addWidget(ota_grp)
1260
1261 leaderboard_grp = QtWidgets.QGroupBox("Max force")
1262 leaderboard_grp.setStyleSheet(
1263 "QGroupBox { font-size: 10px; } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; padding: 0 4px 2px 4px; }"
1264 )
1265 leaderboard_grp.setSizePolicy(
1266 QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Maximum
1267 )
1268 leaderboard_layout = QtWidgets.QVBoxLayout(leaderboard_grp)
1269 leaderboard_layout.setContentsMargins(6, 4, 6, 4)
1270 leaderboard_layout.setSpacing(2)
1271 leaderboard_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
1272 lb_row = QtWidgets.QHBoxLayout()
1273 self.leaderboard_name_edit = QtWidgets.QLineEdit()
1274 self.leaderboard_name_edit.setPlaceholderText("Name")
1275 self.leaderboard_name_edit.setMaxLength(40)
1276 self.leaderboard_name_edit.setMaximumWidth(88)
1277 lb_row.addWidget(self.leaderboard_name_edit)
1278 self.leaderboard_add_btn = QtWidgets.QPushButton("Add max")
1279 self.leaderboard_add_btn.setToolTip(
1280 "Add max force in current time window to leaderboard."
1281 )
1283 lb_row.addWidget(self.leaderboard_add_btn)
1284 leaderboard_layout.addLayout(lb_row)
1285 self.leaderboard_list = QtWidgets.QListWidget()
1286 self.leaderboard_list.setMaximumHeight(56)
1287 self.leaderboard_list.setFont(QtGui.QFont(None, 9))
1288 leaderboard_layout.addWidget(self.leaderboard_list)
1289 right_column_layout.addWidget(leaderboard_grp)
1290 right_column_layout.addStretch()
1291
1292 content.addWidget(right_column)
1293 layout.addLayout(content)
1294 self._load_leaderboard()
1295
1296 # Stats bar
1297 self.stats_label = QtWidgets.QLabel(
1298 "Packets: 0 | Pkts/s: 0 | Bytes: 0 | B/s: 0"
1299 )
1300 layout.addWidget(self.stats_label)
1301
1302 self.receiver = None
1303 self.update_timer = QtCore.QTimer(self)
1304 self.update_timer.timeout.connect(self._update_plots)
1305 self._started = False
1306
1307 # OTA worker
1308 self._ota_worker: Optional["OTAWorker"] = None
1309
1310 # Start listening on load (receiver uses same socket as _send_sock so board replies reach us)
1311 port = self.port_spin.value()
1312 self.receiver = UDPReceiver(port=port, sock=self._shared_sock)
1313 self.receiver.sensor_data_received.connect(self._on_sensor_data)
1314 self.receiver.sensor_data_packet_status.connect(
1316 )
1317 self.receiver.board_heartbeat_received.connect(self._on_board_heartbeat)
1318 self.receiver.self_test_received.connect(self._on_self_test_results)
1319 self.receiver.status_update.connect(self.status_label.setText)
1320 self.receiver.start()
1322 self._started = True
1323 self.update_timer.start(UPDATE_INTERVAL_MS)
1324
1325 def _on_ref_voltage_changed(self, value: float):
1326 self.ref_voltage = value
1327
1328 def _on_window_seconds_changed(self, value: float):
1329 self.window_seconds = value
1330
1332 # Start browsing from the DiabloAvionics repo root by default
1333 default_root = str(Path(__file__).resolve().parents[1])
1334 directory = QtWidgets.QFileDialog.getExistingDirectory(
1335 self,
1336 "Select PlatformIO project folder",
1337 default_root,
1338 )
1339 if directory:
1340 self.ota_project_dir_edit.setText(directory)
1341
1343 if self._ota_worker is not None and self._ota_worker.isRunning():
1344 self.ota_log.appendPlainText("[OTA] Already running…")
1345 return
1346 project_dir_str = self.ota_project_dir_edit.text().strip()
1347 if not project_dir_str:
1348 self.ota_log.appendPlainText("[OTA] Error: set PlatformIO project folder.")
1349 return
1350 project_dir = Path(project_dir_str)
1351 if not project_dir.is_dir():
1352 self.ota_log.appendPlainText("[OTA] Error: project folder does not exist.")
1353 return
1354 env_name = self.ota_env_edit.text().strip()
1355 ip = self.target_ip_edit.text().strip()
1356 if not ip:
1357 self.ota_log.appendPlainText("[OTA] Error: set Target IP first.")
1358 return
1359 port = int(self.ota_port_spin.value())
1360 board_id_val = int(self.ota_board_id_spin.value())
1361 board_id_opt: Optional[int] = board_id_val if board_id_val > 0 else None
1362
1363 self.ota_log.clear()
1364 if board_id_opt is not None:
1365 self.ota_log.appendPlainText(
1366 f"[OTA] Starting — target {ip}:{port}, board ID {board_id_opt}"
1367 )
1368 else:
1369 self.ota_log.appendPlainText(
1370 f"[OTA] Starting — target {ip}:{port} (no board ID override)"
1371 )
1372 self.ota_upload_btn.setEnabled(False)
1373
1374 self._ota_worker = OTAWorker(
1375 project_dir, env_name, ip, port, board_id=board_id_opt
1376 )
1377 self._ota_worker.progress.connect(self._on_ota_progress)
1378 self._ota_worker.finished.connect(self._on_ota_finished)
1379 self._ota_worker.start()
1380
1381 def _on_ota_progress(self, msg: str):
1382 self.ota_log.appendPlainText(msg)
1383 self.ota_log.verticalScrollBar().setValue(
1384 self.ota_log.verticalScrollBar().maximum()
1385 )
1386
1387 def _on_ota_finished(self, ok: bool, msg: str):
1388 self.ota_log.appendPlainText(msg)
1389 self.ota_log.verticalScrollBar().setValue(
1390 self.ota_log.verticalScrollBar().maximum()
1391 )
1392 self.ota_upload_btn.setEnabled(True)
1393 self.ota_upload_btn.setEnabled(True)
1394
1396 from PyQt6.QtCore import Qt
1397
1398 self.show_temperature_k_type = state == Qt.CheckState.Checked.value
1399 if self.show_temperature_k_type and self.show_temp_pt1000_cb.isChecked():
1400 self.show_temp_pt1000_cb.setChecked(False)
1401 if self.show_temperature_k_type and self.show_force_lc_cb.isChecked():
1402 self.show_force_lc_cb.setChecked(False)
1403 if self.show_temperature_k_type and self.show_encoder_angle_cb.isChecked():
1404 self.show_encoder_angle_cb.setChecked(False)
1405
1407 from PyQt6.QtCore import Qt
1408
1409 self.show_temperature_pt1000 = state == Qt.CheckState.Checked.value
1410 if self.show_temperature_pt1000 and self.show_temp_k_cb.isChecked():
1411 self.show_temp_k_cb.setChecked(False)
1412 if self.show_temperature_pt1000 and self.show_force_lc_cb.isChecked():
1413 self.show_force_lc_cb.setChecked(False)
1414 if self.show_temperature_pt1000 and self.show_encoder_angle_cb.isChecked():
1415 self.show_encoder_angle_cb.setChecked(False)
1416
1418 from PyQt6.QtCore import Qt
1419
1420 if state == Qt.CheckState.Checked.value:
1421 if self.show_temp_k_cb.isChecked():
1422 self.show_temp_k_cb.setChecked(False)
1423 if self.show_temp_pt1000_cb.isChecked():
1424 self.show_temp_pt1000_cb.setChecked(False)
1425 if self.show_encoder_angle_cb.isChecked():
1426 self.show_encoder_angle_cb.setChecked(False)
1427
1429 from PyQt6.QtCore import Qt
1430
1431 if state == Qt.CheckState.Checked.value:
1432 if self.show_temp_k_cb.isChecked():
1433 self.show_temp_k_cb.setChecked(False)
1434 if self.show_temp_pt1000_cb.isChecked():
1435 self.show_temp_pt1000_cb.setChecked(False)
1436 if self.show_force_lc_cb.isChecked():
1437 self.show_force_lc_cb.setChecked(False)
1438
1440 """Find max force in current time window and add (name, force) to leaderboard."""
1441 if self.stats_start_time is None:
1442 self.status_label.setText(
1443 "No data yet — start receiving to add to leaderboard."
1444 )
1445 return
1446 current_time = time.time() - self.stats_start_time
1447 time_window = self.window_seconds
1448 exc = self.lc_excitation_spin.value()
1449 sens = self.lc_sensitivity_spin.value()
1450 pga = self.lc_pga_gain_spin.value()
1451 fs = self.lc_full_scale_spin.value()
1452 ref_v = self.ref_voltage
1453 max_force: Optional[float] = None
1454 for sid, dq in self.sensor_data.items():
1455 if not self.plot_enabled.get(sid, True):
1456 continue
1457 for t, code in dq:
1458 if current_time - t <= time_window:
1459 f = code_to_force(code, ref_v, exc, sens, pga, fs)
1460 if f is not None and (max_force is None or f > max_force):
1461 max_force = f
1462 if max_force is None:
1463 self.status_label.setText(
1464 "No valid force in current window (check Show force & data)."
1465 )
1466 return
1467 name = self.leaderboard_name_edit.text().strip() or "Anonymous"
1468 self.force_leaderboard.append((name, max_force))
1469 self.force_leaderboard.sort(key=lambda x: x[1], reverse=True)
1471 self._save_leaderboard()
1472 self.status_label.setText(f"Added: {name} — {max_force:.2f}")
1473
1475 """Load leaderboard from JSON file (persisted between sessions)."""
1476 try:
1477 if LEADERBOARD_JSON.exists():
1478 with open(LEADERBOARD_JSON, "r", encoding="utf-8") as f:
1479 data = json.load(f)
1480 self.force_leaderboard = [(e["name"], float(e["force"])) for e in data]
1481 self.force_leaderboard.sort(key=lambda x: x[1], reverse=True)
1483 except Exception:
1484 pass
1485
1487 """Save leaderboard to JSON file."""
1488 try:
1489 data = [{"name": n, "force": f} for n, f in self.force_leaderboard]
1490 with open(LEADERBOARD_JSON, "w", encoding="utf-8") as f:
1491 json.dump(data, f, indent=2)
1492 except Exception:
1493 pass
1494
1496 self.leaderboard_list.clear()
1497 for i, (name, force) in enumerate(self.force_leaderboard, 1):
1498 self.leaderboard_list.addItem(f"{i}. {name}: {force:.2f}")
1499
1500 def _on_channel_toggled(self, channel: int, state):
1501 from PyQt6.QtCore import Qt
1502
1503 self.plot_enabled[channel] = state == Qt.CheckState.Checked.value
1504 if channel in self.sensor_plots:
1505 self.sensor_plots[channel].setVisible(self.plot_enabled[channel])
1506
1507 def _on_sensor_data_packet_status(self, source_ip: str, parsed_ok: bool):
1508 if parsed_ok:
1509 self.sensor_data_status_label.setText(f"Sensor data: from {source_ip} (ok)")
1510 self.sensor_data_status_label.setStyleSheet(
1511 "color: #2ecc71; font-size: 11px;"
1512 )
1513 else:
1514 self.sensor_data_status_label.setText(
1515 f"Sensor data: from {source_ip} (malformed)"
1516 )
1517 self.sensor_data_status_label.setStyleSheet(
1518 "color: #e67e22; font-size: 11px;"
1519 )
1520
1522 self,
1523 timestamp: float,
1524 board_id: int,
1525 board_state: int,
1526 engine_state: int,
1527 source_ip: str,
1528 firmware_hash: bytes,
1529 ):
1530 self.heartbeat_timestamps.append(timestamp)
1531 self.last_heartbeat_time = timestamp
1532 self.board_id = board_id
1533 self.board_state = board_state
1534 self.board_source_ip = source_ip
1535 self.firmware_hash = firmware_hash
1536 full_hex = firmware_hash.hex()
1537 short_hex = full_hex[:16] + "..."
1538 self.firmware_hash_label.setText(f"Firmware hash: {short_hex}")
1539 self.firmware_hash_label.setToolTip(f"SHA-256: {full_hex}")
1540 # Auto-fill Target IP to the IP we are receiving heartbeats from so commands go back to that board.
1541 # User can still override this manually in the GUI.
1542 self.target_ip_edit.setText(source_ip)
1543
1544 def _on_self_test_results(self, header: dict, results: list, source_ip: str):
1545 """
1546 Update the self-test UI when a SELF_TEST packet is received.
1547
1548 header: {'packet_type', 'version', 'timestamp', 'adc_good', 'num_sensors}
1549 results: [{'sensor_id': uint8, 'result': uint8}, ...]
1550 """
1551 self.self_test_label.setText(f"Self-test: from {source_ip}")
1552
1553 adc_good = bool(header.get("adc_good", 0))
1554 if adc_good:
1555 self.self_test_adc_label.setText("ADC: PASS")
1556 self.self_test_adc_label.setStyleSheet("font-weight: bold; color: #2ecc71;")
1557 else:
1558 self.self_test_adc_label.setText("ADC: FAIL")
1559 self.self_test_adc_label.setStyleSheet("font-weight: bold; color: #e74c3c;")
1560
1561 self.self_test_results_list.clear()
1562 for entry in results:
1563 sensor_id = entry.get("sensor_id", 0)
1564 passed = bool(entry.get("result", 0))
1565 text = f"Sensor {sensor_id}: {'PASS' if passed else 'FAIL'}"
1566 item = QtWidgets.QListWidgetItem(text)
1567 color = QtGui.QColor("#2ecc71" if passed else "#e74c3c")
1568 item.setForeground(color)
1569 self.self_test_results_list.addItem(item)
1570
1572 if state == QtCore.Qt.CheckState.Checked.value:
1573 self._server_heartbeat_timer.start(SERVER_HEARTBEAT_INTERVAL_MS)
1574 else:
1576
1578
1579 ip = self.target_ip_edit.text().strip()
1580 if not ip:
1581 self.send_status_label.setText("Send status: No target IP")
1582 return
1583 try:
1584 port = self.target_port_spin.value()
1586 self._send_sock.sendto(pkt, (ip, port))
1587 self.send_status_label.setText(f"Send status: Sending to {ip}:{port}")
1588 except Exception as e:
1589 self.send_status_label.setText(f"Send status: Error — {e}")
1590
1592 ip = self.target_ip_edit.text().strip()
1593 if not ip:
1594 return
1595 try:
1596 port = self.target_port_spin.value()
1597 necessary = self.necessary_for_abort_cb.isChecked()
1598 controller_ip = self.actuator_ip_edit.text().strip() or None
1599 # 0 = internal 2.5V, 1 = VDD (e.g. 3.3V), 2 = ignore/board default
1600 ref_index = self.ref_voltage_combo.currentIndex()
1601 ref_enum = self.ref_voltage_combo.itemData(ref_index)
1602 if ref_enum is None:
1603 ref_enum = 0
1604 enable_serial = 1 if self.enable_serial_printing_cb.isChecked() else 0
1605 sensor_ids_text = self.sensor_ids_edit.text().strip()
1606 sensor_ids = parse_sensor_ids_from_text(sensor_ids_text)
1608 sensor_ids,
1609 necessary,
1610 controller_ip,
1611 reference_voltage=ref_enum,
1612 enable_serial_printing=enable_serial,
1613 )
1614 self._send_sock.sendto(pkt, (ip, port))
1615 except Exception:
1616 pass
1617
1618 def _send_header_only(self, packet_type: int):
1619 ip = self.target_ip_edit.text().strip()
1620 if not ip:
1621 return
1622 try:
1623 port = self.target_port_spin.value()
1624 pkt = build_header_only_packet(packet_type)
1625 self._send_sock.sendto(pkt, (ip, port))
1626 except Exception:
1627 pass
1628
1629 def _on_sensor_data(self, header, chunks: list, source_ip: str):
1630 if self.stats_start_time is None:
1632 base_time = self.stats_start_time
1633 receive_time = time.time() - base_time
1634
1635 for chunk in chunks:
1636 for dp in chunk["datapoints"]:
1637 sensor_id = dp["sensor_id"]
1638 code_uint32 = dp["data"]
1639
1640 if sensor_id not in self.sensor_data:
1641 self.sensor_data[sensor_id] = deque(maxlen=MAX_POINTS)
1642 if sensor_id not in self.plot_enabled:
1643 self.plot_enabled[sensor_id] = True
1644 if sensor_id in self.channel_checkboxes:
1645 self.channel_checkboxes[sensor_id].setChecked(
1646 self.plot_enabled[sensor_id]
1647 )
1648 color = SENSOR_COLORS[(sensor_id - 1) % len(SENSOR_COLORS)]
1649 pen = pg.mkPen(color=color, width=2)
1650 plot = self.plot_widget.plot(
1651 [], [], pen=pen, name=f"Ch {sensor_id}"
1652 )
1653 plot.setVisible(self.plot_enabled.get(sensor_id, True))
1654 self.sensor_plots[sensor_id] = plot
1655
1656 self.sensor_data[sensor_id].append((receive_time, code_uint32))
1657
1658 def _update_plots(self):
1659 if not self._started or not self.receiver:
1660 return
1661 stats = self.receiver.get_stats()
1662 self.network_stats = {
1663 "packets": str(stats["packets"]),
1664 "pps": f"{stats['packets_per_sec']:.1f}",
1665 "bytes": f"{stats['bytes']} B",
1666 "bps": f"{stats['bytes_per_sec']:.0f} B/s",
1667 }
1668 self.stats_label.setText(
1669 f"Packets: {self.network_stats['packets']} | "
1670 f"Pkts/s: {self.network_stats['pps']} | "
1671 f"Bytes: {self.network_stats['bytes']} | "
1672 f"B/s: {self.network_stats['bps']}"
1673 )
1674
1675 # Board status panel: heartbeat rate, state, connection
1676 now = time.time()
1677 if len(self.heartbeat_timestamps) >= 2:
1678 span = self.heartbeat_timestamps[-1] - self.heartbeat_timestamps[0]
1679 rate = (len(self.heartbeat_timestamps) - 1) / span if span > 0 else 0
1680 self.heartbeat_rate_label.setText(f"Heartbeat rate: {rate:.2f} Hz")
1681 else:
1682 self.heartbeat_rate_label.setText("Heartbeat rate: — Hz")
1683
1684 if self.board_state is not None:
1685 self.board_state_label.setText(
1686 f"Board state: {board_state_name(self.board_state)}"
1687 )
1688 else:
1689 self.board_state_label.setText("Board state: —")
1690
1691 if self.last_heartbeat_time is not None:
1692 if now - self.last_heartbeat_time > HEARTBEAT_TIMEOUT_SEC:
1693 self.connection_label.setText("Connection: Lost")
1694 self.connection_label.setStyleSheet(
1695 "font-weight: bold; color: #e74c3c;"
1696 )
1697 else:
1698 self.connection_label.setText("Connection: Connected")
1699 self.connection_label.setStyleSheet(
1700 "font-weight: bold; color: #2ecc71;"
1701 )
1702 else:
1703 self.connection_label.setText("Connection: —")
1704 self.connection_label.setStyleSheet("font-weight: bold;")
1705
1706 self.board_id_label.setText(
1707 f"Board ID: {self.board_id if self.board_id is not None else '—'}"
1708 )
1709 self.board_ip_label.setText(f"Source: {self.board_source_ip or '—'}")
1710
1711 current_time = time.time() - self.stats_start_time
1712 time_window = self.window_seconds
1713
1714 use_pt1000 = self.show_temp_pt1000_cb.isChecked()
1715 use_k_type = self.show_temp_k_cb.isChecked()
1716 use_force = self.show_force_lc_cb.isChecked()
1717 use_encoder = self.show_encoder_angle_cb.isChecked()
1718 _label_style = {"color": "#FFFFFF", "font-size": "11pt"}
1719 if use_encoder:
1720 self.plot_widget.setLabel("left", "Angle (°)", **_label_style)
1721 elif use_force:
1722 self.plot_widget.setLabel("left", "Force", **_label_style)
1723 elif use_pt1000:
1724 self.plot_widget.setLabel("left", "Temperature (°C)", **_label_style)
1725 elif use_k_type:
1726 self.plot_widget.setLabel("left", "Temperature (°C)", **_label_style)
1727 else:
1728 # Choose y-axis unit from visible data range (V, mV, or µV)
1729 max_abs_v = 0.0
1730 for sid, dq in self.sensor_data.items():
1731 if not self.plot_enabled.get(sid, True):
1732 continue
1733 for t, code in dq:
1734 if current_time - t <= time_window:
1735 v = code_to_voltage(code, self.ref_voltage)
1736 max_abs_v = max(max_abs_v, abs(v))
1737 if max_abs_v < 1e-6:
1738 y_unit, y_scale = "µV", 1e6
1739 elif max_abs_v < 1e-3:
1740 y_unit, y_scale = "mV", 1e3
1741 else:
1742 y_unit, y_scale = "V", 1.0
1743 self.plot_widget.setLabel("left", f"Voltage ({y_unit})", **_label_style)
1744 ax_left = self.plot_widget.getPlotItem().getAxis("left")
1745 if hasattr(ax_left, "label") and hasattr(ax_left.label, "setColor"):
1746 ax_left.label.setColor("#FFFFFF")
1747
1748 for sensor_id, data_deque in self.sensor_data.items():
1749 if sensor_id not in self.sensor_plots:
1750 continue
1751 if not self.plot_enabled.get(sensor_id, True):
1752 self.sensor_plots[sensor_id].setData([], [])
1753 self.sensor_plots[sensor_id].setVisible(False)
1754 continue
1755 self.sensor_plots[sensor_id].setVisible(True)
1756 times, values = [], []
1757 for t, code in data_deque:
1758 if current_time - t <= time_window:
1759 if use_encoder:
1760 angle = (code & 0x0FFF) * 360.0 / 4096.0
1761 times.append(t)
1762 values.append(angle)
1763 elif use_force:
1764 exc = self.lc_excitation_spin.value()
1765 sens = self.lc_sensitivity_spin.value()
1766 pga = self.lc_pga_gain_spin.value()
1767 fs = self.lc_full_scale_spin.value()
1768 force = code_to_force(
1769 code, self.ref_voltage, exc, sens, pga, fs
1770 )
1771 if force is not None:
1772 times.append(t)
1773 values.append(force)
1774 elif use_pt1000:
1775 v = code_to_voltage(code, self.ref_voltage)
1777 v, excitation_ua=PT1000_EXCITATION_UA
1778 )
1779 if temp is not None:
1780 times.append(t)
1781 values.append(temp)
1782 elif use_k_type:
1783 v = code_to_voltage(code, self.ref_voltage)
1785 if temp is not None:
1786 times.append(t)
1787 values.append(temp)
1788 else:
1789 v = code_to_voltage(code, self.ref_voltage)
1790 times.append(t)
1791 values.append(v * y_scale)
1792 if times:
1793 self.sensor_plots[sensor_id].setData(times, values)
1794
1795 if current_time > time_window:
1796 self.plot_widget.setXRange(
1797 current_time - time_window, current_time, padding=0
1798 )
1799 else:
1800 self.plot_widget.setXRange(0, time_window, padding=0)
1801
1802 def closeEvent(self, event):
1803 self._save_leaderboard()
1805 if self._ota_worker is not None:
1806 try:
1807 self._ota_worker.requestInterruption()
1808 self._ota_worker.wait(2000)
1809 except Exception:
1810 pass
1811 if self._send_sock:
1812 try:
1813 self._send_sock.close()
1814 except Exception:
1815 pass
1816 if self._started and self.receiver:
1817 self.receiver.stop()
1818 self.receiver.wait(2000)
1819 self.update_timer.stop()
1820 event.accept()
1821
1822
1823def main():
1824 import argparse
1825
1826 parser = argparse.ArgumentParser(
1827 description="Sense Testing GUI: plot sensor voltage, track board status, send server/abort packets"
1828 )
1829 parser.add_argument(
1830 "-p",
1831 "--port",
1832 type=int,
1833 default=DEFAULT_PORT,
1834 help=f"UDP port (default: {DEFAULT_PORT})",
1835 )
1836 parser.add_argument(
1837 "-r",
1838 "--ref-voltage",
1839 type=float,
1840 default=DEFAULT_REF_VOLTAGE,
1841 help=f"ADC reference voltage in V (default: {DEFAULT_REF_VOLTAGE})",
1842 )
1843 args = parser.parse_args()
1844
1845 app = QtWidgets.QApplication(sys.argv)
1846 app.setStyle("Fusion")
1847 palette = QtGui.QPalette()
1848 palette.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor(53, 53, 53))
1849 palette.setColor(QtGui.QPalette.ColorRole.WindowText, QtCore.Qt.GlobalColor.white)
1850 palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(25, 25, 25))
1851 palette.setColor(QtGui.QPalette.ColorRole.Text, QtCore.Qt.GlobalColor.white)
1852 palette.setColor(QtGui.QPalette.ColorRole.Button, QtGui.QColor(53, 53, 53))
1853 palette.setColor(QtGui.QPalette.ColorRole.ButtonText, QtCore.Qt.GlobalColor.white)
1854 app.setPalette(palette)
1855
1856 window = SenseTestingGUIWindow(port=args.port, ref_voltage=args.ref_voltage)
1857 window.show()
1858 sys.exit(app.exec())
1859
1860
1861if __name__ == "__main__":
1862 main()
#define max(a, b)
Definition Arduino.h:43
__init__(self, Path project_dir, str env_name, str ip, int port, Optional[int] board_id=None)
_on_sensor_data_packet_status(self, str source_ip, bool parsed_ok)
__init__(self, int port=DEFAULT_PORT, float ref_voltage=DEFAULT_REF_VOLTAGE)
_on_channel_toggled(self, int channel, 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
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
Definition run.py:1
None upload_firmware_to_esp32(Path bin_path, str ip, int port, Optional[Callable[[str], None]] progress_cb=None)
float code_to_voltage(int code_uint32, float ref_voltage)
Optional[Tuple[dict, List[dict]]] parse_sensor_data_packet(bytes data)
bytes build_header_only_packet(int packet_type)
bytes _make_header(int packet_type)
str board_state_name(int state)
Path compile_firmware(Path project_dir, str env_name, Optional[int] board_id=None)
Optional[float] pt1000_voltage_to_temperature_c(float v_volts, float excitation_ua=1000.0)
Optional[Tuple[tuple, bytes, int, int, int]] parse_board_heartbeat_packet(bytes data)
bytes build_sensor_config_packet(List[int] sensor_ids, bool necessary_for_abort, Optional[str] controller_ip, int reference_voltage=0, int enable_serial_printing=0)
List[int] parse_sensor_ids_from_text(str text)
Optional[Tuple[dict, List[dict]]] parse_self_test_packet(bytes data)
Optional[Tuple[int, int, int]] parse_packet_header(bytes data)
bytes build_server_heartbeat_packet()
Optional[float] k_type_voltage_to_temperature_c(float v_volts)
Optional[float] code_to_force(int code_uint32, float adc_ref_voltage, float excitation_voltage, float sensitivity_mv_per_v, float pga_gain, float full_scale_force)
static void emit(sensors_core_t *c, uint8_t type, const void *data, uint8_t len)
TOML_NODISCARD_CTOR time() noexcept=default
int main()
static void decode(const disk_t *d)