""" 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- 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}"}