Files
grotti/grotti/config.py
T

378 lines
13 KiB
Python

"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
import json
import os
import secrets
import threading
from pathlib import Path
from grotti.errors import GrottiError
ROOT = Path(__file__).resolve().parent.parent
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
DEFAULT_WATCH_REL = "slice"
_SETTINGS_LOCK = threading.Lock()
def config_path():
"""GROTTI_CONFIG, sonst /etc/grotti/config.json (Paket), sonst Repo-config.json."""
env = (os.environ.get("GROTTI_CONFIG") or "").strip()
if env:
return Path(env)
etc = Path("/etc/grotti/config.json")
if etc.is_file():
return etc
return ROOT / "config.json"
CONFIG_PATH = config_path()
def _tariff(raw):
tariff = raw.get("tariff") if isinstance(raw, dict) else None
if not isinstance(tariff, dict) or not tariff:
return None
try:
ht = float(tariff.get("ht_euro"))
nt = float(tariff.get("nt_euro"))
except (TypeError, ValueError):
return None
return {
"ht_euro": ht,
"nt_euro": nt,
"nt_from": str(tariff.get("nt_from") or "22:00").strip(),
"nt_to": str(tariff.get("nt_to") or "06:00").strip(),
}
def _energy(raw):
energy = raw.get("energy") or {}
out = {
"watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
"waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)),
}
tariff = _tariff(energy)
if tariff:
out["tariff"] = tariff
return out
def _ntfy(raw):
ntfy = raw.get("ntfy") or {}
return {
"enabled": bool(ntfy.get("enabled")),
"server": str(ntfy.get("server") or "https://ntfy.sh").rstrip("/"),
"topic": str(ntfy.get("topic") or ""),
"token": str(ntfy.get("token") or ""),
}
def _telegram(raw):
tg = raw.get("telegram") or {}
return {
"enabled": bool(tg.get("enabled")),
"bot_token": str(tg.get("bot_token") or ""),
"chat_id": str(tg.get("chat_id") or ""),
}
def _alerts(raw):
alerts = raw.get("alerts") or {}
return {
"fertig": bool(alerts.get("fertig", True)),
"fehler": bool(alerts.get("fehler", True)),
"offline": bool(alerts.get("offline", True)),
"queue": bool(alerts.get("queue", True)),
"spule": bool(alerts.get("spule", True)),
"ablauf": bool(alerts.get("ablauf", False)),
"start": bool(alerts.get("start", False)),
}
def normalize_fqdn(value):
"""Leer string oder Basis-URL ohne trailing slash; ohne Schema → https://."""
text = str(value or "").strip().rstrip("/")
if not text:
return ""
if "://" not in text:
text = "https://" + text
return text.rstrip("/")
def public_base(cfg, request_root=None):
"""Öffentliche Basis für Etiketten-QR: fqdn, sonst Request-Root."""
base = normalize_fqdn(cfg.get("fqdn") or "")
if base:
return base
return str(request_root or "").rstrip("/")
def _oauth(raw):
oauth = raw.get("oauth") or {}
return {
"enabled": bool(oauth.get("enabled")),
"issuer": str(oauth.get("issuer") or ""),
"client_id": str(oauth.get("client_id") or ""),
"client_secret": str(oauth.get("client_secret") or ""),
"redirect_uri": str(oauth.get("redirect_uri") or ""),
}
def _ensure_secret_key(path, raw):
key = str(raw.get("secret_key") or "").strip()
if key:
return key
key = secrets.token_hex(32)
raw["secret_key"] = key
path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return key
def load():
path = config_path()
if not path.exists():
example = ROOT / "config.example.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
raw = json.loads(path.read_text(encoding="utf-8"))
host = (raw.get("host") or "127.0.0.1").strip()
token = raw.get("token") or ""
secret_key = _ensure_secret_key(path, raw)
# Nicht-Loopback ohne Shared-Token ist ok — Login/API-Tokens schützen die App.
# Neu laden falls secret_key geschrieben wurde
if raw.get("secret_key") == secret_key:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
data_dir = Path(raw.get("data_dir") or "data")
if not data_dir.is_absolute():
data_dir = ROOT / data_dir
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
printers_file = Path(raw.get("printers_file") or (ROOT / "drucker.json"))
if not printers_file.is_absolute():
printers_file = ROOT / printers_file
return {
"host": host,
"port": int(raw.get("port") or 8770),
"data_dir": data_dir,
"watch_dir": watch_dir,
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
"token": token,
"secret_key": secret_key,
"oauth": _oauth(raw),
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
"printers_file": printers_file,
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
"energy": _energy(raw),
"telegram": _telegram(raw),
"ntfy": _ntfy(raw),
"alerts": _alerts(raw),
"auto_queue": bool(raw.get("auto_queue", True)),
"auto_retry": bool(raw.get("auto_retry", True)),
"_raw": raw,
"_path": path,
}
def mask_token(token):
token = token or ""
if len(token) <= 8:
return "••••" if token else ""
return token[:4] + "…" + token[-2:]
def parse_watch_dir(value, *, create=False, missing_ok=False):
"""Pfad aus Einstellungen/Config → (Path|None, gespeicherter String).
Relativ zum Repo-Root. Leer = aus. create legt den Ordner an.
"""
text = str(value or "").strip()
if not text:
return None, ""
path = Path(text)
if not path.is_absolute():
path = (ROOT / path).resolve()
else:
path = path.resolve()
if path.exists() and not path.is_dir():
raise GrottiError("Der Slice-Ordner ist keine Ordner.", "watch")
if create:
try:
path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise GrottiError("Slice-Ordner konnte nicht angelegt werden: %s" % exc, "watch") from exc
elif not path.is_dir():
if missing_ok:
return None, text
raise GrottiError("Slice-Ordner fehlt: %s" % path, "watch")
try:
stored = path.relative_to(ROOT.resolve()).as_posix()
except ValueError:
stored = str(path)
return path, stored
def default_watch_suggestion():
"""Absoluter Standardpfad (Repo/slice) für die Einstellungen."""
return str((ROOT / DEFAULT_WATCH_REL).resolve())
def settings_public(cfg):
tg = cfg.get("telegram") or {}
oauth = cfg.get("oauth") or {}
return {
"watch_dir": str(cfg.get("watch_dir") or ""),
"watch_dir_default": default_watch_suggestion(),
"auto_queue": bool(cfg.get("auto_queue", True)),
"auto_retry": bool(cfg.get("auto_retry", True)),
"fqdn": str(cfg.get("fqdn") or ""),
"legacy_token_set": bool(cfg.get("token")),
"energy": dict(cfg.get("energy") or {}),
"telegram": {
"enabled": bool(tg.get("enabled")),
"bot_token": mask_token(tg.get("bot_token") or ""),
"bot_token_set": bool(tg.get("bot_token")),
"chat_id": str(tg.get("chat_id") or ""),
},
"ntfy": {
"enabled": bool((cfg.get("ntfy") or {}).get("enabled")),
"server": str((cfg.get("ntfy") or {}).get("server") or "https://ntfy.sh"),
"topic": str((cfg.get("ntfy") or {}).get("topic") or ""),
"token": mask_token((cfg.get("ntfy") or {}).get("token") or ""),
"token_set": bool((cfg.get("ntfy") or {}).get("token")),
},
"alerts": dict(cfg.get("alerts") or {}),
"oauth": {
"enabled": bool(oauth.get("enabled")),
"issuer": str(oauth.get("issuer") or ""),
"client_id": str(oauth.get("client_id") or ""),
"redirect_uri": str(oauth.get("redirect_uri") or ""),
},
}
def save_settings(
cfg,
energy=None,
telegram=None,
ntfy=None,
alerts=None,
auto_queue=None,
auto_retry=None,
fqdn=None,
watch_dir=None,
):
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
with _SETTINGS_LOCK:
return _save_settings_locked(
cfg,
energy=energy,
telegram=telegram,
ntfy=ntfy,
alerts=alerts,
auto_queue=auto_queue,
auto_retry=auto_retry,
fqdn=fqdn,
watch_dir=watch_dir,
)
def _save_settings_locked(
cfg,
energy=None,
telegram=None,
ntfy=None,
alerts=None,
auto_queue=None,
auto_retry=None,
fqdn=None,
watch_dir=None,
):
path = cfg.get("_path") or CONFIG_PATH
raw = dict(cfg.get("_raw") or {})
if path.exists():
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
if auto_queue is not None:
raw["auto_queue"] = bool(auto_queue)
cfg["auto_queue"] = bool(auto_queue)
if auto_retry is not None:
raw["auto_retry"] = bool(auto_retry)
cfg["auto_retry"] = bool(auto_retry)
if watch_dir is not None:
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
raw["watch_dir"] = stored
cfg["watch_dir"] = resolved
if fqdn is not None:
normalized = normalize_fqdn(fqdn)
raw["fqdn"] = normalized
cfg["fqdn"] = normalized
if energy is not None:
raw_energy = {
"watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
"waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)),
}
tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None
if tariff_in:
raw_energy["tariff"] = {
"ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])),
"nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])),
"nt_from": str(tariff_in.get("nt_from") or "22:00"),
"nt_to": str(tariff_in.get("nt_to") or "06:00"),
}
raw["energy"] = raw_energy
cfg["energy"] = _energy(raw)
if telegram is not None:
current = _telegram(raw)
token = telegram.get("bot_token")
if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"):
token = current["bot_token"]
raw["telegram"] = {
"enabled": bool(telegram.get("enabled", current["enabled"])),
"bot_token": str(token or ""),
"chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""),
}
cfg["telegram"] = _telegram(raw)
if ntfy is not None:
current = _ntfy(raw)
token = ntfy.get("token")
if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"):
token = current["token"]
raw["ntfy"] = {
"enabled": bool(ntfy.get("enabled", current["enabled"])),
"server": str(ntfy.get("server", current["server"]) or "https://ntfy.sh").rstrip("/"),
"topic": str(ntfy.get("topic", current["topic"]) or ""),
"token": str(token or ""),
}
cfg["ntfy"] = _ntfy(raw)
if alerts is not None:
raw["alerts"] = {
"fertig": bool(alerts.get("fertig", True)),
"fehler": bool(alerts.get("fehler", True)),
"offline": bool(alerts.get("offline", True)),
"queue": bool(alerts.get("queue", True)),
"spule": bool(alerts.get("spule", True)),
"ablauf": bool(alerts.get("ablauf", False)),
"start": bool(alerts.get("start", False)),
}
cfg["alerts"] = _alerts(raw)
path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
cfg["_raw"] = raw
cfg["_path"] = path
return settings_public(cfg)