summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--api_layers/arduino_layer.py14
-rw-r--r--core/acquisition.py19
-rw-r--r--core/signal_processor.py4
-rw-r--r--devices/arduino_device.py7
-rw-r--r--devices/serial_device.py37
-rw-r--r--ui/add_device_dialog.py18
-rw-r--r--ui/code_templates.py20
-rw-r--r--ui/control_editor.py96
-rw-r--r--ui/control_panel.py41
-rw-r--r--ui/main_window.py3
-rw-r--r--ui/strip_chart.py35
-rw-r--r--ui/style_dark.qss13
-rw-r--r--ui/style_light.qss7
-rw-r--r--ui/windows/channels_window.py305
-rw-r--r--ui/windows/config_window.py113
-rw-r--r--ui/windows/devices_window.py20
-rw-r--r--ui/windows/plot_window.py132
17 files changed, 637 insertions, 247 deletions
diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py
index 52eeb30..a3c3584 100644
--- a/api_layers/arduino_layer.py
+++ b/api_layers/arduino_layer.py
@@ -87,7 +87,7 @@ class ArduinoLayer:
DEFAULT_DIGITAL_PINS = ["D2", "D3", "D4", "D5", "D6", "D7"]
# Firmware DIGITAL_IN[] and DIGITAL_OUT[] arrays — match arduino_layer.py firmware sketch
DEFAULT_DI_PINS = ["D2", "D3", "D4"]
- DEFAULT_DO_PINS = ["D5", "D6", "D7", "D9", "D10", "D11"]
+ DEFAULT_DO_PINS = ["D5", "D6", "D7", "D8", "D9", "D10", "D11", "D13"]
def __init__(
self,
@@ -563,6 +563,18 @@ class ArduinoLayer:
def is_pyserial_available() -> bool:
return _SERIAL_AVAILABLE
+ @staticmethod
+ def describe_port(port: str) -> str:
+ """OS-reported device description for a port — the same string shown
+ in Windows Device Manager or a Linux udev/lsusb listing — so an
+ auto-named device mirrors what the operator already sees outside
+ this app. Empty string if the port isn't currently enumerable
+ (e.g. simulation mode, or the device was unplugged)."""
+ for dev, desc in ArduinoLayer.list_ports():
+ if dev == port and desc:
+ return desc
+ return ""
+
def __repr__(self):
mode = "SIM" if self.simulate else f"HW:{self.port}@{self.baud}"
return f"<ArduinoLayer {mode} pins={self.analog_pins}>"
diff --git a/core/acquisition.py b/core/acquisition.py
index 4fffb8b..79bdb79 100644
--- a/core/acquisition.py
+++ b/core/acquisition.py
@@ -73,12 +73,15 @@ class AcquisitionEngine(QObject):
new_data(timestamp, readings) # readings: list[(device_id, channel_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
+ # switched lb/kgF/N/ozF on its own front panel
log_started(filepath)
log_stopped(filepath)
"""
new_data = pyqtSignal(float, list)
device_status_changed = pyqtSignal(str, str)
+ channel_unit_changed = pyqtSignal(str, str, str)
log_started = pyqtSignal(str)
log_stopped = pyqtSignal(str)
@@ -87,6 +90,8 @@ class AcquisitionEngine(QObject):
self._interval = poll_interval_ms / 1000.0
self._devices: List[BaseDevice] = []
self._buffers: Dict[str, Dict[str, ChannelBuffer]] = {}
+ self._last_units: Dict[Tuple[str, str], str] = {}
+ self._last_status: Dict[str, str] = {}
self._running = False
self._thread: Optional[threading.Thread] = None
self._t0 = 0.0
@@ -104,10 +109,15 @@ class AcquisitionEngine(QObject):
ch.channel_id: ChannelBuffer()
for ch in dev.info.channels
}
+ for ch in dev.info.channels:
+ self._last_units[(dev.info.device_id, ch.channel_id)] = ch.unit
def remove_device(self, device_id: str):
self._devices = [d for d in self._devices if d.info.device_id != device_id]
self._buffers.pop(device_id, None)
+ self._last_status.pop(device_id, None)
+ 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)
@@ -186,6 +196,10 @@ class AcquisitionEngine(QObject):
batch: List[Tuple[str, str, float]] = []
for dev in list(self._devices):
+ if self._last_status.get(dev.info.device_id) != dev.status.value:
+ self._last_status[dev.info.device_id] = dev.status.value
+ self.device_status_changed.emit(dev.info.device_id, dev.status.value)
+
active = dev.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED)
try:
readings = dev.read_channels() if active else {}
@@ -194,6 +208,11 @@ class AcquisitionEngine(QObject):
readings = {}
for ch in dev.info.channels:
+ key = (dev.info.device_id, ch.channel_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)
+
val = readings.get(ch.channel_id)
if val is None or not ch.enabled:
log_row.append("")
diff --git a/core/signal_processor.py b/core/signal_processor.py
index 8ab3937..c00df73 100644
--- a/core/signal_processor.py
+++ b/core/signal_processor.py
@@ -598,4 +598,8 @@ class SignalProcessor(QObject):
elif dc.kind == "none":
return inputs[0] if inputs else None
+ elif dc.kind == "scale_offset":
+ if not inputs: return None
+ return inputs[0] * dc.params.get("scale", 1.0) + dc.params.get("offset", 0.0)
+
return None
diff --git a/devices/arduino_device.py b/devices/arduino_device.py
index 2f5e8e6..3ad0c7b 100644
--- a/devices/arduino_device.py
+++ b/devices/arduino_device.py
@@ -112,9 +112,14 @@ class ArduinoDevice(BaseDevice):
color=_DO_COLORS[i % len(_DO_COLORS)],
))
+ _desc = ArduinoLayer.describe_port(port) if not simulate else ""
+ if _desc:
+ _name = _desc if port in _desc else f"{_desc} ({port})"
+ else:
+ _name = f"Arduino ({port})"
info = DeviceInfo(
device_id=device_id,
- name="Arduino",
+ 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"),
diff --git a/devices/serial_device.py b/devices/serial_device.py
index fb131aa..61c3a64 100644
--- a/devices/serial_device.py
+++ b/devices/serial_device.py
@@ -52,6 +52,27 @@ _LABEL_FOR: Dict[str, str] = {v: k for k, v in _FORMAT_LABELS.items()}
_GENERIC_FORMATS = {"key:val", "csv", "json"}
_PROTOCOL_FORMATS = {"scpi", "modbus_rtu", "mark10", "cml"}
+# parse_format → auto-name label — fallback only, used when the OS can't
+# describe the port (simulation mode, or the device isn't enumerable)
+_AUTO_NAME_LABEL: Dict[str, str] = {
+ "mark10": "Mark-10 USB Device",
+ "scpi": "SCPI Serial Device",
+ "modbus_rtu": "Modbus Serial Device",
+ "cml": "CML Serial Device",
+}
+
+
+def _auto_name(parse_format: str, port: str, simulate: bool = False) -> str:
+ """Prefer the OS-reported device description (same string Windows Device
+ Manager / Linux udev shows for this port) over a generic protocol label,
+ so the auto-assigned name matches what the operator sees outside the app."""
+ if not simulate:
+ desc = ArduinoLayer.describe_port(port)
+ if desc:
+ return desc if port in desc else f"{desc} ({port})"
+ label = _AUTO_NAME_LABEL.get(parse_format, "USB Serial Device")
+ return f"{label} ({port})"
+
# Index into the config-widget stacked panel
_STACK_IDX = {
"key:val": 0,
@@ -78,7 +99,7 @@ class SerialDevice(BaseDevice):
parse_format: str = "key:val",
simulate: bool = True,
# ── Generic stream options ──────────────────────────────────────
- num_channels: int = 4,
+ num_channels: int = 1,
channel_names: List[str] = None,
units: List[str] = None,
# ── SCPI ───────────────────────────────────────────────────────
@@ -116,7 +137,7 @@ class SerialDevice(BaseDevice):
channels = self._build_channel_configs()
info = DeviceInfo(
device_id=device_id,
- name="Serial / UART",
+ name=_auto_name(parse_format, port, simulate),
device_type=self.DEVICE_TYPE,
description=self._description(),
icon=self.ICON,
@@ -459,14 +480,12 @@ class SerialConfigWidget(QWidget):
self._fmt_cb.currentIndexChanged.connect(self._on_fmt_changed)
conn_form.addRow("Protocol / Format:", self._fmt_cb)
- self._sim_cb = QComboBox()
- self._sim_cb.addItems(["Simulate", "Real Hardware"])
- self._sim_cb.setCurrentIndex(0 if self.device.simulate else 1)
+ self._sim_cb = QCheckBox("Simulation Mode (no hardware)")
+ self._sim_cb.setChecked(self.device.simulate)
from core.app_settings import is_developer_mode
- mode_lbl = QLabel("Mode:")
dev_mode = is_developer_mode()
- mode_lbl.setVisible(dev_mode); self._sim_cb.setVisible(dev_mode)
- conn_form.addRow(mode_lbl, self._sim_cb)
+ self._sim_cb.setVisible(dev_mode)
+ conn_form.addRow(self._sim_cb)
root.addWidget(conn_grp)
@@ -535,7 +554,7 @@ class SerialConfigWidget(QWidget):
port=self._port_edit.text().strip() or "COM3",
baud=int(self._baud_cb.currentText()),
fmt=fmt,
- simulate=(self._sim_cb.currentIndex() == 0),
+ simulate=self._sim_cb.isChecked(),
)
if fmt in _GENERIC_FORMATS:
kwargs.update(self._generic_panel.collect())
diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py
index 2bf0233..719c5a2 100644
--- a/ui/add_device_dialog.py
+++ b/ui/add_device_dialog.py
@@ -152,13 +152,20 @@ class _DeviceListScanner(QWidget):
class SerialPanel(QWidget):
def __init__(self):
super().__init__()
- lay = QVBoxLayout(self)
+ lay = QFormLayout(self)
lay.setContentsMargins(0, 4, 0, 4)
- note = QLabel("Additional protocol settings available in the device config dialog.")
+
+ self.ch_spin = QSpinBox()
+ self.ch_spin.setRange(1, 32)
+ self.ch_spin.setValue(1)
+ lay.addRow("#Channels:", self.ch_spin)
+
+ note = QLabel("Applies to Key:Value / CSV / JSON formats. "
+ "SCPI / Modbus / Mark-10 / CML define channels via their own protocol tables "
+ "(available in the device config dialog after adding).")
note.setObjectName("traceSource")
note.setWordWrap(True)
- lay.addWidget(note)
- lay.addStretch()
+ lay.addRow(note)
class ArduinoPanel(QWidget):
@@ -302,7 +309,7 @@ class AddDeviceDialog(QDialog):
cfg_form.addRow("Device ID:", self._id_edit)
self._name_edit = QLineEdit()
- self._name_edit.setPlaceholderText("Leave blank to use the default type name")
+ self._name_edit.setPlaceholderText("Leave blank to auto-name from device + port")
cfg_form.addRow("Display Name:", self._name_edit)
self._fmt_cb = QComboBox()
@@ -429,6 +436,7 @@ class AddDeviceDialog(QDialog):
dev = SerialDevice(
device_id=dev_id, port=port, baud_rate=baud,
parse_format=fmt, simulate=sim,
+ num_channels=panel.ch_spin.value(),
)
elif type_name == "Arduino":
from devices.arduino_device import _parse_pin_edit, _parse_analog_pin_edit
diff --git a/ui/code_templates.py b/ui/code_templates.py
index ffa1662..2a3ff09 100644
--- a/ui/code_templates.py
+++ b/ui/code_templates.py
@@ -19,6 +19,15 @@ Namespace available in all kinds:
math — Python math module
vars — shared SignalProcessor._script_vars dict (read/write)
state — per-channel persistent dict (survives between evaluations)
+
+`vars` is the bridge from the Control Panel into Channel math: a button's
+"On Action Script" (ui/control_editor.py) and every Channel script here see
+the *same* `vars` dict. e.g. a "Zero" button sets
+ vars['zero'] = channels.get('force_raw', 0)
+and a Channel reads it back with `vars.get('zero', 0)` — see the
+"custom_script" template below. Same pattern for an operator-entered
+constant like specimen length: a Setpoint control's On Action Script sets
+`vars['specimen_length_cm'] = value`, and a Channel divides by it.
"""
BUILTIN_TEMPLATES: dict = {
@@ -27,6 +36,14 @@ BUILTIN_TEMPLATES: dict = {
result = x[0]
""",
+ "scale_offset": """\
+# scale/offset also settable from a Control Panel button's On Action Script,
+# e.g. vars["offset"] = -x[0] to zero the reading on press
+scale = vars.get("scale", 1.0)
+offset = vars.get("offset", 0.0)
+result = x[0] * scale + offset
+""",
+
"derivative": """\
prev_v = state.get("v", x[0])
prev_t = state.get("t", t)
@@ -99,11 +116,14 @@ SCRIPT_TEMPLATES: dict = {
"expression": "x[0]",
"function": """\
+# "offset" can be set here or from a Control Panel button's On Action Script
+# (vars is the same dict on both sides)
offset = vars.get("offset", 0)
return x[0] - offset
""",
"custom_script": """\
+# Wire a "Zero" button to this: On Action Script -> vars["zero"] = channels.get("<signal_id>", 0)
zero = vars.get("zero", 0)
result = x[0] - zero
""",
diff --git a/ui/control_editor.py b/ui/control_editor.py
index 111a71f..d167517 100644
--- a/ui/control_editor.py
+++ b/ui/control_editor.py
@@ -204,31 +204,26 @@ class ControlEditorDialog(QDialog):
root.addWidget(id_grp)
- # ── Device & channel ────────────────────────────────────────────
+ # ── Target signal ────────────────────────────────────────────────
+ # Sourced from the same writable-channel set Configuration > Channels >
+ # Signals shows as "OUT" — the Signals tab is the central place hardware
+ # endpoints (including button targets) are registered.
hw_grp = QGroupBox("Hardware Assignment")
hw_form = QFormLayout(hw_grp); hw_form.setContentsMargins(10, 16, 10, 10)
- self._dev_cb = QComboBox()
- self._dev_cb.addItem("— none —", userData="")
+ self._target_cb = QComboBox()
+ self._target_cb.addItem("— none —", userData=("", ""))
for dev in self.registry.all_instances():
- label = f"{dev.info.device_id} ({dev.info.name})"
- self._dev_cb.addItem(label, userData=dev.info.device_id)
- self._dev_cb.currentIndexChanged.connect(self._on_dev_changed)
- hw_form.addRow("Device:", self._dev_cb)
-
- self._ch_cb = QComboBox()
- self._ch_cb.setEditable(True) # allow typing custom pin like "D7"
- self._ch_cb.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
- hw_form.addRow("Channel / Pin:", self._ch_cb)
+ for ch in dev.info.channels:
+ 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))
+ hw_form.addRow("Target Signal:", self._target_cb)
self._active_low_chk = QCheckBox("Active-low output (ON sends LOW)")
hw_form.addRow("Polarity:", self._active_low_chk)
- self._ch_hint = QLabel("")
- self._ch_hint.setObjectName("traceSource")
- self._ch_hint.setWordWrap(True)
- hw_form.addRow(self._ch_hint)
-
root.addWidget(hw_grp)
# ── Type-specific params ────────────────────────────────────────
@@ -256,7 +251,10 @@ class ControlEditorDialog(QDialog):
_help = QLabel(
"Runs every time this control changes value.\n"
"Available: value, channel_id, device_id, vars (shared dict), "
- "channels (latest values by channel_id), math"
+ "channels (latest values by channel_id), math\n"
+ "vars set here are readable from any Channel's script "
+ "(Configuration > Channels) via vars.get('key', default) — "
+ "this is how a button feeds a value into Channel math."
)
_help.setObjectName("traceSource"); _help.setWordWrap(True)
script_lay.addWidget(_help)
@@ -316,21 +314,22 @@ class ControlEditorDialog(QDialog):
self._type_cb.setCurrentIndex(idx)
self._title_edit.setText(self.spec.title)
- # Device
- for i in range(self._dev_cb.count()):
- if self._dev_cb.itemData(i) == self.spec.device_id:
- self._dev_cb.setCurrentIndex(i); break
-
- # Channel (populated after device selection)
- self._on_dev_changed()
+ # Target signal
found = False
- for i in range(self._ch_cb.count()):
- if self._ch_cb.itemData(i) == self.spec.channel_id:
- self._ch_cb.setCurrentIndex(i)
+ target = (self.spec.device_id, self.spec.channel_id)
+ for i in range(self._target_cb.count()):
+ if self._target_cb.itemData(i) == target:
+ self._target_cb.setCurrentIndex(i)
found = True
break
- if not found:
- self._ch_cb.setCurrentText(self.spec.channel_id)
+ if not found and self.spec.device_id:
+ # Not in the current writable-signal list (e.g. a legacy raw pin
+ # target, or the channel/device is now disabled/removed) — keep it
+ # as a synthetic entry so editing this control doesn't silently
+ # clear its saved hardware assignment.
+ label = f"{self.spec.device_id} / {self.spec.channel_id} (not currently available)"
+ self._target_cb.addItem(label, userData=target)
+ self._target_cb.setCurrentIndex(self._target_cb.count() - 1)
self._active_low_chk.setChecked(bool(self.spec.active_low))
# Params
@@ -351,50 +350,17 @@ class ControlEditorDialog(QDialog):
self._title_edit.text() in CONTROL_TYPES:
self._title_edit.setText(type_name)
- def _on_dev_changed(self):
- self._ch_cb.clear()
- dev_id = self._dev_cb.currentData()
- if not dev_id:
- self._ch_hint.setText("No device selected — type a channel manually")
- return
- dev = self.registry.get_instance(dev_id)
- if not dev:
- return
- # Add actual channels (skip disabled — can't be driven while switched
- # off — and skip read-only channels — a control writes, so a channel
- # with no write mapping should never be offered as a target)
- for ch in dev.info.channels:
- if not ch.enabled or not ch.writable:
- continue
- self._ch_cb.addItem(f"{ch.name} ({ch.channel_id})",
- userData=ch.channel_id)
- # For Arduino backends also suggest digital pins for output
- if hasattr(dev, "backend") and dev.backend == "arduino":
- self._ch_cb.insertSeparator(self._ch_cb.count())
- for pin in ["D5", "D6", "D7", "D8", "D9", "D10", "D11", "D13"]:
- self._ch_cb.addItem(f"{pin} (digital out)", userData=pin)
- self._ch_hint.setText(
- "Analog pins (A0…) for reading.\n"
- "Digital pins (D5…) for on/off output.\n"
- "D9, D10, D11 support PWM."
- )
- else:
- self._ch_hint.setText("")
-
def _on_ok(self):
ctype = self._type_cb.currentText()
title = self._title_edit.text().strip() or ctype
- # Resolve channel_id — prefer userData if it's a real combo item
- ch_text = self._ch_cb.currentText().strip()
- ch_data = self._ch_cb.currentData()
- channel_id = ch_data if ch_data else ch_text.split()[0] # strip "(name)" part
+ dev_id, channel_id = self._target_cb.currentData() or ("", "")
spec = ControlSpec(
control_type=ctype,
title=title,
icon=CONTROL_ICONS.get(ctype, "⚙"),
- device_id=self._dev_cb.currentData() or "",
+ device_id=dev_id,
channel_id=channel_id,
active_low=self._active_low_chk.isChecked(),
on_action_script=self._script_edit.toPlainText().strip(),
diff --git a/ui/control_panel.py b/ui/control_panel.py
index 3499a44..adf48e6 100644
--- a/ui/control_panel.py
+++ b/ui/control_panel.py
@@ -88,6 +88,8 @@ class ControlWidget(QFrame):
self._compile_action(on_action_script)
self.setObjectName("controlWidget")
self._build_frame()
+ self._stale = None
+ self._check_stale()
def _compile_action(self, script: str):
try:
@@ -96,6 +98,31 @@ class ControlWidget(QFrame):
except SyntaxError as e:
print(f"[Control '{self.title}'] script syntax error: {e}")
+ def _check_stale(self):
+ """Re-evaluate whether this control's target signal is currently
+ reachable — unbound controls (no device/channel assigned) are never
+ stale, only ones pointing at a signal that's since gone missing."""
+ stale = False
+ if self.device_id and self.channel_id:
+ dev = self.registry.get_instance(self.device_id) if self.registry else None
+ if dev is None:
+ stale = True
+ else:
+ ch = dev.get_channel(self.channel_id)
+ if ch is None or not ch.enabled:
+ stale = True
+ elif dev.status not in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED):
+ stale = True
+ self._set_stale(stale)
+
+ def _set_stale(self, stale: bool):
+ if self._stale == stale:
+ return
+ self._stale = stale
+ self._stale_lbl.setVisible(stale)
+ self.setProperty("stale", "true" if stale else "false")
+ self.style().unpolish(self); self.style().polish(self)
+
def _logic_level(self, enabled: bool) -> float:
"""Map logical ON/OFF to electrical level, respecting active-low outputs."""
if self.active_low:
@@ -123,6 +150,11 @@ class ControlWidget(QFrame):
title_lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
hdr_lay.addWidget(icon_lbl)
hdr_lay.addWidget(title_lbl, 1)
+ self._stale_lbl = QLabel("⚠")
+ self._stale_lbl.setObjectName("controlWidgetStale")
+ self._stale_lbl.setToolTip("Target signal not connected")
+ self._stale_lbl.setVisible(False)
+ hdr_lay.addWidget(self._stale_lbl)
self._header = hdr
self._header_layout = hdr_lay
outer.addWidget(hdr)
@@ -635,13 +667,20 @@ class ControlPanel(QWidget):
controls_changed = pyqtSignal() # emitted whenever widgets are added/edited/removed
- def __init__(self, registry: DeviceRegistry, processor=None):
+ def __init__(self, registry: DeviceRegistry, processor=None, engine=None):
super().__init__()
self.registry = registry
self.processor = processor
+ self.engine = engine
self._widgets: list[ControlWidget] = []
self._specs: list = [] # parallel list of ControlSpec
self._build()
+ if engine is not None:
+ engine.device_status_changed.connect(self._on_device_status_changed)
+
+ def _on_device_status_changed(self, device_id: str, status: str):
+ for w in self._widgets:
+ w._check_stale()
# ── Layout ───────────────────────────────────────────────────────────────
diff --git a/ui/main_window.py b/ui/main_window.py
index 616f16d..c258424 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -255,7 +255,7 @@ class MainWindow(QMainWindow):
root.addWidget(self._chart, 1)
# ── Control Panel dock ────────────────────────────────────────────
- self._ctrl = ControlPanel(self.registry, processor=self.processor)
+ self._ctrl = ControlPanel(self.registry, processor=self.processor, engine=self.engine)
self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340)
self._ctrl._add_demo_widgets()
@@ -306,6 +306,7 @@ class MainWindow(QMainWindow):
def _connect_signals(self):
self.engine.new_data.connect(self.processor.on_raw_batch)
self.processor.processed_data.connect(self._chart.on_new_data)
+ self.engine.channel_unit_changed.connect(self._chart.refresh_pane_unit)
self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● {p}"))
self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ {p}"))
diff --git a/ui/strip_chart.py b/ui/strip_chart.py
index a212e5c..6c644be 100644
--- a/ui/strip_chart.py
+++ b/ui/strip_chart.py
@@ -79,6 +79,9 @@ class StripChartWidget(QWidget):
self._cfg: Optional[LayoutConfig] = None
# (dev_id, ch_id) → [{curve, plot, spec}]
self._curves: Dict[Tuple, List[dict]] = {}
+ # (dev_id, ch_id) → last unit seen — lets refresh_pane_unit() tell an
+ # auto-seeded pane label apart from one the operator typed by hand
+ self._known_units: Dict[Tuple[str, str], str] = {}
# Time-axis plots, scrolled continuously by _scroll_timer rather than
# only when a new sample lands — decouples the visible scroll rate
# from the (much slower) poll rate so it doesn't look stepped.
@@ -179,6 +182,10 @@ class StripChartWidget(QWidget):
key = (tr.device_id, tr.channel_id)
self._curves.setdefault(key, []).append(
{"curve": curve, "plot": plot, "spec": spec, "pane": spec})
+ # Seed with the channel's true current unit (not spec.y_label,
+ # which may be pane title text or a custom label) so the first
+ # live unit-change after a layout rebuild is still detected.
+ self._known_units[key] = self._lookup_channel_unit(*key)
def set_theme(self, theme: str):
"""Hot-swap pyqtgraph colours when the user changes theme."""
@@ -196,6 +203,34 @@ class StripChartWidget(QWidget):
for e in self._curves.get(key, []):
e["curve"].setVisible(enabled)
+ def _lookup_channel_unit(self, device_id: str, channel_id: str) -> str:
+ if device_id == "derived":
+ for dc in self.processor.get_derived():
+ if dc.channel_id == channel_id:
+ return dc.unit
+ return ""
+ dev = self.registry.get_instance(device_id)
+ ch = dev.get_channel(channel_id) if dev else None
+ return ch.unit if ch else ""
+
+ @pyqtSlot(str, str, str)
+ def refresh_pane_unit(self, device_id: str, channel_id: str, new_unit: str):
+ """A channel's unit changed live (e.g. a Mark-10 gauge switched
+ lb/kgF/N/ozF on its own front panel). Update any open pane's Y-axis
+ label that's still showing the *previous* unit verbatim — but never
+ touch a label the operator typed by hand."""
+ key = (device_id, channel_id)
+ old_unit = self._known_units.get(key)
+ self._known_units[key] = new_unit
+ if old_unit == new_unit:
+ return
+ for entry in self._curves.get(key, []):
+ spec = entry["spec"]
+ if spec.y_label != old_unit:
+ continue
+ spec.y_label = new_unit
+ entry["plot"].setLabel("left", new_unit or spec.title)
+
@pyqtSlot(str, str, float, float)
def on_new_data(self, device_id: str, channel_id: str, ts: float, val: float):
if self._paused or not _HAS_PG or not self._cfg: return
diff --git a/ui/style_dark.qss b/ui/style_dark.qss
index cfb127d..06c70d9 100644
--- a/ui/style_dark.qss
+++ b/ui/style_dark.qss
@@ -125,6 +125,12 @@ QFrame#deviceCard:hover { border-color: #3b82f6; background-color: #1c2540; }
QLabel#deviceIcon { font-size: 18px; }
QLabel#deviceName { font-size: 13px; font-weight: 600; color: #e2e8f0; }
+QLineEdit#deviceName {
+ font-size: 13px; font-weight: 600; color: #e2e8f0;
+ background: transparent; border: 1px solid transparent; border-radius: 3px; padding: 0 2px;
+}
+QLineEdit#deviceName:hover { border-color: #2a3558; }
+QLineEdit#deviceName:focus { border-color: #3b82f6; background-color: #161d2e; }
QLabel#deviceSub { font-family: "IBM Plex Mono", monospace; font-size: 11px; color: #8b9dc3; }
QLabel#deviceChannelCount { font-size: 11px; color: #3b82f6; }
@@ -222,6 +228,10 @@ QTableWidget#channelTable {
color: #e2e8f0;
font-size: 12px;
}
+QTableWidget#channelTable::item:selected {
+ background-color: #3b82f6;
+ color: #0b0e13;
+}
QHeaderView::section {
background-color: #161d2e;
color: #8b9dc3;
@@ -404,10 +414,13 @@ QPushButton#applyButton:hover { background-color: #1d4ed8; color: #eff6ff; }
/* ── Control widgets ─────────────────────────────────────────────── */
QFrame#controlWidget { background-color: #161d2e; border: 1px solid #2a3558; border-radius: 6px; }
+QFrame#controlWidget[stale="true"] { border: 1px solid #f59e0b; }
QWidget#controlWidgetHeader { background-color: #0f1521; border-radius: 5px 5px 0 0; border-bottom: 1px solid #2a3558; }
QLabel#controlWidgetIcon { font-size: 14px; color: #3b82f6; padding-right: 4px; }
QLabel#controlWidgetTitle { font-family: "IBM Plex Mono", monospace; font-size: 11px; font-weight: 700; letter-spacing: 1.5px; color: #8b9dc3; }
+QLabel#controlWidgetStale { color: #f59e0b; font-size: 12px; padding: 0 2px; }
QWidget#controlWidgetBody { background-color: #161d2e; border-radius: 0 0 5px 5px; }
+QFrame#controlWidget[stale="true"] QWidget#controlWidgetBody { background-color: #1c1710; }
QLabel#motorSpeedLbl { font-family: "IBM Plex Mono", monospace; font-size: 28px; font-weight: 700; color: #00d4ff; }
QLabel#motorUnitLbl { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: #64748b; padding-top: 12px; }
diff --git a/ui/style_light.qss b/ui/style_light.qss
index b98dc52..f75e7cb 100644
--- a/ui/style_light.qss
+++ b/ui/style_light.qss
@@ -17,6 +17,9 @@ QLabel#panelHeader { background:#f1f5f9; color:#64748b; font-family:"IBM Plex Mo
QFrame#deviceCard { background:#ffffff; border:1px solid #e2e8f0; border-radius:6px; }
QFrame#deviceCard:hover { border-color:#3b82f6; background:#f0f9ff; }
QLabel#deviceName { font-size:13px; font-weight:600; color:#1e293b; }
+QLineEdit#deviceName { font-size:13px; font-weight:600; color:#1e293b; background:transparent; border:1px solid transparent; border-radius:3px; padding:0 2px; }
+QLineEdit#deviceName:hover { border-color:#cbd5e1; }
+QLineEdit#deviceName:focus { border-color:#3b82f6; background:#ffffff; }
QLabel#deviceSub { font-family:"IBM Plex Mono",monospace; font-size:10px; color:#94a3b8; }
QLabel#deviceChannelCount { font-size:11px; color:#3b82f6; }
QPushButton#configButton { background:#f1f5f9; color:#64748b; border:1px solid #cbd5e1; border-radius:3px; padding:3px 10px; font-size:11px; }
@@ -60,6 +63,7 @@ QFrame#devWindowDivider { color:#e2e8f0; max-height:1px; }
QWidget#cfgGlobalBar { background:#f8fafc; border-bottom:1px solid #e2e8f0; }
QWidget#cfgBottomBar { background:#f8fafc; border-top:1px solid #e2e8f0; }
QTableWidget#channelTable { background:#ffffff; gridline-color:#f1f5f9; border:1px solid #e2e8f0; alternate-background-color:#f8fafc; }
+QTableWidget#channelTable::item:selected { background-color:#93c5fd; color:#0f172a; }
QHeaderView::section { background:#f1f5f9; color:#475569; border:none; border-bottom:1px solid #e2e8f0; padding:4px 8px; font-weight:600; }
/* Port scanner list — light theme */
@@ -73,10 +77,13 @@ QListWidget#portList::item:selected { background:#dbeafe; color:#1d4ed8; border-
/* ── Control panel widgets ───────────────────────────────────────── */
QFrame#controlWidget { background:#ffffff; border:1px solid #cbd5e1; border-radius:6px; }
+QFrame#controlWidget[stale="true"] { border:1px solid #d97706; }
QWidget#controlWidgetHeader { background:#f1f5f9; border-radius:5px 5px 0 0; border-bottom:1px solid #e2e8f0; }
QLabel#controlWidgetIcon { font-size:14px; color:#3b82f6; padding-right:4px; }
QLabel#controlWidgetTitle { font-family:"IBM Plex Mono",monospace; font-size:11px; font-weight:700; letter-spacing:1.5px; color:#64748b; }
+QLabel#controlWidgetStale { color:#d97706; font-size:12px; padding:0 2px; }
QWidget#controlWidgetBody { background:#ffffff; border-radius:0 0 5px 5px; }
+QFrame#controlWidget[stale="true"] QWidget#controlWidgetBody { background:#fffbeb; }
/* Motor */
QLabel#motorSpeedLbl { font-family:"IBM Plex Mono",monospace; font-size:28px; font-weight:700; color:#1d4ed8; }
diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py
index 38d477b..2388b83 100644
--- a/ui/windows/channels_window.py
+++ b/ui/windows/channels_window.py
@@ -215,7 +215,8 @@ class ChannelPipelineBlock(QFrame):
removed = pyqtSignal(object)
def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str,
- processor: SignalProcessor):
+ processor: SignalProcessor, show_calibration: bool = True,
+ show_header: bool = True):
super().__init__()
self.dev_id = dev_id
self.ch_id = ch_id
@@ -224,6 +225,8 @@ class ChannelPipelineBlock(QFrame):
self._cal_rows: List[FilterRow] = []
self._cur_pipeline: Optional[ChannelPipeline] = None
self._expanded = False
+ self._show_calibration = show_calibration
+ self._show_header = show_header
self.setObjectName("plotBlock")
self._build(ch_name, unit)
self._load()
@@ -233,26 +236,28 @@ class ChannelPipelineBlock(QFrame):
self._unit = unit
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)
- title = f"{ch_name} ({self.dev_id}/{self.ch_id})"
- if unit:
- title += f" [{unit}]"
- self._title_lbl = QLabel(title); self._title_lbl.setObjectName("traceSource")
- hl.addWidget(self._title_lbl, 1)
-
- rm_btn = QToolButton(); rm_btn.setText("✕")
- rm_btn.setObjectName("traceRemoveBtn"); rm_btn.setFixedSize(22, 22)
- rm_btn.clicked.connect(lambda: self.removed.emit(self))
- hl.addWidget(rm_btn)
-
- self._toggle_btn = QToolButton(); self._toggle_btn.setText("▶")
- self._toggle_btn.setObjectName("traceRemoveBtn"); self._toggle_btn.setFixedSize(22, 22)
- self._toggle_btn.clicked.connect(self._toggle)
- hl.addWidget(self._toggle_btn)
- outer.addWidget(hdr)
- hdr.mousePressEvent = lambda e: self._toggle()
+ # Header (omitted when embedded in another block, e.g. DerivedBlock's
+ # Filters section — the parent already provides a header/toggle)
+ if self._show_header:
+ hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32)
+ hl = QHBoxLayout(hdr); hl.setContentsMargins(8, 0, 6, 0)
+ title = f"{ch_name} ({self.dev_id}/{self.ch_id})"
+ if unit:
+ title += f" [{unit}]"
+ self._title_lbl = QLabel(title); self._title_lbl.setObjectName("traceSource")
+ hl.addWidget(self._title_lbl, 1)
+
+ rm_btn = QToolButton(); rm_btn.setText("✕")
+ rm_btn.setObjectName("traceRemoveBtn"); rm_btn.setFixedSize(22, 22)
+ rm_btn.clicked.connect(lambda: self.removed.emit(self))
+ hl.addWidget(rm_btn)
+
+ self._toggle_btn = QToolButton(); self._toggle_btn.setText("▶")
+ self._toggle_btn.setObjectName("traceRemoveBtn"); self._toggle_btn.setFixedSize(22, 22)
+ self._toggle_btn.clicked.connect(self._toggle)
+ hl.addWidget(self._toggle_btn)
+ outer.addWidget(hdr)
+ hdr.mousePressEvent = lambda e: self._toggle()
# Body
self._body = QWidget(); self._body.setObjectName("plotBlockSettings")
@@ -281,21 +286,25 @@ class ChannelPipelineBlock(QFrame):
div.setObjectName("devWindowDivider")
bl.addWidget(div)
- # ── Calibration section ───────────────────────────────────────────────
- cal_lbl = QLabel("Calibration"); cal_lbl.setObjectName("pwmLabel")
- bl.addWidget(cal_lbl)
-
+ # ── Calibration section — hidden when embedded (calibration is now
+ # configured as a Channel "Scale/Offset" Operation instead), but the
+ # widgets still exist so a legacy scale_offset filter already saved
+ # on this pipeline (from an older profile) is preserved by _push()
+ # rather than silently dropped the next time a filter is edited.
self._cal_area = QWidget()
self._cal_lay = QVBoxLayout(self._cal_area)
self._cal_lay.setContentsMargins(0, 0, 0, 0); self._cal_lay.setSpacing(3)
- bl.addWidget(self._cal_area)
-
- cal_add_bar = QHBoxLayout()
- cal_add_btn = QPushButton("+ Add Calibration"); cal_add_btn.setObjectName("addTraceBtn")
- cal_add_btn.clicked.connect(self._add_calibrate)
- cal_add_bar.addWidget(cal_add_btn)
- cal_add_bar.addStretch()
- bl.addLayout(cal_add_bar)
+ if self._show_calibration:
+ cal_lbl = QLabel("Calibration"); cal_lbl.setObjectName("pwmLabel")
+ bl.addWidget(cal_lbl)
+ bl.addWidget(self._cal_area)
+
+ cal_add_bar = QHBoxLayout()
+ cal_add_btn = QPushButton("+ Add Calibration"); cal_add_btn.setObjectName("addTraceBtn")
+ cal_add_btn.clicked.connect(self._add_calibrate)
+ cal_add_bar.addWidget(cal_add_btn)
+ cal_add_bar.addStretch()
+ bl.addLayout(cal_add_bar)
ap_row = QHBoxLayout(); ap_row.addStretch()
ap_btn = QPushButton("✓ Apply"); ap_btn.setObjectName("applyButton")
@@ -304,9 +313,12 @@ class ChannelPipelineBlock(QFrame):
bl.addLayout(ap_row)
outer.addWidget(self._body)
- self._body.setVisible(False)
+ self._body.setVisible(self._show_header is False)
+ self._expanded = not self._show_header
def _refresh_title(self):
+ if not self._show_header:
+ return
title = f"{self._ch_name} ({self.dev_id}/{self.ch_id})"
if self._unit:
title += f" [{self._unit}]"
@@ -447,7 +459,7 @@ class PipelineTab(QWidget):
vb_lay.addWidget(vb_lbl)
self._src_cb = _channel_combo(self.registry, self.processor, include_derived=True)
self._src_cb.setObjectName("channelPickerCb")
- self._src_cb.insertItem(0, "Source: none (empty channel)", userData=None)
+ self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None)
self._src_cb.setCurrentIndex(0)
vb_lay.addWidget(self._src_cb, 1)
add_virt = QPushButton("+ Add Channel"); add_virt.setObjectName("addTraceBtn")
@@ -521,6 +533,7 @@ class PipelineTab(QWidget):
block = self._blocks.get(key)
if block:
self._on_remove_block(block)
+ self._refresh_src_cb()
def on_device_reconfigured(self, dev_id: str):
dev = self.registry.get_instance(dev_id)
@@ -534,9 +547,10 @@ class PipelineTab(QWidget):
block = self._blocks.get(key)
if block:
self._on_remove_block(block)
+ self._refresh_src_cb()
def on_device_added(self):
- pass
+ self._refresh_src_cb()
def on_channel_name_changed(self, dev_id: str, ch_id: str, ch_name: str):
key = (dev_id, ch_id)
@@ -548,12 +562,42 @@ class PipelineTab(QWidget):
ch = dev.get_channel(ch_id)
if ch and ch.enabled:
self._add_block(dev_id, ch_id, ch_name, ch.unit)
+ self._refresh_src_cb()
def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str):
key = (dev_id, ch_id)
if key in self._blocks:
self._blocks[key].update_unit(unit)
+ def _refresh_src_cb(self):
+ """Rebuild the 'Source: …' picker at the top of the Channels pane —
+ it's a persistent combo (not a dialog), so it must be kept in sync
+ whenever devices/channels are added, removed, or renamed, not just
+ built once at construction time."""
+ current = self._src_cb.currentData()
+ self._src_cb.blockSignals(True)
+ 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:
+ 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))
+ for dc in self.processor.get_derived():
+ label = f"[derived] {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()):
+ if self._src_cb.itemData(i) == current:
+ self._src_cb.setCurrentIndex(i)
+ break
+ else:
+ self._src_cb.setCurrentIndex(0)
+ else:
+ self._src_cb.setCurrentIndex(0)
+ self._src_cb.blockSignals(False)
+
def _on_add_channel(self):
dlg = ChannelPickerDialog(self.registry, self._shown, self)
if dlg.exec() != QDialog.DialogCode.Accepted:
@@ -579,14 +623,21 @@ class PipelineTab(QWidget):
def _add_virtual(self):
color = _DERIVED_COLORS[self._color_idx % len(_DERIVED_COLORS)]
self._color_idx += 1
- n = len(self._derived_blocks) + 1
- dc = DerivedChannel(channel_id=f"CH_{n}", name=f"Channel {n}",
+ existing_names = {d.name for d in self.processor.get_derived()}
+ existing_ids = {d.channel_id for d in self.processor.get_derived()}
+ # Pre-seed the source picked in the bar above, if any — otherwise the
+ # channel is created empty and signals can be added manually.
+ src = self._src_cb.currentData()
+ if src:
+ src_name = _channel_display_name(self.registry, self.processor, src[0], src[1])
+ else:
+ 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,
kind="expression", color=color)
blk = self._make_derived_block(dc)
self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk)
- # Pre-seed the source picked in the bar above, if any — otherwise the
- # channel is created empty and sources can be added manually.
- src = self._src_cb.currentData()
if src:
blk._add_src(src)
@@ -615,45 +666,85 @@ class PipelineTab(QWidget):
self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk)
self._color_idx = len(self._derived_blocks)
+ self._refresh_src_cb()
# ══════════════════════════════════════════════════════════════════════════════
# Derived editor for one DerivedChannel
# ══════════════════════════════════════════════════════════════════════════════
-_BUILTIN = ["none", "derivative", "second_derivative", "power", "rms", "difference", "sum"]
+_BUILTIN = ["none", "scale_offset", "derivative", "second_derivative", "power", "rms", "difference", "sum"]
_SCRIPT = ["expression", "function", "custom_script"]
_ALL_KINDS = _BUILTIN + _SCRIPT
_KIND_DISPLAY = {k: k.replace("_", " ").title() for k in _ALL_KINDS}
_KIND_DISPLAY["none"] = "None"
+_KIND_DISPLAY["scale_offset"] = "Scale/Offset (Calibration)"
_KIND_HELP = {
- "none": "Pass source through unchanged",
- "derivative": "Backward difference dy/dt from 1 source",
- "second_derivative": "Second derivative d²y/dt² from 1 source",
- "power": "V × I from 2 sources (voltage, current)",
- "rms": "Rolling RMS from 1 source (set window below)",
- "difference": "source[0] − source[1] from 2 sources",
- "sum": "Σ all source values",
+ "none": "Pass signal through unchanged",
+ "scale_offset": "Linear calibration from 1 signal: result = value × scale + offset",
+ "derivative": "Backward difference dy/dt from 1 signal",
+ "second_derivative": "Second derivative d²y/dt² from 1 signal",
+ "power": "V × I from 2 signals (voltage, current)",
+ "rms": "Rolling RMS from 1 signal (set window below)",
+ "difference": "signal[0] − signal[1] from 2 signals",
+ "sum": "Σ all signal values",
"expression": (
"Single-line Python expression.\n"
"Available: x[0]… or use signal names directly (e.g. A0, encoder_0)\n"
" t = elapsed time (s), math, vars, state\n"
- "Example: A0 * 3.14159 / 180"
+ "Example: A0 * 3.14159 / 180\n"
+ "Tip: vars is shared with Control Panel 'On Action Script' — a button\n"
+ " can set vars['specimen_length'] = value; read it here as\n"
+ " vars.get('specimen_length', 1.0)."
),
"function": (
"Multi-line function body — auto-wrapped as def compute(x, t):\n"
"Available globals: x, signal names, t, math, vars, state\n"
- "Example:\n offset = vars.get('zero', 0)\n return x[0] - offset"
+ "Example:\n offset = vars.get('zero', 0)\n return x[0] - offset\n"
+ "Tip: vars is shared with Control Panel 'On Action Script' — a 'Zero'\n"
+ " button can set vars['zero'] = value while this reads it back."
),
"custom_script": (
"Multi-line Python block. Assign computed value to result.\n"
- "Available: x, signal names, t, math, vars, state"
+ "Available: x, signal names, t, math, vars, state\n"
+ "Tip: vars is shared with Control Panel 'On Action Script' — a button\n"
+ " can set vars['key'] = value; read it here as vars.get('key', default)."
),
}
+def _slug_id(name: str, existing: Set[str]) -> str:
+ """Short-hand channel_id derived from a display name, deduplicated against
+ existing derived channel IDs so a new Channel never silently overwrites
+ (goes stale on top of) an existing one."""
+ words = re.findall(r"[A-Za-z0-9]+", name)
+ if not words:
+ base = "CH"
+ elif len(words) == 1:
+ base = words[0][:4].upper()
+ else:
+ base = "".join(w[0] for w in words[:4]).upper()
+ slug = base
+ i = 2
+ while slug in existing:
+ slug = f"{base}_{i}"
+ i += 1
+ return slug
+
+
+def _dedupe_name(name: str, existing: Set[str]) -> str:
+ """Two channels must never share a display name — append ' (2)', ' (3)'...
+ until unique."""
+ if name not in existing:
+ return name
+ i = 2
+ while f"{name} ({i})" in existing:
+ i += 1
+ return f"{name} ({i})"
+
+
def _sub_xrefs(code: str, names: list) -> str:
"""Replace x[0], x[1], ... with named source vars where index is in range."""
def repl(m):
@@ -712,6 +803,22 @@ class DerivedBlock(QFrame):
meta.addWidget(self._unit); meta.addStretch()
bl.addLayout(meta)
+ # Sources — include derived channels as valid sources
+ src_grp = QGroupBox("Signals"); src_grp.setObjectName("cfgGlobalBar")
+ src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6, 4, 6, 4); src_lay.setSpacing(3)
+ self._src_cont = QWidget(); self._src_vlay = QVBoxLayout(self._src_cont)
+ self._src_vlay.setContentsMargins(0, 0, 0, 0); self._src_vlay.setSpacing(3)
+ _is_deriv = self.dc.kind in ("derivative", "second_derivative")
+ if _is_deriv:
+ self._add_src(self.dc.sources[0] if self.dc.sources else None)
+ else:
+ for _s in self.dc.sources:
+ self._add_src(_s)
+ self._add_src_btn = QPushButton("+ Add Signal"); self._add_src_btn.setObjectName("addTraceBtn")
+ self._add_src_btn.clicked.connect(lambda: self._add_src())
+ src_lay.addWidget(self._src_cont); src_lay.addWidget(self._add_src_btn)
+ bl.addWidget(src_grp)
+
# Kind combo + help
k_row = QHBoxLayout(); k_row.addWidget(QLabel("Operation:"))
self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb")
@@ -736,21 +843,30 @@ class DerivedBlock(QFrame):
self._win_widget = QWidget(); self._win_widget.setLayout(self._win_row)
bl.addWidget(self._win_widget)
- # Sources — include derived channels as valid sources
- src_grp = QGroupBox("Sources"); src_grp.setObjectName("cfgGlobalBar")
- src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6, 4, 6, 4); src_lay.setSpacing(3)
- self._src_cont = QWidget(); self._src_vlay = QVBoxLayout(self._src_cont)
- self._src_vlay.setContentsMargins(0, 0, 0, 0); self._src_vlay.setSpacing(3)
- _is_deriv = self.dc.kind in ("derivative", "second_derivative")
- if _is_deriv:
- self._add_src(self.dc.sources[0] if self.dc.sources else None)
- else:
- for _s in self.dc.sources:
- self._add_src(_s)
- self._add_src_btn = QPushButton("+ Add Source"); self._add_src_btn.setObjectName("addTraceBtn")
- self._add_src_btn.clicked.connect(lambda: self._add_src())
- src_lay.addWidget(self._src_cont); src_lay.addWidget(self._add_src_btn)
- bl.addWidget(src_grp)
+ # Scale/Offset params
+ self._so_row = QHBoxLayout()
+ self._so_row.addWidget(QLabel("Scale:"))
+ self._scale_sp = QDoubleSpinBox(); self._scale_sp.setRange(-1e6, 1e6); self._scale_sp.setDecimals(6)
+ self._scale_sp.setValue(self.dc.params.get("scale", 1.0))
+ self._scale_sp.setObjectName("traceWidthSpin"); self._scale_sp.setFixedWidth(90)
+ self._scale_sp.valueChanged.connect(lambda v: self.dc.params.update({"scale": v}))
+ self._so_row.addWidget(self._scale_sp)
+ self._so_row.addWidget(QLabel("Offset:"))
+ self._offset_sp = QDoubleSpinBox(); self._offset_sp.setRange(-1e9, 1e9); self._offset_sp.setDecimals(6)
+ self._offset_sp.setValue(self.dc.params.get("offset", 0.0))
+ self._offset_sp.setObjectName("traceWidthSpin"); self._offset_sp.setFixedWidth(90)
+ self._offset_sp.valueChanged.connect(lambda v: self.dc.params.update({"offset": v}))
+ self._so_row.addWidget(self._offset_sp); self._so_row.addStretch()
+ self._so_widget = QWidget(); self._so_widget.setLayout(self._so_row)
+ bl.addWidget(self._so_widget)
+
+ # Filters — only for a single-physical-source passthrough channel
+ # (kind="none"); the embedded block is (re)built by _refresh_filters()
+ self._filters_grp = QGroupBox("Filters"); self._filters_grp.setObjectName("cfgGlobalBar")
+ self._filters_lay = QVBoxLayout(self._filters_grp)
+ self._filters_lay.setContentsMargins(6, 4, 6, 4)
+ self._filters_block: Optional[ChannelPipelineBlock] = None
+ bl.addWidget(self._filters_grp)
# W.r.t. — only for derivative/second_derivative
self._wrt_grp = QGroupBox("With Respect To"); self._wrt_grp.setObjectName("cfgGlobalBar")
@@ -876,8 +992,10 @@ class DerivedBlock(QFrame):
if wrt_src and wrt_src not in sources:
sources.append(wrt_src)
+ self._refresh_filters()
+
if not sources:
- self._vars_lbl.setText("No sources — add sources above to use named variables")
+ self._vars_lbl.setText("No signals — add signals above to use named variables")
return
display_names = [_channel_display_name(self.registry, self.processor, d, c)
@@ -903,6 +1021,36 @@ class DerivedBlock(QFrame):
self._code.setTextCursor(c)
self._code.blockSignals(False)
+ def _refresh_filters(self):
+ """(Re)build the embedded single-source Filters block when eligible:
+ kind is a plain passthrough ("none") with exactly one physical source."""
+ if not hasattr(self, "_filters_grp"):
+ return # called during initial Sources construction, too early
+ sources = [cb.currentData() for cb in self._src_combos if cb.currentData()]
+ eligible = (self.dc.kind == "none" and len(sources) == 1
+ and sources[0][0] != "derived")
+ if not eligible:
+ self._filters_grp.setVisible(False)
+ if self._filters_block is not None:
+ self._filters_lay.removeWidget(self._filters_block)
+ self._filters_block.deleteLater()
+ self._filters_block = None
+ return
+ dev_id, ch_id = sources[0]
+ if (self._filters_block is not None
+ and (self._filters_block.dev_id, self._filters_block.ch_id) != (dev_id, ch_id)):
+ self._filters_lay.removeWidget(self._filters_block)
+ self._filters_block.deleteLater()
+ self._filters_block = None
+ if self._filters_block is None:
+ ch_name = _channel_display_name(self.registry, self.processor, dev_id, ch_id)
+ self._filters_block = ChannelPipelineBlock(
+ dev_id, ch_id, ch_name, "", self.processor,
+ show_calibration=False, show_header=False,
+ )
+ self._filters_lay.addWidget(self._filters_block)
+ self._filters_grp.setVisible(True)
+
def _on_kind(self, idx: int):
from ui.code_templates import BUILTIN_TEMPLATES, SCRIPT_TEMPLATES
k = _ALL_KINDS[idx]
@@ -958,12 +1106,33 @@ class DerivedBlock(QFrame):
self._code.setReadOnly(is_builtin)
self._edit_as_script_btn.setVisible(is_builtin)
self._win_widget.setVisible(self.dc.kind == "rms")
+ self._so_widget.setVisible(self.dc.kind == "scale_offset")
+ self._refresh_filters()
self._wrt_grp.setVisible(is_deriv)
self._add_src_btn.setVisible(not is_deriv)
if is_deriv:
self._wrt_x_row.setVisible(self._wrt_cb.currentIndex() == 1)
def _apply(self):
+ name = self.dc.name.strip()
+ channel_id = self.dc.channel_id.strip()
+ if not name:
+ self._st.setText("⚠ Name cannot be empty")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
+ if not channel_id:
+ self._st.setText("⚠ ID cannot be empty")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
+ others = [d for d in self.processor.get_derived() if d is not self.dc]
+ if any(d.name == name for d in others):
+ self._st.setText(f"⚠ Name '{name}' is already used by another channel")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
+ if any(d.channel_id == channel_id for d in others):
+ self._st.setText(f"⚠ ID '{channel_id}' is already used by another channel")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
sources = [cb.currentData() for cb in self._src_combos if cb.currentData()]
if self.dc.kind in ("derivative", "second_derivative"):
wrt = "channel" if self._wrt_cb.currentIndex() == 1 else "time"
diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py
index 05cc1fb..64c669d 100644
--- a/ui/windows/config_window.py
+++ b/ui/windows/config_window.py
@@ -5,8 +5,8 @@ Unified Configuration window — Devices | Channels | Plot in one tabbed window.
Channels tab layout (2 columns):
Left — signal table (Device / Signal ID / Dir / On / Name / Unit / Min / Max)
- ⚙ button per row opens that channel's filter/calibration pipeline dialog
- Right — derived / virtual channels
+ Right — derived / virtual channels — operations (filters/calibration/math/script)
+ are configured per Channel here, not on the Signals side.
"""
from __future__ import annotations
@@ -17,17 +17,17 @@ from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QToolButton,
QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem,
QHeaderView, QCheckBox, QMessageBox, QAbstractItemView, QSplitter,
- QDoubleSpinBox, QDialog, QDialogButtonBox,
+ QDoubleSpinBox,
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
from PyQt6.QtGui import QCloseEvent, QColor
from devices.device_registry import DeviceRegistry
from core.acquisition import AcquisitionEngine
-from core.signal_processor import SignalProcessor
+from core.signal_processor import SignalProcessor, DerivedChannel
from ui.windows.devices_window import DeviceRow, ChannelsTab
-from ui.windows.channels_window import PipelineTab, ChannelPipelineBlock
+from ui.windows.channels_window import PipelineTab, _slug_id, _dedupe_name
from ui.windows.plot_window import (
PlotWindow, LayoutConfig, LayoutCanvas, PaneBlock,
build_default_layout,
@@ -35,37 +35,12 @@ from ui.windows.plot_window import (
# ══════════════════════════════════════════════════════════════════════════════
-# Pipeline dialog — per-channel filter/calibration popup
-# ══════════════════════════════════════════════════════════════════════════════
-
-class _PipelineDialog(QDialog):
- """Shows one ChannelPipelineBlock inside a dialog for a single signal."""
-
- def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str,
- processor: SignalProcessor, parent=None):
- super().__init__(parent)
- self.setWindowTitle(f"Pipeline — {dev_id} / {ch_id}")
- self.setMinimumWidth(460)
- self.resize(500, 420)
-
- lay = QVBoxLayout(self)
- lay.setContentsMargins(0, 0, 0, 12)
-
- self._block = ChannelPipelineBlock(dev_id, ch_id, ch_name, unit, processor)
- self._block._toggle() # start expanded
- lay.addWidget(self._block, 1)
-
- btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
- btns.rejected.connect(self.accept)
- lay.addWidget(btns)
-
-
-# ══════════════════════════════════════════════════════════════════════════════
# Enhanced signals table — adds Dir badge and pipeline ⚙ button
# ══════════════════════════════════════════════════════════════════════════════
class _SignalsTab(ChannelsTab):
- """ChannelsTab with Dir (IN/OUT) badge and per-row ⚙ pipeline button."""
+ """ChannelsTab with a Dir (READ/WRITE) badge. Operations (filters/calibration/math)
+ are configured per Channel in the Channels pane, not here."""
_C_DEVICE = 0
_C_CH_ID = 1
@@ -75,7 +50,9 @@ class _SignalsTab(ChannelsTab):
_C_UNIT = 5
_C_MIN = 6
_C_MAX = 7
- _C_PIPE = 8 # ⚙ pipeline button
+ _C_MKCH = 8 # "+ Channel" button
+
+ channel_created = pyqtSignal()
def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
self._processor = processor
@@ -97,7 +74,7 @@ class _SignalsTab(ChannelsTab):
hdr.setSectionResizeMode(self._C_CH_ID, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(self._C_DIR, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(self._C_ENABLED, QHeaderView.ResizeMode.ResizeToContents)
- hdr.setSectionResizeMode(self._C_PIPE, QHeaderView.ResizeMode.ResizeToContents)
+ hdr.setSectionResizeMode(self._C_MKCH, QHeaderView.ResizeMode.ResizeToContents)
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setAlternatingRowColors(True)
self._table.verticalHeader().setVisible(False)
@@ -122,8 +99,8 @@ class _SignalsTab(ChannelsTab):
ch_item.setForeground(QColor(ch.color))
self._table.setItem(row, self._C_CH_ID, ch_item)
- is_out = getattr(ch, "is_output", False)
- dir_item = QTableWidgetItem("OUT" if is_out else "IN")
+ is_out = getattr(ch, "writable", False)
+ dir_item = QTableWidgetItem("WRITE" if is_out else "READ")
dir_item.setFlags(dir_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
dir_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
dir_item.setForeground(QColor("#f59e0b") if is_out else QColor("#22c55e"))
@@ -156,33 +133,47 @@ class _SignalsTab(ChannelsTab):
self._table.setItem(row, self._C_MIN, QTableWidgetItem(str(ch.min_value)))
self._table.setItem(row, self._C_MAX, QTableWidgetItem(str(ch.max_value)))
- # Pipeline ⚙ button
- pipe_w = QWidget()
- pipe_l = QHBoxLayout(pipe_w)
- pipe_l.setContentsMargins(2, 0, 2, 0)
- pipe_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
- pipe_btn = QToolButton()
- pipe_btn.setText("⚙")
- pipe_btn.setObjectName("traceRemoveBtn")
- pipe_btn.setFixedSize(22, 22)
- pipe_btn.setToolTip("Configure filters / calibration")
- d_cap, c_cap, n_cap, u_cap = (
- dev.info.device_id, ch.channel_id, ch.name, ch.unit
+ mkch_w = QWidget()
+ mkch_l = QHBoxLayout(mkch_w)
+ mkch_l.setContentsMargins(2, 0, 2, 0)
+ mkch_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ mkch_btn = QToolButton()
+ mkch_btn.setText("⊕")
+ mkch_btn.setObjectName("addTraceBtn")
+ 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
)
- pipe_btn.clicked.connect(
- lambda _=False, d=d_cap, c=c_cap, n=n_cap, u=u_cap:
- self._open_pipeline(d, c, n, u)
+ mkch_btn.clicked.connect(
+ lambda _=False, d=d_cap, c=c_cap, n=n_cap, u=u_cap, col=col_cap:
+ self._on_create_channel(d, c, n, u, col)
)
- pipe_l.addWidget(pipe_btn)
- self._table.setCellWidget(row, self._C_PIPE, pipe_w)
+ mkch_l.addWidget(mkch_btn)
+ self._table.setCellWidget(row, self._C_MKCH, mkch_w)
row += 1
self._table.blockSignals(False)
- def _open_pipeline(self, dev_id: str, ch_id: str, ch_name: str, unit: str):
- dlg = _PipelineDialog(dev_id, ch_id, ch_name, unit, self._processor, self)
- dlg.exec()
+ 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
+ a passthrough Channel for this exact Signal already exists."""
+ existing = self._processor.get_derived()
+ for dc in existing:
+ if dc.kind == "none" and dc.sources == [(dev_id, ch_id)]:
+ return
+ existing_names = {d.name for d in existing}
+ 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",
+ sources=[(dev_id, ch_id)], source_names=[ch_name],
+ unit=unit, color=color or "#00d4ff")
+ err = self._processor.add_derived(dc)
+ if err is None:
+ self.channel_created.emit()
# ══════════════════════════════════════════════════════════════════════════════
@@ -353,6 +344,7 @@ class ConfigWindow(QWidget):
row = DeviceRow(dev)
row.configure_clicked.connect(self._on_configure_device)
row.remove_clicked.connect(self._on_remove_device)
+ row.name_changed.connect(self._on_device_renamed)
self._dev_inner.insertWidget(self._dev_inner.count() - 1, row)
self._dev_rows[dev.info.device_id] = row
@@ -371,14 +363,20 @@ class ConfigWindow(QWidget):
self.engine.add_device(dev)
self.refresh_devices()
self._signals_tab.refresh()
+ self._pipeline_tab.on_device_added()
self.device_added.emit()
+ def _on_device_renamed(self, device_id: str):
+ self._signals_tab.refresh()
+ self.device_reconfigured.emit(device_id)
+
def _on_configure_device(self, device_id: str):
from ui.config_dialog import DeviceConfigDialog
dev = self.registry.get_instance(device_id)
if dev:
DeviceConfigDialog(dev, self).exec()
self._signals_tab.refresh()
+ self._pipeline_tab.on_device_reconfigured(device_id)
self.device_reconfigured.emit(device_id)
def _on_remove_device(self, device_id: str):
@@ -412,7 +410,7 @@ class ConfigWindow(QWidget):
sig_l = QVBoxLayout(sig_w); sig_l.setContentsMargins(0, 0, 0, 0); sig_l.setSpacing(0)
sig_bar = QWidget(); sig_bar.setObjectName("cfgGlobalBar")
- sb_lay = QHBoxLayout(sig_bar); sb_lay.setContentsMargins(10, 7, 10, 7)
+ sb_lay = QHBoxLayout(sig_bar); sb_lay.setContentsMargins(10, 7, 10, 7); sb_lay.setSpacing(6)
sb_lbl = QLabel("Signals"); sb_lbl.setObjectName("devWindowTitle")
sb_lay.addWidget(sb_lbl, 1)
sig_l.addWidget(sig_bar)
@@ -435,6 +433,7 @@ class ConfigWindow(QWidget):
if inner_splitters:
inner_splitters[0].widget(0).setVisible(False)
splitter.addWidget(self._pipeline_tab)
+ self._signals_tab.channel_created.connect(self._pipeline_tab.refresh_derived)
splitter.setStretchFactor(0, 2)
splitter.setStretchFactor(1, 1)
diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py
index eec65c8..ae88c6b 100644
--- a/ui/windows/devices_window.py
+++ b/ui/windows/devices_window.py
@@ -41,6 +41,7 @@ _STATUS_TEXT = {
class DeviceRow(QFrame):
configure_clicked = pyqtSignal(str)
remove_clicked = pyqtSignal(str)
+ name_changed = pyqtSignal(str)
def __init__(self, device):
super().__init__()
@@ -66,11 +67,14 @@ class DeviceRow(QFrame):
info_col = QVBoxLayout()
info_col.setSpacing(1)
- name = QLabel(self.device.info.name)
- name.setObjectName("deviceName")
+ self._name_edit = QLineEdit(self.device.info.name)
+ self._name_edit.setObjectName("deviceName")
+ self._name_edit.setFrame(False)
+ self._name_edit.setToolTip("Click to rename")
+ self._name_edit.editingFinished.connect(self._on_name_edited)
id_lbl = QLabel(f"{self.device.info.device_id} · {self.device.info.device_type}")
id_lbl.setObjectName("deviceSub")
- info_col.addWidget(name)
+ info_col.addWidget(self._name_edit)
info_col.addWidget(id_lbl)
lay.addLayout(info_col, 1)
@@ -103,6 +107,16 @@ class DeviceRow(QFrame):
self._status_lbl.setText(_STATUS_TEXT.get(self.device.status, "?"))
self._set_status_style()
+ def _on_name_edited(self):
+ new_name = self._name_edit.text().strip()
+ if not new_name:
+ self._name_edit.setText(self.device.info.name)
+ return
+ if new_name == self.device.info.name:
+ return
+ self.device.info.name = new_name
+ self.name_changed.emit(self.device.info.device_id)
+
def _set_dot_color(self):
c = _DOT_COLOR.get(self.device.status, "#475569")
self._dot.setStyleSheet(f"color:{c}; font-size:10px;")
diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py
index f311e9e..119efab 100644
--- a/ui/windows/plot_window.py
+++ b/ui/windows/plot_window.py
@@ -26,7 +26,7 @@ from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, QSpinBox,
QCheckBox, QComboBox, QColorDialog, QToolButton,
- QFileDialog, QMessageBox,
+ QFileDialog, QMessageBox, QDialog, QDialogButtonBox,
)
from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QRect
from PyQt6.QtGui import QColor, QCloseEvent, QPainter, QPen
@@ -89,18 +89,12 @@ class LayoutConfig:
def build_default_layout(registry: DeviceRegistry,
processor: SignalProcessor) -> LayoutConfig:
+ """One pane per Channel (processor.get_derived()), titled by channel
+ name — raw Signals (physical device channels) are not auto-plotted.
+ Signals are just hardware endpoints; a Channel is what the operator has
+ explicitly chosen to process/observe, so that's what the default plot
+ shows. Use the "+ Add Channel" button on a Signal row to promote it."""
cfg = LayoutConfig()
- for dev in registry.all_instances():
- enabled = [ch for ch in dev.info.channels if ch.enabled]
- if not enabled: continue
- pane = PaneSpec(title=dev.info.name,
- y_label=enabled[0].unit if enabled else "")
- for ch in enabled:
- pane.traces.append(TraceSpec(
- device_id=dev.info.device_id, channel_id=ch.channel_id,
- label=ch.name, color=ch.color,
- ))
- if pane.traces: cfg.panes.append(pane)
for dc in processor.get_derived():
pane = PaneSpec(title=dc.name, y_label=dc.unit)
pane.traces.append(TraceSpec(
@@ -281,8 +275,9 @@ class ColorSwatch(QPushButton):
# ══════════════════════════════════════════════════════════════════════════════
class TraceRow(QFrame):
- removed = pyqtSignal(object)
- changed = pyqtSignal()
+ removed = pyqtSignal(object)
+ changed = pyqtSignal()
+ move_requested = pyqtSignal(object, int) # (self, direction: -1 up / +1 down)
def __init__(self, tr: TraceSpec):
super().__init__(); self.tr = tr; self.setObjectName("traceRow"); self._build()
def _build(self):
@@ -309,12 +304,77 @@ class TraceRow(QFrame):
self._w.setValue(self.tr.width); self._w.setFixedWidth(56)
self._w.valueChanged.connect(lambda v: setattr(self.tr,"width",v))
lay.addWidget(self._w)
+ up = QToolButton(); up.setText("▲"); up.setObjectName("traceRemoveBtn")
+ up.setFixedSize(18,18); up.setToolTip("Move up")
+ up.clicked.connect(lambda: self.move_requested.emit(self, -1))
+ lay.addWidget(up)
+ dn = QToolButton(); dn.setText("▼"); dn.setObjectName("traceRemoveBtn")
+ dn.setFixedSize(18,18); dn.setToolTip("Move down")
+ dn.clicked.connect(lambda: self.move_requested.emit(self, 1))
+ lay.addWidget(dn)
rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn")
rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self))
lay.addWidget(rm)
# ══════════════════════════════════════════════════════════════════════════════
+# Trace channel picker — checkbox dialog, replaces the old inline dropdown
+# ══════════════════════════════════════════════════════════════════════════════
+
+class TraceChannelPickerDialog(QDialog):
+ """Checkbox list of enabled device + derived channels — same pattern as
+ channels_window.ChannelPickerDialog, so adding traces to a pane is
+ consistent with how channels are added elsewhere in the app."""
+
+ def __init__(self, registry: DeviceRegistry, processor: SignalProcessor, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Add Channels to Pane")
+ self.setMinimumWidth(380)
+ self.resize(420, 320)
+ self._checks: List[Tuple] = [] # (QCheckBox, dev_id, ch_id, name, color)
+
+ lay = QVBoxLayout(self)
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
+ scroll.setObjectName("deviceScroll")
+ cont = QWidget()
+ cl = QVBoxLayout(cont); cl.setContentsMargins(8, 8, 8, 8); cl.setSpacing(4)
+
+ any_available = False
+ for dev in registry.all_instances():
+ for ch in dev.info.channels:
+ 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))
+ 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})")
+ self._checks.append((chk, "derived", dc.channel_id, dc.name, dc.color))
+ cl.addWidget(chk)
+
+ if not any_available:
+ cl.addWidget(QLabel("No channels available."))
+
+ cl.addStretch()
+ scroll.setWidget(cont)
+ lay.addWidget(scroll, 1)
+
+ btns = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
+ )
+ btns.accepted.connect(self.accept)
+ btns.rejected.connect(self.reject)
+ lay.addWidget(btns)
+
+ def selected(self) -> List[Tuple[str, str, str, str]]:
+ return [(d, c, n, col) for chk, d, c, n, col in self._checks if chk.isChecked()]
+
+
+# ══════════════════════════════════════════════════════════════════════════════
# Pane block
# ══════════════════════════════════════════════════════════════════════════════
@@ -411,30 +471,15 @@ class PaneBlock(QFrame):
for tr in self.spec.traces: self._add_row(tr)
pick_row = QHBoxLayout()
- self._picker = self._make_picker(); pick_row.addWidget(self._picker,1)
ab = QPushButton("+ Add Channel"); ab.setObjectName("addTraceBtn")
- ab.clicked.connect(self._on_add); pick_row.addWidget(ab)
+ ab.clicked.connect(self._on_add); pick_row.addWidget(ab); pick_row.addStretch()
self._tlay.addLayout(pick_row)
outer.addWidget(tr_w)
- def _make_picker(self):
- cb = QComboBox(); cb.setObjectName("channelPickerCb")
- 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} ([virtual]/{dc.channel_id})",
- userData=("derived", dc.channel_id, dc.name, dc.color))
- return cb
-
def _add_row(self, tr: TraceSpec):
row = TraceRow(tr); row.removed.connect(self._rm_row)
row.changed.connect(self.changed)
+ row.move_requested.connect(self._move_row)
self._rows.append(row)
self._tlay.insertWidget(self._tlay.count()-1, row)
@@ -443,12 +488,27 @@ class PaneBlock(QFrame):
self._rows.remove(row); self._tlay.removeWidget(row); row.deleteLater()
self.changed.emit()
+ def _move_row(self, row: TraceRow, direction: int):
+ if row not in self._rows:
+ return
+ idx = self._rows.index(row)
+ new_idx = idx + direction
+ if new_idx < 0 or new_idx >= len(self._rows):
+ return
+ self._rows[idx], self._rows[new_idx] = self._rows[new_idx], self._rows[idx]
+ self.spec.traces[idx], self.spec.traces[new_idx] = self.spec.traces[new_idx], self.spec.traces[idx]
+ self._tlay.removeWidget(row)
+ self._tlay.insertWidget(new_idx, row)
+ self.changed.emit()
+
def _on_add(self):
- data = self._picker.currentData()
- if not data: return
- d,c,n,col = data
- tr = TraceSpec(device_id=d,channel_id=c,label=n,color=col)
- self.spec.traces.append(tr); self._add_row(tr); self.changed.emit()
+ dlg = TraceChannelPickerDialog(self.registry, self.processor, self)
+ if dlg.exec() != QDialog.DialogCode.Accepted:
+ return
+ for d, c, n, col in dlg.selected():
+ tr = TraceSpec(device_id=d, channel_id=c, label=n, color=col)
+ self.spec.traces.append(tr); self._add_row(tr)
+ self.changed.emit()
def _on_auto(self, v):
self.spec.y_auto = v; self._ymin.setEnabled(not v); self._ymax.setEnabled(not v)