Files
Smart-Dashboard/restricted/zeitleiste.php
T
adminandClaude Opus 5 fe8630eb4f Ueberall dasselbe Beschattungs-Symbol (bi-beschattung)
Raum-Modal, Zeitleiste und Automatik-Editor zeigten drei verschiedene
Zeichen; bi-blinds gibt es in der Icon-Schrift gar nicht. Das neue Symbol
liegt als CSS-Maske in solar.css und verhaelt sich wie ein Icon aus der
Schrift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:37:42 +02:00

420 lines
17 KiB
PHP

<?php
/**
* Die Automatiken eines Tages als Zeitleiste.
*
* Die Übersicht als Tabelle beantwortete nicht, was man eigentlich wissen
* will: läuft der Tag stimmig, und warum ist etwas noch nicht gefahren? Die
* Zeitleiste legt deshalb jede Automatik auf die Stunde, zu der sie greift -
* und zwar ausnahmslos jede, auch künftige, nach festen Regeln:
*
* feste Uhrzeit (um / ab 07:00) Punkt
* Sonnenauf-/-untergang ± Versatz Punkt, an diesem Tag ausgerechnet
* Kette (andere Automatik + 00:10) Punkt hinter dem Auslöser
* Zeitfenster und Messwerte Balken über das Fenster
* gar keine Zeit, nur Messwerte "jederzeit"
*
* Die Bahn - die Zeile, in der sie steht - ergibt sich aus den Geräten, die
* sie schaltet: dieselbe Einordnung wie im Raum-Modal (bedienform() in
* roomControls.php), die Mehrheit gewinnt. Bewässerung ist dort "sonstiges"
* und wird hier am Gerätetyp erkannt.
*
* Ob eine Automatik an diesem Tag überhaupt dran ist, wird genau so
* gerechnet wie im Runner (tag_passt und gemeinter_tag in
* autoaction_runner.py) - sonst hieße "heute nicht" hier etwas anderes als
* dort. Ausgeblendet wird trotzdem nichts: wer nicht dran ist, steht
* gestrichelt da, mit Grund.
*
* Gerechnet wird nur die Lage auf dem Tag, nicht, ob eine Bedingung gerade
* erfüllt ist. Das entscheidet der Runner; hier steht, ob sie gelaufen ist
* (automation_log) und wann sie frühestens könnte.
*/
require_once(__DIR__ . "/automations.php");
require_once(__DIR__ . "/roomControls.php");
require_once(__DIR__ . "/costs.php"); // solarDb() für die Sonnenzeiten
/** Reihenfolge der Bahnen und wie sie heißen. */
function zeitleisteBahnen()
{
return [
"licht" => ["titel" => "Licht", "symbol" => "bi-lightbulb"],
"beschattung" => ["titel" => "Beschattung", "symbol" => "bi-beschattung"],
"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),
];
}