summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/main_window.py143
-rw-r--r--ui/windows/settings_window.py145
2 files changed, 278 insertions, 10 deletions
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):