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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user