diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-09-19 17:38:07 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-09-19 17:38:07 -0600 |
| commit | f3327d56d48d63b21ffc03a55fa5e7db753d1707 (patch) | |
| tree | e19d4c7846dd4a434f8a18f82290597731b4a12c /ui | |
| parent | 00671a90bb51bfe485d98a928a456d55adbe1faf (diff) | |
Redesign Signal/Channel naming and fix pin-remap breaking references
Arduino analog Signal ids used to be the pin name itself, so fixing a
wrong pin silently changed the id and orphaned any plot trace, control,
or filter pipeline pointing at it. Analog Signal ids are now positional
(ai0, ai1, ...) and decoupled from the underlying pin, matching how
digital I/O already worked.
More broadly, formalizes a Device -> Signal -> Channel -> Plot naming
model (SignalConfig for physical device channels, Channel for derived/
computed ones) and adds core/reference_registry.py to propagate any
Signal or Channel id rename through plot traces, controls, and filter
pipelines instead of leaving them dangling. Signal ids are now editable
in the Signals tab with the same propagation.
.labui profile schema bumped to v2.0 with a transparent migration for
old files, including remapping old pin-name Signal ids to the new
positional ones. Removed six confirmed-dead files (two legacy device
classes, four superseded top-level ui/ duplicates).
Diffstat (limited to 'ui')
| -rw-r--r-- | ui/config_dialog.py | 2 | ||||
| -rw-r--r-- | ui/control_editor.py | 6 | ||||
| -rw-r--r-- | ui/control_panel.py | 7 | ||||
| -rw-r--r-- | ui/device_panel.py | 148 | ||||
| -rw-r--r-- | ui/devices_window.py | 224 | ||||
| -rw-r--r-- | ui/main_window.py | 25 | ||||
| -rw-r--r-- | ui/namespace_panel.py | 6 | ||||
| -rw-r--r-- | ui/plot_builder.py | 510 | ||||
| -rw-r--r-- | ui/plot_config.py | 631 | ||||
| -rw-r--r-- | ui/strip_chart.py | 10 | ||||
| -rw-r--r-- | ui/windows/channels_window.py | 105 | ||||
| -rw-r--r-- | ui/windows/config_window.py | 69 | ||||
| -rw-r--r-- | ui/windows/devices_window.py | 20 | ||||
| -rw-r--r-- | ui/windows/plot_window.py | 16 |
14 files changed, 154 insertions, 1625 deletions
diff --git a/ui/config_dialog.py b/ui/config_dialog.py index cefee2d..a1ad625 100644 --- a/ui/config_dialog.py +++ b/ui/config_dialog.py @@ -74,5 +74,5 @@ class DeviceConfigDialog(QDialog): form.addRow("Model:", _ro(info.model)) form.addRow("Version:", _ro(info.version)) form.addRow("Status:", _ro(self.device.status.value)) - form.addRow("Signals:", _ro(len(info.channels))) + form.addRow("Signals:", _ro(len(info.signals))) return w diff --git a/ui/control_editor.py b/ui/control_editor.py index 624e4c1..40cde7e 100644 --- a/ui/control_editor.py +++ b/ui/control_editor.py @@ -214,11 +214,11 @@ class ControlEditorDialog(QDialog): self._target_cb = QComboBox() self._target_cb.addItem("— none —", userData=("", "")) for dev in self.registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: if not ch.enabled or not ch.writable: continue - label = f"{dev.info.device_id} / {ch.name} ({ch.channel_id})" - self._target_cb.addItem(label, userData=(dev.info.device_id, ch.channel_id)) + label = f"{dev.info.device_id} / {ch.name} ({ch.signal_id})" + self._target_cb.addItem(label, userData=(dev.info.device_id, ch.signal_id)) hw_form.addRow("Target Signal:", self._target_cb) self._active_low_chk = QCheckBox("Active-low output (ON sends LOW)") diff --git a/ui/control_panel.py b/ui/control_panel.py index dafeabf..f008853 100644 --- a/ui/control_panel.py +++ b/ui/control_panel.py @@ -160,7 +160,7 @@ class ControlWidget(QFrame): if dev is None: stale = True else: - ch = dev.get_channel(self.channel_id) + ch = dev.get_signal(self.channel_id) if ch is None or not ch.enabled: stale = True elif dev.status not in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED): @@ -225,8 +225,7 @@ class ControlWidget(QFrame): Write value to the linked device channel. Routing: - DigitalIODevice → write_channel() → ArduinoLayer.digital_write() → W:Dxx:val - AnalogInputDevice (arduino backend) → write_channel() → ArduinoLayer.digital_write() + ArduinoDevice (do* signal) → write_channel() → ArduinoLayer.digital_write() → W:Dxx:val Any device with no write support → logs a warning, emits signal only The actual write is dispatched to a background worker (_enqueue_write) @@ -238,7 +237,7 @@ class ControlWidget(QFrame): if self.registry and self.device_id and self.channel_id: dev = self.registry.get_instance(self.device_id) if dev: - ch = dev.get_channel(self.channel_id) + ch = dev.get_signal(self.channel_id) if ch is not None and not ch.enabled: print(f"[Control] write_channel({self.channel_id}, {value}) skipped on " f"{self.device_id} — channel is disabled") diff --git a/ui/device_panel.py b/ui/device_panel.py deleted file mode 100644 index 932f536..0000000 --- a/ui/device_panel.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -ui/device_panel.py — Left sidebar: device list with status indicators. -""" - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QScrollArea, QFrame, QSizePolicy, -) -from PyQt6.QtCore import Qt, pyqtSignal, QTimer -from devices.base_device import DeviceStatus -from devices.device_registry import DeviceRegistry -from core.acquisition import AcquisitionEngine - - -_STATUS_STYLE = { - DeviceStatus.CONNECTED: "color:#22c55e;", - DeviceStatus.SIMULATED: "color:#3b82f6;", - DeviceStatus.DISCONNECTED: "color:#475569;", - DeviceStatus.CONNECTING: "color:#f59e0b;", - DeviceStatus.ERROR: "color:#ef4444;", -} -_STATUS_LABEL = { - DeviceStatus.CONNECTED: "CONNECTED", - DeviceStatus.SIMULATED: "SIMULATED", - DeviceStatus.DISCONNECTED: "OFFLINE", - DeviceStatus.CONNECTING: "CONNECTING", - DeviceStatus.ERROR: "ERROR", -} - - -class DeviceCard(QFrame): - config_requested = pyqtSignal(str) - - def __init__(self, device): - super().__init__() - self.device = device - self.setObjectName("deviceCard") - self._build() - - def _build(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(5) - - # Header row: icon + name + status dot - hdr = QHBoxLayout() - icon_lbl = QLabel(self.device.info.icon) - icon_lbl.setObjectName("deviceIcon") - hdr.addWidget(icon_lbl) - - name_lbl = QLabel(self.device.info.name) - name_lbl.setObjectName("deviceName") - hdr.addWidget(name_lbl, 1) - - self._dot = QLabel("●") - self._dot.setStyleSheet(_STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;") - hdr.addWidget(self._dot) - layout.addLayout(hdr) - - # Device ID - id_lbl = QLabel(self.device.info.device_id) - id_lbl.setObjectName("deviceSub") - layout.addWidget(id_lbl) - - # Status text - self._status_lbl = QLabel(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) - style = _STATUS_STYLE.get(self.device.status, "color:#475569;") - self._status_lbl.setStyleSheet( - style + " font-family:'IBM Plex Mono',monospace; font-size:10px; font-weight:700; letter-spacing:1px;" - ) - layout.addWidget(self._status_lbl) - - # Channel count - n = sum(1 for c in self.device.info.channels if c.enabled) - ch_lbl = QLabel(f"{n} ch · {self.device.info.device_type}") - ch_lbl.setObjectName("deviceChannelCount") - layout.addWidget(ch_lbl) - - # Config button - cfg = QPushButton("Configure") - cfg.setObjectName("configButton") - cfg.clicked.connect(lambda: self.config_requested.emit(self.device.info.device_id)) - layout.addWidget(cfg) - - def refresh(self): - dot_style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;" - label_style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + \ - " font-family:'IBM Plex Mono',monospace; font-size:10px; font-weight:700; letter-spacing:1px;" - self._dot.setStyleSheet(dot_style) - self._status_lbl.setText(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) - self._status_lbl.setStyleSheet(label_style) - - -class DevicePanel(QWidget): - config_requested = pyqtSignal(str) - - def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine): - super().__init__() - self.registry = registry - self.engine = engine - self._cards = {} - self._build() - - # Refresh status every 2 s - self._timer = QTimer(self) - self._timer.setInterval(2000) - self._timer.timeout.connect(self._refresh_status) - self._timer.start() - - def _build(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - hdr = QLabel(" DEVICES") - hdr.setObjectName("panelHeader") - hdr.setMinimumHeight(28) - layout.addWidget(hdr) - - scroll = QScrollArea() - scroll.setObjectName("deviceScroll") - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - - self._container = QWidget() - self._inner = QVBoxLayout(self._container) - self._inner.setContentsMargins(8, 8, 8, 8) - self._inner.setSpacing(8) - self._inner.addStretch() - - scroll.setWidget(self._container) - layout.addWidget(scroll) - - def refresh(self): - for card in self._cards.values(): - self._inner.removeWidget(card) - card.deleteLater() - self._cards.clear() - - for dev in self.registry.all_instances(): - card = DeviceCard(dev) - card.config_requested.connect(self.config_requested.emit) - self._inner.insertWidget(self._inner.count() - 1, card) - self._cards[dev.info.device_id] = card - - def _refresh_status(self): - for dev_id, card in self._cards.items(): - card.refresh() diff --git a/ui/devices_window.py b/ui/devices_window.py deleted file mode 100644 index 0548330..0000000 --- a/ui/devices_window.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -ui/devices_window.py - -Floating "Devices" settings window. -Opens from the toolbar ⚙ DEVICES button; can stay open alongside the main window. -Contains: device status cards, Add Device, and per-device Configure. -""" - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QScrollArea, QFrame, QSizePolicy, - QDialog, -) -from PyQt6.QtCore import Qt, pyqtSignal, QTimer -from PyQt6.QtGui import QCloseEvent - -from devices.base_device import DeviceStatus -from devices.device_registry import DeviceRegistry -from core.acquisition import AcquisitionEngine - - -_STATUS_STYLE = { - DeviceStatus.CONNECTED: "color:#22c55e;", - DeviceStatus.SIMULATED: "color:#3b82f6;", - DeviceStatus.DISCONNECTED: "color:#475569;", - DeviceStatus.CONNECTING: "color:#f59e0b;", - DeviceStatus.ERROR: "color:#ef4444;", -} -_STATUS_LABEL = { - DeviceStatus.CONNECTED: "CONNECTED", - DeviceStatus.SIMULATED: "SIMULATED", - DeviceStatus.DISCONNECTED: "OFFLINE", - DeviceStatus.CONNECTING: "CONNECTING", - DeviceStatus.ERROR: "ERROR", -} - - -class DeviceCard(QFrame): - config_requested = pyqtSignal(str) - - def __init__(self, device): - super().__init__() - self.device = device - self.setObjectName("deviceCard") - self._build() - - def _build(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(5) - - # Header: icon + name + status dot - hdr = QHBoxLayout() - icon_lbl = QLabel(self.device.info.icon) - icon_lbl.setObjectName("deviceIcon") - hdr.addWidget(icon_lbl) - - name_lbl = QLabel(self.device.info.name) - name_lbl.setObjectName("deviceName") - hdr.addWidget(name_lbl, 1) - - self._dot = QLabel("●") - self._dot.setStyleSheet( - _STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;" - ) - hdr.addWidget(self._dot) - layout.addLayout(hdr) - - # ID - id_lbl = QLabel(self.device.info.device_id) - id_lbl.setObjectName("deviceSub") - layout.addWidget(id_lbl) - - # Status text - self._status_lbl = QLabel(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) - self._apply_status_style() - layout.addWidget(self._status_lbl) - - # Channel count · type - n = sum(1 for c in self.device.info.channels if c.enabled) - ch_lbl = QLabel(f"{n} ch · {self.device.info.device_type}") - ch_lbl.setObjectName("deviceChannelCount") - layout.addWidget(ch_lbl) - - # Configure button - cfg = QPushButton("Configure") - cfg.setObjectName("configButton") - cfg.clicked.connect(lambda: self.config_requested.emit(self.device.info.device_id)) - layout.addWidget(cfg) - - def refresh(self): - dot_style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;" - self._dot.setStyleSheet(dot_style) - self._status_lbl.setText(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) - self._apply_status_style() - - def _apply_status_style(self): - s = _STATUS_STYLE.get(self.device.status, "color:#475569;") - self._status_lbl.setStyleSheet( - s + " font-family:'IBM Plex Mono',monospace;" - " font-size:10px; font-weight:700; letter-spacing:1px;" - ) - - -class DevicesWindow(QWidget): - """ - Standalone floating window for device management. - - Signals - ------- - config_requested(device_id) — user clicked Configure on a card - device_added() — a new device was added via the dialog - closed() — window was closed (so toolbar btn can untoggle) - """ - - config_requested = pyqtSignal(str) - device_added = pyqtSignal() - closed = pyqtSignal() - - def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine, parent=None): - super().__init__( - parent, - # Acts like a real standalone window but stays on top of parent - Qt.WindowType.Window | - Qt.WindowType.Tool, - ) - self.registry = registry - self.engine = engine - self._cards: dict = {} - - self.setWindowTitle("Devices") - self.setMinimumSize(300, 480) - self.resize(320, 600) - self._build() - self.refresh() - - self._timer = QTimer(self) - self._timer.setInterval(2000) - self._timer.timeout.connect(self._refresh_status) - self._timer.start() - - # ── Build ───────────────────────────────────────────────────────────── - - def _build(self): - root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) - - # ── Title bar row ────────────────────────────────────────────── - title_bar = QWidget() - title_bar.setObjectName("devWindowTitleBar") - title_bar.setFixedHeight(40) - tb_lay = QHBoxLayout(title_bar) - tb_lay.setContentsMargins(12, 0, 8, 0) - - title_lbl = QLabel("⚙ DEVICES") - title_lbl.setObjectName("devWindowTitle") - tb_lay.addWidget(title_lbl, 1) - - self._add_btn = QPushButton("+ Add") - self._add_btn.setObjectName("addDeviceButton") - self._add_btn.clicked.connect(self._on_add) - tb_lay.addWidget(self._add_btn) - - root.addWidget(title_bar) - - # ── Divider ──────────────────────────────────────────────────── - div = QFrame() - div.setFrameShape(QFrame.Shape.HLine) - div.setObjectName("devWindowDivider") - root.addWidget(div) - - # ── Scrollable device cards ──────────────────────────────────── - scroll = QScrollArea() - scroll.setObjectName("deviceScroll") - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - - self._container = QWidget() - self._inner = QVBoxLayout(self._container) - self._inner.setContentsMargins(10, 10, 10, 10) - self._inner.setSpacing(10) - self._inner.addStretch() - - scroll.setWidget(self._container) - root.addWidget(scroll) - - # ── Public API ──────────────────────────────────────────────────────── - - def refresh(self): - """Rebuild all device cards from current registry state.""" - for card in self._cards.values(): - self._inner.removeWidget(card) - card.deleteLater() - self._cards.clear() - - for dev in self.registry.all_instances(): - card = DeviceCard(dev) - card.config_requested.connect(self.config_requested.emit) - self._inner.insertWidget(self._inner.count() - 1, card) - self._cards[dev.info.device_id] = card - - # ── Slots ──────────────────────────────────────────────────────────── - - def _on_add(self): - from ui.add_device_dialog import AddDeviceDialog - from ui.config_dialog import DeviceConfigDialog - dlg = AddDeviceDialog(self.registry, self) - if dlg.exec(): - dev = dlg.created_device - if dev: - dev.connect() - self.registry.add_instance(dev) - self.engine.add_device(dev) - self.refresh() - self.device_added.emit() - - def _refresh_status(self): - for card in self._cards.values(): - card.refresh() - - def closeEvent(self, event: QCloseEvent): - self.closed.emit() - event.accept() diff --git a/ui/main_window.py b/ui/main_window.py index b632535..0e21b2e 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -93,9 +93,6 @@ class _DockTitleBar(QWidget): lay.addWidget(close_btn) -from devices.arduino_device import ArduinoDevice -from devices.nidaqmx_device import NidaqmxDevice -from devices.serial_device import SerialDevice from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine from core.signal_processor import SignalProcessor @@ -148,24 +145,11 @@ class MainWindow(QMainWindow): self._wheel_blocker = _WheelBlocker(self) QApplication.instance().installEventFilter(self._wheel_blocker) - self._init_demo_devices() self._build_ui() self._connect_signals() self._init_plugins() self._apply_settings_on_startup() - # ── Demo ────────────────────────────────────────────────────────────── - - def _init_demo_devices(self): - for dev in [ - NidaqmxDevice(device_id="ni_0", num_analog=4, num_di=2, num_do=4, simulate=True), - ArduinoDevice(device_id="ard_0", num_analog=4, num_di=2, num_do=4, simulate=True), - SerialDevice (device_id="ser_0", num_channels=3, simulate=True), - ]: - dev.connect() - self.registry.add_instance(dev) - self.engine.add_device(dev) - # ── UI ──────────────────────────────────────────────────────────────── def _build_ui(self): @@ -276,7 +260,6 @@ class MainWindow(QMainWindow): # ── Control Panel dock ──────────────────────────────────────────── self._ctrl = ControlPanel(self.registry, processor=self.processor, engine=self.engine) self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340) - self._ctrl._add_demo_widgets() self._ctrl_dock = QDockWidget("Control Panel", self) self._ctrl_dock.setObjectName("controlPanelDock") @@ -561,6 +544,7 @@ class MainWindow(QMainWindow): self._win_config.channel_unit_changed.connect(self._on_channel_unit_changed) self._win_config.pipeline_changed.connect(lambda: None) self._win_config.derived_changed.connect(self._on_derived_changed) + self._win_config.channel_renamed.connect(self._on_channel_renamed) self._win_config.layout_applied.connect(self._chart.apply_layout) self._win_config.tabs.setCurrentIndex(tab) self._show_win(self._win_config, "right") @@ -762,6 +746,13 @@ class MainWindow(QMainWindow): if self._win_config: self._win_config.refresh_plot(self._chart._cfg) + def _on_channel_renamed(self, old_dev: str, old_id: str, new_dev: str, new_id: str): + """A Signal or Channel's id changed — rewrite every plot trace, + control target, and filter pipeline that still points at the old + (device_id, channel_id) pair so it doesn't silently stop updating.""" + from core.reference_registry import propagate_rename + propagate_rename(self, (old_dev, old_id), (new_dev, new_id)) + # ── Run / Log ───────────────────────────────────────────────────────── def _toggle_run(self, c: bool): diff --git a/ui/namespace_panel.py b/ui/namespace_panel.py index 44750c6..9616db7 100644 --- a/ui/namespace_panel.py +++ b/ui/namespace_panel.py @@ -107,9 +107,9 @@ class NamespacePanel(QWidget): def _desired_keys(self): keys = [] for dev in self.registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: if ch.enabled: - keys.append(("signal", dev.info.device_id, ch.channel_id)) + keys.append(("signal", dev.info.device_id, ch.signal_id)) for dc in self.processor.get_derived(): keys.append(("channel", dc.channel_id)) for name in self.processor.list_vars(): @@ -131,7 +131,7 @@ class NamespacePanel(QWidget): if kind == "signal": _, dev_id, ch_id = key dev = self.registry.get_instance(dev_id) - ch = dev.get_channel(ch_id) if dev else None + ch = dev.get_signal(ch_id) if dev else None name = ch.name if ch else ch_id is_wr = bool(ch and ch.writable) self._set_row(row, "Signal", ch_id, name, "WRITE" if is_wr else "READ", diff --git a/ui/plot_builder.py b/ui/plot_builder.py deleted file mode 100644 index 7445bda..0000000 --- a/ui/plot_builder.py +++ /dev/null @@ -1,510 +0,0 @@ -""" -ui/plot_builder.py - -Plot Builder — floating window for operators to: - • Create named subplot rows - • Drag channels (physical + derived) into any row - • Name each axis, set Y range, grid, color - • Reorder rows with ▲▼ arrows - • Toggle per-trace visibility - • Export / Import config as JSON - -Emits config_applied(ChartConfig) to rebuild the strip chart. -""" - -from __future__ import annotations -import json -from copy import deepcopy -from dataclasses import dataclass, field, asdict -from typing import List, Optional, Tuple - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, - QCheckBox, QComboBox, QColorDialog, QToolButton, - QSizePolicy, QFileDialog, QMessageBox, QSpinBox, -) -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtGui import QColor, QFont, QCloseEvent - -from devices.device_registry import DeviceRegistry -from core.signal_processor import SignalProcessor - - -# ══════════════════════════════════════════════════════════════════════════════ -# Data model (same as before, kept self-contained for clarity) -# ══════════════════════════════════════════════════════════════════════════════ - -@dataclass -class TraceSpec: - device_id: str - channel_id: str - label: str = "" - color: str = "#00d4ff" - width: float = 1.8 - visible: bool = True - style: str = "solid" # solid | dash | dot - -@dataclass -class PlotSpec: - title: str = "Plot" - y_label: str = "" - y_auto: bool = True - y_min: float = -10.0 - y_max: float = 10.0 - grid: bool = True - height: int = 1 # relative weight in splitter - traces: List[TraceSpec] = field(default_factory=list) - -@dataclass -class LayoutConfig: - plots: List[PlotSpec] = field(default_factory=list) - time_window_s: float = 30.0 - link_x: bool = True - show_legend: bool = True - - def to_json(self) -> str: - return json.dumps(asdict(self), indent=2) - - @staticmethod - def from_json(s: str) -> "LayoutConfig": - d = json.loads(s) - plots = [] - for p in d.get("plots", []): - traces = [TraceSpec(**t) for t in p.pop("traces", [])] - plots.append(PlotSpec(**p, traces=traces)) - d.pop("plots") - return LayoutConfig(**d, plots=plots) - - -def build_default_layout(registry: DeviceRegistry, - processor: SignalProcessor) -> LayoutConfig: - cfg = LayoutConfig() - for dev in registry.all_instances(): - enabled = [ch for ch in dev.info.channels if ch.enabled] - if not enabled: continue - p = PlotSpec(title=dev.info.name, - y_label=enabled[0].unit if enabled else "") - for ch in enabled: - p.traces.append(TraceSpec( - device_id=dev.info.device_id, - channel_id=ch.channel_id, - label=ch.name, color=ch.color, - )) - if p.traces: cfg.plots.append(p) - # Add derived channels - for dc in processor.get_derived(): - p = PlotSpec(title=dc.name, y_label=dc.unit) - p.traces.append(TraceSpec( - device_id="derived", channel_id=dc.channel_id, - label=dc.name, color=dc.color, - )) - cfg.plots.append(p) - return cfg - - -# ══════════════════════════════════════════════════════════════════════════════ -# Color swatch button -# ══════════════════════════════════════════════════════════════════════════════ - -class ColorSwatch(QPushButton): - color_changed = pyqtSignal(str) - - def __init__(self, color: str = "#00d4ff"): - super().__init__() - self._color = color - self.setFixedSize(26, 20) - self._paint() - self.clicked.connect(self._pick) - - def _paint(self): - self.setStyleSheet( - f"QPushButton{{background:{self._color};border:1px solid #2a3558;" - f"border-radius:3px;}}QPushButton:hover{{border-color:#3b82f6;}}") - - def _pick(self): - c = QColorDialog.getColor(QColor(self._color), self) - if c.isValid(): - self._color = c.name(); self._paint() - self.color_changed.emit(self._color) - - @property - def color(self): return self._color - @color.setter - def color(self, v): - self._color = v; self._paint() - - -# ══════════════════════════════════════════════════════════════════════════════ -# Trace row inside a plot block -# ══════════════════════════════════════════════════════════════════════════════ - -class TraceRow(QFrame): - removed = pyqtSignal(object) - changed = pyqtSignal() - - def __init__(self, trace: TraceSpec): - super().__init__() - self.trace = trace - self.setObjectName("traceRow") - self._build() - - def _build(self): - lay = QHBoxLayout(self) - lay.setContentsMargins(6, 3, 6, 3); lay.setSpacing(6) - - self._vis = QCheckBox(); self._vis.setChecked(self.trace.visible) - self._vis.setToolTip("Visible"); self._vis.toggled.connect(self._on_vis) - lay.addWidget(self._vis) - - self._color = ColorSwatch(self.trace.color) - self._color.color_changed.connect(self._on_color) - lay.addWidget(self._color) - - src = QLabel(f"{self.trace.device_id} / {self.trace.channel_id}") - src.setObjectName("traceSource"); src.setMinimumWidth(130) - lay.addWidget(src) - - self._lbl = QLineEdit(self.trace.label) - self._lbl.setObjectName("traceLabel"); self._lbl.setPlaceholderText("Label…") - self._lbl.textChanged.connect(lambda t: setattr(self.trace, "label", t)) - lay.addWidget(self._lbl, 1) - - self._style = QComboBox(); self._style.setObjectName("traceStyleCb") - self._style.addItems(["solid", "dash", "dot"]) - self._style.setCurrentText(self.trace.style); self._style.setFixedWidth(64) - self._style.currentTextChanged.connect(lambda s: setattr(self.trace, "style", s)) - lay.addWidget(self._style) - - self._w = QDoubleSpinBox(); self._w.setObjectName("traceWidthSpin") - self._w.setRange(0.5, 6.0); self._w.setSingleStep(0.5) - self._w.setValue(self.trace.width); self._w.setFixedWidth(56) - self._w.valueChanged.connect(lambda v: setattr(self.trace, "width", v)) - lay.addWidget(self._w) - - rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") - rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self)) - lay.addWidget(rm) - - def _on_vis(self, v): self.trace.visible = v; self.changed.emit() - def _on_color(self, c): self.trace.color = c; self.changed.emit() - - -# ══════════════════════════════════════════════════════════════════════════════ -# Plot block — one collapsible row per PlotSpec -# ══════════════════════════════════════════════════════════════════════════════ - -class PlotBlock(QFrame): - removed = pyqtSignal(object) - changed = pyqtSignal() - move_up = pyqtSignal(object) - move_dn = pyqtSignal(object) - - def __init__(self, spec: PlotSpec, registry: DeviceRegistry, - processor: SignalProcessor): - super().__init__() - self.spec = spec - self.registry = registry - self.processor = processor - self._rows: List[TraceRow] = [] - self.setObjectName("plotBlock") - self._build() - - def _build(self): - outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0) - - # ── Header ──────────────────────────────────────────────────── - hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) - hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0); hl.setSpacing(4) - - self._title = QLineEdit(self.spec.title) - self._title.setObjectName("plotBlockTitle") - self._title.textChanged.connect(lambda t: (setattr(self.spec, "title", t), self.changed.emit())) - hl.addWidget(self._title, 1) - - for txt, sig in [("▲", self.move_up), ("▼", self.move_dn)]: - b = QToolButton(); b.setText(txt); b.setObjectName("plotMoveBtn") - b.setFixedSize(22,22); b.clicked.connect(lambda _, s=sig: s.emit(self)) - hl.addWidget(b) - - rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") - rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) - hl.addWidget(rm) - outer.addWidget(hdr) - - # ── Axis / range settings ───────────────────────────────────── - sett = QWidget(); sett.setObjectName("plotBlockSettings") - sl = QHBoxLayout(sett); sl.setContentsMargins(8,6,8,6); sl.setSpacing(8) - - sl.addWidget(QLabel("Y Axis:")) - self._ylabel = QLineEdit(self.spec.y_label) - self._ylabel.setObjectName("plotYLabelEdit"); self._ylabel.setMaximumWidth(130) - self._ylabel.setPlaceholderText("e.g. Voltage (V)") - self._ylabel.textChanged.connect(lambda t: setattr(self.spec, "y_label", t)) - sl.addWidget(self._ylabel) - - self._auto = QCheckBox("Auto Y"); self._auto.setChecked(self.spec.y_auto) - self._auto.toggled.connect(self._on_auto) - sl.addWidget(self._auto) - - sl.addWidget(QLabel("Min:")) - self._ymin = QDoubleSpinBox(); self._ymin.setRange(-1e9,1e9) - self._ymin.setValue(self.spec.y_min); self._ymin.setFixedWidth(72) - self._ymin.setEnabled(not self.spec.y_auto) - self._ymin.valueChanged.connect(lambda v: setattr(self.spec, "y_min", v)) - sl.addWidget(self._ymin) - - sl.addWidget(QLabel("Max:")) - self._ymax = QDoubleSpinBox(); self._ymax.setRange(-1e9,1e9) - self._ymax.setValue(self.spec.y_max); self._ymax.setFixedWidth(72) - self._ymax.setEnabled(not self.spec.y_auto) - self._ymax.valueChanged.connect(lambda v: setattr(self.spec, "y_max", v)) - sl.addWidget(self._ymax) - - self._grid = QCheckBox("Grid"); self._grid.setChecked(self.spec.grid) - self._grid.toggled.connect(lambda v: setattr(self.spec, "grid", v)) - sl.addWidget(self._grid) - - sl.addWidget(QLabel("H:")) - self._height_spin = QSpinBox(); self._height_spin.setRange(1,10) - self._height_spin.setValue(self.spec.height); self._height_spin.setFixedWidth(44) - self._height_spin.setToolTip("Relative row height") - self._height_spin.valueChanged.connect(lambda v: setattr(self.spec, "height", v)) - sl.addWidget(self._height_spin) - sl.addStretch() - outer.addWidget(sett) - - # ── Trace rows ──────────────────────────────────────────────── - traces_w = QWidget(); traces_w.setObjectName("plotBlockTraces") - self._tlay = QVBoxLayout(traces_w) - self._tlay.setContentsMargins(4,2,4,4); self._tlay.setSpacing(2) - - for tr in self.spec.traces: - self._add_trace_row(tr) - - # Channel picker + add button - pick_row = QHBoxLayout() - self._picker = self._make_picker() - pick_row.addWidget(self._picker, 1) - add_btn = QPushButton("+ Add Channel") - add_btn.setObjectName("addTraceBtn") - add_btn.clicked.connect(self._on_add_trace) - pick_row.addWidget(add_btn) - self._tlay.addLayout(pick_row) - outer.addWidget(traces_w) - - def _make_picker(self) -> QComboBox: - cb = QComboBox(); cb.setObjectName("channelPickerCb") - cb.setPlaceholderText("Select channel…") - for dev in self.registry.all_instances(): - for ch in dev.info.channels: - if not ch.enabled: - continue - cb.addItem(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})", - userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color)) - for dc in self.processor.get_derived(): - if not dc.enabled: - continue - cb.addItem(f"{dc.name} ([derived]/{dc.channel_id})", - userData=("derived", dc.channel_id, dc.name, dc.color)) - return cb - - def _add_trace_row(self, trace: TraceSpec): - row = TraceRow(trace) - row.removed.connect(self._remove_trace) - row.changed.connect(self.changed) - self._rows.append(row) - self._tlay.insertWidget(self._tlay.count() - 1, row) - - def _remove_trace(self, row: TraceRow): - if row.trace in self.spec.traces: self.spec.traces.remove(row.trace) - self._rows.remove(row) - self._tlay.removeWidget(row); row.deleteLater() - self.changed.emit() - - def _on_add_trace(self): - data = self._picker.currentData() - if not data: return - dev_id, ch_id, ch_name, ch_color = data - tr = TraceSpec(device_id=dev_id, channel_id=ch_id, - label=ch_name, color=ch_color) - self.spec.traces.append(tr) - self._add_trace_row(tr) - self.changed.emit() - - def _on_auto(self, v: bool): - self.spec.y_auto = v - self._ymin.setEnabled(not v); self._ymax.setEnabled(not v) - - -# ══════════════════════════════════════════════════════════════════════════════ -# PlotBuilderWindow -# ══════════════════════════════════════════════════════════════════════════════ - -class PlotBuilderWindow(QWidget): - """ - Floating Plot Builder. Emits config_applied(LayoutConfig) on Apply. - """ - - config_applied = pyqtSignal(object) # LayoutConfig - closed = pyqtSignal() - - def __init__(self, registry: DeviceRegistry, - processor: SignalProcessor, - current: Optional[LayoutConfig] = None, - parent=None): - super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) - self.registry = registry - self.processor = processor - self.cfg = deepcopy(current) if current \ - else build_default_layout(registry, processor) - - self.setWindowTitle("Plot Builder") - self.setMinimumSize(700, 780) - self.resize(800, 780) - self._blocks: List[PlotBlock] = [] - self._build() - self._populate() - - def _build(self): - root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) - - # ── Top bar ────────────────────────────────────────────────── - top = QWidget(); top.setObjectName("cfgTopBar"); top.setFixedHeight(44) - tl = QHBoxLayout(top); tl.setContentsMargins(12,0,12,0) - title = QLabel("📐 PLOT BUILDER"); title.setObjectName("cfgTopBarTitle") - tl.addWidget(title, 1) - self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk") - tl.addWidget(self._live) - root.addWidget(top) - - # ── Global settings ────────────────────────────────────────── - glob = QWidget(); glob.setObjectName("cfgGlobalBar") - gl = QHBoxLayout(glob); gl.setContentsMargins(12,6,12,6); gl.setSpacing(12) - gl.addWidget(QLabel("Time window:")) - self._win = QDoubleSpinBox(); self._win.setRange(1,3600) - self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s) - self._win.setObjectName("cfgGlobalSpin") - self._win.valueChanged.connect(self._global_changed) - gl.addWidget(self._win) - self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x) - self._linkx.setObjectName("cfgLiveChk"); self._linkx.toggled.connect(self._global_changed) - gl.addWidget(self._linkx) - self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend) - self._legend.setObjectName("cfgLiveChk"); self._legend.toggled.connect(self._global_changed) - gl.addWidget(self._legend) - gl.addStretch() - add_row_btn = QPushButton("+ Add Plot Row"); add_row_btn.setObjectName("addDeviceButton") - add_row_btn.clicked.connect(self._add_plot) - gl.addWidget(add_row_btn) - root.addWidget(glob) - - div = QFrame(); div.setFrameShape(QFrame.Shape.HLine); div.setObjectName("devWindowDivider") - root.addWidget(div) - - # ── Scrollable plot blocks ─────────────────────────────────── - scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setObjectName("deviceScroll") - self._container = QWidget() - self._blay = QVBoxLayout(self._container) - self._blay.setContentsMargins(10,10,10,10); self._blay.setSpacing(12) - self._blay.addStretch() - scroll.setWidget(self._container) - root.addWidget(scroll, 1) - - # ── Bottom bar ─────────────────────────────────────────────── - btm = QWidget(); btm.setObjectName("cfgBottomBar") - bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8) - for lbl, fn in [("⟳ Rebuild from Devices", self._reset), - ("↓ Export JSON", self._export), - ("↑ Import JSON", self._import)]: - b = QPushButton(lbl); b.setObjectName("configButton"); b.clicked.connect(fn) - bl.addWidget(b) - bl.addStretch() - ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") - ap.clicked.connect(self._apply) - bl.addWidget(ap) - root.addWidget(btm) - - def _populate(self): - for b in self._blocks: - self._blay.removeWidget(b); b.deleteLater() - self._blocks.clear() - for spec in self.cfg.plots: - self._insert_block(spec) - - def _insert_block(self, spec: PlotSpec): - blk = PlotBlock(spec, self.registry, self.processor) - blk.removed.connect(self._remove_block) - blk.changed.connect(self._maybe_live) - blk.move_up.connect(self._move_up) - blk.move_dn.connect(self._move_dn) - self._blocks.append(blk) - self._blay.insertWidget(self._blay.count() - 1, blk) - - def _add_plot(self): - spec = PlotSpec(title=f"Plot {len(self.cfg.plots)+1}") - self.cfg.plots.append(spec) - self._insert_block(spec) - self._maybe_live() - - def _remove_block(self, blk: PlotBlock): - if blk.spec in self.cfg.plots: self.cfg.plots.remove(blk.spec) - self._blocks.remove(blk) - self._blay.removeWidget(blk); blk.deleteLater() - self._maybe_live() - - def _move_up(self, blk: PlotBlock): - i = self._blocks.index(blk) - if i == 0: return - self.cfg.plots.insert(i-1, self.cfg.plots.pop(i)) - self._blocks.insert(i-1, self._blocks.pop(i)) - self._blay.removeWidget(blk); self._blay.insertWidget(i-1, blk) - self._maybe_live() - - def _move_dn(self, blk: PlotBlock): - i = self._blocks.index(blk) - if i >= len(self._blocks)-1: return - self.cfg.plots.insert(i+1, self.cfg.plots.pop(i)) - self._blocks.insert(i+1, self._blocks.pop(i)) - self._blay.removeWidget(blk); self._blay.insertWidget(i+1, blk) - self._maybe_live() - - def _global_changed(self): - self.cfg.time_window_s = self._win.value() - self.cfg.link_x = self._linkx.isChecked() - self.cfg.show_legend = self._legend.isChecked() - self._maybe_live() - - def _maybe_live(self): - if self._live.isChecked(): - self.config_applied.emit(deepcopy(self.cfg)) - - def _apply(self): - self.config_applied.emit(deepcopy(self.cfg)) - - def _reset(self): - self.cfg = build_default_layout(self.registry, self.processor) - self._win.setValue(self.cfg.time_window_s) - self._populate(); self._maybe_live() - - def _export(self): - path, _ = QFileDialog.getSaveFileName(self, "Export Layout", "layout.json", "JSON (*.json)") - if path: - open(path, "w").write(self.cfg.to_json()) - - def _import(self): - path, _ = QFileDialog.getOpenFileName(self, "Import Layout", "", "JSON (*.json)") - if path: - try: - self.cfg = LayoutConfig.from_json(open(path).read()) - self._win.setValue(self.cfg.time_window_s) - self._populate(); self._maybe_live() - except Exception as e: - QMessageBox.critical(self, "Import failed", str(e)) - - def refresh_channels(self): - """Call after devices/derived channels change.""" - self._populate() - - def closeEvent(self, event: QCloseEvent): - self.closed.emit(); event.accept() diff --git a/ui/plot_config.py b/ui/plot_config.py deleted file mode 100644 index c6ecaa4..0000000 --- a/ui/plot_config.py +++ /dev/null @@ -1,631 +0,0 @@ -""" -ui/plot_config.py - -Plot Configuration Builder — floating window. - -Lets the user define: - • How many subplot rows to show - • Which channels go in each row (any mix of devices/channels) - • Per-channel: label override, color, line width, visibility - • Per-plot: Y-axis label, Y min/max (or auto), grid on/off - • Global: time window, sample decimation, background color - -The resulting config is stored as a list of PlotConfig dataclasses -and emitted via the `config_applied` signal so StripChartWidget can -rebuild itself on demand. -""" - -from __future__ import annotations -import json -from copy import deepcopy -from dataclasses import dataclass, field, asdict -from typing import List, Optional - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, QSpinBox, - QCheckBox, QComboBox, QColorDialog, QGroupBox, QFormLayout, - QSizePolicy, QSplitter, QListWidget, QListWidgetItem, - QAbstractItemView, QToolButton, QSlider, -) -from PyQt6.QtCore import Qt, pyqtSignal, QSize -from PyQt6.QtGui import QColor, QIcon, QCloseEvent - -from devices.device_registry import DeviceRegistry - - -# ══════════════════════════════════════════════════════════════════════════════ -# Data model -# ══════════════════════════════════════════════════════════════════════════════ - -@dataclass -class ChannelTrace: - device_id: str - channel_id: str - label: str = "" # "" → use channel name - color: str = "#00d4ff" - width: float = 1.8 - visible: bool = True - style: str = "solid" # "solid" | "dash" | "dot" - - -@dataclass -class PlotConfig: - title: str = "Plot" - y_label: str = "" - y_auto: bool = True - y_min: float = -10.0 - y_max: float = 10.0 - grid: bool = True - traces: List[ChannelTrace] = field(default_factory=list) - - -@dataclass -class ChartConfig: - plots: List[PlotConfig] = field(default_factory=list) - time_window_s: float = 30.0 - bg_color: str = "#0b0e13" - link_x: bool = True - show_legend: bool = True - - def to_json(self) -> str: - return json.dumps(asdict(self), indent=2) - - @staticmethod - def from_json(s: str) -> "ChartConfig": - d = json.loads(s) - plots = [] - for p in d.get("plots", []): - traces = [ChannelTrace(**t) for t in p.pop("traces", [])] - plots.append(PlotConfig(**p, traces=traces)) - d["plots"] = plots - return ChartConfig(**{k: v for k, v in d.items() if k != "plots"}, - plots=plots) - - -def default_config(registry: DeviceRegistry) -> ChartConfig: - """Build a sensible default config from whatever devices are registered.""" - cfg = ChartConfig() - for dev in registry.all_instances(): - if not dev.info.channels: - continue - p = PlotConfig( - title=dev.info.name, - y_label=dev.info.channels[0].unit if dev.info.channels else "", - ) - for ch in dev.info.channels: - if ch.enabled: - p.traces.append(ChannelTrace( - device_id=dev.info.device_id, - channel_id=ch.channel_id, - label=ch.name, - color=ch.color, - )) - if p.traces: - cfg.plots.append(p) - return cfg - - -# ══════════════════════════════════════════════════════════════════════════════ -# Colour swatch button -# ══════════════════════════════════════════════════════════════════════════════ - -class ColorButton(QPushButton): - color_changed = pyqtSignal(str) - - def __init__(self, color: str = "#00d4ff"): - super().__init__() - self._color = color - self.setFixedSize(28, 22) - self._apply() - self.clicked.connect(self._pick) - - def _apply(self): - self.setStyleSheet( - f"QPushButton {{ background:{self._color}; border:1px solid #2a3558;" - f" border-radius:3px; }} " - f"QPushButton:hover {{ border-color:#3b82f6; }}" - ) - - def _pick(self): - c = QColorDialog.getColor(QColor(self._color), self, "Pick colour") - if c.isValid(): - self._color = c.name() - self._apply() - self.color_changed.emit(self._color) - - @property - def color(self): return self._color - - @color.setter - def color(self, v: str): - self._color = v - self._apply() - - -# ══════════════════════════════════════════════════════════════════════════════ -# Trace row widget (one row per ChannelTrace inside a plot) -# ══════════════════════════════════════════════════════════════════════════════ - -class TraceRow(QFrame): - removed = pyqtSignal(object) # self - changed = pyqtSignal() - - def __init__(self, trace: ChannelTrace, registry: DeviceRegistry): - super().__init__() - self.trace = trace - self.registry = registry - self.setObjectName("traceRow") - self._build() - - def _build(self): - lay = QHBoxLayout(self) - lay.setContentsMargins(6, 4, 6, 4) - lay.setSpacing(6) - - # Visibility checkbox - self._vis = QCheckBox() - self._vis.setChecked(self.trace.visible) - self._vis.setToolTip("Show/hide trace") - self._vis.toggled.connect(self._on_vis) - lay.addWidget(self._vis) - - # Color swatch - self._color_btn = ColorButton(self.trace.color) - self._color_btn.color_changed.connect(self._on_color) - lay.addWidget(self._color_btn) - - # Source label dev_id / ch_id - src = QLabel(f"{self.trace.device_id} / {self.trace.channel_id}") - src.setObjectName("traceSource") - src.setMinimumWidth(120) - lay.addWidget(src) - - # Label override - self._label_edit = QLineEdit(self.trace.label) - self._label_edit.setPlaceholderText("Label…") - self._label_edit.setObjectName("traceLabel") - self._label_edit.textChanged.connect(self._on_label) - lay.addWidget(self._label_edit, 1) - - # Line style - self._style_cb = QComboBox() - self._style_cb.addItems(["solid", "dash", "dot"]) - self._style_cb.setCurrentText(self.trace.style) - self._style_cb.setObjectName("traceStyleCb") - self._style_cb.setFixedWidth(64) - self._style_cb.currentTextChanged.connect(self._on_style) - lay.addWidget(self._style_cb) - - # Width - self._width_spin = QDoubleSpinBox() - self._width_spin.setRange(0.5, 6.0) - self._width_spin.setSingleStep(0.5) - self._width_spin.setValue(self.trace.width) - self._width_spin.setFixedWidth(56) - self._width_spin.setObjectName("traceWidthSpin") - self._width_spin.valueChanged.connect(self._on_width) - lay.addWidget(self._width_spin) - - # Remove button - rm = QToolButton() - rm.setText("✕") - rm.setObjectName("traceRemoveBtn") - rm.setFixedSize(22, 22) - rm.clicked.connect(lambda: self.removed.emit(self)) - lay.addWidget(rm) - - def _on_vis(self, v): self.trace.visible = v; self.changed.emit() - def _on_color(self, c): self.trace.color = c; self.changed.emit() - def _on_label(self, t): self.trace.label = t; self.changed.emit() - def _on_style(self, s): self.trace.style = s; self.changed.emit() - def _on_width(self, w): self.trace.width = w; self.changed.emit() - - -# ══════════════════════════════════════════════════════════════════════════════ -# Plot block widget (one collapsible section per PlotConfig) -# ══════════════════════════════════════════════════════════════════════════════ - -class PlotBlock(QFrame): - removed = pyqtSignal(object) # self - changed = pyqtSignal() - move_up = pyqtSignal(object) - move_dn = pyqtSignal(object) - - def __init__(self, plot_cfg: PlotConfig, registry: DeviceRegistry, index: int): - super().__init__() - self.plot_cfg = plot_cfg - self.registry = registry - self.index = index - self.setObjectName("plotBlock") - self._trace_rows: list[TraceRow] = [] - self._build() - - def _build(self): - outer = QVBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - outer.setSpacing(0) - - # ── Header bar ────────────────────────────────────────────── - hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) - hdr_lay = QHBoxLayout(hdr); hdr_lay.setContentsMargins(8, 0, 6, 0) - - self._title_edit = QLineEdit(self.plot_cfg.title) - self._title_edit.setObjectName("plotBlockTitle") - self._title_edit.textChanged.connect(self._on_title) - hdr_lay.addWidget(self._title_edit, 1) - - up_btn = QToolButton(); up_btn.setText("▲"); up_btn.setObjectName("plotMoveBtn") - up_btn.setFixedSize(22, 22); up_btn.clicked.connect(lambda: self.move_up.emit(self)) - dn_btn = QToolButton(); dn_btn.setText("▼"); dn_btn.setObjectName("plotMoveBtn") - dn_btn.setFixedSize(22, 22); dn_btn.clicked.connect(lambda: self.move_dn.emit(self)) - rm_btn = QToolButton(); rm_btn.setText("✕"); rm_btn.setObjectName("plotRemoveBtn") - rm_btn.setFixedSize(22, 22); rm_btn.clicked.connect(lambda: self.removed.emit(self)) - - for b in (up_btn, dn_btn, rm_btn): hdr_lay.addWidget(b) - outer.addWidget(hdr) - - # ── Settings row ──────────────────────────────────────────── - settings = QWidget(); settings.setObjectName("plotBlockSettings") - s_lay = QHBoxLayout(settings); s_lay.setContentsMargins(8, 6, 8, 6) - - s_lay.addWidget(QLabel("Y Label:")) - self._ylabel_edit = QLineEdit(self.plot_cfg.y_label) - self._ylabel_edit.setPlaceholderText("e.g. Voltage (V)") - self._ylabel_edit.setObjectName("plotYLabelEdit") - self._ylabel_edit.textChanged.connect(self._on_ylabel) - s_lay.addWidget(self._ylabel_edit) - - self._auto_chk = QCheckBox("Auto Y") - self._auto_chk.setChecked(self.plot_cfg.y_auto) - self._auto_chk.toggled.connect(self._on_auto) - s_lay.addWidget(self._auto_chk) - - s_lay.addWidget(QLabel("Min:")) - self._ymin = QDoubleSpinBox() - self._ymin.setRange(-1e9, 1e9); self._ymin.setValue(self.plot_cfg.y_min) - self._ymin.setFixedWidth(72); self._ymin.setEnabled(not self.plot_cfg.y_auto) - self._ymin.valueChanged.connect(self._on_ymin) - s_lay.addWidget(self._ymin) - - s_lay.addWidget(QLabel("Max:")) - self._ymax = QDoubleSpinBox() - self._ymax.setRange(-1e9, 1e9); self._ymax.setValue(self.plot_cfg.y_max) - self._ymax.setFixedWidth(72); self._ymax.setEnabled(not self.plot_cfg.y_auto) - self._ymax.valueChanged.connect(self._on_ymax) - s_lay.addWidget(self._ymax) - - self._grid_chk = QCheckBox("Grid") - self._grid_chk.setChecked(self.plot_cfg.grid) - self._grid_chk.toggled.connect(self._on_grid) - s_lay.addWidget(self._grid_chk) - - s_lay.addStretch() - outer.addWidget(settings) - - # ── Traces container ───────────────────────────────────────── - self._traces_widget = QWidget() - self._traces_widget.setObjectName("plotBlockTraces") - self._traces_layout = QVBoxLayout(self._traces_widget) - self._traces_layout.setContentsMargins(4, 2, 4, 4) - self._traces_layout.setSpacing(2) - - for tr in self.plot_cfg.traces: - self._add_trace_row(tr) - - # ── Add trace button ───────────────────────────────────────── - add_row = QHBoxLayout() - self._ch_picker = self._build_channel_picker() - add_row.addWidget(self._ch_picker, 1) - add_tr_btn = QPushButton("+ Add Trace") - add_tr_btn.setObjectName("addTraceBtn") - add_tr_btn.clicked.connect(self._on_add_trace) - add_row.addWidget(add_tr_btn) - self._traces_layout.addLayout(add_row) - - outer.addWidget(self._traces_widget) - - # ── Helpers ────────────────────────────────────────────────────────── - - def _build_channel_picker(self) -> QComboBox: - cb = QComboBox() - cb.setObjectName("channelPickerCb") - cb.setPlaceholderText("Select channel…") - for dev in self.registry.all_instances(): - for ch in dev.info.channels: - if not ch.enabled: - continue - label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})" - cb.addItem(label, userData=(dev.info.device_id, ch.channel_id, - ch.name, ch.color)) - return cb - - def _add_trace_row(self, trace: ChannelTrace): - row = TraceRow(trace, self.registry) - row.removed.connect(self._remove_trace_row) - row.changed.connect(self.changed) - self._trace_rows.append(row) - # Insert before the add-row (last item) - idx = self._traces_layout.count() - 1 - self._traces_layout.insertWidget(idx, row) - - def _remove_trace_row(self, row: TraceRow): - self.plot_cfg.traces.remove(row.trace) - self._trace_rows.remove(row) - self._traces_layout.removeWidget(row) - row.deleteLater() - self.changed.emit() - - def _on_add_trace(self): - data = self._ch_picker.currentData() - if data is None: - return - dev_id, ch_id, ch_name, ch_color = data - tr = ChannelTrace(device_id=dev_id, channel_id=ch_id, - label=ch_name, color=ch_color) - self.plot_cfg.traces.append(tr) - self._add_trace_row(tr) - self.changed.emit() - - # ── Config slots ───────────────────────────────────────────────────── - - def _on_title(self, t): self.plot_cfg.title = t; self.changed.emit() - def _on_ylabel(self, t): self.plot_cfg.y_label = t; self.changed.emit() - def _on_auto(self, v): - self.plot_cfg.y_auto = v - self._ymin.setEnabled(not v); self._ymax.setEnabled(not v) - self.changed.emit() - def _on_ymin(self, v): self.plot_cfg.y_min = v; self.changed.emit() - def _on_ymax(self, v): self.plot_cfg.y_max = v; self.changed.emit() - def _on_grid(self, v): self.plot_cfg.grid = v; self.changed.emit() - - -# ══════════════════════════════════════════════════════════════════════════════ -# PlotConfigWindow — the main floating builder -# ══════════════════════════════════════════════════════════════════════════════ - -class PlotConfigWindow(QWidget): - """ - Floating plot configuration builder. - - Emits `config_applied(ChartConfig)` when the user clicks Apply or - when live-preview is enabled and any setting changes. - """ - - config_applied = pyqtSignal(object) # ChartConfig - closed = pyqtSignal() - - def __init__(self, registry: DeviceRegistry, - current_config: Optional[ChartConfig] = None, - parent=None): - super().__init__( - parent, - Qt.WindowType.Window | Qt.WindowType.Tool, - ) - self.registry = registry - self.cfg = deepcopy(current_config) if current_config \ - else default_config(registry) - - self.setWindowTitle("Plot Configuration") - self.setMinimumSize(680, 560) - self.resize(760, 640) - self._blocks: list[PlotBlock] = [] - self._build() - self._populate() - - # ── Build shell ─────────────────────────────────────────────────────── - - def _build(self): - root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) - - # ── Top bar ────────────────────────────────────────────────── - topbar = QWidget(); topbar.setObjectName("cfgTopBar"); topbar.setFixedHeight(44) - tb_lay = QHBoxLayout(topbar); tb_lay.setContentsMargins(12, 0, 12, 0) - - title = QLabel("📈 PLOT CONFIGURATION") - title.setObjectName("cfgTopBarTitle") - tb_lay.addWidget(title, 1) - - self._live_chk = QCheckBox("Live preview") - self._live_chk.setChecked(False) - self._live_chk.setObjectName("cfgLiveChk") - tb_lay.addWidget(self._live_chk) - - root.addWidget(topbar) - - # ── Global settings strip ──────────────────────────────────── - glob = QWidget(); glob.setObjectName("cfgGlobalBar") - g_lay = QHBoxLayout(glob); g_lay.setContentsMargins(12, 6, 12, 6) - - g_lay.addWidget(QLabel("Time window:")) - self._win_spin = QDoubleSpinBox() - self._win_spin.setRange(1, 3600); self._win_spin.setSuffix(" s") - self._win_spin.setValue(self.cfg.time_window_s) - self._win_spin.setObjectName("cfgGlobalSpin") - self._win_spin.valueChanged.connect(self._on_global_change) - g_lay.addWidget(self._win_spin) - g_lay.addSpacing(16) - - self._linkx_chk = QCheckBox("Link X axes") - self._linkx_chk.setChecked(self.cfg.link_x) - self._linkx_chk.setObjectName("cfgLiveChk") - self._linkx_chk.toggled.connect(self._on_global_change) - g_lay.addWidget(self._linkx_chk) - - self._legend_chk = QCheckBox("Show legend") - self._legend_chk.setChecked(self.cfg.show_legend) - self._legend_chk.setObjectName("cfgLiveChk") - self._legend_chk.toggled.connect(self._on_global_change) - g_lay.addWidget(self._legend_chk) - - g_lay.addStretch() - - add_plot_btn = QPushButton("+ Add Plot Row") - add_plot_btn.setObjectName("addDeviceButton") - add_plot_btn.clicked.connect(self._add_plot) - g_lay.addWidget(add_plot_btn) - - root.addWidget(glob) - - # divider - div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) - div.setObjectName("devWindowDivider"); root.addWidget(div) - - # ── Scrollable plot blocks ─────────────────────────────────── - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - scroll.setObjectName("deviceScroll") - - self._container = QWidget() - self._blocks_lay = QVBoxLayout(self._container) - self._blocks_lay.setContentsMargins(10, 10, 10, 10) - self._blocks_lay.setSpacing(12) - self._blocks_lay.addStretch() - - scroll.setWidget(self._container) - root.addWidget(scroll, 1) - - # ── Bottom action bar ──────────────────────────────────────── - btm = QWidget(); btm.setObjectName("cfgBottomBar") - b_lay = QHBoxLayout(btm); b_lay.setContentsMargins(12, 8, 12, 8) - - self._reset_btn = QPushButton("⟳ Reset to Default") - self._reset_btn.setObjectName("configButton") - self._reset_btn.clicked.connect(self._reset_defaults) - b_lay.addWidget(self._reset_btn) - - self._export_btn = QPushButton("↓ Export JSON") - self._export_btn.setObjectName("configButton") - self._export_btn.clicked.connect(self._export_json) - b_lay.addWidget(self._export_btn) - - self._import_btn = QPushButton("↑ Import JSON") - self._import_btn.setObjectName("configButton") - self._import_btn.clicked.connect(self._import_json) - b_lay.addWidget(self._import_btn) - - b_lay.addStretch() - - self._apply_btn = QPushButton("✓ Apply") - self._apply_btn.setObjectName("applyButton") - self._apply_btn.clicked.connect(self._apply) - b_lay.addWidget(self._apply_btn) - - root.addWidget(btm) - - # ── Populate from config ─────────────────────────────────────────────── - - def _populate(self): - for blk in self._blocks: - self._blocks_lay.removeWidget(blk) - blk.deleteLater() - self._blocks.clear() - - for i, p in enumerate(self.cfg.plots): - self._insert_block(p, i) - - def _insert_block(self, plot_cfg: PlotConfig, index: int): - blk = PlotBlock(plot_cfg, self.registry, index) - blk.removed.connect(self._remove_block) - blk.changed.connect(self._on_block_changed) - blk.move_up.connect(self._move_block_up) - blk.move_dn.connect(self._move_block_dn) - self._blocks.append(blk) - self._blocks_lay.insertWidget(self._blocks_lay.count() - 1, blk) - - # ── Block management ────────────────────────────────────────────────── - - def _add_plot(self): - p = PlotConfig(title=f"Plot {len(self.cfg.plots) + 1}") - self.cfg.plots.append(p) - self._insert_block(p, len(self._blocks)) - self._maybe_live() - - def _remove_block(self, blk: PlotBlock): - if blk.plot_cfg in self.cfg.plots: - self.cfg.plots.remove(blk.plot_cfg) - self._blocks.remove(blk) - self._blocks_lay.removeWidget(blk) - blk.deleteLater() - self._maybe_live() - - def _move_block_up(self, blk: PlotBlock): - i = self._blocks.index(blk) - if i == 0: return - self.cfg.plots.insert(i - 1, self.cfg.plots.pop(i)) - self._blocks.insert(i - 1, self._blocks.pop(i)) - self._blocks_lay.removeWidget(blk) - self._blocks_lay.insertWidget(i - 1, blk) - self._maybe_live() - - def _move_block_dn(self, blk: PlotBlock): - i = self._blocks.index(blk) - if i >= len(self._blocks) - 1: return - self.cfg.plots.insert(i + 1, self.cfg.plots.pop(i)) - self._blocks.insert(i + 1, self._blocks.pop(i)) - self._blocks_lay.removeWidget(blk) - self._blocks_lay.insertWidget(i + 1, blk) - self._maybe_live() - - # ── Global settings ─────────────────────────────────────────────────── - - def _on_global_change(self): - self.cfg.time_window_s = self._win_spin.value() - self.cfg.link_x = self._linkx_chk.isChecked() - self.cfg.show_legend = self._legend_chk.isChecked() - self._maybe_live() - - def _on_block_changed(self): - self._maybe_live() - - def _maybe_live(self): - if self._live_chk.isChecked(): - self.config_applied.emit(deepcopy(self.cfg)) - - # ── Actions ─────────────────────────────────────────────────────────── - - def _apply(self): - self.config_applied.emit(deepcopy(self.cfg)) - - def _reset_defaults(self): - self.cfg = default_config(self.registry) - self._win_spin.setValue(self.cfg.time_window_s) - self._populate() - self._maybe_live() - - def _export_json(self): - from PyQt6.QtWidgets import QFileDialog - path, _ = QFileDialog.getSaveFileName( - self, "Export Plot Config", "plot_config.json", - "JSON Files (*.json)" - ) - if path: - with open(path, "w") as f: - f.write(self.cfg.to_json()) - - def _import_json(self): - from PyQt6.QtWidgets import QFileDialog - path, _ = QFileDialog.getOpenFileName( - self, "Import Plot Config", "", "JSON Files (*.json)" - ) - if path: - try: - with open(path) as f: - self.cfg = ChartConfig.from_json(f.read()) - self._win_spin.setValue(self.cfg.time_window_s) - self._populate() - self._maybe_live() - except Exception as e: - from PyQt6.QtWidgets import QMessageBox - QMessageBox.critical(self, "Import failed", str(e)) - - def closeEvent(self, event: QCloseEvent): - self.closed.emit() - event.accept() - - def update_registry(self): - """Call after devices are added/removed to refresh channel pickers.""" - self._populate() diff --git a/ui/strip_chart.py b/ui/strip_chart.py index 6c644be..bdffb91 100644 --- a/ui/strip_chart.py +++ b/ui/strip_chart.py @@ -54,8 +54,8 @@ _STYLES = { def _windowed(times_dq, values_dq, seconds: float): """Last `seconds` of a (times, values) deque pair, walking in from the - newest sample only — same trick as ChannelBuffer.window(), needed here - for derived-channel buffers which aren't wrapped in ChannelBuffer.""" + newest sample only — same trick as SignalBuffer.window(), needed here + for derived-channel buffers which aren't wrapped in SignalBuffer.""" if not times_dq: return [], [] cutoff = times_dq[-1] - seconds @@ -210,7 +210,7 @@ class StripChartWidget(QWidget): return dc.unit return "" dev = self.registry.get_instance(device_id) - ch = dev.get_channel(channel_id) if dev else None + ch = dev.get_signal(channel_id) if dev else None return ch.unit if ch else "" @pyqtSlot(str, str, str) @@ -246,7 +246,7 @@ class StripChartWidget(QWidget): ts_l, vs_l = _windowed(bufs[0], bufs[1], window) if not ts_l: return else: - buf = self.engine.get_buffer(device_id, channel_id) + buf = self.engine.get_signal_buffer(device_id, channel_id) if buf is None or len(buf) < 2: return ts_l, vs_l = buf.window(window) if not ts_l: return @@ -273,7 +273,7 @@ class StripChartWidget(QWidget): mn = min(len(x_arr), len(y_arr)) e["curve"].setData(x_arr[-mn:], y_arr[-mn:]) else: - xbuf = self.engine.get_buffer(x_dev, x_ch) + xbuf = self.engine.get_signal_buffer(x_dev, x_ch) if xbuf and len(xbuf) >= 2: _, xvs = xbuf.all() x_arr = np.array(xvs, dtype=np.float64) diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py index 87d7840..f581851 100644 --- a/ui/windows/channels_window.py +++ b/ui/windows/channels_window.py @@ -15,7 +15,7 @@ Classes: PipelineTab — splitter content (physical + virtual sections) ChannelPipelineBlock — collapsible filter+math stack for one physical channel ChannelPickerDialog — checkbox dialog for selecting channels to add - DerivedBlock — editor for one virtual/derived channel + ChannelBlock — editor for one virtual/derived channel FilterRow — single filter stage row inside a pipeline block SignalsListTab — unused; kept for profile-load compatibility """ @@ -35,7 +35,7 @@ from PyQt6.QtGui import QCloseEvent, QFont, QFontMetrics from devices.device_registry import DeviceRegistry from core.signal_processor import ( - SignalProcessor, ChannelPipeline, DerivedChannel, + SignalProcessor, ChannelPipeline, Channel, FilterBase, FILTER_CLASSES, ) from ui.windows.plot_window import _DragHandle, _DragReorderContainer @@ -60,8 +60,8 @@ def _channel_display_name(registry: DeviceRegistry, else: for dev in registry.all_instances(): if dev.info.device_id == dev_id: - for ch in dev.info.channels: - if ch.channel_id == ch_id: + for ch in dev.info.signals: + if ch.signal_id == ch_id: return ch.name if ch.name else ch_id return ch_id @@ -72,16 +72,16 @@ def _channel_combo(registry: DeviceRegistry, show_unit: bool = False) -> QComboBox: cb = QComboBox(); cb.setObjectName("channelPickerCb") for dev in registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: if not ch.enabled: continue - label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})" + label = f"Signal: {ch.name} ({dev.info.device_id}/{ch.signal_id})" if show_unit and ch.unit: label += f" [{ch.unit}]" - cb.addItem(label, userData=(dev.info.device_id, ch.channel_id)) + cb.addItem(label, userData=(dev.info.device_id, ch.signal_id)) if include_derived and processor: for dc in processor.get_derived(): - label = f"[derived] {dc.channel_id} ({dc.name})" + label = f"Channel: {dc.channel_id} ({dc.name})" if show_unit and dc.unit: label += f" [{dc.unit}]" cb.addItem(label, userData=("derived", dc.channel_id)) @@ -110,17 +110,17 @@ class ChannelPickerDialog(QDialog): any_available = False for dev in registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: if not ch.enabled: continue - if (dev.info.device_id, ch.channel_id) in already_shown: + if (dev.info.device_id, ch.signal_id) in already_shown: continue any_available = True - label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})" + label = f"{ch.name} ({dev.info.device_id}/{ch.signal_id})" if ch.unit: label += f" [{ch.unit}]" chk = QCheckBox(label) - self._checks.append((chk, dev.info.device_id, ch.channel_id)) + self._checks.append((chk, dev.info.device_id, ch.signal_id)) cl.addWidget(chk) if not any_available: @@ -237,7 +237,7 @@ class ChannelPipelineBlock(QFrame): self._unit = unit outer = QVBoxLayout(self); outer.setContentsMargins(0, 0, 0, 0); outer.setSpacing(0) - # Header (omitted when embedded in another block, e.g. DerivedBlock's + # Header (omitted when embedded in another block, e.g. ChannelBlock's # Filters section — the parent already provides a header/toggle) if self._show_header: hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) @@ -405,6 +405,7 @@ class ChannelPipelineBlock(QFrame): class PipelineTab(QWidget): changed = pyqtSignal() derived_changed = pyqtSignal() + channel_renamed = pyqtSignal(str, str, str, str) # old_dev, old_id, new_dev, new_id def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): super().__init__() @@ -412,7 +413,7 @@ class PipelineTab(QWidget): self.processor = processor self._shown: Set[Tuple[str, str]] = set() self._blocks: dict[Tuple[str, str], ChannelPipelineBlock] = {} - self._derived_blocks: List[DerivedBlock] = [] + self._derived_blocks: List[ChannelBlock] = [] self._color_idx = 0 self._build() @@ -494,9 +495,9 @@ class PipelineTab(QWidget): QTimer.singleShot(0, _init_split) for dev in self.registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: if ch.enabled: - self._add_block(dev.info.device_id, ch.channel_id, ch.name, ch.unit) + self._add_block(dev.info.device_id, ch.signal_id, ch.name, ch.unit) # ── Physical channel block management ────────────────────────────────────── @@ -516,7 +517,7 @@ class PipelineTab(QWidget): if key not in self._shown: dev = self.registry.get_instance(dev_id) if dev: - ch = dev.get_channel(ch_id) + ch = dev.get_signal(ch_id) if ch: self._add_block(dev_id, ch_id, ch.name, ch.unit) else: @@ -541,7 +542,7 @@ class PipelineTab(QWidget): if dev is None: self.on_device_removed(dev_id) return - valid = {ch.channel_id for ch in dev.info.channels} + valid = {ch.signal_id for ch in dev.info.signals} keys = [(d, c) for (d, c) in list(self._shown) if d == dev_id and c not in valid] for key in keys: @@ -560,7 +561,7 @@ class PipelineTab(QWidget): else: dev = self.registry.get_instance(dev_id) if dev: - ch = dev.get_channel(ch_id) + ch = dev.get_signal(ch_id) if ch and ch.enabled: self._add_block(dev_id, ch_id, ch_name, ch.unit) self._refresh_src_cb() @@ -580,13 +581,13 @@ class PipelineTab(QWidget): self._src_cb.clear() self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None) for dev in self.registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: if not ch.enabled: continue - label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})" - self._src_cb.addItem(label, userData=(dev.info.device_id, ch.channel_id)) + label = f"Signal: {ch.name} ({dev.info.device_id}/{ch.signal_id})" + self._src_cb.addItem(label, userData=(dev.info.device_id, ch.signal_id)) for dc in self.processor.get_derived(): - label = f"[derived] {dc.channel_id} ({dc.name})" + label = f"Channel: {dc.channel_id} ({dc.name})" self._src_cb.addItem(label, userData=("derived", dc.channel_id)) if current: for i in range(self._src_cb.count()): @@ -607,7 +608,7 @@ class PipelineTab(QWidget): dev = self.registry.get_instance(dev_id) if not dev: continue - ch = dev.get_channel(ch_id) + ch = dev.get_signal(ch_id) if not ch: continue self._add_block(dev_id, ch_id, ch.name, ch.unit) @@ -635,23 +636,27 @@ class PipelineTab(QWidget): src_name = f"Channel {len(self._derived_blocks) + 1}" name = _dedupe_name(src_name, existing_names) channel_id = _slug_id(name, existing_ids) - dc = DerivedChannel(channel_id=channel_id, name=name, + dc = Channel(channel_id=channel_id, name=name, kind="expression", color=color) blk = self._make_derived_block(dc) self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk) if src: blk._add_src(src) - def _make_derived_block(self, dc: DerivedChannel) -> DerivedBlock: - blk = DerivedBlock(dc, self.registry, self.processor) + def _make_derived_block(self, dc: Channel) -> ChannelBlock: + blk = ChannelBlock(dc, self.registry, self.processor) blk.removed.connect(self._rm_virtual) blk.applied.connect(lambda _: self.derived_changed.emit()) - blk.id_renamed.connect(self.refresh_derived) + blk.id_renamed.connect(self._on_block_id_renamed) blk._drag_handle.set_drag_source(self, blk) self._derived_blocks.append(blk) return blk - def _rm_virtual(self, blk: DerivedBlock): + def _on_block_id_renamed(self, old_id: str, new_id: str): + self.refresh_derived() + self.channel_renamed.emit("derived", old_id, "derived", new_id) + + def _rm_virtual(self, blk: ChannelBlock): self.processor.remove_derived(blk.dc.channel_id) self._derived_blocks.remove(blk) self._virt_inner.removeWidget(blk); blk.deleteLater() @@ -659,7 +664,7 @@ class PipelineTab(QWidget): # ── Drag-to-reorder — see _DragReorderContainer in ui/windows/plot_window.py - def _index_of(self, block: DerivedBlock) -> int: + def _index_of(self, block: ChannelBlock) -> int: return self._derived_blocks.index(block) def _drop_index(self, y: float) -> int: @@ -708,7 +713,7 @@ class PipelineTab(QWidget): self.derived_changed.emit() def refresh_derived(self): - """Rebuild DerivedBlock widgets from processor state (called after profile load).""" + """Rebuild ChannelBlock widgets from processor state (called after profile load).""" for blk in self._derived_blocks: self._virt_inner.removeWidget(blk); blk.deleteLater() self._derived_blocks.clear() @@ -723,7 +728,7 @@ class PipelineTab(QWidget): # ══════════════════════════════════════════════════════════════════════════════ -# Derived editor for one DerivedChannel +# Derived editor for one Channel # ══════════════════════════════════════════════════════════════════════════════ _BUILTIN = ["none", "scale_offset", "derivative", "second_derivative", "power", "rms", "difference", "sum"] @@ -806,13 +811,17 @@ def _sub_xrefs(code: str, names: list) -> str: return re.sub(r'\bx\[(\d+)\]', repl, code) -class DerivedBlock(QFrame): +class ChannelBlock(QFrame): removed = pyqtSignal(object) - applied = pyqtSignal(object) # DerivedChannel - id_renamed = pyqtSignal() # this Channel's ID changed — other blocks' - # source pickers may now be stale - - def __init__(self, dc: DerivedChannel, registry: DeviceRegistry, + applied = pyqtSignal(object) # Channel + id_renamed = pyqtSignal(str, str) # old_id, new_id — this Channel's ID + # changed; other blocks' source + # pickers, and everything outside + # this processor referencing it + # (plot traces, controls), may now + # be stale — see propagate_rename() + + def __init__(self, dc: Channel, registry: DeviceRegistry, processor: SignalProcessor): super().__init__() self.dc = dc; self.registry = registry; self.processor = processor @@ -1192,12 +1201,8 @@ class DerivedBlock(QFrame): self._st.setStyleSheet("color:#ef4444;") return renamed = channel_id != self._orig_id + old_id = self._orig_id if renamed: - # Other channels sourced from this one under the old ID would - # otherwise keep pointing at a reference that no longer exists — - # fix their sources up front, carry the ring buffer + latest - # value over so the rename doesn't reset their plotted history. - self.processor.rename_derived(self._orig_id, channel_id) self._orig_id = channel_id sources = [cb.currentData() for cb in self._src_combos if cb.currentData()] if self.dc.kind in ("derivative", "second_derivative"): @@ -1215,11 +1220,11 @@ class DerivedBlock(QFrame): elif self.dc.kind in ("function", "custom_script"): self.dc.script = self._code.toPlainText() err = self.processor.add_derived(self.dc) - # The rename above already committed regardless of whether this - # channel's own expression/script compiles — other blocks' source - # pickers need to refresh either way. + # Emitted regardless of whether this channel's own expression/script + # compiles — propagate_rename() (via id_renamed) still needs to run + # so other channels' sources / plot traces / controls don't dangle. if renamed: - self.id_renamed.emit() + self.id_renamed.emit(old_id, channel_id) if err: self._st.setText(f"⚠ {err[:100]}") self._st.setStyleSheet("color:#ef4444;") @@ -1261,12 +1266,12 @@ class SignalsListTab(QWidget): if it.widget(): it.widget().deleteLater() for dev in self.registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: dev_id_cap = dev.info.device_id - ch_id_cap = ch.channel_id + ch_id_cap = ch.signal_id self._lay.addWidget(self._make_row( color = ch.color, - name = f"{ch.channel_id} — {ch.name}", + name = f"{ch.signal_id} — {ch.name}", unit = ch.unit, source = dev_id_cap, enabled = ch.enabled, diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py index 604b6ed..62789f1 100644 --- a/ui/windows/config_window.py +++ b/ui/windows/config_window.py @@ -28,9 +28,9 @@ from PyQt6.QtGui import QCloseEvent, QColor, QShowEvent from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine -from core.signal_processor import SignalProcessor, DerivedChannel +from core.signal_processor import SignalProcessor, Channel -from ui.windows.devices_window import DeviceRow, ChannelsTab +from ui.windows.devices_window import DeviceRow, SignalsTab from ui.windows.channels_window import PipelineTab, _slug_id, _dedupe_name from ui.windows.plot_window import ( PlotWindow, LayoutConfig, LayoutCanvas, PaneBlock, @@ -43,8 +43,8 @@ from ui.namespace_panel import NamespacePanel # Enhanced signals table — adds Dir badge and pipeline ⚙ button # ══════════════════════════════════════════════════════════════════════════════ -class _SignalsTab(ChannelsTab): - """ChannelsTab with a Dir (READ/WRITE) badge. Operations (filters/calibration/math) +class _SignalsTab(SignalsTab): + """SignalsTab with a Dir (READ/WRITE) badge. Operations (filters/calibration/math) are configured per Channel in the Channels pane, not here.""" _C_DEVICE = 0 @@ -58,6 +58,7 @@ class _SignalsTab(ChannelsTab): _C_MKCH = 8 # "+ Channel" button channel_created = pyqtSignal() + channel_renamed = pyqtSignal(str, str, str, str) # old_dev, old_id, new_dev, new_id def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): self._processor = processor @@ -91,7 +92,7 @@ class _SignalsTab(ChannelsTab): self._table.setRowCount(0) row = 0 for dev in self.registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: self._table.insertRow(row) dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id}") @@ -99,8 +100,8 @@ class _SignalsTab(ChannelsTab): dev_item.setForeground(QColor("#64748b")) self._table.setItem(row, self._C_DEVICE, dev_item) - ch_item = QTableWidgetItem(ch.channel_id) - ch_item.setFlags(ch_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + ch_item = QTableWidgetItem(ch.signal_id) + ch_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.signal_id)) ch_item.setForeground(QColor(ch.color)) self._table.setItem(row, self._C_CH_ID, ch_item) @@ -122,7 +123,7 @@ class _SignalsTab(ChannelsTab): chk.setEnabled(False) else: dev_id_cap = dev.info.device_id - ch_id_cap = ch.channel_id + ch_id_cap = ch.signal_id chk.toggled.connect( lambda checked, d=dev_id_cap, c=ch_id_cap: self._on_enabled(d, c, checked) @@ -131,7 +132,7 @@ class _SignalsTab(ChannelsTab): self._table.setCellWidget(row, self._C_ENABLED, chk_w) name_item = QTableWidgetItem(ch.name) - name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id)) + name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.signal_id)) self._table.setItem(row, self._C_NAME, name_item) self._table.setItem(row, self._C_UNIT, QTableWidgetItem(ch.unit)) @@ -148,7 +149,7 @@ class _SignalsTab(ChannelsTab): mkch_btn.setFixedSize(24, 22) mkch_btn.setToolTip("Create a Channel from this Signal") d_cap, c_cap, n_cap, u_cap, col_cap = ( - dev.info.device_id, ch.channel_id, ch.name, ch.unit, ch.color + dev.info.device_id, ch.signal_id, ch.name, ch.unit, ch.color ) mkch_btn.clicked.connect( lambda _=False, d=d_cap, c=c_cap, n=n_cap, u=u_cap, col=col_cap: @@ -161,6 +162,49 @@ class _SignalsTab(ChannelsTab): self._table.blockSignals(False) + def _on_cell_changed(self, row: int, col: int): + if col == self._C_CH_ID: + self._on_id_edited(row) + return + super()._on_cell_changed(row, col) + + def _on_id_edited(self, row: int): + """Signal IDs are stable/backend-facing by default (see + devices/base_device.py::SignalConfig) but user-editable here, same + as a derived Channel's ID — renaming propagates through + reference_registry.propagate_rename() (wired via channel_renamed) + so plot traces / controls / pipelines pointing at the old id follow + it instead of going dangling (the bug that motivated this feature: + an Arduino analog pin remap used to change the id outright).""" + id_item = self._table.item(row, self._C_CH_ID) + if id_item is None: + return + ref = id_item.data(Qt.ItemDataRole.UserRole) + if ref is None: + return + dev_id, old_id = ref + new_id = id_item.text().strip() + if not new_id or new_id == old_id: + self._table.blockSignals(True) + id_item.setText(old_id) + self._table.blockSignals(False) + return + dev = self.registry.get_instance(dev_id) + if not dev or any(s.signal_id == new_id for s in dev.info.signals if s.signal_id != old_id): + self._table.blockSignals(True) + id_item.setText(old_id) + self._table.blockSignals(False) + return + ch = dev.get_signal(old_id) + if not ch: + return + ch.signal_id = new_id + id_item.setData(Qt.ItemDataRole.UserRole, (dev_id, new_id)) + name_item = self._table.item(row, self._C_NAME) + if name_item is not None: + name_item.setData(Qt.ItemDataRole.UserRole, (dev_id, new_id)) + self.channel_renamed.emit(dev_id, old_id, dev_id, new_id) + def _on_create_channel(self, dev_id: str, ch_id: str, ch_name: str, unit: str, color: str): """Create a passthrough Channel (kind="none") from this Signal, named after it — the "1 Signal -> 1 Channel" quick path. No-ops if @@ -173,7 +217,7 @@ class _SignalsTab(ChannelsTab): existing_ids = {d.channel_id for d in existing} name = _dedupe_name(ch_name, existing_names) channel_id = _slug_id(name, existing_ids) - dc = DerivedChannel(channel_id=channel_id, name=name, kind="none", + dc = Channel(channel_id=channel_id, name=name, kind="none", sources=[(dev_id, ch_id)], source_names=[ch_name], unit=unit, color=color or "#00d4ff") err = self._processor.add_derived(dc) @@ -275,6 +319,7 @@ class ConfigWindow(QWidget): channel_unit_changed = pyqtSignal(str, str, str) pipeline_changed = pyqtSignal() derived_changed = pyqtSignal() + channel_renamed = pyqtSignal(str, str, str, str) # old_dev, old_id, new_dev, new_id layout_applied = pyqtSignal(object) closed = pyqtSignal() @@ -360,6 +405,7 @@ class ConfigWindow(QWidget): self._signals_tab.channel_visibility_changed.connect(self._on_sig_visibility) self._signals_tab.channel_name_changed.connect(self._on_sig_name) self._signals_tab.channel_unit_changed.connect(self._on_sig_unit) + self._signals_tab.channel_renamed.connect(self.channel_renamed) sig_l.addWidget(self._signals_tab, 1) splitter.addWidget(sig_w) @@ -468,6 +514,7 @@ class ConfigWindow(QWidget): self._pipeline_tab = PipelineTab(self.registry, self.processor) self._pipeline_tab.changed.connect(self.pipeline_changed) self._pipeline_tab.derived_changed.connect(self.derived_changed) + self._pipeline_tab.channel_renamed.connect(self.channel_renamed) self._pipeline_tab.refresh_derived() # load derived channels already in processor # (e.g. window built fresh after profile load) inner_splitters = self._pipeline_tab.findChildren(QSplitter) diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py index ae88c6b..5d47681 100644 --- a/ui/windows/devices_window.py +++ b/ui/windows/devices_window.py @@ -5,7 +5,7 @@ DEVICES window — toolbar section 2. Tabs: Physical Devices — add / remove / connect devices; shows status cards - Channels — per-device channel list: rename, set unit, enable/disable + Signals — per-device signal list: rename, set unit, enable/disable """ from PyQt6.QtWidgets import ( @@ -84,7 +84,7 @@ class DeviceRow(QFrame): self._status_lbl = QLabel(_STATUS_TEXT.get(self.device.status, "?")) self._status_lbl.setObjectName("deviceSub") self._set_status_style() - n = sum(1 for c in self.device.info.channels if c.enabled) + n = sum(1 for c in self.device.info.signals if c.enabled) ch_lbl = QLabel(f"{n} ch") ch_lbl.setObjectName("deviceChannelCount") stat_col.addWidget(self._status_lbl) @@ -129,7 +129,7 @@ class DeviceRow(QFrame): ) -class ChannelsTab(QWidget): +class SignalsTab(QWidget): """Shows all channels across all devices in an editable table.""" channel_visibility_changed = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled @@ -174,12 +174,12 @@ class ChannelsTab(QWidget): self._table.setRowCount(0) row = 0 for dev in self.registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: self._table.insertRow(row) # Device + signal ID folded into one non-editable column — # the separate "Signal ID" column was removed as redundant. - dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id} / {ch.channel_id}") + dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id} / {ch.signal_id}") dev_item.setFlags(dev_item.flags() & ~Qt.ItemFlag.ItemIsEditable) dev_item.setForeground(QColor("#64748b")) self._table.setItem(row, self._C_DEVICE, dev_item) @@ -192,7 +192,7 @@ class ChannelsTab(QWidget): chk = QCheckBox() chk.setChecked(ch.enabled) dev_id_cap = dev.info.device_id - ch_id_cap = ch.channel_id + ch_id_cap = ch.signal_id chk.toggled.connect( lambda checked, d=dev_id_cap, c=ch_id_cap: self._on_enabled(d, c, checked) ) @@ -200,7 +200,7 @@ class ChannelsTab(QWidget): self._table.setCellWidget(row, self._C_ENABLED, chk_container) name_item = QTableWidgetItem(ch.name) - name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id)) + name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.signal_id)) self._table.setItem(row, self._C_NAME, name_item) unit_item = QTableWidgetItem(ch.unit) @@ -218,7 +218,7 @@ class ChannelsTab(QWidget): def _on_enabled(self, dev_id: str, ch_id: str, checked: bool): dev = self.registry.get_instance(dev_id) if dev: - ch = dev.get_channel(ch_id) + ch = dev.get_signal(ch_id) if ch: ch.enabled = checked self.channel_visibility_changed.emit(dev_id, ch_id, checked) @@ -234,7 +234,7 @@ class ChannelsTab(QWidget): dev = self.registry.get_instance(dev_id) if not dev: return - ch = dev.get_channel(ch_id) + ch = dev.get_signal(ch_id) if not ch: return if col == self._C_NAME: @@ -318,7 +318,7 @@ class DevicesWindow(QWidget): tabs.addTab(dev_tab, " Physical Devices ") # Tab 2: Channels - self._ch_tab = ChannelsTab(self.registry) + self._ch_tab = SignalsTab(self.registry) self._ch_tab.channel_visibility_changed.connect(self.channel_visibility_changed) self._ch_tab.channel_name_changed.connect(self.channel_name_changed) self._ch_tab.channel_unit_changed.connect(self.channel_unit_changed) diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index e810365..c0aea4e 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -341,18 +341,18 @@ class TraceChannelPickerDialog(QDialog): any_available = False for dev in registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: if not ch.enabled: continue any_available = True - chk = QCheckBox(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})") - self._checks.append((chk, dev.info.device_id, ch.channel_id, ch.name, ch.color)) + chk = QCheckBox(f"Signal: {ch.name} ({dev.info.device_id}/{ch.signal_id})") + self._checks.append((chk, dev.info.device_id, ch.signal_id, ch.name, ch.color)) cl.addWidget(chk) for dc in processor.get_derived(): if not dc.enabled: continue any_available = True - chk = QCheckBox(f"{dc.name} ([virtual]/{dc.channel_id})") + chk = QCheckBox(f"Channel: {dc.name} ([virtual]/{dc.channel_id})") self._checks.append((chk, "derived", dc.channel_id, dc.name, dc.color)) cl.addWidget(chk) @@ -498,15 +498,15 @@ class PaneBlock(QFrame): self._x_cb.setMinimumWidth(150) self._x_cb.addItem("⏱ Time (elapsed s)", userData="time") for dev in self.registry.all_instances(): - for ch in dev.info.channels: + for ch in dev.info.signals: if not ch.enabled: continue - self._x_cb.addItem(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})", - userData=f"{dev.info.device_id}/{ch.channel_id}") + self._x_cb.addItem(f"Signal: {ch.name} ({dev.info.device_id}/{ch.signal_id})", + userData=f"{dev.info.device_id}/{ch.signal_id}") for dc in self.processor.get_derived(): if not dc.enabled: continue - self._x_cb.addItem(f"{dc.name} ([virtual]/{dc.channel_id})", + self._x_cb.addItem(f"Channel: {dc.name} ([virtual]/{dc.channel_id})", userData=f"derived/{dc.channel_id}") for i in range(self._x_cb.count()): if self._x_cb.itemData(i) == self.spec.x_source: |
