5972ef2c35
- hasEV-Flag aus invertersList: nur KATHREIN_WALLBOX zeigt EV-Node - Ohne Wallbox: flacheres Layout (viewBox 520x278 statt 410) - SEG-Array und SVG-Node konditionell Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
803 lines
37 KiB
HTML
803 lines
37 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: 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()">⇓ 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="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,'&').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 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 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 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 (optional)
|
||
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 },
|
||
...(hasEV ? [{ 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) + '%' : '';
|
||
|
||
const svgH = hasEV ? 410 : 278;
|
||
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, 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)}
|
||
${hasEV ? 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>
|