diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-05-07 13:02:34 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-06-03 09:50:17 -0600 |
| commit | deb3ad65d25d4c3167a3cbeca2ac19b5c45b0627 (patch) | |
| tree | 131eebcabfab2d0a19e52aec21a21ad0a24775fa | |
| parent | 2b9943ba0d28449a590acfa8a41555b174b6ba84 (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.py | 66 | ||||
| -rw-r--r-- | core/signal_processor.py | 10 | ||||
| -rw-r--r-- | docs/plugin-development.md | 546 | ||||
| -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 | ||||
| -rw-r--r-- | ui/main_window.py | 143 | ||||
| -rw-r--r-- | ui/windows/settings_window.py | 145 |
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 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) 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): |
