From deb3ad65d25d4c3167a3cbeca2ac19b5c45b0627 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Thu, 7 May 2026 13:02:34 -0600 Subject: Added plugin/add-on system Plugins live in plugins// 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 --- ui/main_window.py | 143 ++++++++++++++++++++++++++++++++++++++++- ui/windows/settings_window.py | 145 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 278 insertions(+), 10 deletions(-) (limited to 'ui') 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"{manifest.name} v{manifest.version}") + 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): -- cgit v1.2.3