""" ui/devices_window.py Floating "Devices" settings window. Opens from the toolbar ⚙ DEVICES button; can stay open alongside the main window. Contains: device status cards, Add Device, and per-device Configure. """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, QSizePolicy, QDialog, ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer from PyQt6.QtGui import QCloseEvent from devices.base_device import DeviceStatus from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine _STATUS_STYLE = { DeviceStatus.CONNECTED: "color:#22c55e;", DeviceStatus.SIMULATED: "color:#3b82f6;", DeviceStatus.DISCONNECTED: "color:#475569;", DeviceStatus.CONNECTING: "color:#f59e0b;", DeviceStatus.ERROR: "color:#ef4444;", } _STATUS_LABEL = { DeviceStatus.CONNECTED: "CONNECTED", DeviceStatus.SIMULATED: "SIMULATED", DeviceStatus.DISCONNECTED: "OFFLINE", DeviceStatus.CONNECTING: "CONNECTING", DeviceStatus.ERROR: "ERROR", } class DeviceCard(QFrame): config_requested = pyqtSignal(str) def __init__(self, device): super().__init__() self.device = device self.setObjectName("deviceCard") self._build() def _build(self): layout = QVBoxLayout(self) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(5) # Header: icon + name + status dot hdr = QHBoxLayout() icon_lbl = QLabel(self.device.info.icon) icon_lbl.setObjectName("deviceIcon") hdr.addWidget(icon_lbl) name_lbl = QLabel(self.device.info.name) name_lbl.setObjectName("deviceName") hdr.addWidget(name_lbl, 1) self._dot = QLabel("●") self._dot.setStyleSheet( _STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;" ) hdr.addWidget(self._dot) layout.addLayout(hdr) # ID id_lbl = QLabel(self.device.info.device_id) id_lbl.setObjectName("deviceSub") layout.addWidget(id_lbl) # Status text self._status_lbl = QLabel(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) self._apply_status_style() layout.addWidget(self._status_lbl) # Channel count · type n = sum(1 for c in self.device.info.channels if c.enabled) ch_lbl = QLabel(f"{n} ch · {self.device.info.device_type}") ch_lbl.setObjectName("deviceChannelCount") layout.addWidget(ch_lbl) # Configure button cfg = QPushButton("Configure") cfg.setObjectName("configButton") cfg.clicked.connect(lambda: self.config_requested.emit(self.device.info.device_id)) layout.addWidget(cfg) def refresh(self): dot_style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;" self._dot.setStyleSheet(dot_style) self._status_lbl.setText(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) self._apply_status_style() def _apply_status_style(self): s = _STATUS_STYLE.get(self.device.status, "color:#475569;") self._status_lbl.setStyleSheet( s + " font-family:'IBM Plex Mono',monospace;" " font-size:10px; font-weight:700; letter-spacing:1px;" ) class DevicesWindow(QWidget): """ Standalone floating window for device management. Signals ------- config_requested(device_id) — user clicked Configure on a card device_added() — a new device was added via the dialog closed() — window was closed (so toolbar btn can untoggle) """ config_requested = pyqtSignal(str) device_added = pyqtSignal() closed = pyqtSignal() def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine, parent=None): super().__init__( parent, # Acts like a real standalone window but stays on top of parent Qt.WindowType.Window | Qt.WindowType.Tool, ) self.registry = registry self.engine = engine self._cards: dict = {} self.setWindowTitle("Devices") self.setMinimumSize(300, 480) self.resize(320, 600) self._build() self.refresh() self._timer = QTimer(self) self._timer.setInterval(2000) self._timer.timeout.connect(self._refresh_status) self._timer.start() # ── Build ───────────────────────────────────────────────────────────── def _build(self): root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) # ── Title bar row ────────────────────────────────────────────── title_bar = QWidget() title_bar.setObjectName("devWindowTitleBar") title_bar.setFixedHeight(40) tb_lay = QHBoxLayout(title_bar) tb_lay.setContentsMargins(12, 0, 8, 0) title_lbl = QLabel("⚙ DEVICES") title_lbl.setObjectName("devWindowTitle") tb_lay.addWidget(title_lbl, 1) self._add_btn = QPushButton("+ Add") self._add_btn.setObjectName("addDeviceButton") self._add_btn.clicked.connect(self._on_add) tb_lay.addWidget(self._add_btn) root.addWidget(title_bar) # ── Divider ──────────────────────────────────────────────────── div = QFrame() div.setFrameShape(QFrame.Shape.HLine) div.setObjectName("devWindowDivider") root.addWidget(div) # ── Scrollable device cards ──────────────────────────────────── scroll = QScrollArea() scroll.setObjectName("deviceScroll") scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._container = QWidget() self._inner = QVBoxLayout(self._container) self._inner.setContentsMargins(10, 10, 10, 10) self._inner.setSpacing(10) self._inner.addStretch() scroll.setWidget(self._container) root.addWidget(scroll) # ── Public API ──────────────────────────────────────────────────────── def refresh(self): """Rebuild all device cards from current registry state.""" for card in self._cards.values(): self._inner.removeWidget(card) card.deleteLater() self._cards.clear() for dev in self.registry.all_instances(): card = DeviceCard(dev) card.config_requested.connect(self.config_requested.emit) self._inner.insertWidget(self._inner.count() - 1, card) self._cards[dev.info.device_id] = card # ── Slots ──────────────────────────────────────────────────────────── def _on_add(self): from ui.add_device_dialog import AddDeviceDialog from ui.config_dialog import DeviceConfigDialog 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 _refresh_status(self): for card in self._cards.values(): card.refresh() def closeEvent(self, event: QCloseEvent): self.closed.emit() event.accept()