331 lines
11 KiB
Python
Executable file
331 lines
11 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"))
|
||
|
||
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()
|