Merge branch 'wartung/einstellungen-stand-altreste' into wartung/restarbeiten

# Conflicts:
#	deploy/deploy.sh
This commit is contained in:
Hitonabi
2026-09-24 20:47:53 +02:00
27 changed files with 2738 additions and 110 deletions
+233
View File
@@ -11,10 +11,18 @@ Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche
(User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer).
Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt:
Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen.
Seit 24.09.2026 zusätzlich: Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees, Alt-Units) aus
einer festen Liste — siehe ALTRESTE unten.
"""
import os
import shutil
import stat
import subprocess
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from config import MODELS_DIR
@@ -144,3 +152,228 @@ def loeschen(art: str, name: str) -> dict:
except (OSError, ValueError) as e:
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
# --- Altreste neben dem Betrieb (seit 24.09.2026) ---------------------------------------------------------------
# Feste Liste aus dem Prüfbericht vom 24.09.2026, am selben Abend auf der Box nachgemessen (du) und gegengeprüft:
# Kein laufender Dienst, keine eingetragene Unit, kein Cron- oder Hermes-Job, kein aktiver Skill und keine
# Hermes-Einstellung nennt einen dieser Pfade. Angezeigt wird nur, was noch da ist; gelöscht wird nur auf Knopfdruck
# mit Rückfrage und nur ein Eintrag dieser Liste — die Anfrage nennt eine Kennung, nie einen Pfad.
#
# Bewusst NICHT in der Liste, weil nicht klar tot: ~/.voice (die Spracherkennung schläft nur), ~/.cache (Modelle
# von Lucys Stimme), ~/.opencode (steht in ~/.bashrc), ~/.hermes/kanban.db (Hermes öffnet sie noch), Archive
# (profiles-archive, archiv-aufraeumen, wissens-vault), ~/.hermes/state-snapshots, der PBS-Weg, die Rückweg-Kopien
# des Updaters (/opt/llamacpp-vulkan.bak, llama-swap.bak) und Skripte, die ein aktiver Skill noch nennt
# (ampel-waechter.sh, night-cron-wrapper.sh, pc_path_lookup.*).
@dataclass(frozen=True)
class Altrest:
id: str
name: str
pfade: tuple[str, ...] # "~/…" (Home des Dienst-Nutzers) oder absolut
hinweis: str
art: str = "ordner" # "ordner" | "worktree" (danach git worktree prune) | "unit" (danach daemon-reload)
sperre: tuple[str, ...] = () # User-Units, die weder laufen noch für den Autostart eingetragen sein dürfen
sudo: bool = False # Elternordner gehört root (/opt): den geleerten Ordner mit sudo -n entfernen
_UNITS = "~/.config/systemd/user/"
_STEMPEL = "~/.local/share/systemd/timers/stamp-"
_SKRIPTE = "~/.hermes/scripts/"
ALTRESTE: tuple[Altrest, ...] = (
Altrest("zonos2-test", "TTS-Test Zonos2", ("~/zonos2-test",),
"Test einer anderen Sprachausgabe vom 04.09. Lucys Stimme ist pocket-tts (~/.lucy-stimme); "
"nichts ruft diesen Ordner auf."),
Altrest("rocm-build", "Alter ROCm-Build von llama.cpp", ("/opt/llamacpp",),
"Motor aus der ROCm-Zeit (Juni). Seit dem Vulkan-Verdikt läuft er aus /opt/llamacpp-vulkan, und "
"llama-swap verweist nicht mehr hierher. Zurück zu ROCm ginge danach nur mit einem neuen Download.",
sudo=True),
Altrest("mem0", "mem0-Umgebung", ("~/.mem0",),
"Gedächtnis-Sidecar, am 27.08. abgelöst: Hermes führt das Gedächtnis selbst. Nur die abgeschaltete "
"Unit mem0-service verweist noch darauf.", sperre=("mem0-service.service",)),
Altrest("kanban", "Kanban-Arbeitsordner", ("~/.hermes/kanban",),
"Arbeitsordner, Anhänge und Protokolle der Kanban-Aufgaben aus Juli und August. Kanban ist in Hermes "
"abgeschaltet; die kleine Datenbank kanban.db bleibt liegen, weil Hermes sie noch öffnet."),
Altrest("alte-venvs", "Alte Python-Umgebungen", ("~/.venv-audit", "~/.venv-lint", "~/.litellm-venv"),
"Umgebungen für den früheren venv-Audit, Lint-Versuche und LiteLLM (der Gateway ist seit Juli "
"eingebaut). Kein Dienst und kein Job nutzt sie."),
Altrest("audiocpp-test", "audio.cpp-Test", ("~/audiocpp-test",),
"Test von audio.cpp mit Vulkan vom 04.09., nie in Betrieb gegangen."),
Altrest("avatar", "Avatar in der Oberfläche", ("~/mission-control-v2/frontend/dist/avatar.vrm",),
"VRM-Avatar vom 25.07., nie im Repo. Die Oberfläche lädt ihn nicht (kein Abruf in 30 Tagen), "
"Lucy-Desktop bringt einen eigenen mit."),
Altrest("worktrees", "Alte Git-Arbeitskopien",
("~/.hermes/worktrees/orch-graph-verknuepfer", "~/.hermes/worktrees/orch-graph-verknuepfer-work",
"~/.hermes/worktrees/wartung-remove-openrouter", "~/projekte/mc2-referenz",
"~/mission-control-v2/.worktrees/t_b7b426dc"),
"Arbeitskopien alter Aufgaben aus Juli und August, weit hinter main. Ihre Zweige bleiben im Box-Repo "
"erhalten; verloren gingen nur Änderungen, die dort nie committet wurden.", art="worktree"),
Altrest("test-reste", "Test-Reste im Home-Ordner",
("~/v2test", "~/bench_ab", "~/stimm_varianten", "~/stimm_moods", "~/emotion_test", "~/english_probe",
"~/zed-web-search-mcp", "~/cron_digest.py", "~/mc2-träum-duplikatscheck.patch"),
"Hörproben der Stimm-Tests vom 09. bis 12.07., ein MCP-Server der abgelösten Zed-Bahn, ein Skript des "
"alten Morgen-Digests und ein Patch aus der mem0-Zeit. Lucys Stimme nutzt ihre Referenz in "
"~/.lucy-stimme."),
Altrest("hermes-fremd", "Fremde Dateien im Hermes-Quellbaum",
("~/.hermes/hermes-agent/hermes_cli/mc2_kanban_reaper.py",
"~/.hermes/hermes-agent/hermes_cli/web_dist.bak-rootbuild"),
"Ein alter Kanban-Aufräumer und eine Sicherung der Web-Oberfläche, beide nicht von Hermes. Nichts lädt "
"sie; Hermes selbst bleibt unverändert."),
Altrest("hermes-skripte", "Alte Skripte in ~/.hermes/scripts",
tuple(_SKRIPTE + s for s in (
"blogwatcher-logger.py", "briefing-date.sh", "fremdblick.sh", "gitea-repo-create.sh",
"hermes-release-radar-feed.sh", "news-melden.sh.bak-20260923", "radar-feed.sh", "restore-probe.sh",
"selbstkritik-feed.sh", "venv-audit.sh", "worker.sh")),
"Skripte früherer Cron-Jobs (Werkstatt, Ampel, alte Radare). Kein Job, kein Cron und kein aktiver "
"Skill ruft sie auf; deploy.sh legt nur die heutigen Skripte dorthin."),
Altrest("mc2-memory", "Plugin mc2-memory", ("~/.hermes/plugins/mc2-memory",),
"Gedächtnis-Plugin aus der mem0-Zeit, in Hermes nicht aktiviert und nicht mehr im Repo."),
Altrest("pinned-version", "Veraltete PINNED_VERSION", ("~/.hermes/PINNED_VERSION",),
"Sagt noch „v0.17.0“ und führt in die Irre: Festgehaltene Versionen stehen in /srv/models/mc2-pins.json."),
Altrest("alt-units", "Abgeschaltete Alt-Units",
tuple(_UNITS + u for u in (
"mem0-service.service", "mem0-service.service.bak-174750", "mc2-morgen-digest.service",
"mc2-morgen-digest.timer", "mc2-selfsmoke.service", "mc2-selfsmoke.timer",
"hermes-dashboard.service", "lucy-stimme.service.bak-20260904"))
+ (_STEMPEL + "mc2-morgen-digest.timer", _STEMPEL + "mc2-selfsmoke.timer"),
"Units früherer Dienste (mem0, Morgen-Digest, Selbsttest, altes Hermes-Dashboard) samt zwei Sicherungen "
"davon, alle abgeschaltet; ihre Skripte gibt es nicht mehr. Danach liest systemd die Units neu ein.",
art="unit",
sperre=("mem0-service.service", "mc2-morgen-digest.service", "mc2-morgen-digest.timer",
"mc2-selfsmoke.service", "mc2-selfsmoke.timer", "hermes-dashboard.service")),
)
REPO = Path(__file__).resolve().parents[2]
ALTRESTE_CACHE_S = 300 # Größen messen dauert (os.walk über ~20 000 Dateien); sie ändern sich selten
_altreste_lock = threading.Lock()
_altreste_cache: dict = {"ts": 0.0, "liste": None}
def _home() -> Path:
return Path(os.environ.get("MC_ALTRESTE_HOME", str(Path.home())))
def _pfad(roh: str) -> Path:
return _home() / roh[2:] if roh.startswith("~/") else Path(roh)
def _belegt(pfad: Path) -> int:
"""Bytes einer Datei oder eines Ordners, ohne Verknüpfungen zu folgen (ein venv zeigt sonst aufs System-Python)."""
try:
st = pfad.lstat()
except OSError:
return 0
if not stat.S_ISDIR(st.st_mode):
return st.st_size
summe = 0
for wurzel, _, dateien in os.walk(pfad):
for datei in dateien:
try:
summe += os.lstat(os.path.join(wurzel, datei)).st_size
except OSError:
pass
return summe
def _menge(n: int) -> str:
"""Größe in Worten, dezimal wie die Modell-Kandidaten: „7,7 GB“, „323 MB“, „44 KB“."""
if n >= 1e9:
return f"{n / 1e9:.1f} GB".replace(".", ",")
if n >= 1e6:
return f"{n / 1e6:.0f} MB"
if n >= 1e3:
return f"{n / 1e3:.0f} KB"
return "unter 1 KB"
def _befehl(args: list[str]) -> tuple[bool, str]:
"""Befehle (systemctl, git, sudo) über eine Schicht, die Tests ersetzen. Ohne das Programm: (False, Grund)."""
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=60)
except (OSError, subprocess.SubprocessError) as exc:
return False, f"{exc.__class__.__name__}: {exc}"
return r.returncode == 0, ((r.stdout if r.returncode == 0 else (r.stderr or r.stdout)) or "").strip()[:300]
def _vorhanden(a: Altrest) -> list[tuple[str, Path]]:
return [(roh, p) for roh in a.pfade if (p := _pfad(roh)).exists() or p.is_symlink()]
def _sperrgrund(a: Altrest) -> str | None:
"""Läuft eine der Units noch oder steht sie im Autostart, wird der Rest gebraucht — dann nicht anbieten."""
for unit in a.sperre:
ok, ausgabe = _befehl(["systemctl", "--user", "show", unit, "-p", "ActiveState,UnitFileState"])
z = dict(zeile.split("=", 1) for zeile in ausgabe.splitlines() if "=" in zeile) if ok else {}
if z.get("ActiveState") in ("active", "activating", "reloading"):
return f"{unit} läuft gerade, {a.name} wird also noch gebraucht."
if z.get("UnitFileState") in ("enabled", "enabled-runtime", "linked", "linked-runtime"):
return f"{unit} steht im Autostart, {a.name} wird also noch gebraucht."
return None
def altreste(frisch: bool = False) -> list[dict]:
"""Die Altreste der festen Liste, die noch da und nicht in Gebrauch sind — größte zuerst. Löscht nichts."""
with _altreste_lock:
if not frisch and _altreste_cache["liste"] is not None \
and time.time() - _altreste_cache["ts"] < ALTRESTE_CACHE_S:
return [dict(e) for e in _altreste_cache["liste"]]
liste = []
for a in ALTRESTE:
da = _vorhanden(a)
if not da or _sperrgrund(a):
continue
groesse = sum(_belegt(p) for _, p in da)
liste.append({"id": a.id, "name": a.name, "pfade": [roh for roh, _ in da], "groesse_bytes": groesse,
"groesse": _menge(groesse), "hinweis": a.hinweis})
liste.sort(key=lambda e: -e["groesse_bytes"])
with _altreste_lock:
_altreste_cache.update(ts=time.time(), liste=liste)
return [dict(e) for e in liste]
def _entfernen(pfad: Path, a: Altrest) -> None:
if pfad.is_symlink() or not pfad.is_dir():
pfad.unlink() # eine Verknüpfung selbst, nie ihr Ziel
return
try:
shutil.rmtree(pfad)
except PermissionError:
if not a.sudo:
raise
# /opt gehört root: Den Inhalt darf der Dienst-Nutzer löschen, den geleerten Ordner selbst nur mit sudo.
ok, grund = _befehl(["sudo", "-n", "rm", "-rf", "--", str(pfad)])
if not ok:
raise PermissionError(f"sudo -n rm ging nicht ({grund})") from None
def altrest_loeschen(kennung: str) -> dict:
"""Knopf „Löschen“ eines Altrests: nur Einträge der festen Liste, nur was da ist, nur wenn nichts es braucht."""
a = next((x for x in ALTRESTE if x.id == kennung), None)
if a is None:
return {"ok": False, "detail": "Diesen Altrest kennt die Liste nicht."}
if not AUF_DER_BOX:
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
da = _vorhanden(a)
if not da:
return {"ok": False, "detail": f"{a.name}: schon weg."}
if (grund := _sperrgrund(a)):
return {"ok": False, "detail": grund}
frei, fehler = 0, []
for roh, pfad in da:
groesse = _belegt(pfad)
try:
_entfernen(pfad, a)
frei += groesse
except OSError as exc:
fehler.append(f"{roh} ({exc.strerror or exc})")
if a.art == "worktree": # Git vergisst die gelöschten Arbeitskopien, die Zweige bleiben im Repo
_befehl(["git", "-C", str(REPO), "worktree", "prune"])
elif a.art == "unit":
_befehl(["systemctl", "--user", "daemon-reload"])
with _altreste_lock:
_altreste_cache.update(ts=0.0, liste=None)
if fehler:
return {"ok": False, "detail": f"{a.name} nur zum Teil gelöscht ({_menge(frei)} frei). Geblieben: "
+ "; ".join(fehler)}
return {"ok": True, "text": f"{a.name} gelöscht, {_menge(frei)} frei."}
+415
View File
@@ -0,0 +1,415 @@
"""
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}"}
+77
View File
@@ -0,0 +1,77 @@
"""
Software-Stand einer Instanz (seit 24.09.2026): welcher Commit seit wann live ist.
Nach einem erfolgreichen Umschalten schreibt deploy/deploy.sh (KI-Box) bzw. deploy/homelab/ausrollen.sh
(Container des Homelab-Teils) die Stand-Datei mc2-stand.json in den Datenordner (deploy/stand-schreiben.py):
Commit kurz, Betreff, Commit-Datum und den Zeitpunkt des Deploys. Die Oberfläche zeigt sie unter
Einstellungen → Software-Stand, für diese Instanz über /api/instanz und für die andere über /api/partner/instanz.
Ohne Stand-Datei (Entwicklung am PC, Box vor dem ersten Deploy mit dieser Datei) kommt der Stand aus git, sonst
heißt er „unbekannt“. Nur lesend.
"""
import json
import os
import subprocess
import threading
import time
from pathlib import Path
from kern.einstellungen import einstellungen
REPO = Path(__file__).resolve().parents[2]
GIT_CACHE_S = 60.0
_lock = threading.Lock()
_git_cache: dict = {"ts": 0.0, "stand": None}
def stand_datei() -> Path:
return Path(os.environ.get("MC_STAND_DATEI", str(einstellungen().daten_dir / "mc2-stand.json")))
def _text(wert: object) -> str | None:
return wert.strip() if isinstance(wert, str) and wert.strip() else None
def _aus_datei() -> dict | None:
try:
daten = json.loads(stand_datei().read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(daten, dict) or not _text(daten.get("commit")):
return None
return {"quelle": "deploy", **{k: _text(daten.get(k)) for k in ("commit", "betreff", "commit_datum",
"deploy_zeit")}}
def _git_log() -> str | None:
"""Kopf des Checkouts, in dem dieser Code liegt. Die Schicht, die Tests ersetzen."""
try:
r = subprocess.run(["git", "-C", str(REPO), "log", "-1", "--format=%h%n%cI%n%s"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5)
except (OSError, subprocess.SubprocessError):
return None
return r.stdout if r.returncode == 0 else None
def _aus_git() -> dict | None:
with _lock:
if time.time() - _git_cache["ts"] < GIT_CACHE_S:
return _git_cache["stand"]
ausgabe = _git_log()
stand = None
if ausgabe:
commit, datum, betreff = ([z.strip() for z in ausgabe.splitlines()] + ["", "", ""])[:3]
if commit:
stand = {"quelle": "git", "commit": commit, "betreff": betreff or None,
"commit_datum": datum or None, "deploy_zeit": None}
with _lock:
_git_cache.update(ts=time.time(), stand=stand)
return stand
def stand() -> dict:
"""{"quelle": "deploy"|"git"|"unbekannt", "commit", "betreff", "commit_datum", "deploy_zeit"}."""
return _aus_datei() or _aus_git() or {"quelle": "unbekannt", "commit": None, "betreff": None,
"commit_datum": None, "deploy_zeit": None}
+4
View File
@@ -241,6 +241,10 @@ def pruefe_timer_dienste() -> list[Befund]:
continue
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
continue
# Der Dienst hinter einem Timer ist „static“ und schläft nie selbst — schlafen kann nur sein Timer
# (Einstellungen → Radar aus, seit 24.09.2026). Dann ist ein alter Fehlschlag kein Befund mehr.
if schlaeft(_systemctl_show(f"{name}.timer", False)):
continue
grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}."
befunde.append(Befund(
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",