# 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.