""" 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()) # ── 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) # Arduino port (shown always, relevant for arduino backend) 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(True) lay.addRow(self.sim_chk) 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(True) lay.addRow(self.sim_chk) 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(True) form.addRow(self.sim_chk) lay.addLayout(form) # Port scanner scan_grp = QGroupBox("Available Serial Ports") scan_lay = QVBoxLayout(scan_grp) scan_lay.setSpacing(4) scan_top = QHBoxLayout() self._scan_btn = QPushButton("🔍 Scan Ports") self._scan_btn.setObjectName("addTraceBtn") self._scan_btn.clicked.connect(self._scan_ports) self._scan_status = QLabel("Click Scan to detect ports") self._scan_status.setObjectName("traceSource") scan_top.addWidget(self._scan_btn) scan_top.addWidget(self._scan_status, 1) scan_lay.addLayout(scan_top) self._port_list = QListWidget() self._port_list.setMaximumHeight(100) self._port_list.setObjectName("portList") self._port_list.itemClicked.connect(self._on_port_selected) self._port_list.setToolTip("Click a port to select it") scan_lay.addWidget(self._port_list) hint = QLabel("↑ Click a port above to fill the Port field") hint.setObjectName("traceSource") scan_lay.addWidget(hint) lay.addWidget(scan_grp) self._scanner = None def _scan_ports(self): self._scan_btn.setEnabled(False) self._scan_status.setText("Scanning…") self._port_list.clear() self._scanner = PortScanThread() self._scanner.ports_found.connect(self._on_ports_found) self._scanner.start() def _on_ports_found(self, ports): self._scan_btn.setEnabled(True) self._port_list.clear() if not ports: self._scan_status.setText("No ports found") item = QListWidgetItem(" No serial ports detected") item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) self._port_list.addItem(item) else: self._scan_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._port_list.addItem(item) def _on_port_selected(self, item: QListWidgetItem): port = item.data(Qt.ItemDataRole.UserRole) if port: self.port_edit.setText(port) 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))