Einstellungen, die etwas einstellen (services/einstellungen.py, /api/einstellungen): - Update-Zeitfenster: Wochentag und Uhrzeit des Update-Laufs als Drop-in mc2-autoupdate.timer.d/zeitfenster.conf. Gegen die Nachhol-Falle von Persistent=true (24.09.): Timer vor dem daemon-reload stoppen, Stempel auf jetzt, dann starten. Bei Fehler zurück auf das alte Drop-in. Während eines Update-Laufs abgelehnt. - Neustart nur im eingestellten Fenster (deploy/wartungsfenster.sh, drei Stunden ab der vollen Stunde des Beginns; Standard So 04:00-06:59). - Radar an/aus: disable --now bzw. enable + Stempel + start (kein Nachholen). deploy.sh respektiert den Schalter und startet den Radar-Timer nur mit Stempel; das Zeitfenster-Drop-in fasst er nie an. - Wächter: schläft der Timer eines Timer-Dienstes, ist ein alter Fehlschlag kein Befund mehr (der Dienst selbst ist static und schläft nie). - Telegram-Test je Instanz (/api/einstellungen/telegram-test und /api/homelab/einstellungen/telegram-test) über notify.sh -d; Ergebnis und Grund aus dem Melde-Log. notify.sh schreibt jetzt auch den Grund des gescheiterten Zweitwegs in den FALLBACK-Eintrag. - Einstellungsdatei mc2-einstellungen.json im Datenordner. Software-Stand: deploy.sh und homelab/ausrollen.sh schreiben nach dem Umschalten mc2-stand.json (deploy/stand-schreiben.py); /api/instanz liefert den Stand, die Einstellungen zeigen beide Instanzen und warnen bei Abweichung. Ohne Datei aus git, sonst unbekannt. Aufräumen: Altreste neben dem Betrieb aus einer festen, auf der Box geprüften Liste (Größe, Hinweis, nur was da und nicht in Gebrauch ist). Löschen nur per Kennung und Klick mit Rückfrage; Worktrees mit git worktree prune, Alt-Units mit daemon-reload, /opt/llamacpp notfalls mit sudo -n. Tests: pytest mit einem systemd-Abbild samt Nachhol-Regel, echtem notify.sh (Ersatz-Hermes, Tmp-Zugang, lokale Bot-API), Shell-Funktionen des Wartungsfensters und von deploy.sh; vitest für die Rückfragen der Schublade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
416 lines
20 KiB
Python
416 lines
20 KiB
Python
"""
|
||
Einstellungen der Oberfläche (seit 24.09.2026): was sich unter „Einstellungen“ wirklich einstellen lässt.
|
||
|
||
• Update-Zeitfenster Wochentag und Uhrzeit des wöchentlichen Update-Laufs (mc2-autoupdate.timer, Standard
|
||
So 04:30). Umgesetzt als Drop-in ~/.config/systemd/user/mc2-autoupdate.timer.d/zeitfenster.conf,
|
||
das deploy.sh nicht anfasst. Neu gestartet wird die Box nur in den drei Stunden ab der vollen
|
||
Stunde des Beginns; deploy/wartungsfenster.sh liest das Fenster aus der Einstellungsdatei.
|
||
• Modell-Radar mc2-radar.timer (täglich 00:30) an oder aus. deploy.sh respektiert den Schalter.
|
||
• Telegram-Test eine Testmeldung über den echten Meldeweg (deploy/notify.sh -d, also auch nachts sofort);
|
||
das Ergebnis samt Grund kommt aus dem Melde-Log. Geht auf beiden Instanzen.
|
||
|
||
Was eingestellt ist, steht in einer Datei im Datenordner (mc2-einstellungen.json; nur MC2 schreibt sie, deploy.sh
|
||
und autoupdate.sh lesen sie). Was gilt, sagt systemd. Die Oberfläche zeigt beides: den Wunsch aus der Datei und den
|
||
nächsten Lauf laut systemd, und ob beides zusammenpasst.
|
||
|
||
‼ Nachhol-Falle (24.09.2026): Beide Timer haben Persistent=true. Startet ein Timer mit neuem Plan, holt er einen
|
||
Termin, der seit seinem letzten Lauf „verpasst“ wurde, sofort nach — so startete die Box an einem Donnerstagnachmittag
|
||
neu. Darum wird der Update-Timer VOR dem daemon-reload angehalten (ein laufender Timer rechnet beim Reload mit seinem
|
||
letzten Lauf und dem neuen Plan), und vor jedem Start steht der Stempel ~/.local/share/systemd/timers/stamp-<timer>
|
||
auf jetzt: systemd nimmt dessen Zeit als letzten Lauf und plant den nächsten Termin von dort aus.
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import secrets
|
||
import shutil
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from kern.einstellungen import einstellungen as instanz
|
||
from kern.zeit import LOCAL_TZ
|
||
|
||
# Bewusst kein Import aus services.waechter: Der Telegram-Test läuft auch im Homelab-Teil, und der lädt nichts
|
||
# von der KI-Box (waechter zieht llamaswap und maintenance nach, siehe test_partner).
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
UPDATE_TIMER = "mc2-autoupdate.timer"
|
||
UPDATE_DIENST = "mc2-autoupdate.service"
|
||
RADAR_TIMER = "mc2-radar.timer"
|
||
TAGE = ("Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag")
|
||
SYSTEMD_TAGE = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||
STANDARD_FENSTER = {"tag": 7, "uhrzeit": "04:30"}
|
||
NEUSTART_DAUER_MIN = 180 # wie WARTUNG_DAUER_MIN in deploy/wartungsfenster.sh
|
||
TEST_ABSTAND_S = 20 # höchstens ein Telegram-Test alle 20 s
|
||
AUF_DER_BOX = os.name == "posix" # am Windows-PC (Entwicklung) wird nichts geschaltet und nichts gesendet
|
||
NOTIFY_SH = Path(__file__).resolve().parents[2] / "deploy" / "notify.sh"
|
||
|
||
_sperre = threading.Lock()
|
||
_test_sperre = threading.Lock()
|
||
_letzter_test = {"ts": 0.0}
|
||
|
||
|
||
# --- Orte (zur Laufzeit gelesen, damit Tests sie umlenken können) ------------------------------------
|
||
|
||
def datei() -> Path:
|
||
return Path(os.environ.get("MC_EINSTELLUNGEN", str(instanz().daten_dir / "mc2-einstellungen.json")))
|
||
|
||
|
||
def _user_units() -> Path:
|
||
return Path(os.environ.get("MC_SYSTEMD_USER_DIR", str(Path.home() / ".config" / "systemd" / "user")))
|
||
|
||
|
||
def _stempel_ordner() -> Path:
|
||
return Path(os.environ.get("MC_TIMER_STEMPEL_DIR", str(Path.home() / ".local" / "share" / "systemd" / "timers")))
|
||
|
||
|
||
def dropin() -> Path:
|
||
return _user_units() / f"{UPDATE_TIMER}.d" / "zeitfenster.conf"
|
||
|
||
|
||
def melde_log() -> Path:
|
||
return Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
|
||
|
||
|
||
# --- Die Datei -------------------------------------------------------------------------------------
|
||
|
||
def fenster_fehler(tag: object, uhrzeit: object) -> str | None:
|
||
"""Warum ein Zeitfenster ungültig ist — oder None."""
|
||
if not isinstance(tag, int) or isinstance(tag, bool) or not 1 <= tag <= 7:
|
||
return "Der Wochentag muss zwischen 1 (Montag) und 7 (Sonntag) liegen."
|
||
if not isinstance(uhrzeit, str) or not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", uhrzeit):
|
||
return "Die Uhrzeit braucht die Form HH:MM, etwa 04:30."
|
||
return None
|
||
|
||
|
||
def lesen() -> dict:
|
||
"""Was eingestellt ist. Fehlt die Datei oder ist ein Wert unsinnig, gilt der Standard (So 04:30, Radar an)."""
|
||
try:
|
||
roh = json.loads(datei().read_text(encoding="utf-8"))
|
||
except (OSError, ValueError):
|
||
roh = {}
|
||
roh = roh if isinstance(roh, dict) else {}
|
||
fenster = roh.get("update_fenster") if isinstance(roh.get("update_fenster"), dict) else {}
|
||
tag, uhrzeit = fenster.get("tag"), fenster.get("uhrzeit")
|
||
if fenster_fehler(tag, uhrzeit):
|
||
tag, uhrzeit = STANDARD_FENSTER["tag"], STANDARD_FENSTER["uhrzeit"]
|
||
radar = roh.get("radar") if isinstance(roh.get("radar"), dict) else {}
|
||
an = radar.get("an") if isinstance(radar.get("an"), bool) else True
|
||
return {"update_fenster": {"tag": tag, "uhrzeit": uhrzeit}, "radar": {"an": an}}
|
||
|
||
|
||
def _speichern(daten: dict) -> None:
|
||
"""Atomar über eine Nachbardatei — deploy.sh und autoupdate.sh lesen die Datei jederzeit."""
|
||
ziel = datei()
|
||
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = ziel.with_suffix(".json.tmp")
|
||
tmp.write_text(json.dumps({**daten, "geaendert": datetime.now(LOCAL_TZ).isoformat(timespec="seconds")},
|
||
ensure_ascii=False, indent=1), encoding="utf-8")
|
||
os.replace(tmp, ziel)
|
||
|
||
|
||
# --- Zeitfenster -----------------------------------------------------------------------------------
|
||
|
||
def neustart_fenster(tag: int, uhrzeit: str) -> str:
|
||
"""Wann die Box nach einem Update neu starten darf, in Worten (gleiche Regel wie deploy/wartungsfenster.sh):
|
||
drei Stunden ab der vollen Stunde des Beginns. So 04:30 → „Sonntag 04:00–06:59“."""
|
||
stunde = int(uhrzeit[:2])
|
||
ende = stunde * 60 + NEUSTART_DAUER_MIN - 1
|
||
tag_ende = tag
|
||
if ende >= 1440:
|
||
ende -= 1440
|
||
tag_ende = tag % 7 + 1
|
||
von, bis = f"{stunde:02d}:00", f"{ende // 60:02d}:{ende % 60:02d}"
|
||
if tag_ende == tag:
|
||
return f"{TAGE[tag - 1]} {von}–{bis}"
|
||
return f"{TAGE[tag - 1]} {von} – {TAGE[tag_ende - 1]} {bis}"
|
||
|
||
|
||
def dropin_text(tag: int, uhrzeit: str) -> str:
|
||
"""Das leere OnCalendar= löscht den Plan der Repo-Unit, statt einen zweiten danebenzustellen."""
|
||
return ("# Update-Zeitfenster aus den Einstellungen der Oberfläche (backend/services/einstellungen.py).\n"
|
||
"# Nicht von Hand ändern: Die Oberfläche schreibt diese Datei beim Speichern neu; deploy.sh lässt sie stehen.\n"
|
||
"[Timer]\n"
|
||
"OnCalendar=\n"
|
||
f"OnCalendar={SYSTEMD_TAGE[tag - 1]} *-*-* {uhrzeit}:00\n")
|
||
|
||
|
||
def _dropin_lesen() -> str | None:
|
||
try:
|
||
return dropin().read_text(encoding="utf-8")
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def _dropin_schreiben(inhalt: str | None) -> None:
|
||
"""None heißt: kein Drop-in (zurück auf den Plan der Repo-Unit)."""
|
||
pfad = dropin()
|
||
if inhalt is None:
|
||
pfad.unlink(missing_ok=True)
|
||
return
|
||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = pfad.with_suffix(".conf.tmp")
|
||
tmp.write_text(inhalt, encoding="utf-8")
|
||
os.replace(tmp, pfad)
|
||
|
||
|
||
# --- systemd ---------------------------------------------------------------------------------------
|
||
|
||
def _systemctl(*args: str) -> tuple[bool, str]:
|
||
"""systemctl --user … — die eine Schicht zu systemd, die Tests ersetzen. Gibt (ok, Ausgabe bzw. Grund)."""
|
||
try:
|
||
r = subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True, timeout=60)
|
||
except (OSError, subprocess.SubprocessError) as exc:
|
||
return False, f"systemctl ging nicht ({exc.__class__.__name__}: {exc})"
|
||
if r.returncode == 0:
|
||
return True, (r.stdout or "").strip()
|
||
return False, ((r.stderr or r.stdout or "").strip() or f"systemctl endete mit Code {r.returncode}")[:300]
|
||
|
||
|
||
def _zustand(unit: str) -> dict[str, str]:
|
||
ok, ausgabe = _systemctl("show", unit, "-p", "LoadState,ActiveState,UnitFileState")
|
||
if not ok:
|
||
return {}
|
||
return dict(z.split("=", 1) for z in ausgabe.splitlines() if "=" in z)
|
||
|
||
|
||
def _naechster_lauf(timer: str) -> str | None:
|
||
ok, ausgabe = _systemctl("list-timers", "--all", "--output=json", timer)
|
||
if not ok:
|
||
return None
|
||
try:
|
||
timer_liste = json.loads(ausgabe or "[]")
|
||
except ValueError:
|
||
return None
|
||
for t in timer_liste if isinstance(timer_liste, list) else []:
|
||
if isinstance(t, dict) and t.get("unit") == timer and t.get("next"):
|
||
try:
|
||
return datetime.fromtimestamp(int(t["next"]) / 1_000_000, LOCAL_TZ).isoformat(timespec="seconds")
|
||
except (TypeError, ValueError, OverflowError, OSError):
|
||
return None
|
||
return None
|
||
|
||
|
||
def _stempel_setzen(timer: str) -> None:
|
||
"""Letzter Lauf = jetzt: So holt der Timer beim nächsten Start keinen „verpassten“ Termin nach (Persistent=true).
|
||
Scheitert das, startet der Timer trotzdem — ein nachgeholter Lauf ist besser als gar keiner mehr."""
|
||
try:
|
||
pfad = _stempel_ordner() / f"stamp-{timer}"
|
||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||
pfad.touch(exist_ok=True)
|
||
except OSError:
|
||
log.warning("einstellungen: Stempel für %s nicht setzbar — der Timer könnte nachholen", timer, exc_info=True)
|
||
|
||
|
||
def _fenster_anwenden(inhalt: str | None) -> tuple[bool, str]:
|
||
"""Drop-in schreiben (None: entfernen) und den Update-Timer ohne Nachholen neu planen. Lief der Timer nicht,
|
||
bleibt er aus — er übernimmt das Fenster beim nächsten Start."""
|
||
lief = _zustand(UPDATE_TIMER).get("ActiveState") == "active"
|
||
if lief:
|
||
ok, grund = _systemctl("stop", UPDATE_TIMER)
|
||
if not ok:
|
||
return False, grund
|
||
try:
|
||
_dropin_schreiben(inhalt)
|
||
ok, grund = _systemctl("daemon-reload")
|
||
except OSError as exc:
|
||
ok, grund = False, f"Drop-in {dropin()} nicht schreibbar: {exc}"
|
||
if lief: # auch nach einem Fehler wieder starten: ohne Timer gäbe es gar keine Updates mehr
|
||
_stempel_setzen(UPDATE_TIMER)
|
||
gestartet, grund_start = _systemctl("start", UPDATE_TIMER)
|
||
if ok and not gestartet:
|
||
ok, grund = False, grund_start
|
||
return ok, grund
|
||
|
||
|
||
# --- Für die Oberfläche ----------------------------------------------------------------------------
|
||
|
||
def schlaeft(z: dict[str, str]) -> bool:
|
||
"""Dieselbe Regel wie waechter.schlaeft (ein Test hält beide gleich): abgeschaltet und nicht mehr für den
|
||
Autostart eingetragen = bewusst schlafen gelegt."""
|
||
return z.get("ActiveState") == "inactive" and z.get("UnitFileState") == "disabled"
|
||
|
||
|
||
def _radar_zustand(z: dict[str, str]) -> str:
|
||
if not z or z.get("LoadState") in ("not-found", ""):
|
||
return "unbekannt"
|
||
if z.get("ActiveState") == "active":
|
||
return "an"
|
||
return "schlaeft" if schlaeft(z) else "aus"
|
||
|
||
|
||
def stand() -> dict:
|
||
"""Einstellungen samt dem, was systemd daraus gemacht hat."""
|
||
daten = lesen()
|
||
f = daten["update_fenster"]
|
||
update = _zustand(UPDATE_TIMER)
|
||
radar = _zustand(RADAR_TIMER)
|
||
ist = _dropin_lesen()
|
||
fenster_wirksam = (ist == dropin_text(f["tag"], f["uhrzeit"])) if ist is not None else (f == STANDARD_FENSTER)
|
||
radar_zustand = _radar_zustand(radar)
|
||
radar_an = daten["radar"]["an"]
|
||
return {
|
||
"schaltbar": AUF_DER_BOX and bool(update or radar),
|
||
"update_fenster": {
|
||
**f,
|
||
"tag_name": TAGE[f["tag"] - 1],
|
||
"neustart_fenster": neustart_fenster(f["tag"], f["uhrzeit"]),
|
||
"timer_aktiv": update.get("ActiveState") == "active",
|
||
"naechster_lauf": _naechster_lauf(UPDATE_TIMER) if update else None,
|
||
"wirksam": fenster_wirksam,
|
||
},
|
||
"radar": {
|
||
"an": radar_an,
|
||
"zustand": radar_zustand,
|
||
"naechster_lauf": _naechster_lauf(RADAR_TIMER) if radar_zustand == "an" else None,
|
||
# „aus“ (gestoppt, aber noch im Autostart) passt nicht zu „aus“: nach einem Neustart liefe es wieder.
|
||
"wirksam": radar_zustand == "unbekannt" or radar_zustand == ("an" if radar_an else "schlaeft"),
|
||
},
|
||
}
|
||
|
||
|
||
def update_fenster_setzen(tag: int, uhrzeit: str) -> dict:
|
||
"""Knopf „Speichern“ beim Update-Zeitfenster."""
|
||
if (fehler := fenster_fehler(tag, uhrzeit)):
|
||
return {"ok": False, "detail": fehler}
|
||
if not AUF_DER_BOX:
|
||
return {"ok": False, "detail": "Das Update-Zeitfenster lässt sich nur auf der Box einstellen."}
|
||
with _sperre:
|
||
if _zustand(UPDATE_DIENST).get("ActiveState") in ("active", "activating", "deactivating"):
|
||
return {"ok": False, "detail": "Gerade läuft das Update. Das Zeitfenster lässt sich danach ändern."}
|
||
vorher = _dropin_lesen()
|
||
ok, grund = _fenster_anwenden(dropin_text(tag, uhrzeit))
|
||
if not ok:
|
||
zurueck, _ = _fenster_anwenden(vorher)
|
||
return {"ok": False, "detail": f"systemd hat das neue Zeitfenster nicht übernommen: {grund}"
|
||
+ ("" if zurueck else " Der Timer lässt sich auch nicht zurückstellen.")}
|
||
daten = lesen()
|
||
daten["update_fenster"] = {"tag": tag, "uhrzeit": uhrzeit}
|
||
try:
|
||
_speichern(daten)
|
||
except OSError as exc:
|
||
return {"ok": False, "detail": f"Der Timer ist umgestellt, die Einstellung ließ sich aber nicht speichern: {exc}"}
|
||
return {"ok": True, "text": f"Updates laufen ab jetzt {TAGE[tag - 1]} um {uhrzeit}. Neu gestartet wird die Box "
|
||
f"nur {neustart_fenster(tag, uhrzeit)}. Ein verpasster Termin wird nicht nachgeholt.",
|
||
"einstellungen": stand()}
|
||
|
||
|
||
def radar_schalten(an: bool) -> dict:
|
||
"""Knopf „Einschalten“/„Ausschalten“ beim Modell-Radar."""
|
||
if not AUF_DER_BOX:
|
||
return {"ok": False, "detail": "Das Radar lässt sich nur auf der Box schalten."}
|
||
with _sperre:
|
||
if an:
|
||
ok, grund = _systemctl("enable", RADAR_TIMER)
|
||
if ok:
|
||
_stempel_setzen(RADAR_TIMER)
|
||
ok, grund = _systemctl("start", RADAR_TIMER)
|
||
else: # ein Lauf, der gerade läuft (Nachttest), endet noch von selbst
|
||
ok, grund = _systemctl("disable", "--now", RADAR_TIMER)
|
||
if not ok:
|
||
return {"ok": False, "detail": f"systemd hat das Radar nicht {'eingeschaltet' if an else 'ausgeschaltet'}: "
|
||
f"{grund}"}
|
||
daten = lesen()
|
||
daten["radar"] = {"an": an}
|
||
try:
|
||
_speichern(daten)
|
||
except OSError as exc:
|
||
return {"ok": False, "detail": f"Das Radar ist geschaltet, die Einstellung ließ sich aber nicht speichern: "
|
||
f"{exc}. Der nächste Deploy könnte es zurückschalten."}
|
||
text = ("Das Radar ist an und sucht wieder jede Nacht ab 00:30. Ein verpasster Lauf wird nicht nachgeholt." if an
|
||
else "Das Radar ist aus und schläft. Es sucht und testet keine Modelle mehr, bis du es wieder einschaltest.")
|
||
return {"ok": True, "text": text, "einstellungen": stand()}
|
||
|
||
|
||
# --- Telegram-Test ---------------------------------------------------------------------------------
|
||
|
||
_KOPF = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} ")
|
||
_FALLBACK = "FALLBACK (telegram fehlgeschlagen:"
|
||
|
||
|
||
def log_eintrag(pfad: Path, marke: str, max_bytes: int = 262_144) -> str | None:
|
||
"""Der jüngste Eintrag des Melde-Logs, der `marke` enthält — samt Folgezeilen, falls die Ausgabe von
|
||
`hermes send` mehrzeilig war (dann beginnt nur die erste Zeile mit dem Zeitstempel)."""
|
||
try:
|
||
with pfad.open("rb") as f:
|
||
f.seek(0, os.SEEK_END)
|
||
f.seek(max(0, f.tell() - max_bytes))
|
||
zeilen = f.read().decode("utf-8", "replace").splitlines()
|
||
except OSError:
|
||
return None
|
||
for i in range(len(zeilen) - 1, -1, -1):
|
||
if marke in zeilen[i]:
|
||
anfang = i
|
||
while anfang > 0 and not _KOPF.match(zeilen[anfang]):
|
||
anfang -= 1
|
||
return "\n".join(zeilen[anfang:i + 1])
|
||
return None
|
||
|
||
|
||
def deute_eintrag(eintrag: str | None, text: str) -> dict:
|
||
"""Was notify.sh mit der Meldung gemacht hat. Wortlaut der Zeilen: deploy/notify.sh."""
|
||
if eintrag is None:
|
||
return {"gesendet": False, "weg": None, "grund": None}
|
||
rest = _KOPF.sub("", eintrag, count=1)
|
||
if rest.startswith("OK telegram direkt:"):
|
||
return {"gesendet": True, "weg": "direkt", "grund": None}
|
||
if rest.startswith("OK telegram:"):
|
||
return {"gesendet": True, "weg": "hermes", "grund": None}
|
||
if rest.startswith("QUEUED"):
|
||
return {"gesendet": False, "weg": None,
|
||
"grund": "notify.sh hat sie für die Morgenmeldung zurückgelegt statt sie zu senden."}
|
||
if rest.startswith(_FALLBACK):
|
||
innen = rest[len(_FALLBACK):]
|
||
ende = innen.rfind("): " + text[:40])
|
||
grund = " ".join((innen[:ende] if ende >= 0 else innen).split())
|
||
return {"gesendet": False, "weg": None,
|
||
"grund": grund[:400] or "Telegram hat sie nicht angenommen, ohne Angabe eines Grundes."}
|
||
return {"gesendet": False, "weg": None, "grund": " ".join(rest.split())[:300]}
|
||
|
||
|
||
def _notify(bash: str, args: list[str]) -> tuple[int | None, str]:
|
||
"""deploy/notify.sh aufrufen — der echte Meldeweg. Lucys Briefkasten und wall bleiben beim Test außen vor."""
|
||
env = {**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1", "MC_NOTIFY_OHNE_WALL": "1"}
|
||
try:
|
||
r = subprocess.run([bash, NOTIFY_SH.as_posix(), *args], env=env, capture_output=True, text=True,
|
||
encoding="utf-8", errors="replace", timeout=90, stdin=subprocess.DEVNULL)
|
||
except (OSError, subprocess.SubprocessError) as exc:
|
||
return None, f"{exc.__class__.__name__}: {exc}"
|
||
return r.returncode, (r.stderr or r.stdout or "").strip()
|
||
|
||
|
||
def telegram_test() -> dict:
|
||
"""Knopf „Testmeldung senden“: eine kurze Meldung, dringend (-d), damit sie auch nachts sofort rausgeht."""
|
||
e = instanz()
|
||
jetzt = datetime.now(LOCAL_TZ)
|
||
basis = {"instanz": e.instanz, "zeit": jetzt.isoformat(timespec="seconds")}
|
||
with _test_sperre:
|
||
if time.time() - _letzter_test["ts"] < TEST_ABSTAND_S:
|
||
return {**basis, "ok": False, "gesendet": False, "weg": None, "grund": None,
|
||
"detail": "Der letzte Test ist erst ein paar Sekunden her. Bitte kurz warten."}
|
||
_letzter_test["ts"] = time.time()
|
||
bash = shutil.which("bash") # voller Pfad: unter Windows fände „bash“ sonst womöglich die WSL-Hülle
|
||
if not AUF_DER_BOX or not bash:
|
||
return {**basis, "ok": False, "gesendet": False, "weg": None, "grund": None,
|
||
"detail": "Der Telegram-Test geht nur auf der Box und im Homelab-Teil."}
|
||
kennung = secrets.token_hex(3)
|
||
text = (f"Testmeldung aus den Einstellungen ({e.instanz}, {jetzt:%d.%m. %H:%M} Uhr, Kennung {kennung}). "
|
||
"Kommt sie an, funktioniert der Meldeweg.")
|
||
code, ausgabe = _notify(bash, ["-d", "-s", "[Test]", text])
|
||
gedeutet = deute_eintrag(log_eintrag(melde_log(), f"Kennung {kennung}"), text)
|
||
basis["kennung"] = kennung
|
||
if gedeutet["gesendet"]:
|
||
weg = "über Hermes" if gedeutet["weg"] == "hermes" else "direkt an die Telegram-Bot-API"
|
||
return {**basis, "ok": True, **gedeutet,
|
||
"text": f"Die Testmeldung ist raus ({weg}). In Telegram steht sie mit der Kennung {kennung}."}
|
||
grund = gedeutet["grund"] or (f"Im Melde-Log {melde_log()} steht nichts zu dieser Testmeldung"
|
||
+ (f"; notify.sh endete mit Code {code}" if code is not None else "")
|
||
+ (f": {ausgabe[:200]}" if ausgabe else "."))
|
||
return {**basis, "ok": False, **gedeutet, "grund": grund, "detail": f"Die Testmeldung ging nicht raus: {grund}"}
|