HAOS Add-on: MVP + NuttX Binary + .gitignore
- haos-addon/: vollständiges HA Add-on (config.yaml, Dockerfile, build.yaml) - Python Backend: pymodbus Modbus TCP → paho-mqtt MQTT Discovery - Unterstützte Modelle: MIC 1500/2000 TL-X, SPH 5000 TL3, MOD 6000 TL3 - Web UI: Wechselrichter-Auswahl, Modbus/MQTT-Konfig, Live-Sensor-Grid (dark theme) - MQTT HA Discovery für alle Sensoren mit device_class, state_class, icon - ShineLAN-X/releases/nuttx-mbusd-shinelanx.bin: NuttX Firmware (ohne DFU, 0x08000000) - .gitignore: Logs, MQTT-JSON, shinelanx-modbus/ ausgeschlossen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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;
|
||||
--radius: 10px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
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; }
|
||||
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);
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
.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 */
|
||||
.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); }
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
.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 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;
|
||||
}
|
||||
.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>
|
||||
|
||||
<header>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="5" fill="#f0c040"/>
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" stroke="#f0c040" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h1>Growatt ShineLAN-X</h1>
|
||||
<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>
|
||||
|
||||
<main>
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('live')">Live-Daten</div>
|
||||
<div class="tab" onclick="switchTab('config')">Konfiguration</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>
|
||||
</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>
|
||||
|
||||
<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": "📥",
|
||||
};
|
||||
|
||||
let currentConfig = {};
|
||||
let inverterList = {};
|
||||
let refreshTimer = null;
|
||||
|
||||
async function fetchJSON(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
const el = document.getElementById("toast");
|
||||
el.textContent = msg;
|
||||
el.className = `toast show ${type}`;
|
||||
clearTimeout(el._t);
|
||||
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);
|
||||
});
|
||||
document.querySelectorAll(".panel").forEach((p, i) => {
|
||||
p.classList.toggle("active", ["panel-live", "panel-config"][i] === `panel-${name}`);
|
||||
});
|
||||
if (name === "live") startRefresh();
|
||||
else stopRefresh();
|
||||
}
|
||||
|
||||
// ── Live data ──
|
||||
async function refreshData() {
|
||||
try {
|
||||
const d = await fetchJSON("/api/data");
|
||||
updateStatus(d.modbus_ok, d.mqtt_ok);
|
||||
updateSubtitle(d);
|
||||
updateInfoRow(d);
|
||||
updateGrid(d);
|
||||
} catch (e) {
|
||||
updateStatus(false, false);
|
||||
}
|
||||
}
|
||||
|
||||
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">Fehler <span>${d.error_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>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function formatVal(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(".", ",");
|
||||
}
|
||||
|
||||
function startRefresh() {
|
||||
stopRefresh();
|
||||
refreshData();
|
||||
refreshTimer = setInterval(refreshData, 5000);
|
||||
}
|
||||
|
||||
function stopRefresh() {
|
||||
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
|
||||
}
|
||||
|
||||
// ── Config ──
|
||||
async function loadConfig() {
|
||||
try {
|
||||
currentConfig = await fetchJSON("/api/config");
|
||||
fillForm(currentConfig);
|
||||
} catch (e) {
|
||||
showToast("Konfiguration konnte nicht geladen werden", "err");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInverters() {
|
||||
try {
|
||||
inverterList = await fetchJSON("/api/inverters");
|
||||
buildInverterGrid(inverterList, currentConfig.inverter_model);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
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 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 saveConfig(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("save-btn");
|
||||
btn.disabled = true;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
try {
|
||||
await fetchJSON("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
currentConfig = { ...currentConfig, ...body };
|
||||
showToast("Gespeichert! Neustart...", "ok");
|
||||
setTimeout(loadConfig, 2000);
|
||||
} catch (e) {
|
||||
showToast("Fehler beim Speichern", "err");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
(async () => {
|
||||
await loadConfig();
|
||||
await loadInverters();
|
||||
startRefresh();
|
||||
})();
|
||||
|
||||
// Cleanup on unload
|
||||
window.addEventListener("beforeunload", stopRefresh);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user