From deb3ad65d25d4c3167a3cbeca2ac19b5c45b0627 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Thu, 7 May 2026 13:02:34 -0600 Subject: Added plugin/add-on system Plugins live in plugins// with a manifest.json and a LabPlugin subclass. Enabled/disabled in Settings > Plugins tab; state persists in plugins/enabled.json and in .labdaq profiles (loading a profile enables/disables plugins to match). Plugins can contribute toolbar buttons, BaseDevice instances (auto-wired into acquisition pipeline), custom signal filter classes, a settings widget, and profile state via get_save_state/apply_save_state. Load errors now report the exact failing stage (file not found, class missing, on_load crash). Includes example plugin skeleton and docs/plugin-development.md. Co-Authored-By: Claude Sonnet 4.6 --- docs/plugin-development.md | 546 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 546 insertions(+) create mode 100644 docs/plugin-development.md (limited to 'docs') diff --git a/docs/plugin-development.md b/docs/plugin-development.md new file mode 100644 index 0000000..b0c3a63 --- /dev/null +++ b/docs/plugin-development.md @@ -0,0 +1,546 @@ +# LabDAQ Plugin Development Guide + +Plugins live under `plugins/` as self-contained directories. The app discovers them automatically; the user enables or disables them in **Settings → Plugins**. A disabled plugin leaves zero trace in the UI. + +--- + +## Table of Contents + +1. [Directory structure](#1-directory-structure) +2. [manifest.json](#2-manifestjson) +3. [The LabPlugin class](#3-the-labplugin-class) +4. [PluginContext — accessing the app](#4-plugincontext--accessing-the-app) +5. [Integration hooks](#5-integration-hooks) + - [Toolbar buttons](#51-toolbar-buttons) + - [Devices — feeding data into the pipeline](#52-devices--feeding-data-into-the-pipeline) + - [Custom signal filters](#53-custom-signal-filters) + - [Settings widget](#54-settings-widget) + - [Profile persistence](#55-profile-persistence) +6. [Writing a virtual device from scratch](#6-writing-a-virtual-device-from-scratch) +7. [Data flow reference](#7-data-flow-reference) +8. [Minimal end-to-end example](#8-minimal-end-to-end-example) +9. [Rules and gotchas](#9-rules-and-gotchas) + +--- + +## 1. Directory structure + +``` +plugins/ + my_plugin/ + manifest.json ← required + plugin.py ← required (entry point) + my_device.py ← any supporting files you need + my_filter.py + ui/ + my_window.py +``` + +The plugin directory is added to `sys.path` at load time, so internal imports work without path gymnastics: + +```python +from my_device import MyDevice # works inside plugin.py +from ui.my_window import MyWindow # works too +``` + +--- + +## 2. manifest.json + +```json +{ + "plugin_id": "my_plugin", + "name": "My Plugin", + "version": "1.0.0", + "description": "One-line description shown in Settings.", + "author": "Your Name", + "entry_point": "plugin.MyPlugin" +} +``` + +| Field | Required | Notes | +|---|---|---| +| `plugin_id` | Yes | Unique `snake_case` identifier. Must match `LabPlugin.plugin_id`. | +| `name` | Yes | Display name shown in Settings → Plugins. | +| `version` | No | Defaults to `"1.0.0"`. | +| `description` | No | One sentence. Shown in Settings. | +| `author` | No | Shown in Settings. | +| `entry_point` | No | `"module.ClassName"` relative to the plugin dir. Defaults to `"plugin.Plugin"`. | + +--- + +## 3. The LabPlugin class + +```python +from plugins.base_plugin import LabPlugin, PluginAction, PluginContext + +class MyPlugin(LabPlugin): + + @property + def plugin_id(self) -> str: # must match manifest + return "my_plugin" + + @property + def name(self) -> str: + return "My Plugin" + + # version / description / author — optional overrides + + def on_load(self, context: PluginContext) -> None: + self._ctx = context + # initialise hardware, start threads, etc. + + def on_unload(self) -> None: + # stop threads, close hardware, release memory + pass +``` + +`plugin_id` and `name` are the only abstract properties — everything else is optional. + +### Lifecycle + +``` +User enables plugin + │ + ▼ + on_load(context) ← store context, init hardware + │ + ├── get_devices() ← called once; devices added to registry + engine + ├── get_filter_classes() ← registered in FILTER_CLASSES + └── get_toolbar_actions() ← buttons added to main toolbar + +User disables plugin (or app closes) + │ + ▼ + on_unload() ← stop threads, close hardware + (toolbar buttons removed, devices removed, filter classes unregistered) +``` + +--- + +## 4. PluginContext — accessing the app + +`context` is passed to `on_load`. Store it as `self._ctx`. + +```python +self._ctx.registry # DeviceRegistry — add/get/remove devices +self._ctx.engine # AcquisitionEngine — start/stop, add/remove devices +self._ctx.processor # SignalProcessor — add derived channels, set pipelines +self._ctx.main_window # QMainWindow — parent for dialogs, geometry reference +``` + +### Common patterns + +**Read a live channel value:** +```python +latest = self._ctx.processor._latest.get(("my_device", "ch0")) +if latest: + timestamp, value = latest +``` + +**Subscribe to every processed sample:** +```python +# In on_load: +self._ctx.processor.processed_data.connect(self._on_data) + +def _on_data(self, device_id, channel_id, timestamp, value): + if device_id == "my_device": + ... + +# In on_unload: +self._ctx.processor.processed_data.disconnect(self._on_data) +``` + +**Add a derived channel programmatically:** +```python +from core.signal_processor import DerivedChannel + +dc = DerivedChannel( + channel_id = "my_computed", + name = "My Computed", + unit = "m/s", + kind = "expression", + sources = [("my_device", "ch0")], + expression = "x[0] * 0.001", +) +self._ctx.processor.add_derived(dc) + +# Clean up in on_unload: +self._ctx.processor.remove_derived("my_computed") +``` + +--- + +## 5. Integration hooks + +All hooks are **optional** — return empty lists / `None` for anything your plugin doesn't use. + +--- + +### 5.1 Toolbar buttons + +```python +def get_toolbar_actions(self) -> list: + return [ + PluginAction( + label = "Motion Capture", + icon = "🎥", # emoji or empty string + tooltip = "Open motion capture window", + checkable = True, # button stays pressed + callback = self._open_window, + ) + ] + +def _open_window(self, checked: bool): + if checked: + self._win.show() + else: + self._win.hide() +``` + +Buttons appear between the **Plot** button and the clock. They are removed automatically when the plugin is disabled. + +You can return multiple `PluginAction` objects for multiple buttons. + +--- + +### 5.2 Devices — feeding data into the pipeline + +Return `BaseDevice` instances from `get_devices()`. The app calls `device.connect()`, adds the device to the `DeviceRegistry` and `AcquisitionEngine`, and polls it at the configured rate (default 100 ms). The device's channels then appear everywhere — Signals window, Plot builder, derived channel expressions — exactly like hardware channels. + +```python +def get_devices(self) -> list: + self._device = MyCustomDevice(device_id="my_plugin_dev") + return [self._device] +``` + +See [§6](#6-writing-a-virtual-device-from-scratch) for how to write a `BaseDevice`. + +> **Important:** `get_devices()` is called once at load time. The list must be stable — don't return different objects each call. + +--- + +### 5.3 Custom signal filters + +Return a dict of `{type_name: FilterBase_subclass}`. These become available in the Signals → pipeline editor alongside built-ins like `low_pass`, `moving_average`, etc. + +```python +def get_filter_classes(self) -> dict: + from my_filter import PixelToMillimetreFilter + return {"pixel_to_mm": PixelToMillimetreFilter} +``` + +**Writing a filter:** + +```python +from core.signal_processor import FilterBase + +class PixelToMillimetreFilter(FilterBase): + name = "pixel_to_mm" + + def __init__(self, px_per_mm: float = 10.0): + self.params = {"px_per_mm": px_per_mm} + + def __call__(self, value: float) -> float: + return value / self.params["px_per_mm"] + + def reset(self): + pass # stateless filter — nothing to reset + + # to_dict() is inherited and uses self.name + self.params automatically +``` + +Rules: +- `name` must match the dict key returned from `get_filter_classes()`. +- `__init__` parameters must be JSON-serialisable (used in profiles). +- Stateful filters (ring buffers, IIR memory) must implement `reset()`. +- For filters that need the timestamp (derivatives, integrals), implement `process_with_t(value, timestamp) -> float` in addition to `__call__`. + +--- + +### 5.4 Settings widget + +Return any `QWidget` from `get_settings_widget()`. It is embedded inside **Settings → Plugins** below the plugin's name card, visible only when the plugin is enabled. + +```python +def get_settings_widget(self) -> QWidget: + from PyQt6.QtWidgets import QWidget, QFormLayout, QDoubleSpinBox + + w = QWidget() + lay = QFormLayout(w) + + self._scale_spin = QDoubleSpinBox() + self._scale_spin.setValue(self._scale) + self._scale_spin.valueChanged.connect(self._on_scale_changed) + lay.addRow("px / mm:", self._scale_spin) + + return w + +def _on_scale_changed(self, value: float): + self._scale = value + # update whatever needs updating +``` + +The widget is created once when the plugin loads. Apply changes immediately (no Apply button required — the Settings window Apply button only applies the general settings, not plugin-specific widgets). + +--- + +### 5.5 Profile persistence + +`.labdaq` profiles save the **enabled plugin list** automatically — loading a profile enables/disables plugins to match the saved state. Per-plugin configuration state is also saved if you implement these two methods. + +```python +def get_save_state(self) -> dict: + # Must be JSON-serialisable + return { + "scale": self._scale, + "track_point": list(self._track_point), + } + +def apply_save_state(self, state: dict) -> None: + self._scale = state.get("scale", 10.0) + self._track_point = tuple(state.get("track_point", [0, 0])) + # update UI if it exists +``` + +`apply_save_state` is called after `on_load`, so `self._ctx` is available. The plugin must already be enabled in `enabled.json` for state to be restored — profiles do not enable plugins automatically. + +--- + +## 6. Writing a virtual device from scratch + +A plugin device is a normal `BaseDevice` subclass. The polling loop in `AcquisitionEngine` calls `read_channels()` every 100 ms (configurable) and routes the returned values through the signal processor to the strip chart. + +```python +import time +import threading +from devices.base_device import BaseDevice, DeviceInfo, DeviceStatus, ChannelConfig + +class MyVirtualDevice(BaseDevice): + + def __init__(self, device_id: str = "my_virtual"): + super().__init__(DeviceInfo( + device_id = device_id, + name = "My Virtual Device", + device_type = "virtual", + description = "Produces synthetic data.", + channels = [ + ChannelConfig("ch0", "X Position", unit="px", + min_value=-1000, max_value=1000), + ChannelConfig("ch1", "Y Position", unit="px", + min_value=-1000, max_value=1000), + ], + )) + self._value = {"ch0": 0.0, "ch1": 0.0} + self._lock = threading.Lock() + + # ── Required interface ──────────────────────────────────────────────── + + def connect(self) -> bool: + # Open camera / serial port / socket here. + # Return False and set status to ERROR if it fails. + self.status = DeviceStatus.SIMULATED + return True + + def disconnect(self) -> None: + self.status = DeviceStatus.DISCONNECTED + + def read_channels(self) -> dict: + # Called every poll interval from AcquisitionEngine's background thread. + # Must return quickly — no blocking I/O here. + # If your hardware is slow, read in a background thread and cache here. + with self._lock: + return dict(self._value) + + def write_channel(self, channel_id: str, value) -> bool: + return False # read-only device + + def get_config_widget(self): + from PyQt6.QtWidgets import QLabel + return QLabel("No configuration available.") + + # ── Plugin-specific: push data from your own thread ─────────────────── + + def push(self, ch0: float, ch1: float): + """Call this from your background thread to update the cached value.""" + with self._lock: + self._value["ch0"] = ch0 + self._value["ch1"] = ch1 +``` + +### Background thread pattern + +If your hardware delivers data asynchronously (camera callback, serial stream), use a background thread that writes to the cache, and let the polling loop read from it: + +```python +def connect(self) -> bool: + self._running = True + self._thread = threading.Thread(target=self._reader, daemon=True) + self._thread.start() + self.status = DeviceStatus.CONNECTED + return True + +def disconnect(self) -> None: + self._running = False + self._thread.join(timeout=2) + self.status = DeviceStatus.DISCONNECTED + +def _reader(self): + while self._running: + x, y = self._capture_frame() # your hardware call + with self._lock: + self._value["ch0"] = x + self._value["ch1"] = y +``` + +--- + +## 7. Data flow reference + +``` +Your hardware / background thread + │ + ▼ + MyVirtualDevice.read_channels() ← polled every 100 ms + │ + ▼ + AcquisitionEngine ← engine.new_data signal emitted + │ + ▼ + SignalProcessor.on_raw_data() ← applies filter pipelines + │ ← evaluates derived channels + ▼ + SignalProcessor.processed_data ← (device_id, channel_id, t, value) + │ + ├─→ StripChartWidget ← plotted in real time + └─→ your plugin callback ← if you connected to processed_data +``` + +Your plugin channels participate in every stage: +- **Signals window** — can rename, set color, enable/disable +- **Signal pipeline** — user can attach built-in or custom filters +- **Derived channels** — can reference your channel in expressions (`x[0]`) +- **Plot builder** — appears in channel picker like any physical channel +- **CSV logging** — logged automatically when LOG is active + +--- + +## 8. Minimal end-to-end example + +This plugin adds a sine-wave virtual channel and a toolbar button to toggle a display window. + +**`plugins/sine_demo/manifest.json`** +```json +{ + "plugin_id": "sine_demo", + "name": "Sine Demo", + "version": "1.0.0", + "description": "Virtual sine-wave channel for testing.", + "author": "LabDAQ", + "entry_point": "plugin.SineDemoPlugin" +} +``` + +**`plugins/sine_demo/plugin.py`** +```python +import math, time, threading +from devices.base_device import BaseDevice, DeviceInfo, DeviceStatus, ChannelConfig +from plugins.base_plugin import LabPlugin, PluginAction, PluginContext + + +class SineDevice(BaseDevice): + def __init__(self): + super().__init__(DeviceInfo( + device_id = "sine_demo_dev", + name = "Sine Generator", + device_type = "virtual", + channels = [ChannelConfig("sine", "Sine Wave", unit="V", + min_value=-1, max_value=1)], + )) + self._t0 = time.monotonic() + + def connect(self): + self.status = DeviceStatus.SIMULATED; return True + def disconnect(self): + self.status = DeviceStatus.DISCONNECTED + def read_channels(self): + return {"sine": math.sin(2 * math.pi * (time.monotonic() - self._t0))} + def write_channel(self, ch, v): return False + def get_config_widget(self): + from PyQt6.QtWidgets import QLabel + return QLabel("No config.") + + +class SineDemoPlugin(LabPlugin): + + @property + def plugin_id(self): return "sine_demo" + + @property + def name(self): return "Sine Demo" + + def on_load(self, context: PluginContext): + self._ctx = context + self._dev = SineDevice() + self._win = None + + def on_unload(self): + if self._win: + self._win.close() + + def get_devices(self): + return [self._dev] + + def get_toolbar_actions(self): + return [PluginAction( + label="Sine Demo", icon="〜", + tooltip="Open sine demo window", + checkable=True, + callback=self._toggle_window, + )] + + def _toggle_window(self, checked: bool): + from PyQt6.QtWidgets import QLabel, QWidget, QVBoxLayout + if self._win is None: + self._win = QWidget(None) + self._win.setWindowTitle("Sine Demo") + lay = QVBoxLayout(self._win) + lay.addWidget(QLabel( + "Sine wave channel 'sine_demo_dev / sine' is now live.\n" + "Add it to a plot via Plot → channel picker." + )) + if checked: + self._win.show() + else: + self._win.hide() +``` + +Enable it in **Settings → Plugins**, then open **Plot** and add the `sine_demo_dev / sine` channel. + +--- + +## 9. Rules and gotchas + +**`plugin_id` must be globally unique and match the manifest.** +The manager keys everything by this string. Collision = second plugin silently ignored. + +**`get_devices()` is called once.** +Return a stable list. Don't construct new device objects on repeated calls. + +**`read_channels()` runs on the acquisition thread.** +Keep it fast. Do not block. Cache hardware values from a separate thread if needed. + +**Don't import Qt in module scope inside a plugin.** +Import Qt widgets inside methods or inside `on_load`. This avoids import errors if the plugin directory is scanned before the `QApplication` is created. + +**`on_unload()` must clean up everything.** +Stop background threads (`_running = False; _thread.join()`), disconnect signals, close windows. The app calls `on_unload()` both on user disable and on application close. + +**Filter `__init__` params must be JSON-serialisable.** +They are written into `.labdaq` profiles via `to_dict()` and reconstructed via `filter_from_dict()`. Stick to `int`, `float`, `str`, `bool`. + +**Profiles enable and disable plugins.** +Loading a `.labdaq` profile reconciles plugin state: plugins not in the profile's enabled list are disabled, plugins in the list are enabled. `apply_save_state` is called after the plugin is loaded. `plugins/enabled.json` is updated to match. + +**Enabled state persists across restarts.** +`plugins/enabled.json` is written every time a toggle changes. Delete it to reset all plugins to disabled. -- cgit v1.2.3