diff options
Diffstat (limited to 'plugins')
| -rw-r--r-- | plugins/__init__.py | 11 | ||||
| -rw-r--r-- | plugins/__pycache__/__init__.cpython-312.pyc | bin | 0 -> 422 bytes | |||
| -rw-r--r-- | plugins/__pycache__/__init__.cpython-314.pyc | bin | 0 -> 424 bytes | |||
| -rw-r--r-- | plugins/__pycache__/base_plugin.cpython-312.pyc | bin | 0 -> 5620 bytes | |||
| -rw-r--r-- | plugins/__pycache__/base_plugin.cpython-314.pyc | bin | 0 -> 7450 bytes | |||
| -rw-r--r-- | plugins/__pycache__/plugin_manager.cpython-312.pyc | bin | 0 -> 10714 bytes | |||
| -rw-r--r-- | plugins/__pycache__/plugin_manager.cpython-314.pyc | bin | 0 -> 13351 bytes | |||
| -rw-r--r-- | plugins/base_plugin.py | 133 | ||||
| -rw-r--r-- | plugins/enabled.json | 3 | ||||
| -rw-r--r-- | plugins/example/__pycache__/plugin.cpython-312.pyc | bin | 0 -> 3549 bytes | |||
| -rw-r--r-- | plugins/example/manifest.json | 8 | ||||
| -rw-r--r-- | plugins/example/plugin.py | 105 | ||||
| -rw-r--r-- | plugins/plugin_manager.py | 225 |
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 Binary files differnew file mode 100644 index 0000000..1d4680f --- /dev/null +++ b/plugins/__pycache__/__init__.cpython-312.pyc diff --git a/plugins/__pycache__/__init__.cpython-314.pyc b/plugins/__pycache__/__init__.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..52a1579 --- /dev/null +++ b/plugins/__pycache__/__init__.cpython-314.pyc diff --git a/plugins/__pycache__/base_plugin.cpython-312.pyc b/plugins/__pycache__/base_plugin.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..08d0af2 --- /dev/null +++ b/plugins/__pycache__/base_plugin.cpython-312.pyc diff --git a/plugins/__pycache__/base_plugin.cpython-314.pyc b/plugins/__pycache__/base_plugin.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..29b49d4 --- /dev/null +++ b/plugins/__pycache__/base_plugin.cpython-314.pyc diff --git a/plugins/__pycache__/plugin_manager.cpython-312.pyc b/plugins/__pycache__/plugin_manager.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..6aaf365 --- /dev/null +++ b/plugins/__pycache__/plugin_manager.cpython-312.pyc diff --git a/plugins/__pycache__/plugin_manager.cpython-314.pyc b/plugins/__pycache__/plugin_manager.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..ad55c5f --- /dev/null +++ b/plugins/__pycache__/plugin_manager.cpython-314.pyc 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 Binary files differnew file mode 100644 index 0000000..c31b466 --- /dev/null +++ b/plugins/example/__pycache__/plugin.cpython-312.pyc 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) |
