HAOS Add-on v1.1.0: Multi-Wechselrichter Support

- Unbegrenzt viele Wechselrichter über Web UI verwaltbar (Add/Edit/Delete)
- Pro Wechselrichter: eigener Poll-Thread, MQTT-Topic-Präfix, HA Device
- Shared MQTT-Publisher: eine Verbindung für alle Wechselrichter
- Migration: bestehende Single-Inverter-Config wird automatisch übernommen
- Live-Daten: pro Wechselrichter mit Online/Offline-Badge
- config.yaml: nur noch MQTT global, Wechselrichter über /data/config.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-26 11:17:06 +02:00
parent 4fd54025ad
commit 35a3c01e36
4 changed files with 549 additions and 619 deletions
+342 -442
View File
@@ -6,260 +6,139 @@
<title>Growatt ShineLAN-X</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #f0c040;
--green: #3fb950;
--red: #f85149;
--blue: #58a6ff;
--orange: #ffa657;
--purple: #bc8cff;
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
--border: #30363d; --text: #e6edf3; --text-dim: #8b949e;
--accent: #f0c040; --green: #3fb950; --red: #f85149;
--blue: #58a6ff; --orange: #ffa657; --purple: #bc8cff;
--radius: 10px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
body { background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
min-height: 100vh;
}
font-size: 14px; min-height: 100vh; }
/* ── Header ── */
header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: var(--surface);
header { display: flex; align-items: center; gap: 12px;
padding: 16px 20px; background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
header svg { flex-shrink: 0; }
position: sticky; top: 0; z-index: 100; }
header h1 { font-size: 16px; font-weight: 600; }
header .subtitle { font-size: 12px; color: var(--text-dim); }
.status-pill {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
}
.pill {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
background: var(--surface2);
border: 1px solid var(--border);
}
.status-pill { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.pill { display: flex; align-items: center; gap: 5px; padding: 4px 10px;
border-radius: 20px; font-size: 12px; font-weight: 600;
background: var(--surface2); border: 1px solid var(--border); }
.pill.ok { color: var(--green); border-color: var(--green); }
.pill.err { color: var(--red); border-color: var(--red); }
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: currentColor;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor;
animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
/* ── Layout ── */
main { padding: 20px; max-width: 1100px; margin: 0 auto; }
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.tab {
padding: 10px 16px;
cursor: pointer;
color: var(--text-dim);
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color .15s, border-color .15s;
}
.tabs { display: flex; gap: 4px; margin-bottom: 20px;
border-bottom: 1px solid var(--border); }
.tab { padding: 10px 16px; cursor: pointer; color: var(--text-dim);
font-weight: 500; border-bottom: 2px solid transparent;
margin-bottom: -1px; transition: color .15s, border-color .15s; }
.tab.active { color: var(--accent); border-color: var(--accent); }
.tab:hover { color: var(--text); }
.panel { display: none; }
.panel.active { display: block; }
/* ── Sensor Grid ── */
.sensor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.sensor-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
transition: border-color .15s;
}
.sensor-card:hover { border-color: var(--text-dim); }
.sensor-icon {
font-size: 20px;
margin-bottom: 8px;
}
.sensor-name {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .05em;
margin-bottom: 6px;
}
.sensor-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text);
}
.sensor-unit {
font-size: 12px;
color: var(--text-dim);
margin-left: 3px;
}
/* Color coding by device class */
/* Sensor Grid */
.inv-section { margin-bottom: 28px; }
.inv-header { display: flex; align-items: center; gap: 10px;
margin-bottom: 12px; }
.inv-title { font-size: 15px; font-weight: 600; }
.inv-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px;
background: var(--surface2); border: 1px solid var(--border);
color: var(--text-dim); }
.inv-badge.ok { color: var(--green); border-color: var(--green); }
.inv-badge.err { color: var(--red); border-color: var(--red); }
.sensor-grid { display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; }
.sensor-card { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; }
.sensor-icon { font-size: 18px; margin-bottom: 6px; }
.sensor-name { font-size: 11px; color: var(--text-dim);
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
.sensor-value { font-size: 20px; font-weight: 700;
font-variant-numeric: tabular-nums; }
.sensor-unit { font-size: 11px; color: var(--text-dim); margin-left: 2px; }
.dc-power .sensor-value { color: var(--accent); }
.dc-voltage .sensor-value { color: var(--blue); }
.dc-current .sensor-value { color: var(--orange); }
.dc-energy .sensor-value { color: var(--green); }
.dc-temperature .sensor-value { color: var(--red); }
.dc-battery .sensor-value { color: var(--purple); }
.dc-frequency .sensor-value { color: var(--text); }
.no-data { text-align: center; padding: 40px 20px; color: var(--text-dim); font-size: 13px; }
/* ── Config Form ── */
.config-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 640px) { .config-grid { grid-template-columns: 1fr; } }
.config-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.config-section h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .06em;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
/* Inverter management */
.inv-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.inv-card { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 18px; }
.inv-card-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
.inv-card-icon { font-size: 28px; }
.inv-card-info { flex: 1; }
.inv-card-name { font-weight: 600; font-size: 15px; margin-bottom: 2px; }
.inv-card-model { font-size: 12px; color: var(--text-dim); }
.inv-card-actions { display: flex; gap: 6px; margin-top: 14px; }
.btn { padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface2); color: var(--text); cursor: pointer;
font-size: 12px; font-weight: 500; transition: border-color .15s; }
.btn:hover { border-color: var(--text-dim); }
.btn-danger { color: var(--red); border-color: var(--red); }
.btn-danger:hover { background: rgba(248,81,73,.1); }
.btn-primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 700; }
.btn-primary:hover { opacity: .85; }
.inv-card-meta { font-size: 12px; color: var(--text-dim); line-height: 1.7; }
.add-btn { display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 20px; background: var(--surface); border: 2px dashed var(--border);
border-radius: var(--radius); cursor: pointer; color: var(--text-dim);
font-size: 14px; transition: border-color .15s, color .15s; }
.add-btn:hover { border-color: var(--accent); color: var(--accent); }
/* Settings */
.settings-section { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; max-width: 500px; }
.settings-section h3 { font-size: 13px; font-weight: 600; color: var(--text-dim);
text-transform: uppercase; letter-spacing: .06em;
margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
.field { margin-bottom: 14px; }
.field label {
display: block;
font-size: 12px;
color: var(--text-dim);
margin-bottom: 5px;
}
.field input, .field select {
width: 100%;
padding: 8px 10px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
outline: none;
transition: border-color .15s;
}
.field input:focus, .field select:focus {
border-color: var(--accent);
}
.field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 5px; }
.field input, .field select { width: 100%; padding: 8px 10px;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 13px; outline: none;
transition: border-color .15s; }
.field input:focus, .field select:focus { border-color: var(--accent); }
.field select option { background: var(--surface2); }
.inverter-select-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
grid-column: 1 / -1;
}
.inverter-card {
padding: 12px;
background: var(--surface2);
border: 2px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: border-color .15s, background .15s;
text-align: center;
}
.inverter-card:hover { border-color: var(--text-dim); }
.inverter-card.selected { border-color: var(--accent); background: rgba(240,192,64,0.08); }
.inverter-card .inv-name { font-weight: 600; font-size: 13px; margin-bottom: 3px; }
.inverter-card .inv-sensors { font-size: 11px; color: var(--text-dim); }
.save-btn {
margin-top: 20px;
padding: 10px 24px;
background: var(--accent);
color: #000;
font-weight: 700;
font-size: 14px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: opacity .15s;
}
.save-btn:hover { opacity: 0.85; }
.save-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
z-index: 999;
transform: translateY(80px);
opacity: 0;
transition: transform .3s, opacity .3s;
pointer-events: none;
}
/* Modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.7);
z-index: 200; display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .2s; }
.modal-backdrop.open { opacity: 1; pointer-events: all; }
.modal { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 24px; width: 100%; max-width: 440px;
max-height: 90vh; overflow-y: auto; }
.modal h2 { font-size: 16px; margin-bottom: 20px; }
.modal-actions { display: flex; gap: 8px; margin-top: 20px; justify-content: flex-end; }
/* Info row */
.info-row { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
.info-chip { padding: 5px 12px; background: var(--surface);
border: 1px solid var(--border); border-radius: 20px;
font-size: 12px; color: var(--text-dim); }
.info-chip span { color: var(--text); font-weight: 600; }
/* Toast */
.toast { position: fixed; bottom: 24px; right: 24px; padding: 12px 20px;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; font-size: 13px; z-index: 999;
transform: translateY(80px); opacity: 0;
transition: transform .3s, opacity .3s; pointer-events: none; }
.toast.show { transform: translateY(0); opacity: 1; }
.toast.ok { border-color: var(--green); color: var(--green); }
.toast.err { border-color: var(--red); color: var(--red); }
/* ── Info row ── */
.info-row {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.info-chip {
padding: 6px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 12px;
color: var(--text-dim);
}
.info-chip span { color: var(--text); font-weight: 600; }
.no-data {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.no-data p { margin-top: 8px; font-size: 13px; }
</style>
</head>
<body>
@@ -274,7 +153,6 @@
<div class="subtitle" id="subtitle">Lade...</div>
</div>
<div class="status-pill">
<div class="pill" id="pill-modbus"><div class="dot"></div>Modbus</div>
<div class="pill" id="pill-mqtt"><div class="dot"></div>MQTT</div>
</div>
</header>
@@ -282,106 +160,81 @@
<main>
<div class="tabs">
<div class="tab active" onclick="switchTab('live')">Live-Daten</div>
<div class="tab" onclick="switchTab('config')">Konfiguration</div>
<div class="tab" onclick="switchTab('inverters')">Wechselrichter</div>
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
</div>
<!-- Live Panel -->
<div class="panel active" id="panel-live">
<div class="info-row" id="info-row"></div>
<div class="sensor-grid" id="sensor-grid">
<div class="no-data">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#8b949e" stroke-width="1.5">
<path d="M12 22V12M12 12L8 16M12 12L16 16"/>
<path d="M20 16.5A4.5 4.5 0 0 0 12 8a6 6 0 1 0-8 5.66"/>
</svg>
<p>Warte auf erste Messung...</p>
</div>
<div id="live-content"><div class="no-data">Warte auf erste Messung...</div></div>
</div>
<!-- Inverters Panel -->
<div class="panel" id="panel-inverters">
<div class="inv-list" id="inv-list"></div>
</div>
<!-- Settings Panel -->
<div class="panel" id="panel-settings">
<div class="settings-section">
<h3>MQTT Broker</h3>
<div class="field"><label>Broker</label>
<input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto"></div>
<div class="field"><label>Port</label>
<input type="number" id="cfg-mqtt-port" placeholder="1883"></div>
<div class="field"><label>Benutzername</label>
<input type="text" id="cfg-mqtt-user" autocomplete="off"></div>
<div class="field"><label>Passwort</label>
<input type="password" id="cfg-mqtt-pass" placeholder="leer = unverändert"></div>
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
</div>
</div>
<!-- Config Panel -->
<div class="panel" id="panel-config">
<form id="config-form" onsubmit="saveConfig(event)">
<div class="config-section" style="margin-bottom:20px">
<h3>Wechselrichter-Modell</h3>
<div class="inverter-select-grid" id="inverter-grid"></div>
<input type="hidden" id="cfg-inverter" name="inverter_model">
</div>
<div class="config-grid">
<div class="config-section">
<h3>Modbus TCP</h3>
<div class="field">
<label>IP-Adresse des ShineLAN-X</label>
<input type="text" id="cfg-modbus-ip" placeholder="10.10.20.190" pattern="\d+\.\d+\.\d+\.\d+">
</div>
<div class="field">
<label>Port</label>
<input type="number" id="cfg-modbus-port" placeholder="502" min="1" max="65535">
</div>
<div class="field">
<label>Modbus Slave-Adresse</label>
<input type="number" id="cfg-modbus-addr" placeholder="1" min="1" max="247">
</div>
<div class="field">
<label>Abfrageintervall (Sekunden)</label>
<input type="number" id="cfg-interval" placeholder="30" min="5" max="3600">
</div>
</div>
<div class="config-section">
<h3>MQTT</h3>
<div class="field">
<label>Broker</label>
<input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto oder IP">
</div>
<div class="field">
<label>Port</label>
<input type="number" id="cfg-mqtt-port" placeholder="1883" min="1" max="65535">
</div>
<div class="field">
<label>Benutzername</label>
<input type="text" id="cfg-mqtt-user" placeholder="optional" autocomplete="off">
</div>
<div class="field">
<label>Passwort</label>
<input type="password" id="cfg-mqtt-pass" placeholder="leer lassen = unverändert" autocomplete="new-password">
</div>
<div class="field">
<label>Topic-Präfix</label>
<input type="text" id="cfg-mqtt-prefix" placeholder="growatt/shinelanx">
</div>
</div>
</div>
<button type="submit" class="save-btn" id="save-btn">Speichern & Neu starten</button>
</form>
</div>
</main>
<!-- Inverter Edit Modal -->
<div class="modal-backdrop" id="modal-backdrop" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<h2 id="modal-title">Wechselrichter hinzufügen</h2>
<input type="hidden" id="modal-id">
<div class="field"><label>Name</label>
<input type="text" id="modal-name" placeholder="z.B. Dach Süd"></div>
<div class="field"><label>Modell</label>
<select id="modal-model"></select></div>
<div class="field"><label>IP-Adresse</label>
<input type="text" id="modal-ip" placeholder="10.10.20.190"></div>
<div class="field"><label>Modbus Port</label>
<input type="number" id="modal-port" value="502"></div>
<div class="field"><label>Modbus Slave-Adresse</label>
<input type="number" id="modal-addr" value="1" min="1" max="247"></div>
<div class="field"><label>MQTT Topic-Präfix</label>
<input type="text" id="modal-prefix" placeholder="growatt/wechselrichter1"></div>
<div class="field"><label>Abfrageintervall (Sekunden)</label>
<input type="number" id="modal-interval" value="30" min="5"></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="saveInverter()">Speichern</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const ICON_MAP = {
"mdi:solar-panel": "☀️",
"mdi:flash": "",
"mdi:sine-wave": "",
"mdi:solar-power": "🔋",
"mdi:thermometer": "🌡️",
"mdi:battery": "🔋",
"mdi:battery-minus": "🪫",
"mdi:battery-plus": "⚡",
"mdi:transmission-tower-export": "📤",
"mdi:transmission-tower-import": "📥",
"mdi:solar-panel":"☀️","mdi:flash":"⚡","mdi:sine-wave":"〜",
"mdi:solar-power":"🔆","mdi:thermometer":"🌡️","mdi:battery":"🔋",
"mdi:battery-minus":"🪫","mdi:battery-plus":"",
"mdi:transmission-tower-export":"📤","mdi:transmission-tower-import":"📥",
};
let currentConfig = {};
let inverterList = {};
let refreshTimer = null;
// Basis-URL relativ zur aktuellen Seite (funktioniert hinter HA Ingress-Proxy)
const BASE = new URL("./", window.location.href).pathname;
function apiUrl(path) { return BASE + path; }
const api = p => BASE + p;
let globalConfig = {};
let invertersList = [];
let modelsList = {};
let liveData = {};
let refreshTimer = null;
async function fetchJSON(url, opts) {
const r = await fetch(url, opts);
@@ -397,176 +250,223 @@ function showToast(msg, type) {
el._t = setTimeout(() => el.className = "toast", 3000);
}
// ── Tab switching ──
function switchTab(name) {
document.querySelectorAll(".tab").forEach((t, i) => {
t.classList.toggle("active", ["live", "config"][i] === name);
["live","inverters","settings"].forEach((t, i) => {
document.querySelectorAll(".tab")[i].classList.toggle("active", t === name);
document.querySelectorAll(".panel")[i].classList.toggle("active", t === name);
});
document.querySelectorAll(".panel").forEach((p, i) => {
p.classList.toggle("active", ["panel-live", "panel-config"][i] === `panel-${name}`);
});
if (name === "live") startRefresh();
else stopRefresh();
if (name === "live") startRefresh(); else stopRefresh();
}
// ── Live data ──
// ── Live Data ─────────────────────────────────────────────────
async function refreshData() {
try {
const d = await fetchJSON(apiUrl("api/data"));
updateStatus(d.modbus_ok, d.mqtt_ok);
updateSubtitle(d);
updateInfoRow(d);
updateGrid(d);
} catch (e) {
updateStatus(false, false);
const d = await fetchJSON(api("api/data"));
liveData = d;
document.getElementById("pill-mqtt").className = `pill ${d.mqtt_ok ? "ok" : "err"}`;
const keys = Object.keys(d.inverters || {});
document.getElementById("subtitle").textContent =
keys.length ? `${keys.length} Wechselrichter` : "Keine Wechselrichter";
renderLive(d.inverters || {});
} catch(e) {
document.getElementById("pill-mqtt").className = "pill err";
}
}
function updateStatus(modbus, mqtt) {
const pm = document.getElementById("pill-modbus");
const pq = document.getElementById("pill-mqtt");
pm.className = `pill ${modbus ? "ok" : "err"}`;
pq.className = `pill ${mqtt ? "ok" : "err"}`;
}
function updateSubtitle(d) {
const inv = currentConfig.inverter_model || "";
const name = (inverterList[inv] || {}).name || inv;
document.getElementById("subtitle").textContent = name;
}
function updateInfoRow(d) {
if (!d.last_update) return;
const ago = Math.round(Date.now() / 1000 - d.last_update);
const ip = currentConfig.modbus_ip || "";
document.getElementById("info-row").innerHTML = `
<div class="info-chip">Letzte Messung <span>${ago}s</span> vor</div>
<div class="info-chip">Messungen <span>${d.poll_count}</span></div>
<div class="info-chip">ShineLAN-X <span>${ip}:${currentConfig.modbus_port || 502}</span></div>
`;
}
function updateGrid(d) {
const grid = document.getElementById("sensor-grid");
if (!d.last_update || !d.sensors || d.sensors.length === 0) return;
grid.innerHTML = d.sensors.map(s => {
const val = d.values[s.id];
const display = val !== undefined ? formatVal(val) : "—";
const dcClass = s.device_class ? `dc-${s.device_class}` : "";
const icon = ICON_MAP[s.icon] || "📊";
return `<div class="sensor-card ${dcClass}">
<div class="sensor-icon">${icon}</div>
<div class="sensor-name">${s.name}</div>
<div class="sensor-value">${display}<span class="sensor-unit">${s.unit}</span></div>
function renderLive(inverters) {
const el = document.getElementById("live-content");
if (!Object.keys(inverters).length) {
el.innerHTML = '<div class="no-data">Keine Wechselrichter konfiguriert.<br>Bitte im Tab „Wechselrichter" hinzufügen.</div>';
return;
}
el.innerHTML = Object.values(inverters).map(inv => {
const ago = inv.last_update ? Math.round(Date.now()/1000 - inv.last_update) + "s" : "—";
const cards = (inv.sensors || []).map(s => {
const val = inv.values[s.id];
const display = val !== undefined ? fmtVal(val) : "—";
const dcClass = s.device_class ? `dc-${s.device_class}` : "";
return `<div class="sensor-card ${dcClass}">
<div class="sensor-icon">${ICON_MAP[s.icon]||"📊"}</div>
<div class="sensor-name">${s.name}</div>
<div class="sensor-value">${display}<span class="sensor-unit">${s.unit}</span></div>
</div>`;
}).join("");
return `<div class="inv-section">
<div class="inv-header">
<div class="inv-title">${inv.name}</div>
<div class="inv-badge ${inv.modbus_ok ? "ok" : "err"}">${inv.modbus_ok ? "online" : "offline"}</div>
<div class="info-chip" style="margin-left:auto;font-size:11px">⏱ ${ago} vor · ${inv.poll_count} Messungen</div>
</div>
<div class="sensor-grid">${cards || '<div class="no-data">Warte...</div>'}</div>
</div>`;
}).join("");
}
function formatVal(v) {
if (v >= 1000) return (v / 1000).toFixed(2).replace(".", ",") + "k";
function fmtVal(v) {
if (v >= 1000) return (v/1000).toFixed(2).replace(".",",") + "k";
if (v % 1 === 0) return v.toString();
return v.toFixed(v < 10 ? 2 : 1).replace(".", ",");
return v.toFixed(v < 10 ? 2 : 1).replace(".",",");
}
function startRefresh() {
stopRefresh();
refreshData();
refreshTimer = setInterval(refreshData, 5000);
}
function startRefresh() { stopRefresh(); refreshData(); refreshTimer = setInterval(refreshData, 5000); }
function stopRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
function stopRefresh() {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
}
// ── Config ──
async function loadConfig() {
try {
currentConfig = await fetchJSON(apiUrl("api/config"));
fillForm(currentConfig);
} catch (e) {
showToast("Konfiguration konnte nicht geladen werden", "err");
}
}
// ── Inverter Management ───────────────────────────────────────
async function loadInverters() {
try {
inverterList = await fetchJSON(apiUrl("api/inverters"));
buildInverterGrid(inverterList, currentConfig.inverter_model);
} catch (e) {}
invertersList = await fetchJSON(api("api/inverters-config"));
renderInverterList();
}
function fillForm(cfg) {
document.getElementById("cfg-modbus-ip").value = cfg.modbus_ip || "";
document.getElementById("cfg-modbus-port").value = cfg.modbus_port || 502;
document.getElementById("cfg-modbus-addr").value = cfg.modbus_address || 1;
document.getElementById("cfg-interval").value = cfg.update_interval || 30;
document.getElementById("cfg-mqtt-broker").value = cfg.mqtt_broker || "";
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883;
document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || "";
document.getElementById("cfg-mqtt-prefix").value = cfg.mqtt_topic_prefix || "growatt/shinelanx";
document.getElementById("cfg-inverter").value = cfg.inverter_model || "MIC_1500_TL_X";
async function loadModels() {
modelsList = await fetchJSON(api("api/inverter-models"));
const sel = document.getElementById("modal-model");
sel.innerHTML = Object.values(modelsList).map(m =>
`<option value="${m.id}">${m.name} (${m.sensor_count} Sensoren)</option>`
).join("");
}
function buildInverterGrid(list, selected) {
const grid = document.getElementById("inverter-grid");
grid.innerHTML = Object.values(list).map(inv => `
<div class="inverter-card ${inv.id === selected ? "selected" : ""}"
onclick="selectInverter('${inv.id}', this)">
<div class="inv-name">${inv.name}</div>
<div class="inv-sensors">${inv.sensor_count} Sensoren</div>
</div>
`).join("");
function renderInverterList() {
const el = document.getElementById("inv-list");
const cards = invertersList.map(inv => {
const model = modelsList[inv.inverter_model] || {};
return `<div class="inv-card">
<div class="inv-card-header">
<div class="inv-card-icon">☀️</div>
<div class="inv-card-info">
<div class="inv-card-name">${inv.name || "Wechselrichter"}</div>
<div class="inv-card-model">${model.name || inv.inverter_model}</div>
</div>
</div>
<div class="inv-card-meta">
📡 ${inv.modbus_ip}:${inv.modbus_port || 502} · Slave ${inv.modbus_address || 1}<br>
📨 ${inv.mqtt_topic_prefix}<br>
⏱ alle ${inv.update_interval || 30}s
</div>
<div class="inv-card-actions">
<button class="btn" onclick="editInverter('${inv.id}')">Bearbeiten</button>
<button class="btn btn-danger" onclick="deleteInverter('${inv.id}')">Löschen</button>
</div>
</div>`;
}).join("");
el.innerHTML = cards + `<div class="add-btn" onclick="openModal()"> Wechselrichter hinzufügen</div>`;
}
function selectInverter(id, el) {
document.querySelectorAll(".inverter-card").forEach(c => c.classList.remove("selected"));
el.classList.add("selected");
document.getElementById("cfg-inverter").value = id;
async function openModal(invId) {
await loadModels();
const modal = document.getElementById("modal-backdrop");
if (invId) {
const inv = invertersList.find(i => i.id === invId) || {};
document.getElementById("modal-title").textContent = "Wechselrichter bearbeiten";
document.getElementById("modal-id").value = inv.id || "";
document.getElementById("modal-name").value = inv.name || "";
document.getElementById("modal-model").value = inv.inverter_model || "MIC_1500_TL_X";
document.getElementById("modal-ip").value = inv.modbus_ip || "";
document.getElementById("modal-port").value = inv.modbus_port || 502;
document.getElementById("modal-addr").value = inv.modbus_address || 1;
document.getElementById("modal-prefix").value = inv.mqtt_topic_prefix || "";
document.getElementById("modal-interval").value = inv.update_interval || 30;
} else {
document.getElementById("modal-title").textContent = "Wechselrichter hinzufügen";
document.getElementById("modal-id").value = "";
document.getElementById("modal-name").value = "";
document.getElementById("modal-ip").value = "";
document.getElementById("modal-port").value = "502";
document.getElementById("modal-addr").value = "1";
document.getElementById("modal-prefix").value = "growatt/wechselrichter" + (invertersList.length + 1);
document.getElementById("modal-interval").value = "30";
}
modal.classList.add("open");
}
async function saveConfig(e) {
e.preventDefault();
const btn = document.getElementById("save-btn");
btn.disabled = true;
function editInverter(id) { openModal(id); }
const body = {
modbus_ip: document.getElementById("cfg-modbus-ip").value.trim(),
modbus_port: parseInt(document.getElementById("cfg-modbus-port").value),
modbus_address: parseInt(document.getElementById("cfg-modbus-addr").value),
update_interval: parseInt(document.getElementById("cfg-interval").value),
mqtt_broker: document.getElementById("cfg-mqtt-broker").value.trim(),
mqtt_port: parseInt(document.getElementById("cfg-mqtt-port").value),
mqtt_user: document.getElementById("cfg-mqtt-user").value,
mqtt_pass: document.getElementById("cfg-mqtt-pass").value,
mqtt_topic_prefix: document.getElementById("cfg-mqtt-prefix").value.trim(),
inverter_model: document.getElementById("cfg-inverter").value,
function closeModal(e) {
if (!e || e.target === document.getElementById("modal-backdrop"))
document.getElementById("modal-backdrop").classList.remove("open");
}
async function saveInverter() {
let id = document.getElementById("modal-id").value;
if (!id) {
const r = await fetchJSON(api("api/new-id"), {method: "POST"});
id = r.id;
}
const inv = {
id,
name: document.getElementById("modal-name").value.trim() || "Wechselrichter",
inverter_model: document.getElementById("modal-model").value,
modbus_ip: document.getElementById("modal-ip").value.trim(),
modbus_port: parseInt(document.getElementById("modal-port").value),
modbus_address: parseInt(document.getElementById("modal-addr").value),
mqtt_topic_prefix: document.getElementById("modal-prefix").value.trim(),
update_interval: parseInt(document.getElementById("modal-interval").value),
};
const idx = invertersList.findIndex(i => i.id === id);
if (idx >= 0) invertersList[idx] = inv; else invertersList.push(inv);
try {
await fetchJSON(apiUrl("api/config"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
await fetchJSON(api("api/inverters-config"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify(invertersList),
});
currentConfig = { ...currentConfig, ...body };
showToast("Gespeichert! Neustart...", "ok");
setTimeout(loadConfig, 2000);
} catch (e) {
closeModal();
renderInverterList();
showToast("Gespeichert!", "ok");
} catch(e) {
showToast("Fehler beim Speichern", "err");
} finally {
btn.disabled = false;
}
}
// ── Init ──
async function deleteInverter(id) {
if (!confirm("Wechselrichter wirklich löschen?")) return;
invertersList = invertersList.filter(i => i.id !== id);
try {
await fetchJSON(api("api/inverters-config"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify(invertersList),
});
renderInverterList();
showToast("Gelöscht", "ok");
} catch(e) {
showToast("Fehler", "err");
}
}
// ── MQTT Settings ─────────────────────────────────────────────
async function loadSettings() {
globalConfig = await fetchJSON(api("api/config"));
document.getElementById("cfg-mqtt-broker").value = globalConfig.mqtt_broker || "";
document.getElementById("cfg-mqtt-port").value = globalConfig.mqtt_port || 1883;
document.getElementById("cfg-mqtt-user").value = globalConfig.mqtt_user || "";
}
async function saveMqtt() {
const body = {
mqtt_broker: document.getElementById("cfg-mqtt-broker").value.trim(),
mqtt_port: parseInt(document.getElementById("cfg-mqtt-port").value),
mqtt_user: document.getElementById("cfg-mqtt-user").value,
mqtt_pass: document.getElementById("cfg-mqtt-pass").value,
};
try {
await fetchJSON(api("api/config"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
showToast("Gespeichert! Neustart...", "ok");
} catch(e) {
showToast("Fehler beim Speichern", "err");
}
}
// ── Init ──────────────────────────────────────────────────────
(async () => {
await loadConfig();
await loadInverters();
await Promise.all([loadSettings(), loadInverters(), loadModels()]);
startRefresh();
})();
// Cleanup on unload
window.addEventListener("beforeunload", stopRefresh);
</script>
</body>