Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion dronecan_gui_tool/panels/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from . import RemoteID_panel
from . import hobbywing_esc
from . import rc_panel
from . import hardpoints_panel
from . import circuit_status_panel

class PanelDescriptor:
def __init__(self, module):
Expand Down Expand Up @@ -45,5 +47,7 @@ def safe_spawn(self, parent, node):
PanelDescriptor(stats_panel),
PanelDescriptor(RemoteID_panel),
PanelDescriptor(hobbywing_esc),
PanelDescriptor(rc_panel)
PanelDescriptor(rc_panel),
PanelDescriptor(hardpoints_panel),
PanelDescriptor(circuit_status_panel)
]
186 changes: 186 additions & 0 deletions dronecan_gui_tool/panels/circuit_status_panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#
# Copyright (C) 2026 DroneCAN Development Team <dronecan.org>
#
# This software is distributed under the terms of the MIT License.
#

import dronecan
from functools import partial
from PyQt6.QtWidgets import QVBoxLayout, QLabel, QDialog, QGroupBox, QTableWidget, QTableWidgetItem, QHeaderView
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QBrush, QColor
from logging import getLogger
from ..widgets import get_icon, get_monospace_font

__all__ = 'PANEL_NAME', 'spawn', 'get_icon'

PANEL_NAME = 'CircuitStatus'

logger = getLogger(__name__)

_singleton = None


class CircuitStatusPanel(QDialog):
def __init__(self, parent, node):
super(CircuitStatusPanel, self).__init__(parent)
self.setWindowTitle('CircuitStatus Monitor')
self.setAttribute(Qt.WA_DeleteOnClose)

self._node = node

# Main Layout
layout = QVBoxLayout(self)

# ---------------------------------------------------------
# Monitoring Group Box (CircuitStatus updates)
# ---------------------------------------------------------
monitor_group = QGroupBox('Circuit Status Monitor', self)
monitor_layout = QVBoxLayout()

self._table = QTableWidget(self)
self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels(['Circuit', 'Voltage', 'Current', 'Power', 'Status / Errors'])
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self._table.verticalHeader().setVisible(False)

monitor_layout.addWidget(self._table)
monitor_group.setLayout(monitor_layout)
layout.addWidget(monitor_group)

self._circuit_rows = {}

self.setLayout(layout)
self.resize(550, 300)

# Register DroneCAN handler for CircuitStatus
self._handlers = [
self._node.add_handler(dronecan.uavcan.equipment.power.CircuitStatus, self._on_circuit_status)
]

# Timer to check for offline/stale status
self._stale_timer = QTimer(self)
self._stale_timer.timeout.connect(self._check_stale_circuits)
self._stale_timer.start(1000)

def _on_circuit_status(self, event):
import time
msg = event.message
cid = msg.circuit_id

if cid not in self._circuit_rows:
# Insert row at sorted position
row_idx = sum(1 for existing_cid in self._circuit_rows if existing_cid < cid)
self._table.insertRow(row_idx)

item_name = QTableWidgetItem(f'Circuit {cid}')
item_volt = QTableWidgetItem('NC')
item_curr = QTableWidgetItem('NC')
item_pwr = QTableWidgetItem('NC')
item_err = QTableWidgetItem('NC')

font = get_monospace_font()
for item in (item_name, item_volt, item_curr, item_pwr, item_err):
item.setFont(font)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)

self._table.setItem(row_idx, 0, item_name)
self._table.setItem(row_idx, 1, item_volt)
self._table.setItem(row_idx, 2, item_curr)
self._table.setItem(row_idx, 3, item_pwr)
self._table.setItem(row_idx, 4, item_err)

self._circuit_rows[cid] = {
'voltage': item_volt,
'current': item_curr,
'power': item_pwr,
'error': item_err,
'last_update': 0
}

row = self._circuit_rows[cid]
row['last_update'] = time.time()

# Format voltage, current, power
v = msg.voltage
i = msg.current
p = v * i

row['voltage'].setText(f'{v:6.2f} V')
row['current'].setText(f'{i:6.2f} A')
row['power'].setText(f'{p:6.2f} W')

# Parse error flags
errs = []
flags = msg.error_flags
if flags & msg.ERROR_FLAG_OVERVOLTAGE:
errs.append('OVER_V')
if flags & msg.ERROR_FLAG_UNDERVOLTAGE:
errs.append('UNDER_V')
if flags & msg.ERROR_FLAG_OVERCURRENT:
errs.append('OVER_C')
if flags & msg.ERROR_FLAG_UNDERCURRENT:
errs.append('UNDER_C')

if errs:
row['error'].setText(', '.join(errs))
row['error'].setForeground(QBrush(QColor('red')))
font = row['error'].font()
font.setBold(True)
row['error'].setFont(font)
else:
row['error'].setText('OK')
row['error'].setForeground(QBrush(QColor('green')))
font = row['error'].font()
font.setBold(False)
row['error'].setFont(font)

