From b61ac6636755b6180822656d371055cbe79b0df2 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 20 Apr 2026 22:31:32 +0200 Subject: [PATCH] =?UTF-8?q?docs(smart-home):=20dokumentiere=20Heizung/Bren?= =?UTF-8?q?ner=20komplett,=20alle=20Zug=C3=A4nge=20in=20homelab.conf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - smart-home/HEIZUNG.md: komplette Doku zur Ölbrenner-Erkennung (brennerstarts.py), Schwellwerte, Rekonstruktion, Dashboard-Panels, Troubleshooting - smart-home/STATE.md: klare Tabelle mit allen Dienst-URLs (public+intern) und Logins — Grafana/ioBroker/InfluxDB laufen ALLE in CT 143 auf pve-mu-3 - homelab.conf: CT_143_MU3 Beschreibung korrigiert (war "Raspi-Broker"), neue Variablen GRAFANA_URL_*/IOBROKER_URL_*/INFLUX_URL_INTERN + User/Pass (=PW_DEFAULT) damit beim nächsten Mal keine Fragen aufkommen - smart-home/scripts/: alle relevanten Skripte ins Repo: grafana_shot.js (Puppeteer-Login mit admin/astral66) add_month_panel.py (idempotente Monatskacheln im Heizung-Dashboard) brenner_rekonstruktion.py + cleanup_reconstruct.py + check_april.py patch_brenner.sh (Anpassung der Schwellwerte nach Regelkurven-Änderung) - MASTER_INDEX.md: Verweis auf HEIZUNG.md Made-with: Cursor --- MASTER_INDEX.md | 3 +- homelab.conf | 33 ++- smart-home/HEIZUNG.md | 165 ++++++++++++++ smart-home/STATE.md | 67 ++++-- smart-home/scripts/add_month_panel.py | 114 ++++++++++ smart-home/scripts/brenner_rekonstruktion.py | 215 +++++++++++++++++++ smart-home/scripts/check_april.py | 62 ++++++ smart-home/scripts/cleanup_reconstruct.py | 43 ++++ smart-home/scripts/grafana_shot.js | 59 +++++ smart-home/scripts/patch_brenner.sh | 25 +++ 10 files changed, 767 insertions(+), 19 deletions(-) create mode 100644 smart-home/HEIZUNG.md create mode 100644 smart-home/scripts/add_month_panel.py create mode 100644 smart-home/scripts/brenner_rekonstruktion.py create mode 100644 smart-home/scripts/check_april.py create mode 100644 smart-home/scripts/cleanup_reconstruct.py create mode 100644 smart-home/scripts/grafana_shot.js create mode 100644 smart-home/scripts/patch_brenner.sh diff --git a/MASTER_INDEX.md b/MASTER_INDEX.md index c6a50a5f..c2af590c 100644 --- a/MASTER_INDEX.md +++ b/MASTER_INDEX.md @@ -8,7 +8,8 @@ |---|---|---| | **Arakava News** | arakava-news/STATE.md | WordPress + RSS-Manager + KI-Artikel | | **Edelmetall Dashboard** | edelmetall/STATE.md | Gold/Silber Preisbot | -| **Smart Home** | smart-home/STATE.md | ioBroker, Grafana, MQTT, Sensoren | +| **Smart Home** | smart-home/STATE.md | ioBroker + InfluxDB + Grafana (alle in CT 143 pve-mu-3) | +| **Heizung & Ölverbrauch** | smart-home/HEIZUNG.md | Brennererkennung, Dashboard, Zugänge, Scripts | | **ESP32 Projekte** | esp32/PLAN.md | Heizungssteuerung, Sensorik | | **FünfVorAcht** | fuenfvoracht/STATE.md | Telegram KI-Poster (täglich 19:55) | | **Redakteur** | redax-wp/STATE.md | WordPress KI-Autor + DeutschlandBlog | diff --git a/homelab.conf b/homelab.conf index 8e1f1762..f9106ee0 100644 --- a/homelab.conf +++ b/homelab.conf @@ -171,7 +171,7 @@ CT_502_MU2="Test-Shop-2|—|Test Shop 2" CT_139_MU3="Syncthing-Muldenstein|—|Syncthing" CT_141_MU3="syncthing|—|Syncthing" CT_142_MU3="WG-easy|—|WireGuard VPN" -CT_143_MU3="Raspi-Broker|—|ioBroker MQTT Broker" +CT_143_MU3="smart-home|100.66.78.56|ioBroker (MQTT) + InfluxDB 1.x + Grafana 12.3.1 + brennerstarts.py — ersetzt alten Raspi, siehe smart-home/HEIZUNG.md" CT_145_MU3="flugscanner-mu|100.75.182.15|Flugpreisscanner Node DE" CT_504_MU3="projektscan-template|—|Projektscan Template" CT_600_MU3="wp-mirror|100.92.205.101|WordPress Mirror (Redundanz CT 101)" @@ -231,6 +231,37 @@ MAIL_IMAP_PORT="993" MAIL_USER="info@orbitalo.info" MAIL_PASS="Astral-66" +# --- SMART HOME / HEIZUNG (CT 143 auf pve-mu-3) --- +# Alle drei Dienste laufen im GLEICHEN Container CT 143. +# Doku: smart-home/HEIZUNG.md, smart-home/STATE.md +SMARTHOME_CT="143" +SMARTHOME_HOST="pve-mu-3" +SMARTHOME_TS="100.66.78.56" # Tailscale IP von CT 143 +SMARTHOME_LAN="192.168.178.36" # LAN IP (Muldenstein) + +# Grafana (Dashboards + Alerts) +GRAFANA_URL_PUBLIC="https://grafana.orbitalo.net" # Cloudflare Tunnel +GRAFANA_URL_INTERN="http://100.66.78.56:3000" # Tailscale +GRAFANA_USER="admin" +GRAFANA_PASS="astral66" # = PW_DEFAULT +GRAFANA_DASHBOARD_HEIZUNG="heizung" # UID → /d/heizung/ + +# ioBroker (MQTT + Smart-Home-Logik + JS-Skripte) +IOBROKER_URL_INTERN="http://100.66.78.56:8081" +IOBROKER_URL_LAN="http://192.168.178.36:8081" +IOBROKER_USER="admin" +IOBROKER_PASS="astral66" # = PW_DEFAULT + +# InfluxDB 1.x (Zeitreihen, Datenbank "iobroker", keine Auth intern) +INFLUX_URL_INTERN="http://100.66.78.56:8086" +INFLUX_DB="iobroker" + +# SSH-Zugang zum Container: +# ssh pve-mu-3 → Proxmox-Host (via ~/.ssh/config mit SOCKS5 ProxyCommand) +# pct exec 143 -- → Kommando im Container +# Grafana-Dashboard-Screenshot: +# node /tmp/grafana_shot.js (Puppeteer, loggt sich mit admin/astral66 ein) + # --- LOKI --- LOKI_URL="http://100.109.206.43:3100" LOKI_CT="110" diff --git a/smart-home/HEIZUNG.md b/smart-home/HEIZUNG.md new file mode 100644 index 00000000..49e4e159 --- /dev/null +++ b/smart-home/HEIZUNG.md @@ -0,0 +1,165 @@ +# Heizung Muldenstein — Brenner-Erkennung & Ölverbrauch + +> **Wo läuft das?** Alles in **CT 143 auf pve-mu-3** (Tailscale `100.66.78.56`, LAN `192.168.178.36`). +> **Was heißt das?** Es gibt KEINEN Raspberry Pi mehr — der alte `raspi-broker` wurde durch diesen LXC-Container ersetzt. + +--- + +## Zugänge — NICHT MEHR FRAGEN, STEHT HIER + +Alle Passwörter sind `PW_DEFAULT` aus `homelab.conf`, also **`astral66`**. + +| Dienst | URL intern (Tailscale) | URL public / LAN | Login | +|---|---|---|---| +| Grafana | http://100.66.78.56:3000 | https://grafana.orbitalo.net (Cloudflare) | `admin` / `astral66` | +| ioBroker | http://100.66.78.56:8081 | http://192.168.178.36:8081 | `admin` / `astral66` | +| InfluxDB | http://100.66.78.56:8086 | — (nur intern) | keine Auth, DB `iobroker` | + +**SSH-Zugang** (funktioniert aus dem `monitoring-bot` CT 116 heraus, wo Cursor läuft): + +```bash +ssh pve-mu-3 # Proxmox-Host (ProxyCommand via SOCKS5 Tailscale) +ssh pve-mu-3 'pct exec 143 -- ' # direkt im Container ausführen +# Beispiel: +ssh pve-mu-3 'pct exec 143 -- systemctl status brennerstarts.service' +``` + +Die SSH-Config dafür liegt in `~/.ssh/config` auf CT 116 und nutzt den Tailscale-SOCKS5-Proxy auf `127.0.0.1:1055`. + +**Grafana-Screenshots** (Puppeteer, loggt sich automatisch ein): + +```bash +node /tmp/grafana_shot.js "https://grafana.orbitalo.net/d/heizung/f09f94a5-heizung-and-puffer?kiosk" /tmp/out.png +``` + +Der Login ist im Script fest auf `admin` / `astral66` gesetzt — Script liegt in `scripts/grafana_shot.js` im Repo. + +--- + +## Hardware-Kontext + +- **Ölkessel** ohne eigenen digitalen Status-Ausgang. Daher wird "Brenner an/aus" über die + Vorlauftemperatur `mqtt.0.Oelkessel.Oelkessel_VL.Vorlauf` detektiert. +- Brenner-Rate: **1,89 L/h** Heizöl (Messwert). +- **Regelkurve wurde im April 2026 abgesenkt** → max. Vorlauf jetzt ~40 °C statt vorher ~60 °C. + Das hat die alte Erkennungslogik kaputt gemacht (Schwellwerte zu hoch). + +--- + +## Erkennungslogik: `/root/brennerstarts.py` (im CT 143) + +Python-Daemon, läuft als `brennerstarts.service` (systemd). +Pollt jede Minute die Vorlauftemperatur aus InfluxDB und schreibt `brennerstatus`, `brennerstarts`, +`brennerlaufzeit` und `brenner_heute` zurück. + +**Aktuelle Parameter (nach Regelkurven-Anpassung, Stand 2026-04-20):** + +```python +STEIGUNG_AN = 0.3 # °C über 3 Min → "Brenner zündet" +STEIGUNG_1MIN = 0.1 # °C über 1 Min → Sofort-Anstieg (zusätzlich zu AN) +STEIGUNG_AUS = -0.15 # °C über 3 Min → "Brenner aus" +MIN_TEMP_BRENNER = 30 # unter 30 °C gar keine Brennerdetektion +COOLDOWN_MINUTEN = 10 # Mindestpause zwischen zwei START-Events +BRENNER_RATE_LH = 1.89 # Liter/Stunde +``` + +**Alte Werte** (vor Regelkurven-Anpassung, falls Rollback nötig): `55 / 1.5 / 0.3 / -0.3`. +Backup der Originaldatei liegt im CT 143 unter `/root/brennerstarts.py.bak-20260420-2142`. + +**InfluxDB-Query-Timeouts** wurden von 10s auf 30s erhöht (Log zeigte gehäuft `timed out` um 04:00, +während das Backup lief). + +### Service-Kommandos + +```bash +ssh pve-mu-3 'pct exec 143 -- systemctl status brennerstarts.service' +ssh pve-mu-3 'pct exec 143 -- systemctl restart brennerstarts.service' +ssh pve-mu-3 'pct exec 143 -- journalctl -u brennerstarts.service -n 200 --no-pager' +``` + +--- + +## InfluxDB Measurements (DB `iobroker`) + +| Measurement | Bedeutung | Quelle | +|---|---|---| +| `mqtt.0.Oelkessel.Oelkessel_VL.Vorlauf` | Vorlauftemperatur °C | MQTT → ioBroker | +| `mqtt.0.Holzvergaser_Sensoren_6.Aussenfühler.temperature` | Außentemp °C | MQTT | +| `mqtt.0.Wohnstube_Temperatur_1.Wohnstube.Wohnstube_Temperatur` | Raumtemp °C | MQTT | +| `brennerstatus` | 0/1 live | `brennerstarts.py` | +| `brennerstarts` | Event pro Brennerstart (value=1) | `brennerstarts.py` | +| `brennerlaufzeit` | Sekunden kumulativ pro Intervall | `brennerstarts.py` | +| `brenner_heute` | Sekunden seit 00:00 | `brennerstarts.py` | + +Heizöl-Liter = `sum(brennerlaufzeit) / 3600 * 1.89`. + +--- + +## Grafana-Dashboard `Heizung & Puffer` (UID: `heizung`) + +URL: https://grafana.orbitalo.net/d/heizung/ + +Aktueller Aufbau (Stand 2026-04-20): + +| Zeile | Panels | +|---|---| +| Oben | Puffer Oben/Mitte/Unten · Füllgrad · Außen · Ölkessel VL · **Brenner AN/AUS** · Rücklauf | +| Row "🛢️ Ölkessel Statistik (1,89 L/h)" | Heute · Letzte 7 Tage · Letzte 30 Tage · Gesamt | +| Mitte | Temperaturverlauf (Zeitreihe) | +| Unten | 📅 Tagesverbrauch (letzte 7 Tage) als Bar Chart | +| Ganz unten | **Ölverbrauch je Heizmonat (Liter)** — farbige Kachelzeile, 1 Kachel pro Kalendermonat | + +### Monats-Kacheln pflegen / erweitern + +Wenn ein neuer Monat dazukommt, **einfach das Script nochmal laufen lassen** — +es entfernt das alte Panel und erzeugt die Kacheln neu für alle Monate ab Jan 2026 +bis einschließlich aktueller Monat: + +```bash +python3 scripts/add_month_panel.py +``` + +Idempotent. Script liegt in `scripts/add_month_panel.py` in diesem Repo. + +--- + +## Historische Daten-Rekonstruktion (einmalig, 2026-04-20) + +Weil die Erkennung zwischen **06.04. und 20.04.2026** wegen der zu hohen Schwellwerte keine +Brenner-Events geschrieben hat, wurde die Periode nachträglich rekonstruiert: + +1. **Löschen** alter/doppelter Events im Fenster + (`2026-04-06T02:00:00Z` bis `2026-04-20T19:45:00Z`) via `DELETE` auf `brennerstarts`, + `brennerstatus`, `brennerlaufzeit`. +2. **Neu berechnen** mit den aktuellen Schwellwerten aus den noch vorhandenen Rohdaten + (`Oelkessel_VL.Vorlauf`), Dry-Run zuerst, dann `--commit`. +3. **Ergebnis** (April gesamt): 52 Starts, 18,2 h Laufzeit, 34,4 L Öl. + +Scripts: + +- `scripts/brenner_rekonstruktion.py` — Rekonstruktion (Dry-Run default, `--commit` schreibt) +- `scripts/cleanup_reconstruct.py` — löscht Events im Reconstruction-Fenster +- `scripts/check_april.py` — Sanity-Check der Monatszahlen + +**Nicht nochmal laufen lassen**, außer die Logik ändert sich erneut und ein Zeitraum muss +neu berechnet werden. + +--- + +## Häufige Fragen / Troubleshooting + +**"Der Brenner scheint nicht erkannt zu werden."** +→ Vorlauftemperatur `Oelkessel_VL.Vorlauf` anschauen (Grafana „Temperaturverlauf"). Wenn die +Amplitude < 30 °C bleibt, ist `MIN_TEMP_BRENNER` zu hoch → in `brennerstarts.py` anpassen. + +**"Zahlen in 'Letzte 30 Tage' passen nicht zum Kalendermonat."** +→ Das ist gewollt: Das Panel zeigt ein rollierendes 30-Tage-Fenster (Grafana `now()-30d`). +Für Kalendermonate ist die Kachelzeile unten da. + +**"Wo sehe ich ob der Brenner gerade läuft?"** +→ Oben rechts im Dashboard: Panel `Brenner`. Rot/AN wenn `brennerstatus == 1`, grün/AUS sonst. +Datenquelle: das Measurement `brennerstatus` das `brennerstarts.py` minütlich aktualisiert. + +**"Grafana liefert keine Screenshots über die Render-API."** +→ Der native `grafana-image-renderer` ist auf dieser Installation kaputt (Plugin-Signatur-Issue). +**Immer** den Puppeteer-Wrapper `scripts/grafana_shot.js` verwenden. diff --git a/smart-home/STATE.md b/smart-home/STATE.md index e0c00f40..214360bc 100644 --- a/smart-home/STATE.md +++ b/smart-home/STATE.md @@ -1,24 +1,57 @@ # Smart Home Muldenstein — Live State -> Auto-generiert: 2026-04-17 22:00 + +> **Alles läuft in CT 143 auf pve-mu-3.** Es gibt keinen Raspberry Pi mehr. +> Für die Heizung & Brennererkennung: siehe **[HEIZUNG.md](HEIZUNG.md)**. + +## Container CT 143 — smart-home + +| Attribut | Wert | +|---|---| +| Host | `pve-mu-3` (Tailscale `100.109.101.12`) | +| Tailscale IP | `100.66.78.56` | +| LAN IP | `192.168.178.36` | +| OS | Debian LXC | + +### Dienste im Container (ein Container, drei Dienste) + +| Dienst | Port | URL public | URL intern | Login | +|---|---|---|---|---| +| **Grafana** | 3000 | https://grafana.orbitalo.net (Cloudflare Tunnel) | http://100.66.78.56:3000 | `admin` / `astral66` | +| **ioBroker** | 8081 | — | http://100.66.78.56:8081 · http://192.168.178.36:8081 | `admin` / `astral66` | +| **InfluxDB 1.x** | 8086 | — | http://100.66.78.56:8086 | keine Auth, DB `iobroker` | + +**Alle Passwörter = `PW_DEFAULT` = `astral66`** (siehe `homelab.conf`). + +## Zugang + +```bash +# aus dem monitoring-bot (CT 116 auf pve-mu-2, wo Cursor läuft): +ssh pve-mu-3 # Host +ssh pve-mu-3 'pct exec 143 -- ' # im Container +ssh pve-mu-3 'pct exec 143 -- bash' # interaktive Shell + +# Grafana-Dashboard als PNG (Puppeteer-Login mit admin/astral66): +node /tmp/grafana_shot.js "https://grafana.orbitalo.net/d/heizung/f09f94a5-heizung-and-puffer?kiosk" /tmp/out.png +``` + +## Wichtige Python-Services in CT 143 + +| Service | Datei | Zweck | +|---|---|---| +| `brennerstarts.service` | `/root/brennerstarts.py` | Ölbrenner-Erkennung per Vorlauftemperatur → InfluxDB | + +Backup der Originalskripte vor Anpassungen: `/root/*.bak-YYYYMMDD-HHMM` direkt im Container. ## Backup-Status -- Letztes Backup: 696MB, 2026-04-17 04:43 +- Letztes Backup: 696 MB, 2026-04-17 04:43 - Backups gesamt: 34 -- Ziel: /home/backup-muldenstein/backups/ (CT 144) +- Ziel: `/home/backup-muldenstein/backups/` auf CT 144 (muldenstein-backup) +- Cronjob: täglich 04:00 → `/root/backup-to-hetzner.sh` (auf `pve-mu-3`) +- Retention: 30 d täglich, 90 d wöchentlich, monatlich unbegrenzt -## Services (CT 143) -| Dienst | URL | -|---|---| -| Grafana | https://grafana.orbitalo.net | -| ioBroker | http://192.168.178.36:8081 | -| InfluxDB | http://192.168.178.36:8086 | +## Grafana Alerts → Telegram (Chat `674951792`) -## Grafana Alerts → Telegram 674951792 - Promtail DOWN (> 5 Min keine Daten) -- CPU > 70% -- Memory > 80% -- Disk > 90% - -## Backup-Zeitplan -- täglich 04:00 → /root/backup-to-hetzner.sh (auf pve3) -- Retention: 30d tägl, 90d wöchl, unbegrenzt monatl +- CPU > 70 % +- Memory > 80 % +- Disk > 90 % diff --git a/smart-home/scripts/add_month_panel.py b/smart-home/scripts/add_month_panel.py new file mode 100644 index 00000000..ca2e7f40 --- /dev/null +++ b/smart-home/scripts/add_month_panel.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Fuegt 'Ölverbrauch je Heizmonat' Panel am unteren Dashboard-Rand hinzu (idempotent).""" +import json, subprocess, string +from datetime import date + +BASE='http://100.66.78.56:3000' +PANEL_TITLE='Ölverbrauch je Heizmonat (Liter)' +PANEL_ID=900 + +def curl(path, method='GET', body=None): + cmd=['curl','-s','--socks5-hostname','127.0.0.1:1055','-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 + +# Welche Monate? Alle Kalendermonate ab erstem Monat mit Daten (Jan 2026) bis heute. +# Damit wir nicht manuell nachpflegen muessen, generieren wir alle Monate von Jan 2026 bis today.month+1 +START_Y, START_M = 2026, 1 +today = date.today() +months = [] +y, m = START_Y, START_M +while (y, m) <= (today.year, today.month): + months.append((y, m)) + y, m = (y+1, 1) if m == 12 else (y, m+1) + +print('Monate:', months) + +def alphabet(i): + # A,B,...,Z,AA,AB,... + if i < 26: + return string.ascii_uppercase[i] + return string.ascii_uppercase[i//26 - 1] + string.ascii_uppercase[i % 26] + +MON_DE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'] + +targets = [] +overrides = [] +for i, (y, m) in enumerate(months): + ref = alphabet(i) + ny, nm = (y+1, 1) if m == 12 else (y, m+1) + q = (f"SELECT sum(\"value\") / 3600 * 1.89 FROM \"brennerlaufzeit\" " + f"WHERE time >= '{y}-{m:02d}-01T00:00:00Z' AND time < '{ny}-{nm:02d}-01T00:00:00Z'") + targets.append({'query': q, 'rawQuery': True, 'refId': ref}) + overrides.append({ + 'matcher': {'id': 'byFrameRefID', 'options': ref}, + 'properties': [ + {'id': 'displayName', 'value': f'{MON_DE[m-1]} {y}'}, + ] + }) + +# Get current dashboard +d = curl('/api/dashboards/uid/heizung') +dash = d['dashboard'] + +# Max y+h +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)) + +# Remove existing panel with same title (idempotent) +dash['panels'] = [p for p in dash['panels'] + if p.get('title') not in (PANEL_TITLE, 'Ölverbrauch je Heizmonat') + and p.get('id') != PANEL_ID] + +new_panel = { + 'id': PANEL_ID, + 'type': 'stat', + 'title': PANEL_TITLE, + 'datasource': 'InfluxDB', + 'gridPos': {'x': 0, 'y': max_y, 'w': 24, 'h': 5}, + 'fieldConfig': { + 'defaults': { + 'decimals': 1, + 'unit': 'none', + 'color': {'mode': 'thresholds'}, + 'thresholds': {'mode': 'absolute', 'steps': [ + {'value': None, 'color': 'green'}, + {'value': 100, 'color': 'orange'}, + {'value': 250, 'color': 'red'}, + ]}, + }, + 'overrides': overrides, + }, + 'options': { + 'colorMode': 'background_solid', + 'graphMode': 'none', + 'justifyMode': 'center', + 'reduceOptions': { + 'calcs': ['lastNotNull'], + 'fields': '', + 'values': False, + }, + 'textMode': 'value_and_name', + 'orientation': 'vertical', + 'text': { + 'titleSize': 14, + 'valueSize': 32, + }, + 'wideLayout': True, + 'percentChangeColorMode': 'standard', + }, + 'targets': targets, +} +dash['panels'].append(new_panel) + +resp = curl('/api/dashboards/db', 'POST', { + 'dashboard': dash, + 'overwrite': True, + 'message': f'add monthly oil consumption tiles ({len(months)} months)', +}) +print(resp) diff --git a/smart-home/scripts/brenner_rekonstruktion.py b/smart-home/scripts/brenner_rekonstruktion.py new file mode 100644 index 00000000..e022134e --- /dev/null +++ b/smart-home/scripts/brenner_rekonstruktion.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +Rekonstruiert brennerstarts/brennerstatus/brennerlaufzeit aus VL-Rohdaten fuer +den Zeitraum, in dem die Live-Erkennung wegen zu hoher Schwelle (55 C) nichts +mehr erfasst hat. + +Verwendet die AKTUELLEN Schwellen (MIN_TEMP=30, Steigung 0.3/3min, 0.1/1min, +-0.15/3min AUS, Cooldown 10 min). Schreibt mit historischen Timestamps. + +Dry-run by default. --commit zum tatsaechlichen Schreiben. +""" +import argparse +import json +from datetime import datetime, timedelta, timezone +from urllib.parse import quote +from urllib.request import Request, urlopen + +INFLUX = 'http://localhost:8086' +DB = 'iobroker' +VL_MEASUREMENT = 'mqtt.0.Oelkessel.Oelkessel_VL.Vorlauf' + +MIN_TEMP_BRENNER = 30 +STEIGUNG_AN = 0.3 +STEIGUNG_1MIN = 0.1 +STEIGUNG_AUS = -0.15 +COOLDOWN_SEC = 10 * 60 +BRENNER_RATE_LH = 1.89 + +# Live-Service fing am 2026-04-20 21:45 MESZ an; davor war Erkennung tot +# ab dem 06.04. mittags (letzter AUS war 2026-04-06 03:24 UTC = 05:24 MESZ) +START_UTC = datetime(2026, 4, 6, 4, 0, tzinfo=timezone.utc) # 06:00 MESZ +END_UTC = datetime(2026, 4, 20, 19, 44, tzinfo=timezone.utc) # 21:44 MESZ + +STEP_SEC = 30 + + +def influx_query(q): + url = f'{INFLUX}/query?db={DB}&epoch=ns&q={quote(q)}' + with urlopen(url, timeout=60) as r: + return json.loads(r.read().decode()) + + +def fetch_vl(start_utc, end_utc): + q = ( + f'SELECT value FROM "{VL_MEASUREMENT}" ' + f"WHERE time >= '{start_utc.strftime('%Y-%m-%dT%H:%M:%SZ')}' " + f"AND time <= '{end_utc.strftime('%Y-%m-%dT%H:%M:%SZ')}' " + f'ORDER BY time ASC' + ) + data = influx_query(q) + series = data['results'][0].get('series', []) + if not series: + return [] + return [(int(t), float(v)) for t, v in series[0]['values'] if v is not None] + + +def temp_at_or_before(samples, i, target_ns): + """Binary-search-ish: nimm letzten Sample <= target_ns beginnend bei i rueckwaerts.""" + j = i + while j > 0 and samples[j][0] > target_ns: + j -= 1 + return samples[j][1] if samples[j][0] <= target_ns else None + + +def reconstruct(samples): + """Events liste [(ts_ns, 'an'|'aus', laufzeit_s_at_aus), ...]""" + events = [] + brenner_laeuft = False + start_ts_ns = None + last_start_ns = None + + if not samples: + return events + # Iteriere in Schritten zeitlich gleichmaessig + t0 = samples[0][0] + t1 = samples[-1][0] + cur = t0 + step = STEP_SEC * 1_000_000_000 + three_min = 3 * 60 * 1_000_000_000 + one_min = 1 * 60 * 1_000_000_000 + cooldown = COOLDOWN_SEC * 1_000_000_000 + + while cur <= t1: + # finde index <= cur + # linear forward search (samples sorted) + pass + break + # Stattdessen: Pro-Sample-Iteration (einfacher, robust) + # An jedem Sample i werten wir ueber das 3min-Fenster aus. + for i in range(len(samples)): + ts_now, temp_now = samples[i] + target_3m = ts_now - three_min + target_1m = ts_now - one_min + # finde temp_vor_3m, temp_vor_1m + j3 = i + while j3 > 0 and samples[j3][0] > target_3m: + j3 -= 1 + j1 = i + while j1 > 0 and samples[j1][0] > target_1m: + j1 -= 1 + if samples[j3][0] > ts_now - int(3.5 * 60 * 1_000_000_000): + pass # ok + temp_vor_3m = samples[j3][1] + temp_vor_1m = samples[j1][1] + # Ueberpruefe genug Spreizung Daten + if ts_now - samples[j3][0] < 2 * 60 * 1_000_000_000: + # weniger als 2 min Historie -> ueberspringen + continue + + steigung_3m = temp_now - temp_vor_3m + steigung_1m = temp_now - temp_vor_1m + + if not brenner_laeuft: + if ( + temp_now > MIN_TEMP_BRENNER + and steigung_3m >= STEIGUNG_AN + and steigung_1m >= STEIGUNG_1MIN + ): + if last_start_ns is None or (ts_now - last_start_ns) > cooldown: + brenner_laeuft = True + start_ts_ns = ts_now + last_start_ns = ts_now + events.append((ts_now, 'an', None)) + else: + if steigung_3m <= STEIGUNG_AUS: + laufzeit_s = (ts_now - start_ts_ns) / 1_000_000_000 + events.append((ts_now, 'aus', laufzeit_s)) + brenner_laeuft = False + start_ts_ns = None + return events + + +def write_line(line, dry=True): + if dry: + return True + url = f'{INFLUX}/write?db={DB}&precision=ns' + req = Request(url, data=line.encode(), method='POST') + with urlopen(req, timeout=30) as r: + return r.status == 204 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--commit', action='store_true') + ap.add_argument('--start', default=START_UTC.isoformat()) + ap.add_argument('--end', default=END_UTC.isoformat()) + args = ap.parse_args() + + start = datetime.fromisoformat(args.start) + end = datetime.fromisoformat(args.end) + + print(f'Fetch VL {start} -> {end}') + samples = fetch_vl(start, end) + print(f' {len(samples)} Samples, erster {datetime.fromtimestamp(samples[0][0]/1e9, timezone.utc)}, letzter {datetime.fromtimestamp(samples[-1][0]/1e9, timezone.utc)}') + print(f' Min {min(v for _,v in samples):.1f} C, Max {max(v for _,v in samples):.1f} C') + + events = reconstruct(samples) + print(f'\nErkannte Events: {len(events)}') + ans = [e for e in events if e[1] == 'an'] + auss = [e for e in events if e[1] == 'aus'] + print(f' {len(ans)} Starts, {len(auss)} Stopps') + total_s = sum(e[2] for e in auss) + print(f' Gesamt-Laufzeit: {total_s/3600:.2f} h -> {total_s/3600*BRENNER_RATE_LH:.2f} L') + + # Erste / letzte Events + for label, lst in (('erste 5 Starts', ans[:5]), ('letzte 5 Starts', ans[-5:]), + ('erste 5 Stopps', auss[:5]), ('letzte 5 Stopps', auss[-5:])): + print(f'\n{label}:') + for e in lst: + ts = datetime.fromtimestamp(e[0]/1e9, timezone.utc).astimezone() + if e[1] == 'aus': + print(f' {ts.strftime("%Y-%m-%d %H:%M:%S %z")} AUS {e[2]/60:.1f} min') + else: + print(f' {ts.strftime("%Y-%m-%d %H:%M:%S %z")} AN') + + # Tagesbilanzen + print('\nTagesbilanz:') + per_day = {} + for _, _, _ in [(0,0,0)]: + pass + # Zaehle Starts und Laufzeit pro Tag (lokaler Tag Europe/Berlin ≈ UTC+2 in April) + TZ = timezone(timedelta(hours=2)) + for ts_ns, typ, laufzeit in events: + d = datetime.fromtimestamp(ts_ns/1e9, TZ).date() + if d not in per_day: + per_day[d] = {'starts': 0, 'laufzeit_s': 0.0} + if typ == 'an': + per_day[d]['starts'] += 1 + elif typ == 'aus': + per_day[d]['laufzeit_s'] += laufzeit + for d in sorted(per_day): + s = per_day[d] + h = s['laufzeit_s'] / 3600 + print(f' {d} Starts={s["starts"]:3d} Laufzeit={h:5.2f}h Verbrauch={h*BRENNER_RATE_LH:5.2f}L') + + # Schreiben + if args.commit: + print('\n--- commit: schreibe nach InfluxDB ---') + n = 0 + for ts_ns, typ, laufzeit in events: + if typ == 'an': + write_line(f'brennerstarts value=1 {ts_ns}', dry=False) + write_line(f'brennerstatus value=1 {ts_ns}', dry=False) + n += 2 + elif typ == 'aus': + write_line(f'brennerlaufzeit value={laufzeit} {ts_ns}', dry=False) + write_line(f'brennerstatus value=0 {ts_ns}', dry=False) + n += 2 + print(f' {n} Zeilen geschrieben') + else: + print('\n(dry-run, nichts geschrieben; mit --commit ausfuehren)') + + +if __name__ == '__main__': + main() diff --git a/smart-home/scripts/check_april.py b/smart-home/scripts/check_april.py new file mode 100644 index 00000000..d66a2178 --- /dev/null +++ b/smart-home/scripts/check_april.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +import json +from urllib.parse import quote +from urllib.request import urlopen + +INFLUX = 'http://localhost:8086' +DB = 'iobroker' + +def q(sql): + url = f'{INFLUX}/query?db={DB}&epoch=ns&q={quote(sql)}' + with urlopen(url, timeout=30) as r: + return json.loads(r.read().decode()) + +def rows(sql): + d = q(sql) + s = d['results'][0].get('series', []) + if not s: + return [] + return s[0]['values'] + +from datetime import datetime, timezone, timedelta +TZ = timezone(timedelta(hours=2)) + +print('=== Starts im April (count) ===') +r = rows("SELECT count(value) FROM brennerstarts WHERE time >= '2026-04-01T00:00:00Z' AND time < '2026-05-01T00:00:00Z'") +print(r) + +print('\n=== Laufzeit-Summe April (Stunden) ===') +r = rows("SELECT sum(value) FROM brennerlaufzeit WHERE time >= '2026-04-01T00:00:00Z' AND time < '2026-05-01T00:00:00Z'") +if r: + print(f' Summe = {r[0][1]:.1f} s = {r[0][1]/3600:.2f} h') + +print('\n=== Pro Tag Starts und Laufzeit (April) ===') +starts = rows("SELECT count(value) FROM brennerstarts WHERE time >= '2026-04-01T00:00:00Z' AND time < '2026-05-01T00:00:00Z' GROUP BY time(1d,-2h) fill(0)") +lauf = rows("SELECT sum(value) FROM brennerlaufzeit WHERE time >= '2026-04-01T00:00:00Z' AND time < '2026-05-01T00:00:00Z' GROUP BY time(1d,-2h) fill(0)") +d_starts = {s[0]: s[1] or 0 for s in starts} +d_lauf = {s[0]: s[1] or 0 for s in lauf} +for ts in sorted(set(list(d_starts) + list(d_lauf))): + day = datetime.fromtimestamp(ts/1e9, TZ).date() + st = d_starts.get(ts, 0) + lf = d_lauf.get(ts, 0) / 3600 + if st or lf: + print(f' {day} Starts={st:3d} Laufzeit={lf:5.2f}h Liter={lf*1.89:5.2f}') + +print('\n=== verdächtig lange einzelne Laufzeiten > 1h ===') +r = rows("SELECT value FROM brennerlaufzeit WHERE value > 3600 AND time > '2026-03-01T00:00:00Z' ORDER BY time DESC LIMIT 20") +for ts, v in r: + t = datetime.fromtimestamp(ts/1e9, TZ) + print(f' {t.strftime("%Y-%m-%d %H:%M:%S")} {v:.0f}s = {v/60:.1f}min = {v/3600:.2f}h') + +print('\n=== Daily max einzelne Laufzeit (wann >30min?) ===') +r = rows("SELECT max(value) FROM brennerlaufzeit WHERE time >= '2026-04-01T00:00:00Z' AND time < '2026-05-01T00:00:00Z' GROUP BY time(1d,-2h) fill(0)") +for ts, v in r: + if v and v > 30*60: + t = datetime.fromtimestamp(ts/1e9, TZ).date() + print(f' {t} max einzel-laufzeit = {v/60:.1f} min') + +print('\n=== brennerstarts value-Verteilung April ===') +r = rows("SELECT value FROM brennerstarts WHERE time >= '2026-04-01T00:00:00Z' AND time < '2026-05-01T00:00:00Z'") +vals = [x[1] for x in r] +from collections import Counter +print(f' Anzahl Zeilen = {len(vals)}, Summe = {sum(vals)}, Counter = {Counter(vals)}') diff --git a/smart-home/scripts/cleanup_reconstruct.py b/smart-home/scripts/cleanup_reconstruct.py new file mode 100644 index 00000000..842e53f5 --- /dev/null +++ b/smart-home/scripts/cleanup_reconstruct.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""1) Löscht Überlappungsbereich 2) rekonstruiert sauber mit neuen Schwellen.""" +import json +from urllib.parse import quote +from urllib.request import Request, urlopen + +INFLUX='http://localhost:8086'; DB='iobroker' + +def qget(sql): + with urlopen(f'{INFLUX}/query?db={DB}&epoch=ns&q={quote(sql)}', timeout=30) as r: + return json.loads(r.read().decode()) + +def qpost(sql): + url = f'{INFLUX}/query?db={DB}' + req = Request(url, data=f'q={quote(sql)}'.encode(), method='POST', + headers={'Content-Type':'application/x-www-form-urlencoded'}) + with urlopen(req, timeout=30) as r: + return json.loads(r.read().decode()) + +# Zeitraum: ab erstem "toten" Zeitpunkt bis eine Minute vor Live-Service-Start +# Live-Start war 2026-04-20 21:45 MESZ = 19:45 UTC +# Rekonstruktion soll 06.04. mittags bis 20.04. 21:44 MESZ +START_UTC = '2026-04-06T02:00:00Z' # 04:00 MESZ 06.04. +END_UTC = '2026-04-20T19:45:00Z' # 21:45 MESZ 20.04. + +print('=== VOR DELETE ===') +for m in ('brennerstarts','brennerstatus','brennerlaufzeit'): + r = qget(f'SELECT count(value) FROM {m} WHERE time >= \'{START_UTC}\' AND time < \'{END_UTC}\'') + s = r['results'][0].get('series',[]) + c = s[0]['values'][0][1] if s else 0 + print(f' {m}: {c} Zeilen im Rekonstruktions-Fenster') + +print('\n=== DELETE ===') +for m in ('brennerstarts','brennerstatus','brennerlaufzeit'): + r = qpost(f'DELETE FROM {m} WHERE time >= \'{START_UTC}\' AND time < \'{END_UTC}\'') + print(f' {m}: {r}') + +print('\n=== NACH DELETE ===') +for m in ('brennerstarts','brennerstatus','brennerlaufzeit'): + r = qget(f'SELECT count(value) FROM {m} WHERE time >= \'{START_UTC}\' AND time < \'{END_UTC}\'') + s = r['results'][0].get('series',[]) + c = s[0]['values'][0][1] if s else 0 + print(f' {m}: {c} Zeilen verbleibend') diff --git a/smart-home/scripts/grafana_shot.js b/smart-home/scripts/grafana_shot.js new file mode 100644 index 00000000..43c91bcc --- /dev/null +++ b/smart-home/scripts/grafana_shot.js @@ -0,0 +1,59 @@ +const puppeteer = require('/opt/webpage-screenshot-mcp/node_modules/puppeteer'); + +(async () => { + const url = process.argv[2]; + const out = process.argv[3] || '/tmp/shot.png'; + const user = process.env.GF_USER || 'admin'; + const pass = process.env.GF_PASS || 'astral66'; + const base = new URL(url); + const loginUrl = `${base.protocol}//${base.host}/login`; + + const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + ], + defaultViewport: { width: 1920, height: 1400 }, + }); + const page = await browser.newPage(); + + const res = await page.evaluate( + async (loginUrl, user, pass) => { + const r = await fetch(loginUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user, password: pass }), + }); + return r.status; + }, + loginUrl, + user, + pass + ).catch(() => null); + + await page.goto(loginUrl, { waitUntil: 'domcontentloaded' }); + await page.evaluate( + async (loginUrl, user, pass) => { + await fetch(loginUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ user, password: pass }), + }); + }, + loginUrl, + user, + pass + ); + + await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 }); + await new Promise((r) => setTimeout(r, 6000)); + await page.screenshot({ path: out, fullPage: false }); + console.log('saved', out); + await browser.close(); +})().catch((e) => { + console.error('ERR', e.message); + process.exit(1); +}); diff --git a/smart-home/scripts/patch_brenner.sh b/smart-home/scripts/patch_brenner.sh new file mode 100644 index 00000000..5f6f1657 --- /dev/null +++ b/smart-home/scripts/patch_brenner.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e +python3 - <<'PYEOF' +import re, pathlib +p = pathlib.Path("/root/brennerstarts.py") +s = p.read_text() +s = re.sub(r"STEIGUNG_AN\s*=\s*[0-9.]+", "STEIGUNG_AN = 0.3", s, count=1) +s = re.sub(r"STEIGUNG_1MIN\s*=\s*[0-9.]+", "STEIGUNG_1MIN = 0.1", s, count=1) +s = re.sub(r"MIN_TEMP_BRENNER\s*=\s*[0-9.]+", "MIN_TEMP_BRENNER = 30", s, count=1) +s = re.sub(r"STEIGUNG_AUS\s*=\s*-?[0-9.]+", "STEIGUNG_AUS = -0.15", s, count=1) +p.write_text(s) +print("patched") +PYEOF +echo "--- neue Schwellen ---" +grep -n STEIGUNG_ /root/brennerstarts.py +grep -n MIN_TEMP_ /root/brennerstarts.py +grep -n BRENNER_RATE /root/brennerstarts.py +echo "--- Timeouts ---" +grep -n 'timeout=' /root/brennerstarts.py +echo "--- service restart ---" +systemctl restart brennerstarts +sleep 3 +systemctl is-active brennerstarts +echo "--- log nach restart ---" +tail -15 /var/log/brennerstarts.log