summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-05-07 13:02:34 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-06-03 09:50:17 -0600
commitdeb3ad65d25d4c3167a3cbeca2ac19b5c45b0627 (patch)
tree131eebcabfab2d0a19e52aec21a21ad0a24775fa
parent2b9943ba0d28449a590acfa8a41555b174b6ba84 (diff)
Added plugin/add-on system
Plugins live in plugins/<name>/ 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 <noreply@anthropic.com>
-rw-r--r--core/profile.py66
-rw-r--r--core/signal_processor.py10
-rw-r--r--docs/plugin-development.md546
-rw-r--r--plugins/__init__.py11
-rw-r--r--plugins/__pycache__/__init__.cpython-312.pycbin0 -> 422 bytes
-rw-r--r--plugins/__pycache__/__init__.cpython-314.pycbin0 -> 424 bytes
-rw-r--r--plugins/__pycache__/base_plugin.cpython-312.pycbin0 -> 5620 bytes
-rw-r--r--plugins/__pycache__/base_plugin.cpython-314.pycbin0 -> 7450 bytes
-rw-r--r--plugins/__pycache__/plugin_manager.cpython-312.pycbin0 -> 10714 bytes
-rw-r--r--plugins/__pycache__/plugin_manager.cpython-314.pycbin0 -> 13351 bytes
-rw-r--r--plugins/base_plugin.py133
-rw-r--r--plugins/enabled.json3
-rw-r--r--plugins/example/__pycache__/plugin.cpython-312.pycbin0 -> 3549 bytes
-rw-r--r--plugins/example/manifest.json8
-rw-r--r--plugins/example/plugin.py105
-rw-r--r--plugins/plugin_manager.py225
-rw-r--r--ui/main_window.py143
-rw-r--r--ui/windows/settings_window.py145
18 files changed, 1367 insertions, 28 deletions
diff --git a/core/profile.py b/core/profile.py
index d21bf2f..fdd3a48 100644
--- a/core/profile.py
+++ b/core/profile.py
@@ -67,15 +67,19 @@ class ProfileDerived:
@dataclass
class Profile:
- name: str = "Untitled Profile"
- version: str = "1.0"
- devices: List[Dict] = field(default_factory=list)
- controls: List[Dict] = field(default_factory=list)
- channels: List[Dict] = field(default_factory=list)
- pipelines: List[Dict] = field(default_factory=list)
- derived: List[Dict] = field(default_factory=list)
- plot: Optional[Dict] = None # LayoutConfig.to_json()
- settings: Dict[str, Any] = field(default_factory=dict)
+ name: str = "Untitled Profile"
+ version: str = "1.0"
+ devices: List[Dict] = field(default_factory=list)
+ controls: List[Dict] = field(default_factory=list)
+ channels: List[Dict] = field(default_factory=list)
+ pipelines: List[Dict] = field(default_factory=list)
+ derived: List[Dict] = field(default_factory=list)
+ plot: Optional[Dict] = None # LayoutConfig.to_json()
+ settings: Dict[str, Any] = field(default_factory=dict)
+ plugin_state: Dict[str, Any] = field(default_factory=dict)
+ # plugin_state = {plugin_id: plugin.get_save_state()}
+ plugins_enabled: List[str] = field(default_factory=list)
+ # plugins_enabled = [plugin_id, ...] — which plugins were on when saved
def to_json(self) -> str:
return json.dumps(asdict(self), indent=2)
@@ -84,15 +88,17 @@ class Profile:
def from_json(s: str) -> "Profile":
d = json.loads(s)
return Profile(
- name = d.get("name", ""),
- version = d.get("version", "1.0"),
- devices = d.get("devices", []),
- controls = d.get("controls", []),
- channels = d.get("channels", []),
- pipelines = d.get("pipelines", []),
- derived = d.get("derived", []),
- plot = d.get("plot"),
- settings = d.get("settings", {}),
+ name = d.get("name", ""),
+ version = d.get("version", "1.0"),
+ devices = d.get("devices", []),
+ controls = d.get("controls", []),
+ channels = d.get("channels", []),
+ pipelines = d.get("pipelines", []),
+ derived = d.get("derived", []),
+ plot = d.get("plot"),
+ settings = d.get("settings", {}),
+ plugin_state = d.get("plugin_state", {}),
+ plugins_enabled = d.get("plugins_enabled", []),
)
def save(self, path: str):
@@ -135,6 +141,7 @@ class ProfileManager:
control_specs: List,
settings: dict,
profile_name: str = "Profile",
+ plugin_manager=None,
) -> Profile:
p = Profile(name=profile_name)
@@ -193,6 +200,18 @@ class ProfileManager:
pass
p.settings = dict(settings)
+
+ # Plugin state + enabled list
+ if plugin_manager is not None:
+ p.plugins_enabled = plugin_manager.get_enabled_ids()
+ for plugin in plugin_manager.get_loaded():
+ try:
+ state = plugin.get_save_state()
+ if state:
+ p.plugin_state[plugin.plugin_id] = state
+ except Exception:
+ pass
+
return p
# ── Apply ─────────────────────────────────────────────────────────────────
@@ -205,6 +224,7 @@ class ProfileManager:
control_panel,
settings_ref: dict,
engine=None,
+ plugin_manager=None,
):
"""Apply a loaded profile to the live application state."""
from ui.control_editor import ControlSpec
@@ -329,4 +349,14 @@ class ProfileManager:
except Exception as e:
print(f"[Profile] Could not restore plot layout: {e}")
+ # Plugin state
+ if plugin_manager is not None and profile.plugin_state:
+ for plugin_id, state in profile.plugin_state.items():
+ plugin = plugin_manager.get_plugin(plugin_id)
+ if plugin is not None:
+ try:
+ plugin.apply_save_state(state)
+ except Exception as e:
+ print(f"[Profile] Plugin '{plugin_id}' state restore error: {e}")
+
return plot_cfg # caller applies this to the chart
diff --git a/core/signal_processor.py b/core/signal_processor.py
index 417d142..723de66 100644
--- a/core/signal_processor.py
+++ b/core/signal_processor.py
@@ -185,6 +185,16 @@ def filter_from_dict(d: dict) -> FilterBase:
return cls(**params)
+def register_filter_class(name: str, cls) -> None:
+ """Register a plugin-provided filter class by type name."""
+ FILTER_CLASSES[name] = cls
+
+
+def unregister_filter_class(name: str) -> None:
+ """Remove a previously registered plugin filter class."""
+ FILTER_CLASSES.pop(name, None)
+
+
# ══════════════════════════════════════════════════════════════════════════════
# Derived channel definitions
# ══════════════════════════════════════════════════════════════════════════════
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.
diff --git a/plugins/__init__.py b/plugins/__init__.py
new file mode 100644
index 0000000..bdb24e0
--- /dev/null
+++ b/plugins/__init__.py
@@ -0,0 +1,11 @@
+"""
+plugins/
+
+LabDAQ plugin system. Each plugin lives in its own subdirectory:
+
+ plugins/
+ my_plugin/
+ manifest.json — plugin metadata
+ plugin.py — LabPlugin subclass
+ ... — any other files
+"""
diff --git a/plugins/__pycache__/__init__.cpython-312.pyc b/plugins/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..1d4680f
--- /dev/null
+++ b/plugins/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/plugins/__pycache__/__init__.cpython-314.pyc b/plugins/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..52a1579
--- /dev/null
+++ b/plugins/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/plugins/__pycache__/base_plugin.cpython-312.pyc b/plugins/__pycache__/base_plugin.cpython-312.pyc
new file mode 100644
index 0000000..08d0af2
--- /dev/null
+++ b/plugins/__pycache__/base_plugin.cpython-312.pyc
Binary files differ
diff --git a/plugins/__pycache__/base_plugin.cpython-314.pyc b/plugins/__pycache__/base_plugin.cpython-314.pyc
new file mode 100644
index 0000000..29b49d4
--- /dev/null
+++ b/plugins/__pycache__/base_plugin.cpython-314.pyc
Binary files differ
diff --git a/plugins/__pycache__/plugin_manager.cpython-312.pyc b/plugins/__pycache__/plugin_manager.cpython-312.pyc
new file mode 100644
index 0000000..6aaf365
--- /dev/null
+++ b/plugins/__pycache__/plugin_manager.cpython-312.pyc
Binary files differ
diff --git a/plugins/__pycache__/plugin_manager.cpython-314.pyc b/plugins/__pycache__/plugin_manager.cpython-314.pyc
new file mode 100644
index 0000000..ad55c5f
--- /dev/null
+++ b/plugins/__pycache__/plugin_manager.cpython-314.pyc
Binary files differ
diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py
new file mode 100644
index 0000000..0eeb25f
--- /dev/null
+++ b/plugins/base_plugin.py
@@ -0,0 +1,133 @@
+"""
+plugins/base_plugin.py
+
+Abstract base class for all LabDAQ plugins.
+
+To create a plugin:
+ 1. Create a directory under plugins/ e.g. plugins/my_plugin/
+ 2. Add manifest.json with plugin metadata
+ 3. Add plugin.py with a class subclassing LabPlugin
+ 4. Enable in Settings → Plugins
+
+manifest.json schema:
+ {
+ "plugin_id": "my_plugin", required — unique snake_case id
+ "name": "My Plugin", required
+ "version": "1.0.0",
+ "description": "What this does.",
+ "author": "Name",
+ "entry_point": "plugin.MyPlugin" module.ClassName relative to plugin dir
+ }
+
+Integration points (all optional — override only what you need):
+
+ get_toolbar_actions() → list of PluginAction toolbar buttons in main window
+ get_devices() → list of BaseDevice auto-added to registry + engine
+ get_filter_classes() → {name: FilterBase cls} custom signal pipeline filters
+ get_settings_widget() → QWidget | None shown in Settings > Plugins
+ get_save_state() → dict persisted in .labdaq profiles
+ apply_save_state(dict) restore from profile
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Any, Callable, Dict, List, Optional, Type
+
+from PyQt6.QtWidgets import QWidget
+
+
+# ── Context ───────────────────────────────────────────────────────────────────
+
+@dataclass
+class PluginContext:
+ """Core app objects handed to each plugin on load."""
+ registry: Any # devices.device_registry.DeviceRegistry
+ engine: Any # core.acquisition.AcquisitionEngine
+ processor: Any # core.signal_processor.SignalProcessor
+ main_window: Any # ui.main_window.MainWindow (QMainWindow)
+
+
+# ── Toolbar action descriptor ─────────────────────────────────────────────────
+
+@dataclass
+class PluginAction:
+ """Describes one toolbar button contributed by a plugin."""
+ label: str
+ callback: Callable[[bool], None] # receives checked state
+ icon: str = ""
+ tooltip: str = ""
+ checkable: bool = False
+
+
+# ── Base class ────────────────────────────────────────────────────────────────
+
+class LabPlugin(ABC):
+ """
+ Abstract base for all LabDAQ plugins.
+
+ Subclass this, set the metadata properties, and override whatever
+ integration hooks your plugin needs.
+ """
+
+ # ── Metadata ──────────────────────────────────────────────────────────
+
+ @property
+ @abstractmethod
+ def plugin_id(self) -> str:
+ """Unique identifier matching manifest plugin_id."""
+
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ """Human-readable display name."""
+
+ @property
+ def version(self) -> str:
+ return "1.0.0"
+
+ @property
+ def description(self) -> str:
+ return ""
+
+ @property
+ def author(self) -> str:
+ return ""
+
+ # ── Lifecycle ─────────────────────────────────────────────────────────
+
+ def on_load(self, context: PluginContext) -> None:
+ """Called when the plugin is enabled. Store context for later use."""
+
+ def on_unload(self) -> None:
+ """Called when the plugin is disabled. Release all resources."""
+
+ # ── Integration hooks ─────────────────────────────────────────────────
+
+ def get_toolbar_actions(self) -> List[PluginAction]:
+ """Toolbar buttons added to the main window when plugin is enabled."""
+ return []
+
+ def get_devices(self) -> list:
+ """BaseDevice instances contributed by this plugin.
+ They are automatically added to the DeviceRegistry and AcquisitionEngine."""
+ return []
+
+ def get_filter_classes(self) -> Dict[str, Type]:
+ """Custom signal filters: {filter_type_name: FilterBase subclass}.
+ Registered in SignalProcessor.FILTER_CLASSES when plugin loads."""
+ return {}
+
+ def get_settings_widget(self) -> Optional[QWidget]:
+ """Plugin-specific settings panel shown in Settings > Plugins."""
+ return None
+
+ # ── Profile persistence ───────────────────────────────────────────────
+
+ def get_save_state(self) -> Dict[str, Any]:
+ """Return a JSON-serialisable dict, saved with the .labdaq profile."""
+ return {}
+
+ def apply_save_state(self, state: Dict[str, Any]) -> None:
+ """Restore plugin state when a profile is loaded."""
diff --git a/plugins/enabled.json b/plugins/enabled.json
new file mode 100644
index 0000000..a2216af
--- /dev/null
+++ b/plugins/enabled.json
@@ -0,0 +1,3 @@
+{
+ "example": true
+} \ No newline at end of file
diff --git a/plugins/example/__pycache__/plugin.cpython-312.pyc b/plugins/example/__pycache__/plugin.cpython-312.pyc
new file mode 100644
index 0000000..c31b466
--- /dev/null
+++ b/plugins/example/__pycache__/plugin.cpython-312.pyc
Binary files differ
diff --git a/plugins/example/manifest.json b/plugins/example/manifest.json
new file mode 100644
index 0000000..a5af87d
--- /dev/null
+++ b/plugins/example/manifest.json
@@ -0,0 +1,8 @@
+{
+ "plugin_id": "example",
+ "name": "Example Plugin",
+ "version": "1.0.0",
+ "description": "Skeleton showing all plugin integration points. Safe to delete.",
+ "author": "Your Name",
+ "entry_point": "plugin.ExamplePlugin"
+}
diff --git a/plugins/example/plugin.py b/plugins/example/plugin.py
new file mode 100644
index 0000000..e5fbb8e
--- /dev/null
+++ b/plugins/example/plugin.py
@@ -0,0 +1,105 @@
+"""
+plugins/example/plugin.py
+
+Skeleton plugin — shows every integration point.
+Copy this directory, rename it, update manifest.json, and fill in your logic.
+"""
+
+from __future__ import annotations
+from typing import Any, Dict, List, Optional, Type
+
+from PyQt6.QtWidgets import QLabel, QWidget
+
+# LabDAQ imports available at runtime
+from plugins.base_plugin import LabPlugin, PluginAction, PluginContext
+
+
+class ExamplePlugin(LabPlugin):
+
+ # ── Metadata ──────────────────────────────────────────────────────────
+
+ @property
+ def plugin_id(self) -> str:
+ return "example"
+
+ @property
+ def name(self) -> str:
+ return "Example Plugin"
+
+ @property
+ def version(self) -> str:
+ return "1.0.0"
+
+ @property
+ def description(self) -> str:
+ return "Skeleton showing all plugin integration points."
+
+ @property
+ def author(self) -> str:
+ return "Your Name"
+
+ # ── Lifecycle ─────────────────────────────────────────────────────────
+
+ def on_load(self, context: PluginContext) -> None:
+ self._ctx = context
+ # e.g. start a background thread, open a camera, etc.
+
+ def on_unload(self) -> None:
+ # Release resources — called when user disables plugin
+ pass
+
+ # ── Toolbar ───────────────────────────────────────────────────────────
+
+ def get_toolbar_actions(self) -> List[PluginAction]:
+ return [
+ PluginAction(
+ label = "Example",
+ icon = "🔌",
+ tooltip = "Open example plugin window",
+ checkable = True,
+ callback = self._on_toolbar_click,
+ )
+ ]
+
+ def _on_toolbar_click(self, checked: bool):
+ # Open / close your plugin window here
+ pass
+
+ # ── Devices ───────────────────────────────────────────────────────────
+
+ def get_devices(self) -> list:
+ # Return BaseDevice instances — they are auto-added to the registry
+ # and AcquisitionEngine so their channels appear as normal signals.
+ #
+ # Example (uncomment and adapt):
+ # from devices.analog_input import AnalogInputDevice
+ # return [AnalogInputDevice("my_plugin_ai", num_channels=2, simulate=True)]
+ return []
+
+ # ── Custom filters ────────────────────────────────────────────────────
+
+ def get_filter_classes(self) -> Dict[str, Type]:
+ # Return {type_name: FilterBase subclass} for custom pipeline filters.
+ #
+ # Example:
+ # from .my_filter import MyFilter
+ # return {"my_filter": MyFilter}
+ return {}
+
+ # ── Settings widget ───────────────────────────────────────────────────
+
+ def get_settings_widget(self) -> Optional[QWidget]:
+ # Return a QWidget shown in Settings > Plugins when this plugin is enabled.
+ lbl = QLabel("No settings for this plugin.")
+ lbl.setObjectName("traceSource")
+ return lbl
+
+ # ── Profile persistence ───────────────────────────────────────────────
+
+ def get_save_state(self) -> Dict[str, Any]:
+ # Return JSON-serialisable dict saved with .labdaq profiles.
+ return {}
+
+ def apply_save_state(self, state: Dict[str, Any]) -> None:
+ # Restore plugin state when a profile is loaded.
+ pass
diff --git a/plugins/plugin_manager.py b/plugins/plugin_manager.py
new file mode 100644
index 0000000..36ae71a
--- /dev/null
+++ b/plugins/plugin_manager.py
@@ -0,0 +1,225 @@
+"""
+plugins/plugin_manager.py
+
+Discovers and manages the lifecycle of LabDAQ plugins.
+
+Enabled state is persisted to plugins/enabled.json (one dict
+{plugin_id: bool}). This survives app restarts; profiles store
+per-plugin state separately via get_save_state / apply_save_state.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+import sys
+import traceback
+from dataclasses import dataclass
+from typing import Dict, List, Optional
+
+from plugins.base_plugin import LabPlugin, PluginContext
+
+
+_MANIFEST_FILE = "manifest.json"
+_ENABLED_FILE = "enabled.json"
+
+
+# ── Manifest ──────────────────────────────────────────────────────────────────
+
+@dataclass
+class PluginManifest:
+ plugin_id: str
+ name: str
+ version: str = "1.0.0"
+ description: str = ""
+ author: str = ""
+ entry_point: str = "plugin.Plugin" # "module.ClassName" relative to plugin dir
+ plugin_dir: str = ""
+
+
+# ── Manager ───────────────────────────────────────────────────────────────────
+
+class PluginManager:
+ """
+ Discovers, loads, and lifecycle-manages LabDAQ plugins.
+
+ Typical usage in MainWindow:
+
+ self._plugins = PluginManager(plugins_dir)
+ self._plugins.discover()
+ for plugin in self._plugins.load_enabled(context):
+ self._install_plugin(plugin)
+
+ Enable / disable at runtime:
+
+ plugin = self._plugins.enable("my_plugin", context)
+ if plugin:
+ self._install_plugin(plugin)
+
+ self._plugins.disable("my_plugin")
+ self._uninstall_plugin("my_plugin")
+ """
+
+ def __init__(self, plugins_dir: str):
+ self._dir = plugins_dir
+ self._enabled_path = os.path.join(plugins_dir, _ENABLED_FILE)
+ self._manifests: Dict[str, PluginManifest] = {}
+ self._loaded: Dict[str, LabPlugin] = {}
+ self._enabled: Dict[str, bool] = {}
+ self._load_enabled_state()
+
+ # ── Discovery ─────────────────────────────────────────────────────────
+
+ def discover(self) -> List[PluginManifest]:
+ """Scan plugin directory and return all found manifests."""
+ self._manifests.clear()
+ if not os.path.isdir(self._dir):
+ return []
+
+ for entry in sorted(os.listdir(self._dir)):
+ plugin_dir = os.path.join(self._dir, entry)
+ if not os.path.isdir(plugin_dir):
+ continue
+ manifest_path = os.path.join(plugin_dir, _MANIFEST_FILE)
+ if not os.path.isfile(manifest_path):
+ continue
+ try:
+ with open(manifest_path) as f:
+ data = json.load(f)
+ m = PluginManifest(
+ plugin_id = data["plugin_id"],
+ name = data.get("name", entry),
+ version = data.get("version", "1.0.0"),
+ description = data.get("description", ""),
+ author = data.get("author", ""),
+ entry_point = data.get("entry_point", "plugin.Plugin"),
+ plugin_dir = plugin_dir,
+ )
+ self._manifests[m.plugin_id] = m
+ except Exception as exc:
+ print(f"[PluginManager] Bad manifest in '{entry}': {exc}")
+
+ return list(self._manifests.values())
+
+ # ── Enabled state ─────────────────────────────────────────────────────
+
+ def _load_enabled_state(self):
+ if os.path.isfile(self._enabled_path):
+ try:
+ with open(self._enabled_path) as f:
+ self._enabled = json.load(f)
+ except Exception:
+ self._enabled = {}
+
+ def _save_enabled_state(self):
+ os.makedirs(self._dir, exist_ok=True)
+ with open(self._enabled_path, "w") as f:
+ json.dump(self._enabled, f, indent=2)
+
+ def is_enabled(self, plugin_id: str) -> bool:
+ return self._enabled.get(plugin_id, False)
+
+ def get_enabled_ids(self) -> List[str]:
+ return [pid for pid, on in self._enabled.items() if on]
+
+ # ── Load / unload ─────────────────────────────────────────────────────
+
+ def load_enabled(self, context: PluginContext) -> List[LabPlugin]:
+ """Load all enabled plugins that have a discovered manifest."""
+ result = []
+ for plugin_id in self.get_enabled_ids():
+ if plugin_id not in self._manifests:
+ continue
+ p = self._load_plugin(plugin_id, context)
+ if p:
+ result.append(p)
+ return result
+
+ def _load_plugin(self, plugin_id: str,
+ context: PluginContext) -> Optional[LabPlugin]:
+ if plugin_id in self._loaded:
+ return self._loaded[plugin_id]
+
+ manifest = self._manifests.get(plugin_id)
+ if not manifest:
+ print(f"[PluginManager] No manifest for '{plugin_id}'")
+ return None
+
+ module_name, class_name = manifest.entry_point.rsplit(".", 1)
+ module_file = os.path.join(
+ manifest.plugin_dir, *module_name.split("/")
+ ) + ".py"
+
+ # Each stage named so errors are unambiguous
+ stage = "locating module file"
+ try:
+ if not os.path.isfile(module_file):
+ raise FileNotFoundError(f"'{module_file}' does not exist")
+
+ stage = "importing module"
+ spec = importlib.util.spec_from_file_location(
+ f"_labdaq_plugin_{plugin_id}", module_file
+ )
+ mod = importlib.util.module_from_spec(spec)
+ if manifest.plugin_dir not in sys.path:
+ sys.path.insert(0, manifest.plugin_dir)
+ spec.loader.exec_module(mod)
+
+ stage = f"finding class '{class_name}'"
+ if not hasattr(mod, class_name):
+ raise AttributeError(
+ f"'{module_file}' has no class '{class_name}'"
+ )
+ cls = getattr(mod, class_name)
+
+ stage = "instantiating plugin class"
+ plugin: LabPlugin = cls()
+
+ stage = "calling on_load()"
+ plugin.on_load(context)
+
+ self._loaded[plugin_id] = plugin
+ print(f"[Plugin] Loaded '{plugin.name}' v{plugin.version}")
+ return plugin
+
+ except Exception as exc:
+ print(f"[Plugin] ERROR — could not load '{plugin_id}' "
+ f"(failed at {stage}): {exc}")
+ traceback.print_exc()
+ return None
+
+ def _unload_plugin(self, plugin_id: str):
+ plugin = self._loaded.pop(plugin_id, None)
+ if plugin is None:
+ return
+ try:
+ plugin.on_unload()
+ except Exception as exc:
+ print(f"[Plugin] ERROR — '{plugin_id}' on_unload() raised: {exc}")
+ traceback.print_exc()
+ print(f"[Plugin] Unloaded '{plugin_id}'")
+
+ # ── Public API ────────────────────────────────────────────────────────
+
+ def enable(self, plugin_id: str,
+ context: PluginContext) -> Optional[LabPlugin]:
+ """Mark enabled, persist, load and return the plugin (or None on error)."""
+ self._enabled[plugin_id] = True
+ self._save_enabled_state()
+ return self._load_plugin(plugin_id, context)
+
+ def disable(self, plugin_id: str):
+ """Unload the plugin and persist the disabled state."""
+ self._enabled[plugin_id] = False
+ self._save_enabled_state()
+ self._unload_plugin(plugin_id)
+
+ def get_manifests(self) -> List[PluginManifest]:
+ return list(self._manifests.values())
+
+ def get_loaded(self) -> List[LabPlugin]:
+ return list(self._loaded.values())
+
+ def get_plugin(self, plugin_id: str) -> Optional[LabPlugin]:
+ return self._loaded.get(plugin_id)
diff --git a/ui/main_window.py b/ui/main_window.py
index 0bb62f6..a4b62e9 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -43,6 +43,9 @@ from ui.windows.signals_window import SignalsWindow
from ui.windows.plot_window import PlotWindow, build_default_layout
from ui.windows.settings_window import SettingsWindow
+from plugins.plugin_manager import PluginManager
+from plugins.base_plugin import PluginContext
+
_DARK_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_dark.qss")
_LIGHT_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_light.qss")
@@ -65,12 +68,20 @@ class MainWindow(QMainWindow):
self._win_plot = None
self._win_settings = None
+ _plugins_dir = os.path.join(os.path.dirname(os.path.dirname(
+ os.path.abspath(__file__))), "plugins")
+ self._plugin_mgr = PluginManager(_plugins_dir)
+ self._plugin_mgr.discover()
+ # {plugin_id: [QAction, ...]} toolbar actions to remove on unload
+ self._plugin_toolbar_actions: dict = {}
+
self._wheel_blocker = _WheelBlocker(self)
QApplication.instance().installEventFilter(self._wheel_blocker)
self._init_demo_devices()
self._build_ui()
self._connect_signals()
+ self._init_plugins()
# ── Demo ──────────────────────────────────────────────────────────────
@@ -89,6 +100,7 @@ class MainWindow(QMainWindow):
def _build_ui(self):
tb = QToolBar(); tb.setObjectName("mainToolbar"); tb.setMovable(False)
self.addToolBar(tb)
+ self._toolbar = tb
def _sep():
s = QFrame(); s.setFrameShape(QFrame.Shape.VLine)
@@ -138,11 +150,14 @@ class MainWindow(QMainWindow):
tb.addWidget(_sep())
+ # Plugin buttons are inserted here at runtime (between this sep and spacer)
+ self._plugin_sep_action = tb.addWidget(_sep())
+ self._plugin_sep_action.setVisible(False)
- # Spacer + clock
+ # Spacer + clock — plugin buttons insert before this action
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
- tb.addWidget(spacer)
+ self._spacer_action = tb.addWidget(spacer)
self._time_lbl = QLabel("00:00:00"); self._time_lbl.setObjectName("timeLabel")
tb.addWidget(self._time_lbl)
@@ -185,6 +200,108 @@ class MainWindow(QMainWindow):
self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● {p}"))
self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ {p}"))
+ # ── Plugin lifecycle ──────────────────────────────────────────────────
+
+ def _make_plugin_context(self) -> PluginContext:
+ return PluginContext(
+ registry=self.registry,
+ engine=self.engine,
+ processor=self.processor,
+ main_window=self,
+ )
+
+ def _init_plugins(self):
+ ctx = self._make_plugin_context()
+ for plugin in self._plugin_mgr.load_enabled(ctx):
+ self._install_plugin(plugin)
+
+ def _install_plugin(self, plugin):
+ """Register a loaded plugin's devices, filters, and toolbar buttons."""
+ from core.signal_processor import FILTER_CLASSES
+
+ # Devices
+ for dev in plugin.get_devices():
+ try:
+ dev.connect()
+ self.registry.add_instance(dev)
+ self.engine.add_device(dev)
+ except Exception as exc:
+ print(f"[Plugin:{plugin.plugin_id}] Device error: {exc}")
+
+ # Custom filter classes
+ for name, cls in plugin.get_filter_classes().items():
+ FILTER_CLASSES[name] = cls
+
+ # Toolbar buttons
+ actions = plugin.get_toolbar_actions()
+ tb_actions = []
+ if actions:
+ self._plugin_sep_action.setVisible(True)
+ for pa in actions:
+ label = f"{pa.icon} {pa.label}" if pa.icon else pa.label
+ btn = QPushButton(label)
+ btn.setObjectName("toolbarSectionBtn")
+ btn.setCheckable(pa.checkable)
+ if pa.tooltip:
+ btn.setToolTip(pa.tooltip)
+ btn.clicked.connect(pa.callback)
+ action = self._toolbar.insertWidget(self._spacer_action, btn)
+ tb_actions.append(action)
+ self._plugin_toolbar_actions[plugin.plugin_id] = tb_actions
+
+ self._chart.refresh()
+ if self._win_plot:
+ self._win_plot.refresh_channels()
+ self._status.setText(f"Plugin enabled: {plugin.name}")
+
+ def _uninstall_plugin(self, plugin_id: str):
+ """Remove a plugin's toolbar buttons, devices, and filter classes."""
+ from core.signal_processor import FILTER_CLASSES
+
+ plugin = self._plugin_mgr.get_plugin(plugin_id)
+
+ # Remove toolbar buttons first (before unload)
+ for action in self._plugin_toolbar_actions.pop(plugin_id, []):
+ self._toolbar.removeAction(action)
+
+ # Hide plugin separator if no plugins remain
+ if not any(acts for acts in self._plugin_toolbar_actions.values()):
+ self._plugin_sep_action.setVisible(False)
+
+ if plugin is None:
+ return
+
+ # Remove custom filter classes
+ for name in plugin.get_filter_classes():
+ FILTER_CLASSES.pop(name, None)
+
+ # Remove devices contributed by this plugin
+ for dev in plugin.get_devices():
+ dev_id = dev.info.device_id
+ try:
+ dev.disconnect()
+ except Exception:
+ pass
+ self.registry.remove_instance(dev_id)
+ self.engine.remove_device(dev_id)
+
+ self._chart.refresh()
+ if self._win_plot:
+ self._win_plot.refresh_channels()
+ self._status.setText(f"Plugin disabled: {plugin.name}")
+
+ def plugin_enable(self, plugin_id: str):
+ """Called by SettingsWindow when user enables a plugin."""
+ ctx = self._make_plugin_context()
+ plugin = self._plugin_mgr.enable(plugin_id, ctx)
+ if plugin:
+ self._install_plugin(plugin)
+
+ def plugin_disable(self, plugin_id: str):
+ """Called by SettingsWindow when user disables a plugin."""
+ self._uninstall_plugin(plugin_id)
+ self._plugin_mgr.disable(plugin_id)
+
# ── Window management ─────────────────────────────────────────────────
def _toggle_win(self, name: str, checked: bool, btn: QPushButton):
@@ -239,9 +356,12 @@ class MainWindow(QMainWindow):
def _open_settings(self):
if self._win_settings is None:
self._win_settings = SettingsWindow(self.registry, self.engine,
- self._settings, self)
+ self._settings, self,
+ plugin_manager=self._plugin_mgr)
self._win_settings.theme_changed.connect(self._apply_theme)
self._win_settings.settings_changed.connect(self._on_settings)
+ self._win_settings.plugin_enable_requested.connect(self.plugin_enable)
+ self._win_settings.plugin_disable_requested.connect(self.plugin_disable)
self._win_settings.closed.connect(lambda: self._btn_settings.setChecked(False))
self._show_win(self._win_settings, "right")
@@ -297,10 +417,21 @@ class MainWindow(QMainWindow):
control_specs=self._ctrl.get_specs(),
settings=self._settings,
profile_name=name,
+ plugin_manager=self._plugin_mgr,
)
def _profile_apply(self, profile: Profile):
"""Restore state from a Profile object."""
+ # Reconcile plugin enabled state before the rest of apply runs,
+ # so plugin devices are present when channels/pipelines are restored.
+ if profile.plugins_enabled is not None:
+ wanted = set(profile.plugins_enabled)
+ current = set(self._plugin_mgr.get_enabled_ids())
+ for pid in current - wanted:
+ self.plugin_disable(pid)
+ for pid in wanted - current:
+ self.plugin_enable(pid)
+
plot_cfg = ProfileManager.apply(
profile=profile,
registry=self.registry,
@@ -308,6 +439,7 @@ class MainWindow(QMainWindow):
control_panel=self._ctrl,
settings_ref=self._settings,
engine=self.engine,
+ plugin_manager=self._plugin_mgr,
)
if plot_cfg:
self._chart.apply_layout(plot_cfg)
@@ -400,4 +532,9 @@ class MainWindow(QMainWindow):
for w in (self._win_devices, self._win_signals,
self._win_plot, self._win_settings):
if w: w.close()
+ for plugin in list(self._plugin_mgr.get_loaded()):
+ try:
+ plugin.on_unload()
+ except Exception:
+ pass
self.engine.stop(); event.accept()
diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py
index 9d7b7bc..046cfae 100644
--- a/ui/windows/settings_window.py
+++ b/ui/windows/settings_window.py
@@ -23,9 +23,11 @@ from core.acquisition import AcquisitionEngine
class SettingsWindow(QWidget):
- theme_changed = pyqtSignal(str) # "dark" | "light"
- settings_changed = pyqtSignal(dict)
- closed = pyqtSignal()
+ theme_changed = pyqtSignal(str) # "dark" | "light"
+ settings_changed = pyqtSignal(dict)
+ plugin_enable_requested = pyqtSignal(str) # plugin_id
+ plugin_disable_requested= pyqtSignal(str) # plugin_id
+ closed = pyqtSignal()
# Shared settings dict — written on Apply, read by consumers
_defaults = {
@@ -42,11 +44,13 @@ class SettingsWindow(QWidget):
def __init__(self, registry: DeviceRegistry,
engine: AcquisitionEngine,
- current: dict = None, parent=None):
+ current: dict = None, parent=None, *,
+ plugin_manager=None):
super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool)
- self.registry = registry
- self.engine = engine
- self.cfg = dict(self._defaults)
+ self.registry = registry
+ self.engine = engine
+ self._plugin_mgr = plugin_manager
+ self.cfg = dict(self._defaults)
if current:
self.cfg.update(current)
@@ -72,6 +76,8 @@ class SettingsWindow(QWidget):
tabs.addTab(self._general_tab(), " General ")
tabs.addTab(self._acquisition_tab(), " Acquisition ")
tabs.addTab(self._display_tab(), " Display ")
+ tabs.addTab(self._controls_tab(), " Controls ")
+ tabs.addTab(self._plugins_tab(), " Plugins ")
# Bottom bar
btm = QWidget(); btm.setObjectName("cfgBottomBar")
@@ -157,6 +163,131 @@ class SettingsWindow(QWidget):
root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll)
return w
+ def _controls_tab(self):
+ """Output channel assignments — which device channel each control widget drives."""
+ w = QWidget()
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
+ scroll.setObjectName("deviceScroll")
+ cont = QWidget(); lay = QVBoxLayout(cont)
+ lay.setContentsMargins(14,12,14,12); lay.setSpacing(8)
+
+ info = QLabel(
+ "Configure which physical output channels are driven by each control widget.\n"
+ "Add output widget mappings below. Changes take effect on next app start."
+ )
+ info.setObjectName("traceSource"); info.setWordWrap(True)
+ lay.addWidget(info)
+
+ grp = QGroupBox("Output Assignments")
+ g_lay = QFormLayout(grp); g_lay.setSpacing(6)
+
+ # Collect digital output channels
+ out_channels = ["— none —"]
+ for dev in self.registry.all_instances():
+ for ch in dev.info.channels:
+ if ch.channel_id.startswith("do") or "out" in ch.channel_id.lower():
+ out_channels.append(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})")
+
+ self._out_combos = {}
+ for label in ["Pump Power", "Heater", "Motor Enable", "PWM Ch 1"]:
+ cb = QComboBox(); cb.setObjectName("channelPickerCb")
+ cb.addItems(out_channels)
+ g_lay.addRow(f"{label}:", cb)
+ self._out_combos[label] = cb
+
+ lay.addWidget(grp)
+ lay.addStretch()
+ scroll.setWidget(cont)
+ root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll)
+ return w
+
+ def _plugins_tab(self):
+ w = QWidget()
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
+ scroll.setObjectName("deviceScroll")
+ cont = QWidget(); lay = QVBoxLayout(cont)
+ lay.setContentsMargins(14, 12, 14, 12); lay.setSpacing(10)
+
+ if self._plugin_mgr is None:
+ lay.addWidget(QLabel("Plugin manager not available."))
+ lay.addStretch()
+ scroll.setWidget(cont)
+ root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0)
+ root.addWidget(scroll); return w
+
+ manifests = self._plugin_mgr.get_manifests()
+
+ if not manifests:
+ info = QLabel(
+ "No plugins found.\n\n"
+ "Drop a plugin folder into the plugins/ directory next to main.py.\n"
+ "Each plugin needs a manifest.json and a plugin.py."
+ )
+ info.setObjectName("traceSource"); info.setWordWrap(True)
+ lay.addWidget(info)
+ lay.addStretch()
+ scroll.setWidget(cont)
+ root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0)
+ root.addWidget(scroll); return w
+
+ for manifest in manifests:
+ lay.addWidget(self._plugin_card(manifest))
+
+ lay.addStretch()
+ scroll.setWidget(cont)
+ root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0)
+ root.addWidget(scroll); return w
+
+ def _plugin_card(self, manifest):
+ """One card per discovered plugin."""
+ card = QGroupBox()
+ card.setObjectName("pluginCard")
+ cl = QVBoxLayout(card); cl.setContentsMargins(10, 8, 10, 8); cl.setSpacing(4)
+
+ # Header row: name + version + enable toggle
+ hdr = QHBoxLayout()
+ name_lbl = QLabel(f"<b>{manifest.name}</b> <small>v{manifest.version}</small>")
+ name_lbl.setObjectName("traceLabel")
+ hdr.addWidget(name_lbl, 1)
+
+ enabled = self._plugin_mgr.is_enabled(manifest.plugin_id)
+ toggle = QPushButton("Disable" if enabled else "Enable")
+ toggle.setObjectName("configButton")
+ toggle.setFixedWidth(72)
+ toggle.clicked.connect(
+ lambda _, pid=manifest.plugin_id, btn=toggle: self._toggle_plugin(pid, btn)
+ )
+ hdr.addWidget(toggle)
+ cl.addLayout(hdr)
+
+ # Description / author
+ if manifest.description:
+ desc = QLabel(manifest.description)
+ desc.setObjectName("traceSource"); desc.setWordWrap(True)
+ cl.addWidget(desc)
+
+ if manifest.author:
+ author = QLabel(f"Author: {manifest.author}")
+ author.setObjectName("traceSource")
+ cl.addWidget(author)
+
+ # Plugin-specific settings widget (only when loaded)
+ plugin = self._plugin_mgr.get_plugin(manifest.plugin_id)
+ if plugin:
+ sw = plugin.get_settings_widget()
+ if sw is not None:
+ cl.addWidget(sw)
+
+ return card
+
+ def _toggle_plugin(self, plugin_id: str, btn: QPushButton):
+ if self._plugin_mgr.is_enabled(plugin_id):
+ self.plugin_disable_requested.emit(plugin_id)
+ btn.setText("Enable")
+ else:
+ self.plugin_enable_requested.emit(plugin_id)
+ btn.setText("Disable")
+
# ── Actions ───────────────────────────────────────────────────────────
def _browse_log(self):