summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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: