diff --git a/README.md b/README.md index 91508d4..7fab1b7 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ smart/ │ ├── skoda.php weather.php settings.php logs.php ← die acht Seitenvorlagen │ ├── rooms.php Etagen und Räume lesen (homeMesh), Kachelvorgabe, Symbole │ ├── grundriss.php Etagen, Räume, Kachelpositionen, Bilder ändern (Reiter Grundriss) +│ ├── zeitleiste.php Automatiken eines Tages: Lage, Bahn, ob dran, Läufe │ ├── meshdb.php meshDb(): Verbindung zu homeMesh │ ├── costs.php Preistabellen + solarDb() │ ├── automations.php Automatiken, Gerätekatalog @@ -182,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`, `solarMQTT.js` | `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` | -| `home` | `home.php` | `autoActionFuncs.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` | +| `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` | — | | `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` | @@ -324,6 +325,19 @@ Anfragen dazu, und die Auswahlfelder füllten sich asynchron. Dazu die Übersicht: Nachfolger einklappen (`toggleAutomationGroup`) und die Rückfrage vor dem Löschen (`frageNachfolger`). +**`zeitleiste.js`** — die Automatismen als Zeitleiste eines Tages, neben der +bisherigen Liste (Umschalter oben in der Karte, gemerkt im Browser). Eine Bahn +je Geräteart, abgeleitet aus den geschalteten Geräten über `bedienform()` — die +Mehrheit gewinnt, Bewässerung wird am Gerätetyp erkannt. Die Lage rechnet +`restricted/zeitleiste.php` nach festen Regeln: feste Uhrzeit und Sonnenstand +als Punkt, Zeitfenster mit Messwert als Balken, Ketten hinter ihrem Auslöser, +ohne jede Zeit unter „Jederzeit“, pausierte unter „Pausiert“. Ob eine +Automatik an einem Tag dran ist, wird wie im Runner gerechnet (Wochentage, +Ferien, Feiertage, Vorabend); nicht dran heißt gestrichelt, nie ausgeblendet. +Der Zähler „x von y“ macht sichtbar, dass keine fehlt. Etagen lassen sich +einzeln einblenden. Sonnenzeiten kommen aus `solarLog.daylight`, für spätere +Tage vom selben Kalendertag eines Vorjahres. + **`grundriss.js`** — der Reiter Grundriss: Etagen, Räume, Grundrissbilder und der Plan-Editor, in dem Kacheln gezogen und gesetzt werden. Eigene Datei, weil `settings.js` schon für vier Reiter reicht; es nutzt `textSchuetzen()` von dort. @@ -346,7 +360,7 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben. | Endpunkt | Aufruf | liefert / tut | |---|---|---| | `room.php` | `?room=`, `POST ?action=command\|temp\|jalousie` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden | -| `AutoAction.php` | `?action=editor\|list\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor und -Übersicht | +| `AutoAction.php` | `?action=editor\|list\|zeitleiste\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor, Übersicht als Liste und als Zeitleiste (`&datum=YYYY-MM-DD`) | | `settings.php` | `?action=list\|kacheln\|geraete\|grundriss\|skoda-keys`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen\|etage-speichern\|etagen-reihenfolge\|etage-loeschen\|etage-bild\|raum-speichern\|raum-position\|raum-loeschen\|skoda-keys-save` | Einstellungsseite; die Grundriss-Aktionen antworten mit dem ganzen neuen Stand, `skoda-keys` meldet nur, ob ein Schlüssel hinterlegt ist und worauf er endet | | `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl | | `energyHistory.php` | `?series=prod\|cons&range=month\|year\|decade` | Verbrauchs-/Erzeugungsverlauf (Rohdaten oder Stundenarchiv) | @@ -371,6 +385,7 @@ Kennzahlen, `energyHistory.php` wählt die Quelle nach Zeitraum, | Datei | Zuständig für | Wichtige Funktionen | |---|---|---| | `rooms.php` | Etagen und Räume lesen (`homeMesh`), Kachelvorgabe, Symbole | `grundriss()`, `allRooms()`, `roomsOnFloor()`, `floorsWithPlan()`, `standardEtage()`, `etageBild()`, `roomsWithTile()`, `tileTopics()`, `kachelVorgabe()`, `bootstrapIcons()`, `kachelIcon()` | +| `zeitleiste.php` | Automatiken eines Tages für die Zeitleiste | `zeitleiste()`, `zlTagPasst()`, `zlSonne()`, `zlBahn()` | | `grundriss.php` | Etagen, Räume, Kachelpositionen und Grundrissbilder ändern | `grundrissStand()`, `etageSpeichern()`, `etagenReihenfolge()`, `etageLoeschen()`, `etageBildSpeichern()`, `raumSpeichern()`, `raumPosition()`, `raumLoeschen()` | | `meshdb.php` | Verbindung zu `homeMesh` | `meshDb()` | | `costs.php` | Preistabellen, Verbindung zu `solarLog` | `solarDb()`, `preiseLaden()`, `preiseSpeichern()`, `grundpreisImZeitraum()` | diff --git a/ajax/AutoAction.php b/ajax/AutoAction.php index c5901dd..96365dc 100644 --- a/ajax/AutoAction.php +++ b/ajax/AutoAction.php @@ -83,6 +83,17 @@ if ($action === "werte") { jsonAntwort(["werte" => stateValues($ids)]); } +// --- Zeitleiste ---------------------------------------------------------- +// Alle Automatiken eines Tages mit ihrer Lage, siehe restricted/zeitleiste.php. +if ($action === "zeitleiste") { + require_once("../restricted/zeitleiste.php"); + try { + jsonAntwort(zeitleiste(strval($_GET["datum"] ?? date("Y-m-d")))); + } catch (Throwable $e) { + jsonAntwort(["error" => "Zeitleiste nicht abrufbar: " . $e->getMessage()], 500); + } +} + // --- Uebersichtstabelle -------------------------------------------------- if ($action === "list") { $tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; diff --git a/css/solar.css b/css/solar.css index 840f1cd..a6ac725 100644 --- a/css/solar.css +++ b/css/solar.css @@ -2739,3 +2739,349 @@ input[type='range']::-webkit-slider-runnable-track { } .gr-raum-stand { color: var(--ton-schrift-3, #6f7a81); font-size: .8rem; } .gr-raum .btn.gr-platzieren { padding: 0 .45rem; font-size: .75rem; } + +/* =========================================================================== + Automatismen als Zeitleiste (js/solar/zeitleiste.js) + + Eine Bahn je Geraeteart, die Spur ist der Tag von 0 bis 24 Uhr. Die Nacht + liegt als leichter Schatten dahinter, jetzt ist eine duenne Linie in der + Sonnenfarbe. Jede Bahn traegt ihre Akzentfarbe als --zl-farbe; Punkte, + Balken und Etiketten nehmen sie auf. + =========================================================================== */ +.zl { + --zl-linie: var(--ton-linie, rgba(138, 148, 158, .2)); + --zl-farbe: #9aa6ad; + --zl-farbe-rgb: 154, 166, 173; +} +.zl-bahn-licht { --zl-farbe: #f0b429; --zl-farbe-rgb: 240, 180, 41; } +.zl-bahn-beschattung { --zl-farbe: #5fb0dc; --zl-farbe-rgb: 95, 176, 220; } +.zl-bahn-heizung { --zl-farbe: #e8836a; --zl-farbe-rgb: 232, 131, 106; } +.zl-bahn-bewaesserung { --zl-farbe: #4cc18a; --zl-farbe-rgb: 76, 193, 138; } +.zl-bahn-schalter { --zl-farbe: #b59cf0; --zl-farbe-rgb: 181, 156, 240; } + +/* Kopf */ +.zl-kopf { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .6rem 1rem; + margin-bottom: .9rem; +} +.zl-tag .btn.zl-tag-name { min-width: 9.5rem; font-variant-numeric: tabular-nums; } +.zl-etagen { display: flex; flex-wrap: wrap; gap: .3rem; } +.zl-chip { + display: inline-flex; + align-items: center; + gap: .35rem; + padding: .2rem .65rem; + border: 1px solid var(--zl-linie); + border-radius: 999px; + background: transparent; + color: var(--ton-schrift-2, #9aa6ad); + font: 500 .8rem var(--font-anzeige, inherit); + cursor: pointer; + transition: background .15s, border-color .15s, color .15s; +} +.zl-chip:hover { border-color: var(--ton-linie-satt, rgba(138, 148, 158, .38)); color: var(--ton-schrift, #e8ede9); } +.zl-chip.an { + background: rgba(0, 120, 143, .22); + border-color: #00788F; + color: var(--ton-schrift, #e8ede9); +} +.zl-chip.leer { opacity: .55; } +.zl-chip-zahl { + font-size: .68rem; + padding: 0 .35rem; + border-radius: 999px; + background: rgba(138, 148, 158, .18); + color: var(--ton-schrift-2, #9aa6ad); +} +.zl-zahl { + margin-left: auto; + font-size: .8rem; + color: var(--ton-schrift-2, #9aa6ad); + white-space: nowrap; +} +.zl-zahl-leise { color: var(--ton-schrift-3, #6f7a81); } + +/* Raster */ +.zl-rahmen { + overflow-x: auto; + border-radius: var(--ton-radius-klein, .5rem); +} +.zl-raster { position: relative; } +.zl-zeile { + display: grid; + grid-template-columns: var(--zl-kopf) minmax(0, 1fr); +} +.zl-bahn .zl-spur { border-top: 1px solid var(--zl-linie); } +.zl-bahn-kopf { + position: sticky; + left: 0; + z-index: 3; + display: flex; + align-items: flex-start; + gap: .45rem; + padding: .6rem .6rem 0 0; + background: var(--ton-flaeche, #23282c); + font: 500 .82rem var(--font-anzeige, inherit); + color: var(--ton-schrift, #e8ede9); +} +.zl-bahn-kopf .bi { color: var(--zl-farbe); font-size: .95rem; line-height: 1.1; } +/* Schmal nur das Symbol; der Name steht im Hinweis beim Antippen der Eintraege. */ +.zl-rahmen.schmal .zl-bahn-kopf { justify-content: center; padding-right: 0; } +.zl-rahmen.schmal .zl-bahn-kopf span { display: none; } +.zl-bahn-zahl { + margin-left: auto; + font-size: .68rem; + color: var(--ton-schrift-3, #6f7a81); +} +.zl-spur { + position: relative; + min-height: 38px; + /* Alle drei Stunden eine Linie, jede volle Stunde ein Hauch. */ + background-image: + linear-gradient(to right, rgba(138, 148, 158, .16) 1px, transparent 1px), + linear-gradient(to right, rgba(138, 148, 158, .06) 1px, transparent 1px); + background-size: 12.5% 100%, 4.1667% 100%; +} +.zl-nacht { + position: absolute; + top: 0; + bottom: 0; + background: rgba(0, 0, 0, .24); + pointer-events: none; +} +.zl-jetzt { + position: absolute; + top: 0; + bottom: 0; + width: 0; + border-left: 1.5px solid var(--ton-sonne, #f0b429); + opacity: .8; + z-index: 2; + pointer-events: none; +} + +/* Achse */ +.zl-achse .zl-bahn-kopf { padding-top: 0; } +.zl-achse .zl-spur { height: 26px; min-height: 0; background: none; } +.zl-stunde { + position: absolute; + bottom: 4px; + transform: translateX(-50%); + font-size: .7rem; + color: var(--ton-schrift-3, #6f7a81); + font-variant-numeric: tabular-nums; +} +.zl-stunde.anfang { transform: none; } +.zl-stunde.ende { transform: translateX(-100%); } +.zl-sonne { + position: absolute; + top: -2px; + transform: translateX(-50%); + font-size: .78rem; + color: var(--ton-sonne, #f0b429); + opacity: .75; + cursor: help; +} +.zl-jetzt-marke { + position: absolute; + top: 1px; + transform: translateX(-50%); + padding: 0 .35rem; + border-radius: 999px; + background: var(--ton-sonne, #f0b429); + color: #1b1f22; + font: 600 .66rem var(--font-anzeige, inherit); + z-index: 2; +} + +/* Eintraege */ +.zl-ding { + position: absolute; + height: 24px; + display: flex; + align-items: center; + cursor: pointer; + z-index: 1; +} +.zl-ding:hover { z-index: 4; } +.zl-marke { + flex: none; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--zl-farbe); + background: var(--ton-flaeche, #23282c); + box-sizing: border-box; +} +.zl-punkt { transform: translateX(-6px); } +.zl-punkt.links { flex-direction: row-reverse; transform: translateX(calc(-100% + 6px)); } +.zl-etikett { + margin-left: 6px; + padding: .1rem .5rem; + border-radius: 6px; + border: 1px solid transparent; + background: rgba(var(--zl-farbe-rgb), .1); + color: var(--ton-schrift, #e8ede9); + font-size: .75rem; + line-height: 1.35; + white-space: nowrap; + transition: background .15s, border-color .15s; +} +.zl-punkt.links .zl-etikett { margin-left: 0; margin-right: 6px; } +.zl-etikett b { + font-weight: 600; + margin-right: .4rem; + color: var(--zl-farbe); + font-variant-numeric: tabular-nums; +} +.zl-etikett .bi-check2 { color: var(--ton-gut, #4caf7d); } +.zl-ding:hover .zl-etikett { + background: rgba(var(--zl-farbe-rgb), .2); + border-color: rgba(var(--zl-farbe-rgb), .45); +} + +/* Zustaende */ +.zl-ding.gelaufen .zl-marke { background: var(--zl-farbe); } +.zl-ding.vorbei { opacity: .5; } +.zl-ding.vorbei .zl-etikett b { color: var(--ton-schrift-2, #9aa6ad); } +.zl-ding.aktiv .zl-marke { box-shadow: 0 0 0 3px rgba(var(--zl-farbe-rgb), .25); } +.zl-ding.nichtdran .zl-marke { border-style: dashed; border-color: var(--ton-schrift-3, #6f7a81); background: transparent; } +.zl-ding.nichtdran .zl-etikett { + background: transparent; + border: 1px dashed var(--zl-linie); + color: var(--ton-schrift-3, #6f7a81); +} +.zl-ding.nichtdran .zl-etikett b { color: var(--ton-schrift-3, #6f7a81); } + +/* Zeitfenster: der Balken traegt Laeufe und die frueheste Marke. */ +.zl-fenster { min-width: 10px; } +.zl-balken { + position: absolute; + left: 0; + right: 0; + top: 8px; + height: 8px; + border-radius: 4px; + background: rgba(var(--zl-farbe-rgb), .22); + border: 1px solid rgba(var(--zl-farbe-rgb), .4); +} +.zl-fenster .zl-etikett { + position: relative; + margin-left: 0; + background: var(--ton-flaeche, #23282c); + border-color: rgba(var(--zl-farbe-rgb), .3); +} +/* Am rechten Rand endet das Etikett am Balkenende statt dahinter. */ +.zl-fenster.links { justify-content: flex-end; } +.zl-lauf { + margin-left: .25rem; + color: var(--ton-gut, #4caf7d); + font-variant-numeric: tabular-nums; +} +.zl-fenster.nichtdran .zl-balken { background: transparent; border-style: dashed; border-color: var(--zl-linie); } +.zl-marke-innen { + position: absolute; + top: 50%; + width: 10px; + height: 10px; + transform: translate(-50%, -50%); +} + +/* Ketten */ +.zl-ketten { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + z-index: 0; + overflow: visible; +} +.zl-ketten path { + fill: none; + stroke: rgba(154, 166, 173, .55); + stroke-width: 1.2; + stroke-dasharray: 3 3; +} + +.zl-leer { + padding: 1.2rem 0 .6rem 132px; + color: var(--ton-schrift-3, #6f7a81); + font-size: .85rem; +} + +/* Baender */ +.zl-baender { + display: flex; + flex-direction: column; + gap: .45rem; + margin-top: .8rem; + padding-top: .7rem; + border-top: 1px solid var(--zl-linie); +} +.zl-band { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + align-items: start; +} +.zl-band-chips { display: flex; flex-wrap: wrap; gap: .35rem; } +@media (max-width: 575.98px) { + .zl-band { grid-template-columns: minmax(0, 1fr); gap: .3rem; } + .zl-zahl { margin-left: 0; } +} +.zl-band-titel { + display: inline-flex; + align-items: center; + gap: .4rem; + padding-top: .2rem; + font: 500 .82rem var(--font-anzeige, inherit); + color: var(--ton-schrift-2, #9aa6ad); +} +.zl-band-chip { + display: inline-flex; + align-items: center; + gap: .4rem; + padding: .2rem .6rem; + border-radius: 6px; + border: 1px solid var(--zl-linie); + background: rgba(138, 148, 158, .07); + color: var(--ton-schrift, #e8ede9); + font-size: .75rem; + cursor: pointer; +} +.zl-band-chip:hover { border-color: var(--ton-linie-satt, rgba(138, 148, 158, .38)); } +.zl-band-pause .zl-band-chip { color: var(--ton-schrift-2, #9aa6ad); } +.zl-band-chip.nichtdran { border-style: dashed; } +.zl-band-etage { + font-size: .64rem; + padding: 0 .3rem; + border-radius: 4px; + background: rgba(138, 148, 158, .16); + color: var(--ton-schrift-3, #6f7a81); +} + +/* Legende */ +.zl-legende { + display: flex; + flex-wrap: wrap; + gap: .4rem 1.1rem; + margin-top: .8rem; + font-size: .72rem; + color: var(--ton-schrift-3, #6f7a81); +} +.zl-legende span { display: inline-flex; align-items: center; gap: .4rem; } +.zl-legende i { display: inline-block; } +.zl-l-punkt { width: 10px; height: 10px; border-radius: 50%; border: 2px solid #5fb0dc; } +.zl-l-punkt.gelaufen { background: #5fb0dc; } +.zl-l-punkt.nichtdran { border-style: dashed; border-color: var(--ton-schrift-3, #6f7a81); } +.zl-l-balken { width: 22px; height: 7px; border-radius: 4px; background: rgba(95, 176, 220, .22); border: 1px solid rgba(95, 176, 220, .4); } +.zl-l-kette { width: 22px; border-top: 1.2px dashed rgba(154, 166, 173, .7); } + +/* Ansicht umschalten: eine Klasse mehr als Bootstrap (solar.css laedt vorher). */ +.zl-ansicht .btn.active { + background: rgba(0, 120, 143, .25); + border-color: #00788F; + color: var(--ton-schrift, #e8ede9); +} diff --git a/index.php b/index.php index 44d189e..c5ecf56 100644 --- a/index.php +++ b/index.php @@ -102,7 +102,7 @@ if (isset($_GET["addUser"])) { "solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false, "scripts" => ["js/solar/jahresstatistik.js", "js/solar/solarMQTT.js"]], "home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false, - "scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]], + "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, "scripts" => ["js/solar/heatMQTT.js"]], "history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false, diff --git a/js/solar/autoActionFuncs.js b/js/solar/autoActionFuncs.js index 4178209..e46a9dd 100644 --- a/js/solar/autoActionFuncs.js +++ b/js/solar/autoActionFuncs.js @@ -1396,6 +1396,9 @@ function automationRequest(action, body) { * ausgelassen: die Tabelle waere leer geblieben, ohne Fehlermeldung. */ function refreshAutomations(floor) { + // Die Zeitleiste zeigt dieselben Automatiken - nach Speichern, Pausieren + // oder Loeschen muss sie genauso nachziehen wie die Tabelle. + if (typeof zeitleisteLaden === "function") zeitleisteLaden(); const etagen = floor ? [floor] : Array.from(document.querySelectorAll("#actions-tabContent .tab-pane")) .map(pane => pane.id.replace(/^actions-/, "")); diff --git a/js/solar/zeitleiste.js b/js/solar/zeitleiste.js new file mode 100644 index 0000000..54df163 --- /dev/null +++ b/js/solar/zeitleiste.js @@ -0,0 +1,484 @@ +/* ========================================================================== + Automatismen als Zeitleiste eines Tages. + + Die Daten rechnet der Server (restricted/zeitleiste.php): wo eine + Automatik auf dem Tag liegt, in welcher Bahn sie steht, ob sie an dem Tag + dran ist und wann sie gelaufen ist. Hier wird nur gezeichnet. + + Jede Automatik erscheint genau einmal - in ihrer Bahn, unter "Jederzeit" + oder unter "Pausiert". Der Zähler oben sagt, wie viele es insgesamt sind; + fehlte eine, fiele das dort auf. + + Die bisherige Tabelle bleibt als zweite Ansicht ("Liste"). Welche offen + ist und welche Etagen eingeblendet sind, merkt sich der Browser. + ========================================================================== */ + +const zeitleiste = { + datum: null, // "YYYY-MM-DD", null = heute + daten: null, // Antwort von ?action=zeitleiste + etagen: null, // Set der eingeblendeten Etagen, null = alle + uhr: null, +}; + +const ZL_ZEILE = 30; // Höhe einer Zeile in einer Bahn, px +const ZL_KOPF = 132; // Breite der Bahnbeschriftung, px +const ZL_MIN_SPUR = 620; // schmaler wird die Spur nicht, der Rahmen scrollt dann + +/* --- Kleinigkeiten ------------------------------------------------------- */ + +function zlSpeicher(schluessel, wert) { + try { + if (wert === undefined) return localStorage.getItem(schluessel); + localStorage.setItem(schluessel, wert); + } catch (e) { return null; } + return null; +} + +function zlText(s) { + const d = document.createElement("div"); + d.textContent = String(s === undefined || s === null ? "" : s); + return d.innerHTML.replace(/"/g, """); +} + +function zlUhr(m) { + m = ((Math.round(m) % 1440) + 1440) % 1440; + return String(Math.floor(m / 60)).padStart(2, "0") + ":" + String(m % 60).padStart(2, "0"); +} + +function zlIso(d) { + return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); +} + +function zlDatumText(iso, heute) { + const d = new Date(iso + "T12:00:00"); + const tage = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; + const kurz = tage[d.getDay()] + " " + String(d.getDate()).padStart(2, "0") + "." + String(d.getMonth() + 1).padStart(2, "0") + "."; + const morgen = new Date(); morgen.setDate(morgen.getDate() + 1); + if (heute) return "Heute · " + kurz; + if (iso === zlIso(morgen)) return "Morgen · " + kurz; + return kurz; +} + +/** Textbreite in px, für das Stapeln - einmal ein Canvas, dann nur messen. */ +function zlBreite(text) { + const c = zlBreite.c || (zlBreite.c = document.createElement("canvas").getContext("2d")); + c.font = "500 12px " + getComputedStyle(document.body).fontFamily; + return c.measureText(text).width; +} + +/* --- Einordnen ----------------------------------------------------------- */ + +/** Was an Automatiken nach dem Etagenfilter übrig bleibt. */ +function zlSichtbar() { + const alle = zeitleiste.daten.automatiken; + return zeitleiste.etagen ? alle.filter(a => zeitleiste.etagen.has(a.floor)) : alle; +} + +/** Wo ein Eintrag auf dem Tag beginnt und endet, in Minuten. */ +function zlLage(a) { + if (a.art === "fenster") return [a.von, a.bis]; + const m = a.laeufe.length ? zlMinuteAus(a.laeufe[a.laeufe.length - 1]) : a.punkt; + return [m, m]; +} + +function zlMinuteAus(hhmm) { + const t = String(hhmm).split(":"); + return Number(t[0]) * 60 + Number(t[1]); +} + +/** Zustand eines Eintrags - bestimmt Farbe und Form. */ +function zlZustand(a, d) { + if (!a.dran) return "nichtdran"; + if (a.laeufe.length) return "gelaufen"; + if (d.jetzt !== null) { + const ende = a.art === "fenster" ? a.bis : a.punkt; + if (ende !== null && ende < d.jetzt) return "vorbei"; + const anfang = a.art === "fenster" ? a.von : a.punkt; + if (anfang !== null && anfang <= d.jetzt) return "aktiv"; + } + return "kommt"; +} + +/** + * Einträge einer Bahn in Zeilen verteilen, wie im Kalender: jeder kommt in + * die erste Zeile, in der er nichts überdeckt. Gerechnet in px, weil das + * Etikett mitzählt - zwei Punkte zehn Minuten auseinander liegen zeitlich + * getrennt, ihre Beschriftungen aber übereinander. + */ +function zlStapeln(eintraege, breite) { + const px = m => m / 1440 * breite; + const liste = eintraege.map(a => { + const [von, bis] = zlLage(a); + const etikett = zlEtikettText(a); + const lang = zlBreite(etikett) + 26; + let links = px(von) - 8; + let rechts = a.art === "fenster" ? Math.max(px(bis), px(von) + lang) : px(von) + lang; + // Am rechten Rand steht das Etikett links vom Punkt bzw. endet am + // Balkenende - sonst ragte es aus der Spur und der Rahmen bekaeme einen + // Scrollbalken fuer ein paar Buchstaben. + const nachLinks = px(von) + lang > breite - 4; + if (nachLinks && a.art !== "fenster") { links = px(von) - lang; rechts = px(von) + 8; } + if (nachLinks && a.art === "fenster") { links = Math.min(px(von), px(bis) - lang) - 8; rechts = px(bis); } + return { a, links, rechts, nachLinks, etikett }; + }).sort((x, y) => x.links - y.links); + + const zeilen = []; + liste.forEach(e => { + let z = zeilen.findIndex(ende => ende + 6 <= e.links); + if (z < 0) { z = zeilen.length; zeilen.push(0); } + zeilen[z] = e.rechts; + e.zeile = z; + }); + return { liste, zeilen: Math.max(1, zeilen.length) }; +} + +/** + * Die Zeit vorne im Etikett. Beim Punkt die Zeit des Punkts - nach einem + * Lauf also die echte. Beim Fenster immer das Fenster; der Lauf steht dann + * hinten mit Haken, sonst stünde "12:50" am Balkenanfang bei 09:30. + */ +function zlEtikettZeit(a) { + if (a.art === "fenster") return zlUhr(a.von) + "–" + zlUhr(a.bis); + return a.laeufe.length ? a.laeufe[a.laeufe.length - 1] : zlUhr(a.punkt); +} + +function zlEtikettText(a) { + const lauf = a.art === "fenster" && a.laeufe.length ? " ✓ " + a.laeufe[a.laeufe.length - 1] : (a.laeufe.length ? " ✓" : ""); + return zlEtikettZeit(a) + " " + a.name + lauf; +} + +/** Der Hinweis beim Überfahren: was, wann, wovon abhängig, wie steht es. */ +function zlTitel(a, zustand) { + const zeilen = [a.name, a.satz]; + if (a.art === "fenster") zeilen.push("Zeitfenster " + zlUhr(a.von) + "–" + zlUhr(a.bis) + (a.punkt !== null ? ", frühestens " + zlUhr(a.punkt) : "")); + else if (a.punkt !== null) zeilen.push((a.art === "kette" ? "frühestens " : "um ") + zlUhr(a.punkt) + (a.sonne ? " (Sonnenstand)" : "")); + if (a.wenn) zeilen.push("Wenn: " + a.wenn); + const stand = { + gelaufen: "gelaufen um " + a.laeufe.join(", "), + nichtdran: "an diesem Tag nicht dran: " + a.grund, + vorbei: "nicht gelaufen" + (a.wartet ? " – Bedingung war nicht erfüllt" : ""), + aktiv: a.wartet ? "wartet auf die Bedingung" : "steht an", + kommt: "kommt noch", + }[zustand]; + zeilen.push("Stand: " + stand); + return zeilen.join("\n"); +} + +/* --- Zeichnen ------------------------------------------------------------ */ + +function zeitleisteZeichnen() { + const ziel = document.getElementById("zeitleiste"); + const d = zeitleiste.daten; + if (!ziel || !d || ziel.hidden) return; + + const sichtbar = zlSichtbar(); + const aktiv = sichtbar.filter(a => a.enabled); + const pausiert = sichtbar.filter(a => !a.enabled); + const jederzeit = aktiv.filter(a => a.art === "jederzeit"); + const gelegt = aktiv.filter(a => a.art !== "jederzeit" && (a.punkt !== null || a.von !== null)); + + // --- Kopf: Tag, Etagen, Zähler + const alleEtagen = !zeitleiste.etagen; + let html = '
' + + '
' + + '' + + '" + + '' + + "
" + + '
' + + '' + + d.etagen.map(e => { + const an = alleEtagen || zeitleiste.etagen.has(e.code); + const anzahl = d.automatiken.filter(a => a.floor === e.code).length; + return '"; + }).join("") + + "
" + + '
' + + ' ' + sichtbar.length + " von " + d.automatiken.length + + (pausiert.length ? ' · ' + pausiert.length + " pausiert" : "") + + "
"; + + // --- Raster. Schmal (Handy) traegt die Bahn nur ihr Symbol - der Name + // nähme sonst ein Drittel der Breite, und die Spur scrollt ohnehin. + const rahmenBreite = ziel.clientWidth || 800; + const schmal = rahmenBreite < 560; + const kopf = schmal ? 40 : ZL_KOPF; + const breite = Math.max(ZL_MIN_SPUR, rahmenBreite - kopf - 2); + const altRahmen = ziel.querySelector(".zl-rahmen"); + const altScroll = altRahmen ? altRahmen.scrollLeft : null; + const prozent = m => (m / 1440 * 100).toFixed(3) + "%"; + const nacht = d.sonne.auf !== null + ? '
' + + '
' + : ""; + const jetzt = d.jetzt !== null ? '
' : ""; + + html += '
'; + + // Stundenachse mit Sonnenmarken + html += '
'; + for (let h = 0; h <= 24; h += 3) { + const rand = h === 0 ? " anfang" : h === 24 ? " ende" : ""; + html += '' + h + ""; + } + if (d.sonne.auf !== null) { + const geschaetzt = d.sonne.genau ? "" : " (Vorjahr)"; + html += '' + + ''; + } + if (d.jetzt !== null) html += '' + zlUhr(d.jetzt) + ""; + html += "
"; + + // Bahnen + const bahnen = Object.keys(d.bahnen).filter(b => gelegt.some(a => a.bahn === b)); + if (!bahnen.length) { + html += '
Keine Automatik mit einer Zeit auf diesem Tag' + + (zeitleiste.etagen ? " in den gewählten Etagen" : "") + ".
"; + } + bahnen.forEach(b => { + const eintraege = gelegt.filter(a => a.bahn === b); + const stapel = zlStapeln(eintraege, breite); + const hoehe = stapel.zeilen * ZL_ZEILE + 8; + html += '
' + + '
' + zlText(d.bahnen[b].titel) + "" + + '' + eintraege.length + "
" + + '
' + nacht + jetzt; + stapel.liste.forEach(e => { html += zlEintrag(e, d, prozent); }); + html += "
"; + }); + + html += '
'; + + // --- Bänder ohne Lage auf dem Tag + if (jederzeit.length || pausiert.length) { + html += '
'; + if (jederzeit.length) { + html += '
Jederzeit' + + '
' + + jederzeit.map(a => zlChip(a, d.bahnen[a.bahn] ? d.bahnen[a.bahn].symbol : "bi-dot")).join("") + "
"; + } + if (pausiert.length) { + html += '
Pausiert' + + '
' + pausiert.map(a => zlChip(a, "bi-pause-fill")).join("") + "
"; + } + html += "
"; + } + + html += '
' + + 'gelaufen' + + 'kommt noch' + + 'Zeitfenster, wartet auf Bedingung' + + 'Kette' + + 'an diesem Tag nicht dran' + + "
"; + + ziel.innerHTML = html; + zlKettenZeichnen(ziel); + + // Muss gescrollt werden, steht die Leiste beim ersten Zeichnen bei "jetzt" + // (an einem anderen Tag beim Morgen) statt bei Mitternacht. Beim + // Neuzeichnen bleibt sie, wo man sie hingeschoben hat. + const rahmen = ziel.querySelector(".zl-rahmen"); + if (rahmen && rahmen.scrollWidth > rahmen.clientWidth) { + if (altScroll !== null && zeitleiste.gescrollt) { + rahmen.scrollLeft = altScroll; + } else { + const ziel_m = d.jetzt !== null ? d.jetzt - 120 : 5 * 60; + rahmen.scrollLeft = Math.max(0, ziel_m / 1440 * breite); + zeitleiste.gescrollt = true; + } + } +} + +/** Ein Eintrag in einer Bahn. */ +function zlEintrag(e, d, prozent) { + const a = e.a; + const zustand = zlZustand(a, d); + const oben = 4 + e.zeile * ZL_ZEILE; + const titel = zlText(zlTitel(a, zustand)); + const lauf = zustand !== "gelaufen" ? "" + : ' ' + (a.art === "fenster" ? '' + zlText(a.laeufe[a.laeufe.length - 1]) + "" : ""); + const etikett = '' + zlText(zlEtikettZeit(a)) + "" + zlText(a.name) + lauf + ""; + + if (a.art === "fenster") { + // Läufe und die früheste Marke als Punkte auf dem Balken. + let marken = ""; + if (a.punkt !== null && a.punkt > a.von && !a.laeufe.length) { + marken += ''; + } + a.laeufe.forEach(t => { + const m = zlMinuteAus(t); + marken += ''; + }); + return '"; + } + const m = a.laeufe.length ? zlMinuteAus(a.laeufe[a.laeufe.length - 1]) : a.punkt; + return '"; +} + +function zlChip(a, symbol) { + return '"; +} + +/** + * Ketten als gestrichelte Bögen vom Auslöser zum Nachfolger. Erst nach dem + * Einsetzen, weil die Lage beider Punkte aus dem Layout kommt. + */ +function zlKettenZeichnen(ziel) { + const raster = ziel.querySelector(".zl-raster"); + const svg = ziel.querySelector(".zl-ketten"); + if (!raster || !svg) return; + const basis = raster.getBoundingClientRect(); + svg.setAttribute("width", basis.width); + svg.setAttribute("height", basis.height); + let pfade = ""; + raster.querySelectorAll(".zl-punkt[data-kette]").forEach(kind => { + const vater = raster.querySelector('.zl-ding[data-id="' + kind.dataset.kette + '"]'); + if (!vater) return; + const mv = (vater.querySelector(".zl-marke") || vater).getBoundingClientRect(); + const mk = kind.querySelector(".zl-marke").getBoundingClientRect(); + const x1 = mv.left + mv.width / 2 - basis.left, y1 = mv.top + mv.height / 2 - basis.top; + const x2 = mk.left + mk.width / 2 - basis.left, y2 = mk.top + mk.height / 2 - basis.top; + const bogen = Math.max(18, Math.abs(y2 - y1) * 0.5); + pfade += ''; + }); + svg.innerHTML = pfade; +} + +/* --- Laden und Bedienen -------------------------------------------------- */ + +async function zeitleisteLaden() { + const ziel = document.getElementById("zeitleiste"); + if (!ziel) return; + const datum = zeitleiste.datum || zlIso(new Date()); + try { + const antwort = await fetch("./ajax/AutoAction.php?action=zeitleiste&datum=" + datum, + { headers: { "Requested-With-Ajax": "ajax" }, cache: "no-store" }); + const d = await antwort.json(); + if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); + zeitleiste.daten = d; + // Etagen, die es nicht mehr gibt, fallen aus dem Filter. + if (zeitleiste.etagen) { + const codes = new Set(d.etagen.map(e => e.code)); + zeitleiste.etagen = new Set([...zeitleiste.etagen].filter(c => codes.has(c))); + if (!zeitleiste.etagen.size) zeitleiste.etagen = null; + } + zeitleisteZeichnen(); + } catch (e) { + ziel.innerHTML = '
Zeitleiste nicht abrufbar: ' + zlText(e.message) + "
"; + } +} + +function zlAnsicht(welche) { + const zl = document.getElementById("zeitleiste"); + const liste = document.getElementById("automatikListe"); + if (!zl || !liste) return; + zl.hidden = welche !== "zeitleiste"; + liste.hidden = welche === "zeitleiste"; + document.querySelectorAll(".zl-ansicht [data-ansicht]").forEach(k => + k.classList.toggle("active", k.dataset.ansicht === welche)); + zlSpeicher("automatik.ansicht", welche); + if (welche === "zeitleiste") { + if (zeitleiste.daten) zeitleisteZeichnen(); else zeitleisteLaden(); + } +} + +function zlEtagenMerken() { + zlSpeicher("automatik.etagen", zeitleiste.etagen ? [...zeitleiste.etagen].join(",") : ""); +} + +document.addEventListener("DOMContentLoaded", function () { + const ziel = document.getElementById("zeitleiste"); + if (!ziel) return; + + const gemerkt = zlSpeicher("automatik.etagen"); + zeitleiste.etagen = gemerkt ? new Set(gemerkt.split(",").filter(Boolean)) : null; + if (zeitleiste.etagen && !zeitleiste.etagen.size) zeitleiste.etagen = null; + + document.querySelectorAll(".zl-ansicht [data-ansicht]").forEach(k => + k.addEventListener("click", () => zlAnsicht(k.dataset.ansicht))); + zlAnsicht(zlSpeicher("automatik.ansicht") === "liste" ? "liste" : "zeitleiste"); + + // Neue Automatik: in der ersten eingeblendeten Etage, sonst der Standard. + const neu = document.querySelector(".zl-neu"); + if (neu) neu.addEventListener("click", function () { + let etage = typeof homeStandardEtage !== "undefined" ? homeStandardEtage : ""; + if (zeitleiste.etagen && zeitleiste.daten) { + const erste = zeitleiste.daten.etagen.find(e => zeitleiste.etagen.has(e.code)); + if (erste) etage = erste.code; + } + const liste = document.getElementById("automatikListe"); + if (liste && !liste.hidden) { + const offen = document.querySelector("#actions-tabContent .tab-pane.active"); + if (offen) etage = offen.id.replace(/^actions-/, ""); + } + openAutoActionModal("?action=editor&floor=" + encodeURIComponent(etage)); + }); + + ziel.addEventListener("click", function (ereignis) { + const t = ereignis.target; + const ding = t.closest("[data-id]"); + if (ding) { + openAutoActionModal("?action=editor&id=" + ding.dataset.id); + return; + } + const tag = t.closest("[data-tag]"); + if (tag) { + const schritt = Number(tag.dataset.tag); + if (schritt === 0) { + zeitleiste.datum = null; + } else { + const d = new Date((zeitleiste.datum || zlIso(new Date())) + "T12:00:00"); + d.setDate(d.getDate() + schritt); + zeitleiste.datum = zlIso(d) === zlIso(new Date()) ? null : zlIso(d); + } + zeitleisteLaden(); + return; + } + const chip = t.closest("[data-etage]"); + if (chip && zeitleiste.daten) { + const code = chip.dataset.etage; + if (!code) { + zeitleiste.etagen = null; + } else if (!zeitleiste.etagen) { + // Aus "alle" heraus blendet ein Klick genau diese Etage ein. + zeitleiste.etagen = new Set([code]); + } else { + zeitleiste.etagen.has(code) ? zeitleiste.etagen.delete(code) : zeitleiste.etagen.add(code); + if (!zeitleiste.etagen.size || zeitleiste.etagen.size === zeitleiste.daten.etagen.length) { + zeitleiste.etagen = null; + } + } + zlEtagenMerken(); + zeitleisteZeichnen(); + } + }); + + // Neu zeichnen, wenn sich die Breite ändert - Stapeln und Ketten hängen daran. + let warte = null; + if (typeof ResizeObserver !== "undefined") { + let letzteBreite = 0; + new ResizeObserver(() => { + if (Math.abs(ziel.clientWidth - letzteBreite) < 4) return; + letzteBreite = ziel.clientWidth; + clearTimeout(warte); + warte = setTimeout(zeitleisteZeichnen, 120); + }).observe(ziel); + } + + // Heute rückt die Jetzt-Linie weiter, und neue Läufe kommen dazu. + zeitleiste.uhr = setInterval(() => { + if (!ziel.hidden && !zeitleiste.datum && !document.hidden) zeitleisteLaden(); + }, 5 * 60 * 1000); +}); diff --git a/restricted/home.php b/restricted/home.php index cbcab0e..b8d9152 100644 --- a/restricted/home.php +++ b/restricted/home.php @@ -203,16 +203,23 @@ require_once(__DIR__ . "/reiter.php");
-
-

Automatismen

-
- +
+

Automatismen

+ +
+ +
- +
-
+
+ +
+
diff --git a/restricted/zeitleiste.php b/restricted/zeitleiste.php new file mode 100644 index 0000000..8eb6cb6 --- /dev/null +++ b/restricted/zeitleiste.php @@ -0,0 +1,419 @@ + ["titel" => "Licht", "symbol" => "bi-lightbulb"], + "beschattung" => ["titel" => "Beschattung", "symbol" => "bi-window-split"], + "heizung" => ["titel" => "Heizung", "symbol" => "bi-thermometer-half"], + "bewaesserung" => ["titel" => "Bewässerung", "symbol" => "bi-droplet"], + "schalter" => ["titel" => "Schalter", "symbol" => "bi-toggle-on"], + "sonstiges" => ["titel" => "Sonstiges", "symbol" => "bi-three-dots"], + ]; +} + +/** "07:30" -> 450, "-00:30" wird von aufrufender Seite behandelt. */ +function zlMinuten($text) +{ + if (!preg_match('/^(\d{1,2}):(\d{2})/', trim(strval($text)), $t)) { + return null; + } + return intval($t[1]) * 60 + intval($t[2]); +} + +/** 450 -> "07:30" */ +function zlUhrzeit($m) +{ + $m = (($m % 1440) + 1440) % 1440; + return sprintf("%02d:%02d", intdiv($m, 60), $m % 60); +} + +/** Ferien und Feiertag an einem Datum aus calendar_days. */ +function zlKalender($datum) +{ + static $cache = []; + if (isset($cache[$datum])) { + return $cache[$datum]; + } + $stand = ["feiertag" => null, "ferien" => null]; + $stmt = meshDb()->prepare("SELECT holiday, vacation FROM calendar_days WHERE date = ?"); + $stmt->bind_param("s", $datum); + $stmt->execute(); + $z = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if ($z) { + $stand = ["feiertag" => $z["holiday"], "ferien" => $z["vacation"]]; + } + return $cache[$datum] = $stand; +} + +/** + * Ist die Automatik an diesem Datum dran? Gibt [ja/nein, Grund] zurück. + * Dieselbe Reihenfolge wie tag_passt(): erst öffnen, dann sperren. + */ +function zlTagPasst($a, $datum) +{ + $tag = new DateTimeImmutable($datum); + if (!empty($a["next_day"])) { + $tag = $tag->modify("+1 day"); + } + $kal = zlKalender($tag->format("Y-m-d")); + $bit = 1 << (intval($tag->format("N")) - 1); // Montag = Bit 0 + $vorabend = !empty($a["next_day"]) ? "morgen " : ""; + + $passt = (bool)($a["weekdays"] & $bit); + if ($kal["feiertag"] && $a["on_holiday"] == 2) $passt = true; + if ($kal["ferien"] && $a["on_vacation"] == 2) $passt = true; + if (!$passt) { + $namen = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + $tage = []; + for ($i = 0; $i < 7; $i++) { + if ($a["weekdays"] & (1 << $i)) $tage[] = $namen[$i]; + } + $extra = []; + if ($a["on_holiday"] == 2) $extra[] = "Feiertage"; + if ($a["on_vacation"] == 2) $extra[] = "Ferien"; + $liste = implode(", ", array_merge($tage, $extra)); + return [false, ($vorabend ? "nur am Vorabend von " : "nur ") . ($liste ?: "an keinem Tag")]; + } + if ($kal["feiertag"] && $a["on_holiday"] == 0) { + return [false, $vorabend . "Feiertag (" . $kal["feiertag"] . ")"]; + } + if ($kal["ferien"] && $a["on_vacation"] == 0) { + return [false, $vorabend . "Ferien (" . $kal["ferien"] . ")"]; + } + return [true, ""]; +} + +/** + * Sonnenauf- und -untergang eines Datums in Minuten, wie im Runner. + * + * solarLog.daylight kennt nur heute, morgen und die Vergangenheit. Für einen + * späteren Tag gilt derselbe Kalendertag eines Vorjahres - die Sonne + * verschiebt sich von Jahr zu Jahr um weniger als eine Minute, und so braucht + * es keinen Standort, der in einem anderen Haus wieder falsch wäre. "genau" + * sagt, ob es der Eintrag des Tages selbst war. + */ +function zlSonne($datum) +{ + try { + $db = solarDb(); + $stmt = $db->prepare("SELECT sunrise, sunset, date = ? AS genau FROM daylight + WHERE date = ? OR DATE_FORMAT(date, '%m-%d') = DATE_FORMAT(?, '%m-%d') + ORDER BY genau DESC, date DESC LIMIT 1"); + $stmt->bind_param("sss", $datum, $datum, $datum); + $stmt->execute(); + $z = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if ($z) { + return ["auf" => zlMinuten($z["sunrise"]), "unter" => zlMinuten($z["sunset"]), + "genau" => (bool)$z["genau"]]; + } + } catch (Throwable $e) { + // ohne Sonnenzeiten eben ohne - die betroffenen stehen dann unter "jederzeit" + } + return ["auf" => null, "unter" => null, "genau" => false]; +} + +/** + * Die Bahn einer Automatik: welche Art Gerät sie überwiegend schaltet. + * $geraete ist command_id => Gerät aus deviceCatalog(). + */ +function zlBahn($aktionen, $geraete) +{ + $zaehler = []; + foreach ($aktionen as $akt) { + $d = $geraete[$akt["command_id"]] ?? null; + if (!$d) { + continue; + } + $form = bedienform($d); + if (stripos($d["type"], "bewässer") !== false || stripos($d["type"], "bewaesser") !== false + || stripos($d["url"], "Gartenwasser") !== false) { + $form = "bewaesserung"; + } + if (!isset(zeitleisteBahnen()[$form])) { + $form = "sonstiges"; + } + $zaehler[$form] = ($zaehler[$form] ?? 0) + 1; + } + if (!$zaehler) { + return "sonstiges"; + } + // Bei Gleichstand gewinnt die Bahn, die weiter oben steht. + $reihe = array_flip(array_keys(zeitleisteBahnen())); + uksort($zaehler, function ($x, $y) use ($zaehler, $reihe) { + return [$zaehler[$y], $reihe[$x]] <=> [$zaehler[$x], $reihe[$y]]; + }); + return array_key_first($zaehler); +} + +/** Was die Automatik tut, kurz: "Wozi Fensterfront, Küche Tür +5: Auf". */ +function zlSatz($aktionen, $geraete, $kommandos) +{ + $namen = []; + $was = []; + foreach ($aktionen as $akt) { + $d = $geraete[$akt["command_id"]] ?? null; + $namen[] = $d ? $d["label"] : "?"; + $voll = $kommandos[$akt["command_id"]] ?? ""; + $was[] = strpos($voll, ": ") !== false ? substr($voll, strpos($voll, ": ") + 2) : $voll; + } + $namen = array_values(array_unique($namen)); + $wer = implode(", ", array_slice($namen, 0, 2)) . (count($namen) > 2 ? " +" . (count($namen) - 2) : ""); + $was = array_values(array_unique($was)); + return $wer . ": " . (count($was) === 1 ? $was[0] : implode(" / ", array_slice($was, 0, 2))); +} + +/** + * Die Zeitleiste eines Datums (YYYY-MM-DD). + */ +function zeitleiste($datum) +{ + $db = meshDb(); + $d = DateTimeImmutable::createFromFormat("Y-m-d", $datum) ?: new DateTimeImmutable("today"); + $datum = $d->format("Y-m-d"); + $sonne = zlSonne($datum); + + // Geräte je Kommando, einmal für alle. + $geraete = []; + foreach (deviceCatalog() as $g) { + foreach ($g["commands"] as $c) { + $geraete[$c["id"]] = $g; + } + } + $kommandos = commandNames(); + $namesState = stateNames(); + + // Messwerte, die eine Zeit tragen: Uhrzeit/Datum (Logic), Sonnenstand, + // Automatik-Auslöser. Alles andere ist ein Messwert, auf den gewartet wird. + $zeitStates = []; + $res = $db->query("SELECT s.id, s.url, a.url AS actor_url, t.type + FROM actor_states s JOIN actors a ON a.id = s.actor_id + LEFT JOIN state_types t ON t.id = s.state_type + WHERE a.url IN ('Logic', 'Automatik')"); + while ($z = $res->fetch_assoc()) { + $zeitStates[intval($z["id"])] = $z; + } + + // Läufe an diesem Tag. + $laeufe = []; + $stmt = $db->prepare("SELECT automation_id, ts, result FROM automation_log + WHERE ts >= ? AND ts < ? + INTERVAL 1 DAY AND result IN ('fired', 'forced') + ORDER BY ts"); + $stmt->bind_param("ss", $datum, $datum); + $stmt->execute(); + $r = $stmt->get_result(); + while ($z = $r->fetch_assoc()) { + $laeufe[intval($z["automation_id"])][] = substr($z["ts"], 11, 5); + } + $stmt->close(); + + $ids = array_map("intval", array_column($db->query("SELECT id FROM automations")->fetch_all(MYSQLI_ASSOC), "id")); + $liste = []; + foreach ($ids as $id) { + $a = loadAutomation($id); + if (!$a) { + continue; + } + $von = zlMinuten($a["window_from"]) ?? 0; + $bis = zlMinuten($a["window_to"]) ?? 1439; + $ganzerTag = $von === 0 && $bis >= 1439; + + // Je ODER-Gruppe: Zeitpunkt, frühester Beginn, spätestes Ende, + // Kette, wartet auf Messwert. Innerhalb der Gruppe UND. + $gruppen = []; + foreach ($a["conditions"] as $c) { + $g = &$gruppen[$c["group"]]; + $g = $g ?? ["punkt" => null, "start" => null, "ende" => null, "kette" => null, + "versatz" => 0, "sensor" => false, "sonne" => false]; + $st = $zeitStates[$c["state_id"]] ?? null; + $op = $c["operator"]; + if ($st && $st["actor_url"] === "Logic" && $st["type"] === "time") { + $m = zlMinuten($c["value"]); + if ($op === "=") $g["punkt"] = $m; + elseif ($op === ">=") $g["start"] = max($g["start"] ?? 0, $m); + elseif ($op === "<") $g["ende"] = min($g["ende"] ?? 1440, $m); + } elseif ($st && $st["actor_url"] === "Logic" && $st["type"] === "deltatime") { + $basis = $st["url"] === "sunrise" ? $sonne["auf"] : $sonne["unter"]; + $versatz = zlMinuten($c["value"]) ?? 0; + if ($basis === null) { + $g["sensor"] = true; + continue; + } + $m = $basis + (substr($op, -1) === "-" ? -$versatz : $versatz); + $g["sonne"] = true; + if ($op === "+" || $op === "-") $g["punkt"] = $m; + elseif (strpos($op, ">=") === 0) $g["start"] = max($g["start"] ?? 0, $m); + else $g["ende"] = min($g["ende"] ?? 1440, $m); + } elseif ($st && $st["actor_url"] === "Automatik" && preg_match('/^auto:(\d+)$/', $st["url"], $t)) { + $g["kette"] = intval($t[1]); + $g["versatz"] = zlMinuten($c["value"]) ?? 0; + } elseif ($st && $st["actor_url"] === "Logic") { + // Datum: ein Tagesfilter, keine Lage auf dem Tag. + } else { + $g["sensor"] = true; + } + unset($g); + } + + [$dran, $grund] = zlTagPasst($a, $datum); + $eintrag = [ + "id" => $id, + "name" => $a["name"], + "floor" => $a["floor"], + "enabled" => $a["enabled"], + "dran" => $dran, + "grund" => $grund, + "bahn" => zlBahn($a["actions"], $geraete), + "satz" => zlSatz($a["actions"], $geraete, $kommandos), + "wenn" => describeConditions($a["conditions"], $namesState), + "laeufe" => $laeufe[$id] ?? [], + "art" => "jederzeit", + "punkt" => null, + "von" => null, + "bis" => null, + "kette" => null, + "versatz" => 0, + "sonne" => false, + "wartet" => false, + ]; + + $punkte = array_filter(array_column($gruppen, "punkt"), fn($m) => $m !== null); + $ketten = array_filter($gruppen, fn($g) => $g["kette"] !== null); + $eintrag["wartet"] = (bool)array_filter($gruppen, fn($g) => $g["sensor"]); + $eintrag["sonne"] = (bool)array_filter($gruppen, fn($g) => $g["sonne"]); + + if ($punkte) { + $eintrag["art"] = "punkt"; + $eintrag["punkt"] = min($punkte); + } elseif ($ketten) { + $k = reset($ketten); + $eintrag["art"] = "kette"; + $eintrag["kette"] = $k["kette"]; + $eintrag["versatz"] = $k["versatz"]; + // Die eigenen Grenzen der Kette - aufgelöst wird unten, wenn der + // Auslöser bekannt ist. + $eintrag["_von"] = max($von, $k["start"] ?? $von); + $eintrag["_bis"] = min($bis, $k["ende"] ?? $bis); + } else { + $anfaenge = []; + $enden = []; + $zeitlich = false; + foreach ($gruppen as $g) { + if ($g["start"] !== null || $g["ende"] !== null) $zeitlich = true; + $anfaenge[] = max($von, $g["start"] ?? $von); + $enden[] = min($bis, $g["ende"] ?? $bis); + } + if ($zeitlich || !$ganzerTag) { + $eintrag["art"] = "fenster"; + $eintrag["von"] = $anfaenge ? min($anfaenge) : $von; + $eintrag["bis"] = $enden ? max($enden) : $bis; + // Der früheste zeitlich bestimmte Beginn, falls es einen gibt - + // bei "Sonnenuntergang + 30 oder dunkel" die Sonnenmarke. + $marken = array_filter(array_column($gruppen, "start"), fn($m) => $m !== null); + if ($marken) { + $eintrag["punkt"] = min($marken); + } + } + } + $liste[$id] = $eintrag; + } + + /* + * Ketten auflösen. Die Lage hängt am Auslöser: + * + * Auslöser lief schon Punkt bei dessen Zeit + Versatz + * Auslöser hat einen Punkt Punkt dort + Versatz + * Auslöser ist ein Fenster Fenster ab dessen Beginn + Versatz + * + * Jeweils nicht vor dem eigenen Fensterbeginn und nicht nach dem eigenen + * Ende. Mehrstufig (Wecker -> Rollos -> Schlafzimmer), deshalb reihum. + */ + $aufgeloest = []; + for ($runde = 0; $runde < 6; $runde++) { + foreach ($liste as $id => $e) { + if ($e["art"] !== "kette" || isset($aufgeloest[$id])) { + continue; + } + $vater = $liste[$e["kette"]] ?? null; + if (!$vater || ($vater["art"] === "kette" && !isset($aufgeloest[$e["kette"]]))) { + continue; + } + $aufgeloest[$id] = true; + $eigenVon = $e["_von"]; + $eigenBis = $e["_bis"]; + if ($vater["laeufe"]) { + $liste[$id]["punkt"] = max($eigenVon, zlMinuten(end($vater["laeufe"])) + $e["versatz"]); + } elseif ($vater["punkt"] !== null && $vater["art"] !== "fenster") { + $liste[$id]["punkt"] = max($eigenVon, $vater["punkt"] + $e["versatz"]); + } elseif ($vater["art"] === "fenster") { + $liste[$id]["art"] = "fenster"; + $liste[$id]["von"] = max($eigenVon, $vater["von"] + $e["versatz"]); + $liste[$id]["bis"] = max($liste[$id]["von"], $eigenBis); + } else { + $liste[$id]["art"] = "jederzeit"; // Auslöser ohne Lage: dann eben ohne + } + // Eine Kette erbt, ob der Tag passt: läuft der Wecker nicht, kommt auch der Nachfolger nicht. + if (!$vater["dran"] && $liste[$id]["dran"]) { + $liste[$id]["dran"] = false; + $liste[$id]["grund"] = "Auslöser „" . $vater["name"] . "“ ist nicht dran"; + } + } + } + foreach ($liste as $id => $e) { + if ($e["art"] === "kette" && $e["punkt"] === null) { + $liste[$id]["art"] = "jederzeit"; // Kreis oder fehlender Auslöser + } + unset($liste[$id]["_von"], $liste[$id]["_bis"]); + } + + $etagen = []; + foreach (grundriss()[0] as $code => $e) { + $etagen[] = ["code" => $code, "label" => $e["label"]]; + } + $heute = (new DateTimeImmutable("today"))->format("Y-m-d"); + return [ + "datum" => $datum, + "heute" => $datum === $heute, + "jetzt" => $datum === $heute ? intval(date("G")) * 60 + intval(date("i")) : null, + "sonne" => $sonne, + "bahnen" => zeitleisteBahnen(), + "etagen" => $etagen, + "automatiken" => array_values($liste), + ]; +}