diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml index 8424255..b24e577 100644 --- a/haos-addon/config.yaml +++ b/haos-addon/config.yaml @@ -1,5 +1,5 @@ name: ShineBridge -version: "1.2.1" +version: "1.3.0" slug: shinebridge description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS diff --git a/haos-addon/src/history.py b/haos-addon/src/history.py new file mode 100644 index 0000000..3ee2ba6 --- /dev/null +++ b/haos-addon/src/history.py @@ -0,0 +1,97 @@ +import logging +import sqlite3 +import threading +import time +from typing import Dict, List, Tuple + +log = logging.getLogger(__name__) + +DB_PATH = "/data/history.db" +RETENTION_DAYS = 7 + +_lock = threading.Lock() +_conn: sqlite3.Connection | None = None + + +def _get_conn() -> sqlite3.Connection: + global _conn + if _conn is None: + _conn = sqlite3.connect(DB_PATH, check_same_thread=False) + _conn.execute("PRAGMA journal_mode=WAL") + _conn.execute("PRAGMA synchronous=NORMAL") + return _conn + + +def init_db(): + with _lock: + c = _get_conn() + c.execute(""" + CREATE TABLE IF NOT EXISTS measurements ( + inv_id TEXT NOT NULL, + sensor_id TEXT NOT NULL, + ts REAL NOT NULL, + value REAL NOT NULL + ) + """) + c.execute(""" + CREATE INDEX IF NOT EXISTS idx_inv_sensor_ts + ON measurements(inv_id, sensor_id, ts) + """) + c.commit() + cleanup_old() + log.info("History DB initialisiert: %s", DB_PATH) + + +def write_batch(inv_id: str, ts: float, values: Dict[str, float]): + rows = [(inv_id, sid, ts, val) for sid, val in values.items()] + with _lock: + _get_conn().executemany( + "INSERT INTO measurements(inv_id, sensor_id, ts, value) VALUES(?,?,?,?)", + rows, + ) + _get_conn().commit() + + +def load_recent(inv_id: str, limit: int = 300) -> Dict[str, List[Tuple[float, float]]]: + """Letzte `limit` Messpunkte pro Sensor — zum Befüllen der In-Memory-Deque beim Start.""" + with _lock: + rows = _get_conn().execute(""" + SELECT sensor_id, ts, value + FROM ( + SELECT sensor_id, ts, value, + ROW_NUMBER() OVER (PARTITION BY sensor_id ORDER BY ts DESC) AS rn + FROM measurements + WHERE inv_id = ? + ) + WHERE rn <= ? + ORDER BY ts ASC + """, (inv_id, limit)).fetchall() + + result: Dict[str, List[Tuple[float, float]]] = {} + for sensor_id, ts, value in rows: + result.setdefault(sensor_id, []).append((ts, value)) + return result + + +def query(inv_id: str, sensor_id: str, + from_ts: float, to_ts: float) -> List[Tuple[float, float]]: + """Zeitfenster-Abfrage für die History-API und den späteren Diagnose-Tab.""" + with _lock: + rows = _get_conn().execute(""" + SELECT ts, value FROM measurements + WHERE inv_id = ? AND sensor_id = ? AND ts BETWEEN ? AND ? + ORDER BY ts ASC + """, (inv_id, sensor_id, from_ts, to_ts)).fetchall() + return rows + + +def cleanup_old(days: int = RETENTION_DAYS): + cutoff = time.time() - days * 86400 + with _lock: + c = _get_conn() + deleted = c.execute( + "DELETE FROM measurements WHERE ts < ?", (cutoff,) + ).rowcount + c.commit() + if deleted: + log.info("History: %d alte Einträge gelöscht (>%d Tage)", deleted, days) diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index ab454d1..6b2c9a8 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional from flask import Flask, jsonify, request, send_from_directory from inverters import INVERTERS +import history from modbus_client import ModbusReader from mqtt_publisher import MqttPublisher @@ -160,9 +161,18 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): _publisher.register_inverter(inverter, device_id, prefix, inv_cfg.get("name", inverter.name)) - log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds", + # History aus DB in die In-Memory-Deque laden + hist_data = history.load_recent(inv_id, limit=300) + with State.lock: + d = State.inv_data.setdefault(inv_id, {"poll_count": 0}) + hist = d.setdefault("history", {}) + for sid, points in hist_data.items(): + q = hist.setdefault(sid, deque(maxlen=300)) + for pt in points: + q.append(pt) + log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds — %d Sensoren aus DB geladen", inv_id, inverter.name, inv_cfg["modbus_ip"], - inv_cfg.get("modbus_port", 502), interval) + inv_cfg.get("modbus_port", 502), interval, len(hist_data)) while not stop.is_set(): t0 = time.time() @@ -179,6 +189,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): for sid, val in values.items(): q = hist.setdefault(sid, deque(maxlen=300)) q.append((now, val)) + history.write_batch(inv_id, now, values) if _publisher: _publisher.publish_data(values, prefix) _publisher.publish_status("online", prefix) @@ -321,6 +332,18 @@ def api_get_data(): aggregates = _compute_aggregates() return jsonify({"inverters": result, "mqtt_ok": mqtt_ok, "aggregates": aggregates}) +@app.get("/api/history") +def api_get_history(): + inv_id = request.args.get("inv_id", "") + sensor_id = request.args.get("sensor_id", "") + hours = min(float(request.args.get("hours", "24")), 24 * 7) + if not inv_id or not sensor_id: + return jsonify({"error": "inv_id and sensor_id required"}), 400 + to_ts = time.time() + from_ts = to_ts - hours * 3600 + rows = history.query(inv_id, sensor_id, from_ts, to_ts) + return jsonify([{"ts": t, "value": v} for t, v in rows]) + @app.get("/api/inverter-models") def api_get_models(): return jsonify({ @@ -343,6 +366,7 @@ def static_files(filename): # ── Startup ─────────────────────────────────────────────────── if __name__ == "__main__": + history.init_db() cfg = load_config() with State.lock: State.mqtt_cfg = {k: cfg[k] for k in