diff --git a/README.md b/README.md index 80287a2..f8f2351 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`). | Seite (`?action=`) | Vorlage | Seitenskripte | holt per fetch | hört auf MQTT | |---|---|---|---|---| -| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `energieflussKatalog.js`, `energiefluss.js`, `solarMQTT.js` | `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` | +| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `anlageKatalog.js`, `anlage.js`, `speicher.js`, `energieflussKatalog.js`, `energiefluss.js`, `solarMQTT.js` | `speicher`, `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` | | `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` | | `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics | | `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — | @@ -342,6 +342,18 @@ des Medians liegt. Meldet ein Wechselrichter nichts, stehen seine Platten auf „ohne Daten“ statt „auffällig“. Den Verlauf je Platte gibt es nicht in der Datenbank; die Ansicht schreibt ihn ab dem Seitenaufruf mit (40 Minuten). +**Speicher-Ansicht** (Umschalter „Speicher“ oder Klick auf die Batterie) — +`speicher.js` zeigt die Batterie bis zur Zelle: Kennzahlen (Ladestand mit kWh +und „voll/leer um“, Leistung, SOH, Zellspreizung, Temperaturen, Spannung, +Durchsatz mit gerechneten Zyklen, Zustand), den Turm aus BMU und Modulen mit +je 16 Zellbalken (Farbe und Höhe = Abweichung vom Mittel aller Zellen), das +gewählte Modul mit Zellspannungen und Temperaturfühlern sowie zwei +Verläufe aus `ajax/speicher.php` (Ladestand und Leistung aus `EnergyFlow`, +Spreizung und Temperatur aus `byd`). Live-Werte kommen über MQTT +`solarManager/byd/#` (`gatherBYDData.py`); fehlen sie, zeigt die Ansicht, was +der Wechselrichter weiß. Die Nennenergie je Modul (2,76 kWh, HVM) steht als +Konstante oben in `speicher.js` — die BMU meldet sie nicht. + **`skodaMQTT.js`** — Fahrzeugseite. Holt alles über `ajax/skoda.php?was=…` (live, ladungen, kurve, gesundheit, fahrten, strecke), zeichnet das Fahrzeug als SVG-Draufsicht mit Ladestand im Akku, Ladeknopf und Klimaknopf, und die diff --git a/ajax/speicher.php b/ajax/speicher.php new file mode 100644 index 0000000..fde2b7d --- /dev/null +++ b/ajax/speicher.php @@ -0,0 +1,82 @@ + "Nicht angemeldet."]); + exit; +} + +$was = $_GET["was"] ?? "verlauf"; + +try { + $db = solarDb(); + + if ($was === "verlauf") { + $stunden = max(1, min(24 * 90, intval($_GET["stunden"] ?? 24))); + $stuendlich = $stunden > 24 * 3; + $gruppe = $stuendlich ? "FLOOR(UNIX_TIMESTAMP(datetime) / 3600) * 3600" : "UNIX_TIMESTAMP(datetime)"; + + $soc = []; + $res = $db->query("SELECT $gruppe AS t, AVG(soc) AS soc, AVG(battP) AS p + FROM EnergyFlow + WHERE datetime >= NOW() - INTERVAL $stunden HOUR + GROUP BY t ORDER BY t"); + while ($res && $z = $res->fetch_assoc()) { + // battP > 0 heißt entladen; hinaus geht "positiv = laden", wie bei der BMU. + $soc[] = [(int)$z["t"], round((float)$z["soc"], 1), round(-(float)$z["p"])]; + } + + $bmu = []; + $res = $db->query("SELECT $gruppe AS t, AVG(spreizung) AS spr, MIN(t_min) AS tmin, MAX(t_max) AS tmax, AVG(soh) AS soh + FROM byd + WHERE datetime >= NOW() - INTERVAL $stunden HOUR + GROUP BY t ORDER BY t"); + while ($res && $z = $res->fetch_assoc()) { + $bmu[] = [(int)$z["t"], round((float)$z["spr"], 1), (int)$z["tmin"], (int)$z["tmax"], round((float)$z["soh"], 1)]; + } + + echo json_encode([ + "von" => time() - $stunden * 3600, "bis" => time(), + "soc" => $soc, "bmu" => $bmu, + ]); + exit; + } + + if ($was === "gesundheit") { + $tage = []; + $res = $db->query("SELECT DATE(datetime) AS tag, AVG(soh) AS soh, MAX(spreizung) AS spr, + MAX(geladen_kwh) AS geladen, MAX(entladen_kwh) AS entladen + FROM byd GROUP BY tag ORDER BY tag"); + while ($res && $z = $res->fetch_assoc()) { + $tage[] = [$z["tag"], round((float)$z["soh"], 1), (int)$z["spr"], + (float)$z["geladen"], (float)$z["entladen"]]; + } + echo json_encode(["tage" => $tage]); + exit; + } + + http_response_code(400); + echo json_encode(["error" => "Unbekannter Abschnitt."]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(["error" => "Verlauf nicht abrufbar: " . $e->getMessage()]); +} diff --git a/css/solar.css b/css/solar.css index cd322a5..ce4ef43 100644 --- a/css/solar.css +++ b/css/solar.css @@ -3733,6 +3733,141 @@ svg.anl-plan.anl-hoch { max-height: none; } .anl *, .anl-plan * { animation: none !important; transition: none !important; } } +/* ========================================================================== + Speicher-Ansicht (js/solar/speicher.js). Töne wie Übersicht und Anlage. + ========================================================================== */ +.spk { display: grid; gap: .9rem; } +.spk .spk-meldung { border-radius: .7rem; padding: .55rem .9rem; font-size: .88rem; background: rgba(232, 163, 58, .12); color: #e8a33a; } +.spk .spk-meldung.spk-fehler { background: rgba(229, 98, 79, .14); color: #e5624f; } +.spk .spk-fach { background: var(--ton-grund, #191d20); border-radius: .9rem; padding: .9rem 1rem; display: grid; gap: .7rem; align-content: start; min-width: 0; } +.spk .spk-kopf { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: .4rem .8rem; } +.spk .spk-ueberschrift { font-size: .78rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--ton-schrift-2, #9aa6ad); margin: 0; } +.spk .spk-stand { font-size: .78rem; color: var(--ton-schrift-3, #6f7a81); } +.spk .spk-leer { color: var(--ton-schrift-3, #6f7a81); font-size: .85rem; margin: .5rem 0; } + +/* Kennzahlen */ +.spk .spk-kennzahlen { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .6rem; } +@media (max-width: 991.98px) { .spk .spk-kennzahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.spk .spk-notiz { font-size: .74rem; color: var(--ton-schrift-3, #6f7a81); margin-top: -.4rem; } +.spk .spk-einheit { font-weight: 600; } +.spk .spk-kachel { position: relative; background: var(--ton-grund, #191d20); border-radius: .8rem; padding: .65rem .85rem .7rem; display: grid; gap: .1rem; overflow: hidden; } +.spk .spk-kachel-titel { font-size: .74rem; color: var(--ton-schrift-2, #9aa6ad); display: flex; align-items: center; gap: .4rem; } +.spk .spk-kachel-titel i { font-family: bootstrap-icons; font-style: normal; font-size: .9rem; color: #6cbe58; } +.spk .spk-kachel b { font-size: 1.45rem; font-weight: 600; color: var(--ton-schrift, #e8ede9); font-variant-numeric: tabular-nums; line-height: 1.25; } +.spk .spk-kachel small { font-size: .74rem; color: var(--ton-schrift-3, #6f7a81); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.spk .spk-kachel.spk-gut b { color: #8fd37a; } +.spk .spk-kachel.spk-warm b { color: #e8a33a; } +.spk .spk-kachel.spk-warm .spk-kachel-titel i { color: #e8a33a; } +.spk .spk-kachel.spk-krit b, +.spk .spk-kachel.spk-krit .spk-kachel-titel i { color: #e5624f; } +.spk .spk-kachel-balken { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: #2b3139; } +.spk .spk-kachel-balken span { display: block; height: 100%; background: linear-gradient(90deg, #4f8fb8, #6cbe58); transition: width 1.2s cubic-bezier(.2, .7, .2, 1); } + +/* Turm und Detail */ +.spk .spk-mitte { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: .9rem; align-items: start; } +@media (max-width: 991.98px) { .spk .spk-mitte { grid-template-columns: minmax(0, 1fr); } } +.spk .spk-turm { position: relative; display: grid; gap: 4px; } +.spk .spk-bmu { + display: flex; align-items: center; gap: .6rem; padding: .5rem .8rem; font-size: .82rem; + background: linear-gradient(#2c3238, #24292f); border: 1px solid #333a42; border-radius: .7rem .7rem .25rem .25rem; +} +.spk .spk-bmu b { font-weight: 600; } +.spk .spk-bmu span:last-child { color: var(--ton-schrift-3, #6f7a81); font-size: .74rem; margin-left: auto; } +.spk .spk-led { width: .55rem; height: .55rem; border-radius: 50%; background: #444b53; } +.spk .spk-led.an { background: #6cbe58; box-shadow: 0 0 .5rem #6cbe58; animation: spk-led 2.4s ease-in-out infinite; } +@keyframes spk-led { 50% { opacity: .55; } } +.spk .spk-modul { + all: unset; box-sizing: border-box; cursor: pointer; position: relative; overflow: hidden; + display: grid; grid-template-columns: 2.2rem minmax(0, 1fr) 7.4rem; gap: .7rem; align-items: center; + padding: .45rem .7rem; border-radius: .25rem; border: 1px solid #2f363d; + background: linear-gradient(90deg, #1f2429, #262c32 50%, #1f2429); + transition: border-color .2s, box-shadow .2s; + animation: spk-modul-auf .5s cubic-bezier(.2, .9, .3, 1.1) backwards; animation-delay: calc(var(--i) * 45ms); +} +@keyframes spk-modul-auf { from { opacity: 0; transform: translateY(8px); } } +.spk .spk-modul:hover { border-color: #46505a; } +.spk .spk-modul:focus-visible { outline: 2px solid #8fb8de; outline-offset: 1px; } +.spk .spk-modul.gewaehlt { border-color: #6cbe58; box-shadow: inset 3px 0 0 #6cbe58; } +.spk .spk-nr { font-weight: 600; color: var(--ton-schrift-2, #9aa6ad); font-size: .9rem; } +.spk .spk-zellen { display: grid; grid-template-columns: repeat(16, minmax(0, 1fr)); gap: 2px; height: 2rem; align-items: end; } +.spk .spk-zellen i { display: block; height: var(--h); background: var(--f); border-radius: 2px 2px 1px 1px; transition: height 1s cubic-bezier(.2, .7, .2, 1), background 1s; } +.spk .spk-werte { text-align: right; line-height: 1.25; font-variant-numeric: tabular-nums; } +.spk .spk-werte b { display: block; font-size: .8rem; font-weight: 600; } +.spk .spk-werte small { font-size: .74rem; color: var(--ton-schrift-3, #6f7a81); } +.spk .spk-sockel { height: .6rem; margin-top: -2px; border-radius: 0 0 .6rem .6rem; background: #1a1e22; border: 1px solid #2f363d; border-top: 0; } +/* Laden: Licht wandert nach oben durch die Module, Entladen nach unten. */ +.spk .spk-turm.spk-laden .spk-modul::after, +.spk .spk-turm.spk-entladen .spk-modul::after { + content: ""; position: absolute; inset: 0; pointer-events: none; + background: linear-gradient(90deg, transparent, rgba(108, 190, 88, .16), transparent); + transform: translateX(-100%); animation: spk-strom 2.6s ease-in-out infinite; +} +.spk .spk-turm.spk-laden .spk-modul::after { animation-delay: calc((8 - var(--i)) * 180ms); } +.spk .spk-turm.spk-entladen .spk-modul::after { background: linear-gradient(90deg, transparent, rgba(232, 163, 58, .14), transparent); animation-delay: calc(var(--i) * 180ms); } +@keyframes spk-strom { 0% { transform: translateX(-100%); } 60%, 100% { transform: translateX(100%); } } +.spk.spk-pause *, +.spk-pause .spk * { animation-play-state: paused !important; } +.spk .spk-legende { display: flex; flex-wrap: wrap; gap: .3rem 1rem; font-size: .76rem; color: var(--ton-schrift-2, #9aa6ad); } +.spk .spk-legende i { display: inline-block; width: .7rem; height: .7rem; border-radius: 2px; margin-right: .35rem; vertical-align: -.08rem; } + +.spk .spk-pille { font-size: .72rem; font-weight: 600; border-radius: 99px; padding: .1rem .55rem; } +.spk .spk-pille.ok { color: #6cbe58; background: rgba(108, 190, 88, .13); } +.spk .spk-pille.warn { color: #e8a33a; background: rgba(232, 163, 58, .14); } +.spk .spk-gross { display: flex; flex-wrap: wrap; align-items: baseline; gap: .2rem .8rem; } +.spk .spk-gross b { font-size: 2rem; font-weight: 600; line-height: 1.1; color: var(--ton-schrift, #e8ede9); font-variant-numeric: tabular-nums; } +.spk .spk-gross span { color: var(--ton-schrift-2, #9aa6ad); font-size: .88rem; } +.spk .spk-zellchart { width: 100%; height: auto; display: block; } +.spk .spk-zelle { transform-box: fill-box; transform-origin: 50% 100%; animation: spk-zelle .6s cubic-bezier(.2, .9, .3, 1.1) backwards; animation-delay: calc(var(--j) * 25ms); } +.spk .spk-zelle.markiert { stroke: #fff; stroke-width: 1.5; } +@keyframes spk-zelle { from { transform: scaleY(0); } } +.spk .spk-gitter { stroke: #2a3037; stroke-width: 1; } +.spk .spk-mittel { stroke: #8d96a0; stroke-dasharray: 3 3; } +.spk .spk-achse { fill: var(--ton-schrift-3, #6f7a81); font-size: 10px; font-family: "Poppins", system-ui, sans-serif; } +.spk .spk-fuehler { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 3px; } +.spk .spk-fuehler span { text-align: center; font-size: .74rem; font-weight: 600; padding: .2rem 0; border-radius: .3rem; color: #15181c; background: var(--f); font-variant-numeric: tabular-nums; } +.spk .spk-paare { display: grid; grid-template-columns: auto 1fr; gap: .25rem .9rem; font-size: .86rem; margin: 0; } +.spk .spk-paare dt { color: var(--ton-schrift-2, #9aa6ad); font-weight: 400; } +.spk .spk-paare dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; } + +/* Verlauf */ +.spk .spk-diagramme { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: .9rem; } +@media (max-width: 991.98px) { .spk .spk-diagramme { grid-template-columns: minmax(0, 1fr); } } +.spk .spk-zeitraum { display: inline-flex; gap: 2px; padding: 2px; border-radius: .5rem; background: #23282c; } +.spk .spk-zeitraum button { all: unset; cursor: pointer; font-size: .76rem; font-weight: 600; padding: .15rem .6rem; border-radius: .4rem; color: var(--ton-schrift-2, #9aa6ad); } +.spk .spk-zeitraum button.aktiv { background: #2f363d; color: var(--ton-schrift, #e8ede9); } +.spk .spk-zeitraum button:focus-visible { outline: 2px solid #8fb8de; } +.spk .spk-diagramm { position: relative; min-height: 190px; } +.spk .spk-verlauf { display: block; width: 100%; height: auto; touch-action: pan-y; } +.spk .spk-flaeche { opacity: .22; } +.spk .spk-linie { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } +.spk .spk-verlauf.neu .spk-linie { stroke-dasharray: 1 1; stroke-dashoffset: 1; animation: spk-zeichnen 1.2s cubic-bezier(.5, 0, .2, 1) forwards; animation-delay: calc(var(--i) * 150ms); } +.spk .spk-verlauf.neu .spk-flaeche { animation: spk-einblenden 1.2s backwards; } +@keyframes spk-zeichnen { to { stroke-dashoffset: 0; } } +@keyframes spk-einblenden { from { opacity: 0; } } +.spk .spk-zeiger { stroke: rgba(255, 255, 255, .35); stroke-width: 1; pointer-events: none; } +.spk .spk-tipp { + position: absolute; top: .4rem; z-index: 2; pointer-events: none; min-width: 9rem; + background: rgba(21, 24, 28, .94); border: 1px solid #333a42; border-radius: .55rem; padding: .4rem .6rem; + font-size: .76rem; display: grid; gap: .1rem; box-shadow: 0 .4rem 1rem rgba(0, 0, 0, .35); +} +.spk .spk-tipp b { font-weight: 600; margin-bottom: .1rem; } +.spk .spk-tipp span { display: flex; align-items: center; gap: .4rem; color: var(--ton-schrift-2, #9aa6ad); } +.spk .spk-tipp em { font-style: normal; margin-left: auto; padding-left: .8rem; color: var(--ton-schrift, #e8ede9); font-variant-numeric: tabular-nums; } +.spk .spk-tipp i, +.spk .spk-reihen i { display: inline-block; width: .6rem; height: .6rem; border-radius: 2px; } +.spk .spk-reihen { display: flex; flex-wrap: wrap; gap: .2rem 1rem; font-size: .76rem; color: var(--ton-schrift-2, #9aa6ad); margin-top: .2rem; } +.spk .spk-reihen span { display: inline-flex; align-items: center; gap: .35rem; } +@media (max-width: 575.98px) { + .spk .spk-kennzahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .45rem; } + .spk .spk-kachel b { font-size: 1.2rem; } + .spk .spk-modul { grid-template-columns: 1.8rem minmax(0, 1fr) 5.6rem; gap: .45rem; padding: .4rem .5rem; } + .spk .spk-werte b { font-size: .72rem; } +} +@media (prefers-reduced-motion: reduce) { + .spk *, .spk *::after { animation: none !important; transition: none !important; } + .spk .spk-verlauf.neu .spk-linie { stroke-dashoffset: 0; } +} + /* --- Karte: Umschalter Übersicht / Anlage -------------------------------- */ .enf-umschalter { display: inline-flex; gap: 2px; padding: 3px; border-radius: var(--ton-radius-klein, .5rem); background: var(--ton-grund, #191d20); } .enf-umschalter .btn.btn-sm { border: 0; color: var(--ton-schrift-2, #9aa6ad); font-weight: 600; padding: .2rem .8rem; } diff --git a/index.php b/index.php index 5211273..79377ee 100644 --- a/index.php +++ b/index.php @@ -100,7 +100,7 @@ if (isset($_GET["addUser"])) { */ $pages = [ "solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false, - "scripts" => ["js/solar/jahresstatistik.js", "js/solar/anlageKatalog.js", "js/solar/anlage.js", + "scripts" => ["js/solar/jahresstatistik.js", "js/solar/anlageKatalog.js", "js/solar/anlage.js", "js/solar/speicher.js", "js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/solarMQTT.js"]], "home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false, "scripts" => ["js/solar/autoActionFuncs.js", "js/solar/zeitleiste.js", "js/solar/homeMQTT.js"]], diff --git a/js/solar/energiefluss.js b/js/solar/energiefluss.js index a4ab0b0..95e7eac 100644 --- a/js/solar/energiefluss.js +++ b/js/solar/energiefluss.js @@ -109,14 +109,17 @@ /* Plaketten am Kreis: Zeichen aus Bootstrap Icons (liegt ohnehin im Dashboard). */ const ZEICHEN = { - eco: { glyph: "奈", farbe: "#6cbe58" }, // bi-leaf - uhr: { glyph: "", farbe: "#e8a33a" }, // bi-clock-fill - manuell: { glyph: "", farbe: "#c9a227" }, // bi-sliders - stecker: { glyph: "", farbe: "#8fb8de" }, // bi-plug-fill - schloss: { glyph: "", farbe: "#b9c0c8" }, // bi-lock-fill - heizung: { glyph: "", farbe: "#e5624f" }, // bi-fire - tank: { glyph: "", farbe: "#b9c0c8" }, // bi-fuel-pump-fill - warnung: { glyph: "", farbe: "#e5624f" }, // bi-exclamation-triangle-fill + // Codepunkte als Escape: die Zeichen selbst liegen im privaten Bereich + // (U+F90C sogar auf einem CJK-Kompatibilitaetszeichen) und sind im Editor + // unsichtbar oder werden beim Kopieren leicht verfaelscht. + eco: { glyph: "\uF90C", farbe: "#6cbe58" }, // bi-leaf + uhr: { glyph: "\uF291", farbe: "#e8a33a" }, // bi-clock-fill + manuell: { glyph: "\uF56B", farbe: "#c9a227" }, // bi-sliders + stecker: { glyph: "\uF4F6", farbe: "#8fb8de" }, // bi-plug-fill + schloss: { glyph: "\uF47A", farbe: "#b9c0c8" }, // bi-lock-fill + heizung: { glyph: "\uF7F6", farbe: "#e5624f" }, // bi-fire + tank: { glyph: "\uF83D", farbe: "#b9c0c8" }, // bi-fuel-pump-fill + warnung: { glyph: "\uF33A", farbe: "#e5624f" }, // bi-exclamation-triangle-fill }; /* ------------------------------------------------------------------------ diff --git a/js/solar/energieflussKatalog.js b/js/solar/energieflussKatalog.js index eff084f..592b745 100644 --- a/js/solar/energieflussKatalog.js +++ b/js/solar/energieflussKatalog.js @@ -121,6 +121,7 @@ const ENERGIEFLUSS_KATALOG = (function () { { id: "akku", name: "Batterie", symbol: "akku", farbe: "#6cbe58", eltern: "wr", x: 180, y: 630, skala: 15000, + klick: { ansicht: "speicher" }, lesen(m) { const p = m.zahl("solarManager/P_Akku"); const soc = m.zahl("solarManager/SOC"); @@ -289,9 +290,9 @@ const ENERGIEFLUSS_KATALOG = (function () { // Ids wie in wateringZones (solarMQTT.js) - dort werden Farbe, Text und // Tooltip gesetzt, hier nur die Stellen dafür angelegt. zonen: [ - { name: "Tröge", zeichen: "", status: "wateringTroughStatus", symbol: "wateringTroughIcon" }, - { name: "Vorn", zeichen: "", status: "wateringFrontStatus", symbol: "wateringFrontIcon" }, - { name: "Beete", zeichen: "", status: "wateringBedsStatus", symbol: "wateringBedsIcon" }, + { name: "Tröge", zeichen: "\uF180", status: "wateringTroughStatus", symbol: "wateringTroughIcon" }, + { name: "Vorn", zeichen: "\uF3CE", status: "wateringFrontStatus", symbol: "wateringFrontIcon" }, + { name: "Beete", zeichen: "\uF3CD", status: "wateringBedsStatus", symbol: "wateringBedsIcon" }, ], lesen() { return { wert: "" }; diff --git a/js/solar/solarMQTT.js b/js/solar/solarMQTT.js index 2a1f14d..88e3a25 100644 --- a/js/solar/solarMQTT.js +++ b/js/solar/solarMQTT.js @@ -148,6 +148,7 @@ function setFlowWidth(id, width) { // jetzt in den Katalogen (energieflussKatalog.js, anlageKatalog.js). let energiefluss = null; let anlage = null; +let speicher = null; const solarSVG = { updateCnt: 99, @@ -166,6 +167,7 @@ const solarSVG = { if (energiefluss) energiefluss.aktualisieren(mqttData); // Auch verborgen: so schreibt die Anlage ihren Verlauf je Platte mit. if (anlage) anlage.aktualisieren(mqttData); + if (speicher) speicher.aktualisieren(mqttData); } }; @@ -212,6 +214,11 @@ function energieflussStarten() { anlage = new Anlage(anlageBehaelter); anlage.zeichnen(); } + const speicherBehaelter = document.getElementById("speicher"); + if (speicherBehaelter && typeof Speicher !== "undefined") { + speicher = new Speicher(speicherBehaelter); + speicher.zeichnen(); + } } var chartSettings = { diff --git a/js/solar/speicher.js b/js/solar/speicher.js new file mode 100644 index 0000000..3f8278d --- /dev/null +++ b/js/solar/speicher.js @@ -0,0 +1,532 @@ +/* ========================================================================== + 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 += `