diff --git a/homelab-ai-bot/monitor.py b/homelab-ai-bot/monitor.py index af3888787..bccbdc73e 100644 --- a/homelab-ai-bot/monitor.py +++ b/homelab-ai-bot/monitor.py @@ -24,6 +24,7 @@ ALERT_COOLDOWN_SECONDS = { "default": 3600, "error_rate": 1800, "hermes": 7200, + "heizung": 7200, "backup": 21600, "mirror_sync": 21600, } @@ -102,6 +103,117 @@ def _is_host_suppressed(host: str, suppressed_hosts: set) -> bool: return False + +# Zigbee-Heizung CT152 (heizsteuerung) — erwartet online; Platzhalter ohne TRV weglassen +HEIZ_API_BASES = [ + os.environ.get("HEIZSTEUERUNG_API_BASE", "").rstrip("/"), + "http://100.105.175.123:8090", # Tailscale CT152 + "http://192.168.178.30:8090", # LAN (nur wenn erreichbar) +] +HEIZ_API_BASES = [u for u in HEIZ_API_BASES if u] +HEIZ_Z2M_URLS = [ + "http://100.105.175.123:8080", + "http://192.168.178.30:8080", +] +HEIZ_EXPECTED_ONLINE = { + "wohnstube_links", + "wohnstube_rechts", + "schlafzimmer", + "flur_unten", + "trockenraum", + "buero", + "ankleideraum_oben", + "wc_unten", +} +HEIZ_BATTERY_WARN = 20 + + +def check_heizkoerper() -> list[str]: + """Vitalfunktionen Zigbee-Heizung CT152: API, z2m, TRV-Online, Batterie.""" + alerts: list[str] = [] + headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 heizung-check)"} + + zones = None + last_err = None + api_base = None + for base in HEIZ_API_BASES: + try: + r = requests.get(base + "/zones", timeout=8, headers=headers) + if r.status_code != 200: + last_err = f"HTTP {r.status_code}" + continue + zones = r.json() + api_base = base + break + except Exception as e: # noqa: BLE001 + last_err = str(e)[:80] + if zones is None: + alerts.append( + f"🔴 Heizung: heizsteuerung API nicht erreichbar ({HEIZ_API_BASES[0]}): {last_err}" + ) + return alerts + if not isinstance(zones, list): + alerts.append("🔴 Heizung: /zones liefert unerwartete Antwort") + return alerts + + by_id = {z.get("id"): z for z in zones if isinstance(z, dict) and z.get("id")} + offline = sorted( + zid for zid in HEIZ_EXPECTED_ONLINE + if zid not in by_id or not by_id[zid].get("online") + ) + if offline: + alerts.append( + "🔴 Heizung: TRV offline (erwartet online): " + ", ".join(offline) + ) + + low_batt = [] + for zid in sorted(HEIZ_EXPECTED_ONLINE): + z = by_id.get(zid) or {} + if not z.get("online"): + continue + batt = z.get("battery") + if batt is None: + continue + try: + if float(batt) < HEIZ_BATTERY_WARN: + low_batt.append(f"{zid} {int(float(batt))}%") + except (TypeError, ValueError): + continue + if low_batt: + alerts.append( + f"⚠️ Heizung: Batterie <{HEIZ_BATTERY_WARN}%: " + ", ".join(low_batt) + ) + + # z2m Frontend — Coordinator/UI tot? + z2m_ok = False + z2m_err = None + for url in HEIZ_Z2M_URLS: + try: + r = requests.get(url, timeout=6, headers=headers, allow_redirects=True) + if r.status_code < 500: + z2m_ok = True + break + z2m_err = f"HTTP {r.status_code}" + except Exception as e: # noqa: BLE001 + z2m_err = str(e)[:80] + if not z2m_ok: + alerts.append(f"🔴 Heizung: zigbee2mqtt Frontend nicht erreichbar: {z2m_err}") + + # Abwesenheit laeuft, aber Backup leer = riskanter Zustand + try: + r = requests.get(api_base + "/abwesenheit", timeout=6, headers=headers) + if r.status_code == 200: + away = r.json() + if away.get("active") and not (away.get("backup_setpoints") or {}): + alerts.append( + "⚠️ Heizung: Abwesenheit AN ohne Backup-Sollwerte" + ) + except Exception: + pass + + return alerts + + def check_hermes() -> list[str]: """Prueft Hermes (CT151) als kritischen Dienst ueber HTTP und Loki.""" alerts = [] @@ -176,6 +288,7 @@ def check_all(state: dict | None = None) -> list[str]: suppressed_names = config.get_suppressed_container_names(cfg) alerts = [] alerts.extend(check_hermes()) + alerts.extend(check_heizkoerper()) alerts.extend(loki_client.check_wp_mirror_sync()) containers = proxmox_client.get_all_containers( @@ -426,6 +539,18 @@ def _alert_key(alert_text: str) -> str: m = re.search(r"([\w.\-]+\.service)", alert_text) unit = m.group(1) if m else "generic" return hashlib.md5(("hermes|log|" + unit).encode()).hexdigest() + if "Heizung:" in alert_text: + if "API nicht erreichbar" in alert_text: + return hashlib.md5(b"heizung|api").hexdigest() + if "zigbee2mqtt Frontend" in alert_text: + return hashlib.md5(b"heizung|z2m").hexdigest() + if "TRV offline" in alert_text: + return hashlib.md5(b"heizung|trv_offline").hexdigest() + if "Batterie" in alert_text: + return hashlib.md5(b"heizung|battery").hexdigest() + if "Abwesenheit AN ohne Backup" in alert_text: + return hashlib.md5(b"heizung|away_backup").hexdigest() + return hashlib.md5(("heizung|" + alert_text[:60]).encode()).hexdigest() return hashlib.md5(alert_text.encode()).hexdigest() @@ -450,6 +575,8 @@ def _alert_category(alert_text: str) -> str: return "restart" if "Hermes" in alert_text: return "hermes" + if "Heizung:" in alert_text: + return "heizung" if "Backup" in alert_text or "PBS" in alert_text: return "backup" if "Memory läuft ab" in alert_text: