Solar: Speicher-Ansicht mit Modulen und Zellen der BYD-Batterie
Dritte Ansicht der Realtime-Karte (auch per Klick auf die Batterie): Kennzahlen (Ladestand mit kWh und voll/leer um, Leistung, SOH, Zellspreizung, Temperaturen, Spannung, Durchsatz, Zustand), der Turm aus BMU und acht Modulen mit je 16 Zellbalken, das gewaehlte Modul mit Zellspannungen und Temperaturfuehlern, dazu Verlaeufe fuer Ladestand und Leistung (EnergyFlow) sowie Spreizung und Temperatur (byd) mit Zeitraum- wahl und Anzeige beim Ueberfahren. Neu: ajax/speicher.php. Nebenbei: Icon-Zeichen in energiefluss.js und im Katalog stehen als Escape (\uF...) statt als unsichtbare Zeichen im Quelltext. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/*
|
||||
* Verlauf für die Speicher-Ansicht der Solar-Seite (js/solar/speicher.js).
|
||||
*
|
||||
* ?was=verlauf&stunden=24 Ladestand und Leistung aus EnergyFlow (reicht
|
||||
* Jahre zurück) und die BMU-Werte aus byd
|
||||
* (Spreizung, Temperaturen, SOH - seit 15.09.2026)
|
||||
* ?was=gesundheit SOH, größte Spreizung und Zähler je Tag aus byd
|
||||
*
|
||||
* Die Live-Werte kommen nicht von hier, sondern über MQTT
|
||||
* (solarManager/byd/#, geschrieben von gatherBYDData.py).
|
||||
*
|
||||
* Zeiten gehen als Unix-Sekunden hinaus; ab sieben Tagen wird auf Stunden
|
||||
* gemittelt, damit ein Monat nicht als 8.600 Punkte über die Leitung geht.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/costs.php"); // solarDb()
|
||||
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "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()]);
|
||||
}
|
||||
+135
@@ -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; }
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
|
||||
@@ -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: "" };
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = `
|
||||
<div class="spk">
|
||||
<div class="spk-meldung" hidden></div>
|
||||
<div class="spk-kennzahlen"></div>
|
||||
<div class="spk-mitte">
|
||||
<div class="spk-fach spk-turm-fach">
|
||||
<div class="spk-kopf"><h3 class="spk-ueberschrift">Module und Zellen</h3><span class="spk-stand"></span></div>
|
||||
<div class="spk-turm"></div>
|
||||
<div class="spk-legende">
|
||||
<span><i style="background:${zellFarbe(-1)}"></i>unter dem Mittel</span>
|
||||
<span><i style="background:${zellFarbe(0)}"></i>im Mittel</span>
|
||||
<span><i style="background:${zellFarbe(1)}"></i>darüber</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spk-fach spk-detail" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="spk-diagramme">
|
||||
<div class="spk-fach">
|
||||
<div class="spk-kopf">
|
||||
<h3 class="spk-ueberschrift">Ladestand und Leistung</h3>
|
||||
<div class="spk-zeitraum" role="group" aria-label="Zeitraum">
|
||||
<button type="button" data-stunden="24" class="aktiv">24 h</button>
|
||||
<button type="button" data-stunden="168">7 Tage</button>
|
||||
<button type="button" data-stunden="720">30 Tage</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spk-diagramm" data-diagramm="soc"></div>
|
||||
</div>
|
||||
<div class="spk-fach">
|
||||
<div class="spk-kopf"><h3 class="spk-ueberschrift">Zellspreizung und Temperatur</h3></div>
|
||||
<div class="spk-diagramm" data-diagramm="bmu"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
const $ = s => this.behaelter.querySelector(s);
|
||||
this.dom = {
|
||||
meldung: $(".spk-meldung"), kennzahlen: $(".spk-kennzahlen"), turm: $(".spk-turm"),
|
||||
stand: $(".spk-stand"), detail: $(".spk-detail"),
|
||||
soc: $('[data-diagramm="soc"]'), bmu: $('[data-diagramm="bmu"]'),
|
||||
};
|
||||
this.behaelter.querySelector(".spk-zeitraum").addEventListener("click", ev => {
|
||||
const b = ev.target.closest("[data-stunden]");
|
||||
if (!b) return;
|
||||
this.zeitraum = Number(b.dataset.stunden);
|
||||
b.parentElement.querySelectorAll("button").forEach(x => x.classList.toggle("aktiv", x === b));
|
||||
this.verlaufHolen();
|
||||
});
|
||||
this.dom.turm.addEventListener("click", ev => {
|
||||
const b = ev.target.closest("[data-modul]");
|
||||
if (!b) return;
|
||||
this.modul = Number(b.dataset.modul);
|
||||
this.zuletzt.detail = null;
|
||||
this.zuletzt.turm = null;
|
||||
if (this.stand) this.anzeigen();
|
||||
});
|
||||
if (this.stand) this.anzeigen();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- Aktualisieren */
|
||||
aktualisieren(daten) {
|
||||
const m = leser(daten);
|
||||
const b = "solarManager/byd/";
|
||||
const bmu = m.da(b + "stand");
|
||||
const s = {
|
||||
bmu,
|
||||
socWr: m.zahl("solarManager/SOC"),
|
||||
pWr: -m.zahl("solarManager/P_Akku"), // positiv = laden
|
||||
grenze: m.zahl("solarManager/crgMaxPct"),
|
||||
};
|
||||
if (bmu) {
|
||||
Object.assign(s, {
|
||||
ok: m.zahl(b + "ok") > 0,
|
||||
stand: m.zahl(b + "stand"),
|
||||
soc: m.zahl(b + "soc"), soh: m.zahl(b + "soh"),
|
||||
spannung: m.zahl(b + "spannung"), strom: m.zahl(b + "strom"),
|
||||
tMin: m.zahl(b + "tMin"), tMax: m.zahl(b + "tMax"),
|
||||
zelleMin: m.zahl(b + "zelleMin"), zelleMax: m.zahl(b + "zelleMax"),
|
||||
zelleMinNr: m.zahl(b + "zelleMinNr"), zelleMaxNr: m.zahl(b + "zelleMaxNr"),
|
||||
spreizung: m.zahl(b + "spreizung"), ausgleich: m.zahl(b + "ausgleich"),
|
||||
fehler: m.zahl(b + "fehler"), fehlertext: m.text(b + "fehlertext"),
|
||||
geladen: m.zahl(b + "geladen"), entladen: m.zahl(b + "entladen"),
|
||||
module: m.zahl(b + "module"), firmware: m.text(b + "firmware"), seriennummer: m.text(b + "seriennummer"),
|
||||
zellenRoh: m.text(b + "zellen"), tempRoh: m.text(b + "temperaturen"),
|
||||
});
|
||||
if (s.zellenRoh !== (this.stand && this.stand.zellenRoh)) {
|
||||
s.zellen = m.json(b + "zellen") || [];
|
||||
s.temps = m.json(b + "temperaturen") || [];
|
||||
} else {
|
||||
s.zellen = this.stand.zellen;
|
||||
s.temps = this.stand.temps;
|
||||
}
|
||||
}
|
||||
this.stand = s;
|
||||
if (this.dom) this.anzeigen();
|
||||
}
|
||||
|
||||
anzeigen() {
|
||||
const s = this.stand;
|
||||
const alt = s.bmu && Date.now() / 1000 - s.stand > VERALTET_S;
|
||||
|
||||
// Meldung oben
|
||||
let meldung = "";
|
||||
if (!s.bmu) meldung = "Von der Batteriesteuerung (BMU) kommen keine Daten – gezeigt wird, was der Wechselrichter weiß.";
|
||||
else if (alt) meldung = "Die Werte der BMU sind veraltet: letzter Stand " + uhrzeit(new Date(s.stand * 1000)) + " Uhr.";
|
||||
else if (s.fehler) meldung = "Die BMU meldet: " + s.fehlertext;
|
||||
if (this.dom.meldung.textContent !== meldung) {
|
||||
this.dom.meldung.textContent = meldung;
|
||||
this.dom.meldung.hidden = !meldung;
|
||||
this.dom.meldung.classList.toggle("spk-fehler", !!(s.bmu && s.fehler && !alt));
|
||||
}
|
||||
|
||||
this.kennzahlenZeigen(s);
|
||||
|
||||
// Laden/Entladen als Bewegung im Turm
|
||||
this.dom.turm.classList.toggle("spk-laden", s.pWr > 50);
|
||||
this.dom.turm.classList.toggle("spk-entladen", s.pWr < -50);
|
||||
|
||||
if (s.bmu && s.zellen && s.zellen.length) {
|
||||
const sig = s.zellenRoh + "|" + s.tempRoh + "|" + this.modul;
|
||||
if (this.zuletzt.turm !== sig) { this.zuletzt.turm = sig; this.turmZeigen(s); }
|
||||
const stand = "Messung " + uhrzeit(new Date(s.stand * 1000));
|
||||
if (this.dom.stand.textContent !== stand) this.dom.stand.textContent = stand;
|
||||
} else if (this.zuletzt.turm !== "leer") {
|
||||
this.zuletzt.turm = "leer";
|
||||
this.dom.turm.innerHTML = '<p class="spk-leer">Ohne Daten der BMU gibt es keine Werte je Modul und Zelle.</p>';
|
||||
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 => `
|
||||
<div class="spk-kachel ${k.klasse || ""}"${k.titelLang ? ` title="${schutz(k.titelLang)}"` : ""}>
|
||||
<span class="spk-kachel-titel"><i>${k.zeichen}</i>${k.titel}</span>
|
||||
<b>${schutz(k.wert)}</b>
|
||||
<small>${schutz(k.neben)}</small>
|
||||
${k.balken !== undefined ? `<span class="spk-kachel-balken"><span style="width:${Math.max(0, Math.min(100, k.balken))}%"></span></span>` : ""}
|
||||
</div>`).join("");
|
||||
if (this.zuletzt.kennzahlen === html) return;
|
||||
// Beim ersten Mal ganz, danach Kachel für Kachel - der Balken soll gleiten, nicht springen.
|
||||
const alte = this.dom.kennzahlen.children;
|
||||
if (alte.length !== kacheln.length) {
|
||||
this.dom.kennzahlen.innerHTML = html;
|
||||
} else {
|
||||
const neu = document.createElement("div");
|
||||
neu.innerHTML = html;
|
||||
[...neu.children].forEach((k, i) => {
|
||||
const a = alte[i];
|
||||
if (a.className !== k.className) a.className = k.className;
|
||||
["b", "small", ".spk-kachel-titel"].forEach(sel => {
|
||||
if (a.querySelector(sel).innerHTML !== k.querySelector(sel).innerHTML) a.querySelector(sel).innerHTML = k.querySelector(sel).innerHTML;
|
||||
});
|
||||
const bal = k.querySelector(".spk-kachel-balken span");
|
||||
if (bal) a.querySelector(".spk-kachel-balken span").style.width = bal.style.width;
|
||||
});
|
||||
}
|
||||
this.zuletzt.kennzahlen = html;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Turm */
|
||||
turmZeigen(s) {
|
||||
const z = s.zellen, t = s.temps || [];
|
||||
const module = Math.max(1, Math.round(z.length / ZELLEN_JE_MODUL));
|
||||
const gueltig = z.filter(v => v > 2000 && v < 4500);
|
||||
const mittel = gueltig.reduce((a, v) => a + v, 0) / (gueltig.length || 1);
|
||||
const skala = Math.max(3, ...gueltig.map(v => Math.abs(v - mittel)));
|
||||
this.mittel = mittel; this.skala = skala;
|
||||
|
||||
const daten = [];
|
||||
for (let i = 0; i < module; i++) {
|
||||
const zellen = z.slice(i * ZELLEN_JE_MODUL, (i + 1) * ZELLEN_JE_MODUL);
|
||||
const temps = t.slice(i * FUEHLER_JE_MODUL, (i + 1) * FUEHLER_JE_MODUL);
|
||||
daten.push({
|
||||
zellen, temps,
|
||||
min: Math.min(...zellen), max: Math.max(...zellen),
|
||||
tMin: temps.length ? Math.min(...temps) : null, tMax: temps.length ? Math.max(...temps) : null,
|
||||
});
|
||||
}
|
||||
this.moduldaten = daten;
|
||||
if (this.modul === null || this.modul >= module) {
|
||||
// Ohne Wahl das Modul mit der größten Spreizung - dort gibt es am meisten zu sehen.
|
||||
let best = 0;
|
||||
daten.forEach((d, i) => { if (d.max - d.min > daten[best].max - daten[best].min) best = i; });
|
||||
this.modulAuto = best;
|
||||
}
|
||||
const gewaehlt = this.modul !== null && this.modul < module ? this.modul : this.modulAuto;
|
||||
|
||||
// Oben das Modul 1 wie im Turm selbst: BMU oben, Module darunter.
|
||||
const kopf = `
|
||||
<div class="spk-bmu">
|
||||
<span class="spk-led${s.ok ? " an" : ""}" title="${s.ok ? "BMU liefert Daten" : "keine aktuellen Daten"}"></span>
|
||||
<b>BMU</b><span>${schutz(s.seriennummer || "")}</span>
|
||||
</div>`;
|
||||
const reihen = daten.map((d, i) => `
|
||||
<button type="button" class="spk-modul${i === gewaehlt ? " gewaehlt" : ""}" data-modul="${i}" style="--i:${i}"
|
||||
aria-pressed="${i === gewaehlt}" aria-label="Modul ${i + 1}: ${komma(d.min / 1000, 3)} bis ${komma(d.max / 1000, 3)} Volt">
|
||||
<span class="spk-nr">M${i + 1}</span>
|
||||
<span class="spk-zellen">${d.zellen.map((v, j) => {
|
||||
const a = (v - mittel) / skala;
|
||||
return `<i style="--h:${(52 + a * 40).toFixed(0)}%;--f:${zellFarbe(a)};--j:${j}" title="Zelle ${i * ZELLEN_JE_MODUL + j + 1}: ${tausend(v)} mV"></i>`;
|
||||
}).join("")}</span>
|
||||
<span class="spk-werte"><b>${komma(d.min / 1000, 3)}–${komma(d.max / 1000, 3)} V</b><small>${d.tMin !== null ? d.tMin + "–" + d.tMax + " °C" : ""}</small></span>
|
||||
</button>`).join("");
|
||||
this.dom.turm.innerHTML = kopf + reihen + '<div class="spk-sockel"></div>';
|
||||
this.detailZeigen(s, gewaehlt);
|
||||
}
|
||||
|
||||
detailZeigen(s, nr) {
|
||||
const d = this.moduldaten[nr];
|
||||
const spr = d.max - d.min;
|
||||
const mittel = this.mittel;
|
||||
const lo = Math.min(d.min, mittel) - 3, hi = Math.max(d.max, mittel) + 3;
|
||||
const B = 320, H = 150, L = 38, U = 20;
|
||||
const X = j => L + j * ((B - L - 4) / d.zellen.length);
|
||||
const bw = (B - L - 4) / d.zellen.length - 3;
|
||||
const Y = v => 8 + (hi - v) / (hi - lo) * (H - U - 8);
|
||||
const stufen = [];
|
||||
const schritt = Math.max(1, Math.ceil((hi - lo) / 4));
|
||||
for (let v = Math.ceil(lo / schritt) * schritt; v <= hi; v += schritt) stufen.push(v);
|
||||
|
||||
const balken = d.zellen.map((v, j) => {
|
||||
const a = (v - mittel) / this.skala;
|
||||
const nrGesamt = nr * ZELLEN_JE_MODUL + j + 1;
|
||||
const markiert = nrGesamt === s.zelleMinNr || nrGesamt === s.zelleMaxNr;
|
||||
return `<rect class="spk-zelle${markiert ? " markiert" : ""}" x="${X(j).toFixed(1)}" y="${Y(v).toFixed(1)}" width="${bw.toFixed(1)}"
|
||||
height="${(H - U - Y(v)).toFixed(1)}" rx="2" style="fill:${zellFarbe(a)};--j:${j}"><title>Zelle ${nrGesamt}: ${tausend(v)} mV</title></rect>`;
|
||||
}).join("");
|
||||
|
||||
const temps = d.temps.map((t, j) => `<span style="--f:${tempFarbe(t)}" title="Fühler ${nr * FUEHLER_JE_MODUL + j + 1}">${t}°</span>`).join("");
|
||||
const html = `
|
||||
<div class="spk-kopf"><h3 class="spk-ueberschrift">Modul ${nr + 1}</h3>
|
||||
<span class="spk-pille ${spr > 20 ? "warn" : "ok"}">${spr > 20 ? "ungleich" : "ausgeglichen"}</span></div>
|
||||
<div class="spk-gross"><b>${spr} mV</b><span>Spreizung der ${d.zellen.length} Zellen</span></div>
|
||||
<svg class="spk-zellchart" viewBox="0 0 ${B} ${H}" role="img" aria-label="Zellspannungen Modul ${nr + 1}">
|
||||
${stufen.map(v => `<line x1="${L}" x2="${B}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}" class="spk-gitter"/><text x="${L - 5}" y="${(Y(v) + 3).toFixed(1)}" class="spk-achse" text-anchor="end">${v}</text>`).join("")}
|
||||
<line x1="${L}" x2="${B}" y1="${Y(mittel).toFixed(1)}" y2="${Y(mittel).toFixed(1)}" class="spk-mittel"/>
|
||||
${balken}
|
||||
<text x="${X(0) + bw / 2}" y="${H - 5}" class="spk-achse" text-anchor="middle">1</text>
|
||||
<text x="${X(d.zellen.length - 1) + bw / 2}" y="${H - 5}" class="spk-achse" text-anchor="middle">${d.zellen.length}</text>
|
||||
</svg>
|
||||
<div class="spk-notiz">Zellspannung in mV · gestrichelt: Mittel aller Zellen · weiß umrandet: tiefste und höchste Zelle der Batterie</div>
|
||||
<div class="spk-fuehler" aria-label="Temperaturfühler">${temps}</div>
|
||||
<dl class="spk-paare">
|
||||
<dt>Modulspannung</dt><dd>${komma(d.zellen.reduce((a, v) => a + v, 0) / 1000)} V</dd>
|
||||
<dt>Zellen</dt><dd>${komma(d.min / 1000, 3)} – ${komma(d.max / 1000, 3)} V</dd>
|
||||
<dt>Temperatur</dt><dd>${d.tMin !== null ? d.tMin + " – " + d.tMax + " °C" : "–"}</dd>
|
||||
<dt>Ganze Batterie</dt><dd>Zelle ${s.zelleMinNr} am tiefsten, ${s.zelleMaxNr} am höchsten</dd>
|
||||
</dl>`;
|
||||
this.dom.detail.innerHTML = html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Verlauf */
|
||||
async verlaufHolen() {
|
||||
try {
|
||||
const antwort = await fetch("ajax/speicher.php?was=verlauf&stunden=" + this.zeitraum, { cache: "no-store" });
|
||||
const d = await antwort.json();
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
this.verlauf = d;
|
||||
this.diagrammeZeichnen(true);
|
||||
} catch (e) {
|
||||
if (this.dom) {
|
||||
this.dom.soc.innerHTML = this.dom.bmu.innerHTML = '<p class="spk-leer">Verlauf nicht abrufbar: ' + schutz(e.message) + "</p>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<p class="spk-leer">Noch keine Werte der BMU in diesem Zeitraum – sie werden seit 15.09.2026 alle 5 Minuten gespeichert.</p>';
|
||||
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 class="spk-verlauf${o.neu ? " neu" : ""}" viewBox="0 0 ${B} ${H}" width="${B}" height="${H}">`;
|
||||
gitterWerte("links").forEach(v => {
|
||||
const y = Y("links", v).toFixed(1);
|
||||
svg += `<line x1="${L}" x2="${B - R}" y1="${y}" y2="${y}" class="spk-gitter"/><text x="${L - 6}" y="${+y + 3}" class="spk-achse" text-anchor="end">${zahl(v)}</text>`;
|
||||
});
|
||||
gitterWerte("rechts").forEach(v => {
|
||||
svg += `<text x="${B - R + 6}" y="${Y("rechts", v) + 3}" class="spk-achse">${zahl(v)}</text>`;
|
||||
});
|
||||
svg += `<text x="${L - 6}" y="9" class="spk-achse spk-einheit" text-anchor="end">${ax.links.einheit}</text>`;
|
||||
svg += `<text x="${B - R + 6}" y="9" class="spk-achse spk-einheit">${ax.rechts.einheit}</text>`;
|
||||
ticks.forEach(([t, s]) => { svg += `<text x="${X(t).toFixed(1)}" y="${H - 6}" class="spk-achse" text-anchor="middle">${s}</text>`; });
|
||||
|
||||
o.reihen.forEach((r, i) => {
|
||||
if (r.flaeche) {
|
||||
const nullY = Y(r.achse, Math.max(ax[r.achse].min, Math.min(ax[r.achse].max, 0)));
|
||||
svg += `<defs><clipPath id="${id}-o${i}"><rect x="0" y="0" width="${B}" height="${nullY}"/></clipPath>
|
||||
<clipPath id="${id}-u${i}"><rect x="0" y="${nullY}" width="${B}" height="${H}"/></clipPath></defs>`;
|
||||
const d = pfad(r, true);
|
||||
svg += `<path d="${d}" clip-path="url(#${id}-o${i})" class="spk-flaeche" style="fill:${r.farbe}"/>`;
|
||||
if (r.farbeNegativ) svg += `<path d="${d}" clip-path="url(#${id}-u${i})" class="spk-flaeche" style="fill:${r.farbeNegativ}"/>`;
|
||||
}
|
||||
svg += `<path d="${pfad(r, false)}" class="spk-linie" pathLength="1" style="stroke:${r.farbe};--i:${i}"/>`;
|
||||
});
|
||||
svg += `<line class="spk-zeiger" y1="${O}" y2="${H - U}" x1="-10" x2="-10"/>`;
|
||||
svg += `<rect x="${L}" y="0" width="${B - L - R}" height="${H}" fill="transparent" class="spk-fang"/></svg>`;
|
||||
svg += `<div class="spk-tipp" hidden></div>`;
|
||||
svg += `<div class="spk-reihen">${o.reihen.map(r => `<span><i style="background:${r.farbe}"></i>${r.name}</span>`).join("")}</div>`;
|
||||
ziel.innerHTML = svg;
|
||||
|
||||
// Überfahren: nächster Zeitpunkt, alle Reihen ablesen.
|
||||
const el = ziel.querySelector("svg"), zeiger = el.querySelector(".spk-zeiger"), tipp = ziel.querySelector(".spk-tipp");
|
||||
const zeigen = ev => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const x = (ev.clientX - r.left) / r.width * B;
|
||||
const t = o.von + (x - L) / (B - L - R) * (o.bis - o.von);
|
||||
const zeilen = o.reihen.map(reihe => {
|
||||
let best = null;
|
||||
for (const w of reihe.werte) if (!best || Math.abs(w[0] - t) < Math.abs(best[0] - t)) best = w;
|
||||
return best && Math.abs(best[0] - t) < 3 * 3600 ? { reihe, w: best } : null;
|
||||
}).filter(Boolean);
|
||||
if (!zeilen.length) { tipp.hidden = true; zeiger.setAttribute("x1", -10); zeiger.setAttribute("x2", -10); return; }
|
||||
const tx = X(zeilen[0].w[0]);
|
||||
zeiger.setAttribute("x1", tx); zeiger.setAttribute("x2", tx);
|
||||
const d = new Date(zeilen[0].w[0] * 1000);
|
||||
tipp.innerHTML = `<b>${d.getDate()}.${d.getMonth() + 1}. ${uhrzeit(d)}</b>` + zeilen.map(z =>
|
||||
`<span><i style="background:${z.reihe.farbe}"></i>${z.reihe.name}<em>${z.reihe.format(z.w[1])}</em></span>`).join("");
|
||||
tipp.hidden = false;
|
||||
const links = tx / B * r.width;
|
||||
tipp.style.left = Math.min(r.width - tipp.offsetWidth - 4, Math.max(4, links + 12)) + "px";
|
||||
};
|
||||
el.addEventListener("pointermove", zeigen);
|
||||
el.addEventListener("pointerleave", () => { tipp.hidden = true; zeiger.setAttribute("x1", -10); zeiger.setAttribute("x2", -10); });
|
||||
}
|
||||
|
||||
window.Speicher = Speicher;
|
||||
})();
|
||||
@@ -55,10 +55,12 @@
|
||||
<div class="col-xxl-12 col-xl-12 d-flex align-items-stretch">
|
||||
<div class="card p-0 mb-4" style="width:100%" id="energieflussKarte">
|
||||
<div class="card-header pb-1 pt-1 d-flex align-items-center">
|
||||
<h3 class="card-title me-auto">Realtime</h3>
|
||||
<h3 class="card-title me-auto d-none d-sm-block">Realtime</h3>
|
||||
<span class="me-auto d-sm-none"></span>
|
||||
<div class="enf-umschalter me-2" role="tablist" aria-label="Ansicht">
|
||||
<button type="button" class="btn btn-sm active" role="tab" aria-selected="true" data-enf-ansicht="fluss">Übersicht</button>
|
||||
<button type="button" class="btn btn-sm" role="tab" aria-selected="false" data-enf-ansicht="anlage">Anlage</button>
|
||||
<button type="button" class="btn btn-sm" role="tab" aria-selected="false" data-enf-ansicht="speicher">Speicher</button>
|
||||
</div>
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-tool" href="index.php?action=settings#energiefluss" title="Übersicht einstellen">
|
||||
@@ -70,6 +72,8 @@
|
||||
<div class="enf-ansicht enf-behaelter" data-enf-ansicht-flaeche="fluss" id="energiefluss"></div>
|
||||
<!-- Lageplan der PV-Anlage: js/solar/anlage.js aus anlageKatalog.js -->
|
||||
<div class="enf-ansicht" data-enf-ansicht-flaeche="anlage" id="anlage" hidden></div>
|
||||
<!-- Batterie bis zur Zelle: js/solar/speicher.js, Verlauf aus ajax/speicher.php -->
|
||||
<div class="enf-ansicht" data-enf-ansicht-flaeche="speicher" id="speicher" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user