summaryrefslogtreecommitdiff
path: root/plugins
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 /plugins
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>
Diffstat (limited to 'plugins')
-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
13 files changed, 485 insertions, 0 deletions
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)