summaryrefslogtreecommitdiff
path: root/ui/windows/devices_window.py
diff options
context:
space:
mode:
Diffstat (limited to 'ui/windows/devices_window.py')
-rw-r--r--ui/windows/devices_window.py319
1 files changed, 319 insertions, 0 deletions
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()