diff --git a/homelab-ai-bot/tools/grafana.py b/homelab-ai-bot/tools/grafana.py index 7020ffab0..c4f669cec 100644 --- a/homelab-ai-bot/tools/grafana.py +++ b/homelab-ai-bot/tools/grafana.py @@ -20,9 +20,17 @@ SATELLITE_NODES = { "trockenraum": "Trockenraum", "oelraum": "Ölraum", "heizraum": "Heizraum (ESP32)", + "oelknoten": "Ölknoten (Erdtrasse)", "garage": "Garage (Test)", } +OELKNOTEN_SENSORS = { + "raum": "Raum", + "hk_vorlauf": "HK Vorlauf", + "vl_erd_an": "Erdtrasse VL", + "rl_erd_ab": "Erdtrasse RL", +} + # Eingefrorenes ioBroker-Archiv (nur noch für Sensoren ohne esp-satellite) LEGACY_SENSOR_MAP = { "aussen": ("mqtt.0.Holzvergaser_Sensoren_6.Aussenfühler.temperature", "Außen"), @@ -99,7 +107,7 @@ TOOLS = [ SYSTEM_PROMPT_EXTRA = """Für Smart-Home-Daten (Temperaturen, Energie, Heizung) im Haus Muldenstein: - get_temperaturen: Raum-Satelliten (DB sensors, CT143 InfluxDB) — NICHT ioBroker, NICHT 192.168.178.222 - get_energie: PV, Batterie, Netz, Hausverbrauch (DB iobroker) -- get_heizung: Brenner, Puffer, Vorlauf, Holzvergaser (DB iobroker) +- get_heizung: Brenner, Puffer, Vorlauf, Holzvergaser (iobroker) + Oelknoten (sensors) - get_grafana_status: Dashboard-Übersicht und Alerts Datenquelle Temperaturen: Telegraf/MQTT auf CT143 (192.168.178.36), DB `sensors`. Grafana: https://grafana.orbitalo.net/d/satelliten-temps/satelliten-temperaturen @@ -194,6 +202,36 @@ def _query_satellite_temperatures() -> list: return lines + + +def _query_oelknoten() -> dict | None: + """Aktuelle Oelknoten-Sensoren aus DB sensors.""" + q = ( + "SELECT last(value) AS t FROM temperature " + "WHERE node='oelknoten' AND time > now() - 10m GROUP BY sensor" + ) + result = _influx_query(q, db=INFLUX_DB_SENSORS) + if not result: + return None + temps = {} + for series in result.get("results", [{}])[0].get("series", []) or []: + sensor = series.get("tags", {}).get("sensor") + vals = series.get("values", []) + if sensor and vals and vals[0][1] is not None: + temps[sensor] = float(vals[0][1]) + if not temps: + return None + st = _influx_query( + "SELECT last(value) FROM node_status WHERE node='oelknoten' AND time > now() - 10m", + db=INFLUX_DB_SENSORS, + ) + online = False + try: + online = st["results"][0]["series"][0]["values"][0][1] == 1 + except (KeyError, IndexError, TypeError): + online = bool(temps) + return {"online": online, "temperaturen": temps} + def handle_get_grafana_status(**kw): health = _api("/api/health") if not health: @@ -306,12 +344,21 @@ def handle_get_heizung(**kw): val = starts["results"][0]["series"][0]["values"][0][1] lines.append(f"Brennerstarts (24h): {val:.0f}") + oel = _query_oelknoten() + if oel: + status = "online" if oel["online"] else "offline" + lines.append(f"\nÖlknoten ({status}, DB sensors):") + for key, label in OELKNOTEN_SENSORS.items(): + if key in oel["temperaturen"]: + lines.append(f"- {label}: {oel['temperaturen'][key]:.1f} °C") + lines.append("Grafana Ölknoten: https://grafana.orbitalo.net/d/oelknoten/olknoten") + if data: lines.append("\nTemperaturen (Archiv iobroker):") for name, val in data: lines.append(f"- {name}: {val:.1f} °C") - lines.append("\nGrafana: https://grafana.orbitalo.net/d/heizung/heizung-and-puffer") + lines.append("\nGrafana Heizung: https://grafana.orbitalo.net/d/heizung/heizung-and-puffer") return "\n".join(lines) diff --git a/infra/heizung-api/pruefstand_web.py b/infra/heizung-api/pruefstand_web.py new file mode 100644 index 000000000..f09aedca0 --- /dev/null +++ b/infra/heizung-api/pruefstand_web.py @@ -0,0 +1,420 @@ +import threading, time, json, re +from urllib.request import urlopen +from urllib.parse import quote +from flask import Flask, Response, request, jsonify +import paho.mqtt.client as mqtt + +app = Flask(__name__) +d = {} +lock = threading.Lock() +last_seen = {} +sensor_ts = {} + +SENSOR_TIMEOUT = 60 +INFLUX_URL = "http://192.168.178.36:8086" +INFLUX_DB = "iobroker" +INFLUX_DB_SENSORS = "sensors" + +OELKNOTEN_SENSORS = { + "raum": "Raum", + "hk_vorlauf": "HK Vorlauf", + "vl_erd_an": "Erdtrasse VL", + "rl_erd_ab": "Erdtrasse RL", +} + +# Sensor-Rollen (ROM-ID -> Klartext). Erweiterbar sobald Zuordnung bekannt. +SENSOR_ROLLEN = { + "28BE941600000093": "Heizraum (historisiert)", +} + +# Bekannte Heiz-Measurements fuer Discovery +HEIZ_MEASUREMENTS = { + "brennerlaufzeit": "Brenner-Laufzeit pro Lauf (Minuten)", + "brennerstarts": "Brennerstart-Ereignis (1=Start)", + "brennerstatus": "Brenner an/aus (1/0)", + "brenner_heute": "Tageswerte: liter, starts, stunden", + "mqtt.0.Oelkessel.Oelkessel_VL.Vorlauf": "Oelkessel Vorlauftemperatur (C)", + "mqtt.0.Holzvergaser_Sensoren_6.temp1.temperature1": "Holzvergaser Temp 1 (C)", + "mqtt.0.Holzvergaser_Sensoren_6.temp2.temperature2": "Holzvergaser Temp 2 (C)", + "mqtt.0.heizraum.io.brenner": "Heizraum-Knoten Brenner-Signal", + "mqtt.0.heizraum.io.pumpe1": "Pumpe 1 (1/0)", + "mqtt.0.heizraum.io.pumpe2": "Pumpe 2 (1/0)", + "mqtt.0.heizraum.io.pumpe3": "Pumpe 3 (1/0)", + "mqtt.0.heizraum.io.pumpe4": "Pumpe 4 (1/0)", + "mqtt.0.heizraum.state.kessel": "Kessel gesperrt (1/0)", +} + +# ── InfluxDB Helper ───────────────────────────────────────────────────────── + +def influx_query(q, epoch="ms", db=INFLUX_DB): + url = f"{INFLUX_URL}/query?db={db}&epoch={epoch}&q={quote(q)}" + try: + with urlopen(url, timeout=8) as r: + return json.loads(r.read()) + except Exception as e: + return {"error": str(e)} + +def influx_series(raw): + """Extrahiert (columns, values) aus erster Serie, robust gegen Fehler.""" + try: + s = raw["results"][0]["series"][0] + return s.get("columns", []), s.get("values", []) + except Exception: + return [], [] + +def oelknoten_from_mqtt(x, ts, now): + """Live-Temperaturen oelknoten aus MQTT-Cache.""" + temps = {} + for sensor in OELKNOTEN_SENSORS: + topic = f"oelknoten/{sensor}" + if ts.get(topic, 0) >= now - SENSOR_TIMEOUT: + try: + temps[sensor] = round(float(x.get(topic, "")), 2) + except (TypeError, ValueError): + pass + age = now - last_seen.get("oelknoten", 0) if last_seen.get("oelknoten") else None + online = age is not None and age < 90 + if x.get("oelknoten/status") == "online": + online = True + return { + "online": online, + "status": x.get("oelknoten/status"), + "firmware": x.get("oelknoten/state/fw"), + "letzte_aktivitaet_vor_sek": round(age, 1) if age else None, + "temperaturen": temps, + } + + +def oelknoten_from_influx(): + """Fallback: frische Werte aus InfluxDB sensors (mqtt-influx-bridge).""" + raw = influx_query( + "SELECT last(value) FROM temperature WHERE node='oelknoten' AND time > now()-5m GROUP BY sensor", + db=INFLUX_DB_SENSORS, + ) + temps = {} + try: + for series in raw.get("results", [{}])[0].get("series", []) or []: + sensor = series.get("tags", {}).get("sensor") + vals = series.get("values", []) + if sensor and vals and vals[0][1] is not None: + temps[sensor] = round(float(vals[0][1]), 2) + except Exception: + pass + st = influx_series(influx_query( + "SELECT last(value) FROM node_status WHERE node='oelknoten' AND time > now()-10m", + db=INFLUX_DB_SENSORS, + )) + online = bool(temps) or (st[1] and st[1][0][1] == 1) + return { + "online": online, + "status": "online" if online else "offline", + "firmware": None, + "letzte_aktivitaet_vor_sek": None, + "temperaturen": temps, + "quelle": "influx_sensors", + } + +# ── MQTT ────────────────────────────────────────────────────────────────────── + +def on_msg(c, u, m): + topic = m.topic + val = m.payload.decode('utf-8', 'replace') + with lock: + d[topic] = val + if topic.startswith('heizraum/'): + last_seen['heizraum'] = time.time() + if topic.startswith('oelknoten/'): + last_seen['oelknoten'] = time.time() + if topic.count('/') == 1 and topic.split('/')[1] in OELKNOTEN_SENSORS: + sensor_ts[topic] = time.time() + if topic.startswith('pruefstand/'): + last_seen['pruefstand'] = time.time() + if '/sensor/' in topic: + parts = topic.split('/') + if len(parts) == 4: + sensor_ts[parts[0] + '/' + parts[2]] = time.time() + if topic.startswith('heizraum/28'): + sensor_ts[topic] = time.time() + +def mq(): + c = mqtt.Client() + c.on_message = on_msg + c.connect('192.168.178.36', 1883, 60) + c.subscribe('pruefstand/#') + c.subscribe('heizraum/#') + c.subscribe('oelknoten/#') + c.loop_forever() + +threading.Thread(target=mq, daemon=True).start() + +# ════════════════════════════════════════════════════════════════════════════ +# API +# ════════════════════════════════════════════════════════════════════════════ + +@app.route('/api/hilfe') +def api_hilfe(): + return jsonify({ + "basis_url": "http://100.109.101.12:8765", + "beschreibung": "Heizungs- und PV-Daten des Homelabs. Live via MQTT, Historie via InfluxDB.", + "endpunkte": { + "GET /api/hilfe": "Diese Uebersicht", + "GET /api/status": "System-Uebersicht (online/offline aller Knoten)", + "GET /api/heizung/now": "Live: Temperaturen, Pumpen, Brenner, Kessel (MQTT)", + "GET /api/heizung/history": "Temp-Verlauf. Params: ?hours=24&sensor=", + "GET /api/heizung/brenner": "Brenner Takt-Analyse. Params: ?days=7", + "GET /api/heizung/kessel": "Oelkessel Vorlauf-Verlauf. Params: ?hours=48", + "GET /api/pv/now": "Live PV: Ertrag heute, Netz, Verbrauch", + "GET /api/pv/history": "PV-Tageserträge. Params: ?days=14", + "GET /api/measurements": "Alle verfuegbaren InfluxDB-Measurements + Zeitraum", + "GET /api/query": "GENERISCH: beliebige read-only InfluxQL. Param: ?q=SELECT... (nur SELECT/SHOW erlaubt)", + }, + "influxdb": { + "hinweis": "Fuer eigene Analysen /api/query nutzen. Beispiel:", + "beispiel": "/api/query?q=SELECT count(value) FROM brennerstarts WHERE time > now()-7d", + "db": INFLUX_DB, + }, + "wichtig": [ + "Daten erst ab Dez 2025 / Jan 2026 (DB-Migration vom alten Raspi).", + "Aeltere Logs (Jahre) noch nicht importiert - liegen auf alter Raspi-SD-Karte.", + "Brenner-Daten (brennerlaufzeit/starts/status) enden 2026-05-18.", + "Heizraum-ROM-IDs noch ohne vollstaendige Rollenzuordnung.", + ], + }) + +@app.route('/api/status') +def api_status(): + now = time.time() + with lock: + x = dict(d) + ts = dict(sensor_ts) + ls = dict(last_seen) + hz_age = now - ls.get('heizraum', 0) if ls.get('heizraum') else None + ps_age = now - ls.get('pruefstand', 0) if ls.get('pruefstand') else None + hz_sensors = [k for k in x if k.startswith('heizraum/28') and ts.get(k, 0) >= now - SENSOR_TIMEOUT] + ok_mqtt = oelknoten_from_mqtt(x, ts, now) + ok = ok_mqtt if ok_mqtt.get("temperaturen") else oelknoten_from_influx() + ok_age = now - ls.get('oelknoten', 0) if ls.get('oelknoten') else None + return jsonify({ + "timestamp_utc": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + "heizraum_knoten": { + "online": hz_age is not None and hz_age < 90, + "letzte_aktivitaet_vor_sek": round(hz_age, 1) if hz_age else None, + "aktive_sensoren": len(hz_sensors), + }, + "oelknoten": { + "online": ok.get("online", False), + "letzte_aktivitaet_vor_sek": round(ok_age, 1) if ok_age else ok.get("letzte_aktivitaet_vor_sek"), + "aktive_sensoren": len(ok.get("temperaturen", {})), + "firmware": ok.get("firmware"), + }, + "pruefstand": { + "online": ps_age is not None and ps_age < 90, + "letzte_aktivitaet_vor_sek": round(ps_age, 1) if ps_age else None, + }, + "influxdb_erreichbar": "error" not in influx_query("SHOW DATABASES"), + "datenquellen": {"influxdb": INFLUX_URL, "mqtt": "192.168.178.36:1883"}, + }) + +@app.route('/api/heizung/now') +def api_heizung_now(): + now = time.time() + with lock: + x = dict(d) + ts = dict(sensor_ts) + ls = dict(last_seen) + sensors = {} + for k, v in x.items(): + if k.startswith('heizraum/28') and ts.get(k, 0) >= now - SENSOR_TIMEOUT: + rom = k.split('/')[1] + try: + sensors[rom] = {"temp_c": round(float(v), 2), "rolle": SENSOR_ROLLEN.get(rom, "unbekannt")} + except: + sensors[rom] = {"temp_c": v, "rolle": SENSOR_ROLLEN.get(rom, "unbekannt")} + age = now - ls.get('heizraum', 0) if ls.get('heizraum') else None + heizraum_online = age is not None and age < 90 + ok = oelknoten_from_mqtt(x, ts, now) + if not ok.get("temperaturen"): + ok = oelknoten_from_influx() + return jsonify({ + "timestamp_utc": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + "online": heizraum_online or ok.get("online", False), + "heizraum_online": heizraum_online, + "oelknoten": ok, + "temperaturen": sensors, + "pumpen": {n: x.get(f'heizraum/io/{n}') == '1' for n in ['pumpe1','pumpe2','pumpe3','pumpe4']}, + "brenner_an": x.get('heizraum/io/brenner') == '1', + "kessel_gesperrt": x.get('heizraum/state/kessel') == '1', + "hinweis": "mosquitto+mqtt-influx-bridge laufen unabhaengig vom ioBroker-Adapter mqtt.0", + }) + +@app.route('/api/heizung/history') +def api_heizung_history(): + hours = int(request.args.get('hours', 24)) + sensor = request.args.get('sensor', '28BE941600000093') + if sensor == 'all': + cols, vals = influx_series(influx_query("SHOW MEASUREMENTS")) + sns = [v[0] for v in vals if 'heizraum' in v[0] or 'Oelkessel' in v[0] or 'Holzvergaser' in v[0]] + return jsonify({"verfuegbare_sensoren": sns}) + meas = f"mqtt.0.heizraum.{sensor}" if len(sensor) >= 12 and not sensor.startswith('mqtt') else sensor + raw = influx_query(f'SELECT mean(value) FROM "{meas}" WHERE time > now()-{hours}h GROUP BY time(5m) fill(previous)') + cols, vals = influx_series(raw) + pts = [{"ts_ms": v[0], "temp_c": round(v[1], 2)} for v in vals if v[1] is not None] + return jsonify({"sensor": sensor, "measurement": meas, "zeitraum_h": hours, + "punkte": len(pts), "daten": pts[-300:]}) + +@app.route('/api/heizung/brenner') +def api_heizung_brenner(): + days = int(request.args.get('days', 7)) + out = {"zeitraum_tage": days, "timestamp_utc": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} + # Starts (Takten) + raw = influx_query(f'SELECT count(value) FROM brennerstarts WHERE time > now()-{days}d') + _, v = influx_series(raw) + out["starts_gesamt"] = v[0][1] if v else 0 + # Starts pro Tag + raw = influx_query(f'SELECT count(value) FROM brennerstarts WHERE time > now()-{days}d GROUP BY time(1d) tz(\'Europe/Berlin\')') + _, v = influx_series(raw) + out["starts_pro_tag"] = [{"ts_ms": r[0], "starts": r[1] or 0} for r in v] + # Laufzeit-Summe + raw = influx_query(f'SELECT sum(value) FROM brennerlaufzeit WHERE time > now()-{days}d') + _, v = influx_series(raw) + out["laufzeit_summe_min"] = round(v[0][1], 1) if v and v[0][1] else 0 + # Mittlere Laufzeit pro Start (Takt-Indikator) + if out["starts_gesamt"] and out["laufzeit_summe_min"]: + out["mittlere_laufzeit_pro_start_min"] = round(out["laufzeit_summe_min"] / out["starts_gesamt"], 1) + out["hinweis"] = "Kurze mittlere Laufzeit + viele Starts = Takten (ineffizient). Daten enden 2026-05-18." + return jsonify(out) + +@app.route('/api/heizung/kessel') +def api_heizung_kessel(): + hours = int(request.args.get('hours', 48)) + raw = influx_query(f'SELECT mean(value) FROM "mqtt.0.Oelkessel.Oelkessel_VL.Vorlauf" WHERE time > now()-{hours}h GROUP BY time(10m) fill(previous)') + _, v = influx_series(raw) + pts = [{"ts_ms": r[0], "vorlauf_c": round(r[1], 2)} for r in v if r[1] is not None] + temps = [p["vorlauf_c"] for p in pts] + return jsonify({ + "zeitraum_h": hours, "punkte": len(pts), + "min_c": min(temps) if temps else None, + "max_c": max(temps) if temps else None, + "daten": pts[-300:], + }) + +@app.route('/api/pv/now') +def api_pv_now(): + res = {} + for name, meas in [("ertrag_kwh_heute","mqtt.1.openWB.pv.DailyYieldKwh"), + ("netz_watt","mqtt.1.openWB.evu.W"), + ("verbrauch_wh","mqtt.1.openWB.global.WHouseConsumption")]: + _, v = influx_series(influx_query(f'SELECT last(value) FROM "{meas}"')) + res[name] = round(float(v[0][1]), 2) if v else None + nw = res.get('netz_watt') or 0 + return jsonify({"timestamp_utc": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + **res, "einspeisung": nw < 0, "netzbezug": nw > 0}) + +@app.route('/api/pv/history') +def api_pv_history(): + days = int(request.args.get('days', 14)) + raw = influx_query(f'SELECT max(value)-min(value) AS e FROM "mqtt.1.openWB.pv.DailyYieldKwh" WHERE time > now()-{days}d GROUP BY time(1d) fill(0) tz(\'Europe/Berlin\')') + _, v = influx_series(raw) + tage = [{"datum": time.strftime('%Y-%m-%d', time.gmtime(r[0]/1000)), "ertrag_kwh": round(r[1],2)} + for r in v if r[1] and r[1] > 0] + return jsonify({"zeitraum_tage": days, "gesamt_kwh": round(sum(t["ertrag_kwh"] for t in tage),2), "tage": tage}) + +@app.route('/api/measurements') +def api_measurements(): + cols, vals = influx_series(influx_query("SHOW MEASUREMENTS")) + return jsonify({"db": INFLUX_DB, "measurements": [v[0] for v in vals], + "bekannte_heizung": HEIZ_MEASUREMENTS}) + +# ── GENERISCHER read-only InfluxQL-Passthrough ────────────────────────────── +_FORBIDDEN = re.compile(r'\b(DROP|DELETE|INSERT|ALTER|CREATE|GRANT|REVOKE|UPDATE|KILL)\b', re.IGNORECASE) + +@app.route('/api/query') +def api_query(): + q = request.args.get('q', '').strip() + if not q: + return jsonify({"error": "Parameter q fehlt. Beispiel: ?q=SELECT count(value) FROM brennerstarts WHERE time > now()-7d"}), 400 + if not re.match(r'^\s*(SELECT|SHOW)\b', q, re.IGNORECASE): + return jsonify({"error": "Nur SELECT- und SHOW-Abfragen erlaubt (read-only)."}), 403 + if _FORBIDDEN.search(q): + return jsonify({"error": "Verbotenes Schluesselwort. Nur read-only."}), 403 + raw = influx_query(q) + if "error" in raw: + return jsonify(raw), 502 + return jsonify(raw) + +# ════════════════════════════════════════════════════════════════════════════ +# Web UI +# ════════════════════════════════════════════════════════════════════════════ + +CSS = """""" + +def nav(active): + p = 'active' if active == 'pruefstand' else '' + h = 'active' if active == 'heizraum' else '' + return f'' + +@app.route('/') +def pruefstand(): + now = time.time() + with lock: + x = dict(d); ts = dict(sensor_ts) + sns = {} + for k, v in x.items(): + if '/sensor/' in k: + parts = k.split('/') + if len(parts) == 4 and ts.get(parts[0]+'/'+parts[2], 0) >= now - SENSOR_TIMEOUT: + sns.setdefault(parts[2], {})[parts[3]] = v + rows = '' + for rom, s in sorted(sns.items()): + crc = s.get('crc','--'); cls = 'ok' if crc=='ok' else 'err' + rows += f'{rom[-8:]}{s.get("temp","--")} C{crc}{s.get("err","0")}x' + if not rows: rows = 'Warte auf Sensordaten...' + ins = ''.join(f'IN{i} {x.get(f"pruefstand/in/{i}","?")}' for i in range(4)) + rels = ''.join(f'Relais {i}: {x.get(f"pruefstand/relay/{i}","?")}' for i in range(2)) + st = x.get('pruefstand/status','--'); sc = '#00cc44' if st=='online' else '#cc2200' + return Response(f''' +Pruefstand{CSS}{nav("pruefstand")} +

