homelab-brain/infra/hermes/garage_decke_mcp.py
Homelab Cursor 48852c8665 Garage-Decke Doku und Hermes-MCP ins Git versionieren.
Projektdoku, MCP-Skript und Deploy-Hinweise als Quelle der Wahrheit in homelab-brain; CT999 und Telegraf-README verlinken darauf.
2026-06-28 17:05:03 +02:00

311 lines
10 KiB
Python
Executable file

#!/usr/bin/env python3
"""Garage-Decke MCP — Brunnen/PWT/Kältedecke aus InfluxDB CT143 (DB sensors).
Feste Quelle: node=garage_decke, sensor-Tags (kein ioBroker, kein Raten).
Grafana-Board: https://grafana.orbitalo.net/d/garage_decke/garage-decke
"""
from __future__ import annotations
import json
import os
import sys
import urllib.parse
import urllib.request
HOSTS = [
h.strip()
for h in os.environ.get(
"INFLUX_HOSTS",
"http://192.168.178.36:8086,http://100.66.78.56:8086",
).split(",")
if h.strip()
]
DB = "sensors"
NODE = "garage_decke"
# Aktuelle Sensor-Tags (legacy decke_1..5 / reserve ignorieren)
SENSORS = {
"rl_klimadecke": "RL Klimadecke (Rohr idx0)",
"vl_kaeltedecke": "VL Kältedecke (Rohr idx2)",
"pwt": "PWT-Oberfläche",
"vl_brunnen": "VL Brunnen (vor PWT)",
"rl_brunnen": "RL Brunnen (nach PWT)",
}
KD_FLUSSRICHTUNG = int(os.environ.get("KD_FLUSSRICHTUNG", "-1"))
BRUNNEN_LMIN = float(os.environ.get("BRUNNEN_DURCHFLUSS", "5.27"))
def _query(q: str) -> dict:
last_err = None
for host in HOSTS:
try:
url = host + "/query?" + urllib.parse.urlencode({"db": DB, "q": q})
with urllib.request.urlopen(url, timeout=8) as r:
return json.load(r)
except Exception as e: # noqa: BLE001
last_err = e
raise RuntimeError(f"InfluxDB CT143 nicht erreichbar: {last_err}")
def _last_by_sensor(within: str = "1h") -> dict[str, float]:
q = (
f'SELECT last("value") FROM "temperature" '
f'WHERE "node"=\'{NODE}\' AND time > now() - {within} GROUP BY "sensor"'
)
data = _query(q)
out: dict[str, float] = {}
for s in data.get("results", [{}])[0].get("series", []) or []:
tag = s.get("tags", {}).get("sensor")
vals = s.get("values", [])
if tag in SENSORS and vals and vals[0][1] is not None:
out[tag] = float(vals[0][1])
return out
def _node_last(node: str, within: str = "1h") -> float | None:
q = (
f'SELECT last("value") FROM "temperature" '
f'WHERE "node"=\'{node}\' AND time > now() - {within}'
)
data = _query(q)
series = data.get("results", [{}])[0].get("series")
if not series:
return None
v = series[0].get("values", [[None, None]])[0][1]
return float(v) if v is not None else None
def _mean_sensor_at_hours_ago(sensor: str, hours: float) -> float | None:
center_min = int(hours * 60)
q = (
f'SELECT mean("value") FROM "temperature" '
f'WHERE "node"=\'{NODE}\' AND "sensor"=\'{sensor}\' '
f"AND time > now() - {center_min + 30}m AND time < now() - {max(center_min - 30, 0)}m"
)
data = _query(q)
series = data.get("results", [{}])[0].get("series")
if not series:
return None
v = series[0].get("values", [[None, None]])[0][1]
return float(v) if v is not None else None
def _hyd_kd(rl_raw: float, vl_raw: float, direction: int = KD_FLUSSRICHTUNG):
a, b = rl_raw, vl_raw
rl_h = (a + b - direction * (b - a)) / 2
vl_h = (a + b + direction * (b - a)) / 2
return vl_h, rl_h, rl_h - vl_h
def _calc(vals: dict[str, float]) -> dict:
vb = vals.get("vl_brunnen")
rb = vals.get("rl_brunnen")
dt_b = (rb - vb) if vb is not None and rb is not None else None
power_w = BRUNNEN_LMIN * dt_b * 69.8 if dt_b is not None else None
kd_vl = kd_rl = dt_kd = None
if "rl_klimadecke" in vals and "vl_kaeltedecke" in vals:
kd_vl, kd_rl, dt_kd = _hyd_kd(vals["rl_klimadecke"], vals["vl_kaeltedecke"])
return {
"dt_brunnen": dt_b,
"power_brunnen_w": power_w,
"kd_vl_hyd": kd_vl,
"kd_rl_hyd": kd_rl,
"dt_kd": dt_kd,
}
def tool_live(_args: dict) -> str:
vals = _last_by_sensor()
missing = [k for k in SENSORS if k not in vals]
if len(vals) < 3:
return (
f"Zu wenig Daten (node={NODE}, DB={DB}). "
f"Fehlend: {', '.join(missing) or 'unbekannt'}. "
"Nicht ioBroker — nur Influx CT143 sensors."
)
calc = _calc(vals)
garage = _node_last("garage")
aussen = _node_last("aussen") or _node_last("oelraum")
lines = [
"Garage Decke — Live (InfluxDB sensors, node=garage_decke)",
f"KD Flussrichtung: {KD_FLUSSRICHTUNG} | Brunnen angenommen: {BRUNNEN_LMIN} l/min",
"",
"Brunnenkreis (PWT):",
f"- VL Brunnen: {vals.get('vl_brunnen', 0):.1f} °C",
f"- RL Brunnen: {vals.get('rl_brunnen', 0):.1f} °C",
f"- ΔT Brunnen: {calc['dt_brunnen']:.2f} K" if calc["dt_brunnen"] is not None else "- ΔT Brunnen: n/a",
f"- PWT-Oberfläche: {vals.get('pwt', 0):.1f} °C",
]
if calc["power_brunnen_w"] is not None:
lines.append(f"- Leistung Brunnen: {calc['power_brunnen_w']:.0f} W")
lines += [
"",
"Kältedecke (hydraulisch, per kd_flussrichtung):",
f"- VL KD: {calc['kd_vl_hyd']:.1f} °C" if calc["kd_vl_hyd"] is not None else "- VL KD: n/a",
f"- RL KD: {calc['kd_rl_hyd']:.1f} °C" if calc["kd_rl_hyd"] is not None else "- RL KD: n/a",
f"- ΔT KD: {calc['dt_kd']:.2f} K" if calc["dt_kd"] is not None else "- ΔT KD: n/a",
"",
"Rohr-Fühler (Influx-Raw):",
f"- rl_klimadecke: {vals.get('rl_klimadecke', 0):.1f} °C",
f"- vl_kaeltedecke: {vals.get('vl_kaeltedecke', 0):.1f} °C",
]
ctx = []
if garage is not None:
ctx.append(f"Garage-Raum: {garage:.1f} °C")
if aussen is not None:
ctx.append(f"Außen: {aussen:.1f} °C")
if ctx:
lines += ["", "Kontext:", *[f"- {c}" for c in ctx]]
if missing:
lines += ["", f"Hinweis fehlende Tags: {', '.join(missing)}"]
return "\n".join(lines)
def tool_compare(args: dict) -> str:
hours = float(args.get("hours", 24))
now = _last_by_sensor(within="30m")
if not now.get("vl_brunnen") or not now.get("rl_brunnen"):
return "Aktuelle Brunnen-Daten fehlen — garage_decke_live zuerst prüfen."
past_vals: dict[str, float] = {}
for sensor in SENSORS:
m = _mean_sensor_at_hours_ago(sensor, hours)
if m is not None:
past_vals[sensor] = m
if len(past_vals) < 3:
return f"Keine Vergleichsdaten vor {hours:.0f} h (Influx leer?)."
c_now = _calc(now)
c_past = _calc(past_vals)
def _delta(label, a, b, unit="K"):
if a is None or b is None:
return f"- {label}: n/a"
d = a - b
return f"- {label}: jetzt {a:.2f}{unit}, vor {hours:.0f}h {b:.2f}{unit}{d:+.2f}{unit})"
lines = [
f"Garage Decke — Vergleich jetzt vs. vor ~{hours:.0f} h",
"",
"Brunnen:",
_delta("ΔT Brunnen", c_now["dt_brunnen"], c_past["dt_brunnen"]),
]
if c_now["power_brunnen_w"] is not None and c_past["power_brunnen_w"] is not None:
lines.append(
_delta("Leistung Brunnen", c_now["power_brunnen_w"], c_past["power_brunnen_w"], " W").replace("K", "W")
)
lines += [
"",
"Kältedecke:",
_delta("ΔT KD", c_now["dt_kd"], c_past["dt_kd"]),
"",
"Einzelwerte jetzt:",
f"- vl_brunnen {now.get('vl_brunnen', 0):.1f} °C, rl_brunnen {now.get('rl_brunnen', 0):.1f} °C",
f"- pwt {now.get('pwt', 0):.1f} °C",
]
return "\n".join(lines)
TOOLS = [
{
"name": "garage_decke_live",
"description": (
"Aktuelle Garage-Decke/Brunnen-Sensoren aus InfluxDB (node=garage_decke): "
"vl/rl_brunnen, PWT, Kältedecke VL/RL, ΔT, Leistung Brunnen. "
"IMMER dieses Tool nutzen — nicht curl/raten."
),
"inputSchema": {"type": "object", "properties": {}, "required": []},
},
{
"name": "garage_decke_compare",
"description": (
"Vergleicht Garage-Decke-Werte jetzt vs. vor N Stunden (default 24). "
"Keine erfundenen Vorher-Werte."
),
"inputSchema": {
"type": "object",
"properties": {"hours": {"type": "number", "description": "Stunden zurück (default 24)"}},
"required": [],
},
},
]
HANDLERS = {
"garage_decke_live": tool_live,
"garage_decke_compare": tool_compare,
}
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": "garage_decke", "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:
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:
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()