Initial public release
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user