Files
Shinebridge/haos-addon/src/web/index.html
T
2026-04-29 12:56:43 +02:00

977 lines
47 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ShineBridge</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 { 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 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:.4} }
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 */
.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; }
.sparkline { margin-top: 8px; opacity: .7; }
.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); }
.no-data { text-align: center; padding: 40px 20px; color: var(--text-dim); font-size: 13px; }
/* 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; }
.btn-secondary { color: var(--blue); border-color: var(--blue); }
.btn-secondary:hover { background: rgba(88,166,255,.1); }
.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: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
.field { margin-bottom: 20px; }
.field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
.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); }
/* 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; }
/* Energy Dashboard */
.energy-wrap { max-width: 580px; margin: 0 auto; }
.energy-svg-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; overflow: hidden; }
.energy-kwh { display: grid; grid-template-columns: repeat(auto-fit, minmax(95px, 1fr)); gap: 10px; }
.kwh-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 8px; text-align: center; }
.kwh-card .kv { font-size: 18px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
.kwh-card .kl { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; margin-top: 4px; }
.flow-dot { /* dots via animateMotion — no CSS animation needed */ }
/* 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); }
</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>ShineBridge</h1>
<div class="subtitle" id="subtitle">Lade...</div>
</div>
<div class="status-pill">
<div class="pill" id="pill-mqtt"><div class="dot"></div>MQTT</div>
</div>
</header>
<main>
<div class="tabs">
<div class="tab active" onclick="switchTab('energy')">Energie</div>
<div class="tab" onclick="switchTab('live')">Live-Daten</div>
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
</div>
<!-- Energy Panel -->
<div class="panel active" id="panel-energy">
<div id="energy-content"><div class="no-data">Warte auf erste Messung…</div></div>
</div>
<!-- Live Panel -->
<div class="panel" id="panel-live">
<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 class="settings-section">
<h3>Stromtarif</h3>
<div class="field">
<label>Tarifart</label>
<div style="display:flex;gap:16px;margin-top:4px">
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px">
<input type="radio" name="tariff-type" value="fixed" id="tariff-fixed" onchange="updateTariffUI()"> Festpreis
</label>
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px">
<input type="radio" name="tariff-type" value="spot" id="tariff-spot" onchange="updateTariffUI()"> Flexibel (Börsenstrom)
</label>
</div>
</div>
<div id="tariff-fixed-fields">
<div class="field"><label>Bezugspreis (€/kWh)</label>
<input type="number" id="cfg-price-import" step="0.001" placeholder="0.300"></div>
</div>
<div id="tariff-spot-fields" style="display:none">
<div class="field"><label>Aufschlag Netz + Steuern (ct/kWh)</label>
<input type="number" id="cfg-spot-markup" step="0.1" placeholder="15.0">
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">Netzentgelt + Steuern + Aufschlag Anbieter — wird auf den Börsenpreis addiert</div>
</div>
<div class="field"><label>Land</label>
<select id="cfg-spot-country" style="background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none">
<option value="de">Deutschland</option>
<option value="at">Österreich</option>
</select>
</div>
</div>
<div class="field"><label>Einspeisevergütung (€/kWh)</label>
<input type="number" id="cfg-price-export" step="0.001" placeholder="0.080"></div>
<div class="field" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="cfg-spot-chart" style="width:auto;margin:0">
<label for="cfg-spot-chart" style="margin:0;cursor:pointer;font-size:13px">Börsenstrompreis-Chart anzeigen</label>
</div>
<div class="field"><label>Abrechnungsjahr beginnt am</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="number" id="cfg-billing-day" min="1" max="28" placeholder="1" style="width:72px">
<span style="color:var(--text-dim)">.</span>
<input type="number" id="cfg-billing-month" min="1" max="12" placeholder="1" style="width:72px">
<span style="color:var(--text-dim);font-size:11px">(Tag . Monat)</span>
</div>
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">z.B. 1 . 4 = 01. April</div>
</div>
<button class="btn btn-primary" onclick="savePrices()">Speichern</button>
</div>
<div class="settings-section">
<h3>Konfiguration sichern</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Alle Geräte und MQTT-Einstellungen als JSON exportieren und bei einer Neuinstallation wieder einlesen.</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<button class="btn btn-secondary" onclick="exportConfig()">&#8659; Exportieren</button>
<label class="btn btn-secondary" style="cursor:pointer">
&#8657; Importieren
<input type="file" id="import-file-input" accept=".json" style="display:none" onchange="importConfig(this)">
</label>
</div>
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div>
</div>
</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">Gerät 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>Gerätetyp</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 id="ems-section" style="display:none; border-top:1px solid var(--border); margin-top:14px; padding-top:14px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<div style="font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--accent);">EMS — Ladesteuerung</div>
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-dim)">
<input type="checkbox" id="ems-enabled" checked style="width:auto;margin:0"> Aktiv
</label>
</div>
<div class="field"><label>Mindest-PV-Überschuss (W)</label>
<input type="number" id="ems-min-pv" value="1400" min="100" step="100">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Mindestleistung für PV-Laden (min. 6A × 230V = 1380W)</div></div>
<div class="field"><label>Timeout ohne PV (Stunden)</label>
<input type="number" id="ems-timeout" value="4" min="0.5" max="24" step="0.5">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Nach dieser Zeit ohne Überschuss startet Zwangsladen</div></div>
<div class="field"><label>Vollladung bis (Uhr)</label>
<input type="number" id="ems-target-hour" value="6" min="0" max="23">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Zielzeit für vollgeladenes Auto (z.B. 6 = 06:00 Uhr)</div></div>
<div class="field"><label>Anzahl Phasen</label>
<select id="ems-phases">
<option value="1">1-phasig</option>
<option value="2">2-phasig</option>
<option value="3" selected>3-phasig</option>
</select></div>
</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>
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const AGG_META = {
total_pv_power: {name:"PV Gesamtleistung", unit:"W", device_class:"power", icon:"mdi:solar-power"},
total_ac_power: {name:"AC Gesamtleistung", unit:"W", device_class:"power", icon:"mdi:flash"},
total_energy_today: {name:"Energie Heute Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:solar-power"},
total_energy_total: {name:"Energie Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:solar-power"},
grid_power: {name:"Netzleistung", unit:"W", device_class:"power", icon:"mdi:transmission-tower"},
grid_import_kwh: {name:"Netzbezug Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:transmission-tower-import"},
grid_export_kwh: {name:"Einspeisung Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:transmission-tower-export"},
bat_charge_power: {name:"Batterie Ladeleistung Ges.", unit:"W", device_class:"power", icon:"mdi:battery-plus"},
bat_discharge_power: {name:"Batterie Entladeleist. Ges.", unit:"W", device_class:"power", icon:"mdi:battery-minus"},
bat_charge_total: {name:"Batterie Ladung Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:battery-plus"},
bat_discharge_total: {name:"Batterie Entladung Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:battery-minus"},
bat_soc: {name:"Batterie Ladezustand Ø", unit:"%", device_class:"battery", icon:"mdi:battery"},
};
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 = {
"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":"📥",
};
const BASE = new URL("./", window.location.href).pathname;
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);
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);
}
function switchTab(name) {
["energy","live","inverters","settings"].forEach((t, i) => {
document.querySelectorAll(".tab")[i].classList.toggle("active", t === name);
document.querySelectorAll(".panel")[i].classList.toggle("active", t === name);
});
if (name === "energy" || name === "live") startRefresh(); else stopRefresh();
}
// ── Energy Dashboard ──────────────────────────────────────────
function renderSpotChart(spotData) {
if (!spotData || !spotData.length) return '';
const now = Math.floor(Date.now() / 1000);
const startH = Math.floor(now / 3600) * 3600;
const hours = spotData.filter(d => d.ts >= startH && d.ts < startH + 86400).sort((a,b) => a.ts - b.ts);
if (hours.length < 2) return '';
const prices = hours.map(h => h.price);
const minP = Math.min(...prices), maxP = Math.max(...prices);
const rangeP = maxP - minP || 1;
const q1 = minP + rangeP / 3, q2 = minP + 2 * rangeP / 3;
const priceCol = p => p <= q1 ? '#3fb950' : p <= q2 ? '#f0c040' : '#f85149';
const W=520, H=108, PL=34, PR=8, PT=14, PB=22;
const cW=W-PL-PR, cH=H-PT-PB, bW=cW/hours.length;
const bars = hours.map((h,i) => {
const isNow = now >= h.ts && now < h.ts+3600;
const barH = Math.max(2, ((h.price-minP)/rangeP)*cH);
const x=(PL+i*bW), y=PT+cH-barH;
const col = priceCol(h.price);
const hh = new Date(h.ts*1000).getHours();
const tl = hh%6===0 ? `<text x="${(x+bW/2).toFixed(1)}" y="${H-3}" text-anchor="middle" font-size="8" fill="#8b949e">${String(hh).padStart(2,'0')}h</text>` : '';
const pl = isNow ? `<text x="${(x+bW/2).toFixed(1)}" y="${(y-3).toFixed(1)}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${col}">${h.price.toFixed(1)}</text>` : '';
return `<rect x="${(x+1).toFixed(1)}" y="${y.toFixed(1)}" width="${(bW-2).toFixed(1)}" height="${barH.toFixed(1)}" fill="${col}" opacity="${isNow?1:.6}" rx="1.5"/>
${isNow?`<rect x="${x.toFixed(1)}" y="${PT}" width="${bW.toFixed(1)}" height="${cH}" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5" rx="1.5"/>`:''}
${tl}${pl}`;
}).join('');
const yTicks = [[minP,PT+cH],[maxP,PT]].map(([p,y]) =>
`<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="#30363d" stroke-width="0.5"/>
<text x="${PL-3}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="8" fill="#8b949e">${p.toFixed(0)}</text>`
).join('');
const cur = hours.find(h => now>=h.ts && now<h.ts+3600);
const curLabel = cur
? `<span style="font-size:16px;font-weight:700;color:${priceCol(cur.price)}">${cur.price.toFixed(2)} ct/kWh</span><span style="font-size:10px;color:#8b949e;margin-left:6px">Jetzt</span>`
: '';
return `<div style="border-top:1px solid #30363d;padding:14px 14px 4px">
<div style="display:flex;align-items:baseline;gap:6px;margin-bottom:8px">
<span style="font-size:10px;font-weight:700;letter-spacing:.08em;color:#8b949e;text-transform:uppercase">EPEX SPOT · 24h</span>
${curLabel}
</div>
<svg viewBox="0 0 ${W} ${H}" width="100%" style="display:block" xmlns="http://www.w3.org/2000/svg">
${yTicks}${bars}
</svg>
</div>`;
}
function renderEnergy(inverters, aggregates, period, spotData) {
const el = document.getElementById("energy-content");
if (!aggregates || !Object.keys(aggregates).length) {
el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>';
return;
}
const pvW = aggregates.total_pv_power || 0;
const gridW = aggregates.grid_power || 0;
const batChW = aggregates.bat_charge_power || 0;
const batDchW = aggregates.bat_discharge_power || 0;
const batSoc = aggregates.bat_soc;
let evW = 0;
Object.entries(inverters).forEach(([id, inv]) => {
const cfg = invertersList.find(c => c.id === id);
if (cfg && cfg.inverter_model === 'KATHREIN_WALLBOX')
evW += (inv.values && inv.values.total_power) || 0;
});
const gridImport = Math.max(0, gridW);
const gridExport = Math.max(0, -gridW);
const houseW = Math.max(0, pvW + gridImport + batDchW - batChW - evW);
const hasBat = Object.keys(aggregates).some(k => k.startsWith('bat_'));
const hasEV = invertersList.some(c => c.inverter_model === 'KATHREIN_WALLBOX');
const T = 20;
const pvOn = pvW > T;
const impOn = gridImport > T;
const expOn = gridExport > T;
const chOn = batChW > T;
const dchOn = batDchW > T;
const batFlowIn = batChW >= batDchW; // true = Laden (Haus→Bat), false = Entladen (Bat→Haus)
const evOn = evW > T;
const C = {
pv:'#f0c040', imp:'#f85149', exp:'#3fb950',
bat:'#bc8cff', ev:'#58a6ff', txt:'#e6edf3',
dim:'#8b949e', brd:'#30363d', s2:'#21262d',
};
function fW(w) {
const a = Math.abs(w);
if (a < 5) return '—';
if (a >= 1000) return (a / 1000).toFixed(2) + ' kW';
return Math.round(a) + ' W';
}
// SVG stroke icons on 20×20 canvas
const ICONS = {
solar:`<circle cx="10" cy="10" r="3.2" fill="none" stroke-width="1.7"/>
<path d="M10,1.5v3M10,15.5v3M1.5,10h3M15.5,10h3M4.1,4.1l2.1,2.1M13.8,13.8l2.1,2.1M15.9,4.1l-2.1,2.1M6.2,13.8l-2.1,2.1" stroke-width="1.7" stroke-linecap="round"/>`,
grid: `<path d="M1.5,10 C3.5,3 6.5,3 8.5,10 C10.5,17 13.5,17 15.5,10 C16.5,6.5 17.5,6.5 18.5,10" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`,
house:`<path d="M1.5,9.5 L10,2 L18.5,9.5" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M3.5,9.5 L3.5,18 L16.5,18 L16.5,9.5" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M7.5,18 L7.5,13 Q7.5,11 10,11 Q12.5,11 12.5,13 L12.5,18" stroke-width="1.7" stroke-linecap="round" fill="none"/>`,
bat: `<rect x="1" y="6.5" width="15.5" height="9" rx="2.5" fill="none" stroke-width="1.7"/>
<path d="M16.5,9 L19,9 L19,13 L16.5,13" stroke-width="1.7" stroke-linecap="round" fill="none"/>`,
ev: `<path d="M12,1.5 L5.5,11.5 L10,11.5 L8,20 L14.5,8.5 L10,8.5 Z" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" fill="none"/>`,
};
function icon(name, cx, cy, col, sz) {
sz = sz || 26;
const s = (sz / 20).toFixed(4);
return `<g transform="translate(${(cx-sz/2).toFixed(1)},${(cy-sz/2).toFixed(1)}) scale(${s})" stroke="${col}" fill="none">${ICONS[name]}</g>`;
}
// Circle node — HA style
const R = 62;
function node(cx, cy, iconName, topLabel, valW, col, active, sub) {
const c = active ? col : C.dim;
const sw = active ? 2 : 1;
const fi = active ? '0.08' : '0.03';
return `<g>
<circle cx="${cx}" cy="${cy}" r="${R}" fill="${col}" fill-opacity="${fi}" stroke="${c}" stroke-width="${sw}"/>
${icon(iconName, cx, cy - 17, c, 28)}
<text x="${cx}" y="${cy+8}" text-anchor="middle" font-size="14" font-weight="700" fill="${active?col:C.txt}" dominant-baseline="middle">${fW(valW)}</text>
<text x="${cx}" y="${cy+26}" text-anchor="middle" font-size="10" fill="${C.dim}" dominant-baseline="middle" letter-spacing=".06em">${topLabel}${sub?' · '+sub:''}</text>
</g>`;
}
// Animated dots along a named path (HA-style)
function flowDots(pid, col, on, reverse, spd) {
if (!on) return '';
spd = spd || 1.6;
const kp = reverse ? '1;0' : '0;1';
return [0, spd/3, 2*spd/3].map(b =>
`<circle r="3.8" fill="${col}" opacity=".9">
<animateMotion dur="${spd.toFixed(2)}s" repeatCount="indefinite" begin="${b.toFixed(3)}s" calcMode="linear" keyPoints="${kp}" keyTimes="0;1">
<mpath href="#${pid}"/>
</animateMotion>
</circle>`
).join('');
}
// Kreuz-Layout: Solar(260,72) oben, Grid(108,224) links, Haus(260,224) Mitte,
// Batterie(412,224) rechts, Wallbox(260,376) unten (optional). R=62, Abstand=28px.
const SEG = [
{ id:'ep-pv', d:`M 260,134 C 258,148 262,148 260,162`, col:C.pv, on:pvOn, rev:false },
{ id:'ep-grid', d:`M 170,224 C 182,220 186,228 198,224`, col:impOn?C.imp:C.exp, on:impOn||expOn, rev:expOn },
{ id:'ep-bat', d:`M 322,224 C 334,220 338,228 350,224`, col:C.bat, on:chOn||dchOn, rev:!batFlowIn },
...(hasEV ? [{ id:'ep-ev', d:`M 260,286 C 258,300 262,300 260,314`, col:C.ev, on:evOn, rev:false }] : []),
];
const defs = SEG.map(s => `<path id="${s.id}" d="${s.d}" fill="none"/>`).join('');
const lines = SEG.map(s =>
`<use href="#${s.id}" fill="none" stroke="${s.on?s.col:C.brd}" stroke-width="${s.on?2.5:1.5}" stroke-linecap="round" ${s.on?'':'opacity=".3"'}/>`
).join('');
const dotsSvg = SEG.map(s => flowDots(s.id, s.col, s.on, s.rev)).join('');
const gridCol = impOn ? C.imp : expOn ? C.exp : C.dim;
const gridLbl = impOn ? 'NETZBEZUG' : expOn ? 'EINSPEISUNG' : 'NETZ';
const gridVal = impOn ? gridImport : gridExport;
const batVal = chOn ? batChW : dchOn ? batDchW : 0;
const batLbl = chOn ? 'LADEN' : dchOn ? 'ENTLADEN' : 'BATTERIE';
const batSub = batSoc != null ? Math.round(batSoc) + '%' : '';
const svgH = hasEV ? 468 : 310;
const svg = `<svg viewBox="0 0 520 ${svgH}" width="100%" style="display:block" xmlns="http://www.w3.org/2000/svg">
<defs>${defs}</defs>
${lines}
${dotsSvg}
${node(260, 72, 'solar', 'SOLAR', pvW, C.pv, pvOn, '')}
${node(108, 224, 'grid', gridLbl, gridVal, gridCol, impOn||expOn, '')}
${node(260, 224, 'house', 'HAUS', houseW, C.txt, true, '')}
${node(412, 224, 'bat', batLbl, batVal, C.bat, chOn||dchOn, batSub)}
${hasEV ? node(260, 376, 'ev', 'WALLBOX', evW, C.ev, evOn, '') : ''}
</svg>`;
period = period || {};
const mon = period.monthly || {};
const yr = period.yearly || {};
const pi = period.price_import || 0.30;
const pe = period.price_export || 0.08;
function fEur(v) {
if (v == null) return null;
return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €';
}
function fKwh(v) {
if (v == null) return null;
return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + ' kWh';
}
function periodCard(label, kwh, cost, col, sub) {
if (kwh == null) return '';
return `<div class="kwh-card" style="border-top:3px solid ${col}">
<div class="kv" style="color:${col}">${fKwh(kwh)}</div>
${cost != null ? `<div style="font-size:11px;font-weight:600;color:${col};opacity:.8;margin:2px 0">${fEur(cost)}</div>` : ''}
<div class="kl">${label}${sub?'<br><span style="opacity:.6">'+sub+'</span>':''}</div>
</div>`;
}
function sectionCards(title, data) {
const impSub = data.spot_avg_ct != null
? `Ø ${data.spot_avg_ct.toFixed(1)} + ${(data.spot_markup_ct||0).toFixed(1)} ct/kWh`
: '';
const imp = periodCard('Netzbezug', data.grid_import_kwh, data.import_cost, C.imp, impSub);
const exp = periodCard('Einspeisung', data.grid_export_kwh, data.export_revenue, C.exp, '');
const sav = data.savings_kwh != null
? `<div class="kwh-card" style="border-top:3px solid ${C.bat}">
<div class="kv" style="color:${C.bat}">${fKwh(data.savings_kwh)}</div>
${data.savings_eur != null ? `<div style="font-size:11px;font-weight:600;color:${C.bat};opacity:.8;margin:2px 0">${fEur(data.savings_eur)} gespart</div>` : ''}
<div class="kl">Eigenversorgung<br><span style="opacity:.6">Batterie → Haus &amp; Auto</span></div>
</div>`
: '';
if (!imp && !exp && !sav) return '';
return `<div style="margin-bottom:8px;font-size:10px;font-weight:700;letter-spacing:.08em;color:${C.dim};text-transform:uppercase">${title}</div>
<div class="energy-kwh" style="margin-bottom:16px">${imp}${exp}${sav}</div>`;
}
const cards = sectionCards(mon.label || 'Diesen Monat', mon) +
sectionCards(yr.label || 'Dieses Jahr', yr);
const spotHtml = (period.spot_chart !== false) ? renderSpotChart(spotData) : '';
el.innerHTML = `<div class="energy-wrap">
<div class="energy-svg-wrap">${svg}${spotHtml}</div>
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
</div>`;
}
// ── Live Data ─────────────────────────────────────────────────
async function refreshData() {
try {
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} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
renderLive(d.inverters || {}, d.aggregates || {});
const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
const spot = await fetchJSON(api("api/spot-price")).catch(() => ({}));
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || []));
} catch(e) {
document.getElementById("pill-mqtt").className = "pill err";
}
}
function renderAggregates(aggregates) {
if (!aggregates || !Object.keys(aggregates).length) return '';
const cards = Object.entries(AGG_META).map(([id, meta]) => {
const val = aggregates[id];
if (val === undefined) return '';
const dcClass = meta.device_class ? `dc-${meta.device_class}` : '';
return `<div class="sensor-card ${dcClass}">
<div class="sensor-icon">${ICON_MAP[meta.icon]||"📊"}</div>
<div class="sensor-name">${esc(meta.name)}</div>
<div class="sensor-value">${fmtVal(val)}<span class="sensor-unit">${esc(meta.unit)}</span></div>
</div>`;
}).filter(Boolean).join('');
if (!cards) return '';
return `<div class="inv-section">
<div class="inv-header">
<div class="inv-title">Gesamt</div>
<div class="inv-badge ok">aggregiert</div>
</div>
<div class="sensor-grid">${cards}</div>
</div>`;
}
function renderLive(inverters, aggregates) {
const el = document.getElementById("live-content");
if (!Object.keys(inverters).length) {
el.innerHTML = '<div class="no-data">Keine Geräte konfiguriert.<br>Bitte im Tab „Geräte" hinzufügen.</div>';
return;
}
const aggHtml = renderAggregates(aggregates);
el.innerHTML = aggHtml + 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}` : "";
const hist = (inv.history || {})[s.id] || [];
return `<div class="sensor-card ${dcClass}">
<div class="sensor-icon">${ICON_MAP[s.icon]||"📊"}</div>
<div class="sensor-name">${esc(s.name)}</div>
<div class="sensor-value">${display}<span class="sensor-unit">${esc(s.unit)}</span></div>
${sparkline(hist, s.device_class)}
</div>`;
}).join("");
return `<div class="inv-section">
<div class="inv-header">
<div class="inv-title">${esc(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 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(".",",");
}
function startRefresh() { stopRefresh(); refreshData(); refreshTimer = setInterval(refreshData, 5000); }
function stopRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
// ── Inverter Management ───────────────────────────────────────
async function loadInverters() {
invertersList = await fetchJSON(api("api/inverters-config"));
renderInverterList();
}
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="${esc(m.id)}">${esc(m.name)} (${m.sensor_count} Sensoren)</option>`
).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">${esc(inv.name || "Gerät")}</div>
<div class="inv-card-model">${esc(model.name || inv.inverter_model)}</div>
</div>
</div>
<div class="inv-card-meta">
📡 ${esc(inv.modbus_ip)}:${inv.modbus_port || 502} · Slave ${inv.modbus_address || 1}<br>
📨 ${esc(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()"> Gerät hinzufügen</div>`;
}
function toggleEmsSection(model) {
document.getElementById("ems-section").style.display =
model === "KATHREIN_WALLBOX" ? "" : "none";
}
async function openModal(invId) {
await loadModels();
const modal = document.getElementById("modal-backdrop");
const modelSel = document.getElementById("modal-model");
modelSel.onchange = () => toggleEmsSection(modelSel.value);
if (invId) {
const inv = invertersList.find(i => i.id === invId) || {};
document.getElementById("modal-title").textContent = "Gerät bearbeiten";
document.getElementById("modal-id").value = inv.id || "";
document.getElementById("modal-name").value = inv.name || "";
modelSel.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;
document.getElementById("ems-min-pv").value = inv.ems_min_pv ?? 1400;
document.getElementById("ems-timeout").value = inv.ems_timeout ?? 4;
document.getElementById("ems-target-hour").value = inv.ems_target_hour ?? 6;
document.getElementById("ems-phases").value = inv.ems_phases ?? 3;
document.getElementById("ems-enabled").checked = inv.ems_enabled ?? true;
} else {
document.getElementById("modal-title").textContent = "Gerät 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";
document.getElementById("ems-min-pv").value = "1400";
document.getElementById("ems-timeout").value = "4";
document.getElementById("ems-target-hour").value = "6";
document.getElementById("ems-phases").value = "3";
}
toggleEmsSection(modelSel.value);
modal.classList.add("open");
}
function editInverter(id) { openModal(id); }
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 model = document.getElementById("modal-model").value;
const inv = {
id,
name: document.getElementById("modal-name").value.trim() || "Wechselrichter",
inverter_model: model,
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),
};
if (model === "KATHREIN_WALLBOX") {
inv.ems_min_pv = parseInt(document.getElementById("ems-min-pv").value);
inv.ems_timeout = parseFloat(document.getElementById("ems-timeout").value);
inv.ems_target_hour = parseInt(document.getElementById("ems-target-hour").value);
inv.ems_phases = parseInt(document.getElementById("ems-phases").value);
inv.ems_enabled = document.getElementById("ems-enabled").checked;
}
const idx = invertersList.findIndex(i => i.id === id);
if (idx >= 0) invertersList[idx] = inv; else invertersList.push(inv);
try {
await fetchJSON(api("api/inverters-config"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify(invertersList),
});
closeModal();
renderInverterList();
showToast("Gespeichert!", "ok");
} catch(e) {
showToast("Fehler beim Speichern", "err");
}
}
async function deleteInverter(id) {
if (!confirm("Gerät 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 ─────────────────────────────────────────────
function updateTariffUI() {
const isSpot = document.getElementById("tariff-spot").checked;
document.getElementById("tariff-fixed-fields").style.display = isSpot ? "none" : "";
document.getElementById("tariff-spot-fields").style.display = isSpot ? "" : "none";
}
async function loadSettings() {
const cfg = await fetchJSON(api("api/config"));
globalConfig = cfg;
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-price-import").value = cfg.price_import ?? 0.30;
document.getElementById("cfg-price-export").value = cfg.price_export ?? 0.08;
document.getElementById("cfg-billing-day").value = cfg.billing_day ?? 1;
document.getElementById("cfg-billing-month").value = cfg.billing_month ?? 1;
document.getElementById("cfg-spot-markup").value = cfg.spot_markup ?? 0.0;
document.getElementById("cfg-spot-country").value = cfg.spot_country ?? "de";
document.getElementById("cfg-spot-chart").checked = cfg.spot_chart ?? true;
document.getElementById((cfg.tariff_type ?? "fixed") === "spot" ? "tariff-spot" : "tariff-fixed").checked = true;
updateTariffUI();
}
async function savePrices() {
const isSpot = document.getElementById("tariff-spot").checked;
const body = {
tariff_type: isSpot ? "spot" : "fixed",
price_import: parseFloat(document.getElementById("cfg-price-import").value || 0.30),
price_export: parseFloat(document.getElementById("cfg-price-export").value || 0.08),
spot_markup: parseFloat(document.getElementById("cfg-spot-markup").value || 0),
spot_country: document.getElementById("cfg-spot-country").value,
spot_chart: document.getElementById("cfg-spot-chart").checked,
billing_day: parseInt(document.getElementById("cfg-billing-day").value),
billing_month: parseInt(document.getElementById("cfg-billing-month").value),
};
try {
await fetchJSON(api("api/config"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
showToast("Einstellungen gespeichert", "ok");
} catch(e) {
showToast("Fehler beim Speichern", "err");
}
}
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");
}
}
// ── Export / Import ───────────────────────────────────────────
function exportConfig() {
window.location.href = api("api/export-config");
}
async function importConfig(input) {
const file = input.files[0];
if (!file) return;
const resultEl = document.getElementById("import-result");
resultEl.textContent = "Wird geladen…";
resultEl.style.color = "var(--text-dim)";
try {
const text = await file.text();
const data = JSON.parse(text);
const res = await fetchJSON(api("api/import-config"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify(data),
});
resultEl.textContent = `${res.inverters} Gerät(e) importiert. Neustart läuft…`;
resultEl.style.color = "var(--green)";
showToast(`${res.inverters} Gerät(e) importiert`, "ok");
setTimeout(() => { loadSettings(); loadInverters(); }, 3000);
} catch(e) {
resultEl.textContent = `Fehler: ${e.message}`;
resultEl.style.color = "var(--red)";
showToast("Import fehlgeschlagen", "err");
}
input.value = "";
}
// ── Init ──────────────────────────────────────────────────────
(async () => {
await Promise.all([loadSettings(), loadInverters(), loadModels()]);
startRefresh();
})();
window.addEventListener("beforeunload", stopRefresh);
</script>
</body>
</html>