v1.12.0 hat die Prognose nur intern verrechnet, nirgends sichtbar gemacht. Neuer Endpunkt GET /api/weather-forecast (weather_forecast.summary()) liefert aktuelle Solarstrahlung (naechste 3h/12h Maximum) + ein Beispiel, wie sich der Zwangslade-Timeout dadurch gerade veraendern wuerde (Basis: erster konfigurierter Kathrein-Timeout, sonst 2h-Default). Wird direkt unter den Standort-Feldern angezeigt, aktualisiert sich nach dem Speichern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1571 lines
80 KiB
HTML
1571 lines
80 KiB
HTML
<!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; }
|
||
html, body { max-width: 100vw; overflow-x: hidden; }
|
||
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: 10px;
|
||
padding: 12px 16px; background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
position: sticky; top: 0; z-index: 100;
|
||
min-width: 0; }
|
||
header h1 { font-size: 16px; font-weight: 600; white-space: nowrap; }
|
||
header .subtitle { font-size: 12px; color: var(--text-dim);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.status-pill { margin-left: auto; display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||
.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); white-space: nowrap; }
|
||
.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: 16px; max-width: 1100px; margin: 0 auto; }
|
||
.tabs { display: flex; gap: 2px; margin-bottom: 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
overflow-x: auto; -webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none; }
|
||
.tabs::-webkit-scrollbar { display: none; }
|
||
.tab { padding: 10px 14px; cursor: pointer; color: var(--text-dim);
|
||
font-weight: 500; border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px; transition: color .15s, border-color .15s;
|
||
white-space: nowrap; flex-shrink: 0; }
|
||
.tab.active { color: var(--accent); border-color: var(--accent); }
|
||
.tab:hover { color: var(--text); }
|
||
.panel { display: none; }
|
||
.panel.active { display: flex; flex-direction: column; gap: 16px; }
|
||
#panel-inverters.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); }
|
||
|
||
.surplus-device-row { background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius); padding:10px 12px; }
|
||
.surplus-device-row input[type=text], .surplus-device-row input[type=number] { background:var(--surface); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:5px 8px; font-size:13px; }
|
||
/* 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; width: 100%; }
|
||
.energy-svg-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; overflow: hidden; width: 100%; }
|
||
.energy-kwh { display: grid; grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); gap: 8px; }
|
||
.kwh-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 6px; text-align: center; min-width: 0; }
|
||
.kwh-card .kv { font-size: 17px; 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); }
|
||
|
||
@media (max-width: 520px) {
|
||
main { padding: 10px; }
|
||
.settings-section { max-width: 100%; }
|
||
.sensor-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||
.inv-list { grid-template-columns: 1fr; }
|
||
.kwh-card .kv { font-size: 15px; }
|
||
header svg { display: none; }
|
||
}
|
||
</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('finance')">Finanzen</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>
|
||
|
||
<!-- Finance Panel -->
|
||
<div class="panel" id="panel-finance">
|
||
<div id="finance-content"><div class="no-data">Lade…</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 id="mqtt-error-banner" style="display:none;background:var(--red,#c0392b);color:#fff;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:.9rem;line-height:1.4"></div>
|
||
<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>Standort</h3>
|
||
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Für die prognosebasierte Anpassung des Zwangslade-Timeouts (Solarstrahlungs-Vorhersage via Open-Meteo). Optional — leer lassen deaktiviert die Anpassung, Zwangsladen nutzt dann weiter den festen Timeout.</p>
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||
<div class="field" style="max-width:180px"><label>Breitengrad</label>
|
||
<input type="number" id="cfg-site-lat" step="0.0001" placeholder="z.B. 52.5200"></div>
|
||
<div class="field" style="max-width:180px"><label>Längengrad</label>
|
||
<input type="number" id="cfg-site-lon" step="0.0001" placeholder="z.B. 13.4050"></div>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="saveLocation()">Speichern</button>
|
||
<div id="weather-preview" style="margin-top:.75rem;font-size:.85rem"></div>
|
||
</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>
|
||
<div class="field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:4px">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
|
||
<input type="checkbox" id="cfg-billing-tracker" style="width:auto;margin:0" onchange="updateBillingTrackerUI()">
|
||
<label for="cfg-billing-tracker" style="margin:0;cursor:pointer;font-size:13px;font-weight:600">Abschlags-Tracker aktivieren</label>
|
||
</div>
|
||
<div id="billing-tracker-fields" style="display:none">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||
<div><label style="font-size:11px;color:var(--text-dim)">Monatliche Rate (€)</label>
|
||
<input type="number" id="cfg-monthly-rate" step="0.01" min="0" placeholder="120.00">
|
||
</div>
|
||
<div><label style="font-size:11px;color:var(--text-dim)">Grundpreis pro Monat (€)</label>
|
||
<input type="number" id="cfg-grundpreis" step="0.01" min="0" placeholder="12.50">
|
||
</div>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-dim);margin-top:6px">Zeigt Nachzahlung / Guthaben für das laufende Abrechnungsjahr.</div>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="savePrices()">Speichern</button>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<h3>Überschuss-Geräte (Zigbee2MQTT)</h3>
|
||
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Geräte automatisch einschalten wenn PV-Überschuss ins Netz eingespeist wird.</p>
|
||
<div class="field" style="max-width:280px"><label>Z2M Basis-Topic</label>
|
||
<input type="text" id="cfg-z2m-base" placeholder="zigbee2mqtt"></div>
|
||
<div id="surplus-device-list" style="margin-bottom:10px;display:flex;flex-direction:column;gap:8px"></div>
|
||
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||
<button class="btn btn-secondary" onclick="addSurplusDevice()">+ Gerät hinzufügen</button>
|
||
<button class="btn btn-primary" onclick="saveSurplusDevices()">Speichern</button>
|
||
</div>
|
||
</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()">Exportieren</button>
|
||
<label class="btn btn-secondary" style="cursor:pointer">
|
||
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 class="settings-section">
|
||
<h3>Automatische Backups</h3>
|
||
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Täglich automatisch nach <code style="font-size:11px;background:var(--surface2);padding:1px 5px;border-radius:3px">/share/shinebridge-backups/</code> gesichert — übersteht auch einen Add-on-Uninstall+Reinstall.</p>
|
||
<div class="field" style="max-width:200px"><label>Aufbewahrung (Tage)</label>
|
||
<input type="number" id="cfg-backup-retention" min="1" max="365" value="14"></div>
|
||
<button class="btn btn-primary" onclick="saveBackupRetention()">Speichern</button>
|
||
<div id="backup-list" style="margin-top:1rem;display:flex;flex-direction:column;gap:6px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Flash/OTA-Panel ausgelagert nach growatt-diagnose-tool (v1.9.0) -->
|
||
</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 id="modbus-fields">
|
||
<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>
|
||
<div id="ha-entity-fields" style="display:none">
|
||
<div class="field"><label>HA Entity-Prefix</label>
|
||
<input type="text" id="modal-ha-prefix" placeholder="growatt_wifi_x_1">
|
||
<div style="font-size:11px;color:var(--muted);margin-top:3px">Ohne "sensor."-Präfix — z.B. bei <code>sensor.growatt_wifi_x_1_dcpower</code> ist der Wert <code>growatt_wifi_x_1</code>. Braucht ein bereits per ESPHome in HA eingebundenes Gerät.</div></div>
|
||
<div class="field"><label>Home Assistant URL</label>
|
||
<input type="text" id="modal-ha-url" placeholder="http://localhost:8123">
|
||
<div style="font-size:11px;color:var(--muted);margin-top:3px">Meist unverändert lassen — dank host_network erreicht ShineBridge HA Core direkt über localhost.</div></div>
|
||
<div class="field"><label>Long-Lived Access Token</label>
|
||
<input type="password" id="modal-ha-token" placeholder="Einstellungen → Profil → Sicherheit → Langlebige Zugriffs-Token">
|
||
<div style="font-size:11px;color:var(--muted);margin-top:3px">Kein Supervisor-Token nutzbar (host_network) — braucht ein manuell in HA erstelltes Token.</div></div>
|
||
</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 class="field"><label>Max. Ladestrom (A)</label>
|
||
<input type="number" id="ems-max-current" value="32" min="6" max="32" step="1">
|
||
<div style="font-size:11px;color:var(--muted);margin-top:3px">Obergrenze für Zwangsladen — auf die tatsächliche Wallbox-/Zuleitungs-Absicherung setzen (z.B. 16A bei einer 11kW-Wallbox), nicht höher als die Hardware zulässt</div></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>
|
||
|
||
<datalist id="z2m-device-list"></datalist>
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
function esc(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
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","finance","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();
|
||
if (name === "finance") loadFinance();
|
||
if (name === "settings") loadBackupList();
|
||
}
|
||
|
||
// ── 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 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 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 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 & 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) : '';
|
||
|
||
const bt = period.billing_tracker;
|
||
const trackerHtml = bt ? (() => {
|
||
const nz = bt.nachzahlung_eur;
|
||
const isNachzahlung = nz > 0;
|
||
const nzColor = isNachzahlung ? '#e05c5c' : '#4caf82';
|
||
const nzLabel = isNachzahlung ? 'Voraussichtliche Nachzahlung' : 'Voraussichtliches Guthaben';
|
||
const nzSign = isNachzahlung ? '+' : '−';
|
||
return `<div style="margin-top:16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px">
|
||
<div style="font-size:10px;font-weight:700;letter-spacing:.08em;color:var(--text-dim);text-transform:uppercase;margin-bottom:12px">Abschlags-Übersicht · ${bt.payments_made} Zahlung${bt.payments_made !== 1 ? 'en' : ''}</div>
|
||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px">
|
||
<div class="kwh-card">
|
||
<div class="kv">${fEur(bt.total_paid_eur)}</div>
|
||
<div class="kl">Bereits bezahlt<br><span style="opacity:.6">${bt.payments_made} × ${fEur(bt.monthly_rate_eur)}</span></div>
|
||
</div>
|
||
<div class="kwh-card">
|
||
<div class="kv">${fEur(bt.grundpreis_total_eur)}</div>
|
||
<div class="kl">Grundpreis anteilig<br><span style="opacity:.6">${bt.payments_made} × ${fEur(bt.grundpreis_eur_per_month)}</span></div>
|
||
</div>
|
||
<div class="kwh-card">
|
||
<div class="kv">${fEur(bt.energy_cost_eur)}</div>
|
||
<div class="kl">Energiekosten<br><span style="opacity:.6">Netzbezug</span></div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;background:var(--bg);border-radius:8px;padding:12px 16px">
|
||
<div>
|
||
<div style="font-size:12px;color:var(--text-dim)">${nzLabel}</div>
|
||
<div style="font-size:11px;color:var(--text-dim);margin-top:2px">Gesamtkosten ${fEur(bt.total_cost_eur)} − Bezahlt ${fEur(bt.total_paid_eur)}</div>
|
||
</div>
|
||
<div style="font-size:24px;font-weight:700;color:${nzColor};font-variant-numeric:tabular-nums">${nzSign}${fEur(Math.abs(nz))}</div>
|
||
</div>
|
||
</div>`;
|
||
})() : '';
|
||
|
||
el.innerHTML = `<div class="energy-wrap">
|
||
<div class="energy-svg-wrap">${svg}${spotHtml}</div>
|
||
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
|
||
${trackerHtml}
|
||
</div>`;
|
||
}
|
||
|
||
// ── Finance Tab ───────────────────────────────────────────────
|
||
|
||
// Erzeugt plausible, aber frei erfundene Tagesdaten fürs Beispiel-Diagramm im
|
||
// Leerzustand (Erstinstallation). Deterministisch (kein echter Zufall), damit
|
||
// die Vorschau bei jedem Laden gleich aussieht statt bei jedem Reload zu springen.
|
||
function exampleFinanceDays(n) {
|
||
const out = [];
|
||
const today = new Date();
|
||
for (let i = n - 1; i >= 0; i--) {
|
||
const d = new Date(today); d.setDate(d.getDate() - i);
|
||
const wave = 4.2 + 2.6 * Math.sin(i * 0.6) + (i % 3 === 0 ? 1.4 : 0);
|
||
const fixed = Math.max(1.5, wave);
|
||
const spot = Math.max(0.8, fixed * (0.68 + 0.1 * Math.cos(i * 0.9)));
|
||
out.push({ date: d.toISOString().slice(0, 10), fixed_eur: +fixed.toFixed(2), spot_eur: +spot.toFixed(2) });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Baut das Kosten-Balkendiagramm (echte oder Beispiel-Daten). Aggregiert ab
|
||
// 14 Wochen (98 Tagen) angesammelter Historie automatisch von Tages- auf
|
||
// Wochenbalken; zeigt davon je nach verfügbarer Breite die letzten 10–28
|
||
// Perioden (rollierendes Fenster — ältere Balken wandern rechts raus, sobald
|
||
// neue dazukommen).
|
||
function buildCostChart(days, el, isExample) {
|
||
const C = isExample
|
||
? { fixed: '#5b6472', spot: '#6b6155', grid: '#262b33' }
|
||
: { fixed: '#58a6ff', spot: '#f0883e', grid: '#30363d' };
|
||
|
||
const rawN = days.length;
|
||
const bucket = rawN >= 98 ? 7 : 1;
|
||
const allPeriods = bucket === 1 ? days.map(d => ({
|
||
fixed_eur: d.fixed_eur, spot_eur: d.spot_eur, label: d.date.slice(5),
|
||
})) : (() => {
|
||
const out = [];
|
||
for (let i = 0; i < days.length; i += bucket) {
|
||
const chunk = days.slice(i, i + bucket);
|
||
const fixed = chunk.reduce((s, d) => s + (d.fixed_eur || 0), 0);
|
||
const spotVals = chunk.map(d => d.spot_eur).filter(v => v != null);
|
||
const spot = spotVals.length ? spotVals.reduce((a, b) => a + b, 0) : null;
|
||
out.push({ fixed_eur: fixed, spot_eur: spot, label: chunk[0].date.slice(5) });
|
||
}
|
||
return out;
|
||
})();
|
||
|
||
// Container-Breite messen und die SVG in echten Pixeln in genau dieser Breite
|
||
// zeichnen (keine viewBox-Skalierung) — Höhe bleibt dabei fix, unabhängig von
|
||
// der Breite. Auf breiten Screens werden mehr Perioden gezeigt statt dieselben
|
||
// künstlich zu verbreitern.
|
||
const H = 240, PL = 40, PR = 8, PT = 14, PB = 46;
|
||
const measuredW = el.clientWidth || 380;
|
||
const W = Math.max(300, measuredW);
|
||
const cW = W - PL - PR;
|
||
const IDEAL_SLOT = 24; // px/Periode, entspricht dem Slot, der auf Handy-Breite gut aussah
|
||
const maxPeriods = Math.min(28, Math.max(10, Math.round(cW / IDEAL_SLOT)));
|
||
const periods = allPeriods.slice(-maxPeriods);
|
||
|
||
const n = periods.length;
|
||
const cH = H - PT - PB;
|
||
const slot = cW / n;
|
||
const barW = Math.max(3, Math.min(26, slot * 0.36));
|
||
const barGap = Math.max(1.5, slot * 0.08);
|
||
const rBar = Math.min(2.5, barW / 2);
|
||
|
||
const allVals = periods.flatMap(d => [d.fixed_eur, d.spot_eur].filter(v => v != null));
|
||
const maxVal = Math.max(...allVals, 0.01);
|
||
|
||
const yTicks = 4;
|
||
let gridLines = '', yLabels = '';
|
||
for (let i = 0; i <= yTicks; i++) {
|
||
const v = maxVal * i / yTicks;
|
||
const y = PT + cH - (cH * i / yTicks);
|
||
gridLines += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="${C.grid}" stroke-width="1" stroke-dasharray="2,3"/>`;
|
||
yLabels += `<text x="${PL-6}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="9" fill="#8b949e">${fEur(v)}</text>`;
|
||
}
|
||
|
||
const gradId = isExample ? 'Ex' : '';
|
||
let bars = '', xLabels = '';
|
||
periods.forEach((d, i) => {
|
||
const cx = PL + i * slot + slot / 2;
|
||
const hFixed = Math.max(0, cH * d.fixed_eur / maxVal);
|
||
bars += `<rect x="${(cx - barGap/2 - barW).toFixed(1)}" y="${(PT + cH - hFixed).toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(hFixed,1).toFixed(1)}" rx="${rBar}" fill="url(#gradFixed${gradId})"/>`;
|
||
if (d.spot_eur != null) {
|
||
const hSpot = Math.max(0, cH * d.spot_eur / maxVal);
|
||
bars += `<rect x="${(cx + barGap/2).toFixed(1)}" y="${(PT + cH - hSpot).toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(hSpot,1).toFixed(1)}" rx="${rBar}" fill="url(#gradSpot${gradId})"/>`;
|
||
}
|
||
const ly = H - PB + 12;
|
||
xLabels += `<text x="${cx.toFixed(1)}" y="${ly}" text-anchor="end" font-size="8.5" fill="#8b949e" transform="rotate(-40 ${cx.toFixed(1)} ${ly})">${d.label}</text>`;
|
||
});
|
||
|
||
const defs = `<defs>
|
||
<linearGradient id="gradFixed${gradId}" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="${C.fixed}" stop-opacity="0.95"/>
|
||
<stop offset="100%" stop-color="${C.fixed}" stop-opacity="0.55"/>
|
||
</linearGradient>
|
||
<linearGradient id="gradSpot${gradId}" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="${C.spot}" stop-opacity="0.95"/>
|
||
<stop offset="100%" stop-color="${C.spot}" stop-opacity="0.55"/>
|
||
</linearGradient>
|
||
</defs>`;
|
||
|
||
const bucketLabel = bucket === 1 ? 'Tag' : 'Woche';
|
||
const rangeNote = allPeriods.length > n ? ` · letzte ${n}` : '';
|
||
const legend = `<div style="display:flex;gap:18px;margin-top:10px;font-size:11px;color:var(--text-dim)">
|
||
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:${C.fixed};display:inline-block"></span>Festpreis</span>
|
||
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:${C.spot};display:inline-block"></span>Spot (hypothetisch)</span>
|
||
</div>`;
|
||
|
||
return `
|
||
<div style="position:relative;border-top:1px solid var(--border);padding-top:24px;margin-top:4px">
|
||
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:14px">Kosten je ${bucketLabel}${rangeNote}</div>
|
||
<svg width="${W}" height="${H}" style="display:block">
|
||
${defs}${gridLines}${yLabels}${bars}${xLabels}
|
||
</svg>
|
||
${legend}
|
||
${isExample ? `<div style="position:absolute;top:20px;right:0;font-size:10px;font-weight:700;letter-spacing:.08em;color:var(--text-dim);background:var(--surface2);border:1px solid var(--border);border-radius:20px;padding:3px 10px">BEISPIEL</div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
async function loadFinance() {
|
||
const el = document.getElementById("finance-content");
|
||
let data;
|
||
try { data = await fetchJSON(api("api/finance")); }
|
||
catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; return; }
|
||
|
||
try {
|
||
const { days, fixed_total_eur, spot_total_eur, savings_eur, period_start, spot_days, total_days } = data;
|
||
|
||
if (!days || days.length === 0) {
|
||
const exampleChart = buildCostChart(exampleFinanceDays(14), el, true);
|
||
el.innerHTML = `<div style="padding:4px 0">
|
||
<div style="text-align:center;padding:28px 20px;color:var(--text-dim);border:1px dashed var(--border);border-radius:var(--radius);margin-bottom:24px">
|
||
<div style="font-size:28px;margin-bottom:10px">📊</div>
|
||
<div style="font-weight:600;margin-bottom:6px;color:var(--text)">Noch keine echten Daten</div>
|
||
<div style="font-size:13px">Der Tracker sammelt ab morgen täglich Daten.<br>Abrechnungsjahr ab ${period_start}. Unten siehst du, wie die Ansicht dann aussieht.</div>
|
||
</div>
|
||
${exampleChart}
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
const C = { fixed: '#58a6ff', spot: '#f0883e', grid: '#30363d', green: '#4caf82', red: '#e05c5c' };
|
||
|
||
// ── Empfehlung ───────────────────────────────────────────────
|
||
let empfehlung = '';
|
||
if (spot_total_eur !== null && spot_days >= 7) {
|
||
const lohnt = savings_eur > 0;
|
||
const col = lohnt ? C.green : C.red;
|
||
const icon = lohnt ? '✓' : '✗';
|
||
empfehlung = `<div style="display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid ${col};border-radius:var(--radius);padding:18px 22px;margin-bottom:24px">
|
||
<div>
|
||
<div style="font-weight:700;font-size:15px;color:${col}">${icon} Flexibler Tarif würde sich ${lohnt ? 'lohnen' : 'nicht lohnen'}</div>
|
||
<div style="font-size:12px;color:var(--text-dim);margin-top:5px">Basierend auf ${spot_days} Tagen im Abrechnungsjahr</div>
|
||
</div>
|
||
<div style="font-size:26px;font-weight:700;color:${col};margin-left:20px;white-space:nowrap">${lohnt ? '−' : '+'}${fEur(Math.abs(savings_eur))}</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Summary-Karten ───────────────────────────────────────────
|
||
const spotCard = spot_total_eur !== null
|
||
? `<div class="kwh-card" style="border-top:3px solid ${C.spot};padding:18px 10px">
|
||
<div class="kv" style="color:${C.spot};font-size:22px">${fEur(spot_total_eur)}</div>
|
||
<div class="kl" style="font-size:11px;margin-top:6px">Spot-Tarif (hypothetisch)<br><span style="opacity:.6">${spot_days} Tage mit Preisdaten</span></div>
|
||
</div>`
|
||
: `<div class="kwh-card" style="padding:18px 10px"><div class="kv" style="color:var(--text-dim);font-size:22px">–</div><div class="kl" style="font-size:11px;margin-top:6px">Spot-Tarif<br><span style="opacity:.6">Noch keine Daten</span></div></div>`;
|
||
|
||
const savCard = savings_eur !== null
|
||
? `<div class="kwh-card" style="border-top:3px solid ${savings_eur > 0 ? C.green : C.red};padding:18px 10px">
|
||
<div class="kv" style="color:${savings_eur > 0 ? C.green : C.red};font-size:22px">${savings_eur > 0 ? '−' : '+'}${fEur(Math.abs(savings_eur))}</div>
|
||
<div class="kl" style="font-size:11px;margin-top:6px">${savings_eur > 0 ? 'Ersparnis mit Spot' : 'Mehrkosten mit Spot'}<br><span style="opacity:.6">gegenüber Festpreis</span></div>
|
||
</div>`
|
||
: '';
|
||
|
||
const cards = `
|
||
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:10px">Kostenübersicht · Abrechnungsjahr</div>
|
||
<div class="energy-kwh" style="margin-bottom:28px;gap:12px">
|
||
<div class="kwh-card" style="border-top:3px solid ${C.fixed};padding:18px 10px">
|
||
<div class="kv" style="color:${C.fixed};font-size:22px">${fEur(fixed_total_eur)}</div>
|
||
<div class="kl" style="font-size:11px;margin-top:6px">Festpreis-Kosten<br><span style="opacity:.6">${total_days} Tage</span></div>
|
||
</div>
|
||
${spotCard}${savCard}
|
||
</div>`;
|
||
|
||
const chartSection = buildCostChart(days, el, false);
|
||
|
||
el.innerHTML = `<div style="padding:4px 0">${empfehlung}${cards}${chartSection}</div>`;
|
||
} catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; console.error('loadFinance:', e); }
|
||
}
|
||
|
||
// ── Live Data ─────────────────────────────────────────────────
|
||
|
||
async function refreshData() {
|
||
try {
|
||
const [d, surplusData] = await Promise.all([
|
||
fetchJSON(api("api/data")),
|
||
fetchJSON(api("api/surplus-devices")).catch(() => ({})),
|
||
]);
|
||
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 || {}, surplusData);
|
||
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, surplusData) {
|
||
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);
|
||
const surplusHtml = renderSurplusStatus(surplusData);
|
||
el.innerHTML = aggHtml + surplusHtml + 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">
|
||
${inv.inverter_model === "GROWATT_WIFI_X"
|
||
? `🏠 HA-Entity: ${esc(inv.ha_entity_prefix || "–")}`
|
||
: `📡 ${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 onModelChange(model) {
|
||
document.getElementById("ems-section").style.display =
|
||
model === "KATHREIN_WALLBOX" ? "" : "none";
|
||
const isHaEntity = model === "GROWATT_WIFI_X";
|
||
document.getElementById("modbus-fields").style.display = isHaEntity ? "none" : "";
|
||
document.getElementById("ha-entity-fields").style.display = isHaEntity ? "" : "none";
|
||
}
|
||
|
||
async function openModal(invId) {
|
||
await loadModels();
|
||
const modal = document.getElementById("modal-backdrop");
|
||
const modelSel = document.getElementById("modal-model");
|
||
modelSel.onchange = () => onModelChange(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-ha-prefix").value = inv.ha_entity_prefix || "";
|
||
document.getElementById("modal-ha-url").value = inv.ha_base_url || "http://localhost:8123";
|
||
document.getElementById("modal-ha-token").value = inv.ha_token || "";
|
||
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-max-current").value = inv.ems_max_current_ma ? inv.ems_max_current_ma / 1000 : 32;
|
||
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-ha-prefix").value = "";
|
||
document.getElementById("modal-ha-url").value = "http://localhost:8123";
|
||
document.getElementById("modal-ha-token").value = "";
|
||
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";
|
||
document.getElementById("ems-max-current").value = "32";
|
||
}
|
||
onModelChange(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 === "GROWATT_WIFI_X") {
|
||
inv.ha_entity_prefix = document.getElementById("modal-ha-prefix").value.trim();
|
||
inv.ha_base_url = document.getElementById("modal-ha-url").value.trim() || "http://localhost:8123";
|
||
inv.ha_token = document.getElementById("modal-ha-token").value.trim();
|
||
}
|
||
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_max_current_ma = Math.round(parseFloat(document.getElementById("ems-max-current").value) * 1000);
|
||
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";
|
||
}
|
||
|
||
function updateBillingTrackerUI() {
|
||
const enabled = document.getElementById("cfg-billing-tracker").checked;
|
||
document.getElementById("billing-tracker-fields").style.display = enabled ? "" : "none";
|
||
}
|
||
|
||
async function loadSettings() {
|
||
const cfg = await fetchJSON(api("api/config"));
|
||
globalConfig = cfg;
|
||
loadSurplusDevices();
|
||
const errBanner = document.getElementById("mqtt-error-banner");
|
||
if (cfg.mqtt_error) {
|
||
errBanner.textContent = "⚠ MQTT Fehler: " + cfg.mqtt_error;
|
||
errBanner.style.display = "";
|
||
} else {
|
||
errBanner.style.display = "none";
|
||
}
|
||
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();
|
||
const trackerEnabled = cfg.billing_tracker_enabled ?? false;
|
||
document.getElementById("cfg-billing-tracker").checked = trackerEnabled;
|
||
document.getElementById("billing-tracker-fields").style.display = trackerEnabled ? "" : "none";
|
||
document.getElementById("cfg-monthly-rate").value = cfg.monthly_rate_eur ?? 0;
|
||
document.getElementById("cfg-grundpreis").value = cfg.grundpreis_eur_per_month ?? 0;
|
||
document.getElementById("cfg-backup-retention").value = cfg.config_backup_retention_days ?? 14;
|
||
document.getElementById("cfg-site-lat").value = cfg.site_lat ?? "";
|
||
document.getElementById("cfg-site-lon").value = cfg.site_lon ?? "";
|
||
loadWeatherPreview();
|
||
}
|
||
|
||
async function saveLocation() {
|
||
const lat = document.getElementById("cfg-site-lat").value;
|
||
const lon = document.getElementById("cfg-site-lon").value;
|
||
try {
|
||
await fetchJSON(api("api/config"), {
|
||
method: "POST", headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify({ site_lat: lat === "" ? null : lat, site_lon: lon === "" ? null : lon }),
|
||
});
|
||
showToast("Gespeichert!", "ok");
|
||
setTimeout(loadWeatherPreview, 500);
|
||
} catch(e) { showToast("Fehler beim Speichern", "err"); }
|
||
}
|
||
|
||
async function loadWeatherPreview() {
|
||
const el = document.getElementById("weather-preview");
|
||
let data;
|
||
try { data = await fetchJSON(api("api/weather-forecast")); }
|
||
catch(e) { el.innerHTML = ""; return; }
|
||
|
||
if (!data.configured) {
|
||
el.innerHTML = `<span style="color:var(--text-dim)">Kein Standort gesetzt — Zwangslade-Timeout bleibt fest.</span>`;
|
||
return;
|
||
}
|
||
if (data.error) {
|
||
el.innerHTML = `<span style="color:var(--red)">${esc(data.error)}</span>`;
|
||
return;
|
||
}
|
||
const verdictColor = data.verdict === "verlängert" ? "var(--green)" : data.verdict === "verkürzt" ? "var(--orange, #ffa657)" : "var(--text-dim)";
|
||
el.innerHTML = `
|
||
<div style="color:var(--text-dim)">Solarstrahlung nächste 3h: <strong style="color:var(--text)">${data.next_3h_max_ghi} W/m²</strong> · nächste 12h: <strong style="color:var(--text)">${data.horizon_12h_max_ghi} W/m²</strong></div>
|
||
<div style="margin-top:4px;color:${verdictColor}">Beispiel bei ${data.example_base_h}h Basis-Timeout: <strong>${data.example_adjusted_h}h</strong> (${data.verdict})</div>`;
|
||
}
|
||
|
||
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) || 1,
|
||
billing_month: parseInt(document.getElementById("cfg-billing-month").value) || 1,
|
||
billing_tracker_enabled: document.getElementById("cfg-billing-tracker").checked,
|
||
monthly_rate_eur: parseFloat(document.getElementById("cfg-monthly-rate").value || 0),
|
||
grundpreis_eur_per_month: parseFloat(document.getElementById("cfg-grundpreis").value || 0),
|
||
};
|
||
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 = "";
|
||
}
|
||
|
||
// ── Automatische Backups ─────────────────────────────────────
|
||
|
||
async function saveBackupRetention() {
|
||
const days = parseInt(document.getElementById("cfg-backup-retention").value) || 14;
|
||
try {
|
||
await fetchJSON(api("api/config"), {
|
||
method: "POST", headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify({ config_backup_retention_days: days }),
|
||
});
|
||
showToast("Gespeichert!", "ok");
|
||
} catch(e) { showToast("Fehler beim Speichern", "err"); }
|
||
}
|
||
|
||
function fBytes(n) {
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
|
||
return `${(n/1024/1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
async function loadBackupList() {
|
||
const el = document.getElementById("backup-list");
|
||
let data;
|
||
try { data = await fetchJSON(api("api/config-backups")); }
|
||
catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; return; }
|
||
|
||
const backups = data.backups || [];
|
||
if (backups.length === 0) {
|
||
el.innerHTML = `<div style="font-size:12px;color:var(--text-dim)">Noch keine Backups — das erste entsteht beim nächsten Add-on-Start oder spätestens morgen.</div>`;
|
||
return;
|
||
}
|
||
el.innerHTML = backups.map(b => {
|
||
const date = new Date(b.modified_at * 1000).toLocaleString('de-DE');
|
||
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:8px 12px;font-size:13px">
|
||
<div><strong>${esc(b.filename)}</strong><br><span style="color:var(--text-dim);font-size:11px">${date} · ${fBytes(b.size_bytes)}</span></div>
|
||
<div style="display:flex;gap:6px;flex-shrink:0">
|
||
<button class="btn" style="padding:4px 10px;font-size:12px" onclick="downloadBackup('${esc(b.filename)}')">Download</button>
|
||
<button class="btn btn-danger" style="padding:4px 10px;font-size:12px" onclick="restoreBackup('${esc(b.filename)}')">Wiederherstellen</button>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
function downloadBackup(filename) {
|
||
window.location.href = api(`api/config-backups/${encodeURIComponent(filename)}/download`);
|
||
}
|
||
|
||
async function restoreBackup(filename) {
|
||
if (!confirm(`"${filename}" wiederherstellen? Das überschreibt die aktuelle Konfiguration (Geräte, MQTT) komplett.`)) return;
|
||
try {
|
||
const res = await fetchJSON(api(`api/config-backups/${encodeURIComponent(filename)}/restore`), { method: "POST" });
|
||
showToast(`${res.inverters} Gerät(e) wiederhergestellt`, "ok");
|
||
setTimeout(() => { loadSettings(); loadInverters(); }, 3000);
|
||
} catch(e) { showToast("Wiederherstellen fehlgeschlagen", "err"); }
|
||
}
|
||
|
||
// ── Surplus Devices ───────────────────────────────────────────
|
||
|
||
let surplusDevices = [];
|
||
|
||
function field(label, input) {
|
||
return `<div style="display:flex;flex-direction:column;gap:3px">
|
||
<span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap">${label}</span>
|
||
${input}
|
||
</div>`;
|
||
}
|
||
|
||
function renderSurplusDeviceRow(dev) {
|
||
const id = dev.id || ('sd_' + Math.random().toString(36).slice(2));
|
||
return `<div class="surplus-device-row" data-id="${esc(id)}">
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
|
||
${field('Name', `<input type="text" placeholder="Heizstab Keller" style="width:130px" value="${esc(dev.name||'')}" data-field="name">`)}
|
||
${field('Z2M Friendly Name', `<input type="text" placeholder="heizstab-keller" list="z2m-device-list" style="width:160px" value="${esc(dev.z2m_name||'')}" data-field="z2m_name">`)}
|
||
${field('Schwellwert (W)', `<input type="number" style="width:90px" value="${dev.threshold_w||500}" min="0" step="50" data-field="threshold_w">`)}
|
||
${field('Hysterese (W)', `<input type="number" style="width:90px" value="${dev.hysteresis_w||150}" min="0" step="50" data-field="hysteresis_w">`)}
|
||
${field('Min. Laufzeit (min)', `<input type="number" style="width:90px" value="${dev.min_on_minutes||0}" min="0" step="5" data-field="min_on_minutes">`)}
|
||
<div style="display:flex;flex-direction:column;gap:6px;padding-bottom:2px">
|
||
<label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap">
|
||
<input type="checkbox" ${dev.enabled!==false?'checked':''} data-field="enabled" style="width:auto;margin:0"> Aktiv
|
||
</label>
|
||
<label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap" title="AUS bei Überschuss, EIN bei Netzbezug">
|
||
<input type="checkbox" ${dev.inverted?'checked':''} data-field="inverted" style="width:auto;margin:0"> Invertiert
|
||
</label>
|
||
</div>
|
||
<button class="btn btn-secondary" style="padding:3px 8px;font-size:12px;flex-shrink:0;align-self:flex-end" onclick="removeSurplusDevice(this)">✕</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function loadZ2mDevices() {
|
||
const devices = await fetchJSON(api("api/z2m-devices")).catch(() => []);
|
||
const dl = document.getElementById("z2m-device-list");
|
||
dl.innerHTML = devices.map(d =>
|
||
`<option value="${esc(d.friendly_name)}">${esc(d.friendly_name)}${d.description ? ' — ' + esc(d.description) : ''}</option>`
|
||
).join('');
|
||
}
|
||
|
||
async function loadSurplusDevices() {
|
||
const [d] = await Promise.all([
|
||
fetchJSON(api("api/surplus-devices")).catch(() => ({})),
|
||
loadZ2mDevices(),
|
||
]);
|
||
surplusDevices = d.devices || [];
|
||
document.getElementById("cfg-z2m-base").value = d.z2m_base || "zigbee2mqtt";
|
||
const list = document.getElementById("surplus-device-list");
|
||
list.innerHTML = surplusDevices.map(renderSurplusDeviceRow).join('');
|
||
}
|
||
|
||
function addSurplusDevice() {
|
||
const list = document.getElementById("surplus-device-list");
|
||
const id = 'sd_' + Math.random().toString(36).slice(2);
|
||
const div = document.createElement('div');
|
||
div.innerHTML = renderSurplusDeviceRow({id, name:'', z2m_name:'', threshold_w:500, hysteresis_w:150, enabled:true});
|
||
list.appendChild(div.firstElementChild);
|
||
}
|
||
|
||
function removeSurplusDevice(btn) {
|
||
btn.closest('.surplus-device-row').remove();
|
||
}
|
||
|
||
function _collectSurplusDevices() {
|
||
return Array.from(document.querySelectorAll('.surplus-device-row')).map(row => ({
|
||
id: row.dataset.id,
|
||
name: row.querySelector('[data-field=name]').value.trim(),
|
||
z2m_name: row.querySelector('[data-field=z2m_name]').value.trim(),
|
||
threshold_w: parseFloat(row.querySelector('[data-field=threshold_w]').value) || 0,
|
||
hysteresis_w: parseFloat(row.querySelector('[data-field=hysteresis_w]').value) || 0,
|
||
min_on_minutes: parseFloat(row.querySelector('[data-field=min_on_minutes]').value) || 0,
|
||
inverted: row.querySelector('[data-field=inverted]').checked,
|
||
enabled: row.querySelector('[data-field=enabled]').checked,
|
||
}));
|
||
}
|
||
|
||
async function saveSurplusDevices() {
|
||
const devices = _collectSurplusDevices();
|
||
const z2m_base = document.getElementById("cfg-z2m-base").value.trim() || "zigbee2mqtt";
|
||
for (const d of devices) {
|
||
if (!d.z2m_name) { showToast("Z2M Name darf nicht leer sein", "err"); return; }
|
||
}
|
||
try {
|
||
await fetchJSON(api("api/surplus-devices"), {
|
||
method: "POST", headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify({devices, z2m_base}),
|
||
});
|
||
showToast("Überschuss-Geräte gespeichert", "ok");
|
||
} catch(e) {
|
||
showToast("Fehler beim Speichern", "err");
|
||
}
|
||
}
|
||
|
||
function renderSurplusStatus(surplusData) {
|
||
if (!surplusData || !surplusData.devices || !surplusData.devices.length) return '';
|
||
const states = surplusData.states || {};
|
||
const rows = surplusData.devices
|
||
.filter(d => d.enabled !== false)
|
||
.map(d => {
|
||
const st = states[d.z2m_name] || {};
|
||
const on = st.on === true;
|
||
const onMin = st.on_minutes ?? null;
|
||
const minOn = d.min_on_minutes || 0;
|
||
const dot = `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:${on?'var(--green)':'var(--border)'}"></span>`;
|
||
let info = '';
|
||
if (on && minOn > 0 && onMin !== null) {
|
||
const remaining = Math.max(0, minOn - onMin);
|
||
info = remaining > 0
|
||
? `<span style="font-size:11px;color:var(--text-dim)">(${remaining}min Mindestlaufzeit)</span>`
|
||
: `<span style="font-size:11px;color:var(--text-dim)">(${onMin}min)</span>`;
|
||
}
|
||
return `<div style="display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 0">
|
||
${dot}
|
||
<span>${esc(d.name||d.z2m_name)}</span>
|
||
<span style="color:var(--text-dim);font-size:11px">${d.inverted ? '↓' : '↑'} ${d.threshold_w}W</span>
|
||
${info}
|
||
<span style="margin-left:auto;font-weight:600;color:${on?'var(--green)':'var(--text-dim)'}">${on?'EIN':'AUS'}</span>
|
||
</div>`;
|
||
}).join('');
|
||
if (!rows) return '';
|
||
const surplusW = surplusData.surplus_w ?? null;
|
||
const surplusInfo = surplusW !== null
|
||
? `<span style="font-size:11px;color:var(--text-dim);margin-left:auto">${surplusW >= 0 ? '+' : ''}${Math.round(surplusW)} W Überschuss</span>`
|
||
: '';
|
||
return `<div class="inv-section">
|
||
<div class="inv-header">
|
||
<div class="inv-title">Überschuss-Geräte</div>
|
||
<div class="inv-badge ok">Z2M</div>
|
||
${surplusInfo}
|
||
</div>
|
||
<div style="padding:0 2px">${rows}</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Flash-Wizard + Setup-Wizard ausgelagert nach growatt-diagnose-tool (v1.9.0) —
|
||
// Geräte werden jetzt direkt über "+ Gerät hinzufügen" im Geräte-Tab angelegt.
|
||
|
||
// ── Init ──────────────────────────────────────────────────────
|
||
|
||
(async () => {
|
||
await Promise.all([loadSettings(), loadInverters(), loadModels()]);
|
||
startRefresh();
|
||
})();
|
||
|
||
window.addEventListener("beforeunload", stopRefresh);
|
||
</script>
|
||
</body>
|
||
</html>
|