378 lines
13 KiB
Python
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)
|