"""„Neu entdeckt“: Geräte, die der Orchestrator noch nicht kennt (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026). Jeder Bericht des Ausführers wird mit dem Register /geraete-bekannt.json verglichen. Taucht ein Gast auf, den es noch nicht gibt (oder dessen Nummer jetzt einen anderen Namen trägt), wird er „neu“: Lucy meldet ihn einmal über Telegram, und das Homelab-Cockpit zeigt ihn als Karte „Neu entdeckt“ mit Vorschlägen — welche App darin steckt (katalog.py), ob ihn ein Sicherungsauftrag erfasst, ob seine Oberfläche geprüft wird. Ein Klick übernimmt ihn: • „Für Updates freigeben“ setzt das Etikett watcher (Ausführer: etikett_setzen) — damit darf der Orchestrator Snapshots anlegen und Updates einspielen. Hat er schon community-script, genügt Übernehmen. • „Nur beobachten“ kein Etikett; er steht in der Liste, Updates gibt es für ihn nicht. • „Nicht anfassen“ setzt watcher-aus: auch ein Community-Script bleibt dann unberührt. Beim allerersten Bericht (noch kein Register) gilt alles als bekannt — sonst stünden alle Geräte auf einmal da. Verschwindet ein Gast, fällt er aus dem Register; kommt unter derselben Nummer ein neuer, ist er wieder „neu“. Der eigene Container (Etikett mc2) erscheint nie. """ import json import logging import threading import time from pathlib import Path from kern.einstellungen import einstellungen from services import announce from services.homelab import apps, kanal from services.homelab.inventar import EIGENES_ETIKETT log = logging.getLogger(__name__) BETREFF = "[Homelab]" WAHL = {"freigeben": ("watcher", True), "ausschliessen": ("watcher-aus", True), "beobachten": (None, None)} ZEITLIMIT_S = 60 _lock = threading.Lock() def _pfad() -> Path: return einstellungen().daten_dir / "geraete-bekannt.json" def _lesen() -> dict | None: try: daten = json.loads(_pfad().read_text(encoding="utf-8")) except (OSError, ValueError): return None return daten if isinstance(daten, dict) else None def _schreiben(daten: dict) -> None: pfad = _pfad() pfad.parent.mkdir(parents=True, exist_ok=True) tmp = pfad.with_suffix(".tmp") tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8") tmp.replace(pfad) def _zid(g: dict) -> str: return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}" def _anzeigename(g: dict) -> str: app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name")) return app.name if app else str(g.get("name") or _zid(g)) def bericht_gesehen(bericht: dict, jetzt: float | None = None, melden=None) -> list[str]: """Den Bericht mit dem Register abgleichen. Rückgabe: die Ziel-IDs, die gerade neu dazukamen.""" jetzt = time.time() if jetzt is None else jetzt melden = melden or _melden gaeste = {_zid(g): g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and "vmid" in g and EIGENES_ETIKETT not in (g.get("etiketten") or [])} if not gaeste: return [] # ein leerer oder kaputter Bericht löscht nie das Register with _lock: register = _lesen() if register is None: _schreiben({zid: {"name": g.get("name"), "erstmals": jetzt, "zustand": "bekannt"} for zid, g in gaeste.items()}) return [] neu = [] for zid, g in gaeste.items(): alt = register.get(zid) if alt and alt.get("name") == g.get("name"): continue register[zid] = {"name": g.get("name"), "erstmals": jetzt, "zustand": "neu"} neu.append(zid) for zid in [z for z in register if z not in gaeste]: register.pop(zid) _schreiben(register) for zid in neu: g = gaeste[zid] art = "Container" if g.get("art") == "lxc" else "VM" melden(f"Neu im Homelab: {art} {g['vmid']} „{g.get('name') or zid}“ ({_anzeigename(g)}). Ich habe ihn noch " "nicht übernommen — im Homelab-Cockpit unter „Neu entdeckt“ entscheidest du, ob der Orchestrator ihn " "aktualisieren darf.") return neu def _melden(text: str) -> None: try: announce.notify_telegram(BETREFF, text) except Exception: log.warning("entdecken: Meldung ging nicht raus", exc_info=True) def neue() -> list[dict]: """Für GET /api/homelab/neu: je neu entdecktem Gerät, was der Orchestrator darüber weiß und vorschlägt.""" register = _lesen() or {} eingang = (kanal.bericht() or {}).get("bericht") or {} host = eingang.get("host") or {} ohne_auftrag = set(host.get("nicht_gesichert") or []) liste = [] for g in eingang.get("gaeste") or []: zid = _zid(g) if isinstance(g, dict) and "vmid" in g else None if not zid or (register.get(zid) or {}).get("zustand") != "neu": continue kennung = (g.get("app") or {}).get("kennung") app = apps.app_fuer(kennung, g.get("name")) etiketten = list(g.get("etiketten") or []) liste.append({ "id": zid, "vmid": g["vmid"], "name": app.name if app else str(g.get("name") or zid), "hostname": g.get("name"), "art": "container" if g.get("art") == "lxc" else "vm", "status": g.get("status"), "erstmals": register[zid].get("erstmals"), "etiketten": etiketten, "freigegeben": bool(g.get("erlaubt")), "ausgeschlossen": "watcher-aus" in etiketten, "app": ({"kennung": kennung, "name": app.name, "quelle": app.quelle, "weg": app.weg, "adresse": apps.adresse(app, g.get("ip"))} if app else None), "community_script": "community-script" in etiketten, "ip": g.get("ip"), "ohne_sicherung": g["vmid"] in ohne_auftrag}) return liste def _markieren(zid: str) -> None: with _lock: register = _lesen() or {} if zid in register: register[zid]["zustand"] = "bekannt" _schreiben(register) def uebernehmen(zid: str, wahl: str) -> dict: """POST /api/homelab/neu/{zid}: „freigeben“, „beobachten“ oder „ausschliessen“.""" if wahl not in WAHL: return {"ok": False, "detail": "Unbekannte Wahl."} geraet = next((g for g in neue() if g["id"] == zid), None) if not geraet: return {"ok": False, "detail": "Dieses Gerät steht nicht (mehr) unter „Neu entdeckt“."} etikett, an = WAHL[wahl] schon = (wahl == "freigeben" and geraet["freigegeben"]) or (wahl == "ausschliessen" and geraet["ausgeschlossen"]) if etikett and not schon: auftrag = kanal.anlegen("etikett_setzen", {"vmid": geraet["vmid"], "etikett": etikett, "an": an}) ergebnis = kanal.warten(auftrag, ZEITLIMIT_S) if not ergebnis or ergebnis.get("status") != "fertig": grund = (ergebnis or {}).get("text") or "Der Ausführer hat nicht rechtzeitig geantwortet." return {"ok": False, "detail": f"Das Etikett ließ sich nicht setzen: {str(grund)[-300:]}"} _markieren(zid) text = {"freigeben": f"{geraet['name']} ist übernommen und für Updates freigegeben.", "beobachten": f"{geraet['name']} ist übernommen; der Orchestrator beobachtet ihn nur.", "ausschliessen": f"{geraet['name']} ist übernommen und bleibt unberührt (Etikett watcher-aus)."}[wahl] return {"ok": True, "text": text + (" Der nächste Bericht zeigt es in der Geräteliste." if etikett else "")}