homelab-brain/infra/hermes/garage_decke_mcp.py
Homelab Cursor 75efc18305 fix garage_decke MCP: Primär/Sekundär getrennt ausgeben
PWT-Grenze explizit; KD ohne Durchfluss/kW; verhindert Kreis-Vermischung.
2026-06-28 19:43:25 +02:00

331 lines
11 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"))
CIRCUIT_NOTE = (
"PWT trennt zwei Kreise hydraulisch. Primär-Durchfluss (Brunnen) ≠ Sekundär-Durchfluss (KD). "
"PWT-ΔT / PWT-Oberfläche = Wärmeübertragung am Tauscher — NICHT KD-Durchfluss "
"(Engpass: 6 Platten in Reihe, separat)."
)
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",
"",
CIRCUIT_NOTE,
"",
"══ Primärkreis (Brunnen → PWT-Primärseite) ══",
f"- VL Brunnen (vor PWT): {vals.get('vl_brunnen', 0):.1f} °C",
f"- RL Brunnen (nach PWT): {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"- Durchfluss (Annahme/Wasseruhr): {BRUNNEN_LMIN} l/min",
]
if calc["power_brunnen_w"] is not None:
lines.append(f"- Leistung Brunnen: {calc['power_brunnen_w']:.0f} W (nur Primärkreis!)")
pwt = vals.get("pwt")
if pwt is not None:
lines += [
f"- PWT-Oberfläche: {pwt:.1f} °C (Indikator Wärmeübertragung, kein KD-Durchfluss)",
]
lines += [
"",
"══ Sekundärkreis (PWT-Sekundärseite → 6× Platten Reihe) ══",
f"- VL KD (hydraulisch): {calc['kd_vl_hyd']:.1f} °C" if calc["kd_vl_hyd"] is not None else "- VL KD (hydraulisch): n/a",
f"- RL KD (hydraulisch): {calc['kd_rl_hyd']:.1f} °C" if calc["kd_rl_hyd"] is not None else "- RL KD (hydraulisch): n/a",
f"- ΔT KD: {calc['dt_kd']:.2f} K" if calc["dt_kd"] is not None else "- ΔT KD: n/a",
"- Durchfluss KD: nicht gemessen",
"- Leistung KD: nicht berechenbar (ohne l/min im Sekundärkreis)",
"",
"Rohr-Fühler (Influx-Raw, nicht direkt als VL/RL KD lesen):",
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",
"",
CIRCUIT_NOTE,
"",
"Primärkreis (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:
d = c_now["power_brunnen_w"] - c_past["power_brunnen_w"]
lines.append(
f"- Leistung Brunnen: jetzt {c_now['power_brunnen_w']:.0f} W, "
f"vor {hours:.0f}h {c_past['power_brunnen_w']:.0f} W (Δ {d:+.0f} W)"
)
lines += [
"",
"Sekundärkreis (KD — nur ΔT, kein Durchfluss):",
_delta("ΔT KD", c_now["dt_kd"], c_past["dt_kd"]),
"",
"Einzelwerte jetzt (Primär):",
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": (
"Garage-Decke Live: zwei getrennte Kreise (Primär Brunnen mit l/min/kW, "
"Sekundär KD nur ΔT ohne Durchfluss). PWT trennt hydraulisch — "
"IMMER dieses Tool, 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()