/* ========================================================================== Speicher-Ansicht: die Batterie bis zur einzelnen Zelle. const s = new Speicher(behaelter); s.zeichnen(); s.aktualisieren(mqttData); // jede Sekunde, auch wenn verborgen Live kommt alles über MQTT: solarManager/SOC und P_Akku vom Wechselrichter, solarManager/byd/# direkt aus der BMU (gatherBYDData.py im SolarManager - Zellspannungen, Temperaturfühler, SOH, Zähler). Der Verlauf kommt aus ajax/speicher.php. Ohne BMU-Daten (andere Batterie, Sammler aus) bleibt die Ansicht nützlich: Ladestand, Leistung und ihr Verlauf stehen trotzdem da, Turm und Zellwerte machen einem Hinweis Platz. Die Zellen sind durchgezählt wie bei der BMU: Modul m hat die Zellen (m-1)*16+1 bis m*16 und die Fühler (m-1)*8+1 bis m*8. ========================================================================== */ (function () { "use strict"; // Nennenergie eines Moduls in kWh. HVM: 2,76; HVS: 2,56. Die BMU meldet die // Zahl der Module, aber nicht diese Größe. const KWH_JE_MODUL = 2.76; const ZELLEN_JE_MODUL = 16; const FUEHLER_JE_MODUL = 8; const VERALTET_S = 600; const NS = "http://www.w3.org/2000/svg"; const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ","); const tausend = z => Math.round(z).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "."); const uhrzeit = d => String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); const schutz = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); function leser(daten) { return { da(t) { return daten[t] !== undefined; }, zahl(t) { const v = Number(String(daten[t])); return isFinite(v) ? v : 0; }, text(t) { return daten[t] === undefined ? "" : String(daten[t]); }, json(t) { try { return JSON.parse(String(daten[t])); } catch (e) { return null; } }, }; } /** Abweichung -1..1 als Farbe: unter dem Mittel blau, im Mittel grün, darüber hellgrün. */ function zellFarbe(t) { const mix = (a, b, f) => a.map((x, i) => Math.round(x + (b[i] - x) * f)); const blau = [79, 143, 184], gruen = [108, 190, 88], hell = [176, 222, 128]; const c = t < 0 ? mix(gruen, blau, Math.min(1, -t)) : mix(gruen, hell, Math.min(1, t)); return "rgb(" + c.join(",") + ")"; } function tempFarbe(t) { const f = Math.max(0, Math.min(1, (t - 10) / 30)); const kalt = [79, 143, 184], mild = [108, 190, 88], warm = [232, 163, 58]; const c = f < 0.5 ? kalt.map((x, i) => Math.round(x + (mild[i] - x) * f * 2)) : mild.map((x, i) => Math.round(x + (warm[i] - x) * (f - 0.5) * 2)); return "rgb(" + c.join(",") + ")"; } class Speicher { constructor(behaelter, opt = {}) { this.behaelter = behaelter; this.kwhJeModul = opt.kwhJeModul || KWH_JE_MODUL; this.modul = null; // gewähltes Modul (0-basiert), null = selbst wählen this.zeitraum = 24; // Stunden this.verlauf = null; this.zuletzt = {}; new ResizeObserver(() => this.diagrammeZeichnen()).observe(behaelter); this.sichtbar = false; new IntersectionObserver(e => { const vorher = this.sichtbar; this.sichtbar = e[0].isIntersecting; this.behaelter.classList.toggle("spk-pause", !this.sichtbar); // Beim ersten Hinsehen den Verlauf holen - vorher kostet er nur. if (this.sichtbar && !vorher && !this.verlauf) this.verlaufHolen(); }).observe(behaelter); setInterval(() => { if (this.sichtbar && !document.hidden) this.verlaufHolen(); }, 5 * 60000); } /* --------------------------------------------------------- Zeichnen */ zeichnen() { this.behaelter.innerHTML = `
Ohne Daten der BMU gibt es keine Werte je Modul und Zelle.
'; this.dom.detail.innerHTML = ""; } } /* ------------------------------------------------------ Kennzahlen */ kennzahlenZeigen(s) { const soc = s.bmu ? s.soc : s.socWr; const module = s.bmu && s.module ? s.module : 0; const kwh = module * this.kwhJeModul; const p = s.pWr; let prognose = "in Ruhe"; if (p > 50 && kwh) { const h = kwh * (100 - soc) / 100 / (p / 1000); prognose = soc >= 99.5 ? "voll" : "voll um " + uhrzeit(new Date(Date.now() + h * 3600000)); } else if (p < -50 && kwh) { const h = kwh * soc / 100 / (-p / 1000); prognose = "leer um " + uhrzeit(new Date(Date.now() + h * 3600000)); } if (p > 50 && s.grenze > 0 && s.grenze < 100) prognose += " · begrenzt auf " + Math.round(s.grenze) + " %"; const kacheln = [ { titel: "Ladestand", zeichen: "\uF187", wert: Math.round(soc) + " %", neben: kwh ? "≈ " + komma(kwh * soc / 100) + " von " + komma(kwh) + " kWh" : "", balken: soc }, { titel: "Leistung", zeichen: p > 50 ? "\uF185" : "\uF46E", wert: (p > 50 ? "+" : p < -50 ? "−" : "") + komma(Math.abs(p) < 50 ? 0 : Math.abs(p) / 1000, 2) + " kW", neben: (p > 50 ? "lädt · " : p < -50 ? "entlädt · " : "") + prognose, klasse: p > 50 ? "spk-gut" : p < -50 ? "spk-warm" : "" }, ]; if (s.bmu) { const zyklen = kwh ? s.entladen / kwh : 0; kacheln.push( { titel: "Gesundheit", zeichen: "\uF76E", wert: komma(s.soh, 0) + " %", neben: "SOH laut BMU" }, { titel: "Zellspreizung", zeichen: "\uF304", wert: tausend(s.spreizung) + " mV", neben: komma(s.zelleMin / 1000, 3) + "–" + komma(s.zelleMax / 1000, 3) + " V", klasse: s.spreizung > 30 ? "spk-warm" : "" }, { titel: "Temperatur", zeichen: "\uF5CD", wert: Math.round(s.tMin) + "–" + Math.round(s.tMax) + " °C", neben: (s.temps || []).length + " Fühler" }, { titel: "Spannung", zeichen: "\uF66B", wert: komma(s.spannung) + " V", neben: komma(s.strom) + " A" }, { titel: "Durchsatz", zeichen: "\uF130", wert: tausend(s.entladen) + " kWh", neben: "≈ " + tausend(zyklen) + " Zyklen · " + (s.geladen ? Math.round(s.entladen / s.geladen * 100) : 0) + " % zurück", titelLang: "entladen seit Inbetriebnahme; Zyklen gerechnet mit " + komma(kwh) + " kWh; zurück = entladen je geladen" }, { titel: "Zustand", zeichen: s.fehler ? "\uF33A" : "\uF26A", wert: s.fehler ? "Fehler" : "in Ordnung", neben: (s.firmware || "") + (s.ausgleich ? " · gleicht " + s.ausgleich + " Zellen aus" : ""), klasse: s.fehler ? "spk-krit" : "spk-gut" }, ); } const html = kacheln.map(k => `Verlauf nicht abrufbar: ' + schutz(e.message) + "
"; } } } diagrammeZeichnen(neu) { if (!this.verlauf || !this.dom || !this.dom.soc.clientWidth) return; const v = this.verlauf; diagramm(this.dom.soc, { von: v.von, bis: v.bis, neu, links: { min: 0, max: 100, einheit: "%" }, rechts: { einheit: "kW", symmetrisch: true }, reihen: [ { name: "Leistung", achse: "rechts", werte: v.soc.map(r => [r[0], r[2] / 1000]), flaeche: true, farbe: "#6cbe58", farbeNegativ: "#e8a33a", format: w => (w > 0 ? "+" : "") + komma(w, 2) + " kW" }, { name: "Ladestand", achse: "links", werte: v.soc.map(r => [r[0], r[1]]), farbe: "#e3e8ec", format: w => komma(w, 0) + " %" }, ], }); if (!v.bmu.length) { this.dom.bmu.innerHTML = 'Noch keine Werte der BMU in diesem Zeitraum – sie werden seit 15.09.2026 alle 5 Minuten gespeichert.
'; return; } // Die BMU wird erst seit 15.09.2026 mitgeschrieben: reichen die Werte // nicht über den halben Zeitraum, beginnt die Achse kurz vor dem ersten. const erster = v.bmu[0][0]; const bmuVon = erster - v.von > (v.bis - v.von) / 2 ? Math.max(v.von, erster - 1800) : v.von; diagramm(this.dom.bmu, { von: bmuVon, bis: v.bis, neu, links: { min: 0, einheit: "mV" }, rechts: { einheit: "°C" }, reihen: [ { name: "Spreizung", achse: "links", werte: v.bmu.map(r => [r[0], r[1]]), flaeche: true, farbe: "#8fb8de", format: w => komma(w, 0) + " mV" }, { name: "wärmster Fühler", achse: "rechts", werte: v.bmu.map(r => [r[0], r[3]]), farbe: "#e8a33a", format: w => komma(w, 0) + " °C" }, { name: "kältester Fühler", achse: "rechts", werte: v.bmu.map(r => [r[0], r[2]]), farbe: "#4f8fb8", format: w => komma(w, 0) + " °C" }, ], }); } } /* ------------------------------------------------------------------------ Linien-/Flächendiagramm mit zwei Achsen und Anzeige beim Überfahren. Klein gehalten: genau das, was die zwei Diagramme hier brauchen. ------------------------------------------------------------------------ */ function diagramm(ziel, o) { const B = Math.max(280, ziel.clientWidth), H = 190; const L = 40, R = 46, O = 22, U = 24; const alle = seite => o.reihen.filter(r => r.achse === seite).flatMap(r => r.werte.map(w => w[1])); const achse = (seite, cfg) => { const w = alle(seite); let min = cfg.min !== undefined ? cfg.min : Math.min(...w); let max = cfg.max !== undefined ? cfg.max : Math.max(...w); if (cfg.symmetrisch) { const m = Math.max(0.5, Math.abs(min), Math.abs(max)); min = -m; max = m; } if (!isFinite(min) || !isFinite(max)) { min = 0; max = 1; } if (max - min < 1e-6) { max = min + 1; } const pad = cfg.max !== undefined ? 0 : (max - min) * 0.1; return { min: cfg.min !== undefined ? min : min - pad, max: max + pad, einheit: cfg.einheit }; }; const ax = { links: achse("links", o.links), rechts: achse("rechts", o.rechts) }; const X = t => L + (t - o.von) / (o.bis - o.von) * (B - L - R); const Y = (seite, v) => O + (ax[seite].max - v) / (ax[seite].max - ax[seite].min) * (H - O - U); // Zeitachse const spanne = o.bis - o.von; const schritt = spanne <= 86400 * 1.5 ? 3 * 3600 : spanne <= 86400 * 8 ? 86400 : 5 * 86400; const ticks = []; const start = new Date(o.von * 1000); start.setMinutes(0, 0, 0); if (schritt >= 86400) start.setHours(0); for (let t = start.getTime() / 1000; t <= o.bis; t += schritt) { if (t < o.von) continue; const d = new Date(t * 1000); if (schritt < 86400 && d.getHours() % 3) continue; ticks.push([t, schritt < 86400 ? String(d.getHours()).padStart(2, "0") + ":00" : ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"][d.getDay()] + " " + d.getDate() + "."]); } const gitterWerte = seite => [0, 0.25, 0.5, 0.75, 1].map(f => ax[seite].min + (ax[seite].max - ax[seite].min) * f); const zahl = v => Math.abs(v) >= 10 ? Math.round(v) : komma(v, 1); // Pfade; Lücken über dem Dreifachen des üblichen Abstands werden nicht verbunden. const pfad = (r, flaeche) => { if (!r.werte.length) return ""; const abst = r.werte.length > 1 ? (r.werte[r.werte.length - 1][0] - r.werte[0][0]) / (r.werte.length - 1) : 300; let d = "", zug = []; const nullY = Y(r.achse, Math.max(ax[r.achse].min, Math.min(ax[r.achse].max, 0))); const zu = () => { if (!zug.length) return; d += "M" + zug.map(p => p[0].toFixed(1) + " " + p[1].toFixed(1)).join("L"); if (flaeche) d += `L${zug[zug.length - 1][0].toFixed(1)} ${nullY.toFixed(1)}L${zug[0][0].toFixed(1)} ${nullY.toFixed(1)}Z`; zug = []; }; r.werte.forEach((w, i) => { if (i && w[0] - r.werte[i - 1][0] > abst * 3 + 60) zu(); zug.push([X(w[0]), Y(r.achse, w[1])]); }); zu(); return d; }; const id = "spkd" + Math.random().toString(36).slice(2, 8); let svg = ``; svg += ``; svg += `