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 @@ -
- +
"> + + + +
+
+

Wie laden?

+
+ + + +
+
+ + + +
+

Höchstens

+
+ + + +
+
+ +
Änderungen gelten sofort ·
+
- -
-
-
-
Modus
-
- -
-
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
Max. Ladestrom
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
Ladeplanung
-
-
-
-
- - - -
- , - kWh nutzbar -
-
- 1% - 100% -
- -
-
-
-
- - -
-
-
-
-
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");