summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-09-20 11:31:07 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-09-20 11:31:07 -0600
commitbb8be051ffffa56a01093c9f3aaa80e84748568f (patch)
treedb5c0e3e5f8a453984aea9c70afa007319c610c3
parentf3327d56d48d63b21ffc03a55fa5e7db753d1707 (diff)
Fix Signal name reset and orphaned references on device reconfigure
Apply & Reconnect rebuilt a device's whole Signal list from hardware defaults on every call, silently discarding user/profile-set names, units, and colors even when pins hadn't changed. Add merge_signal_metadata() and call it from ArduinoDevice.remap_pins() and SerialDevice.reconfigure() to reapply the same fields ProfileManager already treats as user overrides, keyed by signal_id. Also close a related gap: removing a pin that a Channel/Plot/Control still referenced left it silently dangling, and SignalProcessor never pruned its latest-value cache, so a derived Channel kept computing off a permanently frozen stale reading. Add SignalProcessor. on_device_reconfigured() to purge that cache, reference_registry. find_references() for a console breadcrumb, and live "missing source" indicators on Plot traces and Channel blocks reusing ControlWidget's existing stale/QSS pattern. Also fixes ControlWidget._write() still enqueuing writes to a channel that no longer exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--core/reference_registry.py32
-rw-r--r--core/signal_processor.py17
-rw-r--r--devices/arduino_device.py4
-rw-r--r--devices/base_device.py17
-rw-r--r--devices/serial_device.py4
-rw-r--r--ui/control_panel.py5
-rw-r--r--ui/main_window.py11
-rw-r--r--ui/strip_chart.py19
-rw-r--r--ui/windows/channels_window.py27
9 files changed, 127 insertions, 9 deletions
diff --git a/core/reference_registry.py b/core/reference_registry.py
index b6fcd0f..7ab9649 100644
--- a/core/reference_registry.py
+++ b/core/reference_registry.py
@@ -20,7 +20,7 @@ hold such a reference and rewrites it. It expects a MainWindow instance
reachable from.
"""
-from typing import Tuple
+from typing import List, Tuple
Ref = Tuple[str, str]
@@ -72,3 +72,33 @@ def propagate_rename(app, old_ref: Ref, new_ref: Ref) -> None:
win.refresh_derived()
if hasattr(win, "refresh_devices"):
win.refresh_devices()
+
+
+def find_references(app, ref: Ref) -> List[str]:
+ """Reverse lookup: human-readable names of everything currently holding
+ a (device_id, channel_id) reference — same 5 subsystems propagate_rename()
+ walks, but collecting instead of rewriting. Used to flag a signal_id an
+ Apply & Reconnect just dropped (still referenced elsewhere = orphaned),
+ without blocking the reconnect itself — see devices_window/channels_window/
+ control_panel "missing source" indicators, which do the live per-widget
+ check; this is only for a one-line summary breadcrumb."""
+ found: List[str] = []
+
+ for dc in app.processor.get_derived():
+ if ref in dc.sources:
+ found.append(f"Channel '{dc.name}'")
+
+ chart = getattr(app, "_chart", None)
+ if chart is not None and chart._cfg is not None:
+ for pane in chart._cfg.panes:
+ for tr in pane.traces:
+ if (tr.device_id, tr.channel_id) == ref:
+ found.append(f"Plot '{pane.title}' trace '{tr.label or tr.channel_id}'")
+
+ ctrl = getattr(app, "_ctrl", None)
+ if ctrl is not None:
+ for spec in ctrl.get_specs():
+ if (spec.device_id, spec.channel_id) == ref:
+ found.append(f"Control '{spec.title}'")
+
+ return found
diff --git a/core/signal_processor.py b/core/signal_processor.py
index da615b6..c79ea87 100644
--- a/core/signal_processor.py
+++ b/core/signal_processor.py
@@ -492,6 +492,23 @@ class SignalProcessor(QObject):
def all_virtual_channel_ids(self) -> List[str]:
return list(self._derived_bufs.keys())
+ def on_device_reconfigured(self, device_id: str, current_signal_ids: set) -> List[str]:
+ """Drop cached state for any signal_id a device's Apply & Reconnect
+ just removed — otherwise _latest keeps a stale frozen (timestamp,
+ value) forever (it's only ever written to, never pruned elsewhere),
+ and a derived Channel sourced from that id keeps silently computing
+ off that frozen value instead of skipping like a genuinely-missing
+ source would. Mirrors AcquisitionEngine.on_device_reconfigured().
+ Returns the removed signal_ids (those with a cached entry keyed to
+ this device that's no longer in current_signal_ids), so the caller
+ can flag any still-dangling references without recomputing the diff."""
+ removed = []
+ with self._lock:
+ for key in [k for k in self._latest if k[0] == device_id and k[1] not in current_signal_ids]:
+ self._latest.pop(key, None)
+ removed.append(key[1])
+ return removed
+
def clear_history(self):
"""Clear derived channel buffers and latest-value cache."""
with self._lock:
diff --git a/devices/arduino_device.py b/devices/arduino_device.py
index 5c954c8..1512223 100644
--- a/devices/arduino_device.py
+++ b/devices/arduino_device.py
@@ -15,7 +15,7 @@ from PyQt6.QtWidgets import (
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
-from devices.base_device import BaseDevice, SignalConfig, DeviceInfo, DeviceStatus
+from devices.base_device import BaseDevice, SignalConfig, DeviceInfo, DeviceStatus, merge_signal_metadata
from api_layers.arduino_layer import ArduinoLayer
_ANALOG_COLORS = [
@@ -268,7 +268,7 @@ class ArduinoDevice(BaseDevice):
self._do_pins = list(do_pins)
self._do_map = {f"do{i}": p for i, p in enumerate(do_pins)}
- self.info.signals = self._build_channels()
+ self.info.signals = merge_signal_metadata(self.info.signals, self._build_channels())
self.info.description = self._description()
self._output_state = {f"do{i}": self._output_state.get(f"do{i}", 0) for i in range(len(self._do_pins))}
self._di_state = {f"di{i}": self._di_state.get(f"di{i}", 0) for i in range(len(self._di_pins))}
diff --git a/devices/base_device.py b/devices/base_device.py
index e3eed65..ae7f438 100644
--- a/devices/base_device.py
+++ b/devices/base_device.py
@@ -104,3 +104,20 @@ class BaseDevice(ABC):
def __repr__(self):
return f"<{self.__class__.__name__} id={self.info.device_id} status={self.status.value}>"
+
+
+def merge_signal_metadata(old_signals: List[SignalConfig], new_signals: List[SignalConfig]) -> List[SignalConfig]:
+ """Reapply user-customized Signal metadata (name/unit/enabled/color) onto a
+ freshly rebuilt signal list, keyed by signal_id. Mirrors exactly the fields
+ ProfileManager treats as user overrides (core/profile.py capture/apply) so
+ identity set at profile-load time survives a later device reconfigure too —
+ otherwise a device's own reconfigure()/remap_pins() rebuilding info.signals
+ from scratch silently resets names back to hardware defaults. Signal ids
+ not present in old_signals (new pins) keep their fresh defaults; ids no
+ longer in new_signals (removed pins) are simply dropped."""
+ old_by_id = {s.signal_id: s for s in old_signals}
+ for sig in new_signals:
+ old = old_by_id.get(sig.signal_id)
+ if old is not None:
+ sig.name, sig.unit, sig.enabled, sig.color = old.name, old.unit, old.enabled, old.color
+ return new_signals
diff --git a/devices/serial_device.py b/devices/serial_device.py
index 86d5e80..53f9275 100644
--- a/devices/serial_device.py
+++ b/devices/serial_device.py
@@ -31,7 +31,7 @@ from PyQt6.QtWidgets import (
QStackedWidget, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget,
)
-from devices.base_device import BaseDevice, SignalConfig, DeviceInfo, DeviceStatus
+from devices.base_device import BaseDevice, SignalConfig, DeviceInfo, DeviceStatus, merge_signal_metadata
from api_layers.arduino_layer import ArduinoLayer
_COLORS = ["#7fff6e", "#4cc9f0", "#f72585", "#00d4ff",
@@ -274,7 +274,7 @@ class SerialDevice(BaseDevice):
self._mb_channels = modbus_channels or self._mb_channels
self._motors = motors or self._motors
- self.info.signals = self._build_channel_configs()
+ self.info.signals = merge_signal_metadata(self.info.signals, self._build_channel_configs())
self.info.description = self._description()
self._layer = self._make_layer()
diff --git a/ui/control_panel.py b/ui/control_panel.py
index f008853..830b985 100644
--- a/ui/control_panel.py
+++ b/ui/control_panel.py
@@ -238,7 +238,10 @@ class ControlWidget(QFrame):
dev = self.registry.get_instance(self.device_id)
if dev:
ch = dev.get_signal(self.channel_id)
- if ch is not None and not ch.enabled:
+ if ch is None:
+ print(f"[Control] write_channel({self.channel_id}, {value}) skipped on "
+ f"{self.device_id} — channel no longer exists (removed by reconfigure)")
+ elif not ch.enabled:
print(f"[Control] write_channel({self.channel_id}, {value}) skipped on "
f"{self.device_id} — channel is disabled")
elif dev.status not in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED):
diff --git a/ui/main_window.py b/ui/main_window.py
index 0e21b2e..ba93121 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -720,6 +720,17 @@ class MainWindow(QMainWindow):
self._status.setText(f"Device '{dev_id}' removed.")
def _on_device_reconfigured(self, dev_id: str):
+ dev = self.registry.get_instance(dev_id)
+ if dev is not None:
+ current_ids = {s.signal_id for s in dev.info.signals}
+ removed_ids = self.processor.on_device_reconfigured(dev_id, current_ids)
+ if removed_ids:
+ from core.reference_registry import find_references
+ for sid in removed_ids:
+ refs = find_references(self, (dev_id, sid))
+ if refs:
+ print(f"[Device '{dev_id}'] signal '{sid}' removed by reconfigure — "
+ f"still referenced by: {', '.join(refs)}")
self.engine.on_device_reconfigured(dev_id)
self._chart.refresh()
if self._win_config:
diff --git a/ui/strip_chart.py b/ui/strip_chart.py
index bdffb91..b295a8c 100644
--- a/ui/strip_chart.py
+++ b/ui/strip_chart.py
@@ -176,9 +176,13 @@ class StripChartWidget(QWidget):
for tr in spec.traces:
if not tr.visible: continue
- pen = pg.mkPen(color=tr.color, width=tr.width,
- style=_STYLES.get(tr.style, Qt.PenStyle.SolidLine))
- curve = plot.plot([], [], pen=pen, name=tr.label or tr.channel_id)
+ missing = self._signal_missing(tr.device_id, tr.channel_id)
+ pen = pg.mkPen(color="#6b7280" if missing else tr.color, width=tr.width,
+ style=Qt.PenStyle.DashLine if missing
+ else _STYLES.get(tr.style, Qt.PenStyle.SolidLine))
+ label = tr.label or tr.channel_id
+ curve = plot.plot([], [], pen=pen,
+ name=f"{label} (missing)" if missing else label)
key = (tr.device_id, tr.channel_id)
self._curves.setdefault(key, []).append(
{"curve": curve, "plot": plot, "spec": spec, "pane": spec})
@@ -203,6 +207,15 @@ class StripChartWidget(QWidget):
for e in self._curves.get(key, []):
e["curve"].setVisible(enabled)
+ def _signal_missing(self, device_id: str, channel_id: str) -> bool:
+ """True if this trace's (device_id, channel_id) no longer resolves —
+ e.g. Apply & Reconnect dropped the pin — so it can be flagged instead
+ of silently freezing with no explanation."""
+ if device_id == "derived":
+ return not any(dc.channel_id == channel_id for dc in self.processor.get_derived())
+ dev = self.registry.get_instance(device_id)
+ return dev is None or dev.get_signal(channel_id) is None
+
def _lookup_channel_unit(self, device_id: str, channel_id: str) -> str:
if device_id == "derived":
for dc in self.processor.get_derived():
diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py
index f581851..31d1458 100644
--- a/ui/windows/channels_window.py
+++ b/ui/windows/channels_window.py
@@ -536,6 +536,8 @@ class PipelineTab(QWidget):
if block:
self._on_remove_block(block)
self._refresh_src_cb()
+ for blk in self._derived_blocks:
+ blk.refresh_missing_sources()
def on_device_reconfigured(self, dev_id: str):
dev = self.registry.get_instance(dev_id)
@@ -550,6 +552,8 @@ class PipelineTab(QWidget):
if block:
self._on_remove_block(block)
self._refresh_src_cb()
+ for blk in self._derived_blocks:
+ blk.refresh_missing_sources()
def on_device_added(self):
self._refresh_src_cb()
@@ -845,6 +849,11 @@ class ChannelBlock(QFrame):
rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn")
rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self))
hl.addWidget(rm)
+ self._missing_lbl = QLabel("⚠")
+ self._missing_lbl.setObjectName("controlWidgetStale")
+ self._missing_lbl.setToolTip("A source signal is missing — reconfigured or removed")
+ self._missing_lbl.setVisible(False)
+ hl.addWidget(self._missing_lbl)
self._toggle_btn = QToolButton(); self._toggle_btn.setText("▶")
self._toggle_btn.setObjectName("traceRemoveBtn"); self._toggle_btn.setFixedSize(22, 22)
self._toggle_btn.clicked.connect(self._toggle)
@@ -1013,6 +1022,24 @@ class ChannelBlock(QFrame):
self._body = body
self._update_vars_hint()
self._update_visibility()
+ self.refresh_missing_sources()
+
+ def refresh_missing_sources(self):
+ """Update the header warning icon for a source signal that no longer
+ resolves (device removed, or Apply & Reconnect dropped that pin).
+ Called after any device add/remove/reconfigure — see PipelineTab."""
+ missing = False
+ for dev_id, ch_id in self.dc.sources:
+ if dev_id == "derived":
+ if ch_id not in self.processor.all_virtual_channel_ids():
+ missing = True
+ break
+ else:
+ dev = self.registry.get_instance(dev_id)
+ if dev is None or dev.get_signal(ch_id) is None:
+ missing = True
+ break
+ self._missing_lbl.setVisible(missing)
def _toggle(self):
self._expanded = not self._expanded