2026-09-11 11:05:01 +02:00
|
|
|
|
"""Persistente Konfiguration: config.json, Defaults, Validierung."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import copy
|
|
|
|
|
|
import json
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULTS: dict = {
|
|
|
|
|
|
"web": {"bind": "0.0.0.0", "port": 8080},
|
|
|
|
|
|
"preview": {"width": 640, "height": 360, "fps": 15,
|
|
|
|
|
|
"jpeg_quality": 80},
|
|
|
|
|
|
"output": {"fullscreen": False},
|
|
|
|
|
|
"media": {"dir": "media", "thumbs_dir": "thumbs",
|
|
|
|
|
|
"max_upload_mb": 2048},
|
|
|
|
|
|
"artnet": {
|
|
|
|
|
|
"enabled": True,
|
|
|
|
|
|
"listen_port": 6454,
|
|
|
|
|
|
"universes": [0],
|
|
|
|
|
|
"master_channel": 1,
|
|
|
|
|
|
"layer_start_channel": 2,
|
|
|
|
|
|
"playback_channel": 6,
|
|
|
|
|
|
"retrigger_channel": 7,
|
|
|
|
|
|
"blackout_channel": 8,
|
2026-09-11 12:01:22 +02:00
|
|
|
|
"fx1_channel": None,
|
|
|
|
|
|
"fx2_channel": None,
|
2026-09-11 13:34:43 +02:00
|
|
|
|
"cue_go_channel": None,
|
2026-09-11 11:05:01 +02:00
|
|
|
|
"signal_loss_policy": "hold",
|
|
|
|
|
|
"signal_loss_timeout_s": 10,
|
|
|
|
|
|
},
|
2026-09-11 12:55:50 +02:00
|
|
|
|
"engine": {"max_layers": 8, "loop_default": True,
|
|
|
|
|
|
"autostart_project": None},
|
2026-09-11 13:11:54 +02:00
|
|
|
|
"servers": [],
|
2026-09-11 11:05:01 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def deep_merge(base: dict, override: dict) -> dict:
|
|
|
|
|
|
out = dict(base)
|
|
|
|
|
|
for k, v in override.items():
|
|
|
|
|
|
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
|
|
|
|
out[k] = deep_merge(out[k], v)
|
|
|
|
|
|
else:
|
|
|
|
|
|
out[k] = v
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load(root: Path) -> dict:
|
|
|
|
|
|
"""Lädt config.json und füllt fehlende Schlüssel mit Defaults."""
|
|
|
|
|
|
file = root / "config.json"
|
|
|
|
|
|
data: dict = {}
|
|
|
|
|
|
if file.exists():
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(file.read_text("utf-8"))
|
|
|
|
|
|
except (OSError, ValueError) as e:
|
|
|
|
|
|
print(f"[Config] FEHLER config.json: {e} – nutze Defaults")
|
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
|
data = {}
|
|
|
|
|
|
return deep_merge(DEFAULTS, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save(root: Path, cfg: dict) -> bool:
|
|
|
|
|
|
file = root / "config.json"
|
|
|
|
|
|
try:
|
|
|
|
|
|
tmp = file.with_suffix(".json.tmp")
|
|
|
|
|
|
tmp.write_text(
|
|
|
|
|
|
json.dumps(cfg, ensure_ascii=False, indent=2), "utf-8")
|
|
|
|
|
|
tmp.replace(file)
|
|
|
|
|
|
return True
|
|
|
|
|
|
except OSError as e:
|
|
|
|
|
|
print(f"[Config] Schreiben fehlgeschlagen: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate(new: dict) -> tuple[dict | None, str | None]:
|
|
|
|
|
|
"""Prüft ein komplettes Settings-Objekt. Rückgabe: (normalized, error)."""
|
|
|
|
|
|
merged = deep_merge(copy.deepcopy(DEFAULTS), new if isinstance(new, dict) else {})
|
|
|
|
|
|
errors: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
def _int(d, key, lo, hi, label):
|
|
|
|
|
|
try:
|
|
|
|
|
|
v = int(d[key])
|
|
|
|
|
|
except (KeyError, TypeError, ValueError):
|
|
|
|
|
|
errors.append(label)
|
|
|
|
|
|
return None
|
|
|
|
|
|
if not lo <= v <= hi:
|
|
|
|
|
|
errors.append(label)
|
|
|
|
|
|
return None
|
|
|
|
|
|
d[key] = v
|
|
|
|
|
|
return v
|
|
|
|
|
|
|
|
|
|
|
|
w = merged["web"]
|
|
|
|
|
|
_int(w, "port", 1, 65535, "web.port")
|
|
|
|
|
|
if not isinstance(w.get("bind"), str):
|
|
|
|
|
|
errors.append("web.bind")
|
|
|
|
|
|
|
|
|
|
|
|
p = merged["preview"]
|
|
|
|
|
|
_int(p, "width", 64, 3840, "preview.width")
|
|
|
|
|
|
_int(p, "height", 64, 2160, "preview.height")
|
|
|
|
|
|
_int(p, "fps", 1, 60, "preview.fps")
|
|
|
|
|
|
_int(p, "jpeg_quality", 10, 95, "preview.jpeg_quality")
|
|
|
|
|
|
|
|
|
|
|
|
m = merged["media"]
|
|
|
|
|
|
_int(m, "max_upload_mb", 1, 8192, "media.max_upload_mb")
|
|
|
|
|
|
for k in ("dir", "thumbs_dir"):
|
|
|
|
|
|
if not isinstance(m.get(k), str) or not m[k] or "/" in m[k] \
|
|
|
|
|
|
or m[k] in (".", ".."):
|
|
|
|
|
|
errors.append(f"media.{k}")
|
|
|
|
|
|
|
|
|
|
|
|
a = merged["artnet"]
|
|
|
|
|
|
_int(a, "listen_port", 1, 65535, "artnet.listen_port")
|
|
|
|
|
|
uni = a.get("universes")
|
|
|
|
|
|
if (not isinstance(uni, list) or not uni
|
|
|
|
|
|
or len(set(uni)) != len(uni)
|
|
|
|
|
|
or any(not isinstance(u, int) or not 0 <= u <= 32767
|
|
|
|
|
|
for u in uni)):
|
|
|
|
|
|
errors.append("artnet.universes")
|
|
|
|
|
|
for k in ("master_channel", "layer_start_channel", "playback_channel",
|
2026-09-11 12:01:22 +02:00
|
|
|
|
"retrigger_channel", "blackout_channel", "fx1_channel",
|
2026-09-11 13:34:43 +02:00
|
|
|
|
"fx2_channel", "cue_go_channel"):
|
2026-09-11 11:05:01 +02:00
|
|
|
|
v = a.get(k)
|
|
|
|
|
|
if v is not None and (not isinstance(v, int)
|
|
|
|
|
|
or not 1 <= v <= 512):
|
|
|
|
|
|
errors.append(f"artnet.{k}")
|
|
|
|
|
|
_int(a, "signal_loss_timeout_s", 1, 3600,
|
|
|
|
|
|
"artnet.signal_loss_timeout_s")
|
|
|
|
|
|
if a.get("signal_loss_policy") not in ("hold", "fade_black"):
|
|
|
|
|
|
errors.append("artnet.signal_loss_policy")
|
|
|
|
|
|
|
|
|
|
|
|
e = merged["engine"]
|
|
|
|
|
|
_int(e, "max_layers", 1, 16, "engine.max_layers")
|
|
|
|
|
|
if not isinstance(e.get("loop_default"), bool):
|
|
|
|
|
|
errors.append("engine.loop_default")
|
2026-09-11 12:55:50 +02:00
|
|
|
|
ap = e.get("autostart_project")
|
|
|
|
|
|
if ap is not None and not isinstance(ap, str):
|
|
|
|
|
|
errors.append("engine.autostart_project")
|
2026-09-11 13:11:54 +02:00
|
|
|
|
sv = merged.get("servers")
|
|
|
|
|
|
if not isinstance(sv, list):
|
|
|
|
|
|
errors.append("servers")
|
|
|
|
|
|
else:
|
|
|
|
|
|
for item in sv:
|
|
|
|
|
|
if (not isinstance(item, dict)
|
|
|
|
|
|
or not isinstance(item.get("name"), str)
|
|
|
|
|
|
or not item.get("name", "").strip()
|
|
|
|
|
|
or not isinstance(item.get("url"), str)
|
|
|
|
|
|
or not item.get("url", "").startswith(
|
|
|
|
|
|
("http://", "https://"))):
|
|
|
|
|
|
errors.append("servers")
|
|
|
|
|
|
break
|
2026-09-11 11:05:01 +02:00
|
|
|
|
if not isinstance(merged["output"].get("fullscreen"), bool):
|
|
|
|
|
|
errors.append("output.fullscreen")
|
|
|
|
|
|
|
|
|
|
|
|
if errors:
|
|
|
|
|
|
return None, "ungültig: " + ", ".join(errors)
|
|
|
|
|
|
return merged, None
|