Pruefstand

Status: {st} · {len(sns)} Sensor(en) · Refresh 4s

+{rows}
ROMTempCRCErr
+

{ins}

{rels}

''', content_type='text/html') + +@app.route('/heizraum') +def heizraum(): + now = time.time() + with lock: + x = dict(d); ts = dict(sensor_ts) + sens = {k.split('/')[1]: v for k, v in x.items() + if k.startswith('heizraum/28') and len(k.split('/'))==2 and ts.get(k,0) >= now - SENSOR_TIMEOUT} + rows = '' + for rom, temp in sorted(sens.items()): + try: cls = 'ok' if -10 < float(temp) < 110 else 'err' + except: cls = 'err' + rows += f'{rom[-8:]}{temp} C' + if not rows: rows = 'Warte auf Sensordaten...' + ios = ''.join(f'{n}: {"AN" if str(x.get(f"heizraum/io/{n}"))=="1" else "AUS"}' for n in ['pumpe1','pumpe2','pumpe3','pumpe4','brenner']) + kg = str(x.get('heizraum/state/kessel'))=='1' + ls = last_seen.get('heizraum', 0); age = now - ls if ls else 9999 + st_txt, st_col = ('unbekannt','#888') if ls==0 else (('online','#00cc44') if age<90 else ('offline','#cc2200')) + return Response(f''' +Messknoten{CSS}{nav("heizraum")} +

