diff options
Diffstat (limited to 'devices')
| -rw-r--r-- | devices/__pycache__/__init__.cpython-314.pyc | bin | 398 -> 401 bytes | |||
| -rw-r--r-- | devices/__pycache__/analog_input.cpython-314.pyc | bin | 16238 -> 24525 bytes | |||
| -rw-r--r-- | devices/__pycache__/base_device.cpython-314.pyc | bin | 7731 -> 7588 bytes | |||
| -rw-r--r-- | devices/__pycache__/device_registry.cpython-314.pyc | bin | 5927 -> 5930 bytes | |||
| -rw-r--r-- | devices/__pycache__/digital_io.cpython-314.pyc | bin | 19275 -> 19331 bytes | |||
| -rw-r--r-- | devices/__pycache__/serial_device.cpython-314.pyc | bin | 11824 -> 16431 bytes | |||
| -rw-r--r-- | devices/analog_input.py | 369 | ||||
| -rw-r--r-- | devices/base_device.py | 2 | ||||
| -rw-r--r-- | devices/digital_io.py | 3 | ||||
| -rw-r--r-- | devices/serial_device.py | 92 |
10 files changed, 329 insertions, 137 deletions
diff --git a/devices/__pycache__/__init__.cpython-314.pyc b/devices/__pycache__/__init__.cpython-314.pyc Binary files differindex 9ab29a6..bbe1fd2 100644 --- a/devices/__pycache__/__init__.cpython-314.pyc +++ b/devices/__pycache__/__init__.cpython-314.pyc diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc Binary files differindex 4c08631..27ce47c 100644 --- a/devices/__pycache__/analog_input.cpython-314.pyc +++ b/devices/__pycache__/analog_input.cpython-314.pyc diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc Binary files differindex ade2b20..358f1ed 100644 --- a/devices/__pycache__/base_device.cpython-314.pyc +++ b/devices/__pycache__/base_device.cpython-314.pyc diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc Binary files differindex 608c8ee..d4adce0 100644 --- a/devices/__pycache__/device_registry.cpython-314.pyc +++ b/devices/__pycache__/device_registry.cpython-314.pyc diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc Binary files differindex b2bb27f..2fd4200 100644 --- a/devices/__pycache__/digital_io.cpython-314.pyc +++ b/devices/__pycache__/digital_io.cpython-314.pyc diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc Binary files differindex 0674606..9ef9fca 100644 --- a/devices/__pycache__/serial_device.cpython-314.pyc +++ b/devices/__pycache__/serial_device.cpython-314.pyc 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), ) |
