summaryrefslogtreecommitdiff
path: root/devices
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 /devices
parent425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff)
V8
Diffstat (limited to 'devices')
-rw-r--r--devices/__pycache__/__init__.cpython-314.pycbin398 -> 401 bytes
-rw-r--r--devices/__pycache__/analog_input.cpython-314.pycbin16238 -> 24525 bytes
-rw-r--r--devices/__pycache__/base_device.cpython-314.pycbin7731 -> 7588 bytes
-rw-r--r--devices/__pycache__/device_registry.cpython-314.pycbin5927 -> 5930 bytes
-rw-r--r--devices/__pycache__/digital_io.cpython-314.pycbin19275 -> 19331 bytes
-rw-r--r--devices/__pycache__/serial_device.cpython-314.pycbin11824 -> 16431 bytes
-rw-r--r--devices/analog_input.py369
-rw-r--r--devices/base_device.py2
-rw-r--r--devices/digital_io.py3
-rw-r--r--devices/serial_device.py92
10 files changed, 329 insertions, 137 deletions
diff --git a/devices/__pycache__/__init__.cpython-314.pyc b/devices/__pycache__/__init__.cpython-314.pyc
index 9ab29a6..bbe1fd2 100644
--- a/devices/__pycache__/__init__.cpython-314.pyc
+++ b/devices/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc
index 4c08631..27ce47c 100644
--- a/devices/__pycache__/analog_input.cpython-314.pyc
+++ b/devices/__pycache__/analog_input.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc
index ade2b20..358f1ed 100644
--- a/devices/__pycache__/base_device.cpython-314.pyc
+++ b/devices/__pycache__/base_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc
index 608c8ee..d4adce0 100644
--- a/devices/__pycache__/device_registry.cpython-314.pyc
+++ b/devices/__pycache__/device_registry.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc
index b2bb27f..2fd4200 100644
--- a/devices/__pycache__/digital_io.cpython-314.pyc
+++ b/devices/__pycache__/digital_io.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc
index 0674606..9ef9fca 100644
--- a/devices/__pycache__/serial_device.cpython-314.pyc
+++ b/devices/__pycache__/serial_device.cpython-314.pyc
Binary files differ
diff --git a/devices/analog_input.py b/devices/analog_input.py
index 5bb5d28..6183f43 100644
--- a/devices/analog_input.py
+++ b/devices/analog_input.py
@@ -1,30 +1,23 @@
"""
devices/analog_input.py
-Analog Input device module.
-
-Supports two interchangeable backends:
- • backend="nidaqmx" → NidaqmxLayer (NI hardware or sim)
- • backend="arduino" → ArduinoLayer (Arduino hardware or sim)
-
-The device is backend-agnostic at the acquisition layer — swap
-the backend without changing any other code.
+Analog Input device. Backends: NI-DAQmx or Arduino.
"""
from typing import Any, Dict, List
from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QFormLayout, QGroupBox,
- QComboBox, QDoubleSpinBox, QSpinBox, QCheckBox,
- QLineEdit, QLabel, QPushButton, QHBoxLayout,
+ QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox,
+ QComboBox, QDoubleSpinBox, QCheckBox, QLineEdit, QLabel,
+ QPushButton, QListWidget, QListWidgetItem, QFrame, QTextEdit,
)
-from PyQt6.QtCore import Qt
+from PyQt6.QtCore import Qt, QThread, pyqtSignal
+from PyQt6.QtGui import QFont
from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
from api_layers.nidaqmx_layer import NidaqmxLayer
from api_layers.arduino_layer import ArduinoLayer
-# Distinct colors for up to 16 channels
_COLORS = [
"#00d4ff", "#ff6b35", "#7fff6e", "#ffcc00",
"#c77dff", "#ff4d6d", "#4cc9f0", "#f72585",
@@ -34,49 +27,38 @@ _COLORS = [
class AnalogInputDevice(BaseDevice):
- """Multi-channel analog input. Backend: NI-DAQmx or Arduino."""
-
DEVICE_TYPE = "analog_input"
ICON = "〜"
def __init__(
self,
- device_id: str = "ai_0",
- num_channels: int = 4,
- simulate: bool = True,
- backend: str = "nidaqmx", # "nidaqmx" | "arduino"
- # NI-specific
- ni_device: str = "Dev1",
- ni_min_v: float = -10.0,
- ni_max_v: float = 10.0,
- # Arduino-specific
- ard_port: str = "COM3",
- ard_baud: int = 115200,
+ device_id: str = "ai_0",
+ num_channels: int = 4,
+ simulate: bool = True,
+ backend: str = "nidaqmx",
+ ni_device: str = "Dev1",
+ ni_min_v: float = -10.0,
+ ni_max_v: float = 10.0,
+ ard_port: str = "COM3",
+ ard_baud: int = 115200,
):
- self.backend = backend
- self.simulate = simulate
+ self.backend = backend
+ self.simulate = simulate
- # Build channel list
if backend == "arduino":
pins = ArduinoLayer.DEFAULT_ANALOG_PINS[:num_channels]
channels = [
- ChannelConfig(
- channel_id=p, name=p, unit="V",
- min_value=0.0, max_value=5.0,
- alarm_low=None, alarm_high=4.8,
- color=_COLORS[i % len(_COLORS)],
- )
+ ChannelConfig(channel_id=p, name=p, unit="V",
+ min_value=0.0, max_value=5.0,
+ color=_COLORS[i % len(_COLORS)])
for i, p in enumerate(pins)
]
else:
ni_pins = [f"ai{i}" for i in range(num_channels)]
channels = [
- ChannelConfig(
- channel_id=p, name=p.upper(), unit="V",
- min_value=ni_min_v, max_value=ni_max_v,
- alarm_low=None, alarm_high=ni_max_v * 0.9,
- color=_COLORS[i % len(_COLORS)],
- )
+ ChannelConfig(channel_id=p, name=p.upper(), unit="V",
+ min_value=ni_min_v, max_value=ni_max_v,
+ color=_COLORS[i % len(_COLORS)])
for i, p in enumerate(ni_pins)
]
@@ -91,155 +73,239 @@ class AnalogInputDevice(BaseDevice):
)
super().__init__(info)
- # Instantiate the backend layer
+ self._ni_device = ni_device
+ self._ni_min_v = ni_min_v
+ self._ni_max_v = ni_max_v
+ self._ard_port = ard_port
+ self._ard_baud = ard_baud
+ self._last_error = ""
+
+ self._layer = self._make_layer(
+ backend, simulate, ni_device, ni_min_v, ni_max_v,
+ ard_port, ard_baud,
+ [ch.channel_id for ch in channels],
+ )
+
+ def _make_layer(self, backend, simulate,
+ ni_device, ni_min_v, ni_max_v,
+ ard_port, ard_baud, channel_ids):
if backend == "arduino":
- self._layer = ArduinoLayer(
+ return ArduinoLayer(
port=ard_port,
baud=ard_baud,
- analog_pins=[ch.channel_id for ch in channels],
+ analog_pins=channel_ids,
simulate=simulate,
)
else:
- self._layer = NidaqmxLayer(
+ return NidaqmxLayer(
device_name=ni_device,
- channels=[ch.channel_id for ch in channels],
+ channels=channel_ids,
min_val=ni_min_v,
max_val=ni_max_v,
simulate=simulate,
)
- # Store config for the config widget
- self._ni_device = ni_device
- self._ni_min_v = ni_min_v
- self._ni_max_v = ni_max_v
- self._ard_port = ard_port
- self._ard_baud = ard_baud
-
- # ── BaseDevice interface ────────────────────────────────────────────
+ # ── BaseDevice ──────────────────────────────────────────────────────────
def connect(self) -> bool:
- ok = self._layer.start() if hasattr(self._layer, "start") else self._layer.connect()
- self.status = DeviceStatus.SIMULATED if self.simulate else (
- DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR
- )
+ self._last_error = ""
+
+ # ArduinoLayer uses connect(); NidaqmxLayer uses start()
+ if hasattr(self._layer, "connect"):
+ ok = self._layer.connect()
+ else:
+ ok = self._layer.start()
+
+ # Surface the underlying error message if available
+ if not ok and hasattr(self._layer, "last_error"):
+ self._last_error = self._layer.last_error
+
+ if self.simulate:
+ self.status = DeviceStatus.SIMULATED
+ else:
+ self.status = DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR
+
return ok
def disconnect(self) -> None:
- if hasattr(self._layer, "stop"):
- self._layer.stop()
- else:
- self._layer.disconnect()
+ try:
+ if hasattr(self._layer, "disconnect"):
+ self._layer.disconnect()
+ elif hasattr(self._layer, "stop"):
+ self._layer.stop()
+ except Exception:
+ pass
self.status = DeviceStatus.DISCONNECTED
def read_channels(self) -> Dict[str, float]:
return self._layer.read()
def write_channel(self, channel_id: str, value: Any) -> bool:
- return False # AI is read-only
+ return False
def get_config_widget(self) -> QWidget:
return AnalogInputConfigWidget(self)
- def switch_backend(self, backend: str, **kwargs) -> None:
- """Hot-swap the API layer without re-creating the device object."""
+ def switch_backend(self, backend: str, simulate: bool,
+ ni_device: str, min_val: float, max_val: float,
+ port: str, baud: int) -> None:
was_running = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED)
if was_running:
self.disconnect()
- self.backend = backend
- self.simulate = kwargs.get("simulate", self.simulate)
- if backend == "arduino":
- self._layer = ArduinoLayer(
- port=kwargs.get("port", self._ard_port),
- baud=kwargs.get("baud", self._ard_baud),
- analog_pins=[ch.channel_id for ch in self.info.channels],
- simulate=self.simulate,
- )
- else:
- self._layer = NidaqmxLayer(
- device_name=kwargs.get("ni_device", self._ni_device),
- channels=[ch.channel_id for ch in self.info.channels],
- min_val=kwargs.get("min_val", self._ni_min_v),
- max_val=kwargs.get("max_val", self._ni_max_v),
- simulate=self.simulate,
- )
+
+ self.backend = backend
+ self.simulate = simulate
+ self._ni_device = ni_device
+ self._ni_min_v = min_val
+ self._ni_max_v = max_val
+ self._ard_port = port
+ self._ard_baud = baud
+
+ self._layer = self._make_layer(
+ backend, simulate, ni_device, min_val, max_val,
+ port, baud,
+ [ch.channel_id for ch in self.info.channels],
+ )
+
if was_running:
self.connect()
-# ── Config Widget ────────────────────────────────────────────────────────────
+# ── Port scanner thread ───────────────────────────────────────────────────────
+
+class _PortScanThread(QThread):
+ done = pyqtSignal(list)
+
+ def run(self):
+ ports = ArduinoLayer.list_ports() # returns [(device, desc), ...]
+ self.done.emit(ports)
+
+
+# ── Config widget ─────────────────────────────────────────────────────────────
class AnalogInputConfigWidget(QWidget):
def __init__(self, device: AnalogInputDevice):
super().__init__()
- self.device = device
+ self.device = device
+ self._scanner = None
self._build()
def _build(self):
root = QVBoxLayout(self)
- root.setContentsMargins(0, 0, 0, 0)
+ root.setContentsMargins(10, 14, 10, 10)
+ root.setSpacing(12)
+ self.setMinimumWidth(460)
- # ── Backend selector ──────────────────────────────────────────
- be_grp = QGroupBox("API Backend")
+ # ── Backend / Simulate ───────────────────────────────────────────
+ be_grp = QGroupBox("Backend Selection")
be_form = QFormLayout(be_grp)
+ be_form.setContentsMargins(10, 16, 10, 10)
self.backend_cb = QComboBox()
self.backend_cb.addItems(["nidaqmx", "arduino"])
self.backend_cb.setCurrentText(self.device.backend)
be_form.addRow("Backend:", self.backend_cb)
- self.sim_check = QCheckBox("Simulation Mode")
+ self.sim_check = QCheckBox("Simulation Mode (no hardware)")
self.sim_check.setChecked(self.device.simulate)
be_form.addRow(self.sim_check)
-
root.addWidget(be_grp)
- # ── NI settings ───────────────────────────────────────────────
+ # ── NI-DAQmx ────────────────────────────────────────────────────
self.ni_grp = QGroupBox("NI-DAQmx Settings")
ni_form = QFormLayout(self.ni_grp)
+ ni_form.setContentsMargins(10, 16, 10, 10)
self.ni_dev_edit = QLineEdit(self.device._ni_device)
- ni_form.addRow("Device:", self.ni_dev_edit)
+ ni_form.addRow("NI Device:", self.ni_dev_edit)
self.ni_min_spin = QDoubleSpinBox()
- self.ni_min_spin.setRange(-100, 0); self.ni_min_spin.setValue(self.device._ni_min_v)
+ self.ni_min_spin.setRange(-100, 0)
+ self.ni_min_spin.setValue(self.device._ni_min_v)
self.ni_min_spin.setSuffix(" V")
- ni_form.addRow("Min V:", self.ni_min_spin)
+ ni_form.addRow("Min Voltage:", self.ni_min_spin)
self.ni_max_spin = QDoubleSpinBox()
- self.ni_max_spin.setRange(0, 100); self.ni_max_spin.setValue(self.device._ni_max_v)
+ self.ni_max_spin.setRange(0, 100)
+ self.ni_max_spin.setValue(self.device._ni_max_v)
self.ni_max_spin.setSuffix(" V")
- ni_form.addRow("Max V:", self.ni_max_spin)
+ ni_form.addRow("Max Voltage:", self.ni_max_spin)
- # Detect button
detect_btn = QPushButton("Detect NI Devices")
+ detect_btn.setObjectName("configButton")
detect_btn.clicked.connect(self._detect_ni)
ni_form.addRow(detect_btn)
self.ni_detect_lbl = QLabel("")
+ self.ni_detect_lbl.setObjectName("traceSource")
ni_form.addRow(self.ni_detect_lbl)
-
root.addWidget(self.ni_grp)
- # ── Arduino settings ──────────────────────────────────────────
- self.ard_grp = QGroupBox("Arduino Settings")
- ard_form = QFormLayout(self.ard_grp)
+ # ── Arduino ──────────────────────────────────────────────────────
+ self.ard_grp = QGroupBox("Arduino Serial Settings")
+ ard_lay = QVBoxLayout(self.ard_grp)
+ ard_lay.setContentsMargins(10, 16, 10, 10)
+ ard_lay.setSpacing(8)
+ ard_form = QFormLayout()
self.ard_port_edit = QLineEdit(self.device._ard_port)
+ self.ard_port_edit.setPlaceholderText("e.g. /dev/ttyUSB0 or COM3")
ard_form.addRow("Port:", self.ard_port_edit)
self.ard_baud_cb = QComboBox()
- self.ard_baud_cb.addItems(["9600", "57600", "115200", "230400"])
+ self.ard_baud_cb.addItems(["9600", "19200", "57600", "115200", "230400"])
self.ard_baud_cb.setCurrentText(str(self.device._ard_baud))
ard_form.addRow("Baud Rate:", self.ard_baud_cb)
-
- scan_btn = QPushButton("Scan Serial Ports")
- scan_btn.clicked.connect(self._scan_ports)
- ard_form.addRow(scan_btn)
- self.ard_port_lbl = QLabel("")
- ard_form.addRow(self.ard_port_lbl)
+ ard_lay.addLayout(ard_form)
+
+ # Scan row
+ scan_row = QHBoxLayout()
+ self._scan_btn = QPushButton("🔍 Scan Ports")
+ self._scan_btn.setObjectName("addTraceBtn")
+ self._scan_btn.clicked.connect(self._scan_ports)
+ self._scan_lbl = QLabel("")
+ self._scan_lbl.setObjectName("traceSource")
+ scan_row.addWidget(self._scan_btn)
+ scan_row.addWidget(self._scan_lbl, 1)
+ ard_lay.addLayout(scan_row)
+
+ # Port list
+ self._port_list = QListWidget()
+ self._port_list.setObjectName("portList")
+ self._port_list.setMaximumHeight(90)
+ self._port_list.itemClicked.connect(self._on_port_selected)
+ ard_lay.addWidget(self._port_list)
+
+ hint = QLabel("↑ Click a port to fill the field above")
+ hint.setObjectName("traceSource")
+ ard_lay.addWidget(hint)
+
+ # pyserial availability notice
+ if not ArduinoLayer.is_pyserial_available():
+ warn = QLabel("⚠ pyserial not installed — run: pip install pyserial")
+ warn.setStyleSheet("color:#f59e0b; font-weight:600;")
+ ard_lay.addWidget(warn)
root.addWidget(self.ard_grp)
- # ── Apply button ──────────────────────────────────────────────
+ # ── Diagnostics ───────────────────────────────────────────────────
+ self.diag_grp = QGroupBox("Connection Diagnostics")
+ diag_lay = QVBoxLayout(self.diag_grp)
+ diag_lay.setContentsMargins(10, 16, 10, 10)
+
+ self._diag_box = QTextEdit()
+ self._diag_box.setObjectName("codeEditor")
+ self._diag_box.setReadOnly(True)
+ self._diag_box.setMaximumHeight(90)
+ self._diag_box.setPlaceholderText("Connection log will appear here…")
+ diag_lay.addWidget(self._diag_box)
+
+ # Show any existing error
+ if self.device._last_error:
+ self._diag_box.setPlainText(f"Last error:\n{self.device._last_error}")
+
+ root.addWidget(self.diag_grp)
+
+ # ── Apply ─────────────────────────────────────────────────────────
apply_btn = QPushButton("Apply & Reconnect")
apply_btn.setObjectName("applyButton")
apply_btn.clicked.connect(self._apply)
@@ -248,29 +314,96 @@ class AnalogInputConfigWidget(QWidget):
self._update_visibility()
self.backend_cb.currentTextChanged.connect(self._update_visibility)
+ self._refresh_diag()
+
+ # ── Helpers ──────────────────────────────────────────────────────────────
def _update_visibility(self):
be = self.backend_cb.currentText()
self.ni_grp.setVisible(be == "nidaqmx")
self.ard_grp.setVisible(be == "arduino")
+ def _refresh_diag(self):
+ lines = []
+ lines.append(f"Backend: {self.device.backend}")
+ lines.append(f"Simulate: {self.device.simulate}")
+ lines.append(f"Status: {self.device.status.value}")
+ if self.device.backend == "arduino":
+ lines.append(f"Port: {self.device._ard_port}")
+ lines.append(f"Baud: {self.device._ard_baud}")
+ lines.append(f"pyserial: {'available' if ArduinoLayer.is_pyserial_available() else 'NOT INSTALLED'}")
+ if self.device._last_error:
+ lines.append(f"\nError:\n{self.device._last_error}")
+ self._diag_box.setPlainText("\n".join(lines))
+
def _detect_ni(self):
- from api_layers.nidaqmx_layer import NidaqmxLayer
devs = NidaqmxLayer.list_devices()
- self.ni_detect_lbl.setText(", ".join(devs) if devs else "None detected")
+ self.ni_detect_lbl.setText(
+ ", ".join(devs) if devs else "No NI devices found (nidaqmx package required)"
+ )
def _scan_ports(self):
- from api_layers.arduino_layer import ArduinoLayer
- ports = ArduinoLayer.list_ports()
- self.ard_port_lbl.setText(", ".join(ports) if ports else "None found")
+ self._scan_btn.setEnabled(False)
+ self._scan_lbl.setText("Scanning…")
+ self._port_list.clear()
+ self._scanner = _PortScanThread()
+ self._scanner.done.connect(self._on_scan_done)
+ self._scanner.start()
+
+ def _on_scan_done(self, ports):
+ self._scan_btn.setEnabled(True)
+ self._port_list.clear()
+
+ if not ArduinoLayer.is_pyserial_available():
+ self._scan_lbl.setText("pyserial not installed")
+ item = QListWidgetItem(" Install pyserial: pip install pyserial")
+ item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
+ self._port_list.addItem(item)
+ return
+
+ if not ports:
+ self._scan_lbl.setText("No ports detected")
+ item = QListWidgetItem(
+ " No serial ports found.\n"
+ " • Check USB cable\n"
+ " • On Linux: sudo usermod -aG dialout $USER then re-login\n"
+ " • On Arch: sudo usermod -aG uucp $USER then re-login"
+ )
+ item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
+ self._port_list.addItem(item)
+ else:
+ self._scan_lbl.setText(f"{len(ports)} port(s) found — click to select")
+ for device, desc in ports:
+ label = f" {device}"
+ if desc and desc.strip() and desc.strip() != 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.ard_port_edit.setText(port)
def _apply(self):
+ backend = self.backend_cb.currentText()
+ simulate = self.sim_check.isChecked()
+ port = self.ard_port_edit.text().strip()
+ baud = int(self.ard_baud_cb.currentText())
+
+ self._diag_box.setPlainText(
+ f"Connecting...\nBackend: {backend}\nSimulate: {simulate}\n"
+ + (f"Port: {port}\nBaud: {baud}" if backend == "arduino" else "")
+ )
+
self.device.switch_backend(
- backend=self.backend_cb.currentText(),
- simulate=self.sim_check.isChecked(),
- ni_device=self.ni_dev_edit.text(),
+ backend=backend,
+ simulate=simulate,
+ ni_device=self.ni_dev_edit.text().strip() or "Dev1",
min_val=self.ni_min_spin.value(),
max_val=self.ni_max_spin.value(),
- port=self.ard_port_edit.text(),
- baud=int(self.ard_baud_cb.currentText()),
+ port=port,
+ baud=baud,
)
+ self._refresh_diag()
diff --git a/devices/base_device.py b/devices/base_device.py
index 922a743..32fe787 100644
--- a/devices/base_device.py
+++ b/devices/base_device.py
@@ -27,8 +27,6 @@ class ChannelConfig:
unit: str = ""
min_value: float = 0.0
max_value: float = 100.0
- alarm_low: Optional[float] = None
- alarm_high: Optional[float] = None
enabled: bool = True
color: str = "#00d4ff"
extra: Dict[str, Any] = field(default_factory=dict)
diff --git a/devices/digital_io.py b/devices/digital_io.py
index 23a345f..291baea 100644
--- a/devices/digital_io.py
+++ b/devices/digital_io.py
@@ -222,7 +222,8 @@ class DigitalIOConfigWidget(QWidget):
def _build(self):
root = QVBoxLayout(self)
- root.setContentsMargins(0, 0, 0, 0)
+ root.setContentsMargins(8, 8, 8, 8)
+ self.setMinimumWidth(380)
be_grp = QGroupBox("Backend")
be_form = QFormLayout(be_grp)
diff --git a/devices/serial_device.py b/devices/serial_device.py
index 6bb7bf1..a5bf08c 100644
--- a/devices/serial_device.py
+++ b/devices/serial_device.py
@@ -132,17 +132,25 @@ class SerialDevice(BaseDevice):
class SerialConfigWidget(QWidget):
def __init__(self, device: SerialDevice):
super().__init__()
- self.device = device
+ self.device = device
+ self._scanner = None
self._build()
def _build(self):
+ from PyQt6.QtWidgets import QListWidget, QListWidgetItem
+ from PyQt6.QtCore import Qt as _Qt
+
root = QVBoxLayout(self)
- root.setContentsMargins(0, 0, 0, 0)
+ root.setContentsMargins(8, 8, 8, 8)
+ root.setSpacing(8)
+ self.setMinimumWidth(420)
+ # ── Port settings ─────────────────────────────────────────────
grp = QGroupBox("Port Settings")
form = QFormLayout(grp)
self.port_edit = QLineEdit(self.device._port)
+ self.port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0")
form.addRow("Port:", self.port_edit)
self.baud_cb = QComboBox()
@@ -155,19 +163,43 @@ class SerialConfigWidget(QWidget):
self.fmt_cb.setCurrentText(self.device._parse_format)
form.addRow("Parse Format:", self.fmt_cb)
- self.sim_chk = QComboBox()
- self.sim_chk.addItems(["Simulate", "Real Hardware"])
- self.sim_chk.setCurrentIndex(0 if self.device.simulate else 1)
- form.addRow("Mode:", self.sim_chk)
-
- scan_btn = QPushButton("Scan Ports")
- scan_btn.clicked.connect(self._scan)
- form.addRow(scan_btn)
- self.port_lbl = QLabel("")
- form.addRow(self.port_lbl)
+ self.sim_cb = QComboBox()
+ self.sim_cb.addItems(["Simulate", "Real Hardware"])
+ self.sim_cb.setCurrentIndex(0 if self.device.simulate else 1)
+ form.addRow("Mode:", self.sim_cb)
root.addWidget(grp)
+ # ── 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)
+ self._scan_lbl = QLabel("Click Scan to detect ports")
+ self._scan_lbl.setObjectName("traceSource")
+ scan_top.addWidget(self._scan_btn)
+ scan_top.addWidget(self._scan_lbl, 1)
+ scan_lay.addLayout(scan_top)
+
+ self._port_list = QListWidget()
+ self._port_list.setObjectName("portList")
+ self._port_list.setMaximumHeight(110)
+ self._port_list.itemClicked.connect(
+ lambda item: self.port_edit.setText(
+ item.data(_Qt.ItemDataRole.UserRole) or ""
+ ) if item.data(_Qt.ItemDataRole.UserRole) else None
+ )
+ hint = QLabel("↑ Click a port above to select it")
+ hint.setObjectName("traceSource")
+ scan_lay.addWidget(self._port_list)
+ scan_lay.addWidget(hint)
+ root.addWidget(scan_grp)
+
+ # ── Apply ─────────────────────────────────────────────────────
apply_btn = QPushButton("Apply & Reconnect")
apply_btn.setObjectName("applyButton")
apply_btn.clicked.connect(self._apply)
@@ -175,13 +207,41 @@ class SerialConfigWidget(QWidget):
root.addStretch()
def _scan(self):
- ports = ArduinoLayer.list_ports()
- self.port_lbl.setText(", ".join(ports) if ports else "None found")
+ self._scan_btn.setEnabled(False)
+ self._scan_lbl.setText("Scanning…")
+ self._port_list.clear()
+ from PyQt6.QtCore import QThread, pyqtSignal as _sig
+ class _Scanner(QThread):
+ done = _sig(list)
+ def run(self):
+ self.done.emit(ArduinoLayer.list_ports())
+ self._scanner = _Scanner()
+ self._scanner.done.connect(self._on_scan_done)
+ self._scanner.start()
+
+ def _on_scan_done(self, ports):
+ from PyQt6.QtWidgets import QListWidgetItem
+ self._scan_btn.setEnabled(True)
+ self._port_list.clear()
+ if not ports:
+ self._scan_lbl.setText("No ports found")
+ item = QListWidgetItem(" No serial ports detected — check USB / permissions")
+ item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
+ self._port_list.addItem(item)
+ else:
+ self._scan_lbl.setText(f"{len(ports)} port(s) — click to select")
+ for device, desc in ports:
+ label = f" {device}"
+ if desc and desc.strip() and desc.strip() != device:
+ label += f" — {desc}"
+ item = QListWidgetItem(label)
+ item.setData(Qt.ItemDataRole.UserRole, device)
+ self._port_list.addItem(item)
def _apply(self):
self.device.reconfigure(
- port=self.port_edit.text(),
+ port=self.port_edit.text().strip() or "COM3",
baud=int(self.baud_cb.currentText()),
fmt=self.fmt_cb.currentText(),
- simulate=(self.sim_chk.currentIndex() == 0),
+ simulate=(self.sim_cb.currentIndex() == 0),
)