summaryrefslogtreecommitdiff
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
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).
-rw-r--r--api_layers/nidaqmx_layer.py4
-rw-r--r--api_layers/port_registry.py12
-rw-r--r--core/__init__.py4
-rw-r--r--core/acquisition.py60
-rw-r--r--core/profile.py166
-rw-r--r--core/reference_registry.py74
-rw-r--r--core/signal_processor.py60
-rw-r--r--devices/__init__.py4
-rw-r--r--devices/analog_input.py461
-rw-r--r--devices/arduino_device.py136
-rw-r--r--devices/base_device.py22
-rw-r--r--devices/digital_io.py334
-rw-r--r--devices/nidaqmx_device.py44
-rw-r--r--devices/serial_device.py66
-rw-r--r--plugins/motion_capture/device.py12
-rw-r--r--ui/config_dialog.py2
-rw-r--r--ui/control_editor.py6
-rw-r--r--ui/control_panel.py7
-rw-r--r--ui/device_panel.py148
-rw-r--r--ui/devices_window.py224
-rw-r--r--ui/main_window.py25
-rw-r--r--ui/namespace_panel.py6
-rw-r--r--ui/plot_builder.py510
-rw-r--r--ui/plot_config.py631
-rw-r--r--ui/strip_chart.py10
-rw-r--r--ui/windows/channels_window.py105
-rw-r--r--ui/windows/config_window.py69
-rw-r--r--ui/windows/devices_window.py20
-rw-r--r--ui/windows/plot_window.py16
29 files changed, 561 insertions, 2677 deletions
diff --git a/api_layers/nidaqmx_layer.py b/api_layers/nidaqmx_layer.py
index ba40efb..e0fc670 100644
--- a/api_layers/nidaqmx_layer.py
+++ b/api_layers/nidaqmx_layer.py
@@ -6,8 +6,8 @@ NI-DAQmx API abstraction layer.
• When nidaqmx package + NI runtime are present → uses real hardware
• Otherwise → falls back to simulation
-Swap this layer by changing the `backend` parameter on AnalogInputDevice
-or by subclassing NidaqmxLayer and overriding _hw_read().
+Used by devices/nidaqmx_device.py::NidaqmxDevice. Swap it out by
+subclassing NidaqmxLayer and overriding _hw_read().
Usage example:
from api_layers.nidaqmx_layer import NidaqmxLayer
diff --git a/api_layers/port_registry.py b/api_layers/port_registry.py
index e3c85fd..4328bb2 100644
--- a/api_layers/port_registry.py
+++ b/api_layers/port_registry.py
@@ -3,10 +3,10 @@ api_layers/port_registry.py
PortRegistry — ensures one ArduinoLayer per physical serial port.
-Problem: if both an AnalogInputDevice and a DigitalIODevice are
-configured for the same Arduino (/dev/ttyUSB0), they would each
-create their own ArduinoLayer and both try to open the same serial
-port → "device reports readiness to read but returned no data".
+Problem: if two ArduinoDevice instances are configured for the same
+physical Arduino (/dev/ttyUSB0), they would each create their own
+ArduinoLayer and both try to open the same serial port → "device
+reports readiness to read but returned no data".
Solution: all devices call PortRegistry.get_layer(port, baud) instead
of constructing ArduinoLayer directly. The registry returns an
@@ -14,9 +14,9 @@ existing layer if one is already open on that port, or creates a new
one. All reads/writes go through the single shared layer.
The registry also merges analog_pins across devices so the layer
-caches values for all channels on the Arduino.
+caches values for all signals on the Arduino.
-Usage (handled automatically by AnalogInputDevice and DigitalIODevice):
+Usage (handled automatically by ArduinoDevice):
from api_layers.port_registry import port_registry
layer = port_registry.get_layer("/dev/ttyUSB0", 115200, simulate=False)
diff --git a/core/__init__.py b/core/__init__.py
index 06fabd5..ba8c2b8 100644
--- a/core/__init__.py
+++ b/core/__init__.py
@@ -1,3 +1,3 @@
# core/__init__.py
-from core.acquisition import AcquisitionEngine, ChannelBuffer
-__all__ = ["AcquisitionEngine", "ChannelBuffer"]
+from core.acquisition import AcquisitionEngine, SignalBuffer
+__all__ = ["AcquisitionEngine", "SignalBuffer"]
diff --git a/core/acquisition.py b/core/acquisition.py
index f54f265..eeeb4c8 100644
--- a/core/acquisition.py
+++ b/core/acquisition.py
@@ -21,7 +21,7 @@ from devices.base_device import BaseDevice, DeviceStatus
MAX_BUFFER = 20_000
-class ChannelBuffer:
+class SignalBuffer:
"""Circular time-series buffer for one channel."""
def __init__(self, maxlen: int = MAX_BUFFER):
@@ -70,10 +70,10 @@ class AcquisitionEngine(QObject):
Signals
-------
- new_data(timestamp, readings) # readings: list[(device_id, channel_id, value)] —
+ new_data(timestamp, readings) # readings: list[(device_id, signal_id, value)] —
# one batched emit per poll tick, not one per channel
device_status_changed(device_id, status_str)
- channel_unit_changed(device_id, channel_id, new_unit) # e.g. Mark-10 gauge
+ channel_unit_changed(device_id, signal_id, new_unit) # e.g. Mark-10 gauge
# switched lb/kgF/N/ozF on its own front panel
log_started(filepath)
log_stopped(filepath)
@@ -89,7 +89,7 @@ class AcquisitionEngine(QObject):
super().__init__()
self._interval = poll_interval_ms / 1000.0
self._devices: List[BaseDevice] = []
- self._buffers: Dict[str, Dict[str, ChannelBuffer]] = {}
+ self._buffers: Dict[str, Dict[str, SignalBuffer]] = {}
self._last_units: Dict[Tuple[str, str], str] = {}
self._last_status: Dict[str, str] = {}
self._running = False
@@ -106,11 +106,11 @@ class AcquisitionEngine(QObject):
def add_device(self, dev: BaseDevice):
self._devices.append(dev)
self._buffers[dev.info.device_id] = {
- ch.channel_id: ChannelBuffer()
- for ch in dev.info.channels
+ ch.signal_id: SignalBuffer()
+ for ch in dev.info.signals
}
- for ch in dev.info.channels:
- self._last_units[(dev.info.device_id, ch.channel_id)] = ch.unit
+ for ch in dev.info.signals:
+ self._last_units[(dev.info.device_id, ch.signal_id)] = ch.unit
def on_device_reconfigured(self, device_id: str):
"""Reconcile buffers with a device's current channel list — reconfigure()
@@ -120,13 +120,13 @@ class AcquisitionEngine(QObject):
if dev is None:
return
bufs = self._buffers.setdefault(device_id, {})
- valid_ids = {ch.channel_id for ch in dev.info.channels}
+ valid_ids = {ch.signal_id for ch in dev.info.signals}
for stale_id in [cid for cid in bufs if cid not in valid_ids]:
bufs.pop(stale_id, None)
- for ch in dev.info.channels:
- if ch.channel_id not in bufs:
- bufs[ch.channel_id] = ChannelBuffer()
- self._last_units[(device_id, ch.channel_id)] = ch.unit
+ for ch in dev.info.signals:
+ if ch.signal_id not in bufs:
+ bufs[ch.signal_id] = SignalBuffer()
+ self._last_units[(device_id, ch.signal_id)] = ch.unit
for key in [k for k in self._last_units if k[0] == device_id and k[1] not in valid_ids]:
self._last_units.pop(key, None)
@@ -137,8 +137,22 @@ class AcquisitionEngine(QObject):
for key in [k for k in self._last_units if k[0] == device_id]:
self._last_units.pop(key, None)
- def get_buffer(self, device_id: str, channel_id: str) -> Optional[ChannelBuffer]:
- return self._buffers.get(device_id, {}).get(channel_id)
+ def get_signal_buffer(self, device_id: str, signal_id: str) -> Optional[SignalBuffer]:
+ return self._buffers.get(device_id, {}).get(signal_id)
+
+ def rename_channel(self, device_id: str, old_id: str, new_id: str) -> None:
+ """Move a channel's buffer/unit-cache entry to a new id after a rename
+ — mirrors on_device_reconfigured's bookkeeping but for a single
+ in-place rename rather than a full channel-list diff, so history is
+ carried over instead of dropped."""
+ if old_id == new_id:
+ return
+ bufs = self._buffers.get(device_id)
+ if bufs and old_id in bufs:
+ bufs[new_id] = bufs.pop(old_id)
+ key = (device_id, old_id)
+ if key in self._last_units:
+ self._last_units[(device_id, new_id)] = self._last_units.pop(key)
def clear_history(self):
for ch_map in self._buffers.values():
@@ -181,8 +195,8 @@ class AcquisitionEngine(QObject):
self._csv_file = open(filepath, "w", newline="")
headers = ["elapsed_s"]
for dev in self._devices:
- for ch in dev.info.channels:
- headers.append(f"{dev.info.device_id}/{ch.channel_id}[{ch.unit}]")
+ for ch in dev.info.signals:
+ headers.append(f"{dev.info.device_id}/{ch.signal_id}[{ch.unit}]")
self._csv_writer = csv.writer(self._csv_file)
self._csv_writer.writerow(headers)
self._logging = True
@@ -225,21 +239,21 @@ class AcquisitionEngine(QObject):
print(f"[Acq] {dev.info.device_id} read error: {e}")
readings = {}
- for ch in dev.info.channels:
- key = (dev.info.device_id, ch.channel_id)
+ for ch in dev.info.signals:
+ key = (dev.info.device_id, ch.signal_id)
if self._last_units.get(key) != ch.unit:
self._last_units[key] = ch.unit
- self.channel_unit_changed.emit(dev.info.device_id, ch.channel_id, ch.unit)
+ self.channel_unit_changed.emit(dev.info.device_id, ch.signal_id, ch.unit)
- val = readings.get(ch.channel_id)
+ val = readings.get(ch.signal_id)
if val is None or not ch.enabled:
log_row.append("")
continue
fval = float(val)
- buf = self._buffers.get(dev.info.device_id, {}).get(ch.channel_id)
+ buf = self._buffers.get(dev.info.device_id, {}).get(ch.signal_id)
if buf is not None:
buf.append(elapsed, fval)
- batch.append((dev.info.device_id, ch.channel_id, fval))
+ batch.append((dev.info.device_id, ch.signal_id, fval))
log_row.append(f"{fval:.5f}")
if batch:
diff --git a/core/profile.py b/core/profile.py
index c0ae5f1..c77e646 100644
--- a/core/profile.py
+++ b/core/profile.py
@@ -3,20 +3,28 @@ core/profile.py
Lab profile — saves and loads the complete operator configuration:
- • Controls — all control widgets (type, title, device, channel, params)
- • Channels — per-channel visibility, name overrides
- • Signal pipelines — filter stacks per channel
- • Derived channels — all virtual/computed channels
- • Plot layout — pane arrangement, traces, axes, time window
+ • Controls — all control widgets (type, title, device, signal, params)
+ • Signals — per-signal visibility, name overrides (physical device channels)
+ • Signal pipelines — filter stacks per signal
+ • Channels — all derived/virtual channels (Signals + math -> a new stream)
+ • Plot layout — pane arrangement, traces, axes, time window
Profiles are stored as human-readable JSON files (.labui).
+
+Schema v2 renamed two sections from v1 to match the Signal/Channel naming
+split (devices/base_device.py, core/signal_processor.py): the old "channels"
+section (physical overrides) is now "signals", and the old "derived" section
+(virtual channels) is now "channels". `_migrate_legacy_profile()` upgrades a
+v1 file transparently on load, including remapping Arduino analog Signal ids
+that used to be the raw pin name (e.g. "A0") to the stable positional id
+("ai0") introduced alongside this schema — see devices/arduino_device.py.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field, asdict
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Tuple
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -26,54 +34,80 @@ def _safe(d: dict, key: str, default=None):
# ══════════════════════════════════════════════════════════════════════════════
-# Profile data model
+# Legacy (pre-2.0) schema migration
# ══════════════════════════════════════════════════════════════════════════════
-@dataclass
-class ProfileChannelOverride:
- """Per-channel settings that survive device reconnects."""
- device_id: str
- channel_id: str
- name: str = ""
- unit: str = ""
- enabled: bool = True
- color: str = ""
-
-
-@dataclass
-class ProfilePipeline:
- """Serialised filter pipeline for one channel."""
- device_id: str
- channel_id: str
- enabled: bool = True
- filters: List[Dict[str, Any]] = field(default_factory=list)
- # filters = [{"type": "low_pass", "alpha": 0.1}, ...]
-
+def _migrate_legacy_profile(raw: dict) -> dict:
+ """Upgrade a pre-2.0 profile dict to the current schema. Returns a new
+ dict; does not mutate the input. Two independent changes bundled
+ together since both only matter for old files:
+
+ 1. Section rename: "channels" (physical overrides) -> "signals", with
+ its "channel_id" keys -> "signal_id"; "derived" (virtual channel
+ defs) -> "channels". The "channels" (ex-"derived") section's own
+ "channel_id" field is untouched — a Channel's own id field was
+ never renamed, only what it's called at the top level.
+
+ 2. Arduino analog Signal ids used to BE the pin name ("A0") instead of
+ a stable positional id ("ai0") that survives a pin remap (see
+ devices/arduino_device.py::_build_channels). Every reference to an
+ old pin-name id is rewritten to the new positional id, using each
+ Arduino device's saved `analog_pins` order to know which pin became
+ which "ai<N>".
+ """
+ raw = dict(raw)
+
+ derived = raw.pop("derived", [])
+ signals = raw.pop("channels", [])
+ for s in signals:
+ if "channel_id" in s:
+ s["signal_id"] = s.pop("channel_id")
+ raw["signals"] = signals
+ raw["channels"] = derived
+
+ pin_to_id: Dict[Tuple[str, str], str] = {} # (device_id, old_pin) -> new positional id
+ for dev_cfg in raw.get("devices", []):
+ if dev_cfg.get("device_type") != "arduino":
+ continue
+ dev_id = dev_cfg.get("device_id", "")
+ for i, pin in enumerate(dev_cfg.get("analog_pins", [])):
+ pin_to_id[(dev_id, pin)] = f"ai{i}"
+
+ if pin_to_id:
+ def _remap(dev_id: str, ch_id: str) -> str:
+ return pin_to_id.get((dev_id, ch_id), ch_id)
+
+ for s in raw["signals"]:
+ s["signal_id"] = _remap(s.get("device_id", ""), s.get("signal_id", ""))
+ for pd in raw.get("pipelines", []):
+ pd["channel_id"] = _remap(pd.get("device_id", ""), pd.get("channel_id", ""))
+ for dc in raw["channels"]:
+ dc["sources"] = [[d, _remap(d, c)] for d, c in dc.get("sources", [])]
+ for c in raw.get("controls", []):
+ c["channel_id"] = _remap(c.get("device_id", ""), c.get("channel_id", ""))
+ plot = raw.get("plot")
+ if plot:
+ for pane in plot.get("panes", []):
+ for tr in pane.get("traces", []):
+ tr["channel_id"] = _remap(tr.get("device_id", ""), tr.get("channel_id", ""))
+
+ raw["version"] = "2.0"
+ return raw
-@dataclass
-class ProfileDerived:
- """Serialised derived/virtual channel."""
- channel_id: str
- name: str
- unit: str = ""
- color: str = "#f72585"
- kind: str = "expression"
- sources: List = field(default_factory=list) # [(dev_id, ch_id), ...]
- expression: str = ""
- script: str = ""
- params: Dict = field(default_factory=dict)
- enabled: bool = True
+# ══════════════════════════════════════════════════════════════════════════════
+# Profile data model
+# ══════════════════════════════════════════════════════════════════════════════
@dataclass
class Profile:
name: str = "Untitled Profile"
- version: str = "1.0"
+ version: str = "2.0"
devices: List[Dict] = field(default_factory=list)
controls: List[Dict] = field(default_factory=list)
- channels: List[Dict] = field(default_factory=list)
+ signals: List[Dict] = field(default_factory=list)
pipelines: List[Dict] = field(default_factory=list)
- derived: List[Dict] = field(default_factory=list)
+ channels: List[Dict] = field(default_factory=list)
plot: Optional[Dict] = None # LayoutConfig.to_json()
settings: Dict[str, Any] = field(default_factory=dict)
plugin_state: Dict[str, Any] = field(default_factory=dict)
@@ -91,14 +125,16 @@ class Profile:
@staticmethod
def from_json(s: str) -> "Profile":
d = json.loads(s)
+ if not str(d.get("version", "1.0")).startswith("2."):
+ d = _migrate_legacy_profile(d)
return Profile(
name = d.get("name", ""),
- version = d.get("version", "1.0"),
+ version = d.get("version", "2.0"),
devices = d.get("devices", []),
controls = d.get("controls", []),
- channels = d.get("channels", []),
+ signals = d.get("signals", []),
pipelines = d.get("pipelines", []),
- derived = d.get("derived", []),
+ channels = d.get("channels", []),
plot = d.get("plot"),
settings = d.get("settings", {}),
plugin_state = d.get("plugin_state", {}),
@@ -171,12 +207,12 @@ class ProfileManager:
# Controls
p.controls = [spec.to_dict() for spec in control_specs]
- # Channel overrides
+ # Signal overrides
for dev in registry.all_instances():
- for ch in dev.info.channels:
- p.channels.append({
+ for ch in dev.info.signals:
+ p.signals.append({
"device_id": dev.info.device_id,
- "channel_id": ch.channel_id,
+ "signal_id": ch.signal_id,
"name": ch.name,
"unit": ch.unit,
"enabled": ch.enabled,
@@ -192,9 +228,9 @@ class ProfileManager:
"filters": [f.to_dict() for f in pipeline.filters],
})
- # Derived channels
+ # Channels (derived)
for dc in processor.get_derived():
- p.derived.append({
+ p.channels.append({
"channel_id": dc.channel_id,
"name": dc.name,
"unit": dc.unit,
@@ -263,7 +299,7 @@ class ProfileManager:
"""Apply a loaded profile to the live application state."""
from ui.control_editor import ControlSpec
from core.signal_processor import (
- ChannelPipeline, DerivedChannel,
+ ChannelPipeline, Channel,
filter_from_dict,
)
@@ -284,18 +320,6 @@ class ProfileManager:
_DEVICE_FACTORIES["serial"] = SerialDevice
except Exception:
pass
- # Backward compatibility with old profiles
- try:
- from devices.analog_input import AnalogInputDevice
- _DEVICE_FACTORIES["analog_input"] = AnalogInputDevice
- except Exception:
- pass
- try:
- from devices.digital_io import DigitalIODevice
- _DEVICE_FACTORIES["digital_io"] = DigitalIODevice
- except Exception:
- pass
-
# Clear all existing devices — profile defines the complete device set
for dev in list(registry.all_instances()):
try:
@@ -362,12 +386,12 @@ class ProfileManager:
except Exception as e:
print(f"[Profile] Could not restore device {dev_id}: {e}")
- # ── Channel overrides ────────────────────────────────────────────
- for ch_data in profile.channels:
+ # ── Signal overrides ─────────────────────────────────────────────
+ for ch_data in profile.signals:
dev = registry.get_instance(ch_data["device_id"])
if not dev:
continue
- ch = dev.get_channel(ch_data["channel_id"])
+ ch = dev.get_signal(ch_data["signal_id"])
if not ch:
continue
ch.name = ch_data.get("name", ch.name)
@@ -387,10 +411,10 @@ class ProfileManager:
)
processor.set_pipeline(pipeline)
- # ── Derived channels ─────────────────────────────────────────────
- for dd in profile.derived:
+ # ── Channels (derived) ────────────────────────────────────────────
+ for dd in profile.channels:
sources = [tuple(s) for s in dd.get("sources", [])]
- dc = DerivedChannel(
+ dc = Channel(
channel_id=dd["channel_id"],
name=dd.get("name", dd["channel_id"]),
unit=dd.get("unit", ""),
diff --git a/core/reference_registry.py b/core/reference_registry.py
new file mode 100644
index 0000000..b6fcd0f
--- /dev/null
+++ b/core/reference_registry.py
@@ -0,0 +1,74 @@
+"""
+core/reference_registry.py
+
+Generic (device_id, channel_id) reference-rename propagation.
+
+A Signal (physical device channel) or a Channel (derived, device_id ==
+"derived") can be renamed by the user. Both kinds are referenced the same
+way everywhere downstream — plot traces, control targets, filter
+pipelines, other derived channels' sources — as a plain
+(device_id, channel_id) pair. Renaming one without rewriting every
+reference to it leaves those references dangling: they silently stop
+receiving data (this is exactly the bug that motivated this module — an
+Arduino analog pin remap changed a Signal's id and orphaned its plot
+trace).
+
+propagate_rename() is the single place that walks every subsystem able to
+hold such a reference and rewrites it. It expects a MainWindow instance
+(or anything exposing the same .engine/.processor/._chart/._ctrl/
+._win_config attributes) since that's the one place all of them are
+reachable from.
+"""
+
+from typing import Tuple
+
+Ref = Tuple[str, str]
+
+
+def propagate_rename(app, old_ref: Ref, new_ref: Ref) -> None:
+ if old_ref == new_ref:
+ return
+
+ # Physical signals live in the acquisition engine's buffers; derived
+ # Channels don't (their buffers live in SignalProcessor._derived_bufs).
+ if old_ref[0] != "derived":
+ app.engine.rename_channel(old_ref[0], old_ref[1], new_ref[1])
+
+ # Filter pipelines, other derived channels' sources, latest-value cache,
+ # and (if old_ref is itself a derived Channel) its ring buffer.
+ app.processor.rename_reference(old_ref, new_ref)
+
+ # Live plot — trace specs in the strip chart's current layout.
+ chart = getattr(app, "_chart", None)
+ changed = False
+ if chart is not None and chart._cfg is not None:
+ for pane in chart._cfg.panes:
+ for tr in pane.traces:
+ if (tr.device_id, tr.channel_id) == old_ref:
+ tr.device_id, tr.channel_id = new_ref
+ changed = True
+ if changed:
+ chart.refresh()
+
+ # Control panel — saved specs (profile persistence) + live bound widgets
+ # (actual I/O routing — ControlWidget reads its own .device_id/.channel_id
+ # directly in _write()/_check_stale(), not from the spec).
+ ctrl = getattr(app, "_ctrl", None)
+ if ctrl is not None:
+ for spec in ctrl.get_specs():
+ if (spec.device_id, spec.channel_id) == old_ref:
+ spec.device_id, spec.channel_id = new_ref
+ for widget in getattr(ctrl, "_widgets", []):
+ if (widget.device_id, widget.channel_id) == old_ref:
+ widget.device_id, widget.channel_id = new_ref
+
+ # Config window tabs, if currently open — refresh so pickers/tables
+ # don't show the stale id until next full refresh.
+ win = getattr(app, "_win_config", None)
+ if win is not None:
+ if chart is not None:
+ win.refresh_plot(chart._cfg)
+ if hasattr(win, "refresh_derived"):
+ win.refresh_derived()
+ if hasattr(win, "refresh_devices"):
+ win.refresh_devices()
diff --git a/core/signal_processor.py b/core/signal_processor.py
index 4781e91..da615b6 100644
--- a/core/signal_processor.py
+++ b/core/signal_processor.py
@@ -230,7 +230,7 @@ def _make_src_names(sources: List[Tuple[str, str]],
# ══════════════════════════════════════════════════════════════════════════════
@dataclass
-class DerivedChannel:
+class Channel:
"""
A virtual channel computed from one or more physical channels.
@@ -393,7 +393,7 @@ class SignalProcessor(QObject):
def __init__(self):
super().__init__()
self._pipelines: Dict[Tuple[str, str], ChannelPipeline] = {}
- self._derived: List[DerivedChannel] = []
+ self._derived: List[Channel] = []
self._lock = threading.Lock()
# Latest raw values cache for derived evaluation
@@ -421,7 +421,7 @@ class SignalProcessor(QObject):
# ── Derived channel management ────────────────────────────────────────
- def add_derived(self, dc: DerivedChannel) -> Optional[str]:
+ def add_derived(self, dc: Channel) -> Optional[str]:
"""Add a derived channel. Returns compile error string or None."""
err = dc.compile()
if err:
@@ -437,36 +437,40 @@ class SignalProcessor(QObject):
self._derived = [d for d in self._derived if d.channel_id != channel_id]
self._derived_bufs.pop(channel_id, None)
- def rename_derived(self, old_id: str, new_id: str):
- """Propagate a Channel ID rename to every other derived channel's
- sources — otherwise anything chained from it keeps a dangling
- ("derived", old_id) reference (SignalProcessor._evaluate_derived
- looks sources up by exact match) and silently stops updating.
- Also carries the ring buffer and latest-value cache over under the
- new key so the rename doesn't reset plotted history or blank the
- Value column for a poll cycle. Does NOT touch source_names or
- expression/script text — those are display labels / user-authored
- code, out of scope for an automatic rewrite."""
- if old_id == new_id:
+ def rename_reference(self, old_ref: Tuple[str, str], new_ref: Tuple[str, str]):
+ """Propagate a (device_id, channel_id) reference rename to everything
+ this processor holds one of — otherwise anything reading it keeps a
+ dangling reference (_evaluate_derived/_process_one look sources and
+ pipelines up by exact match) and silently stops updating. Covers
+ both a physical Signal rename and a derived Channel rename with the
+ same code path — the renamed item's own identity (Channel.
+ channel_id, or a device's SignalConfig) is the caller's job; this
+ only fixes up *references to* old_ref held elsewhere.
+ Also carries the latest-value cache, and — when old_ref is itself a
+ derived Channel (device_id == "derived") — its ring buffer, over
+ under the new key so the rename doesn't reset plotted history or
+ blank the Value column for a poll cycle. Does NOT touch
+ source_names or expression/script text — those are display labels /
+ user-authored code, out of scope for an automatic rewrite."""
+ if old_ref == new_ref:
return
with self._lock:
+ if old_ref in self._pipelines:
+ self._pipelines[new_ref] = self._pipelines.pop(old_ref)
+
for dc in self._derived:
- if dc.channel_id == new_id:
- continue
- new_sources = [
- ("derived", new_id) if d == "derived" and c == old_id else (d, c)
- for d, c in dc.sources
- ]
+ new_sources = [new_ref if s == old_ref else s for s in dc.sources]
if new_sources != dc.sources:
dc.sources = new_sources
- buf = self._derived_bufs.pop(old_id, None)
- if buf is not None:
- self._derived_bufs[new_id] = buf
-
- entry = self._latest.pop(("derived", old_id), None)
+ entry = self._latest.pop(old_ref, None)
if entry is not None:
- self._latest[("derived", new_id)] = entry
+ self._latest[new_ref] = entry
+
+ if old_ref[0] == "derived":
+ buf = self._derived_bufs.pop(old_ref[1], None)
+ if buf is not None:
+ self._derived_bufs[new_ref[1]] = buf
def reorder_derived(self, src_idx: int, dst_idx: int):
"""Move the derived channel at src_idx to dst_idx — list order is
@@ -478,7 +482,7 @@ class SignalProcessor(QObject):
dc = self._derived.pop(src_idx)
self._derived.insert(dst_idx, dc)
- def get_derived(self) -> List[DerivedChannel]:
+ def get_derived(self) -> List[Channel]:
return list(self._derived)
def get_derived_buffer(self, channel_id: str):
@@ -575,7 +579,7 @@ class SignalProcessor(QObject):
self.derived_error.emit(dc.channel_id,
traceback.format_exc(limit=3))
- def _compute_derived(self, dc: DerivedChannel, inputs: List[float],
+ def _compute_derived(self, dc: Channel, inputs: List[float],
t: float, latest: dict) -> Optional[float]:
if dc.kind in ("expression", "function", "custom_script"):
if dc._fn is None:
diff --git a/devices/__init__.py b/devices/__init__.py
index 7cee4ac..d175390 100644
--- a/devices/__init__.py
+++ b/devices/__init__.py
@@ -1,4 +1,4 @@
-# devices/__init__.py from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+# devices/__init__.py from devices.base_device import BaseDevice, SignalConfig, DeviceInfo, DeviceStatus
from devices.device_registry import DeviceRegistry
-__all__ = ["BaseDevice", "ChannelConfig", "DeviceInfo", "DeviceStatus", "DeviceRegistry"]
+__all__ = ["BaseDevice", "SignalConfig", "DeviceInfo", "DeviceStatus", "DeviceRegistry"]
diff --git a/devices/analog_input.py b/devices/analog_input.py
deleted file mode 100644
index 8938bf2..0000000
--- a/devices/analog_input.py
+++ /dev/null
@@ -1,461 +0,0 @@
-"""
-devices/analog_input.py
-
-Analog Input device. Backends: NI-DAQmx or Arduino.
-"""
-
-from typing import Any, Dict, List
-
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox,
- QComboBox, QDoubleSpinBox, QCheckBox, QLineEdit, QLabel,
- QPushButton, QListWidget, QListWidgetItem, QFrame, QTextEdit,
-)
-from PyQt6.QtCore import Qt, QThread, pyqtSignal
-from PyQt6.QtGui import QFont
-
-from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
-from api_layers.nidaqmx_layer import NidaqmxLayer
-from api_layers.arduino_layer import ArduinoLayer
-
-_COLORS = [
- "#00d4ff", "#ff6b35", "#7fff6e", "#ffcc00",
- "#c77dff", "#ff4d6d", "#4cc9f0", "#f72585",
- "#38b000", "#e9c46a", "#a8dadc", "#e63946",
- "#90e0ef", "#fb8500", "#b5e48c", "#d62828",
-]
-
-
-class AnalogInputDevice(BaseDevice):
- DEVICE_TYPE = "analog_input"
- ICON = "〜"
-
- def __init__(
- self,
- device_id: str = "ai_0",
- num_channels: int = 4,
- simulate: bool = True,
- backend: str = "nidaqmx",
- ni_device: str = "Dev1",
- ni_min_v: float = -10.0,
- ni_max_v: float = 10.0,
- ard_port: str = "COM3",
- ard_baud: int = 115200,
- ):
- self.backend = backend
- self.simulate = simulate
-
- if backend == "arduino":
- pins = ArduinoLayer.DEFAULT_ANALOG_PINS[:num_channels]
- channels = [
- ChannelConfig(channel_id=p, name=p, unit="V",
- min_value=0.0, max_value=5.0,
- color=_COLORS[i % len(_COLORS)])
- for i, p in enumerate(pins)
- ]
- else:
- ni_pins = [f"ai{i}" for i in range(num_channels)]
- channels = [
- ChannelConfig(channel_id=p, name=p.upper(), unit="V",
- min_value=ni_min_v, max_value=ni_max_v,
- color=_COLORS[i % len(_COLORS)])
- for i, p in enumerate(ni_pins)
- ]
-
- info = DeviceInfo(
- device_id=device_id,
- name=f"Analog Input ({backend.upper()})",
- device_type=self.DEVICE_TYPE,
- description=f"Multi-channel analog input via {backend}",
- manufacturer="NI" if backend == "nidaqmx" else "Arduino",
- icon=self.ICON,
- channels=channels,
- )
- super().__init__(info)
-
- self._ni_device = ni_device
- self._ni_min_v = ni_min_v
- self._ni_max_v = ni_max_v
- self._ard_port = ard_port
- self._ard_baud = ard_baud
- self._num_channels = num_channels
- self._last_error = ""
-
- self._layer = self._make_layer(
- backend, simulate, ni_device, ni_min_v, ni_max_v,
- ard_port, ard_baud,
- [ch.channel_id for ch in channels],
- )
-
- def _make_layer(self, backend, simulate,
- ni_device, ni_min_v, ni_max_v,
- ard_port, ard_baud, channel_ids):
- if backend == "arduino":
- # Use shared layer so two devices on the same port don't conflict
- from api_layers.port_registry import port_registry
- return port_registry.get_layer(
- port=ard_port,
- baud=ard_baud,
- simulate=simulate,
- extra_pins=channel_ids,
- )
- else:
- return NidaqmxLayer(
- device_name=ni_device,
- channels=channel_ids,
- min_val=ni_min_v,
- max_val=ni_max_v,
- simulate=simulate,
- )
-
- # ── BaseDevice ──────────────────────────────────────────────────────────
-
- def connect(self) -> bool:
- self._last_error = ""
-
- # ArduinoLayer: use connect_if_needed so shared layers aren't
- # opened twice when multiple devices share the same port.
- # NidaqmxLayer: uses start()
- if hasattr(self._layer, "connect"):
- if self._layer.is_connected:
- ok = True # already open — shared with another device
- else:
- ok = self._layer.connect()
- else:
- ok = self._layer.start()
-
- if not ok and hasattr(self._layer, "last_error"):
- self._last_error = self._layer.last_error
-
- if self.simulate:
- self.status = DeviceStatus.SIMULATED
- else:
- self.status = DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR
-
- return ok
-
- def disconnect(self) -> None:
- # For shared Arduino layers, release our ref count via port_registry.
- # The layer stays open until the last device using it disconnects.
- if self.backend == "arduino" and not self.simulate:
- from api_layers.port_registry import port_registry
- port_registry.release(self._ard_port, self._ard_baud)
- else:
- try:
- if hasattr(self._layer, "stop"):
- self._layer.stop()
- elif hasattr(self._layer, "disconnect"):
- self._layer.disconnect()
- except Exception:
- pass
- self.status = DeviceStatus.DISCONNECTED
-
- def read_channels(self) -> Dict[str, float]:
- return self._layer.read()
-
- def write_channel(self, channel_id: str, value: Any) -> bool:
- """
- Route output commands through the Arduino layer.
-
- channel_id examples:
- "D7" → digital write W:D7:1 or W:D7:0
- "D9" → PWM write P:D9:<duty> (if value is 0-255)
- "HEAT" → named param C:HEAT:1.0
- """
- if self.backend != "arduino" or not hasattr(self._layer, "digital_write"):
- return False
- ch = channel_id.strip()
- # Digital pin (D7, D13, etc.)
- if ch.upper().startswith("D") and ch[1:].isdigit():
- return self._layer.digital_write(ch, int(bool(value)))
- # Named parameter / setpoint
- return self._layer.set_parameter(ch, value)
-
- def get_save_config(self) -> dict:
- return {
- "device_type": self.DEVICE_TYPE,
- "device_id": self.info.device_id,
- "name": self.info.name,
- "num_channels": self._num_channels,
- "simulate": self.simulate,
- "backend": self.backend,
- "ni_device": self._ni_device,
- "ni_min_v": self._ni_min_v,
- "ni_max_v": self._ni_max_v,
- "ard_port": self._ard_port,
- "ard_baud": self._ard_baud,
- }
-
- def get_config_widget(self) -> QWidget:
- return AnalogInputConfigWidget(self)
-
- def switch_backend(self, backend: str, simulate: bool,
- ni_device: str, min_val: float, max_val: float,
- port: str, baud: int) -> None:
- was_running = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED)
- if was_running:
- self.disconnect()
-
- self.backend = backend
- self.simulate = simulate
- self._ni_device = ni_device
- self._ni_min_v = min_val
- self._ni_max_v = max_val
- self._ard_port = port
- self._ard_baud = baud
-
- self._layer = self._make_layer(
- backend, simulate, ni_device, min_val, max_val,
- port, baud,
- [ch.channel_id for ch in self.info.channels],
- )
-
- if was_running:
- self.connect()
-
-
-# ── Port scanner thread ───────────────────────────────────────────────────────
-
-class _PortScanThread(QThread):
- done = pyqtSignal(list)
-
- def run(self):
- ports = ArduinoLayer.list_ports() # returns [(device, desc), ...]
- self.done.emit(ports)
-
-
-# ── Config widget ─────────────────────────────────────────────────────────────
-
-class AnalogInputConfigWidget(QWidget):
- def __init__(self, device: AnalogInputDevice):
- super().__init__()
- self.device = device
- self._scanner = None
- self._build()
-
- def _build(self):
- root = QVBoxLayout(self)
- root.setContentsMargins(10, 14, 10, 10)
- root.setSpacing(12)
- self.setMinimumWidth(460)
-
- # ── Backend / Simulate ───────────────────────────────────────────
- be_grp = QGroupBox("Backend Selection")
- be_form = QFormLayout(be_grp)
- be_form.setContentsMargins(10, 16, 10, 10)
-
- self.backend_cb = QComboBox()
- self.backend_cb.addItems(["nidaqmx", "arduino"])
- self.backend_cb.setCurrentText(self.device.backend)
- be_form.addRow("Backend:", self.backend_cb)
-
- self.sim_check = QCheckBox("Simulation Mode (no hardware)")
- self.sim_check.setChecked(self.device.simulate)
- from core.app_settings import is_developer_mode
- self.sim_check.setVisible(is_developer_mode())
- be_form.addRow(self.sim_check)
- root.addWidget(be_grp)
-
- # ── NI-DAQmx ────────────────────────────────────────────────────
- self.ni_grp = QGroupBox("NI-DAQmx Settings")
- ni_form = QFormLayout(self.ni_grp)
- ni_form.setContentsMargins(10, 16, 10, 10)
-
- self.ni_dev_edit = QLineEdit(self.device._ni_device)
- ni_form.addRow("NI Device:", self.ni_dev_edit)
-
- self.ni_min_spin = QDoubleSpinBox()
- self.ni_min_spin.setRange(-100, 0)
- self.ni_min_spin.setValue(self.device._ni_min_v)
- self.ni_min_spin.setSuffix(" V")
- ni_form.addRow("Min Voltage:", self.ni_min_spin)
-
- self.ni_max_spin = QDoubleSpinBox()
- self.ni_max_spin.setRange(0, 100)
- self.ni_max_spin.setValue(self.device._ni_max_v)
- self.ni_max_spin.setSuffix(" V")
- ni_form.addRow("Max Voltage:", self.ni_max_spin)
-
- detect_btn = QPushButton("Detect NI Devices")
- detect_btn.setObjectName("configButton")
- detect_btn.clicked.connect(self._detect_ni)
- ni_form.addRow(detect_btn)
- self.ni_detect_lbl = QLabel("")
- self.ni_detect_lbl.setObjectName("traceSource")
- ni_form.addRow(self.ni_detect_lbl)
- root.addWidget(self.ni_grp)
-
- # ── Arduino ──────────────────────────────────────────────────────
- self.ard_grp = QGroupBox("Arduino Serial Settings")
- ard_lay = QVBoxLayout(self.ard_grp)
- ard_lay.setContentsMargins(10, 16, 10, 10)
- ard_lay.setSpacing(8)
-
- ard_form = QFormLayout()
- self.ard_port_edit = QLineEdit(self.device._ard_port)
- self.ard_port_edit.setPlaceholderText("e.g. /dev/ttyUSB0 or COM3")
- ard_form.addRow("Port:", self.ard_port_edit)
-
- self.ard_baud_cb = QComboBox()
- self.ard_baud_cb.addItems(["9600", "19200", "57600", "115200", "230400"])
- self.ard_baud_cb.setCurrentText(str(self.device._ard_baud))
- ard_form.addRow("Baud Rate:", self.ard_baud_cb)
- ard_lay.addLayout(ard_form)
-
- # Scan row
- scan_row = QHBoxLayout()
- self._scan_btn = QPushButton("🔍 Scan Ports")
- self._scan_btn.setObjectName("addTraceBtn")
- self._scan_btn.clicked.connect(self._scan_ports)
- self._scan_lbl = QLabel("")
- self._scan_lbl.setObjectName("traceSource")
- scan_row.addWidget(self._scan_btn)
- scan_row.addWidget(self._scan_lbl, 1)
- ard_lay.addLayout(scan_row)
-
- # Port list
- self._port_list = QListWidget()
- self._port_list.setObjectName("portList")
- self._port_list.setMaximumHeight(90)
- self._port_list.itemClicked.connect(self._on_port_selected)
- ard_lay.addWidget(self._port_list)
-
- hint = QLabel("↑ Click a port to fill the field above")
- hint.setObjectName("traceSource")
- ard_lay.addWidget(hint)
-
- # pyserial availability notice
- if not ArduinoLayer.is_pyserial_available():
- warn = QLabel("⚠ pyserial not installed — run: pip install pyserial")
- warn.setStyleSheet("color:#f59e0b; font-weight:600;")
- ard_lay.addWidget(warn)
-
- root.addWidget(self.ard_grp)
-
- # ── Diagnostics ───────────────────────────────────────────────────
- self.diag_grp = QGroupBox("Connection Diagnostics")
- diag_lay = QVBoxLayout(self.diag_grp)
- diag_lay.setContentsMargins(10, 16, 10, 10)
-
- self._diag_box = QTextEdit()
- self._diag_box.setObjectName("codeEditor")
- self._diag_box.setReadOnly(True)
- self._diag_box.setMaximumHeight(160)
- self._diag_box.setPlaceholderText("Connection log will appear here…")
- diag_lay.addWidget(self._diag_box)
-
- # Show any existing error
- if self.device._last_error:
- self._diag_box.setPlainText(f"Last error:\n{self.device._last_error}")
-
- root.addWidget(self.diag_grp)
-
- # ── Apply ─────────────────────────────────────────────────────────
- apply_btn = QPushButton("Apply & Reconnect")
- apply_btn.setObjectName("applyButton")
- apply_btn.clicked.connect(self._apply)
- root.addWidget(apply_btn)
- root.addStretch()
-
- self._update_visibility()
- self.backend_cb.currentTextChanged.connect(self._update_visibility)
- self._refresh_diag()
-
- # ── Helpers ──────────────────────────────────────────────────────────────
-
- def _update_visibility(self):
- be = self.backend_cb.currentText()
- self.ni_grp.setVisible(be == "nidaqmx")
- self.ard_grp.setVisible(be == "arduino")
-
- def _refresh_diag(self):
- lines = []
- lines.append(f"Backend: {self.device.backend}")
- lines.append(f"Simulate: {self.device.simulate}")
- lines.append(f"Status: {self.device.status.value}")
- if self.device.backend == "arduino":
- lines.append(f"Port: {self.device._ard_port}")
- lines.append(f"Baud: {self.device._ard_baud}")
- lines.append(f"pyserial: {'available' if ArduinoLayer.is_pyserial_available() else 'NOT INSTALLED'}")
- # Show last raw lines received from Arduino
- if hasattr(self.device._layer, "raw_lines"):
- raw = self.device._layer.raw_lines
- if raw:
- lines.append("\nLast lines from Arduino:")
- for r in raw:
- lines.append(f" {r}")
- if self.device._last_error:
- lines.append(f"\nError:\n{self.device._last_error}")
- self._diag_box.setPlainText("\n".join(lines))
-
- def _detect_ni(self):
- devs = NidaqmxLayer.list_devices()
- self.ni_detect_lbl.setText(
- ", ".join(devs) if devs else "No NI devices found (nidaqmx package required)"
- )
-
- def _scan_ports(self):
- self._scan_btn.setEnabled(False)
- self._scan_lbl.setText("Scanning…")
- self._port_list.clear()
- self._scanner = _PortScanThread()
- self._scanner.done.connect(self._on_scan_done)
- self._scanner.start()
-
- def _on_scan_done(self, ports):
- self._scan_btn.setEnabled(True)
- self._port_list.clear()
-
- if not ArduinoLayer.is_pyserial_available():
- self._scan_lbl.setText("pyserial not installed")
- item = QListWidgetItem(" Install pyserial: pip install pyserial")
- item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
- self._port_list.addItem(item)
- return
-
- if not ports:
- self._scan_lbl.setText("No ports detected")
- item = QListWidgetItem(
- " No serial ports found.\n"
- " • Check USB cable\n"
- " • On Linux: sudo usermod -aG dialout $USER then re-login\n"
- " • On Arch: sudo usermod -aG uucp $USER then re-login"
- )
- item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
- self._port_list.addItem(item)
- else:
- self._scan_lbl.setText(f"{len(ports)} port(s) found — click to select")
- for device, desc in ports:
- label = f" {device}"
- if desc and desc.strip() and desc.strip() != device:
- label += f" — {desc}"
- item = QListWidgetItem(label)
- item.setData(Qt.ItemDataRole.UserRole, device)
- self._port_list.addItem(item)
-
- def _on_port_selected(self, item: QListWidgetItem):
- port = item.data(Qt.ItemDataRole.UserRole)
- if port:
- self.ard_port_edit.setText(port)
-
- def _apply(self):
- backend = self.backend_cb.currentText()
- simulate = self.sim_check.isChecked()
- port = self.ard_port_edit.text().strip()
- baud = int(self.ard_baud_cb.currentText())
-
- self._diag_box.setPlainText(
- f"Connecting...\nBackend: {backend}\nSimulate: {simulate}\n"
- + (f"Port: {port}\nBaud: {baud}" if backend == "arduino" else "")
- )
-
- self.device.switch_backend(
- backend=backend,
- simulate=simulate,
- ni_device=self.ni_dev_edit.text().strip() or "Dev1",
- min_val=self.ni_min_spin.value(),
- max_val=self.ni_max_spin.value(),
- port=port,
- baud=baud,
- )
- self._refresh_diag()
diff --git a/devices/arduino_device.py b/devices/arduino_device.py
index 1641eaa..5c954c8 100644
--- a/devices/arduino_device.py
+++ b/devices/arduino_device.py
@@ -15,7 +15,7 @@ from PyQt6.QtWidgets import (
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
-from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+from devices.base_device import BaseDevice, SignalConfig, DeviceInfo, DeviceStatus
from api_layers.arduino_layer import ArduinoLayer
_ANALOG_COLORS = [
@@ -91,26 +91,11 @@ class ArduinoDevice(BaseDevice):
self._di_pins: List[str] = list(di_pins)
self._do_pins: List[str] = list(do_pins)
- channels = []
- for i, pin in enumerate(self._analog_pins):
- channels.append(ChannelConfig(
- channel_id=pin, name=pin, unit="V",
- min_value=0.0, max_value=5.0,
- color=_ANALOG_COLORS[i % len(_ANALOG_COLORS)],
- ))
- for i in range(len(self._di_pins)):
- channels.append(ChannelConfig(
- channel_id=f"di{i}", name=f"DI {i}", unit="",
- min_value=0.0, max_value=1.0,
- color=_DI_COLORS[i % len(_DI_COLORS)],
- ))
- for i in range(len(self._do_pins)):
- channels.append(ChannelConfig(
- channel_id=f"do{i}", name=f"DO {i}", unit="",
- min_value=0.0, max_value=1.0,
- writable=True,
- color=_DO_COLORS[i % len(_DO_COLORS)],
- ))
+ self._ai_map = {f"ai{i}": pin for i, pin in enumerate(self._analog_pins)}
+ self._di_map = {f"di{i}": pin for i, pin in enumerate(self._di_pins)}
+ self._do_map = {f"do{i}": pin for i, pin in enumerate(self._do_pins)}
+
+ channels = self._build_channels()
_desc = ArduinoLayer.describe_port(port) if not simulate else ""
if _desc:
@@ -121,22 +106,45 @@ class ArduinoDevice(BaseDevice):
device_id=device_id,
name=_name,
device_type=self.DEVICE_TYPE,
- description=(f"Arduino — {len(self._analog_pins)} analog, "
- f"{len(self._di_pins)} DI, {len(self._do_pins)} DO"),
+ description=self._description(),
manufacturer="Arduino",
icon=self.ICON,
- channels=channels,
+ signals=channels,
)
super().__init__(info)
self._output_state: Dict[str, int] = {f"do{i}": 0 for i in range(len(self._do_pins))}
- self._di_state: Dict[str, int] = {f"di{i}": 0 for i in range(len(self._di_pins))}
-
- self._di_map = {f"di{i}": pin for i, pin in enumerate(self._di_pins)}
- self._do_map = {f"do{i}": pin for i, pin in enumerate(self._do_pins)}
+ self._di_state: Dict[str, int] = {f"di{i}": 0 for i in range(len(self._di_pins))}
self._layer = self._make_layer()
+ def _description(self) -> str:
+ return (f"Arduino — {len(self._analog_pins)} analog, "
+ f"{len(self._di_pins)} DI, {len(self._do_pins)} DO")
+
+ def _build_channels(self) -> List[SignalConfig]:
+ channels = []
+ for i, pin in enumerate(self._analog_pins):
+ channels.append(SignalConfig(
+ signal_id=f"ai{i}", name=pin, unit="V",
+ min_value=0.0, max_value=5.0,
+ color=_ANALOG_COLORS[i % len(_ANALOG_COLORS)],
+ ))
+ for i in range(len(self._di_pins)):
+ channels.append(SignalConfig(
+ signal_id=f"di{i}", name=f"DI {i}", unit="",
+ min_value=0.0, max_value=1.0,
+ color=_DI_COLORS[i % len(_DI_COLORS)],
+ ))
+ for i in range(len(self._do_pins)):
+ channels.append(SignalConfig(
+ signal_id=f"do{i}", name=f"DO {i}", unit="",
+ min_value=0.0, max_value=1.0,
+ writable=True,
+ color=_DO_COLORS[i % len(_DO_COLORS)],
+ ))
+ return channels
+
def _make_layer(self):
from api_layers.port_registry import port_registry
return port_registry.get_layer(
@@ -177,10 +185,12 @@ class ArduinoDevice(BaseDevice):
def read_channels(self) -> Dict[str, float]:
raw = self._layer.read()
result = {}
- for ch in self.info.channels:
- cid = ch.channel_id
- if cid in raw:
- result[cid] = raw[cid]
+ for ch in self.info.signals:
+ cid = ch.signal_id
+ if cid.startswith("ai"):
+ pin = self._ai_map.get(cid)
+ if pin and pin in raw:
+ result[cid] = raw[pin]
elif cid.startswith("di"):
pin = self._di_map.get(cid)
if pin and pin in raw:
@@ -244,15 +254,25 @@ class ArduinoDevice(BaseDevice):
di_pins: List[str] = None,
do_pins: List[str] = None,
) -> None:
- """Update active pin mapping and send config commands if hardware-connected."""
+ """Update active pin mapping, rebuild channels, and send config
+ commands if hardware-connected. Caller is responsible for emitting
+ a `device_reconfigured` signal so the acquisition engine, profile,
+ and UI pick up the new channel list (see ui/windows/config_window.py)."""
if analog_pins is not None:
self._analog_pins = list(analog_pins)
+ self._ai_map = {f"ai{i}": p for i, p in enumerate(analog_pins)}
if di_pins is not None:
self._di_pins = list(di_pins)
self._di_map = {f"di{i}": p for i, p in enumerate(di_pins)}
if do_pins is not None:
self._do_pins = list(do_pins)
self._do_map = {f"do{i}": p for i, p in enumerate(do_pins)}
+
+ self.info.signals = self._build_channels()
+ self.info.description = self._description()
+ self._output_state = {f"do{i}": self._output_state.get(f"do{i}", 0) for i in range(len(self._do_pins))}
+ self._di_state = {f"di{i}": self._di_state.get(f"di{i}", 0) for i in range(len(self._di_pins))}
+
if self.status == DeviceStatus.CONNECTED:
self._layer.configure_pins(
di_pins=self._di_pins,
@@ -329,27 +349,40 @@ class ArduinoConfigWidget(QWidget):
# ── Pin Mapping ───────────────────────────────────────────────────
pin_grp = QGroupBox("Pin Mapping")
- pin_form = QFormLayout(pin_grp)
- pin_form.setContentsMargins(10, 16, 10, 10)
+ pin_outer = QVBoxLayout(pin_grp)
+ pin_outer.setContentsMargins(10, 16, 10, 10)
+ pin_outer.setSpacing(8)
+
+ pin_cols = QHBoxLayout()
+ pin_cols.setSpacing(12)
+ analog_grp = QGroupBox("Analog Pins")
+ analog_form = QFormLayout(analog_grp)
self.analog_pins_edit = QLineEdit(", ".join(p[1:] for p in self.device._analog_pins))
- self.analog_pins_edit.setPlaceholderText("e.g. 0, 1, 2, 3 (indices into A0–A5)")
- pin_form.addRow("Analog Input Pins:", self.analog_pins_edit)
+ self.analog_pins_edit.setPlaceholderText("e.g. 0, 1, 2, 3")
+ analog_form.addRow("Input:", self.analog_pins_edit)
+ pin_cols.addWidget(analog_grp)
+ digital_grp = QGroupBox("Digital Pins")
+ digital_form = QFormLayout(digital_grp)
self.di_pins_edit = QLineEdit(", ".join(p[1:] for p in self.device._di_pins))
self.di_pins_edit.setPlaceholderText("e.g. 2, 3, 8")
- pin_form.addRow("Digital Input Pins:", self.di_pins_edit)
-
+ digital_form.addRow("Input:", self.di_pins_edit)
self.do_pins_edit = QLineEdit(", ".join(p[1:] for p in self.device._do_pins))
- self.do_pins_edit.setPlaceholderText("e.g. 5, 6, 7, 9")
- pin_form.addRow("Digital Output Pins:", self.do_pins_edit)
+ self.do_pins_edit.setPlaceholderText("e.g. 5, 6, 7")
+ digital_form.addRow("Output:", self.do_pins_edit)
+ pin_cols.addWidget(digital_grp)
+
+ pin_outer.addLayout(pin_cols)
hint = QLabel("Changes take effect on Apply & Reconnect.\n"
"Analog: enter A-pin indices (0=A0, 1=A1, …). "
- "Digital: enter pin numbers.")
+ "Digital: enter pin numbers. "
+ "For analog-style output, drive a PWM-capable digital "
+ "output pin (e.g. D9) with a duty-cycle value.")
hint.setObjectName("traceSource")
hint.setWordWrap(True)
- pin_form.addRow(hint)
+ pin_outer.addWidget(hint)
root.addWidget(pin_grp)
@@ -417,19 +450,18 @@ class ArduinoConfigWidget(QWidget):
self.port_edit.setText(port)
def _apply(self):
- # Parse pin fields and update device before reconnect so connect() sends correct APIN/DPIN
+ # Parse pin fields and remap (rebuilds device.info.signals) before
+ # reconnect, so connect() sends correct APIN/DPIN and the acquisition
+ # engine picks up the new channel list once this dialog closes.
analog = _parse_analog_pin_edit(self.analog_pins_edit.text())
di = _parse_pin_edit(self.di_pins_edit.text())
do = _parse_pin_edit(self.do_pins_edit.text())
- if analog:
- self.device._analog_pins = analog
- if di:
- self.device._di_pins = di
- self.device._di_map = {f"di{i}": p for i, p in enumerate(di)}
- if do:
- self.device._do_pins = do
- self.device._do_map = {f"do{i}": p for i, p in enumerate(do)}
+ self.device.remap_pins(
+ analog_pins=analog or None,
+ di_pins=di or None,
+ do_pins=do or None,
+ )
self.device.switch_backend(
simulate=self.sim_chk.isChecked(),
port=self.port_edit.text().strip() or self.device._port,
diff --git a/devices/base_device.py b/devices/base_device.py
index 9803867..e3eed65 100644
--- a/devices/base_device.py
+++ b/devices/base_device.py
@@ -20,17 +20,19 @@ class DeviceStatus(Enum):
@dataclass
-class ChannelConfig:
- """Configuration for a single I/O channel."""
- channel_id: str
+class SignalConfig:
+ """Configuration for a single physical Signal — one I/O stream coming
+ off a Device (e.g. an Arduino analog pin, a digital line). Contrast
+ with a Channel (core/signal_processor.py), which takes one or more
+ Signals and applies math/filters to produce a new derived stream."""
+ signal_id: str
name: str
unit: str = ""
min_value: float = 0.0
max_value: float = 100.0
enabled: bool = True
- is_output: bool = False
color: str = "#00d4ff"
- writable: bool = False # True if this channel accepts write_channel() calls
+ writable: bool = False # True if this signal accepts write_channel() calls
extra: Dict[str, Any] = field(default_factory=dict)
@@ -39,13 +41,13 @@ class DeviceInfo:
"""Metadata describing a device module."""
device_id: str
name: str
- device_type: str # "analog_input" | "digital_io" | "serial" | "temperature"
+ device_type: str # "arduino" | "serial" | "nidaqmx" | ...
description: str = ""
manufacturer: str = ""
model: str = ""
version: str = "1.0.0"
icon: str = "⚙"
- channels: List[ChannelConfig] = field(default_factory=list)
+ signals: List[SignalConfig] = field(default_factory=list)
class BaseDevice(ABC):
@@ -75,7 +77,7 @@ class BaseDevice(ABC):
@abstractmethod
def read_channels(self) -> Dict[str, float]:
- """Return {channel_id: value} for all enabled channels."""
+ """Return {signal_id: value} for all enabled signals."""
@abstractmethod
def write_channel(self, channel_id: str, value: Any) -> bool:
@@ -97,8 +99,8 @@ class BaseDevice(ABC):
except Exception:
pass
- def get_channel(self, channel_id: str) -> Optional[ChannelConfig]:
- return next((c for c in self.info.channels if c.channel_id == channel_id), None)
+ def get_signal(self, signal_id: str) -> Optional[SignalConfig]:
+ return next((s for s in self.info.signals if s.signal_id == signal_id), None)
def __repr__(self):
return f"<{self.__class__.__name__} id={self.info.device_id} status={self.status.value}>"
diff --git a/devices/digital_io.py b/devices/digital_io.py
deleted file mode 100644
index 7cda61f..0000000
--- a/devices/digital_io.py
+++ /dev/null
@@ -1,334 +0,0 @@
-"""
-devices/digital_io.py
-
-Digital I/O device module. Backends: NI-DAQmx or Arduino.
-
-NI backend – uses nidaqmx digital line tasks (P0.0..P0.7)
-Arduino – uses ArduinoLayer digital pin reads; writes via W:Dxx:val
-"""
-
-import random
-import time
-from typing import Any, Dict
-
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
- QCheckBox, QPushButton, QLabel, QFormLayout,
- QComboBox, QLineEdit,
-)
-
-from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
-
-_IN_COLORS = ["#00d4ff", "#4cc9f0", "#90e0ef", "#caf0f8",
- "#0077b6", "#023e8a", "#48cae4", "#ade8f4"]
-_OUT_COLORS = ["#ff6b35", "#ffcc00", "#f77f00", "#fcbf49",
- "#d62828", "#e63946", "#fb8500", "#ffd166"]
-
-
-class DigitalIODevice(BaseDevice):
- DEVICE_TYPE = "digital_io"
- ICON = "⬛"
-
- def __init__(
- self,
- device_id: str = "dio_0",
- num_inputs: int = 8,
- num_outputs: int = 8,
- simulate: bool = True,
- backend: str = "nidaqmx", # "nidaqmx" | "arduino"
- ni_device: str = "Dev1",
- ard_port: str = "COM3",
- ard_baud: int = 115200,
- ):
- self.simulate = simulate
- self.backend = backend
- self._ni_device = ni_device
- self._ard_port = ard_port
- self._ard_baud = ard_baud
- self._num_inputs = num_inputs
- self._num_outputs = num_outputs
-
- channels = []
- for i in range(num_inputs):
- channels.append(ChannelConfig(
- channel_id=f"di{i}", name=f"DI {i}", unit="",
- min_value=0.0, max_value=1.0,
- color=_IN_COLORS[i % len(_IN_COLORS)],
- ))
- for i in range(num_outputs):
- channels.append(ChannelConfig(
- channel_id=f"do{i}", name=f"DO {i}", unit="",
- min_value=0.0, max_value=1.0,
- color=_OUT_COLORS[i % len(_OUT_COLORS)],
- writable=True,
- ))
-
- info = DeviceInfo(
- device_id=device_id,
- name=f"Digital I/O ({backend.upper()})",
- device_type=self.DEVICE_TYPE,
- description="Digital input/output module",
- icon=self.ICON,
- channels=channels,
- )
- super().__init__(info)
-
- self._output_state: Dict[str, int] = {
- f"do{i}": 0 for i in range(num_outputs)
- }
- self._sim_toggle: Dict[str, int] = {}
- self._sim_state: Dict[str, int] = {}
-
- # Hardware task placeholders
- self._ni_in_task = None
- self._ni_out_task = None
- self._ard_layer = None
-
- # ── BaseDevice ──────────────────────────────────────────────────────
-
- def connect(self) -> bool:
- if self.simulate:
- self.status = DeviceStatus.SIMULATED
- return True
-
- if self.backend == "nidaqmx":
- return self._ni_connect()
- else:
- return self._ard_connect()
-
- def _ni_connect(self) -> bool:
- try:
- import nidaqmx # type: ignore
- from nidaqmx.constants import LineGrouping # type: ignore
- n_in = sum(1 for c in self.info.channels if c.channel_id.startswith("di"))
- n_out = sum(1 for c in self.info.channels if c.channel_id.startswith("do"))
-
- if n_in:
- self._ni_in_task = nidaqmx.Task()
- for i in range(n_in):
- self._ni_in_task.di_channels.add_di_chan(
- f"{self._ni_device}/port0/line{i}",
- line_grouping=LineGrouping.CHAN_PER_LINE,
- )
- self._ni_in_task.start()
-
- if n_out:
- self._ni_out_task = nidaqmx.Task()
- for i in range(n_out):
- self._ni_out_task.do_channels.add_do_chan(
- f"{self._ni_device}/port1/line{i}",
- line_grouping=LineGrouping.CHAN_PER_LINE,
- )
- self._ni_out_task.start()
-
- self.status = DeviceStatus.CONNECTED
- return True
- except Exception as e:
- print(f"[DigitalIODevice] NI connect failed: {e}")
- self.status = DeviceStatus.ERROR
- return False
-
- def _ard_connect(self) -> bool:
- from api_layers.port_registry import port_registry
- # Get shared layer — won't open a second connection if AnalogInputDevice
- # is already connected on the same port
- self._ard_layer = port_registry.get_layer(
- port=self._ard_port,
- baud=self._ard_baud,
- simulate=False,
- )
- if self._ard_layer.is_connected:
- # Port already open — just register ourselves and return OK
- self.status = DeviceStatus.CONNECTED
- return True
- ok = self._ard_layer.connect()
- self.status = DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR
- return ok
-
- def disconnect(self) -> None:
- if self._ni_in_task:
- try: self._ni_in_task.stop(); self._ni_in_task.close()
- except Exception: pass
- if self._ni_out_task:
- try: self._ni_out_task.stop(); self._ni_out_task.close()
- except Exception: pass
- if self._ard_layer and self.backend == "arduino" and not self.simulate:
- from api_layers.port_registry import port_registry
- port_registry.release(self._ard_port, self._ard_baud)
- self._ard_layer = None
- self.status = DeviceStatus.DISCONNECTED
-
- def read_channels(self) -> Dict[str, float]:
- if self.simulate:
- return self._sim_read()
- if self.backend == "nidaqmx":
- return self._ni_read()
- return self._ard_read()
-
- def _ni_read(self) -> Dict[str, float]:
- result = {}
- try:
- if self._ni_in_task:
- vals = self._ni_in_task.read()
- for i, ch in enumerate(c for c in self.info.channels if c.channel_id.startswith("di")):
- result[ch.channel_id] = float(vals[i] if isinstance(vals, list) else vals)
- except Exception as e:
- print(f"[DigitalIODevice] NI read failed: {e}")
- for ch in (c for c in self.info.channels if c.channel_id.startswith("do")):
- result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0))
- return result
-
- def _ard_read(self) -> Dict[str, float]:
- if not self._ard_layer:
- return {}
- raw = self._ard_layer.read()
- result = {}
- for ch in self.info.channels:
- if ch.channel_id in raw:
- result[ch.channel_id] = raw[ch.channel_id]
- elif ch.channel_id.startswith("do"):
- result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0))
- return result
-
- def _sim_read(self) -> Dict[str, float]:
- result = {}
- for ch in self.info.channels:
- if ch.channel_id.startswith("di"):
- cnt = self._sim_toggle.get(ch.channel_id, 0) + 1
- if cnt >= random.randint(8, 40):
- self._sim_state[ch.channel_id] = 1 - self._sim_state.get(ch.channel_id, 0)
- cnt = 0
- self._sim_toggle[ch.channel_id] = cnt
- result[ch.channel_id] = float(self._sim_state.get(ch.channel_id, 0))
- else:
- result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0))
- return result
-
- def write_channel(self, channel_id: str, value: Any) -> bool:
- self._output_state[channel_id] = int(bool(value))
- if not self.simulate:
- if self.backend == "nidaqmx" and self._ni_out_task:
- try:
- out_chs = [c for c in self.info.channels if c.channel_id.startswith("do")]
- idx = next((i for i, c in enumerate(out_chs) if c.channel_id == channel_id), None)
- if idx is not None:
- states = [self._output_state.get(c.channel_id, 0) for c in out_chs]
- self._ni_out_task.write(states)
- except Exception as e:
- print(f"[DigitalIODevice] NI write failed: {e}")
- elif self.backend == "arduino" and self._ard_layer:
- # Map channel ID to Arduino pin name:
- # do0→D5, do1→D6, do2→D7... (matches DIGITAL_OUT in firmware)
- # Or the channel_id itself if it already looks like D5
- pin = channel_id if channel_id.upper().startswith("D") else f"D{5 + int(channel_id.replace('do',''))}"
- self._ard_layer.digital_write(pin, int(bool(value)))
- return True
-
- def pwm_channel(self, channel_id: str, duty_pct: float) -> bool:
- """Send a PWM command to an Arduino output pin (0–100%)."""
- if self.backend == "arduino" and self._ard_layer and not self.simulate:
- pin = channel_id if channel_id.upper().startswith("D") else f"D{9 + int(channel_id.replace('do',''))}"
- return self._ard_layer.pwm_write_pct(pin, duty_pct)
- return False
-
- def set_parameter(self, name: str, value: Any) -> bool:
- """Send a named parameter/setpoint to the Arduino."""
- if self.backend == "arduino" and self._ard_layer and not self.simulate:
- return self._ard_layer.set_parameter(name, value)
- return False
-
- def switch_backend(self, backend: str, simulate: bool,
- ni_device: str, ard_port: str, ard_baud: int) -> None:
- was_running = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED)
- if was_running:
- self.disconnect()
-
- self.backend = backend
- self.simulate = simulate
- self._ni_device = ni_device
- self._ard_port = ard_port
- self._ard_baud = ard_baud
- self._ard_layer = None
- self._ni_in_task = None
- self._ni_out_task = None
-
- if was_running:
- self.connect()
-
- def get_save_config(self) -> dict:
- return {
- "device_type": self.DEVICE_TYPE,
- "device_id": self.info.device_id,
- "name": self.info.name,
- "num_inputs": self._num_inputs,
- "num_outputs": self._num_outputs,
- "simulate": self.simulate,
- "backend": self.backend,
- "ni_device": self._ni_device,
- "ard_port": self._ard_port,
- "ard_baud": self._ard_baud,
- }
-
- def get_config_widget(self) -> QWidget:
- return DigitalIOConfigWidget(self)
-
-
-class DigitalIOConfigWidget(QWidget):
- def __init__(self, device: DigitalIODevice):
- super().__init__()
- self.device = device
- self._build()
-
- def _build(self):
- root = QVBoxLayout(self)
- root.setContentsMargins(8, 8, 8, 8)
- self.setMinimumWidth(380)
-
- be_grp = QGroupBox("Backend")
- be_form = QFormLayout(be_grp)
- self.be_cb = QComboBox()
- self.be_cb.addItems(["nidaqmx", "arduino"])
- self.be_cb.setCurrentText(self.device.backend)
- be_form.addRow("Backend:", self.be_cb)
- self.sim_chk = QCheckBox("Simulate")
- self.sim_chk.setChecked(self.device.simulate)
- from core.app_settings import is_developer_mode
- self.sim_chk.setVisible(is_developer_mode())
- be_form.addRow(self.sim_chk)
-
- self.ni_dev_edit = QLineEdit(self.device._ni_device)
- be_form.addRow("NI Device:", self.ni_dev_edit)
-
- self.ard_port_edit = QLineEdit(self.device._ard_port)
- self.ard_port_edit.setPlaceholderText("e.g. /dev/ttyUSB0 or COM3")
- be_form.addRow("Arduino Port:", self.ard_port_edit)
-
- self.ard_baud_cb = QComboBox()
- self.ard_baud_cb.addItems(["9600", "19200", "57600", "115200", "230400"])
- self.ard_baud_cb.setCurrentText(str(self.device._ard_baud))
- be_form.addRow("Baud Rate:", self.ard_baud_cb)
-
- apply_btn = QPushButton("Apply & Reconnect")
- apply_btn.setObjectName("applyButton")
- apply_btn.clicked.connect(self._apply)
- be_form.addRow(apply_btn)
-
- self.be_cb.currentTextChanged.connect(self._update_visibility)
- root.addWidget(be_grp)
- self._update_visibility(self.device.backend)
- root.addStretch()
-
- def _update_visibility(self, backend: str = ""):
- backend = backend or self.be_cb.currentText()
- self.ni_dev_edit.setVisible(backend == "nidaqmx")
- self.ard_port_edit.setVisible(backend == "arduino")
- self.ard_baud_cb.setVisible(backend == "arduino")
-
- def _apply(self):
- self.device.switch_backend(
- backend=self.be_cb.currentText(),
- simulate=self.sim_chk.isChecked(),
- ni_device=self.ni_dev_edit.text().strip() or "Dev1",
- ard_port=self.ard_port_edit.text().strip(),
- ard_baud=int(self.ard_baud_cb.currentText()),
- )
diff --git a/devices/nidaqmx_device.py b/devices/nidaqmx_device.py
index 9ebec0f..652b7e2 100644
--- a/devices/nidaqmx_device.py
+++ b/devices/nidaqmx_device.py
@@ -13,7 +13,7 @@ from PyQt6.QtWidgets import (
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
-from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+from devices.base_device import BaseDevice, SignalConfig, DeviceInfo, DeviceStatus
from api_layers.nidaqmx_layer import NidaqmxLayer
_ANALOG_COLORS = [
@@ -53,20 +53,20 @@ class NidaqmxDevice(BaseDevice):
channels = []
for i in range(num_analog):
- channels.append(ChannelConfig(
- channel_id=f"ai{i}", name=f"AI{i}", unit="V",
+ channels.append(SignalConfig(
+ signal_id=f"ai{i}", name=f"AI{i}", unit="V",
min_value=min_v, max_value=max_v,
color=_ANALOG_COLORS[i % len(_ANALOG_COLORS)],
))
for i in range(num_di):
- channels.append(ChannelConfig(
- channel_id=f"di{i}", name=f"DI {i}", unit="",
+ channels.append(SignalConfig(
+ signal_id=f"di{i}", name=f"DI {i}", unit="",
min_value=0.0, max_value=1.0,
color=_DI_COLORS[i % len(_DI_COLORS)],
))
for i in range(num_do):
- channels.append(ChannelConfig(
- channel_id=f"do{i}", name=f"DO {i}", unit="",
+ channels.append(SignalConfig(
+ signal_id=f"do{i}", name=f"DO {i}", unit="",
min_value=0.0, max_value=1.0,
color=_DO_COLORS[i % len(_DO_COLORS)],
writable=True,
@@ -79,7 +79,7 @@ class NidaqmxDevice(BaseDevice):
description=f"NI-DAQmx — {num_analog} AI, {num_di} DI, {num_do} DO",
manufacturer="National Instruments",
icon=self.ICON,
- channels=channels,
+ signals=channels,
)
super().__init__(info)
@@ -99,8 +99,8 @@ class NidaqmxDevice(BaseDevice):
ok = True
try:
if self._num_analog > 0:
- ai_ids = [ch.channel_id for ch in self.info.channels
- if ch.channel_id.startswith("ai")]
+ ai_ids = [ch.signal_id for ch in self.info.signals
+ if ch.signal_id.startswith("ai")]
self._ai_layer = NidaqmxLayer(
device_name=self._ni_device,
channels=ai_ids,
@@ -179,14 +179,14 @@ class NidaqmxDevice(BaseDevice):
try:
if self._ni_in_task:
vals = self._ni_in_task.read()
- di_chs = [c for c in self.info.channels if c.channel_id.startswith("di")]
+ di_chs = [c for c in self.info.signals if c.signal_id.startswith("di")]
for i, ch in enumerate(di_chs):
- result[ch.channel_id] = float(vals[i] if isinstance(vals, list) else vals)
+ result[ch.signal_id] = float(vals[i] if isinstance(vals, list) else vals)
except Exception:
pass
- for ch in (c for c in self.info.channels if c.channel_id.startswith("do")):
- result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0))
+ for ch in (c for c in self.info.signals if c.signal_id.startswith("do")):
+ result[ch.signal_id] = float(self._output_state.get(ch.signal_id, 0))
return result
@@ -194,12 +194,12 @@ class NidaqmxDevice(BaseDevice):
import math, time
t = time.time()
result = {}
- for i, ch in enumerate([c for c in self.info.channels if c.channel_id.startswith("ai")]):
- result[ch.channel_id] = math.sin(t + i) * (self._max_v * 0.5)
- for ch in (c for c in self.info.channels if c.channel_id.startswith("di")):
- result[ch.channel_id] = 0.0
- for ch in (c for c in self.info.channels if c.channel_id.startswith("do")):
- result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0))
+ for i, ch in enumerate([c for c in self.info.signals if c.signal_id.startswith("ai")]):
+ result[ch.signal_id] = math.sin(t + i) * (self._max_v * 0.5)
+ for ch in (c for c in self.info.signals if c.signal_id.startswith("di")):
+ result[ch.signal_id] = 0.0
+ for ch in (c for c in self.info.signals if c.signal_id.startswith("do")):
+ result[ch.signal_id] = float(self._output_state.get(ch.signal_id, 0))
return result
def write_channel(self, channel_id: str, value: Any) -> bool:
@@ -208,8 +208,8 @@ class NidaqmxDevice(BaseDevice):
self._output_state[cid] = int(bool(value))
if not self.simulate and self._ni_out_task:
try:
- do_chs = [c for c in self.info.channels if c.channel_id.startswith("do")]
- states = [self._output_state.get(c.channel_id, 0) for c in do_chs]
+ do_chs = [c for c in self.info.signals if c.signal_id.startswith("do")]
+ states = [self._output_state.get(c.signal_id, 0) for c in do_chs]
self._ni_out_task.write(states)
except Exception as e:
print(f"[NidaqmxDevice] write failed: {e}")
diff --git a/devices/serial_device.py b/devices/serial_device.py
index d402f09..86d5e80 100644
--- a/devices/serial_device.py
+++ b/devices/serial_device.py
@@ -31,7 +31,7 @@ from PyQt6.QtWidgets import (
QStackedWidget, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget,
)
-from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+from devices.base_device import BaseDevice, SignalConfig, DeviceInfo, DeviceStatus
from api_layers.arduino_layer import ArduinoLayer
_COLORS = ["#7fff6e", "#4cc9f0", "#f72585", "#00d4ff",
@@ -145,7 +145,7 @@ class SerialDevice(BaseDevice):
device_type=self.DEVICE_TYPE,
description=self._description(),
icon=self.ICON,
- channels=channels,
+ signals=channels,
)
super().__init__(info)
self._layer = self._make_layer()
@@ -175,21 +175,21 @@ class SerialDevice(BaseDevice):
# keep the channel's unit in sync so new plots/pickers/CSV log
# headers pick up the currently-selected unit instead of a fixed
# default. Does not relabel the axis of an already-open plot pane.
- force_ch = self.get_channel("force")
+ force_ch = self.get_signal("force")
if force_ch is not None:
force_ch.unit = self._layer.current_unit
if self._fmt not in _GENERIC_FORMATS:
- # Protocol layers already key by channel_id — pass through untouched.
+ # Protocol layers already key by signal_id — pass through untouched.
# (Write-only channels, e.g. CML "M1_VS", never appear in raw — correctly dropped.)
return raw
# Generic (ArduinoLayer) may use arbitrary names — remap by position.
mapped: Dict[str, float] = {}
raw_vals = list(raw.values())
- for i, ch in enumerate(self.info.channels):
- if ch.channel_id in raw:
- mapped[ch.channel_id] = raw[ch.channel_id]
+ for i, ch in enumerate(self.info.signals):
+ if ch.signal_id in raw:
+ mapped[ch.signal_id] = raw[ch.signal_id]
elif i < len(raw_vals):
- mapped[ch.channel_id] = raw_vals[i]
+ mapped[ch.signal_id] = raw_vals[i]
return mapped
def write_channel(self, channel_id: str, value: Any) -> bool:
@@ -217,9 +217,9 @@ class SerialDevice(BaseDevice):
cfg["port_serial"] = ArduinoLayer.port_serial_number(self._port)
if self._fmt in _GENERIC_FORMATS:
cfg.update({
- "num_channels": len(self.info.channels),
- "channel_names": [c.channel_id for c in self.info.channels],
- "units": [c.unit for c in self.info.channels],
+ "num_channels": len(self.info.signals),
+ "channel_names": [c.signal_id for c in self.info.signals],
+ "units": [c.unit for c in self.info.signals],
})
elif self._fmt == "scpi":
cfg["scpi_channels"] = self._scpi_channels
@@ -274,7 +274,7 @@ class SerialDevice(BaseDevice):
self._mb_channels = modbus_channels or self._mb_channels
self._motors = motors or self._motors
- self.info.channels = self._build_channel_configs()
+ self.info.signals = self._build_channel_configs()
self.info.description = self._description()
self._layer = self._make_layer()
@@ -287,14 +287,14 @@ class SerialDevice(BaseDevice):
label = _LABEL_FOR.get(self._fmt, self._fmt)
return f"{self._port} @ {self._baud} [{label}]"
- def _build_channel_configs(self) -> List[ChannelConfig]:
+ def _build_channel_configs(self) -> List[SignalConfig]:
fmt = self._fmt
if fmt in _GENERIC_FORMATS:
names = self._chan_names or [f"sig{i}" for i in range(self._num_channels)]
_units = self._units or [""] * len(names)
return [
- ChannelConfig(
- channel_id=names[i], name=names[i], unit=_units[i],
+ SignalConfig(
+ signal_id=names[i], name=names[i], unit=_units[i],
min_value=0.0, max_value=1023.0,
color=_COLORS[i % len(_COLORS)],
)
@@ -302,8 +302,8 @@ class SerialDevice(BaseDevice):
]
elif fmt == "scpi":
return [
- ChannelConfig(
- channel_id=sc["channel_id"],
+ SignalConfig(
+ signal_id=sc["channel_id"],
name=sc.get("name", sc["channel_id"]),
unit=sc.get("unit", ""),
min_value=-1e9, max_value=1e9,
@@ -314,8 +314,8 @@ class SerialDevice(BaseDevice):
]
elif fmt == "modbus_rtu":
return [
- ChannelConfig(
- channel_id=mc["channel_id"],
+ SignalConfig(
+ signal_id=mc["channel_id"],
name=mc.get("name", mc["channel_id"]),
unit=mc.get("unit", ""),
min_value=-1e9, max_value=1e9,
@@ -328,15 +328,15 @@ class SerialDevice(BaseDevice):
elif fmt == "mark10":
from api_layers.protocols.mark10 import UNITS as _MARK10_UNITS
return [
- ChannelConfig("force", "Force", "N", -5000.0, 5000.0, color=_COLORS[0]),
- ChannelConfig("unit_code", "Unit Code", "", 0.0, float(len(_MARK10_UNITS) - 1), color=_COLORS[1]),
+ SignalConfig("force", "Force", "N", -5000.0, 5000.0, color=_COLORS[0]),
+ SignalConfig("unit_code", "Unit Code", "", 0.0, float(len(_MARK10_UNITS) - 1), color=_COLORS[1]),
# Write-only action channels (no reading — for control buttons)
- ChannelConfig("zero", "Zero Gauge", "", 0.0, 1.0, color=_COLORS[2], writable=True),
- ChannelConfig("cycle_units", "Cycle Units", "", 0.0, 1.0, color=_COLORS[3], writable=True),
+ SignalConfig("zero", "Zero Gauge", "", 0.0, 1.0, color=_COLORS[2], writable=True),
+ SignalConfig("cycle_units", "Cycle Units", "", 0.0, 1.0, color=_COLORS[3], writable=True),
]
elif fmt == "dscusb":
return [
- ChannelConfig("extension", "Extension", "", -50.0, 50.0, color=_COLORS[0]),
+ SignalConfig("extension", "Extension", "", -50.0, 50.0, color=_COLORS[0]),
]
elif fmt == "cml":
_CMD_UNITS = {"TP": "counts", "TV": "counts/s", "TC": "%×10", "TS": "flags"}
@@ -346,8 +346,8 @@ class SerialDevice(BaseDevice):
for motor in self._motors:
mid = motor.get("motor_id", "M1")
for cmd in motor.get("read_cmds", ["TP", "TV", "TC"]):
- channels.append(ChannelConfig(
- channel_id=f"{mid}_{cmd}",
+ channels.append(SignalConfig(
+ signal_id=f"{mid}_{cmd}",
name=f"{mid} {cmd}",
unit=_CMD_UNITS.get(cmd, ""),
min_value=-1e6, max_value=1e6,
@@ -356,8 +356,8 @@ class SerialDevice(BaseDevice):
color_idx += 1
# Write-only setpoint channels (no reading — for control widgets)
for cmd in ("VS", "MA"):
- channels.append(ChannelConfig(
- channel_id=f"{mid}_{cmd}",
+ channels.append(SignalConfig(
+ signal_id=f"{mid}_{cmd}",
name=f"{mid} {cmd}",
unit=_WRITE_UNITS.get(cmd, ""),
min_value=-1e6, max_value=1e6,
@@ -367,8 +367,8 @@ class SerialDevice(BaseDevice):
color_idx += 1
# Write-only action channels — motor must be enabled (ME) before VS/MA take effect
for cmd, label in (("ME", "Enable"), ("MD", "Disable"), ("ST", "Stop")):
- channels.append(ChannelConfig(
- channel_id=f"{mid}_{cmd}",
+ channels.append(SignalConfig(
+ signal_id=f"{mid}_{cmd}",
name=f"{mid} {label}",
unit="",
min_value=0.0, max_value=1.0,
@@ -376,13 +376,13 @@ class SerialDevice(BaseDevice):
writable=True,
))
color_idx += 1
- return channels or [ChannelConfig("M1_TP", "M1 TP", "counts", color=_COLORS[0])]
+ return channels or [SignalConfig("M1_TP", "M1 TP", "counts", color=_COLORS[0])]
return []
def _make_layer(self):
fmt = self._fmt
if fmt in _GENERIC_FORMATS:
- names = [ch.channel_id for ch in self.info.channels]
+ names = [ch.signal_id for ch in self.info.signals]
return ArduinoLayer(
port=self._port, baud=self._baud,
analog_pins=names, simulate=self.simulate,
@@ -632,7 +632,7 @@ class _GenericPanel(QWidget):
self._ch_spin = QSpinBox()
self._ch_spin.setRange(1, 32)
- self._ch_spin.setValue(len(device.info.channels) if device._fmt in _GENERIC_FORMATS else 4)
+ self._ch_spin.setValue(len(device.info.signals) if device._fmt in _GENERIC_FORMATS else 4)
lay.addRow("Signals:", self._ch_spin)
# ── Diagnostics ───────────────────────────────────────────────────
diff --git a/plugins/motion_capture/device.py b/plugins/motion_capture/device.py
index fc1c588..ec2ca22 100644
--- a/plugins/motion_capture/device.py
+++ b/plugins/motion_capture/device.py
@@ -18,7 +18,7 @@ from __future__ import annotations
from typing import Any, Dict, Optional
from devices.base_device import (
- BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus,
+ BaseDevice, SignalConfig, DeviceInfo, DeviceStatus,
)
@@ -38,17 +38,17 @@ class CameraDevice(BaseDevice):
device_type = "camera",
description = "Camera point-tracking — x/y position in pixels.",
icon = "📷",
- channels = [
- ChannelConfig(
- channel_id = "x_pos",
+ signals = [
+ SignalConfig(
+ signal_id = "x_pos",
name = "X Position",
unit = "px",
min_value = 0.0,
max_value = 1920.0,
color = "#00d4ff",
),
- ChannelConfig(
- channel_id = "y_pos",
+ SignalConfig(
+ signal_id = "y_pos",
name = "Y Position",
unit = "px",
min_value = 0.0,
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: