2Ball Valve GUI — open/close the BallValve_Hotfire board's two valves over
3Ethernet and watch their positions.
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.
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).
16Your computer needs an address on the board's subnet, e.g. 192.168.2.20/24.
18 python ball_valve_gui.py [--ip 192.168.2.71]
27from PyQt6
import QtCore, QtGui, QtWidgets
31DIABLO_COMMS_VERSION = 0
32PACKET_HEADER_FORMAT =
"<BBI"
40BOARD_HEARTBEAT_BODY_FORMAT =
"<32sBBB"
41BOARD_HEARTBEAT_BODY_SIZE = 35
42BOARD_STATES = {1:
"SETUP", 2:
"ACTIVE"}
44SENSOR_DATAPOINT_FORMAT =
"<BI"
45SENSOR_DATAPOINT_SIZE = 5
50VALVES = [(1, 1), (2, 2)]
51POSITION_SHUNT_OHMS = 100.0
54DEFAULT_BOARD_IP =
"192.168.2.71"
58SERVER_HEARTBEAT_INTERVAL_MS = 500
65 return struct.pack(PACKET_HEADER_FORMAT, packet_type, DIABLO_COMMS_VERSION, ts)
69 return _header(SERVER_HEARTBEAT) + struct.pack(
"<B", 0)
73 body = struct.pack(
"<B", 1) + struct.pack(
"<BB", actuator_id, state)
74 return _header(ACTUATOR_COMMAND) + body
78 """Return (board_id, board_state, firmware_hash) or None."""
79 if len(data) < PACKET_HEADER_SIZE + BOARD_HEARTBEAT_BODY_SIZE:
81 if data[0] != BOARD_HEARTBEAT:
83 fw_hash, board_id, _engine, board_state = struct.unpack_from(
84 BOARD_HEARTBEAT_BODY_FORMAT, data, PACKET_HEADER_SIZE
86 return board_id, board_state, fw_hash
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:
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:
97 offset = PACKET_HEADER_SIZE + 2 + (num_chunks - 1) * chunk_size + 4
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
107 """One valve: commanded state, position, OPEN/CLOSE."""
109 def __init__(self, gui:
"BallValveGui", actuator_id: int, sensor_id: int):
110 super().
__init__(f
"Valve {actuator_id}")
118 self.
state_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
121 font.setPointSize(18)
126 self.
position_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
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()
142 layout = QtWidgets.QVBoxLayout(self)
146 layout.addLayout(btn_row)
161 pct = (ma - 4.0) / 16.0 * 100.0
162 text = f
"{ma:.2f} mA ({self.position_volts:.3f} V) — {pct:.0f}% open"
164 text +=
" [below 4 mA: open loop / no signal?]"
169 if not heartbeats_on:
170 label, fg, bg =
"HEARTBEATS STOPPED",
"#757575",
"#fafafa"
172 label, fg, bg =
"COMMANDED OPEN",
"#1b5e20",
"#c8e6c9"
174 label, fg, bg =
"COMMANDED CLOSED",
"#424242",
"#eeeeee"
176 label, fg, bg =
"READY",
"#424242",
"#eeeeee"
179 f
"color: {fg}; background: {bg}; border: 1px solid {fg}; "
180 "border-radius: 6px;"
191 self.setWindowTitle(
"Ball Valve — Ethernet")
192 self.
sock: socket.socket |
None =
None
200 self.
follow_ip = QtWidgets.QCheckBox(
"Follow board's IP")
203 "Use the source address of the board's heartbeats (it may get a "
204 "DHCP address instead of the fallback)."
206 self.
hb_btn = QtWidgets.QPushButton(
"Start heartbeats")
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)
213 target_row.addWidget(self.
hb_btn)
218 valve_row = QtWidgets.QHBoxLayout()
220 valve_row.addWidget(panel)
222 note = QtWidgets.QLabel(
223 "The board OPENS every valve 5 s after heartbeats stop — stopping "
224 "heartbeats or closing this window opens them."
226 note.setWordWrap(
True)
229 self.
log = QtWidgets.QPlainTextEdit()
230 self.
log.setReadOnly(
True)
231 self.
log.setMaximumBlockCount(2000)
233 QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.SystemFont.FixedFont)
236 layout = QtWidgets.QVBoxLayout(self)
237 layout.addLayout(target_row)
239 layout.addLayout(valve_row)
240 layout.addWidget(note)
241 layout.addWidget(self.
log, 1)
242 self.resize(800, 640)
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)
265 sock.bind((
"0.0.0.0", LISTEN_PORT))
268 f
"Could not listen on UDP {LISTEN_PORT} ({e}) — board status "
269 "and position unavailable; commands still work."
271 sock.setblocking(
False)
274 self.
append(f
"Listening for board packets on UDP {LISTEN_PORT}.")
276 def send(self, packet: bytes) -> bool:
277 ip = self.
ip_edit.text().strip()
279 self.
sock.sendto(packet, (ip, COMMAND_PORT))
282 self.
append(f
"Send to {ip}:{COMMAND_PORT} failed: {e}")
288 data, (src_ip, _port) = self.
sock.recvfrom(2048)
293 if data[0] == BOARD_HEARTBEAT:
295 elif data[0] == SENSOR_DATA
and src_ip == self.
board_src_ip:
300 if values
and panel.sensor_id
in values:
301 panel.on_counts(values[panel.sensor_id])
307 board_id, board_state, fw_hash = hb
308 if board_id != VALVE_BOARD_ID:
317 f
"Board {board_id} heard from {src_ip} "
318 f
"(firmware {fw_hash.hex()[:16]}...)."
320 if board_state != prev_state
and not first:
321 self.
append(f
"Board state -> {BOARD_STATES.get(board_state, '?')}.")
324 self.
append(f
"Target IP set to {src_ip}.")
331 self.
append(
"Heartbeats stopped — board OPENS every valve within 5 s.")
333 panel.commanded =
None
339 f
"Sending SERVER_HEARTBEAT to {self.ip_edit.text().strip()} "
340 f
"every {SERVER_HEARTBEAT_INTERVAL_MS} ms."
349 def command(self, panel:
"ValvePanel", state: int):
359 panel.commanded =
"OPEN" if state
else "CLOSED"
361 f
"> ACTUATOR_COMMAND id={panel.actuator_id} state={state} "
362 f
"to {self.ip_edit.text().strip()}:{COMMAND_PORT}"
366 " warning: board is not reporting ACTIVE, so it may not "
367 "be hearing our heartbeats and will ignore CLOSE."
381 text, color =
"Board: not heard yet",
"#757575"
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"
388 text, color = f
"Board: {state} (hearing our heartbeats)",
"#1b5e20"
390 text, color = f
"Board: {state} (not hearing heartbeats)",
"#8a6d00"
392 self.
board_label.setStyleSheet(f
"color: {color}; font-weight: bold;")
395 panel.refresh_position()
403 "Stop heartbeats" if self.
heartbeats_on else "Start heartbeats"
409 self.
log.appendPlainText(f
"{time.strftime('%H:%M:%S')} {text}")
418 parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
420 "--ip", default=DEFAULT_BOARD_IP, help=
"board IP (default: %(default)s)"
422 args = parser.parse_args()
424 app = QtWidgets.QApplication(sys.argv)
430if __name__ ==
"__main__":
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)
float|None position_volts
set_command_label(self, bool heartbeats_on)
static void stop(emmc_log_t *log, emmc_disk_state_t why)
static void start(gnss_frame_parser_t *p, uint8_t byte)
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