diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-20 16:55:57 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-20 16:55:57 -0600 |
| commit | d5acb04b88373d33b038bb59945fb5ab8b4f543b (patch) | |
| tree | 21c37f2b54258ef20edc1b9b7226327d5f5b1a3a /ui/add_device_dialog.py | |
| parent | 425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff) | |
V8
Diffstat (limited to 'ui/add_device_dialog.py')
| -rw-r--r-- | ui/add_device_dialog.py | 371 |
1 files changed, 321 insertions, 50 deletions
diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py index 30d0016..03ece63 100644 --- a/ui/add_device_dialog.py +++ b/ui/add_device_dialog.py @@ -1,26 +1,264 @@ """ -ui/add_device_dialog.py — Dialog to add a new device at runtime. +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, QFormLayout, QHBoxLayout, - QComboBox, QLineEdit, QSpinBox, QCheckBox, - QPushButton, QLabel, QMessageBox, + 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 - - -# Map display name -> (class, extra_kwargs_defaults) -_DEVICE_TYPES = { - "Analog Input — NI-DAQmx": (AnalogInputDevice, {"backend": "nidaqmx"}), - "Analog Input — Arduino": (AnalogInputDevice, {"backend": "arduino"}), - "Digital I/O — NI-DAQmx": (DigitalIODevice, {"backend": "nidaqmx"}), - "Digital I/O — Arduino": (DigitalIODevice, {"backend": "arduino"}), - "Serial / UART": (SerialDevice, {}), +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, } @@ -30,70 +268,103 @@ class AddDeviceDialog(QDialog): self.registry = registry self.created_device = None self.setWindowTitle("Add Device") - self.setMinimumWidth(360) + self.setMinimumSize(480, 460) + self.resize(500, 520) self._build() def _build(self): - layout = QVBoxLayout(self) - form = QFormLayout() + 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(_DEVICE_TYPES.keys())) - form.addRow("Device Type:", self._type_cb) + 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("e.g. ai_1 / ser_0") - form.addRow("Device ID:", self._id_edit) + 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) - self._ch_spin = QSpinBox() - self._ch_spin.setRange(1, 16) - self._ch_spin.setValue(4) - form.addRow("# Channels:", self._ch_spin) + div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) + div2.setObjectName("devWindowDivider"); root.addWidget(div2) - self._sim_chk = QCheckBox("Simulation mode (no hardware required)") - self._sim_chk.setChecked(True) - form.addRow(self._sim_chk) + # 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) - layout.addLayout(form) + div3 = QFrame(); div3.setFrameShape(QFrame.Shape.HLine) + div3.setObjectName("devWindowDivider"); root.addWidget(div3) - btns = QHBoxLayout() - btns.addStretch() + # Buttons + btn_row = QHBoxLayout() + btn_row.addStretch() cancel = QPushButton("Cancel") cancel.clicked.connect(self.reject) - add = QPushButton("Add Device") - add.setDefault(True) + add = QPushButton("Add Device") add.setObjectName("applyButton") + add.setDefault(True) add.clicked.connect(self._on_add) - btns.addWidget(cancel) - btns.addWidget(add) - layout.addLayout(btns) + 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): - label = self._type_cb.currentText() - cls, kw = _DEVICE_TYPES[label] - dev_id = self._id_edit.text().strip() + 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: - base = kw.get("backend", "dev") + 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"{base}_{i}" + 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.") + f"A device with ID '{dev_id}' already exists.\n" + f"Choose a different ID.") return try: - dev = cls( - device_id=dev_id, - num_channels=self._ch_spin.value(), - simulate=self._sim_chk.isChecked(), - **kw, - ) + dev = panel.build_device(dev_id) self.created_device = dev self.accept() except Exception as e: |
