Files
Shinebridge/haos-addon/src/web/index.html
T

802 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ShineBridge</title>
<style>
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
--border: #30363d; --text: #e6edf3; --text-dim: #8b949e;
--accent: #f0c040; --green: #3fb950; --red: #f85149;
--blue: #58a6ff; --orange: #ffa657; --purple: #bc8cff;
--radius: 10px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px; min-height: 100vh; }
header { display: flex; align-items: center; gap: 12px;
padding: 16px 20px; background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 100; }
header h1 { font-size: 16px; font-weight: 600; }
header .subtitle { font-size: 12px; color: var(--text-dim); }
.status-pill { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.pill { display: flex; align-items: center; gap: 5px; padding: 4px 10px;
border-radius: 20px; font-size: 12px; font-weight: 600;
background: var(--surface2); border: 1px solid var(--border); }
.pill.ok { color: var(--green); border-color: var(--green); }
.pill.err { color: var(--red); border-color: var(--red); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor;
animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
main { padding: 20px; max-width: 1100px; margin: 0 auto; }
.tabs { display: flex; gap: 4px; margin-bottom: 20px;
border-bottom: 1px solid var(--border); }
.tab { padding: 10px 16px; cursor: pointer; color: var(--text-dim);
font-weight: 500; border-bottom: 2px solid transparent;
margin-bottom: -1px; transition: color .15s, border-color .15s; }
.tab.active { color: var(--accent); border-color: var(--accent); }
.tab:hover { color: var(--text); }
.panel { display: none; }
.panel.active { display: block; }
/* Sensor Grid */
.inv-section { margin-bottom: 28px; }
.inv-header { display: flex; align-items: center; gap: 10px;
margin-bottom: 12px; }
.inv-title { font-size: 15px; font-weight: 600; }
.inv-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px;
background: var(--surface2); border: 1px solid var(--border);
color: var(--text-dim); }
.inv-badge.ok { color: var(--green); border-color: var(--green); }
.inv-badge.err { color: var(--red); border-color: var(--red); }
.sensor-grid { display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; }
.sensor-card { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; }
.sensor-icon { font-size: 18px; margin-bottom: 6px; }
.sensor-name { font-size: 11px; color: var(--text-dim);
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
.sensor-value { font-size: 20px; font-weight: 700;
font-variant-numeric: tabular-nums; }
.sensor-unit { font-size: 11px; color: var(--text-dim); margin-left: 2px; }
.sparkline { margin-top: 8px; opacity: .7; }
.dc-power .sensor-value { color: var(--accent); }
.dc-voltage .sensor-value { color: var(--blue); }
.dc-current .sensor-value { color: var(--orange); }
.dc-energy .sensor-value { color: var(--green); }
.dc-temperature .sensor-value { color: var(--red); }
.dc-battery .sensor-value { color: var(--purple); }
.no-data { text-align: center; padding: 40px 20px; color: var(--text-dim); font-size: 13px; }
/* Inverter management */
.inv-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.inv-card { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 18px; }
.inv-card-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
.inv-card-icon { font-size: 28px; }
.inv-card-info { flex: 1; }
.inv-card-name { font-weight: 600; font-size: 15px; margin-bottom: 2px; }
.inv-card-model { font-size: 12px; color: var(--text-dim); }
.inv-card-actions { display: flex; gap: 6px; margin-top: 14px; }
.btn { padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface2); color: var(--text); cursor: pointer;
font-size: 12px; font-weight: 500; transition: border-color .15s; }
.btn:hover { border-color: var(--text-dim); }
.btn-danger { color: var(--red); border-color: var(--red); }
.btn-danger:hover { background: rgba(248,81,73,.1); }
.btn-primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 700; }
.btn-primary:hover { opacity: .85; }
.btn-secondary { color: var(--blue); border-color: var(--blue); }
.btn-secondary:hover { background: rgba(88,166,255,.1); }
.inv-card-meta { font-size: 12px; color: var(--text-dim); line-height: 1.7; }
.add-btn { display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 20px; background: var(--surface); border: 2px dashed var(--border);
border-radius: var(--radius); cursor: pointer; color: var(--text-dim);
font-size: 14px; transition: border-color .15s, color .15s; }
.add-btn:hover { border-color: var(--accent); color: var(--accent); }
/* Settings */
.settings-section { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; max-width: 500px; }
.settings-section h3 { font-size: 13px; font-weight: 600; color: var(--text-dim);
text-transform: uppercase; letter-spacing: .06em;
margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 5px; }
.field input, .field select { width: 100%; padding: 8px 10px;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 13px; outline: none;
transition: border-color .15s; }
.field input:focus, .field select:focus { border-color: var(--accent); }
.field select option { background: var(--surface2); }
/* 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>Konfiguration sichern</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Alle Geräte und MQTT-Einstellungen als JSON exportieren und bei einer Neuinstallation wieder einlesen.</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<button class="btn btn-secondary" onclick="exportConfig()">&#8659; Exportieren</button>
<label class="btn btn-secondary" style="cursor:pointer">
&#8657; Importieren
<input type="file" id="import-file-input" accept=".json" style="display:none" onchange="importConfig(this)">
</label>
</div>
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div>
</div>
</div>
</main>
<!-- Inverter Edit Modal -->
<div class="modal-backdrop" id="modal-backdrop" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<h2 id="modal-title">Gerät hinzufügen</h2>
<input type="hidden" id="modal-id">
<div class="field"><label>Name</label>
<input type="text" id="modal-name" placeholder="z.B. Dach Süd"></div>
<div class="field"><label>Gerätetyp</label>
<select id="modal-model"></select></div>
<div class="field"><label>IP-Adresse</label>
<input type="text" id="modal-ip" placeholder="10.10.20.190"></div>
<div class="field"><label>Modbus Port</label>
<input type="number" id="modal-port" value="502"></div>
<div class="field"><label>Modbus Slave-Adresse</label>
<input type="number" id="modal-addr" value="1" min="1" max="247"></div>
<div class="field"><label>MQTT Topic-Präfix</label>
<input type="text" id="modal-prefix" placeholder="growatt/wechselrichter1"></div>
<div class="field"><label>Abfrageintervall (Sekunden)</label>
<input type="number" id="modal-interval" value="30" min="5"></div>
<div id="ems-section" style="display:none; border-top:1px solid var(--border); margin-top:14px; padding-top:14px;">
<div style="font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--accent); margin-bottom:12px;">EMS — Ladesteuerung</div>
<div class="field"><label>Mindest-PV-Überschuss (W)</label>
<input type="number" id="ems-min-pv" value="1400" min="100" step="100">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Mindestleistung für PV-Laden (min. 6A × 230V = 1380W)</div></div>
<div class="field"><label>Timeout ohne PV (Stunden)</label>
<input type="number" id="ems-timeout" value="4" min="0.5" max="24" step="0.5">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Nach dieser Zeit ohne Überschuss startet Zwangsladen</div></div>
<div class="field"><label>Vollladung bis (Uhr)</label>
<input type="number" id="ems-target-hour" value="6" min="0" max="23">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Zielzeit für vollgeladenes Auto (z.B. 6 = 06:00 Uhr)</div></div>
<div class="field"><label>Anzahl Phasen</label>
<select id="ems-phases">
<option value="1">1-phasig</option>
<option value="2">2-phasig</option>
<option value="3" selected>3-phasig</option>
</select></div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="saveInverter()">Speichern</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const AGG_META = {
total_pv_power: {name:"PV Gesamtleistung", unit:"W", device_class:"power", icon:"mdi:solar-power"},
total_ac_power: {name:"AC Gesamtleistung", unit:"W", device_class:"power", icon:"mdi:flash"},
total_energy_today: {name:"Energie Heute Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:solar-power"},
total_energy_total: {name:"Energie Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:solar-power"},
grid_power: {name:"Netzleistung", unit:"W", device_class:"power", icon:"mdi:transmission-tower"},
grid_import_kwh: {name:"Netzbezug Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:transmission-tower-import"},
grid_export_kwh: {name:"Einspeisung Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:transmission-tower-export"},
bat_charge_power: {name:"Batterie Ladeleistung Ges.", unit:"W", device_class:"power", icon:"mdi:battery-plus"},
bat_discharge_power: {name:"Batterie Entladeleist. Ges.", unit:"W", device_class:"power", icon:"mdi:battery-minus"},
bat_charge_total: {name:"Batterie Ladung Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:battery-plus"},
bat_discharge_total: {name:"Batterie Entladung Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:battery-minus"},
bat_soc: {name:"Batterie Ladezustand Ø", unit:"%", device_class:"battery", icon:"mdi:battery"},
};
const DC_COLORS = {
power: '#f0c040', voltage: '#58a6ff', current: '#ffa657',
energy: '#3fb950', temperature: '#f85149', battery: '#bc8cff',
frequency: '#8b949e', default: '#8b949e',
};
function sparkline(values, dc) {
if (!values || values.length < 2) return '';
const color = DC_COLORS[dc] || DC_COLORS.default;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const W = 138, H = 28, pad = 1;
const pts = values.map((v, i) => {
const x = pad + (i / (values.length - 1)) * (W - pad * 2);
const y = pad + (1 - (v - min) / range) * (H - pad * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg class="sparkline" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<polyline points="${pts}" fill="none" stroke="${color}"
stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
</svg>`;
}
const ICON_MAP = {
"mdi:solar-panel":"☀️","mdi:flash":"⚡","mdi:sine-wave":"〜",
"mdi:solar-power":"🔆","mdi:thermometer":"🌡️","mdi:battery":"🔋",
"mdi:battery-minus":"🪫","mdi:battery-plus":"⚡",
"mdi:transmission-tower-export":"📤","mdi:transmission-tower-import":"📥",
};
const BASE = new URL("./", window.location.href).pathname;
const api = p => BASE + p;
let globalConfig = {};
let invertersList = [];
let modelsList = {};
let liveData = {};
let refreshTimer = null;
async function fetchJSON(url, opts) {
const r = await fetch(url, opts);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
function showToast(msg, type) {
const el = document.getElementById("toast");
el.textContent = msg;
el.className = `toast show ${type}`;
clearTimeout(el._t);
el._t = setTimeout(() => el.className = "toast", 3000);
}
function switchTab(name) {
["energy","live","inverters","settings"].forEach((t, i) => {
document.querySelectorAll(".tab")[i].classList.toggle("active", t === name);
document.querySelectorAll(".panel")[i].classList.toggle("active", t === name);
});
if (name === "energy" || name === "live") startRefresh(); else stopRefresh();
}
// ── Energy Dashboard ──────────────────────────────────────────
function renderEnergy(inverters, aggregates) {
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 T = 20;
const pvOn = pvW > T;
const impOn = gridImport > T;
const expOn = gridExport > T;
const chOn = batChW > T;
const dchOn = batDchW > T;
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 || 22;
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 = 44;
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 - 11, c, 22)}
<text x="${cx}" y="${cy+7}" text-anchor="middle" font-size="12" font-weight="700" fill="${active?col:C.txt}" dominant-baseline="middle">${fW(valW)}</text>
<text x="${cx}" y="${cy+21}" text-anchor="middle" font-size="9" 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('');
}
// Path segments (bezier from node-edge to node-edge)
// Solar(145,75) bottom→ House(260,180) top; Grid(375,75) bottom→ House top
// House bottom → Battery(145,292) top; House bottom → EV(375,292) top
// Kreuz-Layout: Solar oben, Grid links, Haus Mitte, Batterie rechts, Wallbox unten
const SEG = [
{ id:'ep-pv', d:`M 260,121 C 258,138 262,146 260,158`, col:C.pv, on:pvOn, rev:false },
{ id:'ep-grid', d:`M 140,200 C 162,197 192,203 214,200`, col:impOn?C.imp:C.exp, on:impOn||expOn, rev:expOn },
{ id:'ep-bat', d:`M 306,200 C 330,197 356,203 380,200`, col:C.bat, on:chOn||dchOn, rev:dchOn },
{ id:'ep-ev', d:`M 260,244 C 258,261 262,269 260,279`, 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) + '%' : '';
// ViewBox: Grid-left=51, Bat-right=469, Solar-top=33, EV-bottom=367 → 520×410
const svg = `<svg viewBox="0 0 520 410" width="100%" style="display:block" xmlns="http://www.w3.org/2000/svg">
<defs>${defs}</defs>
${lines}
${dotsSvg}
${node(260, 77, 'solar', 'SOLAR', pvW, C.pv, pvOn, '')}
${node( 95, 200, 'grid', gridLbl, gridVal, gridCol, impOn||expOn, '')}
${node(260, 200, 'house', 'HAUS', houseW, C.txt, true, '')}
${node(425, 200, 'bat', batLbl, batVal, C.bat, chOn||dchOn, batSub)}
${node(260, 323, 'ev', 'WALLBOX', evW, C.ev, evOn, '')}
</svg>`;
function kwhCard(label, val, col) {
if (val == null) return '';
const d = val >= 100 ? val.toFixed(0) : val.toFixed(val >= 10 ? 1 : 2);
return `<div class="kwh-card" style="border-top:3px solid ${col}">
<div class="kv" style="color:${col}">${d}<span style="font-size:10px;font-weight:400;color:${C.dim}"> kWh</span></div>
<div class="kl">${label}</div>
</div>`;
}
const cards = [
kwhCard('PV Heute', aggregates.total_energy_today, C.pv),
kwhCard('Netzbezug', aggregates.grid_import_kwh, C.imp),
kwhCard('Einspeisung', aggregates.grid_export_kwh, C.exp),
kwhCard('Bat. Laden', aggregates.bat_charge_total, C.bat),
kwhCard('Bat. Entladen', aggregates.bat_discharge_total, C.bat),
].filter(Boolean).join('');
el.innerHTML = `<div class="energy-wrap">
<div class="energy-svg-wrap">${svg}</div>
${cards ? `<div class="energy-kwh" style="margin-top:14px">${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 || {});
renderEnergy(d.inverters || {}, d.aggregates || {});
} 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;
} 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);
}
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 ─────────────────────────────────────────────
async function loadSettings() {
globalConfig = await fetchJSON(api("api/config"));
document.getElementById("cfg-mqtt-broker").value = globalConfig.mqtt_broker || "";
document.getElementById("cfg-mqtt-port").value = globalConfig.mqtt_port || 1883;
document.getElementById("cfg-mqtt-user").value = globalConfig.mqtt_user || "";
}
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>