summaryrefslogtreecommitdiff
path: root/ui
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
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')
-rw-r--r--ui/control_editor.py5
-rw-r--r--ui/control_panel.py59
-rw-r--r--ui/main_window.py2
-rw-r--r--ui/style_dark.qss1
-rw-r--r--ui/style_light.qss1
5 files changed, 63 insertions, 5 deletions
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:
@@ -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":
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; }