STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
ball_valve_gui.py
Go to the documentation of this file.
1"""
2Ball Valve GUI — open/close the BallValve_Hotfire board's two valves over
3Ethernet and watch their positions.
4
5Sends DAQv2 packets to firmware/Hotfire_Code/BallValve_Hotfire on UDP 5005:
6 - SERVER_HEARTBEAT every 500 ms while "Heartbeats" is running. The board
7 only leaves the open state with a current heartbeat, and OPENS the valve
8 5 s after they stop (its safe state: 20 mA), so stopping heartbeats or
9 closing this window opens every valve.
10 - ACTUATOR_COMMAND (actuator_id 1 or 2): state 1 = open (20 mA), 0 = closed.
11
12Listens on UDP 5006 for the board's BOARD_HEARTBEAT (alive, and ACTIVE when it
13hears our heartbeats) and SENSOR_DATA (sensor_id 1, 2: raw 12-bit ADC counts of
14each valve's 100 ohm position shunt; V = counts / 4095 * 3.3, 4-20 mA = 0.4-2.0 V).
15
16Your computer needs an address on the board's subnet, e.g. 192.168.2.20/24.
17
18 python ball_valve_gui.py [--ip 192.168.2.71]
19"""
20
21import argparse
22import socket
23import struct
24import sys
25import time
26
27from PyQt6 import QtCore, QtGui, QtWidgets
28
29# ---------------------- Protocol constants (DAQv2-Comms) ----------------------
30
31DIABLO_COMMS_VERSION = 0
32PACKET_HEADER_FORMAT = "<BBI" # packet_type, version, timestamp_ms
33PACKET_HEADER_SIZE = 6
34
35BOARD_HEARTBEAT = 1
36SERVER_HEARTBEAT = 2
37SENSOR_DATA = 3
38ACTUATOR_COMMAND = 4
39
40BOARD_HEARTBEAT_BODY_FORMAT = "<32sBBB" # hash, board_id, engine, board state
41BOARD_HEARTBEAT_BODY_SIZE = 35
42BOARD_STATES = {1: "SETUP", 2: "ACTIVE"}
43
44SENSOR_DATAPOINT_FORMAT = "<BI" # sensor_id, data
45SENSOR_DATAPOINT_SIZE = 5
46
47VALVE_BOARD_ID = 71 # -DBOARD_ID in BallValve_Hotfire/platformio.ini
48# (actuator_id, position sensor_id) per valve -- the `valves` table in
49# BallValve_Hotfire/src/main.cpp.
50VALVES = [(1, 1), (2, 2)]
51POSITION_SHUNT_OHMS = 100.0
52ADC_MAX = 4095.0 # 12-bit
53ADC_VREF = 3.3
54DEFAULT_BOARD_IP = "192.168.2.71" # the board's static fallback address
55COMMAND_PORT = 5005 # board listens here
56LISTEN_PORT = 5006 # board sends BOARD_HEARTBEAT and SENSOR_DATA here
57
58SERVER_HEARTBEAT_INTERVAL_MS = 500
59BOARD_SILENT_SEC = 2.5 # board heartbeats are 1 s apart
60POLL_MS = 50
61
62
63def _header(packet_type: int) -> bytes:
64 ts = int(time.time() * 1000) & 0xFFFFFFFF
65 return struct.pack(PACKET_HEADER_FORMAT, packet_type, DIABLO_COMMS_VERSION, ts)
66
67
69 return _header(SERVER_HEARTBEAT) + struct.pack("<B", 0) # engine SAFE
70
71
72def build_actuator_command(actuator_id: int, state: int) -> bytes:
73 body = struct.pack("<B", 1) + struct.pack("<BB", actuator_id, state)
74 return _header(ACTUATOR_COMMAND) + body
75
76
77def parse_board_heartbeat(data: bytes):
78 """Return (board_id, board_state, firmware_hash) or None."""
79 if len(data) < PACKET_HEADER_SIZE + BOARD_HEARTBEAT_BODY_SIZE:
80 return None
81 if data[0] != BOARD_HEARTBEAT:
82 return None
83 fw_hash, board_id, _engine, board_state = struct.unpack_from(
84 BOARD_HEARTBEAT_BODY_FORMAT, data, PACKET_HEADER_SIZE
85 )
86 return board_id, board_state, fw_hash
87
88
89def parse_sensor_data(data: bytes):
90 """Return {sensor_id: raw value} from the last chunk of a SENSOR_DATA packet."""
91 if len(data) < PACKET_HEADER_SIZE + 2 or data[0] != SENSOR_DATA:
92 return None
93 num_chunks, num_sensors = data[PACKET_HEADER_SIZE], data[PACKET_HEADER_SIZE + 1]
94 chunk_size = 4 + num_sensors * SENSOR_DATAPOINT_SIZE
95 if num_chunks == 0 or len(data) < PACKET_HEADER_SIZE + 2 + num_chunks * chunk_size:
96 return None
97 offset = PACKET_HEADER_SIZE + 2 + (num_chunks - 1) * chunk_size + 4
98 values = {}
99 for _ in range(num_sensors):
100 sensor_id, raw = struct.unpack_from(SENSOR_DATAPOINT_FORMAT, data, offset)
101 values[sensor_id] = raw
102 offset += SENSOR_DATAPOINT_SIZE
103 return values
104
105
106class ValvePanel(QtWidgets.QGroupBox):
107 """One valve: commanded state, position, OPEN/CLOSE."""
108
109 def __init__(self, gui: "BallValveGui", actuator_id: int, sensor_id: int):
110 super().__init__(f"Valve {actuator_id}")
111 self.actuator_id = actuator_id
112 self.sensor_id = sensor_id
113 self.commanded: str | None = None
114 self.position_volts: float | None = None
116
117 self.state_label = QtWidgets.QLabel()
118 self.state_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
119 self.state_label.setMinimumHeight(48)
120 font = self.state_label.font()
121 font.setPointSize(18)
122 font.setBold(True)
123 self.state_label.setFont(font)
124
125 self.position_label = QtWidgets.QLabel()
126 self.position_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
127 self.position_label.setWordWrap(True)
128 self.position_bar = QtWidgets.QProgressBar()
129 self.position_bar.setRange(0, 100)
130 self.position_bar.setFormat("%p% open")
131
132 self.open_btn = QtWidgets.QPushButton("OPEN (1)")
133 self.close_btn = QtWidgets.QPushButton("CLOSE (0)")
134 for btn in (self.open_btn, self.close_btn):
135 btn.setMinimumHeight(40)
136 self.open_btn.clicked.connect(lambda: gui.command(self, 1))
137 self.close_btn.clicked.connect(lambda: gui.command(self, 0))
138 btn_row = QtWidgets.QHBoxLayout()
139 btn_row.addWidget(self.open_btn)
140 btn_row.addWidget(self.close_btn)
141
142 layout = QtWidgets.QVBoxLayout(self)
143 layout.addWidget(self.state_label)
144 layout.addWidget(self.position_label)
145 layout.addWidget(self.position_bar)
146 layout.addLayout(btn_row)
147
148 def on_counts(self, counts: int):
149 self.position_volts = counts / ADC_MAX * ADC_VREF
151
153 if (
154 self.position_volts is None
155 or time.time() - self.position_last_seen >= BOARD_SILENT_SEC
156 ):
157 self.position_label.setText("Position: no data")
158 self.position_bar.setValue(0)
159 return
160 ma = self.position_volts / POSITION_SHUNT_OHMS * 1000.0
161 pct = (ma - 4.0) / 16.0 * 100.0
162 text = f"{ma:.2f} mA ({self.position_volts:.3f} V) — {pct:.0f}% open"
163 if ma < 3.5:
164 text += " [below 4 mA: open loop / no signal?]"
165 self.position_label.setText(text)
166 self.position_bar.setValue(int(max(0.0, min(100.0, pct))))
167
168 def set_command_label(self, heartbeats_on: bool):
169 if not heartbeats_on:
170 label, fg, bg = "HEARTBEATS STOPPED", "#757575", "#fafafa"
171 elif self.commanded == "OPEN":
172 label, fg, bg = "COMMANDED OPEN", "#1b5e20", "#c8e6c9"
173 elif self.commanded == "CLOSED":
174 label, fg, bg = "COMMANDED CLOSED", "#424242", "#eeeeee"
175 else:
176 label, fg, bg = "READY", "#424242", "#eeeeee"
177 self.state_label.setText(label)
178 self.state_label.setStyleSheet(
179 f"color: {fg}; background: {bg}; border: 1px solid {fg}; "
180 "border-radius: 6px;"
181 )
182
183 def update_controls(self, heartbeats_on: bool):
184 self.open_btn.setEnabled(True)
185 self.close_btn.setEnabled(heartbeats_on)
186
187
188class BallValveGui(QtWidgets.QWidget):
189 def __init__(self, board_ip: str):
190 super().__init__()
191 self.setWindowTitle("Ball Valve — Ethernet")
192 self.sock: socket.socket | None = None
193 self.heartbeats_on = False
195 self.board_state = 0
196 self.board_src_ip: str | None = None
197
198 # --- target row ---
199 self.ip_edit = QtWidgets.QLineEdit(board_ip)
200 self.follow_ip = QtWidgets.QCheckBox("Follow board's IP")
201 self.follow_ip.setChecked(True)
202 self.follow_ip.setToolTip(
203 "Use the source address of the board's heartbeats (it may get a "
204 "DHCP address instead of the fallback)."
205 )
206 self.hb_btn = QtWidgets.QPushButton("Start heartbeats")
207 self.hb_btn.clicked.connect(self.toggle_heartbeats)
208
209 target_row = QtWidgets.QHBoxLayout()
210 target_row.addWidget(QtWidgets.QLabel(f"Board IP (UDP {COMMAND_PORT}):"))
211 target_row.addWidget(self.ip_edit, 1)
212 target_row.addWidget(self.follow_ip)
213 target_row.addWidget(self.hb_btn)
214
215 # --- board and one panel per valve ---
216 self.board_label = QtWidgets.QLabel()
217 self.panels = [ValvePanel(self, aid, sid) for aid, sid in VALVES]
218 valve_row = QtWidgets.QHBoxLayout()
219 for panel in self.panels:
220 valve_row.addWidget(panel)
221
222 note = QtWidgets.QLabel(
223 "The board OPENS every valve 5 s after heartbeats stop — stopping "
224 "heartbeats or closing this window opens them."
225 )
226 note.setWordWrap(True)
227
228 # --- log ---
229 self.log = QtWidgets.QPlainTextEdit()
230 self.log.setReadOnly(True)
231 self.log.setMaximumBlockCount(2000)
232 self.log.setFont(
233 QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.SystemFont.FixedFont)
234 )
235
236 layout = QtWidgets.QVBoxLayout(self)
237 layout.addLayout(target_row)
238 layout.addWidget(self.board_label)
239 layout.addLayout(valve_row)
240 layout.addWidget(note)
241 layout.addWidget(self.log, 1)
242 self.resize(800, 640)
243
244 self.hb_timer = QtCore.QTimer(self)
245 self.hb_timer.timeout.connect(self.send_server_heartbeat)
246 self.rx_timer = QtCore.QTimer(self)
247 self.rx_timer.timeout.connect(self.poll_socket)
248 self.ui_timer = QtCore.QTimer(self)
249 self.ui_timer.timeout.connect(self.refresh_status)
250 self.ui_timer.start(250)
251
252 self.open_socket()
253 self.set_command_label()
254 self.refresh_status()
255 self.update_controls()
256
257 # -------------------------------------------------------------- socket
258 def open_socket(self):
259 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
260 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
261 if hasattr(socket, "SO_REUSEPORT"):
262 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
263 sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
264 try:
265 sock.bind(("0.0.0.0", LISTEN_PORT))
266 except OSError as e:
267 self.append(
268 f"Could not listen on UDP {LISTEN_PORT} ({e}) — board status "
269 "and position unavailable; commands still work."
270 )
271 sock.setblocking(False)
272 self.sock = sock
273 self.rx_timer.start(POLL_MS)
274 self.append(f"Listening for board packets on UDP {LISTEN_PORT}.")
275
276 def send(self, packet: bytes) -> bool:
277 ip = self.ip_edit.text().strip()
278 try:
279 self.sock.sendto(packet, (ip, COMMAND_PORT))
280 return True
281 except OSError as e:
282 self.append(f"Send to {ip}:{COMMAND_PORT} failed: {e}")
283 return False
284
285 def poll_socket(self):
286 while True:
287 try:
288 data, (src_ip, _port) = self.sock.recvfrom(2048)
289 except OSError:
290 return
291 if not data:
292 continue
293 if data[0] == BOARD_HEARTBEAT:
294 self.on_board_heartbeat(data, src_ip)
295 elif data[0] == SENSOR_DATA and src_ip == self.board_src_ip:
296 # SENSOR_DATA carries no board id: take it from the address
297 # our board's heartbeats come from.
298 values = parse_sensor_data(data)
299 for panel in self.panels:
300 if values and panel.sensor_id in values:
301 panel.on_counts(values[panel.sensor_id])
302
303 def on_board_heartbeat(self, data: bytes, src_ip: str):
304 hb = parse_board_heartbeat(data)
305 if hb is None:
306 return
307 board_id, board_state, fw_hash = hb
308 if board_id != VALVE_BOARD_ID:
309 return
310 first = self.board_last_seen == 0.0
311 prev_state = self.board_state
313 self.board_state = board_state
314 self.board_src_ip = src_ip
315 if first:
316 self.append(
317 f"Board {board_id} heard from {src_ip} "
318 f"(firmware {fw_hash.hex()[:16]}...)."
319 )
320 if board_state != prev_state and not first:
321 self.append(f"Board state -> {BOARD_STATES.get(board_state, '?')}.")
322 if self.follow_ip.isChecked() and self.ip_edit.text().strip() != src_ip:
323 self.ip_edit.setText(src_ip)
324 self.append(f"Target IP set to {src_ip}.")
325
326 # ----------------------------------------------------------- heartbeats
328 if self.heartbeats_on:
329 self.heartbeats_on = False
330 self.hb_timer.stop()
331 self.append("Heartbeats stopped — board OPENS every valve within 5 s.")
332 for panel in self.panels:
333 panel.commanded = None
334 else:
335 self.heartbeats_on = True
337 self.hb_timer.start(SERVER_HEARTBEAT_INTERVAL_MS)
338 self.append(
339 f"Sending SERVER_HEARTBEAT to {self.ip_edit.text().strip()} "
340 f"every {SERVER_HEARTBEAT_INTERVAL_MS} ms."
341 )
342 self.set_command_label()
343 self.update_controls()
344
348 # ------------------------------------------------------------- commands
349 def command(self, panel: "ValvePanel", state: int):
350 # OPEN (the safe state) always goes out; CLOSE is ignored without
351 # heartbeats.
352 if not state and not self.heartbeats_on:
353 return
354 # A heartbeat first, so the board is ACTIVE even if CLOSE is pressed
355 # the instant heartbeats start.
356 if self.heartbeats_on:
358 if self.send(build_actuator_command(panel.actuator_id, state)):
359 panel.commanded = "OPEN" if state else "CLOSED"
360 self.append(
361 f"> ACTUATOR_COMMAND id={panel.actuator_id} state={state} "
362 f"to {self.ip_edit.text().strip()}:{COMMAND_PORT}"
363 )
364 if not state and not self.board_active():
365 self.append(
366 " warning: board is not reporting ACTIVE, so it may not "
367 "be hearing our heartbeats and will ignore CLOSE."
368 )
369 panel.set_command_label(self.heartbeats_on)
370
371 # ------------------------------------------------------------------ UI
372 def board_active(self) -> bool:
373 return (
374 self.board_last_seen > 0
375 and time.time() - self.board_last_seen < BOARD_SILENT_SEC
376 and self.board_state == 2
377 )
378
379 def refresh_status(self):
380 if self.board_last_seen == 0.0:
381 text, color = "Board: not heard yet", "#757575"
382 else:
383 age = time.time() - self.board_last_seen
384 state = BOARD_STATES.get(self.board_state, f"state {self.board_state}")
385 if age >= BOARD_SILENT_SEC:
386 text, color = f"Board: SILENT for {age:.0f} s", "#b71c1c"
387 elif self.board_state == 2:
388 text, color = f"Board: {state} (hearing our heartbeats)", "#1b5e20"
389 else:
390 text, color = f"Board: {state} (not hearing heartbeats)", "#8a6d00"
391 self.board_label.setText(text)
392 self.board_label.setStyleSheet(f"color: {color}; font-weight: bold;")
393
394 for panel in self.panels:
395 panel.refresh_position()
396
398 for panel in self.panels:
399 panel.set_command_label(self.heartbeats_on)
400
402 self.hb_btn.setText(
403 "Stop heartbeats" if self.heartbeats_on else "Start heartbeats"
404 )
405 for panel in self.panels:
406 panel.update_controls(self.heartbeats_on)
407
408 def append(self, text: str):
409 self.log.appendPlainText(f"{time.strftime('%H:%M:%S')} {text}")
410
411 def closeEvent(self, event):
412 if self.sock:
413 self.sock.close()
414 super().closeEvent(event)
415
416
417def main():
418 parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
419 parser.add_argument(
420 "--ip", default=DEFAULT_BOARD_IP, help="board IP (default: %(default)s)"
421 )
422 args = parser.parse_args()
423
424 app = QtWidgets.QApplication(sys.argv)
425 gui = BallValveGui(args.ip)
426 gui.show()
427 sys.exit(app.exec())
428
429
430if __name__ == "__main__":
431 main()
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
command(self, "ValvePanel" panel, int state)
__init__(self, str board_ip)
bool send(self, bytes packet)
on_board_heartbeat(self, bytes data, str src_ip)
update_controls(self, bool heartbeats_on)
__init__(self, "BallValveGui" gui, int actuator_id, int sensor_id)
on_counts(self, int counts)
set_command_label(self, bool heartbeats_on)
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 build_server_heartbeat()
bytes build_actuator_command(int actuator_id, int state)
parse_board_heartbeat(bytes data)
parse_sensor_data(bytes data)
bytes _header(int packet_type)
TOML_NODISCARD_CTOR time() noexcept=default
int main()