#!/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()