From 4ab2eea9af5338485303d421476fa874e5af6841 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 21 Sep 2026 00:16:15 -0600 Subject: Fix Buffer size setting never being applied; add Safety Switch control type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Buffer size (samples) in Settings > Acquisition was captured into the settings dict but never read anywhere — SignalBuffer always used the hardcoded MAX_BUFFER=20_000 constant (which happens to match the dialog's default, hence it looking like the value "reverts"). Add AcquisitionEngine.set_buffer_size(), wired in on startup and on Settings apply; existing buffers are rebuilt at the new size in place, keeping their most recent samples. - New "Safety Switch" control type: an On/Off Switch that's disabled outright until acquisition is running, with a lock icon shown while disabled. AcquisitionEngine gains a run_state_changed(bool) signal (emitted from start()/stop()); ControlWidget gains a _check_safety_lock() hook (no-op by default, called uniformly by ControlPanel on every widget) that SafetySwitch overrides. engine is now threaded through to every ControlWidget for this. Persists via ControlSpec.control_type alone — no schema change needed. Co-Authored-By: Claude Sonnet 5 --- ui/control_editor.py | 5 ++++- ui/control_panel.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++---- ui/main_window.py | 2 ++ ui/style_dark.qss | 1 + ui/style_light.qss | 1 + 5 files changed, 63 insertions(+), 5 deletions(-) (limited to 'ui') diff --git a/ui/control_editor.py b/ui/control_editor.py index 40cde7e..e62e146 100644 --- a/ui/control_editor.py +++ b/ui/control_editor.py @@ -40,6 +40,7 @@ from devices.device_registry import DeviceRegistry CONTROL_TYPES = [ "On/Off Switch", + "Safety Switch", "Motor Control", "PWM Output", "Setpoint", @@ -48,6 +49,7 @@ CONTROL_TYPES = [ CONTROL_ICONS = { "On/Off Switch": "⏻", + "Safety Switch": "🔒", "Motor Control": "⟳", "PWM Output": "⊓", "Setpoint": "◎", @@ -160,6 +162,7 @@ class AnalogOutParams(QWidget): _PARAM_PANELS = { "On/Off Switch": OnOffParams, + "Safety Switch": OnOffParams, # same shape as On/Off — no extra params "Motor Control": MotorParams, "PWM Output": PwmParams, "Setpoint": SetpointParams, @@ -347,7 +350,7 @@ class ControlEditorDialog(QDialog): self._stack.setCurrentIndex(idx) self._stack.updateGeometry() self._params_grp.updateGeometry() - self._active_low_chk.setEnabled(type_name == "On/Off Switch") + self._active_low_chk.setEnabled(type_name in ("On/Off Switch", "Safety Switch")) # Auto-set title if still default if not self._title_edit.text() or \ self._title_edit.text() in CONTROL_TYPES: diff --git a/ui/control_panel.py b/ui/control_panel.py index 830b985..e938ceb 100644 --- a/ui/control_panel.py +++ b/ui/control_panel.py @@ -126,7 +126,8 @@ class ControlWidget(QFrame): registry: Optional[DeviceRegistry] = None, active_low: bool = False, processor=None, - on_action_script: str = ""): + on_action_script: str = "", + engine=None): super().__init__() self.title = title self.icon = icon @@ -135,6 +136,9 @@ class ControlWidget(QFrame): self.registry = registry self.active_low = active_low self._processor = processor + self.engine = engine # AcquisitionEngine — only SafetySwitch uses this + # today (run_state_changed-driven interlock), but + # every widget gets it for future control types. self._on_action_fn = None if on_action_script.strip(): self._compile_action(on_action_script) @@ -142,6 +146,14 @@ class ControlWidget(QFrame): self._build_frame() self._stale = None self._check_stale() + self._check_safety_lock() + + def _check_safety_lock(self): + """No-op for regular controls — overridden by SafetySwitch to + enable/disable itself based on self.engine.is_running(). Called + uniformly on every widget from ControlPanel._on_run_state_changed() + so callers never need an isinstance check.""" + pass def _compile_action(self, script: str): try: @@ -348,6 +360,37 @@ class OnOffSwitch(ControlWidget): self._btn.setChecked(False) # routes through _on_toggle: updates UI + writes off +# ══════════════════════════════════════════════════════════════════════════════ +# Safety Switch — On/Off Switch, but disabled until acquisition is running +# ══════════════════════════════════════════════════════════════════════════════ + +class SafetySwitch(OnOffSwitch): + """On/Off Switch that's disabled (can't be pressed at all) until + acquisition is running — physical safety interlock so a critical/ + dangerous output can't be armed before the system starts actively + monitoring, or left armable after it's stopped. Shows a lock icon in + its header while disabled. Wired to AcquisitionEngine.run_state_changed + via ControlPanel._on_run_state_changed(), which calls every widget's + _check_safety_lock() uniformly — see ControlWidget's base (no-op) + implementation.""" + + def __init__(self, title: str = "Safety", icon: str = "🔒", **kw): + super().__init__(title, icon=icon, **kw) + self._lock_lbl = QLabel("🔒") + self._lock_lbl.setObjectName("controlWidgetSafetyLock") + self._lock_lbl.setToolTip("Locked — only active while acquisition is running") + self._header_layout.addWidget(self._lock_lbl) + self._check_safety_lock() + + def _check_safety_lock(self): + if not hasattr(self, "_lock_lbl"): + return # base ControlWidget.__init__ calls this before our own + # __init__ (and OnOffSwitch's _build_body()) has run + running = bool(self.engine and self.engine.is_running()) + self._btn.setEnabled(running) + self._lock_lbl.setVisible(not running) + + # ══════════════════════════════════════════════════════════════════════════════ # Motor Control # ══════════════════════════════════════════════════════════════════════════════ @@ -738,11 +781,16 @@ class ControlPanel(QWidget): self._build() if engine is not None: engine.device_status_changed.connect(self._on_device_status_changed) + engine.run_state_changed.connect(self._on_run_state_changed) def _on_device_status_changed(self, device_id: str, status: str): for w in self._widgets: w._check_stale() + def _on_run_state_changed(self, running: bool): + for w in self._widgets: + w._check_safety_lock() + # ── Layout ─────────────────────────────────────────────────────────────── def _build(self): @@ -793,7 +841,7 @@ class ControlPanel(QWidget): def _add_widget_from_spec(self, spec): """Instantiate a ControlWidget from a ControlSpec and add to panel.""" - widget = _build_widget_from_spec(spec, self.registry, self.processor) + widget = _build_widget_from_spec(spec, self.registry, self.processor, self.engine) if widget is None: return self._wire_widget(widget, spec) @@ -897,7 +945,7 @@ class ControlPanel(QWidget): self._widgets.pop(idx); self._specs.pop(idx) # Insert new one at same position - new_widget = _build_widget_from_spec(new_spec, self.registry, self.processor) + new_widget = _build_widget_from_spec(new_spec, self.registry, self.processor, self.engine) if new_widget is None: return self._wire_widget(new_widget, new_spec) @@ -931,7 +979,7 @@ class ControlPanel(QWidget): # ── Factory — build a ControlWidget from a ControlSpec ───────────────────────── def _build_widget_from_spec(spec, registry: DeviceRegistry, - processor=None) -> Optional[ControlWidget]: + processor=None, engine=None) -> Optional[ControlWidget]: """Instantiate the right ControlWidget subclass from a ControlSpec.""" # Base kwargs — icon is NOT included here; it's passed explicitly below # so subclasses never get a double-value collision. @@ -942,12 +990,15 @@ def _build_widget_from_spec(spec, registry: DeviceRegistry, active_low=getattr(spec, "active_low", False), processor=processor, on_action_script=getattr(spec, "on_action_script", ""), + engine=engine, ) t = spec.control_type ttl = spec.title ico = spec.icon if t == "On/Off Switch": return OnOffSwitch(title=ttl, icon=ico, **base_kw) + elif t == "Safety Switch": + return SafetySwitch(title=ttl, icon=ico, **base_kw) elif t == "Motor Control": return MotorControl(title=ttl, icon=ico, max_rpm=spec.max_rpm, **base_kw) elif t == "PWM Output": diff --git a/ui/main_window.py b/ui/main_window.py index 6dae1be..07ab0b2 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -827,6 +827,7 @@ class MainWindow(QMainWindow): def _apply_settings_on_startup(self): self._apply_theme(self._settings.get("theme", "dark")) self.engine._interval = self._settings.get("poll_ms", 100) / 1000.0 + self.engine.set_buffer_size(self._settings.get("buffer_size", 20000)) self._apply_developer_mode(self._settings.get("developer_mode", False)) QTimer.singleShot(0, self._apply_dock_state) @@ -862,6 +863,7 @@ class MainWindow(QMainWindow): def _on_settings(self, cfg: dict): self._settings.update(cfg) self.engine._interval = cfg.get("poll_ms", 100) / 1000.0 + self.engine.set_buffer_size(cfg.get("buffer_size", 20000)) self._apply_developer_mode(cfg.get("developer_mode", False)) save_settings(self._settings) diff --git a/ui/style_dark.qss b/ui/style_dark.qss index 08b82e9..3a20cba 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -420,6 +420,7 @@ QWidget#controlWidgetHeader { background-color: #0f1521; border-radius: 5px 5px QLabel#controlWidgetIcon { font-size: 14px; color: #3b82f6; padding-right: 4px; } QLabel#controlWidgetTitle { font-family: "IBM Plex Mono", monospace; font-size: 11px; font-weight: 700; letter-spacing: 1.5px; color: #8b9dc3; } QLabel#controlWidgetStale { color: #f59e0b; font-size: 12px; padding: 0 2px; } +QLabel#controlWidgetSafetyLock { color: #64748b; font-size: 12px; padding: 0 2px; } QWidget#controlWidgetBody { background-color: #161d2e; border-radius: 0 0 5px 5px; } QFrame#controlWidget[stale="true"] QWidget#controlWidgetBody { background-color: #1c1710; } diff --git a/ui/style_light.qss b/ui/style_light.qss index b9c8b62..1bed344 100644 --- a/ui/style_light.qss +++ b/ui/style_light.qss @@ -83,6 +83,7 @@ QWidget#controlWidgetHeader { background:#f1f5f9; border-radius:5px 5px 0 0; bor QLabel#controlWidgetIcon { font-size:14px; color:#3b82f6; padding-right:4px; } QLabel#controlWidgetTitle { font-family:"IBM Plex Mono",monospace; font-size:11px; font-weight:700; letter-spacing:1.5px; color:#64748b; } QLabel#controlWidgetStale { color:#d97706; font-size:12px; padding:0 2px; } +QLabel#controlWidgetSafetyLock { color:#64748b; font-size:12px; padding:0 2px; } QWidget#controlWidgetBody { background:#ffffff; border-radius:0 0 5px 5px; } QFrame#controlWidget[stale="true"] QWidget#controlWidgetBody { background:#fffbeb; } -- cgit v1.2.3