From d5acb04b88373d33b038bb59945fb5ab8b4f543b Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 20 Apr 2026 16:55:57 -0600 Subject: V8 --- ui/windows/devices_window.py | 319 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 ui/windows/devices_window.py (limited to 'ui/windows/devices_window.py') diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py new file mode 100644 index 0000000..fbaf9fa --- /dev/null +++ b/ui/windows/devices_window.py @@ -0,0 +1,319 @@ +""" +ui/windows/devices_window.py + +DEVICES window — toolbar section 2. + +Tabs: + Physical Devices — add / remove / connect devices; shows status cards + Channels — per-device channel list: rename, set unit, enable/disable +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem, + QHeaderView, QCheckBox, QLineEdit, QDoubleSpinBox, QComboBox, + QSizePolicy, QMessageBox, QAbstractItemView, +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QSize +from PyQt6.QtGui import QCloseEvent, QColor, QFont + +from devices.base_device import DeviceStatus +from devices.device_registry import DeviceRegistry +from core.acquisition import AcquisitionEngine + + +_DOT_COLOR = { + DeviceStatus.CONNECTED: "#22c55e", + DeviceStatus.SIMULATED: "#3b82f6", + DeviceStatus.DISCONNECTED: "#475569", + DeviceStatus.CONNECTING: "#f59e0b", + DeviceStatus.ERROR: "#ef4444", +} +_STATUS_TEXT = { + DeviceStatus.CONNECTED: "CONNECTED", + DeviceStatus.SIMULATED: "SIMULATED", + DeviceStatus.DISCONNECTED: "OFFLINE", + DeviceStatus.CONNECTING: "CONNECTING…", + DeviceStatus.ERROR: "ERROR", +} + + +class DeviceRow(QFrame): + configure_clicked = pyqtSignal(str) + remove_clicked = pyqtSignal(str) + + def __init__(self, device): + super().__init__() + self.device = device + self.setObjectName("deviceCard") + self._build() + + def _build(self): + lay = QHBoxLayout(self) + lay.setContentsMargins(10, 8, 10, 8) + lay.setSpacing(10) + + # Status dot + self._dot = QLabel("●") + self._dot.setFixedWidth(14) + self._set_dot_color() + lay.addWidget(self._dot) + + # Icon + name + icon = QLabel(self.device.info.icon) + icon.setObjectName("deviceIcon") + lay.addWidget(icon) + + info_col = QVBoxLayout() + info_col.setSpacing(1) + name = QLabel(self.device.info.name) + name.setObjectName("deviceName") + id_lbl = QLabel(f"{self.device.info.device_id} · {self.device.info.device_type}") + id_lbl.setObjectName("deviceSub") + info_col.addWidget(name) + info_col.addWidget(id_lbl) + lay.addLayout(info_col, 1) + + # Status + channel count + stat_col = QVBoxLayout() + stat_col.setSpacing(1) + self._status_lbl = QLabel(_STATUS_TEXT.get(self.device.status, "?")) + self._status_lbl.setObjectName("deviceSub") + self._set_status_style() + n = sum(1 for c in self.device.info.channels if c.enabled) + ch_lbl = QLabel(f"{n} ch") + ch_lbl.setObjectName("deviceChannelCount") + stat_col.addWidget(self._status_lbl) + stat_col.addWidget(ch_lbl) + lay.addLayout(stat_col) + + cfg_btn = QPushButton("Configure") + cfg_btn.setObjectName("configButton") + cfg_btn.clicked.connect(lambda: self.configure_clicked.emit(self.device.info.device_id)) + lay.addWidget(cfg_btn) + + rm_btn = QPushButton("✕") + rm_btn.setObjectName("traceRemoveBtn") + rm_btn.setFixedSize(24, 24) + rm_btn.clicked.connect(lambda: self.remove_clicked.emit(self.device.info.device_id)) + lay.addWidget(rm_btn) + + def refresh(self): + self._set_dot_color() + self._status_lbl.setText(_STATUS_TEXT.get(self.device.status, "?")) + self._set_status_style() + + def _set_dot_color(self): + c = _DOT_COLOR.get(self.device.status, "#475569") + self._dot.setStyleSheet(f"color:{c}; font-size:10px;") + + def _set_status_style(self): + c = _DOT_COLOR.get(self.device.status, "#475569") + self._status_lbl.setStyleSheet( + f"color:{c}; font-family:'IBM Plex Mono',monospace;" + f" font-size:10px; font-weight:700; letter-spacing:1px;" + ) + + +class ChannelsTab(QWidget): + """Shows all channels across all devices in an editable table.""" + + def __init__(self, registry: DeviceRegistry): + super().__init__() + self.registry = registry + self._build() + + def _build(self): + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + + self._table = QTableWidget() + self._table.setObjectName("channelTable") + self._table.setColumnCount(6) + self._table.setHorizontalHeaderLabels( + ["Device", "Channel ID", "Name", "Unit", "Min", "Max"] + ) + self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self._table.setAlternatingRowColors(True) + self._table.verticalHeader().setVisible(False) + lay.addWidget(self._table) + + def refresh(self): + self._table.setRowCount(0) + row = 0 + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + self._table.insertRow(row) + + dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id}") + dev_item.setFlags(dev_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + dev_item.setForeground(QColor("#64748b")) + self._table.setItem(row, 0, dev_item) + + ch_item = QTableWidgetItem(ch.channel_id) + ch_item.setFlags(ch_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + ch_item.setForeground(QColor(ch.color)) + self._table.setItem(row, 1, ch_item) + + name_item = QTableWidgetItem(ch.name) + self._table.setItem(row, 2, name_item) + + unit_item = QTableWidgetItem(ch.unit) + self._table.setItem(row, 3, unit_item) + + min_item = QTableWidgetItem(str(ch.min_value)) + max_item = QTableWidgetItem(str(ch.max_value)) + self._table.setItem(row, 4, min_item) + self._table.setItem(row, 5, max_item) + + # Store references for saving + name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id)) + row += 1 + + self._table.cellChanged.connect(self._on_cell_changed) + + def _on_cell_changed(self, row: int, col: int): + item = self._table.item(row, 2) # name col + if item is None: + return + ref = item.data(Qt.ItemDataRole.UserRole) + if ref is None: + return + dev_id, ch_id = ref + dev = self.registry.get_instance(dev_id) + if not dev: + return + ch = dev.get_channel(ch_id) + if not ch: + return + if col == 2: + ch.name = self._table.item(row, 2).text() + elif col == 3: + ch.unit = self._table.item(row, 3).text() + elif col == 4: + try: + ch.min_value = float(self._table.item(row, 4).text()) + except ValueError: + pass + elif col == 5: + try: + ch.max_value = float(self._table.item(row, 5).text()) + except ValueError: + pass + + +class DevicesWindow(QWidget): + device_added = pyqtSignal() + device_removed = pyqtSignal(str) + device_reconfigured = pyqtSignal(str) + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.engine = engine + self._rows: dict = {} + + self.setWindowTitle("Devices") + self.setMinimumSize(580, 480) + self.resize(640, 560) + self._build() + self.refresh() + + self._poll = QTimer(self) + self._poll.setInterval(2000) + self._poll.timeout.connect(self._refresh_status) + self._poll.start() + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14, 0, 10, 0) + title = QLabel("DEVICES"); title.setObjectName("devWindowTitle") + hl.addWidget(title, 1) + add_btn = QPushButton("+ Add Device") + add_btn.setObjectName("addDeviceButton") + add_btn.clicked.connect(self._on_add) + hl.addWidget(add_btn) + root.addWidget(hdr) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs") + root.addWidget(tabs, 1) + + # Tab 1: Physical devices + dev_tab = QWidget() + dev_lay = QVBoxLayout(dev_tab); dev_lay.setContentsMargins(0,0,0,0) + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._container = QWidget() + self._inner = QVBoxLayout(self._container) + self._inner.setContentsMargins(10,10,10,10); self._inner.setSpacing(6) + self._inner.addStretch() + scroll.setWidget(self._container) + dev_lay.addWidget(scroll) + tabs.addTab(dev_tab, " Physical Devices ") + + # Tab 2: Channels + self._ch_tab = ChannelsTab(self.registry) + tabs.addTab(self._ch_tab, " Channels ") + tabs.currentChanged.connect(lambda i: self._ch_tab.refresh() if i == 1 else None) + + def refresh(self): + for row in self._rows.values(): + self._inner.removeWidget(row); row.deleteLater() + self._rows.clear() + for dev in self.registry.all_instances(): + row = DeviceRow(dev) + row.configure_clicked.connect(self._on_configure) + row.remove_clicked.connect(self._on_remove) + self._inner.insertWidget(self._inner.count() - 1, row) + self._rows[dev.info.device_id] = row + + def _refresh_status(self): + for row in self._rows.values(): + row.refresh() + + def _on_add(self): + from ui.add_device_dialog import AddDeviceDialog + dlg = AddDeviceDialog(self.registry, self) + if dlg.exec(): + dev = dlg.created_device + if dev: + dev.connect() + self.registry.add_instance(dev) + self.engine.add_device(dev) + self.refresh() + self.device_added.emit() + + def _on_configure(self, device_id: str): + from ui.config_dialog import DeviceConfigDialog + dev = self.registry.get_instance(device_id) + if dev: + DeviceConfigDialog(dev, self).exec() + # Signal that device config may have changed (backend/simulate/port) + self.device_reconfigured.emit(device_id) + + def _on_remove(self, device_id: str): + reply = QMessageBox.question( + self, "Remove Device", + f"Remove '{device_id}'? This cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self.engine.remove_device(device_id) + self.registry.remove_instance(device_id) + self.refresh() + self.device_removed.emit(device_id) + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() -- cgit v1.2.3