diff --git a/README.md b/README.md
index 931b317..555aeab 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`, `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/#` |
+| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `anlageKatalog.js`, `anlage.js`, `speicher.js`, `energieflussKatalog.js`, `energiefluss.js`, `bewaesserungAnzeige.js`, `ladefenster.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` | — |
@@ -433,7 +433,7 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben.
| `skodaCmd.php` | `POST befehl=…` | Laden/Klima/Lüftung am Fahrzeug |
| `tile.php` | `?z=&x=&y=` | Kartenkachel aus dem eigenen Zwischenspeicher, sonst einmalig von OSM |
| `logs.php` | `?action=list\|read` | Zustand und Zeilen der Logdateien |
-| `carEG.php` `carOG.php` `carSteuerung.php` `carForm.php` `heater.php` `watering.php` `tahoma.php` | | Wallboxen, Heizstab, Bewässerung, Tahoma-Bedienung |
+| `carEG.php` `carOG.php` `carSteuerung.php` `carForm.php` `heater.php` `watering.php` `tahoma.php` | | Wallboxen, Heizstab, Bewässerung, Tahoma-Bedienung. Das Ladefenster (`carForm.php` + `js/solar/ladefenster.js`) liest live aus MQTT und schreibt jede Änderung sofort als JSON an `carSteuerung.php` (Aktionen `frc`, `modus`, `strom`, `plan`) |
| `chartData.php` | (kein Endpunkt) | gemeinsame Bausteine der Chart-Endpunkte |
| `phpMQTT.php` | (Bibliothek) | MQTT aus PHP heraus, für Kommandos |
diff --git a/ajax/carForm.php b/ajax/carForm.php
index 14a874c..0cbbd40 100644
--- a/ajax/carForm.php
+++ b/ajax/carForm.php
@@ -1,94 +1,93 @@
-
-
+
">
+
+
+
+
+
+
= htmlspecialchars($auto["name"]) ?>
+
–
+
+
+
+
+
+
+
+
+
+
+
Wie laden?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0 %100 %
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Höchstens
+
+
+
+
+
+
+
+
Änderungen gelten sofort · = htmlspecialchars($box["titel"]) ?>
+
-
-
diff --git a/ajax/carSteuerung.php b/ajax/carSteuerung.php
index 8a3f74b..9aba742 100644
--- a/ajax/carSteuerung.php
+++ b/ajax/carSteuerung.php
@@ -2,86 +2,107 @@
/**
* Ladesteuerung beider Wallboxen.
*
- * carEG.php und carOG.php setzen nur $wallbox und $formId und binden diese
- * Datei ein - vorher standen hier zwei fast gleiche Dateien, die sich nur im
- * Transportweg unterschieden. Was die beiden Wallboxen wirklich trennt,
- * steht in restricted/wallboxen.php.
+ * carEG.php und carOG.php setzen nur $wallbox und binden diese Datei ein.
+ * Was die beiden Wallboxen wirklich trennt, steht in restricted/wallboxen.php.
*
- * Ohne POST wird das Formular ausgeliefert (carForm.php), mit POST wird
- * geschaltet und nichts zurueckgegeben - so war es vorher auch, das
- * JavaScript im Modal erwartet es so.
+ * GET das Ladefenster (carForm.php)
+ * POST {"aktion": "frc", "wert": 0|1|2} Freigabe: 0 Modus entscheidet, 1 gesperrt, 2 sofort
+ * POST {"aktion": "modus", "modus": "sofort"|"sonne"}
+ * POST {"aktion": "strom", "ampere": 6..16}
+ * POST {"aktion": "plan", "wh": 1.., "zeit": "HH:MM"} Ladeplanung: Energie bis Uhrzeit
+ *
+ * Seit September 2026 gilt jede Änderung sofort (js/solar/ladefenster.js) -
+ * vorher gingen Modus, Strom und Planung gemeinsam über "Save changes".
+ * Antwort ist JSON: {"ok": true} oder {"error": "..."}. Ob die Wallbox den
+ * Wert genommen hat, meldet sie über MQTT; das Fenster zeigt es beim
+ * nächsten Takt.
*/
require_once(__DIR__ . "/../helper.php");
require_once(__DIR__ . "/../restricted/wallboxen.php");
-// lmo, der Betriebsmodus der Wallbox. Die Namen links kommen aus den
-// Radiobuttons in carForm.php.
+// lmo, der Betriebsmodus - beide Wallboxen nutzen dieselben Nummern.
const CAR_MODI = [
- "default" => 3, // Immer laden
- "eco" => 4, // Ueberschuss (Awattar)
- "NextTrip" => 5, // Ladeplanung (AutomaticStop)
+ "sofort" => 3, // Default: volle Leistung, auch aus dem Netz
+ "sonne" => 4, // Eco (Awattar): Überschuss
+ "plan" => 5, // Next Trip (AutomaticStop): Energie bis Uhrzeit
];
-$close = 0;
+function ladeAntwort($daten, $code = 200)
+{
+ http_response_code($code);
+ header("Content-Type: application/json; charset=utf-8");
+ echo json_encode($daten, JSON_UNESCAPED_UNICODE);
+ exit;
+}
-if (checkLogin()) {
- $konf = wallboxen()[$wallbox];
-
- if (isset($_POST["evStart/Stop"])) {
- // frc: 2 gibt das Laden frei, 1 sperrt es, 0 waere "die Wallbox
- // entscheidet".
- if ($_POST["evStart/Stop"] === "Laden starten") {
- wallboxSetzen($wallbox, ["frc" => 2]);
- } elseif ($_POST["evStart/Stop"] === "Laden stoppen") {
- wallboxSetzen($wallbox, ["frc" => 1]);
- }
- $close = 1;
-
- } elseif (isset($_POST["evMode"])) {
- $modus = CAR_MODI[$_POST["evMode"]] ?? null;
-
- if ($modus !== null) {
- // Der Schieberegler liefert Prozent zusaetzlich benoetigter
- // Ladung; die Wallbox will Wattstunden. Der Faktor haengt am
- // Auto und steht deshalb in restricted/config.php.
- $prozent = min(100, max(0, intval($_POST["fte"] ?? 0)));
- $energie = $prozent * wallboxWhProProzent($wallbox);
-
- $strom = min(16, max(6, intval($_POST["evAmp"] ?? 6)));
-
- // Das Formularfeld ist ein
, liefert also
- // HH:MM. Alles andere gilt als "keine Ladeplanung" und laesst
- // Zielzeit und Zielenergie unangetastet. Geprueft wird hier
- // weiterhin streng: der Wert ging frueher in eine Kommandozeile
- // und liess sich mit "12:00; befehl" missbrauchen.
- $sekunden = null;
- if (isset($_POST["ftt"])
- && preg_match('/^([01][0-9]|2[0-3]):([0-5][0-9])$/', $_POST["ftt"], $teile)) {
- $sekunden = intval($teile[1]) * 3600 + intval($teile[2]) * 60;
- }
-
- // Reihenfolge zaehlt: Ziel zuerst, Modus zuletzt.
- $werte = [];
- if (!empty($konf["uhrStellen"])) {
- $werte["utc"] = gmdate("Y-m-d\TH:i:s") . ".000";
- }
- if ($sekunden !== null && $energie > 0) {
- $werte["fte"] = $energie;
- $werte["ftt"] = $sekunden;
- }
- $werte["amp"] = $strom;
- $werte["fup"] = true;
- $werte["lmo"] = $modus;
-
- wallboxSetzen($wallbox, $werte);
- }
- $close = 1;
+if (!checkLogin()) {
+ if ($_SERVER["REQUEST_METHOD"] === "POST") {
+ ladeAntwort(["error" => "Nicht angemeldet."], 403);
}
-} else {
- $close = 1;
+ exit;
}
-if (!$close) {
+if ($_SERVER["REQUEST_METHOD"] !== "POST") {
include __DIR__ . "/carForm.php";
+ exit;
}
+
+$konf = wallboxen()[$wallbox];
+$body = json_decode(file_get_contents("php://input"), true);
+if (!is_array($body)) {
+ ladeAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
+}
+
+switch ($body["aktion"] ?? "") {
+ case "frc":
+ $wert = intval($body["wert"] ?? -1);
+ if (!in_array($wert, [0, 1, 2], true)) {
+ ladeAntwort(["error" => "Unbekannte Freigabe."], 400);
+ }
+ $werte = ["frc" => $wert];
+ break;
+
+ case "modus":
+ $modus = CAR_MODI[$body["modus"] ?? ""] ?? null;
+ if ($modus === null || $modus === CAR_MODI["plan"]) {
+ // Die Planung braucht Energie und Uhrzeit - siehe "plan".
+ ladeAntwort(["error" => "Unbekannter Modus."], 400);
+ }
+ $werte = ["fup" => true, "lmo" => $modus];
+ break;
+
+ case "strom":
+ $werte = ["amp" => min(16, max(6, intval($body["ampere"] ?? 6)))];
+ break;
+
+ case "plan":
+ // Die Uhrzeit ging früher in eine Kommandozeile und ließ sich mit
+ // "12:00; befehl" missbrauchen - deshalb bleibt die Prüfung streng.
+ if (!preg_match('/^([01][0-9]|2[0-3]):([0-5][0-9])$/', (string)($body["zeit"] ?? ""), $teile)) {
+ ladeAntwort(["error" => "Die Uhrzeit ist ungültig."], 400);
+ }
+ $wh = intval($body["wh"] ?? 0);
+ $max = (int) round(fahrzeuge()[$wallbox]["akkuKWh"] * 1000);
+ if ($wh < 100 || $wh > $max) {
+ ladeAntwort(["error" => "Die Energiemenge liegt außerhalb dessen, was der Akku fasst."], 400);
+ }
+ // Reihenfolge zählt: Uhr, Ziel, dann der Modus.
+ $werte = [];
+ if (!empty($konf["uhrStellen"])) {
+ $werte["utc"] = gmdate("Y-m-d\TH:i:s") . ".000";
+ }
+ $werte["fte"] = $wh;
+ $werte["ftt"] = intval($teile[1]) * 3600 + intval($teile[2]) * 60;
+ $werte["fup"] = true;
+ $werte["lmo"] = CAR_MODI["plan"];
+ break;
+
+ default:
+ ladeAntwort(["error" => "Unbekannte Aktion."], 400);
+}
+
+if (!wallboxSetzen($wallbox, $werte)) {
+ ladeAntwort(["error" => "Der MQTT-Broker ist nicht erreichbar."], 502);
+}
+ladeAntwort(["ok" => true]);
diff --git a/css/solar.css b/css/solar.css
index 2917e97..79c5462 100644
--- a/css/solar.css
+++ b/css/solar.css
@@ -3949,6 +3949,111 @@ body.anzeige-ohne-maus * { cursor: none !important; }
.anzeige-ruhig .enf.enf-pause .enf-strom { animation: none !important; }
.anzeige-ruhig .enf .enf-koerper { transition: none; }
+/* ==========================================================================
+ Ladefenster der Wallboxen (ajax/carForm.php, js/solar/ladefenster.js).
+ Oben der Zustand, dann ein Knopf, darunter Modus, Planung, Strom.
+ ========================================================================== */
+.lade { display: grid; gap: 1rem; padding: 1rem 1.1rem 1.1rem; --lade-wallbox: #60afbf; }
+.lade .lade-links,
+.lade .lade-rechts { display: grid; gap: .9rem; align-content: start; min-width: 0; }
+
+.lade .lade-status { display: grid; grid-template-columns: 7.5rem minmax(0, 1fr); gap: .9rem; align-items: center; }
+.lade .lade-ring { position: relative; width: 7.5rem; height: 7.5rem; }
+.lade .lade-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
+.lade .lade-spur { fill: none; stroke: #2b3237; stroke-width: 12; }
+.lade .lade-ist { fill: none; stroke: #6cbe58; stroke-width: 12; stroke-linecap: round; stroke-dasharray: 0 100; transition: stroke-dasharray .7s cubic-bezier(.2, .7, .2, 1); }
+.lade .lade-ziel { fill: none; stroke: var(--lade-wallbox); stroke-width: 12; opacity: .45; stroke-dasharray: 0 100; transition: stroke-dasharray .7s, stroke-dashoffset .7s; }
+.lade .lade-ring-mitte { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; }
+.lade .lade-ring-mitte b { font-size: 1.55rem; font-weight: 600; line-height: 1; font-variant-numeric: tabular-nums; }
+.lade .lade-ring-mitte span { font-size: .7rem; color: var(--lade-wallbox); font-weight: 600; margin-top: .25rem; }
+.lade .lade-text { display: grid; gap: .25rem; min-width: 0; }
+.lade .lade-auto { font-size: .8rem; color: var(--ton-schrift-2, #9aa6ad); display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
+.lade .lade-pille { font-size: .68rem; font-weight: 600; border-radius: 99px; padding: .05rem .5rem; background: rgba(255, 255, 255, .06); color: var(--ton-schrift-2, #9aa6ad); }
+.lade .lade-pille:empty { display: none; }
+.lade .lade-pille.an { color: #6cbe58; background: rgba(108, 190, 88, .14); }
+.lade .lade-gross { font-size: 1.45rem; font-weight: 600; font-variant-numeric: tabular-nums; }
+.lade .lade-gross small { font-size: .8rem; font-weight: 400; color: var(--ton-schrift-2, #9aa6ad); margin-left: .35rem; }
+.lade .lade-zeile { font-size: .82rem; color: var(--ton-schrift-2, #9aa6ad); }
+.lade .lade-zeile:empty { display: none; }
+
+.lade .lade-start {
+ all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: .6rem;
+ height: 3.1rem; border-radius: .9rem; font-weight: 600; font-size: 1rem;
+ background: linear-gradient(180deg, #6cbe58, #57a646); color: #0f1a0c; box-shadow: 0 .35rem 1rem rgba(108, 190, 88, .22);
+ transition: transform .15s, filter .2s;
+}
+.lade .lade-start:hover { filter: brightness(1.06); }
+.lade .lade-start:active { transform: scale(.98); }
+.lade .lade-start:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
+.lade .lade-start.laeuft { background: linear-gradient(180deg, #3a4147, #30363b); color: var(--ton-schrift, #e8ede9); box-shadow: none; border: 1px solid var(--ton-linie, rgba(138, 148, 158, .2)); }
+.lade .lade-start.aus { background: #2b3237; color: var(--ton-schrift-3, #6f7a81); box-shadow: none; cursor: default; }
+.lade .lade-start.aus .lade-punkt { display: none; }
+.lade .lade-start.gesperrt { background: linear-gradient(180deg, #e8a33a, #cf8f2c); color: #1d1406; box-shadow: 0 .35rem 1rem rgba(232, 163, 58, .2); }
+.lade .lade-punkt { width: .55rem; height: .55rem; border-radius: 50%; background: currentColor; }
+.lade .lade-start.laeuft .lade-punkt { background: #6cbe58; animation: lade-puls 1.6s infinite; }
+@keyframes lade-puls { 0% { box-shadow: 0 0 0 0 rgba(108, 190, 88, .6); } 70%, 100% { box-shadow: 0 0 0 .5rem rgba(108, 190, 88, 0); } }
+.lade .lade-erklaerung { padding: .5rem .7rem; border-radius: .6rem; background: rgba(96, 175, 191, .1); color: #bfe3ea; font-size: .8rem; }
+.lade .lade-erklaerung:empty { display: none; }
+
+.lade .lade-abschnitt { display: grid; gap: .45rem; }
+.lade h3 { margin: 0; font-size: .7rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--ton-schrift-2, #9aa6ad); }
+.lade .lade-modi { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .45rem; }
+.lade .lade-modus {
+ all: unset; box-sizing: border-box; cursor: pointer; display: grid; gap: .2rem; justify-items: start; padding: .6rem .65rem;
+ border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); border: 1.5px solid transparent; transition: border-color .2s, background .2s;
+}
+.lade .lade-modus:hover { background: #333b41; }
+.lade .lade-modus:focus-visible { outline: 2px solid var(--lade-wallbox); outline-offset: 1px; }
+.lade .lade-modus b { font-weight: 600; font-size: .88rem; }
+.lade .lade-modus > span:last-child { font-size: .7rem; color: var(--ton-schrift-2, #9aa6ad); line-height: 1.3; }
+.lade .lade-sym { width: 1.7rem; height: 1.7rem; border-radius: .5rem; display: grid; place-items: center; background: rgba(255, 255, 255, .06); }
+.lade .lade-sym svg { width: 1.1rem; height: 1.1rem; stroke: var(--c); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.lade .lade-modus[aria-checked="true"] { border-color: var(--c); background: color-mix(in srgb, var(--c) 12%, var(--ton-flaeche-2, #2b3237)); }
+.lade .lade-modus[aria-checked="true"] .lade-sym { background: color-mix(in srgb, var(--c) 25%, transparent); }
+
+.lade .lade-planung { display: grid; gap: .7rem; padding: .7rem .75rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); animation: lade-auf .3s cubic-bezier(.2, .7, .2, 1); }
+.lade .lade-planung[hidden] { display: none; }
+@keyframes lade-auf { from { opacity: 0; transform: translateY(-.35rem); } }
+.lade .lade-plan-zeile { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
+.lade .lade-plan-zeile label { font-size: .82rem; color: var(--ton-schrift-2, #9aa6ad); margin: 0; }
+.lade .lade-plan-zeile output { font-weight: 600; font-variant-numeric: tabular-nums; }
+.lade .lade-plan-zeile output small { color: var(--ton-schrift-2, #9aa6ad); font-weight: 400; margin-left: .3rem; }
+.lade input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 1.9rem; background: transparent; margin: 0; }
+.lade input[type="range"]::-webkit-slider-runnable-track { height: .5rem; border-radius: .25rem; background: linear-gradient(90deg, #6cbe58 0 var(--ist, 0%), var(--lade-wallbox) var(--ist, 0%) var(--wert, 0%), #394046 var(--wert, 0%)); }
+.lade input[type="range"]::-moz-range-track { height: .5rem; border-radius: .25rem; background: linear-gradient(90deg, #6cbe58 0 var(--ist, 0%), var(--lade-wallbox) var(--ist, 0%) var(--wert, 0%), #394046 var(--wert, 0%)); }
+.lade input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 1.6rem; height: 1.6rem; margin-top: -.55rem; border-radius: 50%; background: #fff; border: 4px solid var(--lade-wallbox); box-shadow: 0 .15rem .5rem rgba(0, 0, 0, .4); }
+.lade input[type="range"]::-moz-range-thumb { width: 1.1rem; height: 1.1rem; border-radius: 50%; background: #fff; border: 4px solid var(--lade-wallbox); }
+.lade input[type="range"]:focus-visible { outline: 2px solid var(--lade-wallbox); outline-offset: 4px; }
+.lade .lade-marken { display: flex; justify-content: space-between; font-size: .68rem; color: var(--ton-schrift-3, #6f7a81); margin-top: -.2rem; }
+.lade .lade-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
+.lade .lade-chip { all: unset; cursor: pointer; padding: .35rem .7rem; border-radius: 99px; background: #363d43; font-size: .8rem; font-weight: 500; font-variant-numeric: tabular-nums; }
+.lade .lade-chip[aria-pressed="true"] { background: var(--lade-wallbox); color: #0c1a1d; font-weight: 600; }
+.lade .lade-chip:focus-visible { outline: 2px solid var(--lade-wallbox); outline-offset: 1px; }
+.lade .lade-chip input { all: unset; width: 4.6ch; font: inherit; color: inherit; }
+
+.lade .lade-strom { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .3rem; padding: .25rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); }
+.lade .lade-strom button { all: unset; cursor: pointer; text-align: center; padding: .45rem .2rem; border-radius: .55rem; display: grid; gap: .05rem; }
+.lade .lade-strom button b { font-weight: 600; }
+.lade .lade-strom button span { font-size: .68rem; color: var(--ton-schrift-2, #9aa6ad); }
+.lade .lade-strom button[aria-checked="true"] { background: #363d43; box-shadow: inset 0 0 0 1.5px var(--lade-wallbox); }
+.lade .lade-strom button:focus-visible { outline: 2px solid var(--lade-wallbox); }
+.lade .lade-meldung { font-size: .76rem; color: var(--ton-schrift-3, #6f7a81); }
+.lade .lade-meldung.gut { color: #6cbe58; }
+.lade .lade-meldung.fehler { color: #e5624f; }
+@media (max-width: 420px) { .lade .lade-modus > span:last-child { display: none; } }
+
+/* Am 800er-Display zweispaltig ohne Scrollen. */
+.anzeige #modalEV .modal-dialog:has(.lade) { max-width: 46rem; }
+.anzeige .lade { grid-template-columns: 15rem minmax(0, 1fr); gap: 1rem; padding: .7rem .9rem; }
+.anzeige .lade .lade-status { grid-template-columns: 1fr; justify-items: center; text-align: center; gap: .5rem; }
+.anzeige .lade .lade-auto { justify-content: center; }
+.anzeige .lade .lade-modus > span:last-child { display: none; }
+.anzeige .lade .lade-rechts,
+.anzeige .lade .lade-links { gap: .6rem; }
+.anzeige #modalEV .modal-body:has(.lade) { max-height: none; overflow: visible; }
+.anzeige #modalEV .modal-content:has(.lade) .modal-footer { display: none; }
+.anzeige #modalEV .modal-content:has(.lade) .modal-header { padding: .45rem .9rem; }
+
/* --- 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 a196baa..7d4ae4a 100644
--- a/index.php
+++ b/index.php
@@ -101,7 +101,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", "js/solar/speicher.js",
- "js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/bewaesserungAnzeige.js", "js/solar/solarMQTT.js"]],
+ "js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/bewaesserungAnzeige.js", "js/solar/ladefenster.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"]],
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
diff --git a/js/solar/common.js b/js/solar/common.js
index 1f18161..bd9ccdf 100644
--- a/js/solar/common.js
+++ b/js/solar/common.js
@@ -74,8 +74,8 @@ async function getData(chart, url, options = {}) {
}
/**
- * Formular des Modals per POST abschicken.
- * Der Start/Stop-Knopf der Ladesteuerung schickt nur sich selbst.
+ * Formular des Modals per POST abschicken ("Save changes", heute nur noch
+ * der Heizstab). Das Ladefenster schreibt selbst, siehe ladefenster.js.
*/
function submitFormAjax(event) {
let xmlhttp = new XMLHttpRequest();
@@ -90,23 +90,19 @@ function submitFormAjax(event) {
};
let post = "";
- if (event.currentTarget.id == "evStart/Stop") {
- post = "evStart/Stop=" + event.currentTarget.innerHTML;
- } else {
- const form = document.getElementById('modalEV').querySelector('form');
- for (const element of Array.from(form.elements)) {
- if (!element.name) continue;
- if (element.type == "radio" || element.type == "checkbox") {
- if (element.checked) {
- post += element.name + "=" + encodeURIComponent(element.value) + "&";
- }
- } else if (element.type == "button") {
- if (!element.classList.contains("accordion-button")) {
- post += element.name + "=" + encodeURIComponent(element.innerHTML) + "&";
- }
- } else {
+ const form = document.getElementById('modalEV').querySelector('form');
+ for (const element of Array.from(form.elements)) {
+ if (!element.name) continue;
+ if (element.type == "radio" || element.type == "checkbox") {
+ if (element.checked) {
post += element.name + "=" + encodeURIComponent(element.value) + "&";
}
+ } else if (element.type == "button") {
+ if (!element.classList.contains("accordion-button")) {
+ post += element.name + "=" + encodeURIComponent(element.innerHTML) + "&";
+ }
+ } else {
+ post += element.name + "=" + encodeURIComponent(element.value) + "&";
}
}
diff --git a/js/solar/energieflussKatalog.js b/js/solar/energieflussKatalog.js
index 384f7fb..0a8aa57 100644
--- a/js/solar/energieflussKatalog.js
+++ b/js/solar/energieflussKatalog.js
@@ -271,8 +271,10 @@ const ENERGIEFLUSS_KATALOG = (function () {
const roh = m.text("solarManager/evPlug");
const stecker = isNaN(Number(roh)) ? (roh !== undefined && roh !== "no car") : Number(roh) > 0;
let neben = Math.round(soc) + " %";
- const rest = m.zahl("solarManager/carRemChrg");
- if (w >= 50 && rest > -1 && m.text("solarManager/carRemChrg") !== undefined) {
+ // Restladezeit in Minuten laut Fahrzeug (gatherSkodaData.py). Das
+ // frühere carRemChrg schickt der Manager nicht mehr.
+ const rest = m.zahl("solarManager/evRemChrgTime");
+ if (w >= 50 && rest > 0) {
const fertig = new Date(Date.now() + rest * 60000);
neben = "fertig " + String(fertig.getHours()).padStart(2, "0") + ":" + String(fertig.getMinutes()).padStart(2, "0");
}
diff --git a/js/solar/ladefenster.js b/js/solar/ladefenster.js
new file mode 100644
index 0000000..4cec120
--- /dev/null
+++ b/js/solar/ladefenster.js
@@ -0,0 +1,259 @@
+/* ==========================================================================
+ Ladefenster der Wallboxen (Gerüst: ajax/carForm.php, Schreiben:
+ ajax/carSteuerung.php).
+
+ ladefensterStarten(wurzel, url) nach dem Laden des Fensters
+
+ Liest jede Sekunde aus mqttData (von der Seite gefüllt) und schreibt jede
+ Änderung sofort. Was eben geändert wurde, gilt HALTEN ms lang lokal - so
+ springt die Anzeige nicht auf den alten Stand zurück, bevor die Wallbox
+ den neuen meldet.
+
+ Freigabe (frc) wird gelesen, nicht geraten: 0 = der Modus entscheidet,
+ 1 = gesperrt, 2 = lädt sofort. Der große Knopf bietet jeweils den
+ sinnvollen nächsten Schritt an.
+ ========================================================================== */
+
+(function () {
+ "use strict";
+
+ const HALTEN = 8000;
+
+ // Was die Wallboxen melden und wie es heißt. Geschrieben wird mit denselben
+ // Schlüsseln über restricted/wallboxen.php.
+ const WALLBOXEN = {
+ eg: { // Wattpilot, über wattpilot_bruecke.py im SolarManager
+ modus: "wattpilot/properties/lmo/state", modi: { Default: "sofort", Awattar: "sonne", AutomaticStop: "plan", 3: "sofort", 4: "sonne", 5: "plan" },
+ ampere: "wattpilot/properties/amp/state",
+ energie: "wattpilot/properties/fte/state", zeit: "wattpilot/properties/ftt/state",
+ frc: "wattpilot/properties/frc/state", frcWerte: { Neutral: 0, Off: 1, On: 2, 0: 0, 1: 1, 2: 2 },
+ auto: "wattpilot/properties/car/state", autoWerte: { Idle: "kein", Charging: "laedt", WaitCar: "wartet", Complete: "fertig" },
+ leistungKW: "solarManager/evPower", soc: "solarManager/evSOC", restMin: "solarManager/evRemChrgTime",
+ },
+ og: { // go-e Charger, direkt am Broker
+ modus: "go-eCharger/270003/lmo", modi: { 3: "sofort", 4: "sonne", 5: "plan" },
+ ampere: "go-eCharger/270003/amp",
+ energie: "go-eCharger/270003/ate", zeit: "go-eCharger/270003/att",
+ frc: "go-eCharger/270003/frc", frcWerte: { 0: 0, 1: 1, 2: 2 },
+ auto: "go-eCharger/270003/car", autoWerte: { 1: "kein", 2: "laedt", 3: "wartet", 4: "fertig" },
+ leistungKW: "solarManager/evPowerOG", soc: null, restMin: null,
+ },
+ };
+ const KW_JE_A = { 6: 4.1, 10: 6.9, 16: 11 }; // dreiphasig, 230 V
+
+ const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ",");
+ const hhmm = d => String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
+ const text = t => { const v = window.mqttData && mqttData[t]; return v === undefined ? undefined : String(v); };
+ const zahl = t => { const v = Number(text(t)); return isFinite(v) ? v : null; };
+
+ function sekundenZuZeit(s) {
+ if (s === null) return null;
+ const h = Math.floor(s / 3600) % 24, m = Math.floor(s % 3600 / 60);
+ return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
+ }
+ /** "07:00" -> "heute 07:00" oder "morgen 07:00", je nachdem, was als nächstes kommt. */
+ function naechstes(zeit) {
+ const [h, m] = zeit.split(":").map(Number);
+ const jetzt = new Date(), ziel = new Date();
+ ziel.setHours(h, m, 0, 0);
+ return (ziel > jetzt ? "heute " : "morgen ") + zeit;
+ }
+ function inStunden(h) {
+ const d = new Date(Date.now() + h * 3600000);
+ return (d.getDate() !== new Date().getDate() ? "morgen " : "um ") + hhmm(d);
+ }
+
+ window.ladefensterStarten = function (wurzel, url) {
+ const box = WALLBOXEN[wurzel.dataset.wallbox];
+ if (!box) return;
+ const whProProzent = Number(wurzel.dataset.whProProzent);
+ const $ = name => wurzel.querySelector('[data-lade="' + name + '"]');
+ const lokal = {}; // name -> {wert, bis}
+ let ziehen = false;
+
+ const merke = (name, wert) => { lokal[name] = { wert, bis: Date.now() + HALTEN }; };
+ const gilt = (name, gemeldet) => (lokal[name] && lokal[name].bis > Date.now()) ? lokal[name].wert : gemeldet;
+
+ function meldung(textInhalt, art) {
+ const m = $("meldung");
+ m.textContent = textInhalt;
+ m.className = "lade-meldung" + (art ? " " + art : "");
+ clearTimeout(meldung.uhr);
+ if (art === "gut") meldung.uhr = setTimeout(() => { m.textContent = "Änderungen gelten sofort"; m.className = "lade-meldung"; }, 2200);
+ }
+
+ async function senden(daten) {
+ meldung("wird geschickt …");
+ try {
+ const antwort = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(daten) });
+ const d = await antwort.json().catch(() => ({}));
+ if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
+ meldung("✓ an die Wallbox geschickt", "gut");
+ } catch (e) {
+ meldung("Nicht übernommen: " + e.message, "fehler");
+ }
+ zeichnen();
+ }
+
+ /* ------------------------------------------------ Stand aus MQTT */
+ function stand() {
+ const soc = box.soc ? zahl(box.soc) : null;
+ const energieWh = zahl(box.energie);
+ const zeit = gilt("zeit", sekundenZuZeit(zahl(box.zeit)) || "07:00");
+ // Ziel: mit Ladestand als Prozent des Akkus, ohne als zusätzliche Prozent.
+ const gemeldetZiel = energieWh === null ? 80
+ : soc !== null ? Math.min(100, Math.round((soc + energieWh / whProProzent) / 5) * 5)
+ : Math.min(100, Math.max(5, Math.round(energieWh / whProProzent / 5) * 5));
+ return {
+ soc,
+ modus: gilt("modus", box.modi[text(box.modus)] || null),
+ ampere: gilt("ampere", zahl(box.ampere)),
+ frc: gilt("frc", box.frcWerte[text(box.frc)] ?? 0),
+ auto: box.autoWerte[text(box.auto)] || null,
+ kw: zahl(box.leistungKW) || 0,
+ restMin: box.restMin ? zahl(box.restMin) : null,
+ ziel: gilt("ziel", gemeldetZiel),
+ zeit,
+ netz: zahl("solarManager/P_Grid") || 0,
+ };
+ }
+
+ /* ------------------------------------------------------ Zeichnen */
+ function zeichnen() {
+ const s = stand();
+ const laedt = s.kw >= 0.2 || s.auto === "laedt";
+ const mitSoc = s.soc !== null;
+
+ // Ring
+ const ist = mitSoc ? s.soc : 0;
+ const ziel = s.modus === "plan" ? s.ziel : mitSoc ? 100 : 0;
+ const zielBogen = mitSoc ? Math.max(0, ziel - ist) : (s.modus === "plan" ? s.ziel : 0);
+ const istBogen = wurzel.querySelector(".lade-ist");
+ istBogen.style.strokeDasharray = ist + " 100";
+ // Ein Bogen der Länge 0 zeigt mit runden Enden einen Punkt.
+ istBogen.style.opacity = ist > 0 ? "1" : "0";
+ const zb = wurzel.querySelector(".lade-ziel");
+ zb.style.strokeDasharray = zielBogen + " 100";
+ zb.style.strokeDashoffset = -ist;
+ setze("soc", mitSoc ? Math.round(s.soc) + " %" : (Number(wurzel.dataset.akkuKwh) ? komma(Number(wurzel.dataset.akkuKwh)) + " kWh" : "–"));
+ setze("ringUnten", s.modus === "plan" ? (mitSoc ? "Ziel " + s.ziel + " %" : "+ " + s.ziel + " %") : mitSoc ? "Ladestand" : "Akku");
+
+ // Zustand
+ const zustand = laedt ? "lädt" : s.frc === 1 ? "gestoppt" : s.auto === "fertig" ? "fertig" : s.auto === "kein" ? "kein Auto" : "wartet";
+ $("leistung").innerHTML = komma(s.kw) + " kW
" + zustand + "";
+ const pille = $("stecker");
+ const pilleText = s.auto === "kein" ? "kein Auto" : s.auto === "laedt" ? "lädt" : s.auto === "fertig" ? "voll" : s.auto ? "angesteckt" : "";
+ setze("stecker", pilleText);
+ pille.className = "lade-pille" + (s.auto && s.auto !== "kein" ? " an" : "");
+ const anteil = laedt && s.kw > 0 ? Math.max(0, Math.min(1, 1 - Math.max(0, s.netz) / (s.kw * 1000))) : null;
+ setze("quelle", anteil !== null ? Math.round(anteil * 100) + " % aus Sonnenstrom" : "");
+ let fertig = "";
+ if (laedt && s.restMin > 0) fertig = "fertig " + inStunden(s.restMin / 60);
+ else if (s.modus === "plan") fertig = "Ziel bis " + naechstes(s.zeit);
+ setze("fertig", fertig);
+
+ // Knopf
+ const knopf = $("start");
+ let aktion, beschriftung, klasse = "";
+ if (laedt) { aktion = 1; beschriftung = "Laden stoppen"; klasse = "laeuft"; }
+ else if (s.frc === 1) { aktion = 0; beschriftung = "Ladung freigeben"; klasse = "gesperrt"; }
+ else if (s.frc === 2) { aktion = 0; beschriftung = "Zurück zur Automatik"; }
+ else if (s.auto === "kein") { aktion = 2; beschriftung = "Kein Auto angesteckt"; klasse = "aus"; }
+ else { aktion = 2; beschriftung = "Jetzt laden"; }
+ knopf.dataset.aktion = aktion;
+ knopf.disabled = klasse === "aus";
+ knopf.className = "lade-start " + klasse;
+ setze("startText", beschriftung);
+
+ // Erklärung
+ const kwMax = KW_JE_A[s.ampere] || (s.ampere ? s.ampere * 0.69 : 11);
+ let erkl = {
+ sofort: "Lädt mit bis zu " + komma(kwMax) + " kW – reicht die Sonne nicht, kommt der Rest aus dem Netz.",
+ sonne: "Lädt nur mit Überschuss. Scheint zu wenig Sonne, pausiert die Ladung.",
+ plan: "Lädt bis " + naechstes(s.zeit) + (mitSoc ? " auf " + s.ziel + " %" : " " + s.ziel + " % nach") + " – bevorzugt mit Sonnenstrom, nötigenfalls aus dem Netz.",
+ }[s.modus] || "";
+ if (s.frc === 1) erkl = "Gestoppt: die Wallbox lädt erst wieder nach „Ladung freigeben“ – dann entscheidet der Modus.";
+ if (s.frc === 2) erkl = "Lädt sofort, unabhängig vom Modus. „Zurück zur Automatik“ überlässt es wieder dem Modus.";
+ setze("erklaerung", erkl);
+
+ // Modus, Strom
+ wurzel.querySelectorAll("[data-modus]").forEach(b => b.setAttribute("aria-checked", b.dataset.modus === s.modus ? "true" : "false"));
+ wurzel.querySelectorAll("[data-ampere]").forEach(b => b.setAttribute("aria-checked", Number(b.dataset.ampere) === s.ampere ? "true" : "false"));
+
+ // Planung
+ const planung = $("planung");
+ planung.hidden = s.modus !== "plan";
+ if (!planung.hidden) {
+ const regler = $("ziel");
+ if (!ziehen) regler.value = s.ziel;
+ const wert = Number(regler.value);
+ regler.style.setProperty("--ist", (mitSoc ? s.soc : 0) + "%");
+ regler.style.setProperty("--wert", wert + "%");
+ setze("zielTitel", mitSoc ? "Ladestand bei Abfahrt" : "Zusätzlich laden");
+ setze("markeMitte", mitSoc ? "jetzt " + Math.round(s.soc) + " %" : "");
+ const plusWh = mitSoc ? Math.max(0, wert - s.soc) * whProProzent : wert * whProProzent;
+ $("zielAusgabe").innerHTML = wert + " %
" + (plusWh > 0 ? "+ " + komma(plusWh / 1000) + " kWh" : "schon erreicht") + "";
+ setze("abfahrtAusgabe", naechstes(s.zeit));
+ let bekannt = false;
+ wurzel.querySelectorAll("[data-zeit]").forEach(c => {
+ const an = c.dataset.zeit === s.zeit;
+ bekannt = bekannt || an;
+ c.setAttribute("aria-pressed", an ? "true" : "false");
+ });
+ $("chipEigen").setAttribute("aria-pressed", bekannt ? "false" : "true");
+ const eigen = $("zeitEigen");
+ if (document.activeElement !== eigen && !bekannt) eigen.value = s.zeit;
+ }
+ return s;
+ }
+
+ function setze(name, inhalt) {
+ const e = $(name);
+ if (e && e.textContent !== inhalt) e.textContent = inhalt;
+ }
+
+ /* ------------------------------------------------------ Aktionen */
+ function planSchicken(s) {
+ const ziel = Number($("ziel").value);
+ let wh = s.soc !== null ? Math.max(0, ziel - s.soc) * whProProzent : ziel * whProProzent;
+ wh = Math.max(100, Math.round(wh));
+ merke("modus", "plan");
+ merke("ziel", ziel);
+ merke("zeit", s.zeit);
+ senden({ aktion: "plan", wh, zeit: s.zeit });
+ }
+
+ $("start").addEventListener("click", () => {
+ const wert = Number($("start").dataset.aktion);
+ merke("frc", wert);
+ senden({ aktion: "frc", wert });
+ });
+ wurzel.querySelectorAll("[data-modus]").forEach(b => b.addEventListener("click", () => {
+ const modus = b.dataset.modus;
+ if (modus === "plan") { merke("modus", "plan"); planSchicken(stand()); return; }
+ merke("modus", modus);
+ senden({ aktion: "modus", modus });
+ }));
+ wurzel.querySelectorAll("[data-ampere]").forEach(b => b.addEventListener("click", () => {
+ const ampere = Number(b.dataset.ampere);
+ merke("ampere", ampere);
+ senden({ aktion: "strom", ampere });
+ }));
+ $("ziel").addEventListener("input", () => { ziehen = true; merke("ziel", Number($("ziel").value)); zeichnen(); });
+ $("ziel").addEventListener("change", () => { ziehen = false; planSchicken(stand()); });
+ wurzel.querySelectorAll("[data-zeit]").forEach(c => c.addEventListener("click", () => {
+ merke("zeit", c.dataset.zeit);
+ planSchicken(stand());
+ }));
+ $("zeitEigen").addEventListener("change", e => {
+ if (!/^\d\d:\d\d$/.test(e.target.value)) return;
+ merke("zeit", e.target.value);
+ planSchicken(stand());
+ });
+
+ zeichnen();
+ const takt = setInterval(zeichnen, 1000);
+ const fenster = wurzel.closest(".modal");
+ if (fenster) fenster.addEventListener("hidden.bs.modal", () => clearInterval(takt), { once: true });
+ };
+})();
diff --git a/js/solar/solarMQTT.js b/js/solar/solarMQTT.js
index fc3065d..c5a7a82 100644
--- a/js/solar/solarMQTT.js
+++ b/js/solar/solarMQTT.js
@@ -442,17 +442,10 @@ var chartSettings = {
- // Setzt den Schieberegler aus den Wattstunden, die die Wallbox meldet.
- function setzeLadeRegler(wattstunden) {
- const regler = document.getElementById("modal-slider");
- const whProProzent = Number(regler.dataset.whProProzent);
- if (whProProzent > 0) {
- regler.value = Math.round(Number(String(wattstunden)) / whProProzent);
- }
- }
-
function openModal(type) {
- document.getElementById("modalSaveBtn").style.display = type === "watering" ? "none" : "";
+ // Das Ladefenster und die Bewaesserung uebernehmen jede Aenderung sofort -
+ // "Save changes" braucht nur noch der Heizstab.
+ document.getElementById("modalSaveBtn").style.display = type === "heater" ? "" : "none";
switch(type){
case "CarEG":
document.getElementById("modal-title").innerHTML = "Autoladung EG";
@@ -496,57 +489,9 @@ var chartSettings = {
var span = document.getElementById("modal-slider-label");
switch(type){
case "CarEG":
- bindModalSlider(slider, span, value => value + "%");
- document.getElementById("evStart/Stop").addEventListener("click", submitFormAjax);
- document.getElementById("evStart/Stop").contentURL = contentURL;
- mode = new TextDecoder().decode(mqttData["wattpilot/properties/lmo/state"]);
- time = String(mqttData["wattpilot/properties/ftt/state"]);
- energy = mqttData["wattpilot/properties/fte/state"];
- amp = new TextDecoder().decode(mqttData["wattpilot/properties/amp/state"]);
- car = new TextDecoder().decode(mqttData["wattpilot/properties/car/state"]);
- charge = Number(mqttData["solarManager/p_l1ev"])+Number(mqttData["solarManager/p_l2ev"])+Number(mqttData["solarManager/p_l3ev"])
- if (charge < 0.2) { document.getElementById("evStart/Stop").innerHTML = 'Laden starten' }
- else { document.getElementById("evStart/Stop").innerHTML = 'Laden stoppen' }
- if (mode == "Awattar") { document.getElementById("EV_Eco").click(); }
- else if (mode == "Default") { document.getElementById("EV_Default").click(); }
- else if (mode == "AutomaticStop") { document.getElementById("EV_NextTrip").click(); }
- else { alert(mode); }
- document.getElementById("EV_NextTripTime").value = time.toHHMM();
- // Wattstunden je Prozent liefert das Formular mit (carForm.php);
- // die Akkugroesse steht in restricted/config.php. Frueher standen
- // hier feste 14000 Wh - fuer beide Autos dasselbe, und fuer keines
- // von beiden richtig.
- setzeLadeRegler(energy);
- document.getElementById("modal-slider").dispatchEvent(new Event('input'));
- if (amp == "16") { document.getElementById("EV_16A").click(); }
- else if (amp == "10") { document.getElementById("EV_10A").click(); }
- else if (amp == "6") { document.getElementById("EV_6A").click(); }
- break;
case "CarOG":
- bindModalSlider(slider, span, value => value + "%");
- document.getElementById("evStart/Stop").addEventListener("click", submitFormAjax);
- document.getElementById("evStart/Stop").contentURL = contentURL;
- mode = mqttData["go-eCharger/270003/lmo"];
- time = String(mqttData["go-eCharger/270003/att"]);
- energy = mqttData["go-eCharger/270003/ate"];
- amp = mqttData["go-eCharger/270003/amp"];
- car = mqttData["go-eCharger/270003/car"];
- if (car != "2") { document.getElementById("evStart/Stop").innerHTML = 'Laden starten' }
- else { document.getElementById("evStart/Stop").innerHTML = 'Laden stoppen' }
- if (mode == "4") { document.getElementById("EV_Eco").click(); }
- else if (mode == "3") { document.getElementById("EV_Default").click(); }
- else if (mode == "5") { document.getElementById("EV_NextTrip").click(); }
- else { alert(mode); }
- document.getElementById("EV_NextTripTime").value = time.toHHMM();
- // Wattstunden je Prozent liefert das Formular mit (carForm.php);
- // die Akkugroesse steht in restricted/config.php. Frueher standen
- // hier feste 14000 Wh - fuer beide Autos dasselbe, und fuer keines
- // von beiden richtig.
- setzeLadeRegler(energy);
- document.getElementById("modal-slider").dispatchEvent(new Event('input'));
- if (amp == "16") { document.getElementById("EV_16A").click(); }
- else if (amp == "10") { document.getElementById("EV_10A").click(); }
- else if (amp == "6") { document.getElementById("EV_6A").click(); }
+ // Alles Weitere in js/solar/ladefenster.js.
+ ladefensterStarten(modalBodyElement.querySelector(".lade"), contentURL);
break;
case "heater":
bindModalSlider(slider, span, value => value == 0 ? "Automatik" : (value / 10) + " kW");