summaryrefslogtreecommitdiff
path: root/core/profile.py
diff options
context:
space:
mode:
Diffstat (limited to 'core/profile.py')
-rw-r--r--core/profile.py166
1 files changed, 95 insertions, 71 deletions
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", ""),