summaryrefslogtreecommitdiff
path: root/ui/control_panel.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-09-21 00:16:15 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-09-21 00:16:15 -0600
commit4ab2eea9af5338485303d421476fa874e5af6841 (patch)
tree43aee9e91d25e854fd215a7c9ee7fdc003e1c854 /ui/control_panel.py
parenta0473245f1f453bfc87ea1bc9bd479311e8a944b (diff)
Fix Buffer size setting never being applied; add Safety Switch control type
- 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 <noreply@anthropic.com>
Diffstat (limited to 'ui/control_panel.py')
-rw-r--r--ui/control_panel.py59
1 files changed, 55 insertions, 4 deletions
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:
@@ -349,6 +361,37 @@ class OnOffSwitch(ControlWidget):
# ══════════════════════════════════════════════════════════════════════════════
+# 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":