summaryrefslogtreecommitdiff
path: root/ui/plot_config.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-09-19 17:38:07 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-09-19 17:38:07 -0600
commitf3327d56d48d63b21ffc03a55fa5e7db753d1707 (patch)
treee19d4c7846dd4a434f8a18f82290597731b4a12c /ui/plot_config.py
parent00671a90bb51bfe485d98a928a456d55adbe1faf (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/plot_config.py')
-rw-r--r--ui/plot_config.py631
1 files changed, 0 insertions, 631 deletions
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()