"""Project serialization - save/load the complete project state.""" import os import json from .macro import Macro, NodeData from .settings import Settings from utils.constants import APPDATA_DIR, PROJECT_FILE class Project: """The complete project state: all macros and settings.""" def __init__(self): self.settings = Settings() self.macros: list[Macro] = [] # Schema: # {"universal": {name: value, ...}, # "devices": {"AA:BB:CC:DD:EE:FF": {name: value, ...}, ...}} self.ble_variables: dict = {"universal": {}, "devices": {}} # Per-variable comments — purely a host-side annotation that # mirrors the ble_variables schema by name. Kept in a separate # field so the wire format (BLE pull/push payloads, device-side # storage) stays untouched: comments never leave the desktop app. # Names that appear in ble_variables but not here have no comment. self.ble_comments: dict = {"universal": {}, "devices": {}} @staticmethod def _normalize_ble_variables(raw) -> dict: """Coerce any saved shape into the {universal, devices} schema. Old projects stored a flat {name: value} dict. Wrap it as universal. """ if not isinstance(raw, dict): return {"universal": {}, "devices": {}} if "universal" in raw or "devices" in raw: return { "universal": dict(raw.get("universal") or {}), "devices": { str(mac): dict(vars_ or {}) for mac, vars_ in (raw.get("devices") or {}).items() }, } # Legacy flat dict — treat as universal return {"universal": dict(raw), "devices": {}} @staticmethod def _normalize_ble_comments(raw) -> dict: """Coerce any saved shape into the {universal, devices} schema. Pre-comments projects simply omit this field — we just produce an empty structure. Comments are scoped exactly like variables so a per-device variable's comment lives next to its sibling. """ if not isinstance(raw, dict): return {"universal": {}, "devices": {}} if "universal" in raw or "devices" in raw: return { "universal": {str(k): str(v) for k, v in (raw.get("universal") or {}).items()}, "devices": { str(mac): {str(k): str(v) for k, v in (cs or {}).items()} for mac, cs in (raw.get("devices") or {}).items() }, } # Legacy flat dict (unlikely for comments but mirrored for safety) return {"universal": {str(k): str(v) for k, v in raw.items()}, "devices": {}} def get_universal(self) -> dict: return self.ble_variables.setdefault("universal", {}) def get_device(self, mac: str) -> dict: devices = self.ble_variables.setdefault("devices", {}) return devices.setdefault(mac, {}) def set_device(self, mac: str, vars_: dict): devices = self.ble_variables.setdefault("devices", {}) devices[mac] = dict(vars_ or {}) def get_universal_comments(self) -> dict: return self.ble_comments.setdefault("universal", {}) def get_device_comments(self, mac: str) -> dict: devices = self.ble_comments.setdefault("devices", {}) return devices.setdefault(mac, {}) def set_device_comments(self, mac: str, comments: dict): devices = self.ble_comments.setdefault("devices", {}) devices[mac] = {str(k): str(v) for k, v in (comments or {}).items()} def known_devices(self) -> list: devices = self.ble_variables.get("devices") or {} return sorted(devices.keys()) def add_macro(self, macro: Macro = None) -> Macro: if macro is None: macro = Macro(name=f"Routine {len(self.macros) + 1}") start_node = NodeData("start", x=100, y=100) macro.add_node(start_node) self.macros.append(macro) return macro def remove_macro(self, index: int): if 0 <= index < len(self.macros): self.macros.pop(index) def move_macro(self, from_idx: int, to_idx: int): if 0 <= from_idx < len(self.macros) and 0 <= to_idx < len(self.macros): macro = self.macros.pop(from_idx) self.macros.insert(to_idx, macro) def to_dict(self) -> dict: """Full in-memory dump including BLE variables/comments. Kept inclusive so deep-copy round-trips (``Project.from_dict(p.to_dict())``) preserve everything. The on-disk split — main JSON vs. ``.vars.json`` sidecar — lives in ``save`` / ``load`` below, not here. """ return { "version": 1, "settings": self.settings.to_dict(), "ble_variables": self.ble_variables, "ble_comments": self.ble_comments, "macros": [m.to_dict() for m in self.macros], } def _to_main_dict(self) -> dict: """The portion that goes into the git-trackable main JSON. BLE variables and comments are deliberately EXCLUDED — they often hold per-machine credentials (passwords, hostnames, asset tags) and live in the sidecar file instead so they can be gitignored without losing the rest of the profile. """ return { "version": 1, "settings": self.settings.to_dict(), "macros": [m.to_dict() for m in self.macros], } def _to_vars_dict(self) -> dict: """The portion that goes into the per-machine sidecar.""" return { "ble_variables": self.ble_variables, "ble_comments": self.ble_comments, } @classmethod def from_dict(cls, d: dict) -> "Project": p = cls() p.settings = Settings.from_dict(d.get("settings", {})) # Legacy fallback: pre-sidecar projects stored BLE state inline # in the same file. Accept it here so a single load() can handle # both old and migrated layouts. p.ble_variables = cls._normalize_ble_variables(d.get("ble_variables", {})) p.ble_comments = cls._normalize_ble_comments(d.get("ble_comments", {})) p.macros = [Macro.from_dict(md) for md in d.get("macros", [])] return p @staticmethod def _vars_path_for(target: str) -> str: """Return the sidecar path for a given profile JSON path. Foo.json -> Foo.vars.json Anything else (rare — direct calls with non-.json names) gets a ``.vars.json`` suffix appended verbatim. """ if target.endswith(".json"): return target[:-len(".json")] + ".vars.json" return target + ".vars.json" def save(self, path=None): target = path or PROJECT_FILE os.makedirs(os.path.dirname(target) or ".", exist_ok=True) # Main profile JSON (synced via git for the shipped profiles). with open(target, "w", encoding="utf-8") as f: json.dump(self._to_main_dict(), f, indent=2) # Per-machine BLE sidecar (always written; gitignored). Skip # writing when there's literally nothing to persist so we don't # leave empty sidecars cluttering the profiles directory. vars_dict = self._to_vars_dict() has_vars = bool( (vars_dict["ble_variables"].get("universal") or {}) or (vars_dict["ble_variables"].get("devices") or {}) or (vars_dict["ble_comments"].get("universal") or {}) or (vars_dict["ble_comments"].get("devices") or {}) ) vars_path = self._vars_path_for(target) if has_vars: with open(vars_path, "w", encoding="utf-8") as f: json.dump(vars_dict, f, indent=2) elif os.path.exists(vars_path): # User cleared all variables — remove a now-empty sidecar so # the working tree stays tidy. try: os.remove(vars_path) except OSError: pass @classmethod def load(cls, path=None) -> "Project": target = path or PROJECT_FILE if not os.path.exists(target): return cls() try: with open(target, "r", encoding="utf-8") as f: main = json.load(f) except (json.JSONDecodeError, KeyError, TypeError): return cls() # Merge in the sidecar's BLE data if present. The sidecar always # wins over any inline copy in the main JSON — important during # the transition window where a profile may still have leftover # legacy fields from before the split. vars_path = cls._vars_path_for(target) if os.path.exists(vars_path): try: with open(vars_path, "r", encoding="utf-8") as f: sidecar = json.load(f) if isinstance(sidecar, dict): if "ble_variables" in sidecar: main["ble_variables"] = sidecar["ble_variables"] if "ble_comments" in sidecar: main["ble_comments"] = sidecar["ble_comments"] except (json.JSONDecodeError, OSError): pass try: return cls.from_dict(main) except (KeyError, TypeError): return cls()