summaryrefslogtreecommitdiff
path: root/ui/add_device_dialog.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
commitd5acb04b88373d33b038bb59945fb5ab8b4f543b (patch)
tree21c37f2b54258ef20edc1b9b7226327d5f5b1a3a /ui/add_device_dialog.py
parent425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff)
V8
Diffstat (limited to 'ui/add_device_dialog.py')
-rw-r--r--ui/add_device_dialog.py371
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: