Feature: ShineDiag mobiles Dashboard mit Canvas-Charts und SQLite-History (v2.0)
- Auto-Polling alle 30s im Hintergrund (Thread + Event) - SQLite-Persistenz in /var/lib/shinediag/history.db, 7-Tage-Retention - 4 Tabs: Dashboard, Diagramme, Sensoren, Rohdaten - Große Metrikkarten für Spannung (L1/L2/L3), Frequenz, Leistung - Canvas-Liniendiagramme (kein externes JS) mit 1h/6h/24h/7d-Auswahl - localStorage für Verbindungskonfig, Auto-Reconnect beim Seitenaufruf - Retina-fähiges Canvas (DPR-aware) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+216
-48
@@ -4,12 +4,14 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
|
||||
# Shared code aus dem Add-on
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../haos-addon/src"))
|
||||
from inverters import INVERTERS
|
||||
from modbus_client import ModbusReader
|
||||
@@ -17,11 +19,132 @@ from modbus_client import ModbusReader
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
|
||||
SHINELANX_IP = os.environ.get("SHINELANX_IP", "10.0.0.100")
|
||||
WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
|
||||
DB_PATH = os.environ.get("DB_PATH", "/var/lib/shinediag/history.db")
|
||||
POLL_SEC = int(os.environ.get("POLL_SEC", "30"))
|
||||
|
||||
app = Flask(__name__, static_folder=WEB_DIR)
|
||||
|
||||
# ── SQLite ────────────────────────────────────────────────────
|
||||
|
||||
_db_lock = threading.Lock()
|
||||
_db_conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
|
||||
def _db() -> sqlite3.Connection:
|
||||
global _db_conn
|
||||
if _db_conn is None:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
_db_conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
_db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
_db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
_db_conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS measurements (
|
||||
inv_key TEXT NOT NULL,
|
||||
sensor_id TEXT NOT NULL,
|
||||
ts REAL NOT NULL,
|
||||
value REAL NOT NULL
|
||||
)""")
|
||||
_db_conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_inv_sensor_ts
|
||||
ON measurements(inv_key, sensor_id, ts)""")
|
||||
_db_conn.commit()
|
||||
return _db_conn
|
||||
|
||||
|
||||
def db_write(inv_key: str, ts: float, values: Dict[str, float]):
|
||||
rows = [(inv_key, sid, ts, v) for sid, v in values.items()]
|
||||
with _db_lock:
|
||||
_db().executemany(
|
||||
"INSERT INTO measurements(inv_key,sensor_id,ts,value) VALUES(?,?,?,?)", rows)
|
||||
_db().commit()
|
||||
|
||||
|
||||
def db_query(inv_key: str, sensor_ids: List[str],
|
||||
from_ts: float, to_ts: float) -> Dict[str, List]:
|
||||
result = {sid: [] for sid in sensor_ids}
|
||||
placeholders = ",".join("?" * len(sensor_ids))
|
||||
with _db_lock:
|
||||
rows = _db().execute(f"""
|
||||
SELECT sensor_id, ts, value FROM measurements
|
||||
WHERE inv_key=? AND sensor_id IN ({placeholders})
|
||||
AND ts BETWEEN ? AND ?
|
||||
ORDER BY ts ASC
|
||||
""", [inv_key] + sensor_ids + [from_ts, to_ts]).fetchall()
|
||||
for sid, ts, val in rows:
|
||||
if sid in result:
|
||||
result[sid].append({"ts": ts, "v": val})
|
||||
return result
|
||||
|
||||
|
||||
def db_cleanup(days: int = 7):
|
||||
cutoff = time.time() - days * 86400
|
||||
with _db_lock:
|
||||
n = _db().execute("DELETE FROM measurements WHERE ts<?", (cutoff,)).rowcount
|
||||
_db().commit()
|
||||
if n:
|
||||
log.info("DB: %d alte Einträge gelöscht", n)
|
||||
|
||||
|
||||
# ── Poll-State ────────────────────────────────────────────────
|
||||
|
||||
_state_lock = threading.Lock()
|
||||
_poll_thread: Optional[threading.Thread] = None
|
||||
_poll_stop = threading.Event()
|
||||
_conn_cfg: Dict = {} # aktuelle Verbindungs-Config
|
||||
_last_values: Dict = {} # letzter erfolgreicher Poll
|
||||
_last_ts: float = 0.0
|
||||
_last_ok: bool = False
|
||||
_inv_key: str = ""
|
||||
|
||||
|
||||
def _poll_worker():
|
||||
global _last_values, _last_ts, _last_ok
|
||||
cfg = _conn_cfg.copy()
|
||||
inverter = INVERTERS[cfg["model"]]
|
||||
reader = ModbusReader(cfg["ip"], int(cfg["port"]), int(cfg["slave"]), timeout=5.0)
|
||||
log.info("Poll-Thread gestartet: %s @ %s alle %ds", inverter.name, cfg["ip"], POLL_SEC)
|
||||
|
||||
while not _poll_stop.is_set():
|
||||
t0 = time.time()
|
||||
values = reader.read(inverter)
|
||||
with _state_lock:
|
||||
if values:
|
||||
_last_values = values
|
||||
_last_ts = t0
|
||||
_last_ok = True
|
||||
db_write(_inv_key, t0, values)
|
||||
else:
|
||||
_last_ok = False
|
||||
_poll_stop.wait(max(0.0, POLL_SEC - (time.time() - t0)))
|
||||
|
||||
reader.close()
|
||||
log.info("Poll-Thread beendet")
|
||||
|
||||
|
||||
def _start_poll(cfg: dict):
|
||||
global _poll_thread, _conn_cfg, _inv_key, _last_values, _last_ts, _last_ok
|
||||
_stop_poll()
|
||||
with _state_lock:
|
||||
_conn_cfg = cfg
|
||||
_inv_key = f"{cfg['ip']}:{cfg['port']}:{cfg['slave']}:{cfg['model']}"
|
||||
_last_values = {}
|
||||
_last_ts = 0.0
|
||||
_last_ok = False
|
||||
_poll_stop.clear()
|
||||
_poll_thread = threading.Thread(target=_poll_worker, daemon=True, name="poll")
|
||||
_poll_thread.start()
|
||||
|
||||
|
||||
def _stop_poll():
|
||||
global _poll_thread
|
||||
_poll_stop.set()
|
||||
if _poll_thread and _poll_thread.is_alive():
|
||||
_poll_thread.join(timeout=10)
|
||||
_poll_thread = None
|
||||
|
||||
|
||||
# ── Flask ─────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
@@ -37,74 +160,119 @@ def api_models():
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/connect")
|
||||
def api_connect():
|
||||
body = request.get_json(force=True) or {}
|
||||
for field in ("ip", "model"):
|
||||
if not body.get(field):
|
||||
return jsonify({"error": f"'{field}' fehlt"}), 400
|
||||
if body["model"] not in INVERTERS:
|
||||
return jsonify({"error": "Unbekanntes Modell"}), 400
|
||||
cfg = {
|
||||
"ip": body["ip"],
|
||||
"port": int(body.get("port", 502)),
|
||||
"slave": int(body.get("slave", 1)),
|
||||
"model": body["model"],
|
||||
}
|
||||
_start_poll(cfg)
|
||||
db_cleanup()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/disconnect")
|
||||
def api_disconnect():
|
||||
_stop_poll()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
def api_status():
|
||||
with _state_lock:
|
||||
connected = _poll_thread is not None and _poll_thread.is_alive()
|
||||
cfg = _conn_cfg.copy()
|
||||
values = _last_values.copy()
|
||||
ts = _last_ts
|
||||
ok = _last_ok
|
||||
|
||||
inverter = INVERTERS.get(cfg.get("model", ""), None)
|
||||
sensors = []
|
||||
if inverter:
|
||||
sensors = [{"id": s.id, "name": s.name, "unit": s.unit,
|
||||
"device_class": s.device_class, "icon": s.icon}
|
||||
for s in inverter.sensors]
|
||||
|
||||
return jsonify({
|
||||
"connected": connected,
|
||||
"modbus_ok": ok,
|
||||
"last_ts": ts,
|
||||
"cfg": cfg,
|
||||
"values": values,
|
||||
"sensors": sensors,
|
||||
"inv_name": inverter.name if inverter else "",
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/chart-data")
|
||||
def api_chart_data():
|
||||
sensors = request.args.get("sensors", "")
|
||||
hours = min(float(request.args.get("hours", "24")), 24 * 7)
|
||||
if not sensors:
|
||||
return jsonify({"error": "sensors required"}), 400
|
||||
sensor_ids = [s.strip() for s in sensors.split(",") if s.strip()]
|
||||
to_ts = time.time()
|
||||
from_ts = to_ts - hours * 3600
|
||||
with _state_lock:
|
||||
key = _inv_key
|
||||
if not key:
|
||||
return jsonify({sid: [] for sid in sensor_ids})
|
||||
return jsonify(db_query(key, sensor_ids, from_ts, to_ts))
|
||||
|
||||
|
||||
@app.post("/api/scan")
|
||||
def api_scan():
|
||||
"""Liest alle definierten Sensoren eines Geräts aus."""
|
||||
body = request.get_json(force=True) or {}
|
||||
"""Einmal-Scan ohne Polling — für schnelle Diagnose."""
|
||||
body = request.get_json(force=True) or {}
|
||||
model_id = body.get("model", "MIC_1500_TL_X")
|
||||
ip = body.get("ip", SHINELANX_IP)
|
||||
port = int(body.get("port", 502))
|
||||
slave = int(body.get("slave", 1))
|
||||
|
||||
inverter = INVERTERS.get(model_id)
|
||||
if not inverter:
|
||||
return jsonify({"error": f"Unbekanntes Modell: {model_id}"}), 400
|
||||
|
||||
reader = ModbusReader(host=ip, port=port, slave=slave, timeout=5.0)
|
||||
reader = ModbusReader(body.get("ip", "10.0.0.100"),
|
||||
int(body.get("port", 502)),
|
||||
int(body.get("slave", 1)), timeout=5.0)
|
||||
values = reader.read(inverter)
|
||||
reader.close()
|
||||
|
||||
if values is None:
|
||||
return jsonify({"ok": False, "error": "Keine Verbindung zum ShineLAN-X"}), 502
|
||||
|
||||
sensors_out = []
|
||||
for s in inverter.sensors:
|
||||
sensors_out.append({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"value": values.get(s.id),
|
||||
"unit": s.unit,
|
||||
"device_class": s.device_class,
|
||||
"icon": s.icon,
|
||||
})
|
||||
|
||||
return jsonify({"ok": False, "error": "Keine Verbindung"}), 502
|
||||
return jsonify({"ok": True, "model": inverter.name,
|
||||
"manufacturer": inverter.manufacturer, "sensors": sensors_out})
|
||||
"sensors": [{"id": s.id, "name": s.name, "value": values.get(s.id),
|
||||
"unit": s.unit} for s in inverter.sensors]})
|
||||
|
||||
|
||||
@app.post("/api/raw")
|
||||
def api_raw():
|
||||
"""Liest einen rohen Register-Bereich aus — für Diagnose unbekannter Werte."""
|
||||
body = request.get_json(force=True) or {}
|
||||
ip = body.get("ip", SHINELANX_IP)
|
||||
port = int(body.get("port", 502))
|
||||
slave = int(body.get("slave", 1))
|
||||
body = request.get_json(force=True) or {}
|
||||
start = max(0, int(body.get("start", 0)))
|
||||
count = min(125, max(1, int(body.get("count", 100))))
|
||||
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
client = ModbusTcpClient(ip, port=port, timeout=5.0)
|
||||
client = ModbusTcpClient(body.get("ip", "10.0.0.100"),
|
||||
port=int(body.get("port", 502)), timeout=5.0)
|
||||
if not client.connect():
|
||||
return jsonify({"ok": False, "error": "Verbindung fehlgeschlagen"}), 502
|
||||
|
||||
try:
|
||||
result = client.read_input_registers(start, count, slave=slave)
|
||||
if result.isError():
|
||||
return jsonify({"ok": False, "error": str(result)}), 502
|
||||
regs = []
|
||||
for i, val in enumerate(result.registers):
|
||||
regs.append({
|
||||
"addr": start + i,
|
||||
"hex": f"0x{val:04X}",
|
||||
"dec": val,
|
||||
"signed": val if val < 0x8000 else val - 0x10000,
|
||||
})
|
||||
return jsonify({"ok": True, "registers": regs})
|
||||
r = client.read_input_registers(start, count, slave=int(body.get("slave", 1)))
|
||||
if r.isError():
|
||||
return jsonify({"ok": False, "error": str(r)}), 502
|
||||
return jsonify({"ok": True, "registers": [
|
||||
{"addr": start + i, "hex": f"0x{v:04X}",
|
||||
"dec": v, "signed": v if v < 0x8000 else v - 0x10000}
|
||||
for i, v in enumerate(r.registers)
|
||||
]})
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_db()
|
||||
port = int(os.environ.get("PORT", "80"))
|
||||
log.info("ShineDiag startet auf Port %d — ShineLAN-X: %s", port, SHINELANX_IP)
|
||||
log.info("ShineDiag auf Port %d", port)
|
||||
app.run(host="0.0.0.0", port=port, threaded=True)
|
||||
|
||||
Reference in New Issue
Block a user