Files
Shinebridge/haos-addon/src/web/index.html
T
retr0andClaude Sonnet 5 1ce98df596 ShineBridge v1.12.1 — Feature: Wetterprognose-Vorschau im Einstellungen-Tab
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>
2026-08-26 16:28:34 +02:00

1571 lines
80 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; }
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,'&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","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 &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) : '';
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>