#!/usr/bin/env python3 """Config storage for the ButtonTask web configurator. Reads/writes the same JSON config file consumed by the Qt5 application. Defaults here must stay in sync with ConfigManager::ensureDefaults() in C++. """ import copy import json import os import tempfile from pathlib import Path ALLOWED_ICON_EXT = {".png", ".jpg", ".jpeg", ".bmp", ".svg", ".gif"} def config_path(): """Resolve the active config path. Priority: 1. BUTTONTASK_CONFIG environment variable. 2. /config/config.json next to this package (dev fallback). """ env = os.environ.get("BUTTONTASK_CONFIG") if env: return Path(env) # webconfig/ -> repo root -> config/config.json return (Path(__file__).resolve().parent.parent / "config" / "config.json") def _defaults(): return { "version": 2, "layout": {"mode": "grid", "columns": 2, "spacing": 10, "showLabels": True, "labelColor": "#ffffff"}, "background": { "type": "gradient", "color1": "#00007b", "color2": "#7b007b", "animated": True, "imagePath": "", }, "feedback": { "okColor": "#00cc44", "errorColor": "#cc2200", "okWidth": 6, "errorWidth": 6, "glowRadius": 20, "holdColor": "#ffffff", "pendingColor": "#ffffff", "pendingWidth": 6, }, "settings": { "darkMode": True, "password": "admin_26", "kioskMode": False, "iconsDir": "./icons", "webPort": 8080, "brightness": 60, }, "buttons": [], } def ensure_defaults(cfg): """Fill in any missing sections/keys without overwriting existing values.""" base = _defaults() if not isinstance(cfg, dict): return base cfg.setdefault("version", base["version"]) for section in ("layout", "background", "feedback", "settings"): sec = cfg.setdefault(section, {}) if not isinstance(sec, dict): sec = {} cfg[section] = sec for k, v in base[section].items(): sec.setdefault(k, v) if not isinstance(cfg.get("buttons"), list): cfg["buttons"] = [] cfg.setdefault("settings", {})["darkMode"] = True return cfg def load_config(): p = config_path() if not p.exists(): cfg = ensure_defaults(_defaults()) # point iconsDir at a sensible absolute default for the web side cfg["settings"]["iconsDir"] = str((p.parent.parent / "icons")) return cfg with p.open("r", encoding="utf-8") as f: raw = json.load(f) migrated = ensure_defaults(raw if isinstance(raw, dict) else {}) if migrated != raw: save_config(migrated) return migrated def save_config(cfg): p = config_path() p.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(prefix=".cfg.", suffix=".tmp", dir=str(p.parent)) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=4, ensure_ascii=False) os.replace(tmp, p) except Exception: if os.path.exists(tmp): os.unlink(tmp) raise def icons_dir(): cfg = load_config() d = cfg.get("settings", {}).get("iconsDir") or "./icons" p = Path(d) if not p.is_absolute(): p = (config_path().parent.parent / d).resolve() p.mkdir(parents=True, exist_ok=True) return p def list_icons(): return sorted( f.name for f in icons_dir().iterdir() if f.is_file() and f.suffix.lower() in ALLOWED_ICON_EXT ) def default_config(): return copy.deepcopy(_defaults())