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:
retr0
2026-04-27 12:15:05 +02:00
parent 7f890f8105
commit fe1bdb057d
2 changed files with 738 additions and 249 deletions
+213 -45
View File
@@ -4,12 +4,14 @@
import json import json
import logging import logging
import os import os
import struct import sqlite3
import sys import sys
import threading
import time
from typing import Dict, List, Optional
from flask import Flask, jsonify, request, send_from_directory 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")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../haos-addon/src"))
from inverters import INVERTERS from inverters import INVERTERS
from modbus_client import ModbusReader from modbus_client import ModbusReader
@@ -18,10 +20,131 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(mes
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
WEB_DIR = os.path.join(os.path.dirname(__file__), "web") WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
SHINELANX_IP = os.environ.get("SHINELANX_IP", "10.0.0.100") 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) 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("/") @app.get("/")
def index(): 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") @app.post("/api/scan")
def api_scan(): def api_scan():
"""Liest alle definierten Sensoren eines Geräts aus.""" """Einmal-Scan ohne Polling — für schnelle Diagnose."""
body = request.get_json(force=True) or {} body = request.get_json(force=True) or {}
model_id = body.get("model", "MIC_1500_TL_X") 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) inverter = INVERTERS.get(model_id)
if not inverter: if not inverter:
return jsonify({"error": f"Unbekanntes Modell: {model_id}"}), 400 return jsonify({"error": f"Unbekanntes Modell: {model_id}"}), 400
reader = ModbusReader(body.get("ip", "10.0.0.100"),
reader = ModbusReader(host=ip, port=port, slave=slave, timeout=5.0) int(body.get("port", 502)),
int(body.get("slave", 1)), timeout=5.0)
values = reader.read(inverter) values = reader.read(inverter)
reader.close() reader.close()
if values is None: if values is None:
return jsonify({"ok": False, "error": "Keine Verbindung zum ShineLAN-X"}), 502 return jsonify({"ok": False, "error": "Keine Verbindung"}), 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": True, "model": inverter.name, 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") @app.post("/api/raw")
def api_raw(): def api_raw():
"""Liest einen rohen Register-Bereich aus — für Diagnose unbekannter Werte."""
body = request.get_json(force=True) or {} 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))
start = max(0, int(body.get("start", 0))) start = max(0, int(body.get("start", 0)))
count = min(125, max(1, int(body.get("count", 100)))) count = min(125, max(1, int(body.get("count", 100))))
from pymodbus.client import ModbusTcpClient 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(): if not client.connect():
return jsonify({"ok": False, "error": "Verbindung fehlgeschlagen"}), 502 return jsonify({"ok": False, "error": "Verbindung fehlgeschlagen"}), 502
try: try:
result = client.read_input_registers(start, count, slave=slave) r = client.read_input_registers(start, count, slave=int(body.get("slave", 1)))
if result.isError(): if r.isError():
return jsonify({"ok": False, "error": str(result)}), 502 return jsonify({"ok": False, "error": str(r)}), 502
regs = [] return jsonify({"ok": True, "registers": [
for i, val in enumerate(result.registers): {"addr": start + i, "hex": f"0x{v:04X}",
regs.append({ "dec": v, "signed": v if v < 0x8000 else v - 0x10000}
"addr": start + i, for i, v in enumerate(r.registers)
"hex": f"0x{val:04X}", ]})
"dec": val,
"signed": val if val < 0x8000 else val - 0x10000,
})
return jsonify({"ok": True, "registers": regs})
finally: finally:
client.close() client.close()
if __name__ == "__main__": if __name__ == "__main__":
_db()
port = int(os.environ.get("PORT", "80")) 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) app.run(host="0.0.0.0", port=port, threaded=True)
+504 -183
View File
@@ -5,148 +5,219 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>ShineDiag</title> <title>ShineDiag</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } *{box-sizing:border-box;margin:0;padding:0}
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; } body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
header { background: #1e293b; padding: 16px 20px; border-bottom: 1px solid #334155; /* Header */
display: flex; align-items: center; gap: 12px; } header{background:#1e293b;padding:12px 16px;border-bottom:1px solid #334155;
header svg { flex-shrink: 0; } display:flex;align-items:center;justify-content:space-between;gap:8px;position:sticky;top:0;z-index:10}
header h1 { font-size: 1.2rem; font-weight: 700; color: #f8fafc; } .header-left{display:flex;align-items:center;gap:10px}
header p { font-size: .75rem; color: #94a3b8; } header h1{font-size:1.1rem;font-weight:700;color:#f8fafc}
header p{font-size:.7rem;color:#94a3b8}
.status-dot{width:10px;height:10px;border-radius:50%;background:#334155;flex-shrink:0}
.status-dot.ok{background:#22c55e;box-shadow:0 0 6px #22c55e88}
.status-dot.err{background:#ef4444}
main { padding: 16px; max-width: 640px; margin: 0 auto; } /* Tabs */
.tabs{display:flex;background:#1e293b;border-bottom:1px solid #334155;overflow-x:auto;scrollbar-width:none}
.tabs::-webkit-scrollbar{display:none}
.tab{flex:1;min-width:80px;padding:10px 4px;border:none;background:none;color:#64748b;
font-size:.8rem;cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap}
.tab.active{color:#60a5fa;border-bottom-color:#3b82f6}
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; /* Main */
padding: 16px; margin-bottom: 16px; } main{padding:12px;max-width:640px;margin:0 auto}
.card h2 { font-size: .85rem; font-weight: 600; color: #94a3b8; .card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:14px;margin-bottom:12px}
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 12px; } .card-title{font-size:.75rem;font-weight:600;color:#64748b;text-transform:uppercase;
letter-spacing:.05em;margin-bottom:10px}
label { display: block; font-size: .8rem; color: #94a3b8; margin-bottom: 4px; margin-top: 10px; } /* Connect form */
input, select { label{display:block;font-size:.78rem;color:#94a3b8;margin-bottom:3px;margin-top:8px}
width: 100%; padding: 10px 12px; border-radius: 8px; input,select{width:100%;padding:9px 11px;border-radius:8px;border:1px solid #334155;
border: 1px solid #334155; background: #0f172a; color: #f1f5f9; background:#0f172a;color:#f1f5f9;font-size:.9rem}
font-size: .9rem; input:focus,select:focus{outline:2px solid #3b82f6;border-color:transparent}
} .row2{display:grid;grid-template-columns:1fr 1fr;gap:8px}
input:focus, select:focus { outline: 2px solid #3b82f6; border-color: transparent; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } button{width:100%;padding:11px;border-radius:8px;border:none;cursor:pointer;
font-size:.9rem;font-weight:600;margin-top:10px;transition:opacity .15s}
button:disabled{opacity:.4;cursor:not-allowed}
.btn-primary{background:#3b82f6;color:#fff}.btn-primary:hover:not(:disabled){background:#2563eb}
.btn-danger{background:#dc2626;color:#fff}.btn-danger:hover:not(:disabled){background:#b91c1c}
.btn-secondary{background:#475569;color:#e2e8f0}.btn-secondary:hover:not(:disabled){background:#334155}
.btn-success{background:#059669;color:#fff}.btn-success:hover:not(:disabled){background:#047857}
button { width: 100%; padding: 12px; border-radius: 8px; border: none; cursor: pointer; /* Metric cards grid */
font-size: .95rem; font-weight: 600; margin-top: 14px; transition: opacity .15s; } .metrics{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;margin-bottom:4px}
button:disabled { opacity: .4; cursor: not-allowed; } .metric{background:#0f172a;border:1px solid #334155;border-radius:10px;padding:12px 10px}
.btn-primary { background: #3b82f6; color: #fff; } .metric-label{font-size:.7rem;color:#64748b;text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px}
.btn-primary:hover:not(:disabled) { background: #2563eb; } .metric-value{font-size:1.5rem;font-weight:700;color:#f1f5f9;line-height:1}
.btn-raw { background: #475569; color: #e2e8f0; } .metric-unit{font-size:.75rem;color:#94a3b8;margin-top:2px}
.btn-raw:hover:not(:disabled) { background: #334155; } .metric.highlight .metric-value{color:#60a5fa}
.btn-export { background: #059669; color: #fff; } .metric.warn .metric-value{color:#f59e0b}
.btn-export:hover:not(:disabled) { background: #047857; } .metric.ok .metric-value{color:#4ade80}
.status { display: flex; align-items: center; gap: 8px; padding: 10px 12px; /* Chart */
border-radius: 8px; font-size: .85rem; margin-top: 12px; } .chart-wrap{position:relative;margin-top:8px}
.status.ok { background: #052e16; color: #4ade80; border: 1px solid #166534; } canvas.chart{width:100%;border-radius:8px;display:block}
.status.error { background: #1c0a09; color: #f87171; border: 1px solid #7f1d1d; } .chart-legend{display:flex;flex-wrap:wrap;gap:10px;margin-top:6px;padding:0 4px}
.status.info { background: #0c1a2e; color: #60a5fa; border: 1px solid #1e3a5f; } .legend-item{display:flex;align-items:center;gap:5px;font-size:.75rem;color:#94a3b8}
.legend-dot{width:10px;height:10px;border-radius:50%}
.sensor-table { width: 100%; border-collapse: collapse; margin-top: 4px; } /* Range selector */
.sensor-table th { text-align: left; font-size: .75rem; color: #64748b; .range-btns{display:flex;gap:4px;margin-bottom:10px}
padding: 6px 8px; border-bottom: 1px solid #334155; } .range-btn{flex:1;padding:7px 4px;border-radius:6px;border:1px solid #334155;
.sensor-table td { padding: 8px; font-size: .85rem; border-bottom: 1px solid #1e293b; } background:transparent;color:#94a3b8;font-size:.75rem;cursor:pointer}
.sensor-table tr:last-child td { border-bottom: none; } .range-btn.active{background:#3b82f6;color:#fff;border-color:#3b82f6}
.sensor-table .val { font-weight: 600; color: #f1f5f9; text-align: right; }
.sensor-table .unit { color: #64748b; font-size: .75rem; text-align: right; }
.sensor-name { color: #cbd5e1; }
.tabs { display: flex; gap: 4px; margin-bottom: 12px; } /* Sensor table */
.tab { flex: 1; padding: 8px; border-radius: 6px; border: 1px solid #334155; .sensor-table{width:100%;border-collapse:collapse}
background: transparent; color: #94a3b8; font-size: .8rem; cursor: pointer; } .sensor-table th{text-align:left;font-size:.72rem;color:#64748b;padding:5px 6px;border-bottom:1px solid #334155}
.tab.active { background: #3b82f6; color: #fff; border-color: #3b82f6; } .sensor-table td{padding:7px 6px;font-size:.83rem;border-bottom:1px solid #1e293b}
.sensor-table tr:last-child td{border-bottom:none}
.sensor-table .v{font-weight:600;color:#f1f5f9;text-align:right}
.sensor-table .u{color:#64748b;font-size:.72rem;text-align:right}
.raw-table { width: 100%; border-collapse: collapse; font-family: monospace; font-size: .8rem; } /* Raw table */
.raw-table th { text-align: left; color: #64748b; padding: 4px 8px; .raw-table{width:100%;border-collapse:collapse;font-family:monospace;font-size:.78rem}
border-bottom: 1px solid #334155; } .raw-table th{text-align:left;color:#64748b;padding:4px 6px;border-bottom:1px solid #334155}
.raw-table td { padding: 5px 8px; border-bottom: 1px solid #1e293b; color: #cbd5e1; } .raw-table td{padding:4px 6px;border-bottom:1px solid #1e293b;color:#cbd5e1}
.raw-table td:first-child { color: #60a5fa; } .raw-table td:first-child{color:#60a5fa}
.raw-controls{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px}
#results { display: none; } /* Status bar */
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #ffffff44; .update-bar{font-size:.72rem;color:#475569;text-align:right;margin-bottom:4px}
border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } /* Alert */
.alert{padding:10px 12px;border-radius:8px;font-size:.83rem;margin-bottom:10px}
.alert-info{background:#0c1a2e;color:#60a5fa;border:1px solid #1e3a5f}
.alert-ok{background:#052e16;color:#4ade80;border:1px solid #166534}
.alert-err{background:#1c0a09;color:#f87171;border:1px solid #7f1d1d}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid #fff4;
border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.hidden{display:none!important}
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2"> <div class="header-left">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/> <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2">
<line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> <circle cx="12" cy="12" r="5"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> <line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/> <line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg> </svg>
<div> <div><h1>ShineDiag</h1><p id="hdrSub">Nicht verbunden</p></div>
<h1>ShineDiag</h1>
<p>Vor-Ort Diagnose via ShineLAN-X</p>
</div> </div>
<div id="statusDot" class="status-dot"></div>
</header> </header>
<main>
<div class="card">
<h2>Verbindung</h2>
<label>ShineLAN-X IP</label>
<input id="ip" type="text" value="10.0.0.100" placeholder="10.0.0.100">
<div class="row">
<div>
<label>Port</label>
<input id="port" type="number" value="502">
</div>
<div>
<label>Modbus-Adresse</label>
<input id="slave" type="number" value="1" min="1" max="247">
</div>
</div>
<label>Wechselrichter-Modell</label>
<select id="model"></select>
<button class="btn-primary" id="btnScan" onclick="scan()">
Auslesen
</button>
</div>
<div class="card" id="results">
<div class="tabs"> <div class="tabs">
<button class="tab active" onclick="showTab('sensors')">Sensoren</button> <button class="tab active" onclick="showTab('dashboard')">Dashboard</button>
<button class="tab" onclick="showTab('charts')">Diagramme</button>
<button class="tab" onclick="showTab('sensors')">Sensoren</button>
<button class="tab" onclick="showTab('raw')">Rohdaten</button> <button class="tab" onclick="showTab('raw')">Rohdaten</button>
</div> </div>
<div id="statusMsg"></div> <main>
<div id="tabSensors"> <!-- ── Dashboard ── -->
<div id="tabDashboard">
<!-- Connect card (shown when disconnected) -->
<div class="card" id="connectCard">
<div class="card-title">Verbindung</div>
<label>ShineLAN-X IP</label>
<input id="cfgIp" value="10.0.0.100">
<div class="row2">
<div><label>Port</label><input id="cfgPort" type="number" value="502"></div>
<div><label>Modbus-Adresse</label><input id="cfgSlave" type="number" value="1"></div>
</div>
<label>Modell</label>
<select id="cfgModel"></select>
<button class="btn-primary" id="btnConnect" onclick="connect()">Verbinden &amp; Aufzeichnen</button>
</div>
<!-- Live data (shown when connected) -->
<div id="liveCard" class="hidden">
<div class="update-bar" id="updateBar"></div>
<div class="card">
<div class="card-title">Spannung</div>
<div class="metrics" id="voltageMetrics"></div>
</div>
<div class="card">
<div class="card-title">Frequenz &amp; Leistung</div>
<div class="metrics" id="powerMetrics"></div>
</div>
<div class="card" id="extraCard" style="display:none">
<div class="card-title">Weitere</div>
<div class="metrics" id="extraMetrics"></div>
</div>
<button class="btn-danger" onclick="disconnect()">Verbindung trennen</button>
</div>
</div>
<!-- ── Charts ── -->
<div id="tabCharts" class="hidden">
<div class="card" id="noDataCard">
<div class="alert alert-info">Zuerst im Dashboard verbinden und einige Messpunkte sammeln.</div>
</div>
<div id="chartSection" class="hidden">
<div class="range-btns">
<button class="range-btn active" onclick="setRange(1,this)">1 Std</button>
<button class="range-btn" onclick="setRange(6,this)">6 Std</button>
<button class="range-btn" onclick="setRange(24,this)">24 Std</button>
<button class="range-btn" onclick="setRange(168,this)">7 Tage</button>
</div>
<div class="card">
<div class="card-title">Spannung (V)</div>
<div class="chart-wrap"><canvas id="chartVoltage" class="chart" height="160"></canvas></div>
<div class="chart-legend" id="legendVoltage"></div>
</div>
<div class="card">
<div class="card-title">Frequenz (Hz)</div>
<div class="chart-wrap"><canvas id="chartFreq" class="chart" height="120"></canvas></div>
</div>
<div class="card">
<div class="card-title">Leistung (W)</div>
<div class="chart-wrap"><canvas id="chartPower" class="chart" height="140"></canvas></div>
<div class="chart-legend" id="legendPower"></div>
</div>
</div>
</div>
<!-- ── Sensors ── -->
<div id="tabSensors" class="hidden">
<div class="card">
<table class="sensor-table"> <table class="sensor-table">
<thead><tr><th>Sensor</th><th style="text-align:right">Wert</th><th style="text-align:right">Einheit</th></tr></thead> <thead><tr><th>Sensor</th><th style="text-align:right">Wert</th><th style="text-align:right">Einheit</th></tr></thead>
<tbody id="sensorBody"></tbody> <tbody id="sensorBody"><tr><td colspan="3" style="color:#475569;padding:12px">Noch nicht verbunden.</td></tr></tbody>
</table> </table>
<button class="btn-export" id="btnExport" onclick="exportJson()" style="margin-top:12px"> <button class="btn-success" id="btnExport" onclick="exportJson()" style="margin-top:10px">Export JSON</button>
Export JSON </div>
</button>
</div> </div>
<div id="tabRaw" style="display:none"> <!-- ── Raw ── -->
<div class="row" style="margin-bottom:10px"> <div id="tabRaw" class="hidden">
<div> <div class="card">
<label>Start-Register</label> <div class="raw-controls">
<input id="rawStart" type="number" value="0" min="0"> <div><label>Start</label><input id="rawStart" type="number" value="0"></div>
<div><label>Anzahl (max 125)</label><input id="rawCount" type="number" value="100"></div>
</div> </div>
<div> <button class="btn-secondary" onclick="rawScan()">Register lesen</button>
<label>Anzahl</label> <div id="rawAlert" style="margin-top:8px"></div>
<input id="rawCount" type="number" value="100" min="1" max="125"> <div style="overflow-x:auto;margin-top:8px">
</div>
</div>
<button class="btn-raw" onclick="rawScan()">Register lesen</button>
<div style="overflow-x:auto; margin-top:12px">
<table class="raw-table"> <table class="raw-table">
<thead><tr><th>Addr</th><th>Hex</th><th>Dez</th><th>Signed</th></tr></thead> <thead><tr><th>Addr</th><th>Hex</th><th>Dez</th><th>Signed</th></tr></thead>
<tbody id="rawBody"></tbody> <tbody id="rawBody"></tbody>
@@ -154,127 +225,377 @@
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<script> <script>
let lastScan = null; // ── State ────────────────────────────────────────────────────
let currentStatus = null;
let chartHours = 1;
let refreshTimer = null;
const COLORS = ['#60a5fa','#fbbf24','#4ade80','#f87171','#a78bfa'];
// Sensor-IDs je Messtyp
const VOLTAGE_IDS = ['voltage_l1','voltage_l2','voltage_l3','ac_voltage','bat_voltage'];
const FREQ_IDS = ['frequency'];
const POWER_IDS = ['total_power','ac_power','ac_power_total','pv_power','bat_charge_power','bat_discharge_power'];
const EXTRA_IDS = ['temperature','bat_soc','bat_current'];
const LABELS = {
voltage_l1:'Spannung L1', voltage_l2:'Spannung L2', voltage_l3:'Spannung L3',
ac_voltage:'Spannung AC', bat_voltage:'Batterie',
frequency:'Frequenz',
total_power:'Netzleistung', ac_power:'AC Leistung', ac_power_total:'AC Leistung',
pv_power:'PV Leistung', bat_charge_power:'Bat. Laden', bat_discharge_power:'Bat. Entladen',
temperature:'Temperatur', bat_soc:'Ladezustand', bat_current:'Bat. Strom',
};
// ── Tabs ─────────────────────────────────────────────────────
function showTab(name) {
['dashboard','charts','sensors','raw'].forEach(t => {
document.getElementById('tab'+cap(t)).classList.toggle('hidden', t !== name);
});
document.querySelectorAll('.tab').forEach((btn, i) => {
btn.classList.toggle('active', ['dashboard','charts','sensors','raw'][i] === name);
});
if (name === 'charts') loadCharts();
}
const cap = s => s[0].toUpperCase() + s.slice(1);
// ── Models ───────────────────────────────────────────────────
async function loadModels() { async function loadModels() {
const res = await fetch('/api/models'); const res = await fetch('/api/models');
const data = await res.json(); const data = await res.json();
const sel = document.getElementById('model'); const sel = document.getElementById('cfgModel');
sel.innerHTML = ''; sel.innerHTML = '';
for (const [id, m] of Object.entries(data)) { for (const [id, m] of Object.entries(data)) {
const opt = document.createElement('option'); const o = document.createElement('option');
opt.value = id; o.value = id;
opt.textContent = `${m.name} (${m.sensor_count} Sensoren)`; o.textContent = `${m.name}`;
sel.appendChild(opt); sel.appendChild(o);
} }
// restore from localStorage
const saved = localStorage.getItem('shinediag_model');
if (saved) sel.value = saved;
} }
function showTab(tab) { // ── Connect ──────────────────────────────────────────────────
document.getElementById('tabSensors').style.display = tab === 'sensors' ? '' : 'none';
document.getElementById('tabRaw').style.display = tab === 'raw' ? '' : 'none';
document.querySelectorAll('.tab').forEach((t, i) =>
t.classList.toggle('active', (i === 0) === (tab === 'sensors')));
}
function setStatus(msg, type) { async function connect() {
const el = document.getElementById('statusMsg'); const btn = document.getElementById('btnConnect');
el.innerHTML = `<div class="status ${type}">${msg}</div>`;
}
async function scan() {
const btn = document.getElementById('btnScan');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Verbinde…'; btn.innerHTML = '<span class="spinner"></span>';
document.getElementById('results').style.display = 'block'; const cfg = {
setStatus('Verbinde mit ShineLAN-X…', 'info'); ip: document.getElementById('cfgIp').value,
port: parseInt(document.getElementById('cfgPort').value),
slave: parseInt(document.getElementById('cfgSlave').value),
model: document.getElementById('cfgModel').value,
};
localStorage.setItem('shinediag_model', cfg.model);
const res = await fetch('/api/connect', {method:'POST',
headers:{'Content-Type':'application/json'}, body: JSON.stringify(cfg)});
const d = await res.json();
btn.disabled = false;
btn.textContent = 'Verbinden & Aufzeichnen';
if (d.ok) startRefresh();
}
async function disconnect() {
await fetch('/api/disconnect', {method:'POST'});
stopRefresh();
showDisconnected();
}
// ── Refresh ──────────────────────────────────────────────────
function startRefresh() {
stopRefresh();
pollStatus();
refreshTimer = setInterval(pollStatus, 30000);
}
function stopRefresh() {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
}
async function pollStatus() {
try { try {
const res = await fetch('/api/scan', { const res = await fetch('/api/status');
method: 'POST', currentStatus = await res.json();
headers: {'Content-Type': 'application/json'}, renderDashboard(currentStatus);
body: JSON.stringify({ } catch(e) {
ip: document.getElementById('ip').value, console.error(e);
port: parseInt(document.getElementById('port').value), }
slave: parseInt(document.getElementById('slave').value), }
model: document.getElementById('model').value,
}) // ── Dashboard ────────────────────────────────────────────────
});
function renderDashboard(s) {
const dot = document.getElementById('statusDot');
const hdr = document.getElementById('hdrSub');
if (!s.connected) { showDisconnected(); return; }
document.getElementById('connectCard').classList.add('hidden');
document.getElementById('liveCard').classList.remove('hidden');
dot.className = 'status-dot ' + (s.modbus_ok ? 'ok' : 'err');
hdr.textContent = s.inv_name || s.cfg.model;
if (s.last_ts) {
const ago = Math.round(Date.now()/1000 - s.last_ts);
document.getElementById('updateBar').textContent =
`Letztes Update: ${ago < 60 ? ago+'s' : Math.round(ago/60)+'min'} · alle 30s`;
}
const v = s.values;
// Spannung
const voltIds = VOLTAGE_IDS.filter(id => v[id] !== undefined);
renderMetrics('voltageMetrics', voltIds.map(id => ({
label: LABELS[id], value: v[id]?.toFixed(1), unit: 'V', cls: 'highlight'
})));
// Frequenz + Leistung
const pm = [];
if (v.frequency !== undefined)
pm.push({label:'Frequenz', value: v.frequency?.toFixed(2), unit:'Hz', cls:'ok'});
const pwrId = POWER_IDS.find(id => v[id] !== undefined);
if (pwrId) {
const pval = v[pwrId];
pm.push({label: LABELS[pwrId], value: pval?.toFixed(0), unit:'W',
cls: pval < 0 ? 'ok' : 'warn'});
}
renderMetrics('powerMetrics', pm);
// Extras (Temperatur, SoC, …)
const extras = EXTRA_IDS.filter(id => v[id] !== undefined)
.map(id => ({label: LABELS[id], value: v[id]?.toFixed(1),
unit: id==='bat_soc'?'%':id==='temperature'?'°C':'A'}));
document.getElementById('extraCard').style.display = extras.length ? '' : 'none';
if (extras.length) renderMetrics('extraMetrics', extras);
// Sensor-Tabelle aktualisieren
if (s.sensors.length) {
const tbody = document.getElementById('sensorBody');
tbody.innerHTML = s.sensors.map(sen => {
const val = v[sen.id] !== undefined ? v[sen.id] : '—';
return `<tr><td>${esc(sen.name)}</td>
<td class="v">${val}</td>
<td class="u">${esc(sen.unit||'')}</td></tr>`;
}).join('');
}
}
function renderMetrics(containerId, items) {
document.getElementById(containerId).innerHTML = items.map(m =>
`<div class="metric ${m.cls||''}">
<div class="metric-label">${esc(m.label)}</div>
<div class="metric-value">${esc(m.value??'—')}</div>
<div class="metric-unit">${esc(m.unit||'')}</div>
</div>`
).join('');
}
function showDisconnected() {
document.getElementById('connectCard').classList.remove('hidden');
document.getElementById('liveCard').classList.add('hidden');
document.getElementById('statusDot').className = 'status-dot';
document.getElementById('hdrSub').textContent = 'Nicht verbunden';
}
// ── Charts ───────────────────────────────────────────────────
function setRange(h, btn) {
chartHours = h;
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadCharts();
}
async function loadCharts() {
if (!currentStatus?.connected) {
document.getElementById('noDataCard').classList.remove('hidden');
document.getElementById('chartSection').classList.add('hidden');
return;
}
document.getElementById('noDataCard').classList.add('hidden');
document.getElementById('chartSection').classList.remove('hidden');
const v = currentStatus.values || {};
const voltIds = VOLTAGE_IDS.filter(id => v[id] !== undefined);
const pwrIds = POWER_IDS.filter(id => v[id] !== undefined);
const sensors = [...voltIds, 'frequency', ...pwrIds].join(',');
const res = await fetch(`/api/chart-data?sensors=${sensors}&hours=${chartHours}`);
const data = await res.json(); const data = await res.json();
if (!data.ok) { drawLineChart('chartVoltage', voltIds.map((id,i) => ({
setStatus('&#x26A0; ' + (data.error || 'Fehler'), 'error'); label: LABELS[id], color: COLORS[i], points: data[id]||[]
})), 'legendVoltage');
drawLineChart('chartFreq', [{
label:'Frequenz', color: COLORS[2], points: data.frequency||[]
}], null, {yMin:49, yMax:51});
drawLineChart('chartPower', pwrIds.map((id,i) => ({
label: LABELS[id], color: COLORS[i], points: data[id]||[]
})), 'legendPower');
}
function drawLineChart(canvasId, series, legendId, opts={}) {
const canvas = document.getElementById(canvasId);
const dpr = window.devicePixelRatio || 1;
const W = canvas.parentElement.clientWidth;
const H = canvas.height || 140;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const PAD = {top:10, right:10, bottom:28, left:42};
const iW = W - PAD.left - PAD.right;
const iH = H - PAD.top - PAD.bottom;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0f172a';
ctx.fillRect(0, 0, W, H);
const allPts = series.flatMap(s => s.points);
if (allPts.length < 2) {
ctx.fillStyle = '#475569';
ctx.font = '13px system-ui';
ctx.textAlign = 'center';
ctx.fillText('Noch keine Daten', W/2, H/2);
return; return;
} }
lastScan = data; const tsMin = Math.min(...allPts.map(p => p.ts));
setStatus(`&#x2713; ${data.manufacturer} ${data.model}${data.sensors.length} Sensoren gelesen`, 'ok'); const tsMax = Math.max(...allPts.map(p => p.ts));
let vMin = opts.yMin ?? Math.min(...allPts.map(p => p.v));
let vMax = opts.yMax ?? Math.max(...allPts.map(p => p.v));
if (vMin === vMax) { vMin -= 1; vMax += 1; }
const vPad = (vMax - vMin) * 0.08;
vMin -= vPad; vMax += vPad;
const tbody = document.getElementById('sensorBody'); const tx = ts => PAD.left + (ts - tsMin) / (tsMax - tsMin) * iW;
tbody.innerHTML = ''; const ty = v => PAD.top + (1 - (v - vMin) / (vMax - vMin)) * iH;
for (const s of data.sensors) {
const val = s.value !== null ? s.value : '—'; // Grid
const tr = document.createElement('tr'); ctx.strokeStyle = '#1e293b'; ctx.lineWidth = 1;
tr.innerHTML = ` for (let i = 0; i <= 4; i++) {
<td class="sensor-name">${esc(s.name)}</td> const y = PAD.top + iH * i / 4;
<td class="val">${val}</td> ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left + iW, y); ctx.stroke();
<td class="unit">${esc(s.unit || '')}</td>`; const val = vMax - (vMax - vMin) * i / 4;
tbody.appendChild(tr); ctx.fillStyle = '#475569'; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
ctx.fillText(val.toFixed(val > 100 ? 0 : 1), PAD.left - 4, y + 4);
} }
} catch (e) {
setStatus('&#x26A0; Netzwerkfehler: ' + e.message, 'error'); // X-Achse Zeitstempel
} finally { const fmtTs = ts => {
btn.disabled = false; const d = new Date(ts * 1000);
btn.textContent = 'Auslesen'; if (chartHours <= 6) return d.toLocaleTimeString('de',{hour:'2-digit',minute:'2-digit'});
return d.toLocaleDateString('de',{weekday:'short',hour:'2-digit',minute:'2-digit'});
};
ctx.fillStyle = '#475569'; ctx.font = '10px system-ui'; ctx.textAlign = 'center';
[0, 0.25, 0.5, 0.75, 1].forEach(f => {
const ts = tsMin + (tsMax - tsMin) * f;
ctx.fillText(fmtTs(ts), PAD.left + iW * f, H - 6);
});
// Series
series.forEach(s => {
if (!s.points.length) return;
ctx.beginPath();
ctx.strokeStyle = s.color;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
s.points.forEach((p, i) => {
const x = tx(p.ts), y = ty(p.v);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
});
// Legend
if (legendId) {
document.getElementById(legendId).innerHTML = series.map(s =>
`<div class="legend-item">
<div class="legend-dot" style="background:${s.color}"></div>
<span>${esc(s.label)}</span>
</div>`
).join('');
} }
} }
// ── Raw ──────────────────────────────────────────────────────
async function rawScan() { async function rawScan() {
setStatus('Lese Register…', 'info'); const alert = document.getElementById('rawAlert');
const s = currentStatus;
const cfg = s?.cfg || {ip:'10.0.0.100', port:502, slave:1};
alert.innerHTML = '<div class="alert alert-info">Lese Register…</div>';
try { try {
const res = await fetch('/api/raw', { const res = await fetch('/api/raw', {method:'POST',
method: 'POST',
headers:{'Content-Type':'application/json'}, headers:{'Content-Type':'application/json'},
body: JSON.stringify({ body: JSON.stringify({
ip: document.getElementById('ip').value, ip: cfg.ip, port: cfg.port, slave: cfg.slave,
port: parseInt(document.getElementById('port').value),
slave: parseInt(document.getElementById('slave').value),
start: parseInt(document.getElementById('rawStart').value), start: parseInt(document.getElementById('rawStart').value),
count: parseInt(document.getElementById('rawCount').value), count: parseInt(document.getElementById('rawCount').value),
}) })
}); });
const data = await res.json(); const d = await res.json();
if (!data.ok) { setStatus('&#x26A0; ' + data.error, 'error'); return; } if (!d.ok) { alert.innerHTML = `<div class="alert alert-err">${esc(d.error)}</div>`; return; }
document.getElementById('rawBody').innerHTML = d.registers.map(r =>
const tbody = document.getElementById('rawBody'); `<tr><td>${r.addr}</td><td>${r.hex}</td><td>${r.dec}</td><td>${r.signed}</td></tr>`
tbody.innerHTML = ''; ).join('');
for (const r of data.registers) { alert.innerHTML = `<div class="alert alert-ok">&#x2713; ${d.registers.length} Register</div>`;
const tr = document.createElement('tr');
tr.innerHTML = `<td>${r.addr}</td><td>${r.hex}</td><td>${r.dec}</td><td>${r.signed}</td>`;
tbody.appendChild(tr);
}
setStatus(`&#x2713; ${data.registers.length} Register gelesen`, 'ok');
} catch(e) { } catch(e) {
setStatus('&#x26A0; ' + e.message, 'error'); alert.innerHTML = `<div class="alert alert-err">${esc(e.message)}</div>`;
} }
} }
// ── Export ───────────────────────────────────────────────────
function exportJson() { function exportJson() {
if (!lastScan) return; if (!currentStatus) return;
const blob = new Blob([JSON.stringify(lastScan, null, 2)], {type: 'application/json'}); const blob = new Blob([JSON.stringify(currentStatus, null, 2)], {type:'application/json'});
const a = document.createElement('a'); const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = `shinediag_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.json`; a.download = `shinediag_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.json`;
a.click(); a.click();
} }
// ── Helpers ──────────────────────────────────────────────────
function esc(s) { function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
loadModels(); // ── Init ─────────────────────────────────────────────────────
async function init() {
await loadModels();
const savedIp = localStorage.getItem('shinediag_ip');
if (savedIp) document.getElementById('cfgIp').value = savedIp;
// Reconnect if server already polling
const res = await fetch('/api/status');
const s = await res.json();
if (s.connected) {
currentStatus = s;
renderDashboard(s);
startRefresh();
}
}
init();
</script> </script>
</body> </body>
</html> </html>