""" ui/add_device_dialog.py Add Device dialog — type-aware, shows the right fields for each device. Each device type gets its own config panel so the user sees exactly the fields they need and nothing irrelevant. Serial port scanning populates a clickable list that sets the port field. """ import os from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QPushButton, QLabel, QMessageBox, QStackedWidget, QWidget, QGroupBox, QListWidget, QListWidgetItem, QFrame, QSizePolicy, ) from PyQt6.QtCore import Qt, QThread, pyqtSignal from PyQt6.QtGui import QFont from devices.device_registry import DeviceRegistry from devices.analog_input import AnalogInputDevice from devices.digital_io import DigitalIODevice from devices.serial_device import SerialDevice # ── Port scanner thread ─────────────────────────────────────────────────────── class PortScanThread(QThread): ports_found = pyqtSignal(list) def run(self): from api_layers.arduino_layer import ArduinoLayer self.ports_found.emit(ArduinoLayer.list_ports()) # ── Background scan threads ─────────────────────────────────────────────────── class NIScanThread(QThread): devices_found = pyqtSignal(list) def run(self): try: import nidaqmx # type: ignore devs = [(d.name, d.product_type) for d in nidaqmx.system.System().devices] except Exception: devs = [] self.devices_found.emit(devs) # ── Reusable port scanner widget ───────────────────────────────────────────── class PortScanGroup(QGroupBox): """Scan-and-click widget that fills a target QLineEdit with the chosen port.""" def __init__(self, target_edit: QLineEdit): super().__init__("Available Serial Ports") self._target = target_edit self._scanner = None lay = QVBoxLayout(self) lay.setSpacing(4) top = QHBoxLayout() self._scan_btn = QPushButton("🔍 Scan Ports") self._scan_btn.setObjectName("addTraceBtn") self._scan_btn.clicked.connect(self._scan) self._status = QLabel("Click Scan to detect ports") self._status.setObjectName("traceSource") top.addWidget(self._scan_btn) top.addWidget(self._status, 1) lay.addLayout(top) self._list = QListWidget() self._list.setMaximumHeight(100) self._list.setObjectName("portList") self._list.itemClicked.connect(self._on_select) self._list.setToolTip("Click a port to select it") lay.addWidget(self._list) hint = QLabel("↑ Click a port above to fill the Port field") hint.setObjectName("traceSource") lay.addWidget(hint) def _scan(self): self._scan_btn.setEnabled(False) self._status.setText("Scanning…") self._list.clear() self._scanner = PortScanThread() self._scanner.ports_found.connect(self._on_found) self._scanner.start() def _on_found(self, ports): self._scan_btn.setEnabled(True) self._list.clear() if not ports: self._status.setText("No ports found") item = QListWidgetItem(" No serial ports detected") item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) self._list.addItem(item) else: self._status.setText(f"{len(ports)} port(s) found") for device, desc in ports: label = f" {device}" if desc and desc != device: label += f" — {desc}" item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, device) self._list.addItem(item) def _on_select(self, item: QListWidgetItem): port = item.data(Qt.ItemDataRole.UserRole) if port: self._target.setText(port) class NIScanGroup(QGroupBox): """Scan-and-click widget that fills a target QLineEdit with the chosen NI device.""" def __init__(self, target_edit: QLineEdit): super().__init__("Available NI Devices") self._target = target_edit self._scanner = None lay = QVBoxLayout(self) lay.setSpacing(4) top = QHBoxLayout() self._scan_btn = QPushButton("🔍 Scan NI Devices") self._scan_btn.setObjectName("addTraceBtn") self._scan_btn.clicked.connect(self._scan) self._status = QLabel("Click Scan to detect NI devices") self._status.setObjectName("traceSource") top.addWidget(self._scan_btn) top.addWidget(self._status, 1) lay.addLayout(top) self._list = QListWidget() self._list.setMaximumHeight(100) self._list.setObjectName("portList") self._list.itemClicked.connect(self._on_select) self._list.setToolTip("Click a device to select it") lay.addWidget(self._list) hint = QLabel("↑ Click a device above to fill the NI Device field") hint.setObjectName("traceSource") lay.addWidget(hint) def _scan(self): self._scan_btn.setEnabled(False) self._status.setText("Scanning…") self._list.clear() self._scanner = NIScanThread() self._scanner.devices_found.connect(self._on_found) self._scanner.start() def _on_found(self, devices): self._scan_btn.setEnabled(True) self._list.clear() if not devices: self._status.setText("No NI devices found") item = QListWidgetItem(" No NI devices detected") item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) self._list.addItem(item) else: self._status.setText(f"{len(devices)} device(s) found") for name, product in devices: label = f" {name}" if product: label += f" — {product}" item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, name) self._list.addItem(item) def _on_select(self, item: QListWidgetItem): name = item.data(Qt.ItemDataRole.UserRole) if name: self._target.setText(name) # ── Per-type config panels ──────────────────────────────────────────────────── class AnalogInputPanel(QWidget): """Config fields for AnalogInputDevice.""" def __init__(self): super().__init__() lay = QFormLayout(self) lay.setContentsMargins(0, 4, 0, 4) # Backend self.backend_cb = QComboBox() self.backend_cb.addItems(["nidaqmx", "arduino"]) lay.addRow("Backend:", self.backend_cb) # Channel count self.ch_spin = QSpinBox() self.ch_spin.setRange(1, 16) self.ch_spin.setValue(4) lay.addRow("Channels:", self.ch_spin) # Voltage range (NI) self.ni_device_edit = QLineEdit("Dev1") lay.addRow("NI Device:", self.ni_device_edit) self.min_v_spin = QDoubleSpinBox() self.min_v_spin.setRange(-100.0, 0.0) self.min_v_spin.setValue(-10.0) self.min_v_spin.setSuffix(" V") lay.addRow("Min Voltage:", self.min_v_spin) self.max_v_spin = QDoubleSpinBox() self.max_v_spin.setRange(0.0, 100.0) self.max_v_spin.setValue(10.0) self.max_v_spin.setSuffix(" V") lay.addRow("Max Voltage:", self.max_v_spin) self.ard_port_edit = QLineEdit("") self.ard_port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") lay.addRow("Arduino Port:", self.ard_port_edit) self.ard_baud_cb = QComboBox() self.ard_baud_cb.addItems(["9600", "57600", "115200", "230400"]) self.ard_baud_cb.setCurrentText("115200") lay.addRow("Arduino Baud:", self.ard_baud_cb) # Simulate self.sim_chk = QCheckBox("Simulation mode") self.sim_chk.setChecked(False) lay.addRow(self.sim_chk) self._ni_scan_grp = NIScanGroup(self.ni_device_edit) lay.addRow(self._ni_scan_grp) self._ard_scan_grp = PortScanGroup(self.ard_port_edit) lay.addRow(self._ard_scan_grp) self._ni_widgets = [self.ni_device_edit, self.min_v_spin, self.max_v_spin, self._ni_scan_grp] self._ard_widgets = [self.ard_port_edit, self.ard_baud_cb, self._ard_scan_grp] self.backend_cb.currentTextChanged.connect(self._on_backend_changed) self._on_backend_changed(self.backend_cb.currentText()) def _on_backend_changed(self, backend: str): ni = backend == "nidaqmx" lay = self.layout() for w in self._ni_widgets: w.setVisible(ni) lbl = lay.labelForField(w) if lbl: lbl.setVisible(ni) for w in self._ard_widgets: w.setVisible(not ni) lbl = lay.labelForField(w) if lbl: lbl.setVisible(not ni) def build_device(self, device_id: str) -> AnalogInputDevice: return AnalogInputDevice( device_id=device_id, num_channels=self.ch_spin.value(), simulate=self.sim_chk.isChecked(), backend=self.backend_cb.currentText(), ni_device=self.ni_device_edit.text().strip() or "Dev1", ni_min_v=self.min_v_spin.value(), ni_max_v=self.max_v_spin.value(), ard_port=self.ard_port_edit.text().strip() or "COM3", ard_baud=int(self.ard_baud_cb.currentText()), ) class DigitalIOPanel(QWidget): """Config fields for DigitalIODevice.""" def __init__(self): super().__init__() lay = QFormLayout(self) lay.setContentsMargins(0, 4, 0, 4) self.backend_cb = QComboBox() self.backend_cb.addItems(["nidaqmx", "arduino"]) lay.addRow("Backend:", self.backend_cb) self.in_spin = QSpinBox() self.in_spin.setRange(0, 32) self.in_spin.setValue(4) lay.addRow("Digital Inputs:", self.in_spin) self.out_spin = QSpinBox() self.out_spin.setRange(0, 32) self.out_spin.setValue(4) lay.addRow("Digital Outputs:", self.out_spin) self.ni_device_edit = QLineEdit("Dev1") lay.addRow("NI Device:", self.ni_device_edit) self.ard_port_edit = QLineEdit("") self.ard_port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") lay.addRow("Arduino Port:", self.ard_port_edit) self.sim_chk = QCheckBox("Simulation mode") self.sim_chk.setChecked(False) lay.addRow(self.sim_chk) self._ni_scan_grp = NIScanGroup(self.ni_device_edit) lay.addRow(self._ni_scan_grp) self._ard_scan_grp = PortScanGroup(self.ard_port_edit) lay.addRow(self._ard_scan_grp) self.backend_cb.currentTextChanged.connect(self._on_backend_changed) self._on_backend_changed(self.backend_cb.currentText()) def _on_backend_changed(self, backend: str): ni = backend == "nidaqmx" lay = self.layout() for w, show in [ (self.ni_device_edit, ni), (self._ni_scan_grp, ni), (self.ard_port_edit, not ni), (self._ard_scan_grp, not ni), ]: w.setVisible(show) lbl = lay.labelForField(w) if lbl: lbl.setVisible(show) def build_device(self, device_id: str) -> DigitalIODevice: return DigitalIODevice( device_id=device_id, num_inputs=self.in_spin.value(), num_outputs=self.out_spin.value(), simulate=self.sim_chk.isChecked(), backend=self.backend_cb.currentText(), ni_device=self.ni_device_edit.text().strip() or "Dev1", ard_port=self.ard_port_edit.text().strip() or "COM3", ) class SerialPanel(QWidget): """Config fields for SerialDevice with live port scanner.""" def __init__(self): super().__init__() lay = QVBoxLayout(self) lay.setContentsMargins(0, 4, 0, 4) lay.setSpacing(8) form = QFormLayout() self.port_edit = QLineEdit("") self.port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") form.addRow("Port:", self.port_edit) self.baud_cb = QComboBox() self.baud_cb.addItems(["9600", "19200", "38400", "57600", "115200", "230400", "460800"]) self.baud_cb.setCurrentText("115200") form.addRow("Baud Rate:", self.baud_cb) self.ch_spin = QSpinBox() self.ch_spin.setRange(1, 32) self.ch_spin.setValue(4) form.addRow("Channels:", self.ch_spin) self.fmt_cb = QComboBox() self.fmt_cb.addItems(["key:val", "csv", "json"]) form.addRow("Parse Format:", self.fmt_cb) self.sim_chk = QCheckBox("Simulation mode") self.sim_chk.setChecked(False) form.addRow(self.sim_chk) lay.addLayout(form) lay.addWidget(PortScanGroup(self.port_edit)) def build_device(self, device_id: str) -> SerialDevice: return SerialDevice( device_id=device_id, port=self.port_edit.text().strip() or "COM3", baud_rate=int(self.baud_cb.currentText()), num_channels=self.ch_spin.value(), parse_format=self.fmt_cb.currentText(), simulate=self.sim_chk.isChecked(), ) # ── Main dialog ─────────────────────────────────────────────────────────────── _PANELS = { "Analog Input": AnalogInputPanel, "Digital I/O": DigitalIOPanel, "Serial / UART": SerialPanel, } class AddDeviceDialog(QDialog): def __init__(self, registry: DeviceRegistry, parent=None): super().__init__(parent) self.registry = registry self.created_device = None self.setWindowTitle("Add Device") self.setMinimumSize(480, 460) self.resize(500, 520) self._build() def _build(self): root = QVBoxLayout(self) root.setSpacing(10) # Header hdr = QLabel("Add New Device") hdr.setObjectName("devWindowTitle") root.addWidget(hdr) div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) div.setObjectName("devWindowDivider"); root.addWidget(div) # Device type selector type_row = QFormLayout() self._type_cb = QComboBox() self._type_cb.addItems(list(_PANELS.keys())) self._type_cb.currentIndexChanged.connect(self._on_type_changed) type_row.addRow("Device Type:", self._type_cb) self._id_edit = QLineEdit() self._id_edit.setPlaceholderText("Leave blank for auto (e.g. ai_1, dio_0)") type_row.addRow("Device ID:", self._id_edit) root.addLayout(type_row) div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) div2.setObjectName("devWindowDivider"); root.addWidget(div2) # Stacked type-specific panels self._stack = QStackedWidget() self._panels = {} for name, cls in _PANELS.items(): panel = cls() self._panels[name] = panel self._stack.addWidget(panel) root.addWidget(self._stack, 1) div3 = QFrame(); div3.setFrameShape(QFrame.Shape.HLine) div3.setObjectName("devWindowDivider"); root.addWidget(div3) # Buttons btn_row = QHBoxLayout() btn_row.addStretch() cancel = QPushButton("Cancel") cancel.clicked.connect(self.reject) add = QPushButton("Add Device") add.setObjectName("applyButton") add.setDefault(True) add.clicked.connect(self._on_add) btn_row.addWidget(cancel) btn_row.addWidget(add) root.addLayout(btn_row) def _on_type_changed(self, idx: int): self._stack.setCurrentIndex(idx) # Auto-suggest a device ID based on type type_name = self._type_cb.currentText() prefixes = { "Analog Input": "ai", "Digital I/O": "dio", "Serial / UART": "ser", } prefix = prefixes.get(type_name, "dev") existing = {d.info.device_id for d in self.registry.all_instances()} for i in range(100): candidate = f"{prefix}_{i}" if candidate not in existing: self._id_edit.setPlaceholderText(f"e.g. {candidate} (auto)") break def _on_add(self): type_name = self._type_cb.currentText() panel = self._panels[type_name] dev_id = self._id_edit.text().strip() # Auto-generate ID if blank if not dev_id: prefixes = {"Analog Input": "ai", "Digital I/O": "dio", "Serial / UART": "ser"} prefix = prefixes.get(type_name, "dev") existing = {d.info.device_id for d in self.registry.all_instances()} for i in range(100): candidate = f"{prefix}_{i}" if candidate not in existing: dev_id = candidate break if self.registry.get_instance(dev_id): QMessageBox.warning(self, "Duplicate ID", f"A device with ID '{dev_id}' already exists.\n" f"Choose a different ID.") return try: dev = panel.build_device(dev_id) self.created_device = dev self.accept() except Exception as e: QMessageBox.critical(self, "Error creating device", str(e))