Ölknoten: Grafana-Zeile auf Heizung-Dashboard + MCP/API-Fix

Heizungs-API und Hermes-MCP liefern Ölknoten-Daten aus MQTT/sensors-Influx
statt fälschlich offline zu melden wenn nur ioBroker mqtt.0 tot ist.
This commit is contained in:
Homelab Cursor 2026-07-01 15:07:39 +02:00
parent 8c5b787b67
commit 5161f429df
5 changed files with 865 additions and 2 deletions

View file

@ -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)

View file

@ -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=<ROM|all>",
"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 = """<style>
*{box-sizing:border-box}
body{background:#111;color:#eee;font-family:monospace;padding:10px;max-width:640px;margin:0 auto}
h2{margin:8px 0 4px}
table{width:100%;border-collapse:collapse}
th{background:#1a1a2e;padding:8px 6px;text-align:left;color:#88aacc;font-size:14px}
td{padding:9px 6px;border-bottom:1px solid #222;font-size:14px}
.ok{color:#00dd55}.err{color:#ff4422}
.tag{display:inline-block;padding:6px 14px;border-radius:5px;margin:4px 4px 0 0;color:#fff;font-size:15px}
nav{display:flex;gap:8px;margin-bottom:10px}
nav a{flex:1;text-align:center;padding:12px 6px;background:#1a1a2e;color:#88ccff;
text-decoration:none;border-radius:6px;font-size:16px;border:1px solid #334}
nav a.active{background:#003366;color:#fff;border-color:#66aaff}
.tempval{font-size:26px;font-weight:bold}
.status{font-size:13px;color:#666;margin-bottom:8px}
</style>"""
def nav(active):
p = 'active' if active == 'pruefstand' else ''
h = 'active' if active == 'heizraum' else ''
return f'<nav><a href="/" class="{p}">Pruefstand</a><a href="/heizraum" class="{h}">Messknoten</a></nav>'
@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'<tr><td style="color:#888">{rom[-8:]}</td><td class="{cls} tempval">{s.get("temp","--")} C</td><td class="{cls}">{crc}</td><td style="color:#888">{s.get("err","0")}x</td></tr>'
if not rows: rows = '<tr><td colspan=4 style="color:#444">Warte auf Sensordaten...</td></tr>'
ins = ''.join(f'<span class="tag" style="background:{"#005500" if x.get(f"pruefstand/in/{i}")=="HIGH" else "#550000"}">IN{i} {x.get(f"pruefstand/in/{i}","?")}</span>' for i in range(4))
rels = ''.join(f'<span class="tag" style="background:{"#005500" if x.get(f"pruefstand/relay/{i}")=="ON" else "#222"}">Relais {i}: {x.get(f"pruefstand/relay/{i}","?")}</span>' for i in range(2))
st = x.get('pruefstand/status','--'); sc = '#00cc44' if st=='online' else '#cc2200'
return Response(f'''<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<meta http-equiv=refresh content=4><title>Pruefstand</title>{CSS}</head><body>{nav("pruefstand")}
<h2>Pruefstand</h2><p class=status>Status: <b style="color:{sc}">{st}</b> · {len(sns)} Sensor(en) · Refresh 4s</p>
<table><tr><th>ROM</th><th>Temp</th><th>CRC</th><th>Err</th></tr>{rows}</table>
<p style="margin-top:12px">{ins}</p><p>{rels}</p></body></html>''', 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'<tr><td style="color:#888">{rom[-8:]}</td><td class="{cls} tempval">{temp} C</td></tr>'
if not rows: rows = '<tr><td colspan=2 style="color:#444">Warte auf Sensordaten...</td></tr>'
ios = ''.join(f'<span class="tag" style="background:{"#005500" if str(x.get(f"heizraum/io/{n}"))=="1" else "#222"}">{n}: {"AN" if str(x.get(f"heizraum/io/{n}"))=="1" else "AUS"}</span>' 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'''<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<meta http-equiv=refresh content=4><title>Messknoten</title>{CSS}</head><body>{nav("heizraum")}
<h2>Messknoten (Heizraum)</h2><p class=status>Status: <b style="color:{st_col}">{st_txt}</b> · {len(sens)} Sensor(en) · FW: {x.get("heizraum/state/fw","--")} · Refresh 4s</p>
<table><tr><th>ROM</th><th>Temp</th></tr>{rows}</table><p style="margin-top:12px">{ios}</p>
<p><span class="tag" style="background:{"#660000" if kg else "#005500"};font-size:16px">Kessel: {"GESPERRT" if kg else "FREIGEGEBEN"}</span></p></body></html>''', content_type='text/html')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8765, debug=False)

View file

@ -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).

245
infra/hermes/homelab_mcp.py Normal file
View file

@ -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()

View file

@ -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])