diff options
Diffstat (limited to 'core/profile.py')
| -rw-r--r-- | core/profile.py | 166 |
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", ""), |
