diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-09-21 00:16:15 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-09-21 00:16:15 -0600 |
| commit | 4ab2eea9af5338485303d421476fa874e5af6841 (patch) | |
| tree | 43aee9e91d25e854fd215a7c9ee7fdc003e1c854 | |
| parent | a0473245f1f453bfc87ea1bc9bd479311e8a944b (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>
| -rw-r--r-- | core/acquisition.py | 26 | ||||
| -rw-r--r-- | ui/control_editor.py | 5 | ||||
| -rw-r--r-- | ui/control_panel.py | 59 | ||||
| -rw-r--r-- | ui/main_window.py | 2 | ||||
| -rw-r--r-- | ui/style_dark.qss | 1 | ||||
| -rw-r--r-- | ui/style_light.qss | 1 |
6 files changed, 87 insertions, 7 deletions
diff --git a/core/acquisition.py b/core/acquisition.py index eeeb4c8..c900adf 100644 --- a/core/acquisition.py +++ b/core/acquisition.py @@ -77,6 +77,7 @@ class AcquisitionEngine(QObject): # switched lb/kgF/N/ozF on its own front panel log_started(filepath) log_stopped(filepath) + run_state_changed(running) # emitted from start()/stop() """ new_data = pyqtSignal(float, list) @@ -84,10 +85,14 @@ class AcquisitionEngine(QObject): channel_unit_changed = pyqtSignal(str, str, str) log_started = pyqtSignal(str) log_stopped = pyqtSignal(str) + run_state_changed = pyqtSignal(bool) # True on start(), False on stop() — + # e.g. ControlPanel's safety-interlock + # controls listen for this def __init__(self, poll_interval_ms: int = 100): super().__init__() self._interval = poll_interval_ms / 1000.0 + self._buffer_size: int = MAX_BUFFER # see set_buffer_size() self._devices: List[BaseDevice] = [] self._buffers: Dict[str, Dict[str, SignalBuffer]] = {} self._last_units: Dict[Tuple[str, str], str] = {} @@ -106,12 +111,27 @@ class AcquisitionEngine(QObject): def add_device(self, dev: BaseDevice): self._devices.append(dev) self._buffers[dev.info.device_id] = { - ch.signal_id: SignalBuffer() + ch.signal_id: SignalBuffer(maxlen=self._buffer_size) for ch in dev.info.signals } for ch in dev.info.signals: self._last_units[(dev.info.device_id, ch.signal_id)] = ch.unit + def set_buffer_size(self, n: int): + """Change how much history each Signal's buffer holds — settings_ + window.py's "Buffer size (samples)" field, applied live rather than + only on the next device add/reconfigure. Rebuilds every existing + buffer at the new size, keeping its most recent samples (a maxlen + deque naturally drops the oldest first as it's extended past + capacity, so growing loses nothing and shrinking keeps the tail).""" + self._buffer_size = n + for dev_bufs in self._buffers.values(): + for ch_id, buf in list(dev_bufs.items()): + new_buf = SignalBuffer(maxlen=n) + new_buf.times.extend(buf.times) + new_buf.values.extend(buf.values) + dev_bufs[ch_id] = new_buf + def on_device_reconfigured(self, device_id: str): """Reconcile buffers with a device's current channel list — reconfigure() can add/remove channels (e.g. Signals count changed), and buffers keyed @@ -125,7 +145,7 @@ class AcquisitionEngine(QObject): bufs.pop(stale_id, None) for ch in dev.info.signals: if ch.signal_id not in bufs: - bufs[ch.signal_id] = SignalBuffer() + bufs[ch.signal_id] = SignalBuffer(maxlen=self._buffer_size) self._last_units[(device_id, ch.signal_id)] = ch.unit for key in [k for k in self._last_units if k[0] == device_id and k[1] not in valid_ids]: self._last_units.pop(key, None) @@ -176,12 +196,14 @@ class AcquisitionEngine(QObject): self._running = True self._thread = threading.Thread(target=self._loop, daemon=True, name="DAQ-Acq") self._thread.start() + self.run_state_changed.emit(True) def stop(self): self._running = False if self._thread: self._thread.join(timeout=3.0) self.stop_logging() + self.run_state_changed.emit(False) # ── Logging ────────────────────────────────────────────────────────── 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; } |
