Projektdoku, MCP-Skript und Deploy-Hinweise als Quelle der Wahrheit in homelab-brain; CT999 und Telegraf-README verlinken darauf.
311 lines
10 KiB
Python
Executable file
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()
|