summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--core/acquisition.py26
-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
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; }