""" 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.""" channel_visibility_changed = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled # Col indices _C_DEVICE = 0 _C_CH_ID = 1 _C_ENABLED = 2 _C_NAME = 3 _C_UNIT = 4 _C_MIN = 5 _C_MAX = 6 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(7) self._table.setHorizontalHeaderLabels( ["Device", "Channel ID", "On", "Name", "Unit", "Min", "Max"] ) hdr = self._table.horizontalHeader() hdr.setSectionResizeMode(self._C_NAME, QHeaderView.ResizeMode.Stretch) hdr.setSectionResizeMode(self._C_DEVICE, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_CH_ID, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_ENABLED, QHeaderView.ResizeMode.ResizeToContents) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setAlternatingRowColors(True) self._table.verticalHeader().setVisible(False) self._table.cellChanged.connect(self._on_cell_changed) lay.addWidget(self._table) def refresh(self): self._table.blockSignals(True) 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, self._C_DEVICE, 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, self._C_CH_ID, ch_item) # Enabled checkbox — centred in cell chk_container = QWidget() chk_lay = QHBoxLayout(chk_container) chk_lay.setContentsMargins(0, 0, 0, 0) chk_lay.setAlignment(Qt.AlignmentFlag.AlignCenter) chk = QCheckBox() chk.setChecked(ch.enabled) dev_id_cap = dev.info.device_id ch_id_cap = ch.channel_id chk.toggled.connect( lambda checked, d=dev_id_cap, c=ch_id_cap: self._on_enabled(d, c, checked) ) chk_lay.addWidget(chk) self._table.setCellWidget(row, self._C_ENABLED, chk_container) name_item = QTableWidgetItem(ch.name) name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id)) self._table.setItem(row, self._C_NAME, name_item) unit_item = QTableWidgetItem(ch.unit) self._table.setItem(row, self._C_UNIT, unit_item) min_item = QTableWidgetItem(str(ch.min_value)) max_item = QTableWidgetItem(str(ch.max_value)) self._table.setItem(row, self._C_MIN, min_item) self._table.setItem(row, self._C_MAX, max_item) row += 1 self._table.blockSignals(False) def _on_enabled(self, dev_id: str, ch_id: str, checked: bool): dev = self.registry.get_instance(dev_id) if dev: ch = dev.get_channel(ch_id) if ch: ch.enabled = checked self.channel_visibility_changed.emit(dev_id, ch_id, checked) def _on_cell_changed(self, row: int, col: int): name_item = self._table.item(row, self._C_NAME) if name_item is None: return ref = name_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 == self._C_NAME: ch.name = self._table.item(row, self._C_NAME).text() elif col == self._C_UNIT: ch.unit = self._table.item(row, self._C_UNIT).text() elif col == self._C_MIN: try: ch.min_value = float(self._table.item(row, self._C_MIN).text()) except ValueError: pass elif col == self._C_MAX: try: ch.max_value = float(self._table.item(row, self._C_MAX).text()) except ValueError: pass class DevicesWindow(QWidget): device_added = pyqtSignal() device_removed = pyqtSignal(str) device_reconfigured = pyqtSignal(str) channel_visibility_changed = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled 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) self._ch_tab.channel_visibility_changed.connect(self.channel_visibility_changed) 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()