"""„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. Neue Docker-Stacks unter Arcane (Compose-Projekte, docker.py) kommen genauso dazu, mit eigenem Register (docker-bekannt.json) und nur „Übernehmen“ — Etiketten gibt es dort nicht. 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(name: str = "geraete-bekannt.json") -> Path: return einstellungen().daten_dir / name DOCKER = "docker-bekannt.json" def _lesen(name: str = "geraete-bekannt.json") -> dict | None: try: daten = json.loads(_pfad(name).read_text(encoding="utf-8")) except (OSError, ValueError): return None return daten if isinstance(daten, dict) else None def _schreiben(daten: dict, name: str = "geraete-bekannt.json") -> None: pfad = _pfad(name) 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, name: str = "geraete-bekannt.json") -> None: with _lock: register = _lesen(name) or {} if zid in register: register[zid]["zustand"] = "bekannt" _schreiben(register, name) # --- Docker-Stacks (seit 25.09.2026) ---------------------------------------------------------------------------------- def docker_gesehen(stacks: list[dict], jetzt: float | None = None, melden=None) -> list[str]: """Die Stacks aus docker.lage() mit dem Register abgleichen. Rückgabe: die IDs („docker:“), die neu sind.""" jetzt = time.time() if jetzt is None else jetzt melden = melden or _melden aktuell = {f"docker:{s['name']}": s for s in stacks if s.get("name")} if not aktuell: return [] with _lock: register = _lesen(DOCKER) if register is None: _schreiben({sid: {"erstmals": jetzt, "zustand": "bekannt"} for sid in aktuell}, DOCKER) return [] neu = [sid for sid in aktuell if sid not in register] for sid in neu: register[sid] = {"erstmals": jetzt, "zustand": "neu"} for sid in [s for s in register if s not in aktuell]: register.pop(sid) _schreiben(register, DOCKER) for sid in neu: namen = ", ".join(c["name"] for c in aktuell[sid].get("container") or []) melden(f"Neu unter Arcane: der Docker-Stack „{aktuell[sid]['name']}“ ({namen}). Im Homelab-Cockpit unter „Neu " "entdeckt“ übernehmen.") return neu def docker_abgleichen() -> None: """Nach jedem Bericht im eigenen Faden: Arcane fragen (gemerkt) und neue Stacks eintragen.""" from services.homelab import docker try: lage = docker.lage() if lage["verfuegbar"]: docker_gesehen(lage["stacks"]) except Exception: log.warning("entdecken: Docker-Abgleich gescheitert", exc_info=True) def neue_docker() -> list[dict]: """Neue Docker-Stacks für GET /api/homelab/neu.""" from services.homelab import docker register = _lesen(DOCKER) or {} offen = {sid: e for sid, e in register.items() if e.get("zustand") == "neu"} if not offen: return [] lage = docker.lage() return [{"id": f"docker:{s['name']}", "name": s["name"], "erstmals": offen[f"docker:{s['name']}"].get("erstmals"), "container": [c["name"] for c in s["container"]]} for s in lage["stacks"] if f"docker:{s['name']}" in offen] 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."} if zid.startswith("docker:"): if wahl != "beobachten" or zid not in {s["id"] for s in neue_docker()}: return {"ok": False, "detail": "Dieser Docker-Stack steht nicht (mehr) unter „Neu entdeckt“."} _markieren(zid, DOCKER) return {"ok": True, "text": f"Der Stack {zid.split(':', 1)[1]} ist übernommen."} 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 "")}