Skip to content

Commit ee1c85d

Browse files
makcukjoshanne
authored andcommitted
Add Hardpoints and CircuitStatus panels
1 parent 19db74e commit ee1c85d

3 files changed

Lines changed: 331 additions & 1 deletion

File tree

dronecan_gui_tool/panels/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from . import RemoteID_panel
1818
from . import hobbywing_esc
1919
from . import rc_panel
20+
from . import hardpoints_panel
21+
from . import circuit_status_panel
2022

2123
class PanelDescriptor:
2224
def __init__(self, module):
@@ -45,5 +47,7 @@ def safe_spawn(self, parent, node):
4547
PanelDescriptor(stats_panel),
4648
PanelDescriptor(RemoteID_panel),
4749
PanelDescriptor(hobbywing_esc),
48-
PanelDescriptor(rc_panel)
50+
PanelDescriptor(rc_panel),
51+
PanelDescriptor(hardpoints_panel),
52+
PanelDescriptor(circuit_status_panel)
4953
]
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#
2+
# Copyright (C) 2026 DroneCAN Development Team <dronecan.org>
3+
#
4+
# This software is distributed under the terms of the MIT License.
5+
#
6+
7+
import dronecan
8+
from functools import partial
9+
from PyQt6.QtWidgets import QVBoxLayout, QLabel, QDialog, QGroupBox, QTableWidget, QTableWidgetItem, QHeaderView
10+
from PyQt6.QtCore import Qt, QTimer
11+
from PyQt6.QtGui import QBrush, QColor
12+
from logging import getLogger
13+
from ..widgets import get_icon, get_monospace_font
14+
15+
__all__ = 'PANEL_NAME', 'spawn', 'get_icon'
16+
17+
PANEL_NAME = 'CircuitStatus'
18+
19+
logger = getLogger(__name__)
20+
21+
_singleton = None
22+
23+
24+
class CircuitStatusPanel(QDialog):
25+
def __init__(self, parent, node):
26+
super(CircuitStatusPanel, self).__init__(parent)
27+
self.setWindowTitle('CircuitStatus Monitor')
28+
self.setAttribute(Qt.WA_DeleteOnClose)
29+
30+
self._node = node
31+
32+
# Main Layout
33+
layout = QVBoxLayout(self)
34+
35+
# ---------------------------------------------------------
36+
# Monitoring Group Box (CircuitStatus updates)
37+
# ---------------------------------------------------------
38+
monitor_group = QGroupBox('Circuit Status Monitor', self)
39+
monitor_layout = QVBoxLayout()
40+
41+
self._table = QTableWidget(self)
42+
self._table.setColumnCount(5)
43+
self._table.setHorizontalHeaderLabels(['Circuit', 'Voltage', 'Current', 'Power', 'Status / Errors'])
44+
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
45+
self._table.verticalHeader().setVisible(False)
46+
47+
monitor_layout.addWidget(self._table)
48+
monitor_group.setLayout(monitor_layout)
49+
layout.addWidget(monitor_group)
50+
51+
self._circuit_rows = {}
52+
53+
self.setLayout(layout)
54+
self.resize(550, 300)
55+
56+
# Register DroneCAN handler for CircuitStatus
57+
self._handlers = [
58+
self._node.add_handler(dronecan.uavcan.equipment.power.CircuitStatus, self._on_circuit_status)
59+
]
60+
61+
# Timer to check for offline/stale status
62+
self._stale_timer = QTimer(self)
63+
self._stale_timer.timeout.connect(self._check_stale_circuits)
64+
self._stale_timer.start(1000)
65+
66+
def _on_circuit_status(self, event):
67+
import time
68+
msg = event.message
69+
cid = msg.circuit_id
70+
71+
if cid not in self._circuit_rows:
72+
# Insert row at sorted position
73+
row_idx = sum(1 for existing_cid in self._circuit_rows if existing_cid < cid)
74+
self._table.insertRow(row_idx)
75+
76+
item_name = QTableWidgetItem(f'Circuit {cid}')
77+
item_volt = QTableWidgetItem('NC')
78+
item_curr = QTableWidgetItem('NC')
79+
item_pwr = QTableWidgetItem('NC')
80+
item_err = QTableWidgetItem('NC')
81+
82+
font = get_monospace_font()
83+
for item in (item_name, item_volt, item_curr, item_pwr, item_err):
84+
item.setFont(font)
85+
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
86+
87+
self._table.setItem(row_idx, 0, item_name)
88+
self._table.setItem(row_idx, 1, item_volt)
89+
self._table.setItem(row_idx, 2, item_curr)
90+
self._table.setItem(row_idx, 3, item_pwr)
91+
self._table.setItem(row_idx, 4, item_err)
92+
93+
self._circuit_rows[cid] = {
94+
'voltage': item_volt,
95+
'current': item_curr,
96+
'power': item_pwr,
97+
'error': item_err,
98+
'last_update': 0
99+
}
100+
101+
row = self._circuit_rows[cid]
102+
row['last_update'] = time.time()
103+
104+
# Format voltage, current, power
105+
v = msg.voltage
106+
i = msg.current
107+
p = v * i
108+
109+
row['voltage'].setText(f'{v:6.2f} V')
110+
row['current'].setText(f'{i:6.2f} A')
111+
row['power'].setText(f'{p:6.2f} W')
112+
113+
# Parse error flags
114+
errs = []
115+
flags = msg.error_flags
116+
if flags & msg.ERROR_FLAG_OVERVOLTAGE:
117+
errs.append('OVER_V')
118+
if flags & msg.ERROR_FLAG_UNDERVOLTAGE:
119+
errs.append('UNDER_V')
120+
if flags & msg.ERROR_FLAG_OVERCURRENT:
121+
errs.append('OVER_C')
122+
if flags & msg.ERROR_FLAG_UNDERCURRENT:
123+
errs.append('UNDER_C')
124+
125+
if errs:
126+
row['error'].setText(', '.join(errs))
127+
row['error'].setForeground(QBrush(QColor('red')))
128+
font = row['error'].font()
129+
font.setBold(True)
130+
row['error'].setFont(font)
131+
else:
132+
row['error'].setText('OK')
133+
row['error'].setForeground(QBrush(QColor('green')))
134+
font = row['error'].font()
135+
font.setBold(False)
136+
row['error'].setFont(font)
137+
138+
def _check_stale_circuits(self):
139+
import time
140+
now = time.time()
141+
for cid, row in self._circuit_rows.items():
142+
if row['last_update'] == 0:
143+
continue
144+
if now - row['last_update'] > 3.0:
145+
row['voltage'].setText('STALE')
146+
row['current'].setText('STALE')
147+
row['power'].setText('STALE')
148+
row['error'].setText('OFFLINE')
149+
row['error'].setForeground(QBrush(QColor('gray')))
150+
font = row['error'].font()
151+
font.setBold(False)
152+
row['error'].setFont(font)
153+
154+
def __del__(self):
155+
global _singleton
156+
_singleton = None
157+
for h in self._handlers:
158+
try:
159+
h.remove()
160+
except Exception:
161+
pass
162+
163+
def closeEvent(self, event):
164+
global _singleton
165+
_singleton = None
166+
for h in self._handlers:
167+
try:
168+
h.remove()
169+
except Exception:
170+
pass
171+
super(CircuitStatusPanel, self).closeEvent(event)
172+
173+
174+
def spawn(parent, node):
175+
global _singleton
176+
if _singleton is None:
177+
_singleton = CircuitStatusPanel(parent, node)
178+
179+
_singleton.show()
180+
_singleton.raise_()
181+
_singleton.activateWindow()
182+
183+
return _singleton
184+
185+
186+
get_icon = partial(get_icon, 'fa6s.asterisk')
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
#
2+
# Copyright (C) 2026 DroneCAN Development Team <dronecan.org>
3+
#
4+
# This software is distributed under the terms of the MIT License.
5+
#
6+
7+
import dronecan
8+
from functools import partial
9+
from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QDialog, QSpinBox, QComboBox, QGroupBox, QLayout
10+
from PyQt6.QtCore import Qt
11+
from logging import getLogger
12+
from ..widgets import make_icon_button, get_icon
13+
14+
__all__ = 'PANEL_NAME', 'spawn', 'get_icon'
15+
16+
PANEL_NAME = 'Hardpoints'
17+
18+
logger = getLogger(__name__)
19+
20+
_singleton = None
21+
22+
23+
class HardpointsPanel(QDialog):
24+
def __init__(self, parent, node):
25+
super(HardpointsPanel, self).__init__(parent)
26+
self.setWindowTitle('Hardpoints Control')
27+
self.setAttribute(Qt.WA_DeleteOnClose)
28+
29+
self._node = node
30+
31+
# Main Layout
32+
layout = QVBoxLayout(self)
33+
34+
# ---------------------------------------------------------
35+
# Control Group Box (Relay/Hardpoint Controls)
36+
# ---------------------------------------------------------
37+
control_group = QGroupBox('Relay / Hardpoint Control', self)
38+
control_layout = QVBoxLayout()
39+
40+
# Hardpoint ID field
41+
hp_id_layout = QHBoxLayout()
42+
hp_id_layout.addWidget(QLabel('Hardpoint ID:', self))
43+
self._hardpoint_id = QSpinBox(self)
44+
self._hardpoint_id.setMinimum(0)
45+
self._hardpoint_id.setMaximum(255)
46+
self._hardpoint_id.setValue(0)
47+
hp_id_layout.addWidget(self._hardpoint_id)
48+
hp_id_layout.addStretch()
49+
control_layout.addLayout(hp_id_layout)
50+
51+
# Command / State field
52+
state_layout = QHBoxLayout()
53+
state_layout.addWidget(QLabel('State / Command:', self))
54+
self._state_combo = QComboBox(self)
55+
self._state_combo.addItem('0 - Release / OFF', 0)
56+
self._state_combo.addItem('1 - Hold / ON', 1)
57+
self._state_combo.addItem('Custom...', -1)
58+
self._state_combo.currentIndexChanged.connect(self._on_state_combo_changed)
59+
state_layout.addWidget(self._state_combo)
60+
61+
self._custom_val = QSpinBox(self)
62+
self._custom_val.setMinimum(0)
63+
self._custom_val.setMaximum(65535)
64+
self._custom_val.setValue(0)
65+
self._custom_val.setVisible(False)
66+
state_layout.addWidget(self._custom_val)
67+
state_layout.addStretch()
68+
control_layout.addLayout(state_layout)
69+
70+
# Send Button
71+
self._send_button = make_icon_button('fa6s.paper-plane', 'Send command', self, text='Send Command', on_clicked=self._do_send)
72+
control_layout.addWidget(self._send_button)
73+
74+
# Status Label
75+
self._status_label = QLabel('', self)
76+
control_layout.addWidget(self._status_label)
77+
78+
control_group.setLayout(control_layout)
79+
layout.addWidget(control_group)
80+
81+
self.setLayout(layout)
82+
self.setMinimumWidth(350)
83+
layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
84+
85+
def _on_state_combo_changed(self):
86+
is_custom = self._state_combo.currentData() == -1
87+
self._custom_val.setVisible(is_custom)
88+
89+
def get_command_value(self):
90+
val = self._state_combo.currentData()
91+
if val == -1:
92+
return self._custom_val.value()
93+
return val
94+
95+
def _do_send(self):
96+
try:
97+
# Construct the message (uses default ID 1070)
98+
msg = dronecan.uavcan.equipment.hardpoint.Command()
99+
msg.hardpoint_id = self._hardpoint_id.value()
100+
msg.command = self.get_command_value()
101+
102+
# Broadcast
103+
self._node.broadcast(msg)
104+
105+
cmd_val = msg.command
106+
if cmd_val == 0:
107+
cmd_str = "release"
108+
elif cmd_val == 1:
109+
cmd_str = "hold"
110+
else:
111+
cmd_str = str(cmd_val)
112+
113+
self._status_label.setText(f"command {cmd_str} sent to hardpoint {msg.hardpoint_id}")
114+
except Exception as ex:
115+
logger.error(f'Sending failed: {ex}')
116+
self._status_label.setText(f"Sending failed: {ex}")
117+
118+
def __del__(self):
119+
global _singleton
120+
_singleton = None
121+
122+
def closeEvent(self, event):
123+
global _singleton
124+
_singleton = None
125+
super(HardpointsPanel, self).closeEvent(event)
126+
127+
128+
def spawn(parent, node):
129+
global _singleton
130+
if _singleton is None:
131+
_singleton = HardpointsPanel(parent, node)
132+
133+
_singleton.show()
134+
_singleton.raise_()
135+
_singleton.activateWindow()
136+
137+
return _singleton
138+
139+
140+
get_icon = partial(get_icon, 'fa6s.toggle-on')

0 commit comments

Comments
 (0)