Messknoten (Heizraum)

Status: {st_txt} · {len(sens)} Sensor(en) · FW: {x.get("heizraum/state/fw","--")} · Refresh 4s

+{rows}
ROMTemp

{ios}

+

Kessel: {"GESPERRT" if kg else "FREIGEGEBEN"}

''', content_type='text/html') + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8765, debug=False) diff --git a/infra/hermes/README.md b/infra/hermes/README.md index a400b46fb..1ba3abe16 100644 --- a/infra/hermes/README.md +++ b/infra/hermes/README.md @@ -25,3 +25,15 @@ pct exec 151 -- systemctl restart hermes-gateway ``` `config.yaml`-Block und Env-Variablen siehe Projektdoku. + +## homelab + +Heizung/PV/Raumtemps MCP fuer Hermes (CT151). + +| Datei | Tools | +|-------|-------| +| `homelab_mcp.py` | `heizung_now`, `heizung_brenner`, `heizung_kessel`, `heizung_history`, `pv_now`, `pv_history`, `homelab_status`, `homelab_measurements`, `homelab_query`, `raumtemps` | + +Deploy analog `garage_decke_mcp.py` nach `/root/.hermes/homelab_mcp.py`, dann `systemctl restart hermes-gateway`. + +Heizungs-HTTP-API: `infra/heizung-api/pruefstand_web.py` auf pve-mu-3 (`pruefstand-web.service`, Port 8765). diff --git a/infra/hermes/homelab_mcp.py b/infra/hermes/homelab_mcp.py new file mode 100644 index 000000000..17799ae45 --- /dev/null +++ b/infra/hermes/homelab_mcp.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Homelab MCP — Heizung, PV, Raumtemps, freie InfluxQL-Abfrage. + +Eine Tür pro Datenbereich: kapselt die Homelab-HTTP-API (iobroker-Influx) und +das Raumtemp-Skript (sensors-Influx) als saubere Tools. Ersetzt das frühere +curl/terminal-Gebastel im channel_prompt. + +Quellen: +- HTTP-API: http://100.109.101.12:8765 (Heizung/PV/Status; Oelknoten via MQTT+sensors-Influx) +- raumtemps: /root/.hermes/bin/homelab-temps (sensors-Influx) +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import urllib.parse +import urllib.request + +API_BASE = os.environ.get("HOMELAB_API_BASE", "http://100.109.101.12:8765") +TEMPS_BIN = os.environ.get("HOMELAB_TEMPS_BIN", "/root/.hermes/bin/homelab-temps") +HTTP_TIMEOUT = float(os.environ.get("HOMELAB_HTTP_TIMEOUT", "15")) + + +def _api_get(path: str, params: dict | None = None) -> str: + url = API_BASE + path + if params: + url += "?" + urllib.parse.urlencode(params) + try: + with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT) as r: + raw = r.read().decode("utf-8", "replace") + except Exception as e: # noqa: BLE001 + return f"Homelab-API nicht erreichbar ({url}): {e}" + # Pretty-print falls JSON, sonst roh + try: + return json.dumps(json.loads(raw), ensure_ascii=False, indent=2) + except Exception: + return raw + + +def _int_arg(args: dict, key: str, default: int) -> int: + try: + return int(args.get(key, default)) + except (TypeError, ValueError): + return default + + +# --- Tool-Handler --------------------------------------------------------- + +def tool_heizung_now(_args: dict) -> str: + return _api_get("/api/heizung/now") + + +def tool_heizung_brenner(args: dict) -> str: + return _api_get("/api/heizung/brenner", {"days": _int_arg(args, "days", 7)}) + + +def tool_heizung_kessel(args: dict) -> str: + return _api_get("/api/heizung/kessel", {"hours": _int_arg(args, "hours", 48)}) + + +def tool_heizung_history(args: dict) -> str: + p = {"hours": _int_arg(args, "hours", 24)} + sensor = args.get("sensor") + if sensor: + p["sensor"] = str(sensor) + return _api_get("/api/heizung/history", p) + + +def tool_pv_now(_args: dict) -> str: + return _api_get("/api/pv/now") + + +def tool_pv_history(args: dict) -> str: + return _api_get("/api/pv/history", {"days": _int_arg(args, "days", 14)}) + + +def tool_status(_args: dict) -> str: + return _api_get("/api/status") + + +def tool_measurements(_args: dict) -> str: + return _api_get("/api/measurements") + + +def tool_query(args: dict) -> str: + q = str(args.get("q", "")).strip() + if not q: + return "Fehlt: q (InfluxQL-Abfrage, nur SELECT/SHOW)." + low = q.lstrip().lower() + if not (low.startswith("select") or low.startswith("show")): + return "Nur SELECT/SHOW erlaubt (read-only)." + return _api_get("/api/query", {"q": q}) + + +def tool_raumtemps(args: dict) -> str: + cmd = [TEMPS_BIN] + if args.get("timeline"): + cmd.append("--timeline") + try: + out = subprocess.run( + cmd, capture_output=True, text=True, timeout=30 + ) + return (out.stdout or out.stderr or "").strip() or "Keine Ausgabe." + except Exception as e: # noqa: BLE001 + return f"raumtemps-Skript Fehler: {e}" + + +# --- MCP-Gerüst ----------------------------------------------------------- + +TOOLS = [ + { + "name": "heizung_now", + "description": "Live-Heizung: Heizraum (Pumpen/Brenner), Oelknoten (4x DS18B20 Erdtrasse), Temperaturen. online=true wenn Heizraum ODER Oelknoten aktiv. IMMER dieses Tool, nicht curl/terminal.", + "inputSchema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "heizung_brenner", + "description": "Brenner Takt-Analyse (Starts/Laufzeit). Param days (default 7).", + "inputSchema": {"type": "object", "properties": {"days": {"type": "number", "description": "Tage zurück (default 7)"}}, "required": []}, + }, + { + "name": "heizung_kessel", + "description": "Ölkessel Vorlauf-Verlauf. Param hours (default 48).", + "inputSchema": {"type": "object", "properties": {"hours": {"type": "number", "description": "Stunden zurück (default 48)"}}, "required": []}, + }, + { + "name": "heizung_history", + "description": "Heizungs-Temperatur-Verlauf. Params hours (default 24), sensor (ROM-ID oder 'all').", + "inputSchema": {"type": "object", "properties": {"hours": {"type": "number"}, "sensor": {"type": "string"}}, "required": []}, + }, + { + "name": "pv_now", + "description": "Live-PV: Ertrag heute, Netzbezug, Verbrauch. IMMER dieses Tool, nicht curl/terminal.", + "inputSchema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "pv_history", + "description": "PV-Tageserträge. Param days (default 14).", + "inputSchema": {"type": "object", "properties": {"days": {"type": "number", "description": "Tage zurück (default 14)"}}, "required": []}, + }, + { + "name": "homelab_status", + "description": "System-Übersicht: online/offline aller Knoten.", + "inputSchema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "homelab_measurements", + "description": "Alle verfügbaren InfluxDB-Measurements + Zeitraum (für homelab_query-Planung).", + "inputSchema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "homelab_query", + "description": ( + "FREIE read-only InfluxQL-Abfrage auf die iobroker-DB (Heizung/PV/Sensoren). " + "Nur SELECT/SHOW. Für Sonderauswertungen ohne festes Tool. " + "Ersetzt das frühere curl /api/query — NIEMALS terminal/curl dafür." + ), + "inputSchema": {"type": "object", "properties": {"q": {"type": "string", "description": "InfluxQL, z.B. SELECT count(value) FROM brennerstarts WHERE time > now()-7d"}}, "required": ["q"]}, + }, + { + "name": "raumtemps", + "description": ( + "Raumtemperaturen Muldenstein (Außen, Garage, Wohnstube, Küche, ...) mit 24h-Tief/Hoch. " + "Param timeline=true für Verlauf mit Zeitstempeln. Für Garage-KÜHLDECKE-Hydraulik " + "(VL/RL/ΔT/kW) stattdessen garage_decke_live." + ), + "inputSchema": {"type": "object", "properties": {"timeline": {"type": "boolean", "description": "Verlauf mit Min/Max-Zeitstempeln"}}, "required": []}, + }, +] + +HANDLERS = { + "heizung_now": tool_heizung_now, + "heizung_brenner": tool_heizung_brenner, + "heizung_kessel": tool_heizung_kessel, + "heizung_history": tool_heizung_history, + "pv_now": tool_pv_now, + "pv_history": tool_pv_history, + "homelab_status": tool_status, + "homelab_measurements": tool_measurements, + "homelab_query": tool_query, + "raumtemps": tool_raumtemps, +} + + +def handle(req): + method = req.get("method") + rid = req.get("id") + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": rid, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "homelab", "version": "1.0.0"}, + }, + } + if method in ("notifications/initialized", "initialized"): + return None + if method == "tools/list": + return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}} + if method == "tools/call": + name = req.get("params", {}).get("name") + args = req.get("params", {}).get("arguments", {}) or {} + handler = HANDLERS.get(name) + try: + text = handler(args) if handler else f"Unbekanntes Tool: {name}" + except Exception as e: # noqa: BLE001 + text = f"Fehler: {e}" + return { + "jsonrpc": "2.0", + "id": rid, + "result": {"content": [{"type": "text", "text": text}]}, + } + if method == "ping": + return {"jsonrpc": "2.0", "id": rid, "result": {}} + return { + "jsonrpc": "2.0", + "id": rid, + "error": {"code": -32601, "message": f"Unknown: {method}"}, + } + + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except json.JSONDecodeError: + continue + try: + resp = handle(req) + except Exception as e: # noqa: BLE001 + resp = {"jsonrpc": "2.0", "id": req.get("id"), "error": {"code": -32603, "message": str(e)}} + if resp is not None: + sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/smart-home/scripts/add_oelknoten_heizung_row.py b/smart-home/scripts/add_oelknoten_heizung_row.py new file mode 100755 index 000000000..46ac70af9 --- /dev/null +++ b/smart-home/scripts/add_oelknoten_heizung_row.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Fuegt Oelknoten-Zeile am Heizung-Dashboard hinzu (idempotent).""" +import json +import subprocess + +BASE = "http://100.66.78.56:3000" +ROW_TITLE = "🛢️ Ölknoten (Erdtrasse)" +DS_UID = "ffptl25vzylfke" # InfluxDB-sensors +PANEL_IDS = [901, 902, 903, 904, 905] + + +def curl(path, method="GET", body=None): + cmd = [ + "curl", "-s", + "-u", "admin:astral66", "-X", method, f"{BASE}{path}", + ] + if body is not None: + cmd += ["-H", "Content-Type: application/json", "-d", json.dumps(body)] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + return json.loads(r.stdout) if r.stdout.startswith(("{", "[")) else r.stdout + + +def stat_panel(pid, title, sensor, x, y, w=4, h=4): + return { + "id": pid, + "type": "stat", + "title": title, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "gridPos": {"h": h, "w": w, "x": x, "y": y}, + "fieldConfig": { + "defaults": { + "unit": "celsius", + "decimals": 1, + "color": {"mode": "thresholds"}, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "blue", "value": None}, + {"color": "green", "value": 10}, + {"color": "orange", "value": 40}, + ], + }, + } + }, + "options": { + "reduceOptions": {"values": False, "calcs": ["lastNotNull"]}, + "colorMode": "background", + "graphMode": "none", + }, + "targets": [{ + "refId": "A", + "rawQuery": True, + "query": ( + f'SELECT last("value") FROM "temperature" ' + f"WHERE \"node\"='oelknoten' AND \"sensor\"='{sensor}' AND $timeFilter" + ), + }], + } + + +d = curl("/api/dashboards/uid/heizung") +dash = d["dashboard"] + +# Entferne alte Oelknoten-Zeile +remove_titles = {ROW_TITLE, "Ölknoten Raum", "Ölknoten HK VL", "Ölknoten Erd VL", "Ölknoten Erd RL", "Ölknoten Status"} +dash["panels"] = [ + p for p in dash["panels"] + if p.get("title") not in remove_titles and p.get("id") not in PANEL_IDS +] + +max_y = 0 +for p in dash["panels"]: + gp = p.get("gridPos", {}) + max_y = max(max_y, gp.get("y", 0) + gp.get("h", 0)) + +y = max_y +dash["panels"].append({ + "id": 900, + "type": "row", + "title": ROW_TITLE, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": y}, + "collapsed": False, +}) +y += 1 + +panels = [ + (901, "Status", None, 0), + (902, "Raum", "raum", 4), + (903, "HK Vorlauf", "hk_vorlauf", 8), + (904, "Erd VL", "vl_erd_an", 12), + (905, "Erd RL", "rl_erd_ab", 16), +] + +for pid, title, sensor, x in panels: + if sensor is None: + dash["panels"].append({ + "id": pid, + "type": "stat", + "title": title, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "gridPos": {"h": 4, "w": 4, "x": x, "y": y}, + "fieldConfig": { + "defaults": { + "mappings": [ + {"type": "value", "options": { + "1": {"text": "ONLINE", "color": "green", "index": 0}, + "0": {"text": "OFFLINE", "color": "red", "index": 1}, + }}, + ], + "noValue": "OFFLINE", + "color": {"mode": "thresholds"}, + "thresholds": {"mode": "absolute", "steps": [ + {"color": "red", "value": None}, + {"color": "green", "value": 1}, + ]}, + } + }, + "options": { + "reduceOptions": {"values": False, "calcs": ["lastNotNull"]}, + "colorMode": "background", + "graphMode": "none", + }, + "targets": [{ + "refId": "A", + "rawQuery": True, + "query": 'SELECT last("value") FROM "node_status" WHERE "node"=\'oelknoten\'', + }], + }) + else: + dash["panels"].append(stat_panel(pid, title, sensor, x, y)) + +dash["version"] = dash.get("version", 0) + 1 +resp = curl("/api/dashboards/db", "POST", { + "dashboard": dash, + "folderId": 0, + "overwrite": True, + "message": "Oelknoten-Zeile hinzugefuegt", +}) +print(json.dumps(resp, indent=2)[:500])