def _check_stale_circuits(self):
import time
now = time.time()
for cid, row in self._circuit_rows.items():
if row['last_update'] == 0:
continue
if now - row['last_update'] > 3.0:
row['voltage'].setText('STALE')
row['current'].setText('STALE')
row['power'].setText('STALE')
row['error'].setText('OFFLINE')
row['error'].setForeground(QBrush(QColor('gray')))
font = row['error'].font()
font.setBold(False)
row['error'].setFont(font)

def __del__(self):
global _singleton
_singleton = None
for h in self._handlers:
try:
h.remove()
except Exception:
pass

def closeEvent(self, event):
global _singleton
_singleton = None
for h in self._handlers:
try:
h.remove()
except Exception:
pass
super(CircuitStatusPanel, self).closeEvent(event)


def spawn(parent, node):
global _singleton
if _singleton is None:
_singleton = CircuitStatusPanel(parent, node)

_singleton.show()
_singleton.raise_()
_singleton.activateWindow()

return _singleton


get_icon = partial(get_icon, 'fa6s.asterisk')
140 changes: 140 additions & 0 deletions dronecan_gui_tool/panels/hardpoints_panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#
# Copyright (C) 2026 DroneCAN Development Team <dronecan.org>
#
# This software is distributed under the terms of the MIT License.
#

import dronecan
from functools import partial
from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QDialog, QSpinBox, QComboBox, QGroupBox, QLayout
from PyQt6.QtCore import Qt
from logging import getLogger
from ..widgets import make_icon_button, get_icon

__all__ = 'PANEL_NAME', 'spawn', 'get_icon'

PANEL_NAME = 'Hardpoints'

logger = getLogger(__name__)

_singleton = None


class HardpointsPanel(QDialog):
def __init__(self, parent, node):
super(HardpointsPanel, self).__init__(parent)
self.setWindowTitle('Hardpoints Control')
self.setAttribute(Qt.WA_DeleteOnClose)

self._node = node

# Main Layout
layout = QVBoxLayout(self)

# ---------------------------------------------------------
# Control Group Box (Relay/Hardpoint Controls)
# ---------------------------------------------------------
control_group = QGroupBox('Relay / Hardpoint Control', self)
control_layout = QVBoxLayout()

# Hardpoint ID field
hp_id_layout = QHBoxLayout()
hp_id_layout.addWidget(QLabel('Hardpoint ID:', self))
self._hardpoint_id = QSpinBox(self)
self._hardpoint_id.setMinimum(0)
self._hardpoint_id.setMaximum(255)
self._hardpoint_id.setValue(0)
hp_id_layout.addWidget(self._hardpoint_id)
hp_id_layout.addStretch()
control_layout.addLayout(hp_id_layout)

# Command / State field
state_layout = QHBoxLayout()
state_layout.addWidget(QLabel('State / Command:', self))
self._state_combo = QComboBox(self)
self._state_combo.addItem('0 - Release / OFF', 0)
self._state_combo.addItem('1 - Hold / ON', 1)
self._state_combo.addItem('Custom...', -1)
self._state_combo.currentIndexChanged.connect(self._on_state_combo_changed)
state_layout.addWidget(self._state_combo)

self._custom_val = QSpinBox(self)
self._custom_val.setMinimum(0)
self._custom_val.setMaximum(65535)
self._custom_val.setValue(0)
self._custom_val.setVisible(False)
state_layout.addWidget(self._custom_val)
state_layout.addStretch()
control_layout.addLayout(state_layout)

# Send Button
self._send_button = make_icon_button('fa6s.paper-plane', 'Send command', self, text='Send Command', on_clicked=self._do_send)
control_layout.addWidget(self._send_button)

# Status Label
self._status_label = QLabel('', self)
control_layout.addWidget(self._status_label)

control_group.setLayout(control_layout)
layout.addWidget(control_group)

self.setLayout(layout)
self.setMinimumWidth(350)
layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)

def _on_state_combo_changed(self):
is_custom = self._state_combo.currentData() == -1
self._custom_val.setVisible(is_custom)

def get_command_value(self):
val = self._state_combo.currentData()
if val == -1:
return self._custom_val.value()
return val

def _do_send(self):
try:
# Construct the message (uses default ID 1070)
msg = dronecan.uavcan.equipment.hardpoint.Command()
msg.hardpoint_id = self._hardpoint_id.value()
msg.command = self.get_command_value()

# Broadcast
self._node.broadcast(msg)

cmd_val = msg.command
if cmd_val == 0:
cmd_str = "release"
elif cmd_val == 1:
cmd_str = "hold"
else:
cmd_str = str(cmd_val)

self._status_label.setText(f"command {cmd_str} sent to hardpoint {msg.hardpoint_id}")
except Exception as ex:
logger.error(f'Sending failed: {ex}')
self._status_label.setText(f"Sending failed: {ex}")

def __del__(self):
global _singleton
_singleton = None

def closeEvent(self, event):
global _singleton
_singleton = None
super(HardpointsPanel, self).closeEvent(event)


def spawn(parent, node):
global _singleton
if _singleton is None:
_singleton = HardpointsPanel(parent, node)

_singleton.show()
_singleton.raise_()
_singleton.activateWindow()

return _singleton


get_icon = partial(get_icon, 'fa6s.toggle-on')
Loading