diff options
| -rw-r--r-- | core/reference_registry.py | 32 | ||||
| -rw-r--r-- | core/signal_processor.py | 17 | ||||
| -rw-r--r-- | devices/arduino_device.py | 4 | ||||
| -rw-r--r-- | devices/base_device.py | 17 | ||||
| -rw-r--r-- | devices/serial_device.py | 4 | ||||
| -rw-r--r-- | ui/control_panel.py | 5 | ||||
| -rw-r--r-- | ui/main_window.py | 11 | ||||
| -rw-r--r-- | ui/strip_chart.py | 19 | ||||
| -rw-r--r-- | ui/windows/channels_window.py | 27 |
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 |
