From 864642a84d8e93cc4971c7915e39a60740cdd6fa Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Thu, 4 Jun 2026 12:29:24 -0600 Subject: Redesign motion capture plugin as Camera device type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin now registers a "Camera" option in the Add Device dialog on load (removes it on unload) instead of auto-creating a single device. - camera_panel.py (new): CameraPanel with CameraScanThread that probes OpenCV indices 0–5, lists found cameras with resolution, shows "No cameras detected" when none available, simulation checkbox. build_device() returns CameraDevice for the selected index. - device.py: rewrite MotionCaptureDevice → CameraDevice. Each instance owns its own CameraTracker created in connect(). Status is CONNECTED or SIMULATED based on tracker.simulated. get_config_widget() shows source/status and an "Open Camera View" button that launches the existing MotionCaptureWindow for that device's tracker. Backward-compat alias MotionCaptureDevice = CameraDevice kept for saved profiles. - plugin.py: simplified to on_load/_PANELS registration + pixel_to_mm filter. No auto-devices, no toolbar button, no settings widget. Camera data flows through the standard Device → Signal → Channel → Plot pipeline like any other device. Co-Authored-By: Claude Sonnet 4.6 --- plugins/motion_capture/camera_panel.py | 132 +++++++++++++++++++++++ plugins/motion_capture/device.py | 124 ++++++++++++++++----- plugins/motion_capture/plugin.py | 189 ++++----------------------------- 3 files changed, 249 insertions(+), 196 deletions(-) create mode 100644 plugins/motion_capture/camera_panel.py diff --git a/plugins/motion_capture/camera_panel.py b/plugins/motion_capture/camera_panel.py new file mode 100644 index 0000000..611c942 --- /dev/null +++ b/plugins/motion_capture/camera_panel.py @@ -0,0 +1,132 @@ +""" +motion_capture/camera_panel.py + +CameraPanel for AddDeviceDialog — scans for OpenCV cameras and builds +a CameraDevice when the user clicks "Add Device". +""" + +from __future__ import annotations + +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtWidgets import ( + QCheckBox, QGroupBox, QHBoxLayout, QLabel, QListWidget, + QListWidgetItem, QPushButton, 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._cameras: list = [] + self._selected_index: int = 0 + self._scanner = None + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 4, 0, 4) + lay.setSpacing(8) + + self.sim_chk = QCheckBox("Simulation mode (Lissajous pattern, no camera needed)") + self.sim_chk.setChecked(False) + lay.addWidget(self.sim_chk) + + scan_grp = QGroupBox("Available Cameras") + sg = QVBoxLayout(scan_grp) + sg.setSpacing(4) + + top = QHBoxLayout() + self._scan_btn = QPushButton("🔍 Scan Cameras") + self._scan_btn.setObjectName("addTraceBtn") + self._scan_btn.clicked.connect(self._scan) + self._status = QLabel("Click Scan to detect cameras") + self._status.setObjectName("traceSource") + top.addWidget(self._scan_btn) + top.addWidget(self._status, 1) + sg.addLayout(top) + + self._list = QListWidget() + self._list.setMaximumHeight(120) + self._list.setObjectName("portList") + self._list.itemClicked.connect(self._on_select) + self._list.setToolTip("Click a camera to select it") + sg.addWidget(self._list) + + hint = QLabel("↑ Click a camera above to select it") + hint.setObjectName("traceSource") + sg.addWidget(hint) + + lay.addWidget(scan_grp) + + 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() + + # ── Scan ───────────────────────────────────────────────────────────── + + def _scan(self): + self._scan_btn.setEnabled(False) + self._status.setText("Scanning…") + self._list.clear() + self._scanner = CameraScanThread() + self._scanner.cameras_found.connect(self._on_found) + self._scanner.start() + + def _on_found(self, cameras): + self._scan_btn.setEnabled(True) + self._cameras = cameras + self._list.clear() + if not cameras: + self._status.setText("No cameras found") + item = QListWidgetItem(" No cameras detected") + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + self._list.addItem(item) + else: + self._status.setText(f"{len(cameras)} camera(s) found") + for idx, label in cameras: + item = QListWidgetItem(f" {label}") + item.setData(Qt.ItemDataRole.UserRole, idx) + self._list.addItem(item) + self._list.setCurrentRow(0) + self._selected_index = cameras[0][0] + + def _on_select(self, item: QListWidgetItem): + idx = item.data(Qt.ItemDataRole.UserRole) + if idx is not None: + self._selected_index = idx + + # ── Build ───────────────────────────────────────────────────────────── + + def build_device(self, device_id: str): + from device import CameraDevice + return CameraDevice( + device_id=device_id, + camera_index=self._selected_index, + simulate=self.sim_chk.isChecked(), + ) 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..6eb5508 100644 --- a/plugins/motion_capture/plugin.py +++ b/plugins/motion_capture/plugin.py @@ -3,30 +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. -from __future__ import annotations +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. -from typing import Any, Dict, List, Optional +Install OpenCV for real camera support: + pip install opencv-python +""" -from PyQt6.QtWidgets import ( - QComboBox, QDoubleSpinBox, QFormLayout, QHBoxLayout, - QLabel, QPushButton, QWidget, -) +from __future__ import annotations -from plugins.base_plugin import LabPlugin, PluginAction, PluginContext +from plugins.base_plugin import LabPlugin, PluginContext class MotionCapturePlugin(LabPlugin): @@ -44,8 +36,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" @@ -54,157 +48,16 @@ class MotionCapturePlugin(LabPlugin): 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) + 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 + from ui.add_device_dialog import _PANELS + _PANELS.pop("Camera", None) # ── Integration hooks ───────────────────────────────────────────────── - def get_devices(self) -> list: - return [self._device] - 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 - - # ── Profile state ───────────────────────────────────────────────────── - - 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 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 - - # ── Internal ────────────────────────────────────────────────────────── - - 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_() - 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: - 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) -- cgit v1.2.3 From 2815a90ffa7e734755884db675a9d5aa704c45a0 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Thu, 4 Jun 2026 12:35:36 -0600 Subject: Restore Motion Capture toolbar button with multi-camera support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_toolbar_actions() returns a checkable 🎥 button. On click: - 0 camera devices → info message, button unchecks - 1 camera device → opens MotionCaptureWindow directly - N camera devices → QInputDialog picker, opens selected device's window Windows tracked per device_id; closing a window removes it from the map and unchecks the button when none remain. on_unload closes all open windows before deregistering the Camera panel. Co-Authored-By: Claude Sonnet 4.6 --- plugins/motion_capture/plugin.py | 84 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/plugins/motion_capture/plugin.py b/plugins/motion_capture/plugin.py index 6eb5508..0b44cb4 100644 --- a/plugins/motion_capture/plugin.py +++ b/plugins/motion_capture/plugin.py @@ -18,7 +18,9 @@ Install OpenCV for real camera support: from __future__ import annotations -from plugins.base_plugin import LabPlugin, PluginContext +from typing import List, Optional + +from plugins.base_plugin import LabPlugin, PluginAction, PluginContext class MotionCapturePlugin(LabPlugin): @@ -47,17 +49,95 @@ class MotionCapturePlugin(LabPlugin): # ── Lifecycle ───────────────────────────────────────────────────────── def on_load(self, context: PluginContext) -> None: - self._ctx = context + 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: + 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_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} + + # ── Internal ────────────────────────────────────────────────────────── + + def _camera_devices(self) -> list: + from device import CameraDevice + return [d for d in self._ctx.registry.all_instances() + if isinstance(d, CameraDevice)] + + def _on_btn_toggled(self, checked: bool) -> None: + from PyQt6.QtWidgets import QInputDialog, QMessageBox + if not checked: + return + + cameras = self._camera_devices() + + 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 + + if len(cameras) == 1: + self._open_window(cameras[0]) + else: + 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 + 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) -- cgit v1.2.3 From 17b2f07545b89487c2d1bb936dc52a612d38d470 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Thu, 4 Jun 2026 13:24:54 -0600 Subject: docs: update CLAUDE.md — add plugin system, BSP tree layout, opencv dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 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": } +{"kind": "hsplit", "ratio": , "first": , "second": } # left/right +{"kind": "vsplit", "ratio": , "first": , "second": } # 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//` 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/` -- cgit v1.2.3 From a35b9b121218625445bda40729a8222243199c77 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Tue, 9 Jun 2026 15:40:35 -0600 Subject: Redesign Add Device dialog with streamlined two-section layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unified device scanner at top (serial ports, NI devices, cameras) replaces per-panel scan groups; clicking pre-fills port/type - Port, baud rate, NI Device, and simulation toggle moved into Available Devices section; shown/hidden by device type - Device type, ID, and Protocol/Format grouped under Configuration - Panel order: Serial/UART → Arduino → NI-DAQ (Serial first) - Add button always enabled; validation on press shows error popup with focus jump to the offending field - CameraPanel loses sim_chk (sim state set by dialog via set_simulate) Co-Authored-By: Claude Sonnet 4.6 --- plugins/motion_capture/camera_panel.py | 92 ++----- ui/add_device_dialog.py | 485 +++++++++++++++++---------------- 2 files changed, 270 insertions(+), 307 deletions(-) diff --git a/plugins/motion_capture/camera_panel.py b/plugins/motion_capture/camera_panel.py index 611c942..3fdd286 100644 --- a/plugins/motion_capture/camera_panel.py +++ b/plugins/motion_capture/camera_panel.py @@ -1,17 +1,15 @@ """ motion_capture/camera_panel.py -CameraPanel for AddDeviceDialog — scans for OpenCV cameras and builds +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 Qt, QThread, pyqtSignal -from PyQt6.QtWidgets import ( - QCheckBox, QGroupBox, QHBoxLayout, QLabel, QListWidget, - QListWidgetItem, QPushButton, QVBoxLayout, QWidget, -) +from PyQt6.QtCore import QThread, pyqtSignal +from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget class CameraScanThread(QThread): @@ -40,44 +38,17 @@ class CameraPanel(QWidget): def __init__(self): super().__init__() - self._cameras: list = [] self._selected_index: int = 0 - self._scanner = None + self._simulate: bool = False lay = QVBoxLayout(self) lay.setContentsMargins(0, 4, 0, 4) lay.setSpacing(8) - self.sim_chk = QCheckBox("Simulation mode (Lissajous pattern, no camera needed)") - self.sim_chk.setChecked(False) - lay.addWidget(self.sim_chk) - - scan_grp = QGroupBox("Available Cameras") - sg = QVBoxLayout(scan_grp) - sg.setSpacing(4) - - top = QHBoxLayout() - self._scan_btn = QPushButton("🔍 Scan Cameras") - self._scan_btn.setObjectName("addTraceBtn") - self._scan_btn.clicked.connect(self._scan) - self._status = QLabel("Click Scan to detect cameras") - self._status.setObjectName("traceSource") - top.addWidget(self._scan_btn) - top.addWidget(self._status, 1) - sg.addLayout(top) - - self._list = QListWidget() - self._list.setMaximumHeight(120) - self._list.setObjectName("portList") - self._list.itemClicked.connect(self._on_select) - self._list.setToolTip("Click a camera to select it") - sg.addWidget(self._list) - - hint = QLabel("↑ Click a camera above to select it") - hint.setObjectName("traceSource") - sg.addWidget(hint) - - lay.addWidget(scan_grp) + 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" @@ -88,45 +59,20 @@ class CameraPanel(QWidget): lay.addWidget(note) lay.addStretch() - # ── Scan ───────────────────────────────────────────────────────────── - - def _scan(self): - self._scan_btn.setEnabled(False) - self._status.setText("Scanning…") - self._list.clear() - self._scanner = CameraScanThread() - self._scanner.cameras_found.connect(self._on_found) - self._scanner.start() - - def _on_found(self, cameras): - self._scan_btn.setEnabled(True) - self._cameras = cameras - self._list.clear() - if not cameras: - self._status.setText("No cameras found") - item = QListWidgetItem(" No cameras detected") - item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) - self._list.addItem(item) - else: - self._status.setText(f"{len(cameras)} camera(s) found") - for idx, label in cameras: - item = QListWidgetItem(f" {label}") - item.setData(Qt.ItemDataRole.UserRole, idx) - self._list.addItem(item) - self._list.setCurrentRow(0) - self._selected_index = cameras[0][0] - - def _on_select(self, item: QListWidgetItem): - idx = item.data(Qt.ItemDataRole.UserRole) - if idx is not None: - self._selected_index = idx - - # ── Build ───────────────────────────────────────────────────────────── + 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.sim_chk.isChecked(), + simulate=self._simulate, ) 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 -- cgit v1.2.3