HAOS Add-on v1.1.1: Sparkline-Graphen (letzte 5 Minuten)

- SVG-Sparkline pro Sensor-Karte, farbkodiert nach device_class
- Backend: (timestamp, value) deque pro Sensor, API filtert auf 300s
- Kein Datenverlust bei Neustart (In-Memory, reicht für Trendanzeige)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-26 12:14:24 +02:00
parent 35a3c01e36
commit 12586fa383
3 changed files with 42 additions and 1 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: Growatt ShineLAN-X name: Growatt ShineLAN-X
version: "1.1.0" version: "1.1.1"
slug: growatt_shinelan_x slug: growatt_shinelan_x
description: Growatt Wechselrichter via ShineLAN-X (NuttX Modbus TCP) - MQTT Discovery + Web UI description: Growatt Wechselrichter via ShineLAN-X (NuttX Modbus TCP) - MQTT Discovery + Web UI
url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS
+14
View File
@@ -4,6 +4,7 @@ import os
import threading import threading
import time import time
import uuid import uuid
from collections import deque
from typing import Any, Dict, List, Optional 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
@@ -112,6 +113,12 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
d["last_update"] = time.time() d["last_update"] = time.time()
d["modbus_ok"] = True d["modbus_ok"] = True
d["poll_count"] = d.get("poll_count", 0) + 1 d["poll_count"] = d.get("poll_count", 0) + 1
# History: (timestamp, value) pro Sensor, maximal 5 Minuten
hist = d.setdefault("history", {})
now = time.time()
for sid, val in values.items():
q = hist.setdefault(sid, deque(maxlen=300))
q.append((now, val))
if _publisher: if _publisher:
_publisher.publish_data(values, prefix) _publisher.publish_data(values, prefix)
_publisher.publish_status("online", prefix) _publisher.publish_status("online", prefix)
@@ -209,10 +216,17 @@ def api_get_data():
model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X") model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
inverter = INVERTERS.get(model_id, INVERTERS["MIC_1500_TL_X"]) inverter = INVERTERS.get(model_id, INVERTERS["MIC_1500_TL_X"])
d = State.inv_data.get(inv_id, {}) d = State.inv_data.get(inv_id, {})
cutoff = time.time() - 300 # letzte 5 Minuten
raw_hist = d.get("history", {})
history = {
sid: [v for (t, v) in q if t >= cutoff]
for sid, q in raw_hist.items()
}
result[inv_id] = { result[inv_id] = {
"name": inv_cfg.get("name", inverter.name), "name": inv_cfg.get("name", inverter.name),
"inverter_name": inverter.name, "inverter_name": inverter.name,
"values": d.get("values", {}), "values": d.get("values", {}),
"history": history,
"sensors": [ "sensors": [
{"id": s.id, "name": s.name, "unit": s.unit, {"id": s.id, "name": s.name, "unit": s.unit,
"icon": s.icon, "device_class": s.device_class} "icon": s.icon, "device_class": s.device_class}
+27
View File
@@ -64,6 +64,7 @@
.sensor-value { font-size: 20px; font-weight: 700; .sensor-value { font-size: 20px; font-weight: 700;
font-variant-numeric: tabular-nums; } font-variant-numeric: tabular-nums; }
.sensor-unit { font-size: 11px; color: var(--text-dim); margin-left: 2px; } .sensor-unit { font-size: 11px; color: var(--text-dim); margin-left: 2px; }
.sparkline { margin-top: 8px; opacity: .7; }
.dc-power .sensor-value { color: var(--accent); } .dc-power .sensor-value { color: var(--accent); }
.dc-voltage .sensor-value { color: var(--blue); } .dc-voltage .sensor-value { color: var(--blue); }
.dc-current .sensor-value { color: var(--orange); } .dc-current .sensor-value { color: var(--orange); }
@@ -220,6 +221,30 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script> <script>
const DC_COLORS = {
power: '#f0c040', voltage: '#58a6ff', current: '#ffa657',
energy: '#3fb950', temperature: '#f85149', battery: '#bc8cff',
frequency: '#8b949e', default: '#8b949e',
};
function sparkline(values, dc) {
if (!values || values.length < 2) return '';
const color = DC_COLORS[dc] || DC_COLORS.default;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const W = 138, H = 28, pad = 1;
const pts = values.map((v, i) => {
const x = pad + (i / (values.length - 1)) * (W - pad * 2);
const y = pad + (1 - (v - min) / range) * (H - pad * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg class="sparkline" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<polyline points="${pts}" fill="none" stroke="${color}"
stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
</svg>`;
}
const ICON_MAP = { const ICON_MAP = {
"mdi:solar-panel":"☀️","mdi:flash":"⚡","mdi:sine-wave":"〜", "mdi:solar-panel":"☀️","mdi:flash":"⚡","mdi:sine-wave":"〜",
"mdi:solar-power":"🔆","mdi:thermometer":"🌡️","mdi:battery":"🔋", "mdi:solar-power":"🔆","mdi:thermometer":"🌡️","mdi:battery":"🔋",
@@ -286,10 +311,12 @@ function renderLive(inverters) {
const val = inv.values[s.id]; const val = inv.values[s.id];
const display = val !== undefined ? fmtVal(val) : "—"; const display = val !== undefined ? fmtVal(val) : "—";
const dcClass = s.device_class ? `dc-${s.device_class}` : ""; const dcClass = s.device_class ? `dc-${s.device_class}` : "";
const hist = (inv.history || {})[s.id] || [];
return `<div class="sensor-card ${dcClass}"> return `<div class="sensor-card ${dcClass}">
<div class="sensor-icon">${ICON_MAP[s.icon]||"📊"}</div> <div class="sensor-icon">${ICON_MAP[s.icon]||"📊"}</div>
<div class="sensor-name">${s.name}</div> <div class="sensor-name">${s.name}</div>
<div class="sensor-value">${display}<span class="sensor-unit">${s.unit}</span></div> <div class="sensor-value">${display}<span class="sensor-unit">${s.unit}</span></div>
${sparkline(hist, s.device_class)}
</div>`; </div>`;
}).join(""); }).join("");
return `<div class="inv-section"> return `<div class="inv-section">