summaryrefslogtreecommitdiff
path: root/docs/plugin-development.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/plugin-development.md')
-rw-r--r--docs/plugin-development.md546
1 files changed, 546 insertions, 0 deletions
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.