v1.8.8: Überschuss-Geräte (Zigbee2MQTT) + Bugfix Eigenversorgungskarte

- Neu: SurplusDeviceController — schaltet Z2M-Geräte bei PV-Überschuss
  ein/aus (Schwellwert + Hysterese pro Gerät, Background-Loop 30s)
- Neu: API GET/POST /api/surplus-devices, Konfig persistent in config.json
- Neu: Settings-Tab "Überschuss-Geräte", Live-Tab zeigt ON/OFF-Status
- Bugfix: Eigenversorgungskarte (Monat/Jahr) bleibt abends sichtbar wenn
  Wechselrichter offline — letzte kWh-Zähler werden als Fallback genutzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-05-04 10:53:46 +02:00
parent ec6a0e8514
commit 83035fed0e
5 changed files with 246 additions and 9 deletions
+116 -4
View File
@@ -101,6 +101,8 @@
font-size: 14px; transition: border-color .15s, color .15s; }
.add-btn:hover { border-color: var(--accent); color: var(--accent); }
.surplus-device-row { background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius); padding:10px 12px; }
.surplus-device-row input[type=text], .surplus-device-row input[type=number] { background:var(--surface); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:5px 8px; font-size:13px; }
/* Settings */
.settings-section { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; max-width: 500px; }
@@ -255,6 +257,18 @@
<button class="btn btn-primary" onclick="savePrices()">Speichern</button>
</div>
<div class="settings-section">
<h3>Überschuss-Geräte (Zigbee2MQTT)</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Geräte automatisch einschalten wenn PV-Überschuss ins Netz eingespeist wird.</p>
<div class="field" style="max-width:280px"><label>Z2M Basis-Topic</label>
<input type="text" id="cfg-z2m-base" placeholder="zigbee2mqtt"></div>
<div id="surplus-device-list" style="margin-bottom:10px;display:flex;flex-direction:column;gap:8px"></div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<button class="btn btn-secondary" onclick="addSurplusDevice()">+ Gerät hinzufügen</button>
<button class="btn btn-primary" onclick="saveSurplusDevices()">Speichern</button>
</div>
</div>
<div class="settings-section">
<h3>Konfiguration sichern</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Alle Geräte und MQTT-Einstellungen als JSON exportieren und bei einer Neuinstallation wieder einlesen.</p>
@@ -643,13 +657,16 @@ function renderEnergy(inverters, aggregates, period, spotData) {
async function refreshData() {
try {
const d = await fetchJSON(api("api/data"));
const [d, surplusData] = await Promise.all([
fetchJSON(api("api/data")),
fetchJSON(api("api/surplus-devices")).catch(() => ({})),
]);
liveData = d;
document.getElementById("pill-mqtt").className = `pill ${d.mqtt_ok ? "ok" : "err"}`;
const keys = Object.keys(d.inverters || {});
document.getElementById("subtitle").textContent =
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
renderLive(d.inverters || {}, d.aggregates || {});
renderLive(d.inverters || {}, d.aggregates || {}, surplusData);
const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
const spot = await fetchJSON(api("api/spot-price")).catch(() => ({}));
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || []));
@@ -680,14 +697,15 @@ function renderAggregates(aggregates) {
</div>`;
}
function renderLive(inverters, aggregates) {
function renderLive(inverters, aggregates, surplusData) {
const el = document.getElementById("live-content");
if (!Object.keys(inverters).length) {
el.innerHTML = '<div class="no-data">Keine Geräte konfiguriert.<br>Bitte im Tab „Geräte" hinzufügen.</div>';
return;
}
const aggHtml = renderAggregates(aggregates);
el.innerHTML = aggHtml + Object.values(inverters).map(inv => {
const surplusHtml = renderSurplusStatus(surplusData);
el.innerHTML = aggHtml + surplusHtml + Object.values(inverters).map(inv => {
const ago = inv.last_update ? Math.round(Date.now()/1000 - inv.last_update) + "s" : "—";
const cards = (inv.sensors || []).map(s => {
const val = inv.values[s.id];
@@ -878,6 +896,7 @@ function updateTariffUI() {
async function loadSettings() {
const cfg = await fetchJSON(api("api/config"));
globalConfig = cfg;
loadSurplusDevices();
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 || "";
@@ -964,6 +983,99 @@ async function importConfig(input) {
input.value = "";
}
// ── Surplus Devices ───────────────────────────────────────────
let surplusDevices = [];
function renderSurplusDeviceRow(dev) {
const id = dev.id || ('sd_' + Math.random().toString(36).slice(2));
return `<div class="surplus-device-row" data-id="${esc(id)}">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input type="text" placeholder="Name" style="flex:1;min-width:110px" value="${esc(dev.name||'')}" data-field="name">
<input type="text" placeholder="Z2M Friendly Name" style="flex:1;min-width:140px" value="${esc(dev.z2m_name||'')}" data-field="z2m_name">
<input type="number" placeholder="Ab (W)" title="Schwellwert" style="width:76px" value="${dev.threshold_w||500}" min="0" step="50" data-field="threshold_w">
<input type="number" placeholder="Hysterese (W)" title="Hysterese" style="width:90px" value="${dev.hysteresis_w||150}" min="0" step="50" data-field="hysteresis_w">
<label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap">
<input type="checkbox" ${dev.enabled!==false?'checked':''} data-field="enabled" style="width:auto;margin:0"> Aktiv
</label>
<button class="btn btn-secondary" style="padding:3px 8px;font-size:12px;flex-shrink:0" onclick="removeSurplusDevice(this)">✕</button>
</div>
</div>`;
}
async function loadSurplusDevices() {
const d = await fetchJSON(api("api/surplus-devices")).catch(() => ({}));
surplusDevices = d.devices || [];
document.getElementById("cfg-z2m-base").value = d.z2m_base || "zigbee2mqtt";
const list = document.getElementById("surplus-device-list");
list.innerHTML = surplusDevices.map(renderSurplusDeviceRow).join('');
}
function addSurplusDevice() {
const list = document.getElementById("surplus-device-list");
const id = 'sd_' + Math.random().toString(36).slice(2);
const div = document.createElement('div');
div.innerHTML = renderSurplusDeviceRow({id, name:'', z2m_name:'', threshold_w:500, hysteresis_w:150, enabled:true});
list.appendChild(div.firstElementChild);
}
function removeSurplusDevice(btn) {
btn.closest('.surplus-device-row').remove();
}
function _collectSurplusDevices() {
return Array.from(document.querySelectorAll('.surplus-device-row')).map(row => ({
id: row.dataset.id,
name: row.querySelector('[data-field=name]').value.trim(),
z2m_name: row.querySelector('[data-field=z2m_name]').value.trim(),
threshold_w: parseFloat(row.querySelector('[data-field=threshold_w]').value) || 0,
hysteresis_w: parseFloat(row.querySelector('[data-field=hysteresis_w]').value) || 0,
enabled: row.querySelector('[data-field=enabled]').checked,
}));
}
async function saveSurplusDevices() {
const devices = _collectSurplusDevices();
const z2m_base = document.getElementById("cfg-z2m-base").value.trim() || "zigbee2mqtt";
for (const d of devices) {
if (!d.z2m_name) { showToast("Z2M Name darf nicht leer sein", "err"); return; }
}
try {
await fetchJSON(api("api/surplus-devices"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({devices, z2m_base}),
});
showToast("Überschuss-Geräte gespeichert", "ok");
} catch(e) {
showToast("Fehler beim Speichern", "err");
}
}
function renderSurplusStatus(surplusData) {
if (!surplusData || !surplusData.devices || !surplusData.devices.length) return '';
const states = surplusData.states || {};
const rows = surplusData.devices
.filter(d => d.enabled !== false)
.map(d => {
const on = states[d.z2m_name] === true;
const dot = `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:${on?'var(--green)':'var(--border)'}"></span>`;
return `<div style="display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 0">
${dot}
<span>${esc(d.name||d.z2m_name)}</span>
<span style="color:var(--text-dim);font-size:11px">ab ${d.threshold_w}W</span>
<span style="margin-left:auto;font-weight:600;color:${on?'var(--green)':'var(--text-dim)'}">${on?'EIN':'AUS'}</span>
</div>`;
}).join('');
if (!rows) return '';
return `<div class="inv-section">
<div class="inv-header">
<div class="inv-title">Überschuss-Geräte</div>
<div class="inv-badge ok">Z2M</div>
</div>
<div style="padding:0 2px">${rows}</div>
</div>`;
}
// ── Init ──────────────────────────────────────────────────────
(async () => {