Ölknoten: Grafana-Zeile auf Heizung-Dashboard + MCP/API-Fix
Heizungs-API und Hermes-MCP liefern Ölknoten-Daten aus MQTT/sensors-Influx statt fälschlich offline zu melden wenn nur ioBroker mqtt.0 tot ist.
This commit is contained in:
parent
8c5b787b67
commit
5161f429df
5 changed files with 865 additions and 2 deletions
|
|
@ -20,9 +20,17 @@ SATELLITE_NODES = {
|
|||
"trockenraum": "Trockenraum",
|
||||
"oelraum": "Ölraum",
|
||||
"heizraum": "Heizraum (ESP32)",
|
||||
"oelknoten": "Ölknoten (Erdtrasse)",
|
||||
"garage": "Garage (Test)",
|
||||
}
|
||||
|
||||
OELKNOTEN_SENSORS = {
|
||||
"raum": "Raum",
|
||||
"hk_vorlauf": "HK Vorlauf",
|
||||
"vl_erd_an": "Erdtrasse VL",
|
||||
"rl_erd_ab": "Erdtrasse RL",
|
||||
}
|
||||
|
||||
# Eingefrorenes ioBroker-Archiv (nur noch für Sensoren ohne esp-satellite)
|
||||
LEGACY_SENSOR_MAP = {
|
||||
"aussen": ("mqtt.0.Holzvergaser_Sensoren_6.Aussenfühler.temperature", "Außen"),
|
||||
|
|
@ -99,7 +107,7 @@ TOOLS = [
|
|||
SYSTEM_PROMPT_EXTRA = """Für Smart-Home-Daten (Temperaturen, Energie, Heizung) im Haus Muldenstein:
|
||||
- get_temperaturen: Raum-Satelliten (DB sensors, CT143 InfluxDB) — NICHT ioBroker, NICHT 192.168.178.222
|
||||
- get_energie: PV, Batterie, Netz, Hausverbrauch (DB iobroker)
|
||||
- get_heizung: Brenner, Puffer, Vorlauf, Holzvergaser (DB iobroker)
|
||||
- get_heizung: Brenner, Puffer, Vorlauf, Holzvergaser (iobroker) + Oelknoten (sensors)
|
||||
- get_grafana_status: Dashboard-Übersicht und Alerts
|
||||
Datenquelle Temperaturen: Telegraf/MQTT auf CT143 (192.168.178.36), DB `sensors`.
|
||||
Grafana: https://grafana.orbitalo.net/d/satelliten-temps/satelliten-temperaturen
|
||||
|
|
@ -194,6 +202,36 @@ def _query_satellite_temperatures() -> list:
|
|||
return lines
|
||||
|
||||
|
||||
|
||||
|
||||
def _query_oelknoten() -> dict | None:
|
||||
"""Aktuelle Oelknoten-Sensoren aus DB sensors."""
|
||||
q = (
|
||||
"SELECT last(value) AS t FROM temperature "
|
||||
"WHERE node='oelknoten' AND time > now() - 10m GROUP BY sensor"
|
||||
)
|
||||
result = _influx_query(q, db=INFLUX_DB_SENSORS)
|
||||
if not result:
|
||||
return None
|
||||
temps = {}
|
||||
for series in result.get("results", [{}])[0].get("series", []) or []:
|
||||
sensor = series.get("tags", {}).get("sensor")
|
||||
vals = series.get("values", [])
|
||||
if sensor and vals and vals[0][1] is not None:
|
||||
temps[sensor] = float(vals[0][1])
|
||||
if not temps:
|
||||
return None
|
||||
st = _influx_query(
|
||||
"SELECT last(value) FROM node_status WHERE node='oelknoten' AND time > now() - 10m",
|
||||
db=INFLUX_DB_SENSORS,
|
||||
)
|
||||
online = False
|
||||
try:
|
||||
online = st["results"][0]["series"][0]["values"][0][1] == 1
|
||||
except (KeyError, IndexError, TypeError):
|
||||
online = bool(temps)
|
||||
return {"online": online, "temperaturen": temps}
|
||||
|
||||
def handle_get_grafana_status(**kw):
|
||||
health = _api("/api/health")
|
||||
if not health:
|
||||
|
|
@ -306,12 +344,21 @@ def handle_get_heizung(**kw):
|
|||
val = starts["results"][0]["series"][0]["values"][0][1]
|
||||
lines.append(f"Brennerstarts (24h): {val:.0f}")
|
||||
|
||||
oel = _query_oelknoten()
|
||||
if oel:
|
||||
status = "online" if oel["online"] else "offline"
|
||||
lines.append(f"\nÖlknoten ({status}, DB sensors):")
|
||||
for key, label in OELKNOTEN_SENSORS.items():
|
||||
if key in oel["temperaturen"]:
|
||||
lines.append(f"- {label}: {oel['temperaturen'][key]:.1f} °C")
|
||||
lines.append("Grafana Ölknoten: https://grafana.orbitalo.net/d/oelknoten/olknoten")
|
||||
|
||||
if data:
|
||||
lines.append("\nTemperaturen (Archiv iobroker):")
|
||||
for name, val in data:
|
||||
lines.append(f"- {name}: {val:.1f} °C")
|
||||
|
||||
lines.append("\nGrafana: https://grafana.orbitalo.net/d/heizung/heizung-and-puffer")
|
||||
lines.append("\nGrafana Heizung: https://grafana.orbitalo.net/d/heizung/heizung-and-puffer")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
|
|
|||
420
infra/heizung-api/pruefstand_web.py
Normal file
420
infra/heizung-api/pruefstand_web.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import threading, time, json, re
|
||||
from urllib.request import urlopen
|
||||
from urllib.parse import quote
|
||||
from flask import Flask, Response, request, jsonify
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
app = Flask(__name__)
|
||||
d = {}
|
||||
lock = threading.Lock()
|
||||
last_seen = {}
|
||||
sensor_ts = {}
|
||||
|
||||
SENSOR_TIMEOUT = 60
|
||||
INFLUX_URL = "http://192.168.178.36:8086"
|
||||
INFLUX_DB = "iobroker"
|
||||
INFLUX_DB_SENSORS = "sensors"
|
||||
|
||||
OELKNOTEN_SENSORS = {
|
||||
"raum": "Raum",
|
||||
"hk_vorlauf": "HK Vorlauf",
|
||||
"vl_erd_an": "Erdtrasse VL",
|
||||
"rl_erd_ab": "Erdtrasse RL",
|
||||
}
|
||||
|
||||
# Sensor-Rollen (ROM-ID -> Klartext). Erweiterbar sobald Zuordnung bekannt.
|
||||
SENSOR_ROLLEN = {
|
||||
"28BE941600000093": "Heizraum (historisiert)",
|
||||
}
|
||||
|
||||
# Bekannte Heiz-Measurements fuer Discovery
|
||||
HEIZ_MEASUREMENTS = {
|
||||
"brennerlaufzeit": "Brenner-Laufzeit pro Lauf (Minuten)",
|
||||
"brennerstarts": "Brennerstart-Ereignis (1=Start)",
|
||||
"brennerstatus": "Brenner an/aus (1/0)",
|
||||
"brenner_heute": "Tageswerte: liter, starts, stunden",
|
||||
"mqtt.0.Oelkessel.Oelkessel_VL.Vorlauf": "Oelkessel Vorlauftemperatur (C)",
|
||||
"mqtt.0.Holzvergaser_Sensoren_6.temp1.temperature1": "Holzvergaser Temp 1 (C)",
|
||||
"mqtt.0.Holzvergaser_Sensoren_6.temp2.temperature2": "Holzvergaser Temp 2 (C)",
|
||||
"mqtt.0.heizraum.io.brenner": "Heizraum-Knoten Brenner-Signal",
|
||||
"mqtt.0.heizraum.io.pumpe1": "Pumpe 1 (1/0)",
|
||||
"mqtt.0.heizraum.io.pumpe2": "Pumpe 2 (1/0)",
|
||||
"mqtt.0.heizraum.io.pumpe3": "Pumpe 3 (1/0)",
|
||||
"mqtt.0.heizraum.io.pumpe4": "Pumpe 4 (1/0)",
|
||||
"mqtt.0.heizraum.state.kessel": "Kessel gesperrt (1/0)",
|
||||
}
|
||||
|
||||
# ── InfluxDB Helper ─────────────────────────────────────────────────────────
|
||||
|
||||
def influx_query(q, epoch="ms", db=INFLUX_DB):
|
||||
url = f"{INFLUX_URL}/query?db={db}&epoch={epoch}&q={quote(q)}"
|
||||
try:
|
||||
with urlopen(url, timeout=8) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def influx_series(raw):
|
||||
"""Extrahiert (columns, values) aus erster Serie, robust gegen Fehler."""
|
||||
try:
|
||||
s = raw["results"][0]["series"][0]
|
||||
return s.get("columns", []), s.get("values", [])
|
||||
except Exception:
|
||||
return [], []
|
||||
|
||||
def oelknoten_from_mqtt(x, ts, now):
|
||||
"""Live-Temperaturen oelknoten aus MQTT-Cache."""
|
||||
temps = {}
|
||||
for sensor in OELKNOTEN_SENSORS:
|
||||
topic = f"oelknoten/{sensor}"
|
||||
if ts.get(topic, 0) >= now - SENSOR_TIMEOUT:
|
||||
try:
|
||||
temps[sensor] = round(float(x.get(topic, "")), 2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
age = now - last_seen.get("oelknoten", 0) if last_seen.get("oelknoten") else None
|
||||
online = age is not None and age < 90
|
||||
if x.get("oelknoten/status") == "online":
|
||||
online = True
|
||||
return {
|
||||
"online": online,
|
||||
"status": x.get("oelknoten/status"),
|
||||
"firmware": x.get("oelknoten/state/fw"),
|
||||
"letzte_aktivitaet_vor_sek": round(age, 1) if age else None,
|
||||
"temperaturen": temps,
|
||||
}
|
||||
|
||||
|
||||
def oelknoten_from_influx():
|
||||
"""Fallback: frische Werte aus InfluxDB sensors (mqtt-influx-bridge)."""
|
||||
raw = influx_query(
|
||||
"SELECT last(value) FROM temperature WHERE node='oelknoten' AND time > now()-5m GROUP BY sensor",
|
||||
db=INFLUX_DB_SENSORS,
|
||||
)
|
||||
temps = {}
|
||||
try:
|
||||
for series in raw.get("results", [{}])[0].get("series", []) or []:
|
||||
sensor = series.get("tags", {}).get("sensor")
|
||||
vals = series.get("values", [])
|
||||
if sensor and vals and vals[0][1] is not None:
|
||||
temps[sensor] = round(float(vals[0][1]), 2)
|
||||
except Exception:
|
||||
pass
|
||||
st = influx_series(influx_query(
|
||||
"SELECT last(value) FROM node_status WHERE node='oelknoten' AND time > now()-10m",
|
||||
db=INFLUX_DB_SENSORS,
|
||||
))
|
||||
online = bool(temps) or (st[1] and st[1][0][1] == 1)
|
||||
return {
|
||||
"online": online,
|
||||
"status": "online" if online else "offline",
|
||||
"firmware": None,
|
||||
"letzte_aktivitaet_vor_sek": None,
|
||||
"temperaturen": temps,
|
||||
"quelle": "influx_sensors",
|
||||
}
|
||||
|
||||
# ── MQTT ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def on_msg(c, u, m):
|
||||
topic = m.topic
|
||||
val = m.payload.decode('utf-8', 'replace')
|
||||
with lock:
|
||||
d[topic] = val
|
||||
if topic.startswith('heizraum/'):
|
||||
last_seen['heizraum'] = time.time()
|
||||
if topic.startswith('oelknoten/'):
|
||||
last_seen['oelknoten'] = time.time()
|
||||
if topic.count('/') == 1 and topic.split('/')[1] in OELKNOTEN_SENSORS:
|
||||
sensor_ts[topic] = time.time()
|
||||
if topic.startswith('pruefstand/'):
|
||||
last_seen['pruefstand'] = time.time()
|
||||
if '/sensor/' in topic:
|
||||
parts = topic.split('/')
|
||||
if len(parts) == 4:
|
||||
sensor_ts[parts[0] + '/' + parts[2]] = time.time()
|
||||
if topic.startswith('heizraum/28'):
|
||||
sensor_ts[topic] = time.time()
|
||||
|
||||
def mq():
|
||||
c = mqtt.Client()
|
||||
c.on_message = on_msg
|
||||
c.connect('192.168.178.36', 1883, 60)
|
||||
c.subscribe('pruefstand/#')
|
||||
c.subscribe('heizraum/#')
|
||||
c.subscribe('oelknoten/#')
|
||||
c.loop_forever()
|
||||
|
||||
threading.Thread(target=mq, daemon=True).start()
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# API
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@app.route('/api/hilfe')
|
||||
def api_hilfe():
|
||||
return jsonify({
|
||||
"basis_url": "http://100.109.101.12:8765",
|
||||
"beschreibung": "Heizungs- und PV-Daten des Homelabs. Live via MQTT, Historie via InfluxDB.",
|
||||
"endpunkte": {
|
||||
"GET /api/hilfe": "Diese Uebersicht",
|
||||
"GET /api/status": "System-Uebersicht (online/offline aller Knoten)",
|
||||
"GET /api/heizung/now": "Live: Temperaturen, Pumpen, Brenner, Kessel (MQTT)",
|
||||
"GET /api/heizung/history": "Temp-Verlauf. Params: ?hours=24&sensor=<ROM|all>",
|
||||
"GET /api/heizung/brenner": "Brenner Takt-Analyse. Params: ?days=7",
|
||||
"GET /api/heizung/kessel": "Oelkessel Vorlauf-Verlauf. Params: ?hours=48",
|
||||
"GET /api/pv/now": "Live PV: Ertrag heute, Netz, Verbrauch",
|
||||
"GET /api/pv/history": "PV-Tageserträge. Params: ?days=14",
|
||||
"GET /api/measurements": "Alle verfuegbaren InfluxDB-Measurements + Zeitraum",
|
||||
"GET /api/query": "GENERISCH: beliebige read-only InfluxQL. Param: ?q=SELECT... (nur SELECT/SHOW erlaubt)",
|
||||
},
|
||||
"influxdb": {
|
||||
"hinweis": "Fuer eigene Analysen /api/query nutzen. Beispiel:",
|
||||
"beispiel": "/api/query?q=SELECT count(value) FROM brennerstarts WHERE time > now()-7d",
|
||||
"db": INFLUX_DB,
|
||||
},
|
||||
"wichtig": [
|
||||
"Daten erst ab Dez 2025 / Jan 2026 (DB-Migration vom alten Raspi).",
|
||||
"Aeltere Logs (Jahre) noch nicht importiert - liegen auf alter Raspi-SD-Karte.",
|
||||
"Brenner-Daten (brennerlaufzeit/starts/status) enden 2026-05-18.",
|
||||
"Heizraum-ROM-IDs noch ohne vollstaendige Rollenzuordnung.",
|
||||
],
|
||||
})
|
||||
|
||||
@app.route('/api/status')
|
||||
def api_status():
|
||||
now = time.time()
|
||||
with lock:
|
||||
x = dict(d)
|
||||
ts = dict(sensor_ts)
|
||||
ls = dict(last_seen)
|
||||
hz_age = now - ls.get('heizraum', 0) if ls.get('heizraum') else None
|
||||
ps_age = now - ls.get('pruefstand', 0) if ls.get('pruefstand') else None
|
||||
hz_sensors = [k for k in x if k.startswith('heizraum/28') and ts.get(k, 0) >= now - SENSOR_TIMEOUT]
|
||||
ok_mqtt = oelknoten_from_mqtt(x, ts, now)
|
||||
ok = ok_mqtt if ok_mqtt.get("temperaturen") else oelknoten_from_influx()
|
||||
ok_age = now - ls.get('oelknoten', 0) if ls.get('oelknoten') else None
|
||||
return jsonify({
|
||||
"timestamp_utc": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
"heizraum_knoten": {
|
||||
"online": hz_age is not None and hz_age < 90,
|
||||
"letzte_aktivitaet_vor_sek": round(hz_age, 1) if hz_age else None,
|
||||
"aktive_sensoren": len(hz_sensors),
|
||||
},
|
||||
"oelknoten": {
|
||||
"online": ok.get("online", False),
|
||||
"letzte_aktivitaet_vor_sek": round(ok_age, 1) if ok_age else ok.get("letzte_aktivitaet_vor_sek"),
|
||||
"aktive_sensoren": len(ok.get("temperaturen", {})),
|
||||
"firmware": ok.get("firmware"),
|
||||
},
|
||||
"pruefstand": {
|
||||
"online": ps_age is not None and ps_age < 90,
|
||||
"letzte_aktivitaet_vor_sek": round(ps_age, 1) if ps_age else None,
|
||||
},
|
||||
"influxdb_erreichbar": "error" not in influx_query("SHOW DATABASES"),
|
||||
"datenquellen": {"influxdb": INFLUX_URL, "mqtt": "192.168.178.36:1883"},
|
||||
})
|
||||
|
||||
@app.route('/api/heizung/now')
|
||||
def api_heizung_now():
|
||||
now = time.time()
|
||||
with lock:
|
||||
x = dict(d)
|
||||
ts = dict(sensor_ts)
|
||||
ls = dict(last_seen)
|
||||
sensors = {}
|
||||
for k, v in x.items():
|
||||
if k.startswith('heizraum/28') and ts.get(k, 0) >= now - SENSOR_TIMEOUT:
|
||||
rom = k.split('/')[1]
|
||||
try:
|
||||
sensors[rom] = {"temp_c": round(float(v), 2), "rolle": SENSOR_ROLLEN.get(rom, "unbekannt")}
|
||||
except:
|
||||
sensors[rom] = {"temp_c": v, "rolle": SENSOR_ROLLEN.get(rom, "unbekannt")}
|
||||
age = now - ls.get('heizraum', 0) if ls.get('heizraum') else None
|
||||
heizraum_online = age is not None and age < 90
|
||||
ok = oelknoten_from_mqtt(x, ts, now)
|
||||
if not ok.get("temperaturen"):
|
||||
ok = oelknoten_from_influx()
|
||||
return jsonify({
|
||||
"timestamp_utc": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
"online": heizraum_online or ok.get("online", False),
|
||||
"heizraum_online": heizraum_online,
|
||||
"oelknoten": ok,
|
||||
"temperaturen": sensors,
|
||||
"pumpen": {n: x.get(f'heizraum/io/{n}') == '1' for n in ['pumpe1','pumpe2','pumpe3','pumpe4']},
|
||||
"brenner_an": x.get('heizraum/io/brenner') == '1',
|
||||
"kessel_gesperrt": x.get('heizraum/state/kessel') == '1',
|
||||
"hinweis": "mosquitto+mqtt-influx-bridge laufen unabhaengig vom ioBroker-Adapter mqtt.0",
|
||||
})
|
||||
|
||||
@app.route('/api/heizung/history')
|
||||
def api_heizung_history():
|
||||
hours = int(request.args.get('hours', 24))
|
||||
sensor = request.args.get('sensor', '28BE941600000093')
|
||||
if sensor == 'all':
|
||||
cols, vals = influx_series(influx_query("SHOW MEASUREMENTS"))
|
||||
sns = [v[0] for v in vals if 'heizraum' in v[0] or 'Oelkessel' in v[0] or 'Holzvergaser' in v[0]]
|
||||
return jsonify({"verfuegbare_sensoren": sns})
|
||||
meas = f"mqtt.0.heizraum.{sensor}" if len(sensor) >= 12 and not sensor.startswith('mqtt') else sensor
|
||||
raw = influx_query(f'SELECT mean(value) FROM "{meas}" WHERE time > now()-{hours}h GROUP BY time(5m) fill(previous)')
|
||||
cols, vals = influx_series(raw)
|
||||
pts = [{"ts_ms": v[0], "temp_c": round(v[1], 2)} for v in vals if v[1] is not None]
|
||||
return jsonify({"sensor": sensor, "measurement": meas, "zeitraum_h": hours,
|
||||
"punkte": len(pts), "daten": pts[-300:]})
|
||||
|
||||
@app.route('/api/heizung/brenner')
|
||||
def api_heizung_brenner():
|
||||
days = int(request.args.get('days', 7))
|
||||
out = {"zeitraum_tage": days, "timestamp_utc": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}
|
||||
# Starts (Takten)
|
||||
raw = influx_query(f'SELECT count(value) FROM brennerstarts WHERE time > now()-{days}d')
|
||||
_, v = influx_series(raw)
|
||||
out["starts_gesamt"] = v[0][1] if v else 0
|
||||
# Starts pro Tag
|
||||
raw = influx_query(f'SELECT count(value) FROM brennerstarts WHERE time > now()-{days}d GROUP BY time(1d) tz(\'Europe/Berlin\')')
|
||||
_, v = influx_series(raw)
|
||||
out["starts_pro_tag"] = [{"ts_ms": r[0], "starts": r[1] or 0} for r in v]
|
||||
# Laufzeit-Summe
|
||||
raw = influx_query(f'SELECT sum(value) FROM brennerlaufzeit WHERE time > now()-{days}d')
|
||||
_, v = influx_series(raw)
|
||||
out["laufzeit_summe_min"] = round(v[0][1], 1) if v and v[0][1] else 0
|
||||
# Mittlere Laufzeit pro Start (Takt-Indikator)
|
||||
if out["starts_gesamt"] and out["laufzeit_summe_min"]:
|
||||
out["mittlere_laufzeit_pro_start_min"] = round(out["laufzeit_summe_min"] / out["starts_gesamt"], 1)
|
||||
out["hinweis"] = "Kurze mittlere Laufzeit + viele Starts = Takten (ineffizient). Daten enden 2026-05-18."
|
||||
return jsonify(out)
|
||||
|
||||
@app.route('/api/heizung/kessel')
|
||||
def api_heizung_kessel():
|
||||
hours = int(request.args.get('hours', 48))
|
||||
raw = influx_query(f'SELECT mean(value) FROM "mqtt.0.Oelkessel.Oelkessel_VL.Vorlauf" WHERE time > now()-{hours}h GROUP BY time(10m) fill(previous)')
|
||||
_, v = influx_series(raw)
|
||||
pts = [{"ts_ms": r[0], "vorlauf_c": round(r[1], 2)} for r in v if r[1] is not None]
|
||||
temps = [p["vorlauf_c"] for p in pts]
|
||||
return jsonify({
|
||||
"zeitraum_h": hours, "punkte": len(pts),
|
||||
"min_c": min(temps) if temps else None,
|
||||
"max_c": max(temps) if temps else None,
|
||||
"daten": pts[-300:],
|
||||
})
|
||||
|
||||
@app.route('/api/pv/now')
|
||||
def api_pv_now():
|
||||
res = {}
|
||||
for name, meas in [("ertrag_kwh_heute","mqtt.1.openWB.pv.DailyYieldKwh"),
|
||||
("netz_watt","mqtt.1.openWB.evu.W"),
|
||||
("verbrauch_wh","mqtt.1.openWB.global.WHouseConsumption")]:
|
||||
_, v = influx_series(influx_query(f'SELECT last(value) FROM "{meas}"'))
|
||||
res[name] = round(float(v[0][1]), 2) if v else None
|
||||
nw = res.get('netz_watt') or 0
|
||||
return jsonify({"timestamp_utc": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
**res, "einspeisung": nw < 0, "netzbezug": nw > 0})
|
||||
|
||||
@app.route('/api/pv/history')
|
||||
def api_pv_history():
|
||||
days = int(request.args.get('days', 14))
|
||||
raw = influx_query(f'SELECT max(value)-min(value) AS e FROM "mqtt.1.openWB.pv.DailyYieldKwh" WHERE time > now()-{days}d GROUP BY time(1d) fill(0) tz(\'Europe/Berlin\')')
|
||||
_, v = influx_series(raw)
|
||||
tage = [{"datum": time.strftime('%Y-%m-%d', time.gmtime(r[0]/1000)), "ertrag_kwh": round(r[1],2)}
|
||||
for r in v if r[1] and r[1] > 0]
|
||||
return jsonify({"zeitraum_tage": days, "gesamt_kwh": round(sum(t["ertrag_kwh"] for t in tage),2), "tage": tage})
|
||||
|
||||
@app.route('/api/measurements')
|
||||
def api_measurements():
|
||||
cols, vals = influx_series(influx_query("SHOW MEASUREMENTS"))
|
||||
return jsonify({"db": INFLUX_DB, "measurements": [v[0] for v in vals],
|
||||
"bekannte_heizung": HEIZ_MEASUREMENTS})
|
||||
|
||||
# ── GENERISCHER read-only InfluxQL-Passthrough ──────────────────────────────
|
||||
_FORBIDDEN = re.compile(r'\b(DROP|DELETE|INSERT|ALTER|CREATE|GRANT|REVOKE|UPDATE|KILL)\b', re.IGNORECASE)
|
||||
|
||||
@app.route('/api/query')
|
||||
def api_query():
|
||||
q = request.args.get('q', '').strip()
|
||||
if not q:
|
||||
return jsonify({"error": "Parameter q fehlt. Beispiel: ?q=SELECT count(value) FROM brennerstarts WHERE time > now()-7d"}), 400
|
||||
if not re.match(r'^\s*(SELECT|SHOW)\b', q, re.IGNORECASE):
|
||||
return jsonify({"error": "Nur SELECT- und SHOW-Abfragen erlaubt (read-only)."}), 403
|
||||
if _FORBIDDEN.search(q):
|
||||
return jsonify({"error": "Verbotenes Schluesselwort. Nur read-only."}), 403
|
||||
raw = influx_query(q)
|
||||
if "error" in raw:
|
||||
return jsonify(raw), 502
|
||||
return jsonify(raw)
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Web UI
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CSS = """<style>
|
||||
*{box-sizing:border-box}
|
||||
body{background:#111;color:#eee;font-family:monospace;padding:10px;max-width:640px;margin:0 auto}
|
||||
h2{margin:8px 0 4px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{background:#1a1a2e;padding:8px 6px;text-align:left;color:#88aacc;font-size:14px}
|
||||
td{padding:9px 6px;border-bottom:1px solid #222;font-size:14px}
|
||||
.ok{color:#00dd55}.err{color:#ff4422}
|
||||
.tag{display:inline-block;padding:6px 14px;border-radius:5px;margin:4px 4px 0 0;color:#fff;font-size:15px}
|
||||
nav{display:flex;gap:8px;margin-bottom:10px}
|
||||
nav a{flex:1;text-align:center;padding:12px 6px;background:#1a1a2e;color:#88ccff;
|
||||
text-decoration:none;border-radius:6px;font-size:16px;border:1px solid #334}
|
||||
nav a.active{background:#003366;color:#fff;border-color:#66aaff}
|
||||
.tempval{font-size:26px;font-weight:bold}
|
||||
.status{font-size:13px;color:#666;margin-bottom:8px}
|
||||
</style>"""
|
||||
|
||||
def nav(active):
|
||||
p = 'active' if active == 'pruefstand' else ''
|
||||
h = 'active' if active == 'heizraum' else ''
|
||||
return f'<nav><a href="/" class="{p}">Pruefstand</a><a href="/heizraum" class="{h}">Messknoten</a></nav>'
|
||||
|
||||
@app.route('/')
|
||||
def pruefstand():
|
||||
now = time.time()
|
||||
with lock:
|
||||
x = dict(d); ts = dict(sensor_ts)
|
||||
sns = {}
|
||||
for k, v in x.items():
|
||||
if '/sensor/' in k:
|
||||
parts = k.split('/')
|
||||
if len(parts) == 4 and ts.get(parts[0]+'/'+parts[2], 0) >= now - SENSOR_TIMEOUT:
|
||||
sns.setdefault(parts[2], {})[parts[3]] = v
|
||||
rows = ''
|
||||
for rom, s in sorted(sns.items()):
|
||||
crc = s.get('crc','--'); cls = 'ok' if crc=='ok' else 'err'
|
||||
rows += f'<tr><td style="color:#888">{rom[-8:]}</td><td class="{cls} tempval">{s.get("temp","--")} C</td><td class="{cls}">{crc}</td><td style="color:#888">{s.get("err","0")}x</td></tr>'
|
||||
if not rows: rows = '<tr><td colspan=4 style="color:#444">Warte auf Sensordaten...</td></tr>'
|
||||
ins = ''.join(f'<span class="tag" style="background:{"#005500" if x.get(f"pruefstand/in/{i}")=="HIGH" else "#550000"}">IN{i} {x.get(f"pruefstand/in/{i}","?")}</span>' for i in range(4))
|
||||
rels = ''.join(f'<span class="tag" style="background:{"#005500" if x.get(f"pruefstand/relay/{i}")=="ON" else "#222"}">Relais {i}: {x.get(f"pruefstand/relay/{i}","?")}</span>' for i in range(2))
|
||||
st = x.get('pruefstand/status','--'); sc = '#00cc44' if st=='online' else '#cc2200'
|
||||
return Response(f'''<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv=refresh content=4><title>Pruefstand</title>{CSS}</head><body>{nav("pruefstand")}
|
||||
<h2>Pruefstand</h2><p class=status>Status: <b style="color:{sc}">{st}</b> · {len(sns)} Sensor(en) · Refresh 4s</p>
|
||||
<table><tr><th>ROM</th><th>Temp</th><th>CRC</th><th>Err</th></tr>{rows}</table>
|
||||
<p style="margin-top:12px">{ins}</p><p>{rels}</p></body></html>''', content_type='text/html')
|
||||
|
||||
@app.route('/heizraum')
|
||||
def heizraum():
|
||||
now = time.time()
|
||||
with lock:
|
||||
x = dict(d); ts = dict(sensor_ts)
|
||||
sens = {k.split('/')[1]: v for k, v in x.items()
|
||||
if k.startswith('heizraum/28') and len(k.split('/'))==2 and ts.get(k,0) >= now - SENSOR_TIMEOUT}
|
||||
rows = ''
|
||||
for rom, temp in sorted(sens.items()):
|
||||
try: cls = 'ok' if -10 < float(temp) < 110 else 'err'
|
||||
except: cls = 'err'
|
||||
rows += f'<tr><td style="color:#888">{rom[-8:]}</td><td class="{cls} tempval">{temp} C</td></tr>'
|
||||
if not rows: rows = '<tr><td colspan=2 style="color:#444">Warte auf Sensordaten...</td></tr>'
|
||||
ios = ''.join(f'<span class="tag" style="background:{"#005500" if str(x.get(f"heizraum/io/{n}"))=="1" else "#222"}">{n}: {"AN" if str(x.get(f"heizraum/io/{n}"))=="1" else "AUS"}</span>' for n in ['pumpe1','pumpe2','pumpe3','pumpe4','brenner'])
|
||||
kg = str(x.get('heizraum/state/kessel'))=='1'
|
||||
ls = last_seen.get('heizraum', 0); age = now - ls if ls else 9999
|
||||
st_txt, st_col = ('unbekannt','#888') if ls==0 else (('online','#00cc44') if age<90 else ('offline','#cc2200'))
|
||||
return Response(f'''<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv=refresh content=4><title>Messknoten</title>{CSS}</head><body>{nav("heizraum")}
|
||||
<h2>Messknoten (Heizraum)</h2><p class=status>Status: <b style="color:{st_col}">{st_txt}</b> · {len(sens)} Sensor(en) · FW: {x.get("heizraum/state/fw","--")} · Refresh 4s</p>
|
||||
<table><tr><th>ROM</th><th>Temp</th></tr>{rows}</table><p style="margin-top:12px">{ios}</p>
|
||||
<p><span class="tag" style="background:{"#660000" if kg else "#005500"};font-size:16px">Kessel: {"GESPERRT" if kg else "FREIGEGEBEN"}</span></p></body></html>''', content_type='text/html')
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=8765, debug=False)
|
||||
|
|
@ -25,3 +25,15 @@ pct exec 151 -- systemctl restart hermes-gateway
|
|||
```
|
||||
|
||||
`config.yaml`-Block und Env-Variablen siehe Projektdoku.
|
||||
|
||||
## homelab
|
||||
|
||||
Heizung/PV/Raumtemps MCP fuer Hermes (CT151).
|
||||
|
||||
| Datei | Tools |
|
||||
|-------|-------|
|
||||
| `homelab_mcp.py` | `heizung_now`, `heizung_brenner`, `heizung_kessel`, `heizung_history`, `pv_now`, `pv_history`, `homelab_status`, `homelab_measurements`, `homelab_query`, `raumtemps` |
|
||||
|
||||
Deploy analog `garage_decke_mcp.py` nach `/root/.hermes/homelab_mcp.py`, dann `systemctl restart hermes-gateway`.
|
||||
|
||||
Heizungs-HTTP-API: `infra/heizung-api/pruefstand_web.py` auf pve-mu-3 (`pruefstand-web.service`, Port 8765).
|
||||
|
|
|
|||
245
infra/hermes/homelab_mcp.py
Normal file
245
infra/hermes/homelab_mcp.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Homelab MCP — Heizung, PV, Raumtemps, freie InfluxQL-Abfrage.
|
||||
|
||||
Eine Tür pro Datenbereich: kapselt die Homelab-HTTP-API (iobroker-Influx) und
|
||||
das Raumtemp-Skript (sensors-Influx) als saubere Tools. Ersetzt das frühere
|
||||
curl/terminal-Gebastel im channel_prompt.
|
||||
|
||||
Quellen:
|
||||
- HTTP-API: http://100.109.101.12:8765 (Heizung/PV/Status; Oelknoten via MQTT+sensors-Influx)
|
||||
- raumtemps: /root/.hermes/bin/homelab-temps (sensors-Influx)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
API_BASE = os.environ.get("HOMELAB_API_BASE", "http://100.109.101.12:8765")
|
||||
TEMPS_BIN = os.environ.get("HOMELAB_TEMPS_BIN", "/root/.hermes/bin/homelab-temps")
|
||||
HTTP_TIMEOUT = float(os.environ.get("HOMELAB_HTTP_TIMEOUT", "15"))
|
||||
|
||||
|
||||
def _api_get(path: str, params: dict | None = None) -> str:
|
||||
url = API_BASE + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT) as r:
|
||||
raw = r.read().decode("utf-8", "replace")
|
||||
except Exception as e: # noqa: BLE001
|
||||
return f"Homelab-API nicht erreichbar ({url}): {e}"
|
||||
# Pretty-print falls JSON, sonst roh
|
||||
try:
|
||||
return json.dumps(json.loads(raw), ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
return raw
|
||||
|
||||
|
||||
def _int_arg(args: dict, key: str, default: int) -> int:
|
||||
try:
|
||||
return int(args.get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
# --- Tool-Handler ---------------------------------------------------------
|
||||
|
||||
def tool_heizung_now(_args: dict) -> str:
|
||||
return _api_get("/api/heizung/now")
|
||||
|
||||
|
||||
def tool_heizung_brenner(args: dict) -> str:
|
||||
return _api_get("/api/heizung/brenner", {"days": _int_arg(args, "days", 7)})
|
||||
|
||||
|
||||
def tool_heizung_kessel(args: dict) -> str:
|
||||
return _api_get("/api/heizung/kessel", {"hours": _int_arg(args, "hours", 48)})
|
||||
|
||||
|
||||
def tool_heizung_history(args: dict) -> str:
|
||||
p = {"hours": _int_arg(args, "hours", 24)}
|
||||
sensor = args.get("sensor")
|
||||
if sensor:
|
||||
p["sensor"] = str(sensor)
|
||||
return _api_get("/api/heizung/history", p)
|
||||
|
||||
|
||||
def tool_pv_now(_args: dict) -> str:
|
||||
return _api_get("/api/pv/now")
|
||||
|
||||
|
||||
def tool_pv_history(args: dict) -> str:
|
||||
return _api_get("/api/pv/history", {"days": _int_arg(args, "days", 14)})
|
||||
|
||||
|
||||
def tool_status(_args: dict) -> str:
|
||||
return _api_get("/api/status")
|
||||
|
||||
|
||||
def tool_measurements(_args: dict) -> str:
|
||||
return _api_get("/api/measurements")
|
||||
|
||||
|
||||
def tool_query(args: dict) -> str:
|
||||
q = str(args.get("q", "")).strip()
|
||||
if not q:
|
||||
return "Fehlt: q (InfluxQL-Abfrage, nur SELECT/SHOW)."
|
||||
low = q.lstrip().lower()
|
||||
if not (low.startswith("select") or low.startswith("show")):
|
||||
return "Nur SELECT/SHOW erlaubt (read-only)."
|
||||
return _api_get("/api/query", {"q": q})
|
||||
|
||||
|
||||
def tool_raumtemps(args: dict) -> str:
|
||||
cmd = [TEMPS_BIN]
|
||||
if args.get("timeline"):
|
||||
cmd.append("--timeline")
|
||||
try:
|
||||
out = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=30
|
||||
)
|
||||
return (out.stdout or out.stderr or "").strip() or "Keine Ausgabe."
|
||||
except Exception as e: # noqa: BLE001
|
||||
return f"raumtemps-Skript Fehler: {e}"
|
||||
|
||||
|
||||
# --- MCP-Gerüst -----------------------------------------------------------
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "heizung_now",
|
||||
"description": "Live-Heizung: Heizraum (Pumpen/Brenner), Oelknoten (4x DS18B20 Erdtrasse), Temperaturen. online=true wenn Heizraum ODER Oelknoten aktiv. IMMER dieses Tool, nicht curl/terminal.",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
{
|
||||
"name": "heizung_brenner",
|
||||
"description": "Brenner Takt-Analyse (Starts/Laufzeit). Param days (default 7).",
|
||||
"inputSchema": {"type": "object", "properties": {"days": {"type": "number", "description": "Tage zurück (default 7)"}}, "required": []},
|
||||
},
|
||||
{
|
||||
"name": "heizung_kessel",
|
||||
"description": "Ölkessel Vorlauf-Verlauf. Param hours (default 48).",
|
||||
"inputSchema": {"type": "object", "properties": {"hours": {"type": "number", "description": "Stunden zurück (default 48)"}}, "required": []},
|
||||
},
|
||||
{
|
||||
"name": "heizung_history",
|
||||
"description": "Heizungs-Temperatur-Verlauf. Params hours (default 24), sensor (ROM-ID oder 'all').",
|
||||
"inputSchema": {"type": "object", "properties": {"hours": {"type": "number"}, "sensor": {"type": "string"}}, "required": []},
|
||||
},
|
||||
{
|
||||
"name": "pv_now",
|
||||
"description": "Live-PV: Ertrag heute, Netzbezug, Verbrauch. IMMER dieses Tool, nicht curl/terminal.",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
{
|
||||
"name": "pv_history",
|
||||
"description": "PV-Tageserträge. Param days (default 14).",
|
||||
"inputSchema": {"type": "object", "properties": {"days": {"type": "number", "description": "Tage zurück (default 14)"}}, "required": []},
|
||||
},
|
||||
{
|
||||
"name": "homelab_status",
|
||||
"description": "System-Übersicht: online/offline aller Knoten.",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
{
|
||||
"name": "homelab_measurements",
|
||||
"description": "Alle verfügbaren InfluxDB-Measurements + Zeitraum (für homelab_query-Planung).",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
{
|
||||
"name": "homelab_query",
|
||||
"description": (
|
||||
"FREIE read-only InfluxQL-Abfrage auf die iobroker-DB (Heizung/PV/Sensoren). "
|
||||
"Nur SELECT/SHOW. Für Sonderauswertungen ohne festes Tool. "
|
||||
"Ersetzt das frühere curl /api/query — NIEMALS terminal/curl dafür."
|
||||
),
|
||||
"inputSchema": {"type": "object", "properties": {"q": {"type": "string", "description": "InfluxQL, z.B. SELECT count(value) FROM brennerstarts WHERE time > now()-7d"}}, "required": ["q"]},
|
||||
},
|
||||
{
|
||||
"name": "raumtemps",
|
||||
"description": (
|
||||
"Raumtemperaturen Muldenstein (Außen, Garage, Wohnstube, Küche, ...) mit 24h-Tief/Hoch. "
|
||||
"Param timeline=true für Verlauf mit Zeitstempeln. Für Garage-KÜHLDECKE-Hydraulik "
|
||||
"(VL/RL/ΔT/kW) stattdessen garage_decke_live."
|
||||
),
|
||||
"inputSchema": {"type": "object", "properties": {"timeline": {"type": "boolean", "description": "Verlauf mit Min/Max-Zeitstempeln"}}, "required": []},
|
||||
},
|
||||
]
|
||||
|
||||
HANDLERS = {
|
||||
"heizung_now": tool_heizung_now,
|
||||
"heizung_brenner": tool_heizung_brenner,
|
||||
"heizung_kessel": tool_heizung_kessel,
|
||||
"heizung_history": tool_heizung_history,
|
||||
"pv_now": tool_pv_now,
|
||||
"pv_history": tool_pv_history,
|
||||
"homelab_status": tool_status,
|
||||
"homelab_measurements": tool_measurements,
|
||||
"homelab_query": tool_query,
|
||||
"raumtemps": tool_raumtemps,
|
||||
}
|
||||
|
||||
|
||||
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": "homelab", "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: # noqa: BLE001
|
||||
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: # noqa: BLE001
|
||||
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()
|
||||
139
smart-home/scripts/add_oelknoten_heizung_row.py
Executable file
139
smart-home/scripts/add_oelknoten_heizung_row.py
Executable file
|
|
@ -0,0 +1,139 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fuegt Oelknoten-Zeile am Heizung-Dashboard hinzu (idempotent)."""
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
BASE = "http://100.66.78.56:3000"
|
||||
ROW_TITLE = "🛢️ Ölknoten (Erdtrasse)"
|
||||
DS_UID = "ffptl25vzylfke" # InfluxDB-sensors
|
||||
PANEL_IDS = [901, 902, 903, 904, 905]
|
||||
|
||||
|
||||
def curl(path, method="GET", body=None):
|
||||
cmd = [
|
||||
"curl", "-s",
|
||||
"-u", "admin:astral66", "-X", method, f"{BASE}{path}",
|
||||
]
|
||||
if body is not None:
|
||||
cmd += ["-H", "Content-Type: application/json", "-d", json.dumps(body)]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
return json.loads(r.stdout) if r.stdout.startswith(("{", "[")) else r.stdout
|
||||
|
||||
|
||||
def stat_panel(pid, title, sensor, x, y, w=4, h=4):
|
||||
return {
|
||||
"id": pid,
|
||||
"type": "stat",
|
||||
"title": title,
|
||||
"datasource": {"type": "influxdb", "uid": DS_UID},
|
||||
"gridPos": {"h": h, "w": w, "x": x, "y": y},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "celsius",
|
||||
"decimals": 1,
|
||||
"color": {"mode": "thresholds"},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{"color": "blue", "value": None},
|
||||
{"color": "green", "value": 10},
|
||||
{"color": "orange", "value": 40},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {"values": False, "calcs": ["lastNotNull"]},
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
},
|
||||
"targets": [{
|
||||
"refId": "A",
|
||||
"rawQuery": True,
|
||||
"query": (
|
||||
f'SELECT last("value") FROM "temperature" '
|
||||
f"WHERE \"node\"='oelknoten' AND \"sensor\"='{sensor}' AND $timeFilter"
|
||||
),
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
d = curl("/api/dashboards/uid/heizung")
|
||||
dash = d["dashboard"]
|
||||
|
||||
# Entferne alte Oelknoten-Zeile
|
||||
remove_titles = {ROW_TITLE, "Ölknoten Raum", "Ölknoten HK VL", "Ölknoten Erd VL", "Ölknoten Erd RL", "Ölknoten Status"}
|
||||
dash["panels"] = [
|
||||
p for p in dash["panels"]
|
||||
if p.get("title") not in remove_titles and p.get("id") not in PANEL_IDS
|
||||
]
|
||||
|
||||
max_y = 0
|
||||
for p in dash["panels"]:
|
||||
gp = p.get("gridPos", {})
|
||||
max_y = max(max_y, gp.get("y", 0) + gp.get("h", 0))
|
||||
|
||||
y = max_y
|
||||
dash["panels"].append({
|
||||
"id": 900,
|
||||
"type": "row",
|
||||
"title": ROW_TITLE,
|
||||
"gridPos": {"h": 1, "w": 24, "x": 0, "y": y},
|
||||
"collapsed": False,
|
||||
})
|
||||
y += 1
|
||||
|
||||
panels = [
|
||||
(901, "Status", None, 0),
|
||||
(902, "Raum", "raum", 4),
|
||||
(903, "HK Vorlauf", "hk_vorlauf", 8),
|
||||
(904, "Erd VL", "vl_erd_an", 12),
|
||||
(905, "Erd RL", "rl_erd_ab", 16),
|
||||
]
|
||||
|
||||
for pid, title, sensor, x in panels:
|
||||
if sensor is None:
|
||||
dash["panels"].append({
|
||||
"id": pid,
|
||||
"type": "stat",
|
||||
"title": title,
|
||||
"datasource": {"type": "influxdb", "uid": DS_UID},
|
||||
"gridPos": {"h": 4, "w": 4, "x": x, "y": y},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{"type": "value", "options": {
|
||||
"1": {"text": "ONLINE", "color": "green", "index": 0},
|
||||
"0": {"text": "OFFLINE", "color": "red", "index": 1},
|
||||
}},
|
||||
],
|
||||
"noValue": "OFFLINE",
|
||||
"color": {"mode": "thresholds"},
|
||||
"thresholds": {"mode": "absolute", "steps": [
|
||||
{"color": "red", "value": None},
|
||||
{"color": "green", "value": 1},
|
||||
]},
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {"values": False, "calcs": ["lastNotNull"]},
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
},
|
||||
"targets": [{
|
||||
"refId": "A",
|
||||
"rawQuery": True,
|
||||
"query": 'SELECT last("value") FROM "node_status" WHERE "node"=\'oelknoten\'',
|
||||
}],
|
||||
})
|
||||
else:
|
||||
dash["panels"].append(stat_panel(pid, title, sensor, x, y))
|
||||
|
||||
dash["version"] = dash.get("version", 0) + 1
|
||||
resp = curl("/api/dashboards/db", "POST", {
|
||||
"dashboard": dash,
|
||||
"folderId": 0,
|
||||
"overwrite": True,
|
||||
"message": "Oelknoten-Zeile hinzugefuegt",
|
||||
})
|
||||
print(json.dumps(resp, indent=2)[:500])
|
||||
Loading…
Add table
Reference in a new issue