diff --git a/dronecan_gui_tool/panels/__init__.py b/dronecan_gui_tool/panels/__init__.py index b49fdf2..85a7d6c 100644 --- a/dronecan_gui_tool/panels/__init__.py +++ b/dronecan_gui_tool/panels/__init__.py @@ -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): @@ -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) ] diff --git a/dronecan_gui_tool/panels/circuit_status_panel.py b/dronecan_gui_tool/panels/circuit_status_panel.py new file mode 100644 index 0000000..b5df15c --- /dev/null +++ b/dronecan_gui_tool/panels/circuit_status_panel.py @@ -0,0 +1,186 @@ +# +# Copyright (C) 2026 DroneCAN Development Team +# +# 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') diff --git a/dronecan_gui_tool/panels/hardpoints_panel.py b/dronecan_gui_tool/panels/hardpoints_panel.py new file mode 100644 index 0000000..6def3a3 --- /dev/null +++ b/dronecan_gui_tool/panels/hardpoints_panel.py @@ -0,0 +1,140 @@ +# +# Copyright (C) 2026 DroneCAN Development Team +# +# 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')