diff options
Diffstat (limited to 'ui/control_panel.py')
| -rw-r--r-- | ui/control_panel.py | 59 |
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": |
