""" 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()