448de29cd9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
977 lines
47 KiB
HTML
977 lines
47 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; }
|
||
body { background: var(--bg); color: var(--text);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
font-size: 14px; min-height: 100vh; }
|
||
|
||
header { display: flex; align-items: center; gap: 12px;
|
||
padding: 16px 20px; background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
position: sticky; top: 0; z-index: 100; }
|
||
header h1 { font-size: 16px; font-weight: 600; }
|
||
header .subtitle { font-size: 12px; color: var(--text-dim); }
|
||
.status-pill { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
||
.pill { display: flex; align-items: center; gap: 5px; padding: 4px 10px;
|
||
border-radius: 20px; font-size: 12px; font-weight: 600;
|
||
background: var(--surface2); border: 1px solid var(--border); }
|
||
.pill.ok { color: var(--green); border-color: var(--green); }
|
||
.pill.err { color: var(--red); border-color: var(--red); }
|
||
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor;
|
||
animation: pulse 2s infinite; }
|
||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||
|
||
main { padding: 20px; max-width: 1100px; margin: 0 auto; }
|
||
.tabs { display: flex; gap: 4px; margin-bottom: 20px;
|
||
border-bottom: 1px solid var(--border); }
|
||
.tab { padding: 10px 16px; cursor: pointer; color: var(--text-dim);
|
||
font-weight: 500; border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px; transition: color .15s, border-color .15s; }
|
||
.tab.active { color: var(--accent); border-color: var(--accent); }
|
||
.tab:hover { color: var(--text); }
|
||
.panel { display: none; }
|
||
.panel.active { display: block; }
|
||
|
||
/* Sensor Grid */
|
||
.inv-section { margin-bottom: 28px; }
|
||
.inv-header { display: flex; align-items: center; gap: 10px;
|
||
margin-bottom: 12px; }
|
||
.inv-title { font-size: 15px; font-weight: 600; }
|
||
.inv-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px;
|
||
background: var(--surface2); border: 1px solid var(--border);
|
||
color: var(--text-dim); }
|
||
.inv-badge.ok { color: var(--green); border-color: var(--green); }
|
||
.inv-badge.err { color: var(--red); border-color: var(--red); }
|
||
.sensor-grid { display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; }
|
||
.sensor-card { background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: var(--radius); padding: 14px 16px; }
|
||
.sensor-icon { font-size: 18px; margin-bottom: 6px; }
|
||
.sensor-name { font-size: 11px; color: var(--text-dim);
|
||
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
|
||
.sensor-value { font-size: 20px; font-weight: 700;
|
||
font-variant-numeric: tabular-nums; }
|
||
.sensor-unit { font-size: 11px; color: var(--text-dim); margin-left: 2px; }
|
||
.sparkline { margin-top: 8px; opacity: .7; }
|
||
.dc-power .sensor-value { color: var(--accent); }
|
||
.dc-voltage .sensor-value { color: var(--blue); }
|
||
.dc-current .sensor-value { color: var(--orange); }
|
||
.dc-energy .sensor-value { color: var(--green); }
|
||
.dc-temperature .sensor-value { color: var(--red); }
|
||
.dc-battery .sensor-value { color: var(--purple); }
|
||
.no-data { text-align: center; padding: 40px 20px; color: var(--text-dim); font-size: 13px; }
|
||
|
||
/* Inverter management */
|
||
.inv-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
|
||
.inv-card { background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: var(--radius); padding: 18px; }
|
||
.inv-card-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
|
||
.inv-card-icon { font-size: 28px; }
|
||
.inv-card-info { flex: 1; }
|
||
.inv-card-name { font-weight: 600; font-size: 15px; margin-bottom: 2px; }
|
||
.inv-card-model { font-size: 12px; color: var(--text-dim); }
|
||
.inv-card-actions { display: flex; gap: 6px; margin-top: 14px; }
|
||
.btn { padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border);
|
||
background: var(--surface2); color: var(--text); cursor: pointer;
|
||
font-size: 12px; font-weight: 500; transition: border-color .15s; }
|
||
.btn:hover { border-color: var(--text-dim); }
|
||
.btn-danger { color: var(--red); border-color: var(--red); }
|
||
.btn-danger:hover { background: rgba(248,81,73,.1); }
|
||
.btn-primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 700; }
|
||
.btn-primary:hover { opacity: .85; }
|
||
.btn-secondary { color: var(--blue); border-color: var(--blue); }
|
||
.btn-secondary:hover { background: rgba(88,166,255,.1); }
|
||
.inv-card-meta { font-size: 12px; color: var(--text-dim); line-height: 1.7; }
|
||
.add-btn { display: flex; align-items: center; justify-content: center; gap: 8px;
|
||
padding: 20px; background: var(--surface); border: 2px dashed var(--border);
|
||
border-radius: var(--radius); cursor: pointer; color: var(--text-dim);
|
||
font-size: 14px; transition: border-color .15s, color .15s; }
|
||
.add-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
|
||
/* Settings */
|
||
.settings-section { background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: var(--radius); padding: 20px; max-width: 500px; }
|
||
.settings-section h3 { font-size: 13px; font-weight: 600; color: var(--text-dim);
|
||
text-transform: uppercase; letter-spacing: .06em;
|
||
margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
|
||
.field { margin-bottom: 20px; }
|
||
.field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
|
||
.field input, .field select { width: 100%; padding: 8px 10px;
|
||
background: var(--surface2); border: 1px solid var(--border);
|
||
border-radius: 6px; color: var(--text); font-size: 13px; outline: none;
|
||
transition: border-color .15s; }
|
||
.field input:focus, .field select:focus { border-color: var(--accent); }
|
||
.field select option { background: var(--surface2); }
|
||
|
||
/* Modal */
|
||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.7);
|
||
z-index: 200; display: flex; align-items: center; justify-content: center;
|
||
opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||
.modal-backdrop.open { opacity: 1; pointer-events: all; }
|
||
.modal { background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: var(--radius); padding: 24px; width: 100%; max-width: 440px;
|
||
max-height: 90vh; overflow-y: auto; }
|
||
.modal h2 { font-size: 16px; margin-bottom: 20px; }
|
||
.modal-actions { display: flex; gap: 8px; margin-top: 20px; justify-content: flex-end; }
|
||
|
||
/* Info row */
|
||
.info-row { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
|
||
.info-chip { padding: 5px 12px; background: var(--surface);
|
||
border: 1px solid var(--border); border-radius: 20px;
|
||
font-size: 12px; color: var(--text-dim); }
|
||
.info-chip span { color: var(--text); font-weight: 600; }
|
||
|
||
/* Energy Dashboard */
|
||
.energy-wrap { max-width: 580px; margin: 0 auto; }
|
||
.energy-svg-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; overflow: hidden; }
|
||
.energy-kwh { display: grid; grid-template-columns: repeat(auto-fit, minmax(95px, 1fr)); gap: 10px; }
|
||
.kwh-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 8px; text-align: center; }
|
||
.kwh-card .kv { font-size: 18px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||
.kwh-card .kl { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; margin-top: 4px; }
|
||
.flow-dot { /* dots via animateMotion — no CSS animation needed */ }
|
||
|
||
/* Toast */
|
||
.toast { position: fixed; bottom: 24px; right: 24px; padding: 12px 20px;
|
||
background: var(--surface2); border: 1px solid var(--border);
|
||
border-radius: 8px; font-size: 13px; z-index: 999;
|
||
transform: translateY(80px); opacity: 0;
|
||
transition: transform .3s, opacity .3s; pointer-events: none; }
|
||
.toast.show { transform: translateY(0); opacity: 1; }
|
||
.toast.ok { border-color: var(--green); color: var(--green); }
|
||
.toast.err { border-color: var(--red); color: var(--red); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||
<circle cx="12" cy="12" r="5" fill="#f0c040"/>
|
||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" stroke="#f0c040" stroke-width="2" stroke-linecap="round"/>
|
||
</svg>
|
||
<div>
|
||
<h1>ShineBridge</h1>
|
||
<div class="subtitle" id="subtitle">Lade...</div>
|
||
</div>
|
||
<div class="status-pill">
|
||
<div class="pill" id="pill-mqtt"><div class="dot"></div>MQTT</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<div class="tabs">
|
||
<div class="tab active" onclick="switchTab('energy')">Energie</div>
|
||
<div class="tab" onclick="switchTab('live')">Live-Daten</div>
|
||
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
|
||
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
|
||
</div>
|
||
|
||
<!-- Energy Panel -->
|
||
<div class="panel active" id="panel-energy">
|
||
<div id="energy-content"><div class="no-data">Warte auf erste Messung…</div></div>
|
||
</div>
|
||
|
||
<!-- Live Panel -->
|
||
<div class="panel" id="panel-live">
|
||
<div id="live-content"><div class="no-data">Warte auf erste Messung...</div></div>
|
||
</div>
|
||
|
||
<!-- Inverters Panel -->
|
||
<div class="panel" id="panel-inverters">
|
||
<div class="inv-list" id="inv-list"></div>
|
||
</div>
|
||
|
||
<!-- Settings Panel -->
|
||
<div class="panel" id="panel-settings">
|
||
<div class="settings-section">
|
||
<h3>MQTT Broker</h3>
|
||
<div class="field"><label>Broker</label>
|
||
<input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto"></div>
|
||
<div class="field"><label>Port</label>
|
||
<input type="number" id="cfg-mqtt-port" placeholder="1883"></div>
|
||
<div class="field"><label>Benutzername</label>
|
||
<input type="text" id="cfg-mqtt-user" autocomplete="off"></div>
|
||
<div class="field"><label>Passwort</label>
|
||
<input type="password" id="cfg-mqtt-pass" placeholder="leer = unverändert"></div>
|
||
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<h3>Stromtarif</h3>
|
||
<div class="field">
|
||
<label>Tarifart</label>
|
||
<div style="display:flex;gap:16px;margin-top:4px">
|
||
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px">
|
||
<input type="radio" name="tariff-type" value="fixed" id="tariff-fixed" onchange="updateTariffUI()"> Festpreis
|
||
</label>
|
||
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px">
|
||
<input type="radio" name="tariff-type" value="spot" id="tariff-spot" onchange="updateTariffUI()"> Flexibel (Börsenstrom)
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="tariff-fixed-fields">
|
||
<div class="field"><label>Bezugspreis (€/kWh)</label>
|
||
<input type="number" id="cfg-price-import" step="0.001" placeholder="0.300"></div>
|
||
</div>
|
||
<div id="tariff-spot-fields" style="display:none">
|
||
<div class="field"><label>Aufschlag Netz + Steuern (ct/kWh)</label>
|
||
<input type="number" id="cfg-spot-markup" step="0.1" placeholder="15.0">
|
||
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">Netzentgelt + Steuern + Aufschlag Anbieter — wird auf den Börsenpreis addiert</div>
|
||
</div>
|
||
<div class="field"><label>Land</label>
|
||
<select id="cfg-spot-country" style="background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none">
|
||
<option value="de">Deutschland</option>
|
||
<option value="at">Österreich</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="field"><label>Einspeisevergütung (€/kWh)</label>
|
||
<input type="number" id="cfg-price-export" step="0.001" placeholder="0.080"></div>
|
||
<div class="field" style="display:flex;align-items:center;gap:8px">
|
||
<input type="checkbox" id="cfg-spot-chart" style="width:auto;margin:0">
|
||
<label for="cfg-spot-chart" style="margin:0;cursor:pointer;font-size:13px">Börsenstrompreis-Chart anzeigen</label>
|
||
</div>
|
||
<div class="field"><label>Abrechnungsjahr beginnt am</label>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<input type="number" id="cfg-billing-day" min="1" max="28" placeholder="1" style="width:72px">
|
||
<span style="color:var(--text-dim)">.</span>
|
||
<input type="number" id="cfg-billing-month" min="1" max="12" placeholder="1" style="width:72px">
|
||
<span style="color:var(--text-dim);font-size:11px">(Tag . Monat)</span>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">z.B. 1 . 4 = 01. April</div>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="savePrices()">Speichern</button>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<h3>Konfiguration sichern</h3>
|
||
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Alle Geräte und MQTT-Einstellungen als JSON exportieren und bei einer Neuinstallation wieder einlesen.</p>
|
||
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||
<button class="btn btn-secondary" onclick="exportConfig()">⇓ 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>
|
||
</main>
|
||
|
||
<!-- Inverter Edit Modal -->
|
||
<div class="modal-backdrop" id="modal-backdrop" onclick="closeModal(event)">
|
||
<div class="modal" onclick="event.stopPropagation()">
|
||
<h2 id="modal-title">Gerät hinzufügen</h2>
|
||
<input type="hidden" id="modal-id">
|
||
<div class="field"><label>Name</label>
|
||
<input type="text" id="modal-name" placeholder="z.B. Dach Süd"></div>
|
||
<div class="field"><label>Gerätetyp</label>
|
||
<select id="modal-model"></select></div>
|
||
<div class="field"><label>IP-Adresse</label>
|
||
<input type="text" id="modal-ip" placeholder="10.10.20.190"></div>
|
||
<div class="field"><label>Modbus Port</label>
|
||
<input type="number" id="modal-port" value="502"></div>
|
||
<div class="field"><label>Modbus Slave-Adresse</label>
|
||
<input type="number" id="modal-addr" value="1" min="1" max="247"></div>
|
||
<div class="field"><label>MQTT Topic-Präfix</label>
|
||
<input type="text" id="modal-prefix" placeholder="growatt/wechselrichter1"></div>
|
||
<div class="field"><label>Abfrageintervall (Sekunden)</label>
|
||
<input type="number" id="modal-interval" value="30" min="5"></div>
|
||
|
||
<div id="ems-section" style="display:none; border-top:1px solid var(--border); margin-top:14px; padding-top:14px;">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
|
||
<div style="font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--accent);">EMS — Ladesteuerung</div>
|
||
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-dim)">
|
||
<input type="checkbox" id="ems-enabled" checked style="width:auto;margin:0"> Aktiv
|
||
</label>
|
||
</div>
|
||
<div class="field"><label>Mindest-PV-Überschuss (W)</label>
|
||
<input type="number" id="ems-min-pv" value="1400" min="100" step="100">
|
||
<div style="font-size:11px;color:var(--muted);margin-top:3px">Mindestleistung für PV-Laden (min. 6A × 230V = 1380W)</div></div>
|
||
<div class="field"><label>Timeout ohne PV (Stunden)</label>
|
||
<input type="number" id="ems-timeout" value="4" min="0.5" max="24" step="0.5">
|
||
<div style="font-size:11px;color:var(--muted);margin-top:3px">Nach dieser Zeit ohne Überschuss startet Zwangsladen</div></div>
|
||
<div class="field"><label>Vollladung bis (Uhr)</label>
|
||
<input type="number" id="ems-target-hour" value="6" min="0" max="23">
|
||
<div style="font-size:11px;color:var(--muted);margin-top:3px">Zielzeit für vollgeladenes Auto (z.B. 6 = 06:00 Uhr)</div></div>
|
||
<div class="field"><label>Anzahl Phasen</label>
|
||
<select id="ems-phases">
|
||
<option value="1">1-phasig</option>
|
||
<option value="2">2-phasig</option>
|
||
<option value="3" selected>3-phasig</option>
|
||
</select></div>
|
||
</div>
|
||
|
||
<div class="modal-actions">
|
||
<button class="btn" onclick="closeModal()">Abbrechen</button>
|
||
<button class="btn btn-primary" onclick="saveInverter()">Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
function esc(s) {
|
||
return String(s).replace(/&/g,'&').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","live","inverters","settings"].forEach((t, i) => {
|
||
document.querySelectorAll(".tab")[i].classList.toggle("active", t === name);
|
||
document.querySelectorAll(".panel")[i].classList.toggle("active", t === name);
|
||
});
|
||
if (name === "energy" || name === "live") startRefresh(); else stopRefresh();
|
||
}
|
||
|
||
// ── Energy Dashboard ──────────────────────────────────────────
|
||
|
||
function renderSpotChart(spotData) {
|
||
if (!spotData || !spotData.length) return '';
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const startH = Math.floor(now / 3600) * 3600;
|
||
const hours = spotData.filter(d => d.ts >= startH && d.ts < startH + 86400).sort((a,b) => a.ts - b.ts);
|
||
if (hours.length < 2) return '';
|
||
|
||
const prices = hours.map(h => h.price);
|
||
const minP = Math.min(...prices), maxP = Math.max(...prices);
|
||
const rangeP = maxP - minP || 1;
|
||
const q1 = minP + rangeP / 3, q2 = minP + 2 * rangeP / 3;
|
||
const priceCol = p => p <= q1 ? '#3fb950' : p <= q2 ? '#f0c040' : '#f85149';
|
||
|
||
const W=520, H=108, PL=34, PR=8, PT=14, PB=22;
|
||
const cW=W-PL-PR, cH=H-PT-PB, bW=cW/hours.length;
|
||
|
||
const bars = hours.map((h,i) => {
|
||
const isNow = now >= h.ts && now < h.ts+3600;
|
||
const barH = Math.max(2, ((h.price-minP)/rangeP)*cH);
|
||
const x=(PL+i*bW), y=PT+cH-barH;
|
||
const col = priceCol(h.price);
|
||
const hh = new Date(h.ts*1000).getHours();
|
||
const tl = hh%6===0 ? `<text x="${(x+bW/2).toFixed(1)}" y="${H-3}" text-anchor="middle" font-size="8" fill="#8b949e">${String(hh).padStart(2,'0')}h</text>` : '';
|
||
const pl = isNow ? `<text x="${(x+bW/2).toFixed(1)}" y="${(y-3).toFixed(1)}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${col}">${h.price.toFixed(1)}</text>` : '';
|
||
return `<rect x="${(x+1).toFixed(1)}" y="${y.toFixed(1)}" width="${(bW-2).toFixed(1)}" height="${barH.toFixed(1)}" fill="${col}" opacity="${isNow?1:.6}" rx="1.5"/>
|
||
${isNow?`<rect x="${x.toFixed(1)}" y="${PT}" width="${bW.toFixed(1)}" height="${cH}" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5" rx="1.5"/>`:''}
|
||
${tl}${pl}`;
|
||
}).join('');
|
||
|
||
const yTicks = [[minP,PT+cH],[maxP,PT]].map(([p,y]) =>
|
||
`<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="#30363d" stroke-width="0.5"/>
|
||
<text x="${PL-3}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="8" fill="#8b949e">${p.toFixed(0)}</text>`
|
||
).join('');
|
||
|
||
const cur = hours.find(h => now>=h.ts && now<h.ts+3600);
|
||
const curLabel = cur
|
||
? `<span style="font-size:16px;font-weight:700;color:${priceCol(cur.price)}">${cur.price.toFixed(2)} ct/kWh</span><span style="font-size:10px;color:#8b949e;margin-left:6px">Jetzt</span>`
|
||
: '';
|
||
|
||
return `<div style="border-top:1px solid #30363d;padding:14px 14px 4px">
|
||
<div style="display:flex;align-items:baseline;gap:6px;margin-bottom:8px">
|
||
<span style="font-size:10px;font-weight:700;letter-spacing:.08em;color:#8b949e;text-transform:uppercase">EPEX SPOT · 24h</span>
|
||
${curLabel}
|
||
</div>
|
||
<svg viewBox="0 0 ${W} ${H}" width="100%" style="display:block" xmlns="http://www.w3.org/2000/svg">
|
||
${yTicks}${bars}
|
||
</svg>
|
||
</div>`;
|
||
}
|
||
|
||
function renderEnergy(inverters, aggregates, period, spotData) {
|
||
const el = document.getElementById("energy-content");
|
||
if (!aggregates || !Object.keys(aggregates).length) {
|
||
el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>';
|
||
return;
|
||
}
|
||
|
||
const pvW = aggregates.total_pv_power || 0;
|
||
const gridW = aggregates.grid_power || 0;
|
||
const batChW = aggregates.bat_charge_power || 0;
|
||
const batDchW = aggregates.bat_discharge_power || 0;
|
||
const batSoc = aggregates.bat_soc;
|
||
|
||
let evW = 0;
|
||
Object.entries(inverters).forEach(([id, inv]) => {
|
||
const cfg = invertersList.find(c => c.id === id);
|
||
if (cfg && cfg.inverter_model === 'KATHREIN_WALLBOX')
|
||
evW += (inv.values && inv.values.total_power) || 0;
|
||
});
|
||
|
||
const gridImport = Math.max(0, gridW);
|
||
const gridExport = Math.max(0, -gridW);
|
||
const houseW = Math.max(0, pvW + gridImport + batDchW - batChW - evW);
|
||
const hasBat = Object.keys(aggregates).some(k => k.startsWith('bat_'));
|
||
const hasEV = invertersList.some(c => c.inverter_model === 'KATHREIN_WALLBOX');
|
||
|
||
const T = 20;
|
||
const pvOn = pvW > T;
|
||
const impOn = gridImport > T;
|
||
const expOn = gridExport > T;
|
||
const chOn = batChW > T;
|
||
const dchOn = batDchW > T;
|
||
const batFlowIn = batChW >= batDchW; // true = Laden (Haus→Bat), false = Entladen (Bat→Haus)
|
||
const evOn = evW > T;
|
||
|
||
const C = {
|
||
pv:'#f0c040', imp:'#f85149', exp:'#3fb950',
|
||
bat:'#bc8cff', ev:'#58a6ff', txt:'#e6edf3',
|
||
dim:'#8b949e', brd:'#30363d', s2:'#21262d',
|
||
};
|
||
|
||
function fW(w) {
|
||
const a = Math.abs(w);
|
||
if (a < 5) return '—';
|
||
if (a >= 1000) return (a / 1000).toFixed(2) + ' kW';
|
||
return Math.round(a) + ' W';
|
||
}
|
||
|
||
// SVG stroke icons on 20×20 canvas
|
||
const ICONS = {
|
||
solar:`<circle cx="10" cy="10" r="3.2" fill="none" stroke-width="1.7"/>
|
||
<path d="M10,1.5v3M10,15.5v3M1.5,10h3M15.5,10h3M4.1,4.1l2.1,2.1M13.8,13.8l2.1,2.1M15.9,4.1l-2.1,2.1M6.2,13.8l-2.1,2.1" stroke-width="1.7" stroke-linecap="round"/>`,
|
||
grid: `<path d="M1.5,10 C3.5,3 6.5,3 8.5,10 C10.5,17 13.5,17 15.5,10 C16.5,6.5 17.5,6.5 18.5,10" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`,
|
||
house:`<path d="M1.5,9.5 L10,2 L18.5,9.5" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||
<path d="M3.5,9.5 L3.5,18 L16.5,18 L16.5,9.5" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||
<path d="M7.5,18 L7.5,13 Q7.5,11 10,11 Q12.5,11 12.5,13 L12.5,18" stroke-width="1.7" stroke-linecap="round" fill="none"/>`,
|
||
bat: `<rect x="1" y="6.5" width="15.5" height="9" rx="2.5" fill="none" stroke-width="1.7"/>
|
||
<path d="M16.5,9 L19,9 L19,13 L16.5,13" stroke-width="1.7" stroke-linecap="round" fill="none"/>`,
|
||
ev: `<path d="M12,1.5 L5.5,11.5 L10,11.5 L8,20 L14.5,8.5 L10,8.5 Z" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" fill="none"/>`,
|
||
};
|
||
|
||
function icon(name, cx, cy, col, sz) {
|
||
sz = sz || 26;
|
||
const s = (sz / 20).toFixed(4);
|
||
return `<g transform="translate(${(cx-sz/2).toFixed(1)},${(cy-sz/2).toFixed(1)}) scale(${s})" stroke="${col}" fill="none">${ICONS[name]}</g>`;
|
||
}
|
||
|
||
// Circle node — HA style
|
||
const R = 62;
|
||
function node(cx, cy, iconName, topLabel, valW, col, active, sub) {
|
||
const c = active ? col : C.dim;
|
||
const sw = active ? 2 : 1;
|
||
const fi = active ? '0.08' : '0.03';
|
||
return `<g>
|
||
<circle cx="${cx}" cy="${cy}" r="${R}" fill="${col}" fill-opacity="${fi}" stroke="${c}" stroke-width="${sw}"/>
|
||
${icon(iconName, cx, cy - 17, c, 28)}
|
||
<text x="${cx}" y="${cy+8}" text-anchor="middle" font-size="14" font-weight="700" fill="${active?col:C.txt}" dominant-baseline="middle">${fW(valW)}</text>
|
||
<text x="${cx}" y="${cy+26}" text-anchor="middle" font-size="10" fill="${C.dim}" dominant-baseline="middle" letter-spacing=".06em">${topLabel}${sub?' · '+sub:''}</text>
|
||
</g>`;
|
||
}
|
||
|
||
// Animated dots along a named path (HA-style)
|
||
function flowDots(pid, col, on, reverse, spd) {
|
||
if (!on) return '';
|
||
spd = spd || 1.6;
|
||
const kp = reverse ? '1;0' : '0;1';
|
||
return [0, spd/3, 2*spd/3].map(b =>
|
||
`<circle r="3.8" fill="${col}" opacity=".9">
|
||
<animateMotion dur="${spd.toFixed(2)}s" repeatCount="indefinite" begin="${b.toFixed(3)}s" calcMode="linear" keyPoints="${kp}" keyTimes="0;1">
|
||
<mpath href="#${pid}"/>
|
||
</animateMotion>
|
||
</circle>`
|
||
).join('');
|
||
}
|
||
|
||
// Kreuz-Layout: Solar(260,72) oben, Grid(108,224) links, Haus(260,224) Mitte,
|
||
// Batterie(412,224) rechts, Wallbox(260,376) unten (optional). R=62, Abstand=28px.
|
||
const SEG = [
|
||
{ id:'ep-pv', d:`M 260,134 C 258,148 262,148 260,162`, col:C.pv, on:pvOn, rev:false },
|
||
{ id:'ep-grid', d:`M 170,224 C 182,220 186,228 198,224`, col:impOn?C.imp:C.exp, on:impOn||expOn, rev:expOn },
|
||
{ id:'ep-bat', d:`M 322,224 C 334,220 338,228 350,224`, col:C.bat, on:chOn||dchOn, rev:!batFlowIn },
|
||
...(hasEV ? [{ id:'ep-ev', d:`M 260,286 C 258,300 262,300 260,314`, col:C.ev, on:evOn, rev:false }] : []),
|
||
];
|
||
|
||
const defs = SEG.map(s => `<path id="${s.id}" d="${s.d}" fill="none"/>`).join('');
|
||
const lines = SEG.map(s =>
|
||
`<use href="#${s.id}" fill="none" stroke="${s.on?s.col:C.brd}" stroke-width="${s.on?2.5:1.5}" stroke-linecap="round" ${s.on?'':'opacity=".3"'}/>`
|
||
).join('');
|
||
const dotsSvg = SEG.map(s => flowDots(s.id, s.col, s.on, s.rev)).join('');
|
||
|
||
const gridCol = impOn ? C.imp : expOn ? C.exp : C.dim;
|
||
const gridLbl = impOn ? 'NETZBEZUG' : expOn ? 'EINSPEISUNG' : 'NETZ';
|
||
const gridVal = impOn ? gridImport : gridExport;
|
||
const batVal = chOn ? batChW : dchOn ? batDchW : 0;
|
||
const batLbl = chOn ? 'LADEN' : dchOn ? 'ENTLADEN' : 'BATTERIE';
|
||
const batSub = batSoc != null ? Math.round(batSoc) + '%' : '';
|
||
|
||
const svgH = hasEV ? 468 : 310;
|
||
const svg = `<svg viewBox="0 0 520 ${svgH}" width="100%" style="display:block" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>${defs}</defs>
|
||
${lines}
|
||
${dotsSvg}
|
||
${node(260, 72, 'solar', 'SOLAR', pvW, C.pv, pvOn, '')}
|
||
${node(108, 224, 'grid', gridLbl, gridVal, gridCol, impOn||expOn, '')}
|
||
${node(260, 224, 'house', 'HAUS', houseW, C.txt, true, '')}
|
||
${node(412, 224, 'bat', batLbl, batVal, C.bat, chOn||dchOn, batSub)}
|
||
${hasEV ? node(260, 376, 'ev', 'WALLBOX', evW, C.ev, evOn, '') : ''}
|
||
</svg>`;
|
||
|
||
period = period || {};
|
||
const mon = period.monthly || {};
|
||
const yr = period.yearly || {};
|
||
const pi = period.price_import || 0.30;
|
||
const pe = period.price_export || 0.08;
|
||
|
||
function fEur(v) {
|
||
if (v == null) return null;
|
||
return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €';
|
||
}
|
||
function fKwh(v) {
|
||
if (v == null) return null;
|
||
return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + ' kWh';
|
||
}
|
||
|
||
function periodCard(label, kwh, cost, col, sub) {
|
||
if (kwh == null) return '';
|
||
return `<div class="kwh-card" style="border-top:3px solid ${col}">
|
||
<div class="kv" style="color:${col}">${fKwh(kwh)}</div>
|
||
${cost != null ? `<div style="font-size:11px;font-weight:600;color:${col};opacity:.8;margin:2px 0">${fEur(cost)}</div>` : ''}
|
||
<div class="kl">${label}${sub?'<br><span style="opacity:.6">'+sub+'</span>':''}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function sectionCards(title, data) {
|
||
const impSub = data.spot_avg_ct != null
|
||
? `Ø ${data.spot_avg_ct.toFixed(1)} + ${(data.spot_markup_ct||0).toFixed(1)} ct/kWh`
|
||
: '';
|
||
const imp = periodCard('Netzbezug', data.grid_import_kwh, data.import_cost, C.imp, impSub);
|
||
const exp = periodCard('Einspeisung', data.grid_export_kwh, data.export_revenue, C.exp, '');
|
||
const sav = data.savings_kwh != null
|
||
? `<div class="kwh-card" style="border-top:3px solid ${C.bat}">
|
||
<div class="kv" style="color:${C.bat}">${fKwh(data.savings_kwh)}</div>
|
||
${data.savings_eur != null ? `<div style="font-size:11px;font-weight:600;color:${C.bat};opacity:.8;margin:2px 0">${fEur(data.savings_eur)} gespart</div>` : ''}
|
||
<div class="kl">Eigenversorgung<br><span style="opacity:.6">Batterie → Haus & Auto</span></div>
|
||
</div>`
|
||
: '';
|
||
if (!imp && !exp && !sav) return '';
|
||
return `<div style="margin-bottom:8px;font-size:10px;font-weight:700;letter-spacing:.08em;color:${C.dim};text-transform:uppercase">${title}</div>
|
||
<div class="energy-kwh" style="margin-bottom:16px">${imp}${exp}${sav}</div>`;
|
||
}
|
||
|
||
const cards = sectionCards(mon.label || 'Diesen Monat', mon) +
|
||
sectionCards(yr.label || 'Dieses Jahr', yr);
|
||
|
||
const spotHtml = (period.spot_chart !== false) ? renderSpotChart(spotData) : '';
|
||
|
||
el.innerHTML = `<div class="energy-wrap">
|
||
<div class="energy-svg-wrap">${svg}${spotHtml}</div>
|
||
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
// ── Live Data ─────────────────────────────────────────────────
|
||
|
||
async function refreshData() {
|
||
try {
|
||
const d = await fetchJSON(api("api/data"));
|
||
liveData = d;
|
||
document.getElementById("pill-mqtt").className = `pill ${d.mqtt_ok ? "ok" : "err"}`;
|
||
const keys = Object.keys(d.inverters || {});
|
||
document.getElementById("subtitle").textContent =
|
||
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
|
||
renderLive(d.inverters || {}, d.aggregates || {});
|
||
const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
|
||
const spot = await fetchJSON(api("api/spot-price")).catch(() => ({}));
|
||
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || []));
|
||
} catch(e) {
|
||
document.getElementById("pill-mqtt").className = "pill err";
|
||
}
|
||
}
|
||
|
||
function renderAggregates(aggregates) {
|
||
if (!aggregates || !Object.keys(aggregates).length) return '';
|
||
const cards = Object.entries(AGG_META).map(([id, meta]) => {
|
||
const val = aggregates[id];
|
||
if (val === undefined) return '';
|
||
const dcClass = meta.device_class ? `dc-${meta.device_class}` : '';
|
||
return `<div class="sensor-card ${dcClass}">
|
||
<div class="sensor-icon">${ICON_MAP[meta.icon]||"📊"}</div>
|
||
<div class="sensor-name">${esc(meta.name)}</div>
|
||
<div class="sensor-value">${fmtVal(val)}<span class="sensor-unit">${esc(meta.unit)}</span></div>
|
||
</div>`;
|
||
}).filter(Boolean).join('');
|
||
if (!cards) return '';
|
||
return `<div class="inv-section">
|
||
<div class="inv-header">
|
||
<div class="inv-title">Gesamt</div>
|
||
<div class="inv-badge ok">aggregiert</div>
|
||
</div>
|
||
<div class="sensor-grid">${cards}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderLive(inverters, aggregates) {
|
||
const el = document.getElementById("live-content");
|
||
if (!Object.keys(inverters).length) {
|
||
el.innerHTML = '<div class="no-data">Keine Geräte konfiguriert.<br>Bitte im Tab „Geräte" hinzufügen.</div>';
|
||
return;
|
||
}
|
||
const aggHtml = renderAggregates(aggregates);
|
||
el.innerHTML = aggHtml + Object.values(inverters).map(inv => {
|
||
const ago = inv.last_update ? Math.round(Date.now()/1000 - inv.last_update) + "s" : "—";
|
||
const cards = (inv.sensors || []).map(s => {
|
||
const val = inv.values[s.id];
|
||
const display = val !== undefined ? fmtVal(val) : "—";
|
||
const dcClass = s.device_class ? `dc-${s.device_class}` : "";
|
||
const hist = (inv.history || {})[s.id] || [];
|
||
return `<div class="sensor-card ${dcClass}">
|
||
<div class="sensor-icon">${ICON_MAP[s.icon]||"📊"}</div>
|
||
<div class="sensor-name">${esc(s.name)}</div>
|
||
<div class="sensor-value">${display}<span class="sensor-unit">${esc(s.unit)}</span></div>
|
||
${sparkline(hist, s.device_class)}
|
||
</div>`;
|
||
}).join("");
|
||
return `<div class="inv-section">
|
||
<div class="inv-header">
|
||
<div class="inv-title">${esc(inv.name)}</div>
|
||
<div class="inv-badge ${inv.modbus_ok ? "ok" : "err"}">${inv.modbus_ok ? "online" : "offline"}</div>
|
||
<div class="info-chip" style="margin-left:auto;font-size:11px">⏱ ${ago} vor · ${inv.poll_count} Messungen</div>
|
||
</div>
|
||
<div class="sensor-grid">${cards || '<div class="no-data">Warte...</div>'}</div>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
function fmtVal(v) {
|
||
if (v >= 1000) return (v/1000).toFixed(2).replace(".",",") + "k";
|
||
if (v % 1 === 0) return v.toString();
|
||
return v.toFixed(v < 10 ? 2 : 1).replace(".",",");
|
||
}
|
||
|
||
function startRefresh() { stopRefresh(); refreshData(); refreshTimer = setInterval(refreshData, 5000); }
|
||
function stopRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
|
||
|
||
// ── Inverter Management ───────────────────────────────────────
|
||
|
||
async function loadInverters() {
|
||
invertersList = await fetchJSON(api("api/inverters-config"));
|
||
renderInverterList();
|
||
}
|
||
|
||
async function loadModels() {
|
||
modelsList = await fetchJSON(api("api/inverter-models"));
|
||
const sel = document.getElementById("modal-model");
|
||
sel.innerHTML = Object.values(modelsList).map(m =>
|
||
`<option value="${esc(m.id)}">${esc(m.name)} (${m.sensor_count} Sensoren)</option>`
|
||
).join("");
|
||
}
|
||
|
||
function renderInverterList() {
|
||
const el = document.getElementById("inv-list");
|
||
const cards = invertersList.map(inv => {
|
||
const model = modelsList[inv.inverter_model] || {};
|
||
return `<div class="inv-card">
|
||
<div class="inv-card-header">
|
||
<div class="inv-card-icon">☀️</div>
|
||
<div class="inv-card-info">
|
||
<div class="inv-card-name">${esc(inv.name || "Gerät")}</div>
|
||
<div class="inv-card-model">${esc(model.name || inv.inverter_model)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="inv-card-meta">
|
||
📡 ${esc(inv.modbus_ip)}:${inv.modbus_port || 502} · Slave ${inv.modbus_address || 1}<br>
|
||
📨 ${esc(inv.mqtt_topic_prefix)}<br>
|
||
⏱ alle ${inv.update_interval || 30}s
|
||
</div>
|
||
<div class="inv-card-actions">
|
||
<button class="btn" onclick="editInverter('${inv.id}')">Bearbeiten</button>
|
||
<button class="btn btn-danger" onclick="deleteInverter('${inv.id}')">Löschen</button>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
el.innerHTML = cards + `<div class="add-btn" onclick="openModal()">+ Gerät hinzufügen</div>`;
|
||
}
|
||
|
||
function toggleEmsSection(model) {
|
||
document.getElementById("ems-section").style.display =
|
||
model === "KATHREIN_WALLBOX" ? "" : "none";
|
||
}
|
||
|
||
async function openModal(invId) {
|
||
await loadModels();
|
||
const modal = document.getElementById("modal-backdrop");
|
||
const modelSel = document.getElementById("modal-model");
|
||
modelSel.onchange = () => toggleEmsSection(modelSel.value);
|
||
if (invId) {
|
||
const inv = invertersList.find(i => i.id === invId) || {};
|
||
document.getElementById("modal-title").textContent = "Gerät bearbeiten";
|
||
document.getElementById("modal-id").value = inv.id || "";
|
||
document.getElementById("modal-name").value = inv.name || "";
|
||
modelSel.value = inv.inverter_model || "MIC_1500_TL_X";
|
||
document.getElementById("modal-ip").value = inv.modbus_ip || "";
|
||
document.getElementById("modal-port").value = inv.modbus_port || 502;
|
||
document.getElementById("modal-addr").value = inv.modbus_address || 1;
|
||
document.getElementById("modal-prefix").value = inv.mqtt_topic_prefix || "";
|
||
document.getElementById("modal-interval").value = inv.update_interval || 30;
|
||
document.getElementById("ems-min-pv").value = inv.ems_min_pv ?? 1400;
|
||
document.getElementById("ems-timeout").value = inv.ems_timeout ?? 4;
|
||
document.getElementById("ems-target-hour").value = inv.ems_target_hour ?? 6;
|
||
document.getElementById("ems-phases").value = inv.ems_phases ?? 3;
|
||
document.getElementById("ems-enabled").checked = inv.ems_enabled ?? true;
|
||
} else {
|
||
document.getElementById("modal-title").textContent = "Gerät hinzufügen";
|
||
document.getElementById("modal-id").value = "";
|
||
document.getElementById("modal-name").value = "";
|
||
document.getElementById("modal-ip").value = "";
|
||
document.getElementById("modal-port").value = "502";
|
||
document.getElementById("modal-addr").value = "1";
|
||
document.getElementById("modal-prefix").value = "growatt/wechselrichter" + (invertersList.length + 1);
|
||
document.getElementById("modal-interval").value = "30";
|
||
document.getElementById("ems-min-pv").value = "1400";
|
||
document.getElementById("ems-timeout").value = "4";
|
||
document.getElementById("ems-target-hour").value = "6";
|
||
document.getElementById("ems-phases").value = "3";
|
||
}
|
||
toggleEmsSection(modelSel.value);
|
||
modal.classList.add("open");
|
||
}
|
||
|
||
function editInverter(id) { openModal(id); }
|
||
|
||
function closeModal(e) {
|
||
if (!e || e.target === document.getElementById("modal-backdrop"))
|
||
document.getElementById("modal-backdrop").classList.remove("open");
|
||
}
|
||
|
||
async function saveInverter() {
|
||
let id = document.getElementById("modal-id").value;
|
||
if (!id) {
|
||
const r = await fetchJSON(api("api/new-id"), {method: "POST"});
|
||
id = r.id;
|
||
}
|
||
const model = document.getElementById("modal-model").value;
|
||
const inv = {
|
||
id,
|
||
name: document.getElementById("modal-name").value.trim() || "Wechselrichter",
|
||
inverter_model: model,
|
||
modbus_ip: document.getElementById("modal-ip").value.trim(),
|
||
modbus_port: parseInt(document.getElementById("modal-port").value),
|
||
modbus_address: parseInt(document.getElementById("modal-addr").value),
|
||
mqtt_topic_prefix: document.getElementById("modal-prefix").value.trim(),
|
||
update_interval: parseInt(document.getElementById("modal-interval").value),
|
||
};
|
||
if (model === "KATHREIN_WALLBOX") {
|
||
inv.ems_min_pv = parseInt(document.getElementById("ems-min-pv").value);
|
||
inv.ems_timeout = parseFloat(document.getElementById("ems-timeout").value);
|
||
inv.ems_target_hour = parseInt(document.getElementById("ems-target-hour").value);
|
||
inv.ems_phases = parseInt(document.getElementById("ems-phases").value);
|
||
inv.ems_enabled = document.getElementById("ems-enabled").checked;
|
||
}
|
||
const idx = invertersList.findIndex(i => i.id === id);
|
||
if (idx >= 0) invertersList[idx] = inv; else invertersList.push(inv);
|
||
try {
|
||
await fetchJSON(api("api/inverters-config"), {
|
||
method: "POST", headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify(invertersList),
|
||
});
|
||
closeModal();
|
||
renderInverterList();
|
||
showToast("Gespeichert!", "ok");
|
||
} catch(e) {
|
||
showToast("Fehler beim Speichern", "err");
|
||
}
|
||
}
|
||
|
||
async function deleteInverter(id) {
|
||
if (!confirm("Gerät wirklich löschen?")) return;
|
||
invertersList = invertersList.filter(i => i.id !== id);
|
||
try {
|
||
await fetchJSON(api("api/inverters-config"), {
|
||
method: "POST", headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify(invertersList),
|
||
});
|
||
renderInverterList();
|
||
showToast("Gelöscht", "ok");
|
||
} catch(e) {
|
||
showToast("Fehler", "err");
|
||
}
|
||
}
|
||
|
||
// ── MQTT Settings ─────────────────────────────────────────────
|
||
|
||
function updateTariffUI() {
|
||
const isSpot = document.getElementById("tariff-spot").checked;
|
||
document.getElementById("tariff-fixed-fields").style.display = isSpot ? "none" : "";
|
||
document.getElementById("tariff-spot-fields").style.display = isSpot ? "" : "none";
|
||
}
|
||
|
||
async function loadSettings() {
|
||
const cfg = await fetchJSON(api("api/config"));
|
||
globalConfig = cfg;
|
||
document.getElementById("cfg-mqtt-broker").value = cfg.mqtt_broker || "";
|
||
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883;
|
||
document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || "";
|
||
document.getElementById("cfg-price-import").value = cfg.price_import ?? 0.30;
|
||
document.getElementById("cfg-price-export").value = cfg.price_export ?? 0.08;
|
||
document.getElementById("cfg-billing-day").value = cfg.billing_day ?? 1;
|
||
document.getElementById("cfg-billing-month").value = cfg.billing_month ?? 1;
|
||
document.getElementById("cfg-spot-markup").value = cfg.spot_markup ?? 0.0;
|
||
document.getElementById("cfg-spot-country").value = cfg.spot_country ?? "de";
|
||
document.getElementById("cfg-spot-chart").checked = cfg.spot_chart ?? true;
|
||
document.getElementById((cfg.tariff_type ?? "fixed") === "spot" ? "tariff-spot" : "tariff-fixed").checked = true;
|
||
updateTariffUI();
|
||
}
|
||
|
||
async function savePrices() {
|
||
const isSpot = document.getElementById("tariff-spot").checked;
|
||
const body = {
|
||
tariff_type: isSpot ? "spot" : "fixed",
|
||
price_import: parseFloat(document.getElementById("cfg-price-import").value || 0.30),
|
||
price_export: parseFloat(document.getElementById("cfg-price-export").value || 0.08),
|
||
spot_markup: parseFloat(document.getElementById("cfg-spot-markup").value || 0),
|
||
spot_country: document.getElementById("cfg-spot-country").value,
|
||
spot_chart: document.getElementById("cfg-spot-chart").checked,
|
||
billing_day: parseInt(document.getElementById("cfg-billing-day").value),
|
||
billing_month: parseInt(document.getElementById("cfg-billing-month").value),
|
||
};
|
||
try {
|
||
await fetchJSON(api("api/config"), {
|
||
method: "POST", headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify(body),
|
||
});
|
||
showToast("Einstellungen gespeichert", "ok");
|
||
} catch(e) {
|
||
showToast("Fehler beim Speichern", "err");
|
||
}
|
||
}
|
||
|
||
async function saveMqtt() {
|
||
const body = {
|
||
mqtt_broker: document.getElementById("cfg-mqtt-broker").value.trim(),
|
||
mqtt_port: parseInt(document.getElementById("cfg-mqtt-port").value),
|
||
mqtt_user: document.getElementById("cfg-mqtt-user").value,
|
||
mqtt_pass: document.getElementById("cfg-mqtt-pass").value,
|
||
};
|
||
try {
|
||
await fetchJSON(api("api/config"), {
|
||
method: "POST", headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify(body),
|
||
});
|
||
showToast("Gespeichert! Neustart...", "ok");
|
||
} catch(e) {
|
||
showToast("Fehler beim Speichern", "err");
|
||
}
|
||
}
|
||
|
||
// ── Export / Import ───────────────────────────────────────────
|
||
|
||
function exportConfig() {
|
||
window.location.href = api("api/export-config");
|
||
}
|
||
|
||
async function importConfig(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
const resultEl = document.getElementById("import-result");
|
||
resultEl.textContent = "Wird geladen…";
|
||
resultEl.style.color = "var(--text-dim)";
|
||
try {
|
||
const text = await file.text();
|
||
const data = JSON.parse(text);
|
||
const res = await fetchJSON(api("api/import-config"), {
|
||
method: "POST", headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify(data),
|
||
});
|
||
resultEl.textContent = `✓ ${res.inverters} Gerät(e) importiert. Neustart läuft…`;
|
||
resultEl.style.color = "var(--green)";
|
||
showToast(`${res.inverters} Gerät(e) importiert`, "ok");
|
||
setTimeout(() => { loadSettings(); loadInverters(); }, 3000);
|
||
} catch(e) {
|
||
resultEl.textContent = `Fehler: ${e.message}`;
|
||
resultEl.style.color = "var(--red)";
|
||
showToast("Import fehlgeschlagen", "err");
|
||
}
|
||
input.value = "";
|
||
}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────
|
||
|
||
(async () => {
|
||
await Promise.all([loadSettings(), loadInverters(), loadModels()]);
|
||
startRefresh();
|
||
})();
|
||
|
||
window.addEventListener("beforeunload", stopRefresh);
|
||
</script>
|
||
</body>
|
||
</html>
|