summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md47
-rw-r--r--plugins/motion_capture/camera_panel.py78
-rw-r--r--plugins/motion_capture/device.py124
-rw-r--r--plugins/motion_capture/plugin.py249
-rw-r--r--ui/add_device_dialog.py485
5 files changed, 560 insertions, 423 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 0a10c22..0fa549d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,6 @@
# CLAUDE.md
-Guidance for Claude Code (claude.ai/code) working in this repo.
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Running the app
@@ -14,7 +14,8 @@ No build step. Simulation mode default (no hardware needed).
```bash
pip install PyQt6 pyqtgraph numpy pyserial
-pip install nidaqmx # optional — only for real NI hardware
+pip install nidaqmx # optional — only for real NI hardware
+pip install opencv-python # optional — only for motion capture plugin
```
## Architecture
@@ -61,11 +62,16 @@ Control widgets (left panel) go other direction: UI → `ControlWidget._write()`
| `core/acquisition.py` | `AcquisitionEngine` + `ChannelBuffer` |
| `core/signal_processor.py` | Filter chain + derived/virtual channels |
| `core/profile.py` | `.labdaq` profile save/load |
-| `ui/main_window.py` | Top-level window, toolbar, demo device init |
+| `ui/main_window.py` | Top-level window, toolbar, demo device init, plugin lifecycle |
| `ui/control_panel.py` | Left panel output widgets (`OnOffSwitch`, `MotorControl`, etc.) |
| `ui/strip_chart.py` | Live pyqtgraph chart, config-driven by `LayoutConfig` |
+| `ui/add_device_dialog.py` | Add Device dialog; `_PANELS` dict extended by plugins |
+| `ui/windows/plot_window.py` | Plot Builder: BSP tree, `LayoutCanvas` drag-drop, `LayoutConfig` |
| `ui/windows/` | Floating tool windows (Devices, Signals, Plot, Settings) |
| `ui/style_dark.qss` / `style_light.qss` | Full app theme |
+| `plugins/base_plugin.py` | `LabPlugin` ABC, `PluginAction`, `PluginContext` |
+| `plugins/plugin_manager.py` | Discovery, load/unload, `enabled.json` persistence |
+| `plugins/motion_capture/` | Camera device plugin — adds Camera type to Add Device dialog |
### Arduino firmware
@@ -73,6 +79,41 @@ Firmware embedded as `ARDUINO_FIRMWARE` in `api_layers/arduino_layer.py`. When e
Serial protocol: `A0:3.14,D2:1\n` stream from Arduino; `W:D7:1\n` / `P:D9:128\n` commands from PC.
+### Plot layout — BSP tree
+
+`ui/windows/plot_window.py` stores the subplot arrangement as a binary space-partition tree of plain dicts:
+
+```
+{"kind": "leaf", "pane": <int>}
+{"kind": "hsplit", "ratio": <float>, "first": <node>, "second": <node>} # left/right
+{"kind": "vsplit", "ratio": <float>, "first": <node>, "second": <node>} # top/bottom
+```
+
+Key tree functions (all in `plot_window.py`): `_tree_insert`, `_tree_remove`, `_tree_swap`, `_tree_reindex`, `_tree_equalize_ratios`. `tree_to_grid()` converts the tree to pyqtgraph `addItem(row, col, rowspan, colspan)` coordinates. `_tree_grid_size()` returns the LCM of all split denominators — the minimum grid size that expresses all ratios as integers.
+
+`LayoutCanvas` (also in `plot_window.py`) is the drag-and-drop tile editor. Drop zones detected in `_zone_at()`: outer 1/3 of tile = split (bisect), gap between tiles = squeeze (insert-between), center = swap. `_do_drop()` executes the tree mutation.
+
+### Plugin system
+
+Plugins live in `plugins/<id>/` with a `manifest.json` and entry point module. The plugin directory is added to `sys.path` on load, so intra-plugin imports use bare names (`from tracker import CameraTracker`). Cross-plugin imports use the full path (`from ui.add_device_dialog import _PANELS`).
+
+`PluginContext` passed to `on_load(context)`:
+```python
+context.registry # DeviceRegistry
+context.engine # AcquisitionEngine
+context.processor # SignalProcessor
+context.main_window # MainWindow
+```
+
+Optional hooks a plugin can implement:
+- `get_devices() → list[BaseDevice]` — auto-registered into acquisition engine
+- `get_filter_classes() → dict` — added to `SignalProcessor` filter registry
+- `get_toolbar_actions() → list[PluginAction]` — buttons inserted in main toolbar
+- `get_settings_widget() → QWidget` — shown in Settings → Plugins panel
+- `get_save_state() / apply_save_state(dict)` — persisted in `.labdaq` profiles
+
+**Extending Add Device dialog from a plugin**: `ui/add_device_dialog._PANELS` is a module-level dict `{type_name: (PanelClass, id_prefix)}`. Plugins add/remove entries in `on_load`/`on_unload`. Each panel class needs a `build_device(device_id) → BaseDevice` method.
+
### Adding a new device type
1. Subclass `BaseDevice` in new file under `devices/`
diff --git a/plugins/motion_capture/camera_panel.py b/plugins/motion_capture/camera_panel.py
new file mode 100644
index 0000000..3fdd286
--- /dev/null
+++ b/plugins/motion_capture/camera_panel.py
@@ -0,0 +1,78 @@
+"""
+motion_capture/camera_panel.py
+
+CameraPanel for AddDeviceDialog — shows selected camera info and builds
+a CameraDevice when the user clicks "Add Device".
+Camera discovery and simulation toggle are handled by AddDeviceDialog.
+"""
+
+from __future__ import annotations
+
+from PyQt6.QtCore import QThread, pyqtSignal
+from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget
+
+
+class CameraScanThread(QThread):
+ cameras_found = pyqtSignal(list) # list of (index: int, label: str)
+
+ def run(self):
+ results = []
+ try:
+ import cv2
+ for i in range(6):
+ cap = cv2.VideoCapture(i)
+ if cap.isOpened():
+ ret, _ = cap.read()
+ if ret:
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
+ results.append((i, f"Camera {i} ({w}×{h})"))
+ cap.release()
+ except ImportError:
+ pass
+ self.cameras_found.emit(results)
+
+
+class CameraPanel(QWidget):
+ """Config panel for Camera device in AddDeviceDialog."""
+
+ def __init__(self):
+ super().__init__()
+ self._selected_index: int = 0
+ self._simulate: bool = False
+
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(0, 4, 0, 4)
+ lay.setSpacing(8)
+
+ self._selected_lbl = QLabel("No camera selected — scan above and click a camera")
+ self._selected_lbl.setObjectName("traceSource")
+ self._selected_lbl.setWordWrap(True)
+ lay.addWidget(self._selected_lbl)
+
+ note = QLabel(
+ "Requires OpenCV: pip install opencv-python\n"
+ "Use Simulation mode if no camera is available."
+ )
+ note.setObjectName("traceSource")
+ note.setWordWrap(True)
+ lay.addWidget(note)
+ lay.addStretch()
+
+ def is_valid(self) -> bool:
+ return True
+
+ def set_simulate(self, simulate: bool):
+ self._simulate = simulate
+
+ def set_camera_index(self, index: int):
+ self._selected_index = index
+ self._selected_lbl.setText(f"Selected: Camera {index}")
+
+ def build_device(self, device_id: str):
+ from device import CameraDevice
+ return CameraDevice(
+ device_id=device_id,
+ camera_index=self._selected_index,
+ simulate=self._simulate,
+ )
diff --git a/plugins/motion_capture/device.py b/plugins/motion_capture/device.py
index f0d082c..9806030 100644
--- a/plugins/motion_capture/device.py
+++ b/plugins/motion_capture/device.py
@@ -1,34 +1,39 @@
"""
motion_capture/device.py
-BaseDevice that reads x/y position from CameraTracker.
+BaseDevice implementation for a camera-based motion tracker.
+
+Each CameraDevice owns a CameraTracker that runs its own background
+capture thread. connect() opens the camera; disconnect() releases it.
Channels:
x_pos — horizontal position [px] (left=0, right=frame_width)
y_pos — vertical position [px] (top=0, bottom=frame_height)
-Apply the pixel_to_mm filter in Signals → Channels to convert to mm.
+Apply the pixel_to_mm filter in Signals → Channels to convert to mm.
"""
from __future__ import annotations
-import threading
-from typing import Any, Dict
+from typing import Any, Dict, Optional
from devices.base_device import (
BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus,
)
-class MotionCaptureDevice(BaseDevice):
+class CameraDevice(BaseDevice):
- def __init__(self, tracker, device_id: str = "motion_capture"):
+ def __init__(self, device_id: str = "cam_0",
+ camera_index: int = 0,
+ simulate: bool = False):
+ name = "Camera (Sim)" if simulate else f"Camera {camera_index}"
super().__init__(DeviceInfo(
device_id = device_id,
- name = "Motion Capture",
- device_type = "virtual",
+ name = name,
+ device_type = "camera",
description = "Camera point-tracking — x/y position in pixels.",
- icon = "🎥",
+ icon = "📷",
channels = [
ChannelConfig(
channel_id = "x_pos",
@@ -48,38 +53,101 @@ class MotionCaptureDevice(BaseDevice):
),
],
))
- self._tracker = tracker
- self._lock = threading.Lock()
- self._last = (0.0, 0.0)
+ self._camera_index = camera_index
+ self._simulate = simulate
+ self._tracker: Optional[Any] = None
+ self._tracking_win = None
- # ── BaseDevice interface ──────────────────────────────────────────────
+ # ── BaseDevice ────────────────────────────────────────────────────────
def connect(self) -> bool:
- self.status = DeviceStatus.SIMULATED
+ from tracker import CameraTracker
+ self._tracker = CameraTracker()
+ self._tracker.start(-1 if self._simulate else self._camera_index)
+ if self._tracker.simulated:
+ self.status = DeviceStatus.SIMULATED
+ else:
+ self.status = DeviceStatus.CONNECTED
return True
def disconnect(self) -> None:
+ if self._tracking_win is not None:
+ self._tracking_win.close()
+ self._tracking_win = None
+ if self._tracker is not None:
+ self._tracker.stop()
+ self._tracker = None
self.status = DeviceStatus.DISCONNECTED
def read_channels(self) -> Dict[str, float]:
+ if self._tracker is None:
+ return {"x_pos": 0.0, "y_pos": 0.0}
pos = self._tracker.get_position()
- if pos is not None:
- with self._lock:
- self._last = pos
- with self._lock:
- x, y = self._last
- return {"x_pos": x, "y_pos": y}
+ if pos is None:
+ return {"x_pos": 0.0, "y_pos": 0.0}
+ x, y = pos
+ return {"x_pos": float(x), "y_pos": float(y)}
def write_channel(self, channel_id: str, value: Any) -> bool:
return False
def get_config_widget(self):
- from PyQt6.QtWidgets import QLabel
- lbl = QLabel(
- "Configure via the Motion Capture toolbar window.\n\n"
- "Apply 'pixel_to_mm' filter in Signals → Channels\n"
- "to convert pixel coordinates to millimetres."
+ from PyQt6.QtWidgets import (
+ QFormLayout, QLabel, QPushButton, QVBoxLayout, QWidget,
+ )
+
+ w = QWidget()
+ lay = QVBoxLayout(w)
+ lay.setContentsMargins(8, 8, 8, 8)
+ lay.setSpacing(10)
+
+ form = QFormLayout()
+ form.setContentsMargins(0, 0, 0, 0)
+
+ src_lbl = QLabel(
+ "Simulation" if self._simulate else f"Camera {self._camera_index}"
+ )
+ src_lbl.setObjectName("traceSource")
+ form.addRow("Source:", src_lbl)
+
+ self._status_lbl = QLabel(self.status.name.title())
+ self._status_lbl.setObjectName("traceSource")
+ form.addRow("Status:", self._status_lbl)
+
+ lay.addLayout(form)
+
+ open_btn = QPushButton("🎥 Open Camera View")
+ open_btn.setObjectName("addTraceBtn")
+ open_btn.clicked.connect(self._open_tracking_window)
+ lay.addWidget(open_btn)
+
+ hint = QLabel(
+ "In the Camera View: click the frame to set the tracking\n"
+ "point. Apply the 'pixel_to_mm' filter in Signals → Channels\n"
+ "to convert pixel coordinates to real-world units."
)
- lbl.setObjectName("traceSource")
- lbl.setWordWrap(True)
- return lbl
+ hint.setObjectName("traceSource")
+ hint.setWordWrap(True)
+ lay.addWidget(hint)
+ lay.addStretch()
+
+ return w
+
+ # ── Internal ──────────────────────────────────────────────────────────
+
+ def _open_tracking_window(self):
+ if self._tracker is None:
+ return
+ from window import MotionCaptureWindow
+ if self._tracking_win is None:
+ self._tracking_win = MotionCaptureWindow(self._tracker)
+ self._tracking_win.closed.connect(self._on_tracking_win_closed)
+ self._tracking_win.show()
+ self._tracking_win.raise_()
+
+ def _on_tracking_win_closed(self):
+ self._tracking_win = None
+
+
+# Backward-compat alias for any profiles that reference the old class name
+MotionCaptureDevice = CameraDevice
diff --git a/plugins/motion_capture/plugin.py b/plugins/motion_capture/plugin.py
index 1c655c5..0b44cb4 100644
--- a/plugins/motion_capture/plugin.py
+++ b/plugins/motion_capture/plugin.py
@@ -3,28 +3,22 @@ motion_capture/plugin.py
LabDAQ Motion Capture plugin.
-Tracks a user-selected point via webcam (OpenCV CSRT tracker) and
-streams x/y pixel coordinates as live channels into the acquisition
-pipeline. Falls back to a Lissajous simulation when OpenCV is absent.
-
-Custom filter:
- pixel_to_mm — convert pixel coords to millimetres using a
- px_per_mm calibration value. Add it in Signals → Channels on
- the x_pos or y_pos channel.
-
-Install OpenCV to use a real camera:
- Arch Linux: sudo pacman -S python-opencv
- Other: pip install opencv-python
+When enabled, adds a "Camera" device type to the Add Device dialog.
+Each camera added by the user becomes a first-class device in the
+acquisition pipeline — data flows through the standard
+Device → Signal → Channel → Plot stream.
+
+Custom filter registered globally:
+ pixel_to_mm — convert pixel coords to mm using a px_per_mm value.
+ Add it in Signals → Channels on x_pos or y_pos.
+
+Install OpenCV for real camera support:
+ pip install opencv-python
"""
from __future__ import annotations
-from typing import Any, Dict, List, Optional
-
-from PyQt6.QtWidgets import (
- QComboBox, QDoubleSpinBox, QFormLayout, QHBoxLayout,
- QLabel, QPushButton, QWidget,
-)
+from typing import List, Optional
from plugins.base_plugin import LabPlugin, PluginAction, PluginContext
@@ -44,8 +38,10 @@ class MotionCapturePlugin(LabPlugin):
@property
def description(self) -> str:
- return ("Track a point via webcam and stream x/y position as live signals. "
- "Apply pixel_to_mm filter to convert to real-world units.")
+ return (
+ "Adds a Camera device type to the Add Device dialog. "
+ "Stream x/y tracking coordinates directly into the signal pipeline."
+ )
@property
def author(self) -> str: return "LabDAQ"
@@ -53,158 +49,95 @@ class MotionCapturePlugin(LabPlugin):
# ── Lifecycle ─────────────────────────────────────────────────────────
def on_load(self, context: PluginContext) -> None:
- self._ctx = context
- self._win: Optional[object] = None
- self._toolbar_btn = None
-
- from tracker import CameraTracker
- from device import MotionCaptureDevice
-
- self._tracker = CameraTracker()
- self._device = MotionCaptureDevice(self._tracker)
+ self._ctx = context
+ self._btn = None # toolbar button reference
+ self._windows: dict = {} # device_id → MotionCaptureWindow
+ from ui.add_device_dialog import _PANELS
+ from camera_panel import CameraPanel
+ _PANELS["Camera"] = (CameraPanel, "cam")
def on_unload(self) -> None:
- self._tracker.stop()
- if self._win is not None:
- self._win.close()
- self._win = None
+ for win in list(self._windows.values()):
+ win.close()
+ self._windows.clear()
+ from ui.add_device_dialog import _PANELS
+ _PANELS.pop("Camera", None)
# ── Integration hooks ─────────────────────────────────────────────────
- def get_devices(self) -> list:
- return [self._device]
+ def get_toolbar_actions(self) -> list:
+ return [PluginAction(
+ label="Motion Capture",
+ icon="🎥",
+ tooltip="Open camera tracking view",
+ checkable=True,
+ callback=self._on_btn_toggled,
+ button_ref_callback=lambda btn: setattr(self, "_btn", btn),
+ )]
def get_filter_classes(self) -> dict:
from filter import PixelToMMFilter
return {"pixel_to_mm": PixelToMMFilter}
- def get_toolbar_actions(self) -> List[PluginAction]:
- return [
- PluginAction(
- label = "Motion Capture",
- icon = "🎥",
- tooltip = "Open motion capture window",
- checkable = True,
- callback = self._toggle_window,
- button_ref_callback = self._store_btn,
- )
- ]
-
- def get_settings_widget(self) -> Optional[QWidget]:
- from tracker import list_cameras
- w = QWidget()
- lay = QFormLayout(w)
- lay.setContentsMargins(0, 4, 0, 4)
-
- cam_row = QHBoxLayout()
- self._s_cam = QComboBox()
- self._s_cam.setObjectName("channelPickerCb")
- self._s_cameras: list = []
- self._s_refresh_btn = QPushButton("⟳")
- self._s_refresh_btn.setObjectName("configButton")
- self._s_refresh_btn.setFixedWidth(28)
- self._s_refresh_btn.setToolTip("Rescan cameras")
- self._s_refresh_btn.clicked.connect(lambda: self._populate_settings_cam(list_cameras))
- cam_row.addWidget(self._s_cam, 1)
- cam_row.addWidget(self._s_refresh_btn)
- lay.addRow("Camera:", cam_row)
-
- self._populate_settings_cam(list_cameras)
- self._s_cam.currentIndexChanged.connect(self._sync_cam_to_window)
-
- self._s_pxmm = QDoubleSpinBox()
- self._s_pxmm.setRange(0.01, 100_000)
- self._s_pxmm.setDecimals(2)
- self._s_pxmm.setSuffix(" px/mm")
- self._s_pxmm.setValue(
- self._win.get_px_per_mm() if self._win else 10.0)
- self._s_pxmm.setObjectName("cfgGlobalSpin")
- self._s_pxmm.valueChanged.connect(self._sync_pxmm_to_window)
- lay.addRow("Calibration:", self._s_pxmm)
-
- hint = QLabel(
- "Calibration: count how many pixels span a known real distance\n"
- "in the camera frame, then divide pixels ÷ mm."
- )
- hint.setObjectName("traceSource")
- hint.setWordWrap(True)
- lay.addRow(hint)
-
- return w
+ # ── Internal ──────────────────────────────────────────────────────────
- # ── Profile state ─────────────────────────────────────────────────────
+ def _camera_devices(self) -> list:
+ from device import CameraDevice
+ return [d for d in self._ctx.registry.all_instances()
+ if isinstance(d, CameraDevice)]
- def get_save_state(self) -> Dict[str, Any]:
- return {
- "camera_index": self._win.get_camera_index() if self._win else 0,
- "px_per_mm": self._win.get_px_per_mm() if self._win else 10.0,
- }
+ def _on_btn_toggled(self, checked: bool) -> None:
+ from PyQt6.QtWidgets import QInputDialog, QMessageBox
+ if not checked:
+ return
- def apply_save_state(self, state: Dict[str, Any]) -> None:
- if self._win is not None:
- self._win.set_camera_index(state.get("camera_index", 0))
- self._win.set_px_per_mm(state.get("px_per_mm", 10.0))
- # Store for when window is opened later
- self._pending_state = state
+ cameras = self._camera_devices()
- # ── Internal ──────────────────────────────────────────────────────────
+ if not cameras:
+ QMessageBox.information(
+ None, "No Camera Devices",
+ "Add a Camera device first via Devices → Add Device.",
+ )
+ if self._btn is not None:
+ self._btn.setChecked(False)
+ return
- def _store_btn(self, btn) -> None:
- self._toolbar_btn = btn
-
- def _toggle_window(self, checked: bool) -> None:
- if self._win is None:
- from window import MotionCaptureWindow
- self._win = MotionCaptureWindow(
- self._tracker, self._ctx.main_window)
- self._win.closed.connect(self._on_win_closed)
-
- # Apply any state that arrived before window was created
- state = getattr(self, "_pending_state", None)
- if state:
- self._win.set_camera_index(state.get("camera_index", 0))
- self._win.set_px_per_mm(state.get("px_per_mm", 10.0))
-
- if checked:
- if not self._win.isVisible():
- from PyQt6.QtWidgets import QApplication
- mw = self._ctx.main_window
- screen = QApplication.screenAt(mw.geometry().center()) or QApplication.primaryScreen()
- avail = screen.availableGeometry()
- geo = mw.normalGeometry()
- self._win.adjustSize()
- x = max(avail.left(), min(geo.right() + 8, avail.right() - self._win.width()))
- y = max(avail.top(), min(geo.top() + 40, avail.bottom() - self._win.height()))
- self._win.move(x, y)
- self._win.show()
- self._win.raise_()
+ if len(cameras) == 1:
+ self._open_window(cameras[0])
else:
- self._win.hide()
-
- def _on_win_closed(self) -> None:
- if self._toolbar_btn is not None:
- self._toolbar_btn.setChecked(False)
-
- def _populate_settings_cam(self, list_cameras_fn) -> None:
- current = self._win.get_camera_index() if self._win else 0
- self._s_cam.blockSignals(True)
- self._s_cam.clear()
- self._s_cameras = list_cameras_fn()
- for _, label in self._s_cameras:
- self._s_cam.addItem(label)
- for i, (idx, _) in enumerate(self._s_cameras):
- if idx == current:
- self._s_cam.setCurrentIndex(i)
- break
- self._s_cam.blockSignals(False)
-
- def _sync_cam_to_window(self, combo_i: int) -> None:
- if not self._s_cameras:
+ names = [f"{d.info.name} ({d.info.device_id})" for d in cameras]
+ choice, ok = QInputDialog.getItem(
+ None, "Select Camera", "Open tracking view for:", names, 0, False,
+ )
+ if not ok:
+ if self._btn is not None:
+ self._btn.setChecked(False)
+ return
+ idx = names.index(choice)
+ self._open_window(cameras[idx])
+
+ def _open_window(self, device) -> None:
+ from window import MotionCaptureWindow
+ dev_id = device.info.device_id
+ if dev_id in self._windows:
+ win = self._windows[dev_id]
+ win.show(); win.raise_()
return
- idx = self._s_cameras[combo_i][0]
- if self._win:
- self._win.set_camera_index(idx)
-
- def _sync_pxmm_to_window(self, v: float) -> None:
- if self._win:
- self._win.set_px_per_mm(v)
+ if device._tracker is None:
+ from PyQt6.QtWidgets import QMessageBox
+ QMessageBox.warning(
+ None, "Camera Not Connected",
+ f"{device.info.name} is not connected. Connect it first.",
+ )
+ if self._btn is not None:
+ self._btn.setChecked(False)
+ return
+ win = MotionCaptureWindow(device._tracker)
+ win.closed.connect(lambda: self._close_window(dev_id))
+ self._windows[dev_id] = win
+ win.show()
+
+ def _close_window(self, dev_id: str) -> None:
+ self._windows.pop(dev_id, None)
+ if not self._windows and self._btn is not None:
+ self._btn.setChecked(False)
diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py
index 5258995..f0cbde1 100644
--- a/ui/add_device_dialog.py
+++ b/ui/add_device_dialog.py
@@ -1,12 +1,9 @@
"""
ui/add_device_dialog.py
-Add Device dialog — user selects physical device type (Arduino / NI-DAQ / Serial).
-Backend is determined by device type; Arduino and NI-DAQ combine analog input
-and digital I/O into one physical device entry.
+Add Device dialog — unified device scanner + type/config form.
"""
-import os
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox,
@@ -15,7 +12,6 @@ from PyQt6.QtWidgets import (
QListWidgetItem, QFrame, QSizePolicy,
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
-from PyQt6.QtGui import QFont
from devices.device_registry import DeviceRegistry
from devices.arduino_device import ArduinoDevice
@@ -43,154 +39,132 @@ class NIScanThread(QThread):
self.devices_found.emit(devs)
-# ── Reusable scanner widgets ──────────────────────────────────────────────────
+# ── Device list scanner (embedded in Available Devices section) ───────────────
-class PortScanGroup(QGroupBox):
- """Scan-and-click widget that fills a target QLineEdit with the chosen port."""
+class _DeviceListScanner(QWidget):
+ """Scan button + result list. Emits device_selected(kind, value) on click."""
- def __init__(self, target_edit: QLineEdit):
- super().__init__("Available Serial Ports")
- self._target = target_edit
- self._scanner = None
+ device_selected = pyqtSignal(str, object) # kind = "serial"|"ni"|"camera"
+
+ def __init__(self):
+ super().__init__()
+ self._port_scanner = None
+ self._ni_scanner = None
+ self._cam_scanner = None
+ self._pending = 0
lay = QVBoxLayout(self)
+ lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(4)
top = QHBoxLayout()
- self._scan_btn = QPushButton("🔍 Scan Ports")
+ self._scan_btn = QPushButton("🔍 Scan All")
self._scan_btn.setObjectName("addTraceBtn")
- self._scan_btn.clicked.connect(self._scan)
- self._status = QLabel("Click Scan to detect ports")
+ self._scan_btn.clicked.connect(self.scan_all)
+ self._status = QLabel("Click Scan to detect devices")
self._status.setObjectName("traceSource")
top.addWidget(self._scan_btn)
top.addWidget(self._status, 1)
lay.addLayout(top)
self._list = QListWidget()
- self._list.setMaximumHeight(100)
+ self._list.setFixedHeight(120)
self._list.setObjectName("portList")
self._list.itemClicked.connect(self._on_select)
- self._list.setToolTip("Click a port to select it")
+ self._list.setToolTip("Click a device to pre-fill configuration")
lay.addWidget(self._list)
- hint = QLabel("↑ Click a port above to fill the Port field")
+ hint = QLabel("↑ Click a device to pre-fill port and configuration")
hint.setObjectName("traceSource")
lay.addWidget(hint)
- def _scan(self):
+ def scan_all(self):
self._scan_btn.setEnabled(False)
self._status.setText("Scanning…")
self._list.clear()
- self._scanner = PortScanThread()
- self._scanner.ports_found.connect(self._on_found)
- self._scanner.start()
-
- def _on_found(self, ports):
- self._scan_btn.setEnabled(True)
- self._list.clear()
- if not ports:
- self._status.setText("No ports found")
- item = QListWidgetItem(" No serial ports detected")
- item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
- self._list.addItem(item)
- else:
- self._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._list.addItem(item)
-
- def _on_select(self, item: QListWidgetItem):
- port = item.data(Qt.ItemDataRole.UserRole)
- if port:
- self._target.setText(port)
-
-
-class NIScanGroup(QGroupBox):
- """Scan-and-click widget that fills a target QLineEdit with the chosen NI device."""
-
- def __init__(self, target_edit: QLineEdit):
- super().__init__("Available NI Devices")
- self._target = target_edit
- self._scanner = None
-
- lay = QVBoxLayout(self)
- lay.setSpacing(4)
+ self._pending = 0
- top = QHBoxLayout()
- self._scan_btn = QPushButton("🔍 Scan NI Devices")
- self._scan_btn.setObjectName("addTraceBtn")
- self._scan_btn.clicked.connect(self._scan)
- self._status = QLabel("Click Scan to detect NI devices")
- self._status.setObjectName("traceSource")
- top.addWidget(self._scan_btn)
- top.addWidget(self._status, 1)
- lay.addLayout(top)
+ self._pending += 1
+ self._port_scanner = PortScanThread()
+ self._port_scanner.ports_found.connect(self._on_ports)
+ self._port_scanner.start()
- self._list = QListWidget()
- self._list.setMaximumHeight(100)
- self._list.setObjectName("portList")
- self._list.itemClicked.connect(self._on_select)
- self._list.setToolTip("Click a device to select it")
- lay.addWidget(self._list)
+ self._pending += 1
+ self._ni_scanner = NIScanThread()
+ self._ni_scanner.devices_found.connect(self._on_ni)
+ self._ni_scanner.start()
- hint = QLabel("↑ Click a device above to fill the NI Device field")
- hint.setObjectName("traceSource")
- lay.addWidget(hint)
-
- def _scan(self):
- self._scan_btn.setEnabled(False)
- self._status.setText("Scanning…")
- self._list.clear()
- self._scanner = NIScanThread()
- self._scanner.devices_found.connect(self._on_found)
- self._scanner.start()
+ try:
+ from plugins.motion_capture.camera_panel import CameraScanThread
+ self._pending += 1
+ self._cam_scanner = CameraScanThread()
+ self._cam_scanner.cameras_found.connect(self._on_cameras)
+ self._cam_scanner.start()
+ except Exception:
+ pass
+
+ def _check_done(self):
+ self._pending -= 1
+ if self._pending <= 0:
+ self._scan_btn.setEnabled(True)
+ n = sum(
+ 1 for i in range(self._list.count())
+ if self._list.item(i).flags() & Qt.ItemFlag.ItemIsSelectable
+ )
+ self._status.setText(f"{n} device(s) found" if n else "No devices found")
+
+ def _on_ports(self, ports):
+ for device, desc in ports:
+ label = f"[Serial] {device}"
+ if desc and desc != device:
+ label += f" — {desc}"
+ item = QListWidgetItem(label)
+ item.setData(Qt.ItemDataRole.UserRole, ("serial", device))
+ self._list.addItem(item)
+ self._check_done()
+
+ def _on_ni(self, devices):
+ for name, product in devices:
+ label = f"[NI-DAQ] {name}"
+ if product:
+ label += f" — {product}"
+ item = QListWidgetItem(label)
+ item.setData(Qt.ItemDataRole.UserRole, ("ni", name))
+ self._list.addItem(item)
+ self._check_done()
- def _on_found(self, devices):
- self._scan_btn.setEnabled(True)
- self._list.clear()
- if not devices:
- self._status.setText("No NI devices found")
- item = QListWidgetItem(" No NI devices detected")
- item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
+ def _on_cameras(self, cameras):
+ for idx, label_str in cameras:
+ item = QListWidgetItem(f"[Camera] {label_str}")
+ item.setData(Qt.ItemDataRole.UserRole, ("camera", idx))
self._list.addItem(item)
- else:
- self._status.setText(f"{len(devices)} device(s) found")
- for name, product in devices:
- label = f" {name}"
- if product:
- label += f" — {product}"
- item = QListWidgetItem(label)
- item.setData(Qt.ItemDataRole.UserRole, name)
- self._list.addItem(item)
+ self._check_done()
def _on_select(self, item: QListWidgetItem):
- name = item.data(Qt.ItemDataRole.UserRole)
- if name:
- self._target.setText(name)
-
+ data = item.data(Qt.ItemDataRole.UserRole)
+ if data:
+ self.device_selected.emit(data[0], data[1])
-# ── Per-type config panels ────────────────────────────────────────────────────
-class ArduinoPanel(QWidget):
- """Config fields for ArduinoDevice (analog + digital I/O combined)."""
+# ── Per-type panels (remaining type-specific fields only) ─────────────────────
+class SerialPanel(QWidget):
def __init__(self):
super().__init__()
- lay = QFormLayout(self)
+ lay = QVBoxLayout(self)
lay.setContentsMargins(0, 4, 0, 4)
+ note = QLabel("Additional protocol settings available in the device config dialog.")
+ note.setObjectName("traceSource")
+ note.setWordWrap(True)
+ lay.addWidget(note)
+ lay.addStretch()
- self.port_edit = QLineEdit("")
- self.port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0")
- lay.addRow("Port:", self.port_edit)
- self.baud_cb = QComboBox()
- self.baud_cb.addItems(["9600", "57600", "115200", "230400"])
- self.baud_cb.setCurrentText("115200")
- lay.addRow("Baud Rate:", self.baud_cb)
+class ArduinoPanel(QWidget):
+ def __init__(self):
+ super().__init__()
+ lay = QFormLayout(self)
+ lay.setContentsMargins(0, 4, 0, 4)
self.analog_pins_edit = QLineEdit("0, 1, 2, 3")
self.analog_pins_edit.setPlaceholderText("e.g. 0, 1, 2, 3 (indices into A0–A5)")
@@ -204,39 +178,13 @@ class ArduinoPanel(QWidget):
self.do_pins_edit.setPlaceholderText("e.g. 5, 6, 7, 9")
lay.addRow("Digital Output Pins:", self.do_pins_edit)
- self.sim_chk = QCheckBox("Simulation mode")
- self.sim_chk.setChecked(False)
- lay.addRow(self.sim_chk)
-
- lay.addRow(PortScanGroup(self.port_edit))
-
- def build_device(self, device_id: str) -> ArduinoDevice:
- from devices.arduino_device import _parse_pin_edit, _parse_analog_pin_edit
- analog = _parse_analog_pin_edit(self.analog_pins_edit.text()) or None
- di = _parse_pin_edit(self.di_pins_edit.text()) or None
- do = _parse_pin_edit(self.do_pins_edit.text()) or None
- return ArduinoDevice(
- device_id=device_id,
- analog_pins=analog,
- di_pins=di,
- do_pins=do,
- simulate=self.sim_chk.isChecked(),
- port=self.port_edit.text().strip() or "COM3",
- baud=int(self.baud_cb.currentText()),
- )
-
class NidaqmxPanel(QWidget):
- """Config fields for NidaqmxDevice (analog + digital I/O combined)."""
-
def __init__(self):
super().__init__()
lay = QFormLayout(self)
lay.setContentsMargins(0, 4, 0, 4)
- self.ni_device_edit = QLineEdit("Dev1")
- lay.addRow("NI Device:", self.ni_device_edit)
-
self.analog_spin = QSpinBox()
self.analog_spin.setRange(0, 16)
self.analog_spin.setValue(4)
@@ -264,90 +212,29 @@ class NidaqmxPanel(QWidget):
self.do_spin.setValue(4)
lay.addRow("Digital Outputs:", self.do_spin)
- self.sim_chk = QCheckBox("Simulation mode")
- self.sim_chk.setChecked(False)
- lay.addRow(self.sim_chk)
-
- lay.addRow(NIScanGroup(self.ni_device_edit))
-
- def build_device(self, device_id: str) -> NidaqmxDevice:
- return NidaqmxDevice(
- device_id=device_id,
- num_analog=self.analog_spin.value(),
- min_v=self.min_v_spin.value(),
- max_v=self.max_v_spin.value(),
- num_di=self.di_spin.value(),
- num_do=self.do_spin.value(),
- simulate=self.sim_chk.isChecked(),
- ni_device=self.ni_device_edit.text().strip() or "Dev1",
- )
-
-
-class SerialPanel(QWidget):
- """Config fields for SerialDevice — port, baud, protocol/format, simulate."""
-
- 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)
+# ── Panel registry (order matters: first = default) ───────────────────────────
- self.baud_cb = QComboBox()
- self.baud_cb.addItems(["1200", "2400", "4800", "9600", "19200", "38400",
- "57600", "115200", "230400", "460800"])
- self.baud_cb.setCurrentText("115200")
- form.addRow("Baud Rate:", self.baud_cb)
-
- self.fmt_cb = QComboBox()
- self.fmt_cb.addItems(list(_FORMAT_LABELS.keys()))
- form.addRow("Protocol / Format:", self.fmt_cb)
-
- self.sim_chk = QCheckBox("Simulation mode")
- self.sim_chk.setChecked(False)
- form.addRow(self.sim_chk)
-
- note = QLabel("Protocol-specific settings available in device config dialog.")
- note.setObjectName("traceSource")
- note.setWordWrap(True)
-
- lay.addLayout(form)
- lay.addWidget(note)
- lay.addWidget(PortScanGroup(self.port_edit))
+_PANELS = {
+ "Serial / UART": (SerialPanel, "ser"),
+ "Arduino": (ArduinoPanel, "ard"),
+ "NI-DAQ": (NidaqmxPanel, "ni"),
+}
- def build_device(self, device_id: str) -> SerialDevice:
- fmt = _FORMAT_LABELS.get(self.fmt_cb.currentText(), "key:val")
- return SerialDevice(
- device_id=device_id,
- port=self.port_edit.text().strip() or "COM3",
- baud_rate=int(self.baud_cb.currentText()),
- parse_format=fmt,
- simulate=self.sim_chk.isChecked(),
- )
+_SERIAL_TYPES = {"Serial / UART", "Arduino"}
+_NI_TYPES = {"NI-DAQ"}
# ── Main dialog ───────────────────────────────────────────────────────────────
-_PANELS = {
- "Arduino": (ArduinoPanel, "ard"),
- "NI-DAQ": (NidaqmxPanel, "ni"),
- "Serial / UART":(SerialPanel, "ser"),
-}
-
-
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, 480)
- self.resize(500, 620)
+ self.setMinimumSize(480, 580)
+ self.resize(500, 700)
self._build()
def _build(self):
@@ -358,26 +245,67 @@ class AddDeviceDialog(QDialog):
hdr.setObjectName("devWindowTitle")
root.addWidget(hdr)
- div = QFrame()
- div.setFrameShape(QFrame.Shape.HLine)
- div.setObjectName("devWindowDivider")
- root.addWidget(div)
+ root.addWidget(_divider())
+
+ # ── Available Devices ─────────────────────────────────────────────
+ avail_grp = QGroupBox("Available Devices")
+ avail_lay = QVBoxLayout(avail_grp)
+ avail_lay.setSpacing(6)
+
+ self._scanner = _DeviceListScanner()
+ self._scanner.device_selected.connect(self._on_device_selected)
+ avail_lay.addWidget(self._scanner)
+
+ conn_form = QFormLayout()
+ conn_form.setSpacing(6)
+
+ self._port_edit = QLineEdit()
+ self._port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0")
+ self._port_lbl = QLabel("Port:")
+ conn_form.addRow(self._port_lbl, self._port_edit)
+
+ self._baud_cb = QComboBox()
+ self._baud_cb.addItems(["1200", "2400", "4800", "9600", "19200", "38400",
+ "57600", "115200", "230400", "460800"])
+ self._baud_cb.setCurrentText("115200")
+ self._baud_lbl = QLabel("Baud Rate:")
+ conn_form.addRow(self._baud_lbl, self._baud_cb)
+
+ self._ni_edit = QLineEdit("Dev1")
+ self._ni_lbl = QLabel("NI Device:")
+ conn_form.addRow(self._ni_lbl, self._ni_edit)
+
+ self._sim_chk = QCheckBox("Simulation mode")
+ conn_form.addRow(self._sim_chk)
+
+ avail_lay.addLayout(conn_form)
+ root.addWidget(avail_grp)
+
+ root.addWidget(_divider())
+
+ # ── Configuration ─────────────────────────────────────────────────
+ cfg_hdr = QLabel("Configuration")
+ cfg_hdr.setObjectName("devWindowTitle")
+ root.addWidget(cfg_hdr)
+
+ cfg_form = QFormLayout()
+ cfg_form.setSpacing(6)
- 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)
+ cfg_form.addRow("Device Type:", self._type_cb)
self._id_edit = QLineEdit()
self._id_edit.setPlaceholderText("Leave blank for auto")
- type_row.addRow("Device ID:", self._id_edit)
- root.addLayout(type_row)
+ cfg_form.addRow("Device ID:", self._id_edit)
+
+ self._fmt_cb = QComboBox()
+ self._fmt_cb.addItems(list(_FORMAT_LABELS.keys()))
+ self._fmt_lbl = QLabel("Protocol / Format:")
+ cfg_form.addRow(self._fmt_lbl, self._fmt_cb)
- div2 = QFrame()
- div2.setFrameShape(QFrame.Shape.HLine)
- div2.setObjectName("devWindowDivider")
- root.addWidget(div2)
+ root.addLayout(cfg_form)
self._stack = QStackedWidget()
self._panels = {}
@@ -387,28 +315,59 @@ class AddDeviceDialog(QDialog):
self._stack.addWidget(panel)
root.addWidget(self._stack, 1)
- div3 = QFrame()
- div3.setFrameShape(QFrame.Shape.HLine)
- div3.setObjectName("devWindowDivider")
- root.addWidget(div3)
+ root.addWidget(_divider())
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)
+ self._add_btn = QPushButton("Add Device")
+ self._add_btn.setObjectName("applyButton")
+ self._add_btn.setDefault(True)
+ self._add_btn.clicked.connect(self._on_add)
btn_row.addWidget(cancel)
- btn_row.addWidget(add)
+ btn_row.addWidget(self._add_btn)
root.addLayout(btn_row)
self._on_type_changed(0)
+ # ── Handlers ──────────────────────────────────────────────────────────
+
+ def _on_device_selected(self, kind: str, value):
+ if kind == "serial":
+ self._port_edit.setText(value)
+ self._set_type("Serial / UART")
+ elif kind == "ni":
+ self._ni_edit.setText(value)
+ self._set_type("NI-DAQ")
+ elif kind == "camera":
+ self._set_type("Camera")
+ panel = self._panels.get("Camera")
+ if panel and hasattr(panel, "set_camera_index"):
+ panel.set_camera_index(value)
+
+ def _set_type(self, name: str):
+ idx = self._type_cb.findText(name)
+ if idx >= 0:
+ self._type_cb.setCurrentIndex(idx)
+
def _on_type_changed(self, idx: int):
self._stack.setCurrentIndex(idx)
type_name = self._type_cb.currentText()
+
+ is_serial = type_name in _SERIAL_TYPES
+ is_ni = type_name in _NI_TYPES
+ is_serial_uart = type_name == "Serial / UART"
+
+ self._port_lbl.setVisible(is_serial)
+ self._port_edit.setVisible(is_serial)
+ self._baud_lbl.setVisible(is_serial)
+ self._baud_cb.setVisible(is_serial)
+ self._ni_lbl.setVisible(is_ni)
+ self._ni_edit.setVisible(is_ni)
+ self._fmt_lbl.setVisible(is_serial_uart)
+ self._fmt_cb.setVisible(is_serial_uart)
+
_, prefix = _PANELS.get(type_name, (None, "dev"))
existing = {d.info.device_id for d in self.registry.all_instances()}
for i in range(100):
@@ -420,7 +379,22 @@ class AddDeviceDialog(QDialog):
def _on_add(self):
type_name = self._type_cb.currentText()
panel = self._panels[type_name]
- dev_id = self._id_edit.text().strip()
+ sim = self._sim_chk.isChecked()
+
+ if type_name in _SERIAL_TYPES and not sim and not self._port_edit.text().strip():
+ QMessageBox.warning(self, "Port Required",
+ "Enter a port (e.g. COM3 or /dev/ttyUSB0)\n"
+ "or enable Simulation mode.")
+ self._port_edit.setFocus()
+ return
+ if type_name in _NI_TYPES and not sim and not self._ni_edit.text().strip():
+ QMessageBox.warning(self, "NI Device Required",
+ "Enter an NI device name (e.g. Dev1)\n"
+ "or enable Simulation mode.")
+ self._ni_edit.setFocus()
+ return
+
+ dev_id = self._id_edit.text().strip()
if not dev_id:
_, prefix = _PANELS.get(type_name, (None, "dev"))
@@ -437,9 +411,52 @@ class AddDeviceDialog(QDialog):
f"Choose a different ID.")
return
+ port = self._port_edit.text().strip() or "COM3"
+ baud = int(self._baud_cb.currentText())
+ ni = self._ni_edit.text().strip() or "Dev1"
+ fmt = _FORMAT_LABELS.get(self._fmt_cb.currentText(), "key:val")
+
try:
- dev = panel.build_device(dev_id)
+ if type_name == "Serial / UART":
+ dev = SerialDevice(
+ device_id=dev_id, port=port, baud_rate=baud,
+ parse_format=fmt, simulate=sim,
+ )
+ elif type_name == "Arduino":
+ from devices.arduino_device import _parse_pin_edit, _parse_analog_pin_edit
+ analog = _parse_analog_pin_edit(panel.analog_pins_edit.text()) or None
+ di = _parse_pin_edit(panel.di_pins_edit.text()) or None
+ do = _parse_pin_edit(panel.do_pins_edit.text()) or None
+ dev = ArduinoDevice(
+ device_id=dev_id, analog_pins=analog, di_pins=di, do_pins=do,
+ simulate=sim, port=port, baud=baud,
+ )
+ elif type_name == "NI-DAQ":
+ dev = NidaqmxDevice(
+ device_id=dev_id,
+ num_analog=panel.analog_spin.value(),
+ min_v=panel.min_v_spin.value(),
+ max_v=panel.max_v_spin.value(),
+ num_di=panel.di_spin.value(),
+ num_do=panel.do_spin.value(),
+ simulate=sim,
+ ni_device=ni,
+ )
+ else:
+ if hasattr(panel, "set_simulate"):
+ panel.set_simulate(sim)
+ dev = panel.build_device(dev_id)
+
self.created_device = dev
self.accept()
except Exception as e:
QMessageBox.critical(self, "Error creating device", str(e))
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def _divider() -> QFrame:
+ div = QFrame()
+ div.setFrameShape(QFrame.Shape.HLine)
+ div.setObjectName("devWindowDivider")
+ return div