Feature: Persistente History via SQLite (v1.3.0)
Sensorwerte werden in /data/history.db (SQLite, WAL-Modus) persistiert und überleben damit Add-on-Neustarts. Beim Start werden die letzten 300 Messpunkte pro Sensor in die In-Memory-Deque geladen, sodass Sparklines sofort Daten zeigen. Retention: 7 Tage. Neue API: GET /api/history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.2.1"
|
version: "1.3.0"
|
||||||
slug: shinebridge
|
slug: shinebridge
|
||||||
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
|
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
|
url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS
|
||||||
|
|||||||
@@ -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)
|
||||||
+26
-2
@@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
from flask import Flask, jsonify, request, send_from_directory
|
from flask import Flask, jsonify, request, send_from_directory
|
||||||
|
|
||||||
from inverters import INVERTERS
|
from inverters import INVERTERS
|
||||||
|
import history
|
||||||
from modbus_client import ModbusReader
|
from modbus_client import ModbusReader
|
||||||
from mqtt_publisher import MqttPublisher
|
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,
|
_publisher.register_inverter(inverter, device_id, prefix,
|
||||||
inv_cfg.get("name", inverter.name))
|
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_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():
|
while not stop.is_set():
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
@@ -179,6 +189,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
|||||||
for sid, val in values.items():
|
for sid, val in values.items():
|
||||||
q = hist.setdefault(sid, deque(maxlen=300))
|
q = hist.setdefault(sid, deque(maxlen=300))
|
||||||
q.append((now, val))
|
q.append((now, val))
|
||||||
|
history.write_batch(inv_id, now, values)
|
||||||
if _publisher:
|
if _publisher:
|
||||||
_publisher.publish_data(values, prefix)
|
_publisher.publish_data(values, prefix)
|
||||||
_publisher.publish_status("online", prefix)
|
_publisher.publish_status("online", prefix)
|
||||||
@@ -321,6 +332,18 @@ def api_get_data():
|
|||||||
aggregates = _compute_aggregates()
|
aggregates = _compute_aggregates()
|
||||||
return jsonify({"inverters": result, "mqtt_ok": mqtt_ok, "aggregates": 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")
|
@app.get("/api/inverter-models")
|
||||||
def api_get_models():
|
def api_get_models():
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -343,6 +366,7 @@ def static_files(filename):
|
|||||||
# ── Startup ───────────────────────────────────────────────────
|
# ── Startup ───────────────────────────────────────────────────
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
history.init_db()
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
with State.lock:
|
with State.lock:
|
||||||
State.mqtt_cfg = {k: cfg[k] for k in
|
State.mqtt_cfg = {k: cfg[k] for k in
|
||||||
|
|||||||
Reference in New Issue
Block a user