Etagen, Raeume, Kachelpositionen und Kachelwerte standen als Tabelle in restricted/rooms.php, die Werte zusaetzlich ueberschreibbar in solarLog.kachelWerte. Fuer ein anderes Haus haette man den Quelltext umschreiben muessen. Jetzt liegen sie in homeMesh.floors und homeMesh.rooms (homeMesh_grundriss.sql). rooms.php liest sie und gibt sie in der bisherigen Form weiter; Startseite, Menue, Editor und Einstellungen sind unveraendert - vorher und nachher als HTML verglichen, gleich bis auf Livewerte und die zwei neuen Felder nr und thermostat in homeRooms. - Vorgabe einer Kachel ist jetzt allein das Thermostat (rooms.thermostat): Soll, Ist, Feuchte - sonst nichts. Was bisher im Code als Vorgabe stand, gilt damit als selbst gesetzt. - Grundrissbild je Etage (floors.bild) statt assets/img/<code>.png. - Standard-Etage (floors.standard) statt "OG" an vier Stellen. - meshDb() in eigener Datei, sonst Ring zwischen rooms.php und automations.php. - solarLog_kacheln.sql entfaellt; die Tabelle kachelWerte liest niemand mehr. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1400 lines
52 KiB
PHP
1400 lines
52 KiB
PHP
<?php
|
||
/**
|
||
* Modell-Schicht fuer die Automatiken (AutoActions).
|
||
*
|
||
* Hier liegt alles, was Editor und Uebersicht gemeinsam brauchen: die
|
||
* Verbindung zur homeMesh-Datenbank, der Geraetekatalog, Laden und Speichern
|
||
* einer Automatik und die Beschreibung in Alltagssprache. Die Ajax-Endpunkte
|
||
* sind damit reine Verteiler.
|
||
*
|
||
* Tabellen siehe homeMesh_automations.sql. Kurzfassung: eine Automatik hat
|
||
* Bedingungen (Verweis auf actor_states) und Aktionen (Verweis auf
|
||
* actor_commands, Werte je command_parameters). Bedingungen mit derselben
|
||
* group_no sind mit UND verknuepft, verschiedene Gruppen mit ODER -
|
||
* ausgewertet wird any(all(gruppe)).
|
||
*/
|
||
|
||
require_once(__DIR__ . "/meshdb.php");
|
||
require_once(__DIR__ . "/rooms.php");
|
||
|
||
/**
|
||
* Operatoren, die zu einem Datentyp passen. Immer ASCII - die huebschen
|
||
* Zeichen macht erst die Anzeige daraus. Frueher schickte der Editor sein
|
||
* innerHTML ("≠", ">") an den Server, wo eine Whitelist mit "!=" stand:
|
||
* jede Bedingung "ungleich" wurde still zu "gleich".
|
||
*
|
||
* Bei Zeit und Datum heissen die Operatoren "um", "ab" und "vor":
|
||
*
|
||
* um 16:30 ab dieser Minute, und noch ein paar Minuten danach
|
||
* (catchup_minutes im Runner, Vorgabe fuenf). Der uebliche
|
||
* Fall - einmal taeglich zu einem festen Zeitpunkt. Das
|
||
* Fenster kostet nichts, weil ohnehin nur die steigende
|
||
* Flanke zaehlt; es sorgt nur dafuer, dass ein Neustart oder
|
||
* ein langsamer Durchlauf den Termin nicht verschluckt.
|
||
* ab 16:30 ab da bis Mitternacht wahr. Ausgeloest wird trotzdem nur
|
||
* einmal, weil nur die steigende Flanke zaehlt; dafuer kostet
|
||
* ein verpasster Takt nicht den ganzen Tag. Fuer alles, was
|
||
* laufen muss, auch wenn der Termin selbst verpasst wurde,
|
||
* ist das die richtige Wahl - etwa die Bewaesserung.
|
||
* vor 16:30 bis dahin wahr, danach nicht mehr.
|
||
*
|
||
* Beim Sonnenauf- und -untergang ist der Wert ein Versatz, der davor oder
|
||
* danach liegen kann - deshalb dieselben drei Faelle noch einmal mal zwei:
|
||
* "+ 00:30" eine halbe Stunde nach Sonnenaufgang, "ab - 00:30" ab einer
|
||
* halben Stunde davor, "vor + 00:30" bis eine halbe Stunde danach.
|
||
*
|
||
* Dieselbe Schreibweise traegt die Verkettung. Das gerechnete Geraet
|
||
* "Automatiken" fuehrt jede Automatik als Messwert, ihr Wert ist der
|
||
* Zeitpunkt der letzten Ausloesung (Datentyp `elapsed`) - "Wecker Magdalena
|
||
* + 00:10" heisst zehn Minuten danach. Ein Minus gibt es dort nicht.
|
||
*/
|
||
function operatorsForType($type)
|
||
{
|
||
switch ($type) {
|
||
case "integer":
|
||
case "float":
|
||
return ["=", "!=", ">", "<"];
|
||
case "time":
|
||
case "date":
|
||
case "datetime":
|
||
return ["=", ">=", "<"];
|
||
case "deltatime": // Sonnenauf-/-untergang: Wert ist ein Versatz
|
||
return ["+", "-", ">=+", ">=-", "<+", "<-"];
|
||
case "elapsed": // andere Automatik: Wert ist ein Versatz danach
|
||
// Nur die drei Formen "danach". Ein Minus gibt es nicht: vor dem
|
||
// Auslöser kann nichts liegen, das erst der Auslöser anstößt.
|
||
return ["+", ">=+", "<+"];
|
||
case "bool":
|
||
return ["="]; // JA/NEIN steht im Wertfeld, nicht im Operator
|
||
default:
|
||
return ["=", "!="];
|
||
}
|
||
}
|
||
|
||
/** Operatoren mit ihrer Beschriftung, so wie der Editor sie anbietet. */
|
||
function operatorChoices($type)
|
||
{
|
||
$liste = [];
|
||
foreach (operatorsForType($type) as $op) {
|
||
$liste[] = ["value" => $op, "label" => operatorLabel($op, $type)];
|
||
}
|
||
return $liste;
|
||
}
|
||
|
||
/**
|
||
* Die aktuellen Werte zu einer Liste von Messwerten.
|
||
*
|
||
* Der Editor zeigt neben jeder Bedingung, was der Messwert gerade sagt, und
|
||
* holt sich das alle paar Sekunden nach. Bewusst aus actor_states und nicht
|
||
* aus MQTT: ueber den Broker kommt nur ein Teil der Geraete: die Jalousien
|
||
* haengen an der Tahoma-Box, die gerechneten Werte an gar nichts. Der Runner
|
||
* schreibt dagegen jeden Messwert zurueck, egal ueber welchen Weg er ihn
|
||
* geholt hat - eine Tabelle, ein Zugriff, alle Geraetearten.
|
||
*
|
||
* Die Ids kommen aus dem Browser und werden deshalb zu Zahlen gemacht, bevor
|
||
* sie in die Abfrage gehen.
|
||
*/
|
||
function stateValues(array $ids)
|
||
{
|
||
$ids = array_values(array_unique(array_filter(array_map("intval", $ids))));
|
||
if (!$ids) {
|
||
return [];
|
||
}
|
||
$db = meshDb();
|
||
$liste = implode(",", $ids);
|
||
$res = $db->query("SELECT id, current_value, unit FROM actor_states
|
||
WHERE id IN (" . $liste . ")");
|
||
$werte = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$werte[intval($row["id"])] = [
|
||
"value" => $row["current_value"],
|
||
"unit" => $row["unit"],
|
||
];
|
||
}
|
||
return $werte;
|
||
}
|
||
|
||
/**
|
||
* Ferien und Feiertage sind dreiwertig, nicht ja/nein.
|
||
*
|
||
* Zwei Zustaende gab es immer: "egal" (der Tag aendert nichts) und "nie"
|
||
* (an solchen Tagen laeuft die Automatik nicht - so ist "werktags" gebaut).
|
||
* "Zusaetzlich" ist der dritte und zaehlt wie ein passender Wochentag.
|
||
*
|
||
* Ohne ihn liess sich "an Wochenenden und Feiertagen" gar nicht schreiben:
|
||
* die Wochentagsmaske kennt nur Samstag und Sonntag, und ein Feiertag am
|
||
* Dienstag ist eben ein Dienstag. Jetzt genuegt Sa+So angehakt und
|
||
* "Feiertage: zusaetzlich".
|
||
*
|
||
* Ein Verbot schlaegt eine Erweiterung: wer in den Ferien nie laeuft und an
|
||
* Feiertagen zusaetzlich, laeuft an einem Feiertag in den Ferien nicht.
|
||
* Andersherum liesse sich "nie" nicht mehr verlassen. Der Runner haelt
|
||
* dieselbe Reihenfolge ein (tag_passt in autoaction_runner.py).
|
||
*/
|
||
function kalendertagChoices($was, $wann)
|
||
{
|
||
// Drei kurze Worte auf einer Achse, von eng nach weit: nie - egal -
|
||
// immer. Sie stehen als Schalterreihe neben "Ferien" bzw. "Feiertage",
|
||
// die Erklaerung haengt als title daran und der ganze Rahmen steht
|
||
// darunter noch einmal als Satz.
|
||
//
|
||
// Vorher stand die Erklaerung *im* Etikett ("zusaetzlich, auch am
|
||
// falschen Wochentag"). Ein Auswahlfeld, dessen Eintraege Saetze sind,
|
||
// liest sich wie eine Bedienungsanleitung und zeigt trotzdem nur den
|
||
// gewaehlten Satz - man sieht nie, was es sonst noch gibt.
|
||
return [
|
||
["value" => 0, "label" => "nie",
|
||
"title" => "Läuft " . $wann . " nicht."],
|
||
["value" => 1, "label" => "egal",
|
||
"title" => $was . " ändern nichts."],
|
||
["value" => 2, "label" => "immer",
|
||
"title" => "Läuft " . $wann . ", auch wenn kein Wochentag angehakt ist."],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Vorlagen fuer die Tagesauswahl - die drei Faelle, die man wirklich meint.
|
||
*
|
||
* "Werktags" heisst nicht nur Mo-Fr, sondern auch "nicht an Feiertagen", und
|
||
* "Wochenende" heisst fast immer "Wochenende und Feiertage". Wer das aus
|
||
* Wochentagsmaske und zwei Dreifachschaltern von Hand zusammensetzt, vergisst
|
||
* den zweiten Teil - deshalb steht er hier mit drin.
|
||
*/
|
||
function tagVorlagen()
|
||
{
|
||
return [
|
||
["label" => "täglich", "weekdays" => 127, "on_vacation" => 1, "on_holiday" => 1,
|
||
"title" => "Jeden Tag, Feiertage eingeschlossen."],
|
||
["label" => "werktags", "weekdays" => 31, "on_vacation" => 1, "on_holiday" => 0,
|
||
"title" => "Montag bis Freitag, aber nicht an Feiertagen."],
|
||
["label" => "Wochenende", "weekdays" => 96, "on_vacation" => 1, "on_holiday" => 2,
|
||
"title" => "Samstag und Sonntag, dazu alle Feiertage."],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Ein Kalenderwert aus dem Editor - alles Krumme wird zu "egal".
|
||
*
|
||
* is_numeric davor, und zwar mit Absicht: intval("") ist 0, und 0 heisst hier
|
||
* "nie". Eine leere oder verunglueckte Angabe wuerde eine Automatik sonst
|
||
* stillschweigend an Feiertagen abschalten - eine Regelaenderung, die niemand
|
||
* angeordnet hat und die man erst am naechsten Feiertag bemerkt. "Egal" ist
|
||
* die harmlose Vorgabe: der Tag aendert dann nichts.
|
||
*/
|
||
function kalendertagWert($wert)
|
||
{
|
||
if (!is_numeric($wert)) {
|
||
return 1;
|
||
}
|
||
$zahl = intval($wert);
|
||
return in_array($zahl, [0, 1, 2], true) ? $zahl : 1;
|
||
}
|
||
|
||
/**
|
||
* Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet.
|
||
*
|
||
* Gedacht gegen Messwerte, die um die Schwelle pendeln - "Temperatur > 22"
|
||
* bei 22,1 / 21,9 / 22,1 Grad ist jedes Mal eine echte steigende Flanke, und
|
||
* ohne Sperre ginge jedes Mal ein Kommando raus. Wie schlimm das ist, haengt
|
||
* am Geraet: ein Rollladen soll nicht alle zwanzig Sekunden losfahren, eine
|
||
* Lichtfarbe darf das.
|
||
*
|
||
* Gespeichert werden Sekunden, angeboten werden nur diese Stufen - eine
|
||
* vierte ist damit eine Zeile und keine Wanderung durch die Datenbank.
|
||
*/
|
||
function lockoutChoices()
|
||
{
|
||
return [
|
||
["value" => 0, "label" => "Ohne – jede Flanke schaltet"],
|
||
["value" => 60, "label" => "1 Minute – z.B. Licht und Farbe"],
|
||
["value" => 900, "label" => "15 Minuten – z.B. Rollläden und Ventile"],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Auswahlwerte aus possible_values in eine einheitliche Form bringen.
|
||
*
|
||
* In der Datenbank stehen zwei Schreibweisen. Eine schlichte Liste
|
||
* (["open","close"]) und eine Liste aus Ein-Schluessel-Objekten
|
||
* ([{"0":"Unknown"},{"1":"Idle"}]) - letztere ueberall dort, wo das Geraet
|
||
* eine Zahl schickt und einen Zustand meint.
|
||
*
|
||
* Was davon der gespeicherte Wert ist, haengt an der Seite:
|
||
*
|
||
* Messwert: der Runner uebersetzt die Zahl schon beim Lesen in den
|
||
* Klartext, eine Bedingung vergleicht also "Charging".
|
||
* Parameter: das Geraet will die Zahl - gespeichert wird der Schluessel,
|
||
* angezeigt der Klartext.
|
||
*/
|
||
function optionList($json, $klartextAlsWert)
|
||
{
|
||
$roh = json_decode($json, true);
|
||
if (!is_array($roh)) {
|
||
return [];
|
||
}
|
||
$liste = [];
|
||
foreach ($roh as $eintrag) {
|
||
if (is_array($eintrag)) {
|
||
foreach ($eintrag as $wert => $bezeichnung) {
|
||
$liste[] = [
|
||
"value" => $klartextAlsWert ? strval($bezeichnung) : strval($wert),
|
||
"label" => strval($bezeichnung),
|
||
];
|
||
}
|
||
} else {
|
||
$liste[] = ["value" => strval($eintrag), "label" => strval($eintrag)];
|
||
}
|
||
}
|
||
return $liste;
|
||
}
|
||
|
||
/** Wie das Wertfeld im Editor aussieht. */
|
||
function inputForType($type, $hasOptions)
|
||
{
|
||
if ($type === "bool") {
|
||
return "bool";
|
||
}
|
||
if ($hasOptions) {
|
||
return "select";
|
||
}
|
||
switch ($type) {
|
||
case "integer":
|
||
case "float":
|
||
return "number";
|
||
case "time":
|
||
case "deltatime":
|
||
case "elapsed":
|
||
return "time";
|
||
case "date":
|
||
return "date";
|
||
case "datetime":
|
||
return "datetime-local";
|
||
default:
|
||
return "text";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Anzeigeform eines Operators. Bei Zeitangaben liest sich "um"/"ab"/"vor"
|
||
* verstaendlicher als "="/">="/"<", und beim Sonnenstand steht das
|
||
* Vorzeichen des Versatzes dahinter: "ab + 00:30" heisst ab einer halben
|
||
* Stunde nach Sonnenaufgang.
|
||
*/
|
||
function operatorLabel($op, $type = "")
|
||
{
|
||
if ($type === "time" || $type === "datetime") {
|
||
if ($op === "=") return "um";
|
||
if ($op === ">=") return "ab";
|
||
if ($op === "<") return "vor";
|
||
}
|
||
if ($type === "date") {
|
||
if ($op === "=") return "am";
|
||
if ($op === ">=") return "ab";
|
||
if ($op === "<") return "vor";
|
||
}
|
||
if ($type === "deltatime") {
|
||
$beschriftung = ["+" => "+", "-" => "-", ">=+" => "ab +", ">=-" => "ab -",
|
||
"<+" => "vor +", "<-" => "vor -"];
|
||
if (isset($beschriftung[$op])) {
|
||
return $beschriftung[$op];
|
||
}
|
||
}
|
||
if ($type === "elapsed") {
|
||
// Dieselben Wörter wie beim Sonnenstand, damit ein Versatz überall
|
||
// gleich gelesen wird: "Wecker + 00:10" sind zehn Minuten danach.
|
||
$beschriftung = ["+" => "+", ">=+" => "ab +", "<+" => "vor +"];
|
||
if (isset($beschriftung[$op])) {
|
||
return $beschriftung[$op];
|
||
}
|
||
}
|
||
return $op === "!=" ? "≠" : $op;
|
||
}
|
||
|
||
/**
|
||
* Technische Zutaten aus einem Geraetenamen nehmen, ohne ihn in der
|
||
* Datenbank anzufassen.
|
||
*
|
||
* Die Discovery vergibt Namen, wie das Geraet sie meldet: "u_Wozi
|
||
* Terrassentuer" (das u_ stammt aus der Tahoma-Beschriftung),
|
||
* "Power_EG_EM_0" oder "Handtuchtrockner_OG_Relay_0" (die angehaengte Ziffer
|
||
* ist der Kanal, und es gibt nur einen). In der Auswahlliste stoert das nur.
|
||
*
|
||
* Bewusst nur die einstellige Endung: "go-eCharger_270003" traegt seine
|
||
* Seriennummer, die bleibt stehen.
|
||
*/
|
||
function anzeigeName($name)
|
||
{
|
||
$name = preg_replace('/^u_/', '', $name);
|
||
$name = preg_replace('/_\d$/', '', $name);
|
||
$name = str_replace('_', ' ', $name);
|
||
return trim(preg_replace('/\s+/', ' ', $name));
|
||
}
|
||
|
||
/**
|
||
* Waehlbare Raeume: die Raeume der Home-Ansicht.
|
||
*
|
||
* Einzige Quelle bleibt restricted/rooms.php - ein neuer Raum ist dort eine
|
||
* Zeile und taucht hier automatisch auf. Der Schluessel ist "Etage/Raum",
|
||
* weil "Bad" allein auf drei Etagen vorkommt.
|
||
*
|
||
* Frueher stand hier ein Pseudo-Raum "aussen" fuer alles, was zu keinem Raum
|
||
* der Wohnung gehoert - Carport, Pergola, Garage, die Wechselrichter. Das war
|
||
* ein einziger Topf mit sechzehn Geraeten. Inzwischen ist das Aussengelaende
|
||
* die Etage AG mit eigenen Raeumen, und dieser Sonderfall ist keiner mehr.
|
||
*
|
||
* Etage und Raum stehen zusaetzlich einzeln daneben: die Geraeteauswahl im
|
||
* Editor gruppiert nach der einen und sortiert nach dem anderen, und der
|
||
* Schluessel soll dafuer nicht erst im JavaScript wieder auseinandergenommen
|
||
* werden. Die Reihenfolge dieser Liste ist zugleich die Reihenfolge der
|
||
* Ueberschriften dort - erst OG, dann EG, dann UG, wie in rooms.php notiert.
|
||
*/
|
||
function raumListe()
|
||
{
|
||
$liste = [];
|
||
foreach (allRooms() as $raum) {
|
||
$liste[] = [
|
||
"key" => $raum["floor"] . "/" . $raum["mqtt"],
|
||
"label" => $raum["floor"] . " · " . $raum["mqtt"],
|
||
"floor" => $raum["floor"],
|
||
"room" => $raum["mqtt"],
|
||
];
|
||
}
|
||
return $liste;
|
||
}
|
||
|
||
/** Anzeigename eines gespeicherten Raumschluessels. */
|
||
function raumBeschriftung($key)
|
||
{
|
||
if (!$key) {
|
||
return "Nicht zugeordnet";
|
||
}
|
||
foreach (raumListe() as $raum) {
|
||
if ($raum["key"] === $key) {
|
||
return $raum["label"];
|
||
}
|
||
}
|
||
// Raum aus rooms.php entfernt - der Eintrag am Geraet bleibt stehen,
|
||
// damit eine Umbenennung dort keine Zuordnung stillschweigend loescht.
|
||
return $key . " (unbekannt)";
|
||
}
|
||
|
||
/**
|
||
* Etage und Raumname zu einem gespeicherten Raumschluessel.
|
||
*
|
||
* Die Geraeteauswahl im Editor braucht beides getrennt: die Etage wird zur
|
||
* Ueberschrift, der Raum zur Sortierung darunter. Geraete ohne Zuordnung
|
||
* bekommen ihre eigene Ueberschrift ganz am Ende; ein Raum, den es in
|
||
* rooms.php nicht mehr gibt, ebenso - er verschwindet nicht still zwischen
|
||
* den Unzugeordneten, sondern faellt auf.
|
||
*/
|
||
function raumEinordnung($key)
|
||
{
|
||
if (!$key) {
|
||
return ["floor" => "Ohne Raum", "room" => ""];
|
||
}
|
||
foreach (raumListe() as $raum) {
|
||
if ($raum["key"] === $key) {
|
||
return ["floor" => $raum["floor"], "room" => $raum["room"]];
|
||
}
|
||
}
|
||
return ["floor" => "Unbekannter Raum", "room" => $key];
|
||
}
|
||
|
||
/**
|
||
* Raumvorschlaege aus den MQTT-Topics.
|
||
*
|
||
* Die Thermostate tragen ihren Raum im Topic: "Raumtemp/OG/Bad/Temp[degC]".
|
||
* Das ist keine Rateaufgabe, sondern dieselbe Schreibweise wie in rooms.php,
|
||
* und deckt dreizehn Geraete ohne Zutun ab. Bei allen anderen wird bewusst
|
||
* nicht geraten: "Bad Links" sagt nichts darueber, auf welcher der drei
|
||
* Etagen dieses Bad liegt.
|
||
*/
|
||
function raumVorschlaege()
|
||
{
|
||
$bekannt = [];
|
||
foreach (raumListe() as $raum) {
|
||
$bekannt[strtolower($raum["key"])] = $raum["key"];
|
||
}
|
||
$res = meshDb()->query("SELECT actor_id, url FROM actor_states
|
||
WHERE url LIKE 'Raumtemp/%/%/%'");
|
||
$vorschlag = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$teile = explode("/", $row["url"]);
|
||
if (count($teile) < 3) {
|
||
continue;
|
||
}
|
||
$key = strtolower($teile[1] . "/" . $teile[2]);
|
||
if (isset($bekannt[$key])) {
|
||
$vorschlag[intval($row["actor_id"])] = $bekannt[$key];
|
||
}
|
||
}
|
||
return $vorschlag;
|
||
}
|
||
|
||
/** Zuordnungen speichern. $zuordnung ist [actor_id => Raumschluessel|""]. */
|
||
function saveRooms($zuordnung)
|
||
{
|
||
$db = meshDb();
|
||
$erlaubt = array_column(raumListe(), "key");
|
||
$stmt = $db->prepare("UPDATE actors SET room = ? WHERE id = ?");
|
||
$anzahl = 0;
|
||
foreach ($zuordnung as $actorId => $key) {
|
||
$actorId = intval($actorId);
|
||
$key = strval($key);
|
||
if ($actorId <= 0) {
|
||
continue;
|
||
}
|
||
if ($key !== "" && !in_array($key, $erlaubt, true)) {
|
||
throw new InvalidArgumentException("Unbekannter Raum: " . $key);
|
||
}
|
||
$wert = $key === "" ? null : $key;
|
||
$stmt->bind_param("si", $wert, $actorId);
|
||
$stmt->execute();
|
||
$anzahl++;
|
||
}
|
||
$stmt->close();
|
||
return $anzahl;
|
||
}
|
||
|
||
/**
|
||
* Die Geraeteliste fuer die Einstellungsseite.
|
||
*
|
||
* Enthaelt alles, was man ueber ein Geraet wissen will, ohne es anzuklicken:
|
||
* Beschriftung, Art, Raum, ein Vorschlag fuer den Raum (aus dem MQTT-Topic),
|
||
* wie viele Messwerte und Kommandos daran haengen, wann zuletzt etwas davon
|
||
* gemeldet wurde - und in welchen Automatiken es steckt.
|
||
*
|
||
* Das Letzte ist der Grund, warum die Liste hier steht und nicht in der Maske:
|
||
* ein Geraet, auf das eine Automatik zeigt, darf nicht verschwinden, und wer
|
||
* das entscheidet, muss die Verweise kennen.
|
||
*/
|
||
function geraeteListe()
|
||
{
|
||
$db = meshDb();
|
||
$labels = geraeteBeschriftungen();
|
||
$tipps = raumVorschlaege();
|
||
|
||
$geraete = [];
|
||
$res = $db->query("SELECT id, name, type, url, room FROM actors");
|
||
while ($row = $res->fetch_assoc()) {
|
||
$id = intval($row["id"]);
|
||
$wo = raumEinordnung($row["room"]);
|
||
$geraete[$id] = [
|
||
"id" => $id,
|
||
"name" => $row["name"],
|
||
"label" => $labels[$id] ?? $row["name"],
|
||
"type" => $row["type"],
|
||
"url" => $row["url"],
|
||
"room" => $row["room"],
|
||
"roomLabel" => raumBeschriftung($row["room"]),
|
||
"floor" => $wo["floor"],
|
||
"tipp" => $tipps[$id] ?? null,
|
||
"messwerte" => 0,
|
||
"kommandos" => 0,
|
||
"gesehen" => null,
|
||
"benutzt" => [],
|
||
];
|
||
}
|
||
|
||
// Messwerte: Anzahl und juengste Meldung. Letztere sagt, ob ein Geraet
|
||
// noch lebt - ein Eintrag von vor einem halben Jahr ist eine Karteileiche.
|
||
$res = $db->query("SELECT actor_id, COUNT(*) AS n, MAX(last_updated) AS gesehen
|
||
FROM actor_states GROUP BY actor_id");
|
||
while ($row = $res->fetch_assoc()) {
|
||
$id = intval($row["actor_id"]);
|
||
if (isset($geraete[$id])) {
|
||
$geraete[$id]["messwerte"] = intval($row["n"]);
|
||
$geraete[$id]["gesehen"] = $row["gesehen"];
|
||
}
|
||
}
|
||
|
||
$res = $db->query("SELECT actor_id, COUNT(*) AS n FROM actor_commands GROUP BY actor_id");
|
||
while ($row = $res->fetch_assoc()) {
|
||
$id = intval($row["actor_id"]);
|
||
if (isset($geraete[$id])) {
|
||
$geraete[$id]["kommandos"] = intval($row["n"]);
|
||
}
|
||
}
|
||
|
||
// Verweise aus den Automatiken - ueber Messwerte (Bedingungen) und ueber
|
||
// Kommandos (Aktionen).
|
||
$sql = "SELECT s.actor_id AS actor_id, a.id AS aid, a.name AS aname
|
||
FROM automation_conditions c
|
||
JOIN actor_states s ON s.id = c.state_id
|
||
JOIN automations a ON a.id = c.automation_id
|
||
UNION
|
||
SELECT k.actor_id AS actor_id, a.id AS aid, a.name AS aname
|
||
FROM automation_actions x
|
||
JOIN actor_commands k ON k.id = x.command_id
|
||
JOIN automations a ON a.id = x.automation_id";
|
||
$res = $db->query($sql);
|
||
while ($res && $row = $res->fetch_assoc()) {
|
||
$id = intval($row["actor_id"]);
|
||
if (isset($geraete[$id])) {
|
||
$geraete[$id]["benutzt"][intval($row["aid"])] = $row["aname"];
|
||
}
|
||
}
|
||
foreach ($geraete as $id => $g) {
|
||
$geraete[$id]["benutzt"] = array_values($g["benutzt"]);
|
||
}
|
||
|
||
// Nach Etage, Raum und Beschriftung - dieselbe Ordnung wie ueberall.
|
||
$reihenfolge = array_flip($GLOBALS["floors"]);
|
||
$liste = array_values($geraete);
|
||
usort($liste, function ($a, $b) use ($reihenfolge) {
|
||
$wa = explode("/", (string)$a["room"], 2);
|
||
$wb = explode("/", (string)$b["room"], 2);
|
||
// Geraete ohne Raum nach oben: sie sind das, was Arbeit macht.
|
||
$ra = ($a["room"] === null || $a["room"] === "") ? -1 : ($reihenfolge[$wa[0]] ?? 98);
|
||
$rb = ($b["room"] === null || $b["room"] === "") ? -1 : ($reihenfolge[$wb[0]] ?? 98);
|
||
if ($ra !== $rb) {
|
||
return $ra <=> $rb;
|
||
}
|
||
$raum = strcoll($wa[1] ?? "", $wb[1] ?? "");
|
||
return $raum !== 0 ? $raum : strcoll($a["label"], $b["label"]);
|
||
});
|
||
return $liste;
|
||
}
|
||
|
||
/**
|
||
* Ein Geraet samt Messwerten, Kommandos und Parametern loeschen.
|
||
*
|
||
* Gedacht fuer Karteileichen: der Suchlauf loescht nichts (clear_tables steht
|
||
* bewusst auf false, weil die Automatiken auf die Ids zeigen), also bleibt ein
|
||
* abgebautes Geraet fuer immer in der Liste stehen.
|
||
*
|
||
* Zeigt eine Automatik darauf, wird nicht geloescht, sondern abgelehnt - mit
|
||
* den Namen der Automatiken. Sie stillschweigend mitzuloeschen waere ein
|
||
* stiller Datenverlust, sie kaputt zurueckzulassen ebenfalls. Wer das Geraet
|
||
* wirklich loswerden will, raeumt erst die Automatik auf.
|
||
*
|
||
* Steht das Geraet noch im Netz, findet der naechste Suchlauf es wieder - das
|
||
* ist kein Fehler, sondern der Zweck des Suchlaufs.
|
||
*/
|
||
function geraetLoeschen($id)
|
||
{
|
||
$id = intval($id);
|
||
if ($id <= 0) {
|
||
throw new InvalidArgumentException("Kein Gerät angegeben.");
|
||
}
|
||
$db = meshDb();
|
||
|
||
$res = $db->query("SELECT name FROM actors WHERE id = " . $id);
|
||
$geraet = $res ? $res->fetch_assoc() : null;
|
||
if (!$geraet) {
|
||
throw new InvalidArgumentException("Dieses Gerät gibt es nicht (mehr).");
|
||
}
|
||
|
||
foreach (geraeteListe() as $g) {
|
||
if ($g["id"] === $id && $g["benutzt"]) {
|
||
throw new InvalidArgumentException(
|
||
"Das Gerät steckt in " . count($g["benutzt"]) . " Automatik(en): "
|
||
. implode(", ", $g["benutzt"]) . ". Erst dort entfernen.");
|
||
}
|
||
}
|
||
|
||
$db->begin_transaction();
|
||
try {
|
||
// Von innen nach aussen: Parameter haengen an Kommandos, Kommandos und
|
||
// Messwerte am Geraet.
|
||
$db->query("DELETE p FROM command_parameters p
|
||
JOIN actor_commands k ON k.id = p.command_id
|
||
WHERE k.actor_id = " . $id);
|
||
$db->query("DELETE FROM actor_commands WHERE actor_id = " . $id);
|
||
$db->query("DELETE FROM actor_states WHERE actor_id = " . $id);
|
||
$db->query("DELETE FROM actors WHERE id = " . $id);
|
||
$db->commit();
|
||
} catch (Throwable $e) {
|
||
$db->rollback();
|
||
throw $e;
|
||
}
|
||
return $geraet["name"];
|
||
}
|
||
|
||
/**
|
||
* id => Beschriftung fuer alle Geraete, geputzt und eindeutig.
|
||
*
|
||
* Eine Stelle fuer Editor und Uebersicht, damit in der Auswahlliste nicht
|
||
* "Wozi Terrassentuer" steht und im Satz darunter "u_Wozi Terrassentuer".
|
||
*/
|
||
function geraeteBeschriftungen()
|
||
{
|
||
static $labels = null;
|
||
if ($labels !== null) {
|
||
return $labels;
|
||
}
|
||
$res = meshDb()->query("SELECT id, name, type FROM actors");
|
||
$zeilen = [];
|
||
$haeufigkeit = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$row["label"] = anzeigeName($row["name"]);
|
||
$haeufigkeit[$row["label"]] = ($haeufigkeit[$row["label"]] ?? 0) + 1;
|
||
$zeilen[] = $row;
|
||
}
|
||
// Die Tahoma-Box vergibt Beschriftungen doppelt: "Terasse" ist einmal der
|
||
// Rollladen und einmal der Sonnenenergie-Sensor. Untereinander in der
|
||
// Liste waeren sie nicht auseinanderzuhalten, deshalb kommt bei
|
||
// Doppelungen die Geraeteart dazu.
|
||
$labels = [];
|
||
foreach ($zeilen as $row) {
|
||
$labels[intval($row["id"])] = $haeufigkeit[$row["label"]] > 1
|
||
? $row["label"] . " (" . $row["type"] . ")"
|
||
: $row["label"];
|
||
}
|
||
return $labels;
|
||
}
|
||
|
||
/**
|
||
* Alle Geraete mit ihren Messwerten und Kommandos in einem Rutsch.
|
||
*
|
||
* Der Editor holt den Katalog einmal und baut daraus jede Zeile - frueher
|
||
* kostete jede neue Bedingung zwei zusaetzliche Anfragen (Geraeteliste und
|
||
* Details), und die Dropdowns fuellten sich asynchron nach.
|
||
*/
|
||
function deviceCatalog()
|
||
{
|
||
$db = meshDb();
|
||
$devices = [];
|
||
|
||
$labels = geraeteBeschriftungen();
|
||
$res = $db->query("SELECT id, name, type, url, room FROM actors");
|
||
$zeilen = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$zeilen[] = $row;
|
||
}
|
||
usort($zeilen, function ($a, $b) use ($labels) {
|
||
return strcoll($labels[intval($a["id"])], $labels[intval($b["id"])]);
|
||
});
|
||
foreach ($zeilen as $row) {
|
||
$wo = raumEinordnung($row["room"]);
|
||
$devices[intval($row["id"])] = [
|
||
"id" => intval($row["id"]),
|
||
"name" => $row["name"],
|
||
"label" => $labels[intval($row["id"])],
|
||
"type" => $row["type"],
|
||
"url" => $row["url"],
|
||
"room" => $row["room"],
|
||
"roomLabel" => raumBeschriftung($row["room"]),
|
||
// Etage und Raum getrennt: die Auswahlliste im Editor gruppiert
|
||
// nach der Etage und sortiert darin nach dem Raum.
|
||
"floorLabel" => $wo["floor"],
|
||
"roomName" => $wo["room"],
|
||
"states" => [],
|
||
"commands" => [],
|
||
];
|
||
}
|
||
|
||
/*
|
||
* value_path kommt mit, seit die Einstellungsseite denselben Katalog fuer
|
||
* die Home-Kacheln benutzt: bei Geraeten, die ihre Werte als JSON in einer
|
||
* einzigen Nachricht schicken (der Regensensor etwa), unterscheidet erst
|
||
* dieser Schluessel die sechs Messwerte unter demselben Topic.
|
||
*/
|
||
$res = $db->query("SELECT s.id, s.actor_id, s.state_name, s.unit, s.current_value,
|
||
s.url, s.value_path, s.possible_values, t.type
|
||
FROM actor_states s
|
||
LEFT JOIN state_types t ON s.state_type = t.id
|
||
ORDER BY s.actor_id, s.id");
|
||
while ($row = $res->fetch_assoc()) {
|
||
$id = intval($row["actor_id"]);
|
||
if (!isset($devices[$id])) {
|
||
continue;
|
||
}
|
||
$options = optionList($row["possible_values"], true);
|
||
$type = $row["type"] ?: "string";
|
||
$devices[$id]["states"][] = [
|
||
"id" => intval($row["id"]),
|
||
"name" => $row["state_name"],
|
||
"type" => $type,
|
||
"input" => inputForType($type, count($options) > 0),
|
||
"operators" => operatorChoices($type),
|
||
"options" => $options,
|
||
"unit" => $row["unit"],
|
||
"value" => $row["current_value"],
|
||
"url" => $row["url"],
|
||
// Schluessel in einer JSON-Nachricht, sonst leer - siehe oben.
|
||
"pfad" => $row["value_path"],
|
||
];
|
||
}
|
||
|
||
$res = $db->query("SELECT id, actor_id, command_name, command_url FROM actor_commands
|
||
ORDER BY actor_id, id");
|
||
$commands = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$id = intval($row["actor_id"]);
|
||
if (!isset($devices[$id])) {
|
||
continue;
|
||
}
|
||
$commands[intval($row["id"])] = $id;
|
||
$devices[$id]["commands"][] = [
|
||
"id" => intval($row["id"]),
|
||
"name" => $row["command_name"],
|
||
// Das Raum-Modal erkennt an der URL, was ein Kommando tut:
|
||
// "setClosure" ist eine Position, "up"/"down" faehrt, ein
|
||
// WLED-"{"on":true}" schaltet ein. Am Namen ginge das nicht -
|
||
// der kommt aus dem Geraetesuchlauf und heisst mal "An", mal
|
||
// "turn_on".
|
||
"url" => $row["command_url"],
|
||
"params" => [],
|
||
];
|
||
}
|
||
|
||
if ($commands) {
|
||
$res = $db->query("SELECT p.id, p.command_id, p.parameter_name, p.min_value,
|
||
p.max_value, p.possible_values, p.url, t.type
|
||
FROM command_parameters p
|
||
LEFT JOIN state_types t ON p.parameter_type = t.id
|
||
ORDER BY p.command_id, p.id");
|
||
while ($row = $res->fetch_assoc()) {
|
||
$cmdId = intval($row["command_id"]);
|
||
if (!isset($commands[$cmdId])) {
|
||
continue;
|
||
}
|
||
$options = optionList($row["possible_values"], false);
|
||
$type = $row["type"] ?: "string";
|
||
// Das passende Kommando im Geraet suchen. Wenige Kommandos je
|
||
// Geraet, deshalb reicht die lineare Suche.
|
||
foreach ($devices[$commands[$cmdId]]["commands"] as &$cmd) {
|
||
if ($cmd["id"] === $cmdId) {
|
||
$cmd["params"][] = [
|
||
"id" => intval($row["id"]),
|
||
"name" => $row["parameter_name"],
|
||
"type" => $type,
|
||
"input" => inputForType($type, count($options) > 0),
|
||
"options" => $options,
|
||
"min" => $row["min_value"] === null ? null : floatval($row["min_value"]),
|
||
"max" => $row["max_value"] === null ? null : floatval($row["max_value"]),
|
||
"url" => $row["url"],
|
||
];
|
||
break;
|
||
}
|
||
}
|
||
unset($cmd);
|
||
}
|
||
}
|
||
|
||
return array_values($devices);
|
||
}
|
||
|
||
/** Leere Automatik, wie sie der Editor fuer "Neu anlegen" bekommt. */
|
||
function emptyAutomation($floor)
|
||
{
|
||
return [
|
||
"id" => 0,
|
||
"name" => "",
|
||
// Aufgerufen wird der Editor immer aus einem Etagenreiter. Fehlt die
|
||
// Angabe trotzdem, gilt dieselbe Etage wie auf der Startseite.
|
||
"floor" => in_array($floor, $GLOBALS["floors"], true) ? $floor : standardEtage(),
|
||
"enabled" => true,
|
||
"window_from" => "00:00",
|
||
"window_to" => "23:59",
|
||
"weekdays" => 127,
|
||
"on_vacation" => 1,
|
||
"on_holiday" => 1,
|
||
"next_day" => false,
|
||
"force_once" => false,
|
||
"once_per_day" => false,
|
||
"lockout_secs" => 60,
|
||
"last_run" => null,
|
||
"conditions" => [],
|
||
"actions" => [],
|
||
];
|
||
}
|
||
|
||
/** Eine Automatik mit Bedingungen und Aktionen laden. */
|
||
function loadAutomation($id)
|
||
{
|
||
$db = meshDb();
|
||
$stmt = $db->prepare("SELECT * FROM automations WHERE id = ?");
|
||
$stmt->bind_param("i", $id);
|
||
$stmt->execute();
|
||
$row = $stmt->get_result()->fetch_assoc();
|
||
$stmt->close();
|
||
if (!$row) {
|
||
return null;
|
||
}
|
||
|
||
$auto = [
|
||
"id" => intval($row["id"]),
|
||
"name" => $row["name"],
|
||
"floor" => $row["floor"],
|
||
"enabled" => (bool)$row["enabled"],
|
||
"window_from" => substr($row["window_from"], 0, 5),
|
||
"window_to" => substr($row["window_to"], 0, 5),
|
||
"weekdays" => intval($row["weekdays"]),
|
||
"on_vacation" => intval($row["on_vacation"]),
|
||
"on_holiday" => intval($row["on_holiday"]),
|
||
// Vorabend: der Rahmen gilt fuer morgen. Siehe vorabend.sql im
|
||
// SolarManager; ?? 0 wie bei once_per_day, falls die Spalte fehlt.
|
||
"next_day" => (bool)($row["next_day"] ?? 0),
|
||
"force_once" => (bool)$row["force_once"],
|
||
// ?? 0, damit der Editor auch vor der Migration noch aufgeht:
|
||
// rahmen_erweitern.sql legt die Spalte erst an.
|
||
"once_per_day" => (bool)($row["once_per_day"] ?? 0),
|
||
"lockout_secs" => intval($row["lockout_secs"]),
|
||
"last_run" => $row["last_run"],
|
||
"conditions" => [],
|
||
"actions" => [],
|
||
];
|
||
|
||
$stmt = $db->prepare("SELECT id, group_no, state_id, operator, value
|
||
FROM automation_conditions WHERE automation_id = ?
|
||
ORDER BY group_no, position, id");
|
||
$stmt->bind_param("i", $id);
|
||
$stmt->execute();
|
||
$res = $stmt->get_result();
|
||
while ($c = $res->fetch_assoc()) {
|
||
$auto["conditions"][] = [
|
||
"group" => intval($c["group_no"]),
|
||
"state_id" => intval($c["state_id"]),
|
||
"operator" => $c["operator"],
|
||
"value" => $c["value"],
|
||
];
|
||
}
|
||
$stmt->close();
|
||
|
||
$stmt = $db->prepare("SELECT a.id, a.command_id, p.parameter_id, p.value
|
||
FROM automation_actions a
|
||
LEFT JOIN automation_action_params p ON p.action_id = a.id
|
||
WHERE a.automation_id = ?
|
||
ORDER BY a.position, a.id, p.parameter_id");
|
||
$stmt->bind_param("i", $id);
|
||
$stmt->execute();
|
||
$res = $stmt->get_result();
|
||
$actions = [];
|
||
while ($a = $res->fetch_assoc()) {
|
||
$aid = intval($a["id"]);
|
||
if (!isset($actions[$aid])) {
|
||
$actions[$aid] = ["command_id" => intval($a["command_id"]), "params" => []];
|
||
}
|
||
if ($a["parameter_id"] !== null) {
|
||
$actions[$aid]["params"][strval($a["parameter_id"])] = $a["value"];
|
||
}
|
||
}
|
||
$stmt->close();
|
||
$auto["actions"] = array_values($actions);
|
||
|
||
return $auto;
|
||
}
|
||
|
||
/**
|
||
* Eine Automatik anlegen oder ueberschreiben. Gibt die ID zurueck.
|
||
*
|
||
* Bedingungen und Aktionen werden komplett neu geschrieben statt einzeln
|
||
* abgeglichen: sie haben keine Identitaet, die der Benutzer kennt, und der
|
||
* Editor schickt ohnehin immer den vollstaendigen Stand. Alles laeuft in
|
||
* einer Transaktion, damit eine halb ersetzte Regel nie sichtbar wird - und
|
||
* schon gar nicht ausgefuehrt.
|
||
*/
|
||
function saveAutomation($data)
|
||
{
|
||
$db = meshDb();
|
||
|
||
$name = trim(strval($data["name"] ?? ""));
|
||
if ($name === "") {
|
||
throw new InvalidArgumentException("Bitte einen Namen vergeben.");
|
||
}
|
||
$floor = strval($data["floor"] ?? "");
|
||
if (!in_array($floor, $GLOBALS["floors"], true)) {
|
||
// Nicht stillschweigend auf "" zurueckfallen: die Automatik wuerde
|
||
// dann in keinem der drei Reiter mehr auftauchen.
|
||
throw new InvalidArgumentException("Bitte eine Etage wählen.");
|
||
}
|
||
$conditions = is_array($data["conditions"] ?? null) ? $data["conditions"] : [];
|
||
$actions = is_array($data["actions"] ?? null) ? $data["actions"] : [];
|
||
if (!$conditions) {
|
||
throw new InvalidArgumentException("Ohne Ausloeser wuerde die Automatik nie starten.");
|
||
}
|
||
if (!$actions) {
|
||
throw new InvalidArgumentException("Ohne Aktion haette die Automatik nichts zu tun.");
|
||
}
|
||
pruefeKreis($data["id"] ?? 0, $conditions);
|
||
|
||
$id = intval($data["id"] ?? 0);
|
||
$enabled = !empty($data["enabled"]) ? 1 : 0;
|
||
$windowFrom = normalizeTime($data["window_from"] ?? "00:00", "00:00:00");
|
||
$windowTo = normalizeTime($data["window_to"] ?? "23:59", "23:59:00");
|
||
$weekdays = intval($data["weekdays"] ?? 127) & 127;
|
||
$onVacation = kalendertagWert($data["on_vacation"] ?? 1);
|
||
$onHoliday = kalendertagWert($data["on_holiday"] ?? 1);
|
||
$nextDay = !empty($data["next_day"]) ? 1 : 0;
|
||
$forceOnce = !empty($data["force_once"]) ? 1 : 0;
|
||
$oncePerDay = !empty($data["once_per_day"]) ? 1 : 0;
|
||
// Nur die angebotenen Stufen, sonst die Vorgabe. Eine krumme Zahl kaeme
|
||
// hier nur aus einer selbstgebauten Anfrage.
|
||
$lockout = intval($data["lockout_secs"] ?? 60);
|
||
if (!in_array($lockout, array_column(lockoutChoices(), "value"), true)) {
|
||
$lockout = 60;
|
||
}
|
||
|
||
$db->begin_transaction();
|
||
try {
|
||
if ($id > 0) {
|
||
// Erst nachsehen, ob es die Automatik noch gibt. Ohne das liefe
|
||
// das UPDATE ins Leere und erst der Fremdschluessel der
|
||
// Bedingungen wuerde meckern - mit einer Meldung, die niemandem
|
||
// weiterhilft.
|
||
$stmt = $db->prepare("SELECT id FROM automations WHERE id = ?");
|
||
$stmt->bind_param("i", $id);
|
||
$stmt->execute();
|
||
$exists = $stmt->get_result()->fetch_assoc();
|
||
$stmt->close();
|
||
if (!$exists) {
|
||
throw new RuntimeException("Automatik " . $id . " gibt es nicht mehr.");
|
||
}
|
||
|
||
// changed wird ausdruecklich mitgeschrieben. Es haengt zwar an
|
||
// ON UPDATE, das greift aber nur, wenn sich hier oben wirklich
|
||
// eine Spalte aendert - wer nur eine Bedingung verstellt, laesst
|
||
// Name, Stockwerk und Fenster ja unberuehrt. Der Runner sieht am
|
||
// Zeitstempel, dass er sein Regelwerk neu laden muss.
|
||
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
|
||
window_from = ?, window_to = ?, weekdays = ?,
|
||
on_vacation = ?, on_holiday = ?, next_day = ?, force_once = ?,
|
||
once_per_day = ?, lockout_secs = ?, changed = NOW()
|
||
WHERE id = ?");
|
||
$stmt->bind_param("ssissiiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
|
||
$weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce,
|
||
$oncePerDay, $lockout, $id);
|
||
$stmt->execute();
|
||
$stmt->close();
|
||
$db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id);
|
||
$db->query("DELETE FROM automation_actions WHERE automation_id = " . $id);
|
||
} else {
|
||
$stmt = $db->prepare("INSERT INTO automations
|
||
(name, floor, enabled, window_from, window_to, weekdays,
|
||
on_vacation, on_holiday, next_day, force_once,
|
||
once_per_day, lockout_secs)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
|
||
$stmt->bind_param("ssissiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
|
||
$weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce,
|
||
$oncePerDay, $lockout);
|
||
$stmt->execute();
|
||
$id = $db->insert_id;
|
||
$stmt->close();
|
||
}
|
||
|
||
// Gruppennummern luecken lassen waere erlaubt, aber die Uebersicht
|
||
// liest sich besser, wenn sie bei 0 anfangen und dicht sind.
|
||
$groupMap = [];
|
||
foreach ($conditions as $c) {
|
||
$g = intval($c["group"] ?? 0);
|
||
if (!isset($groupMap[$g])) {
|
||
$groupMap[$g] = count($groupMap);
|
||
}
|
||
}
|
||
|
||
$stmt = $db->prepare("INSERT INTO automation_conditions
|
||
(automation_id, group_no, position, state_id, operator, value)
|
||
VALUES (?,?,?,?,?,?)");
|
||
$positions = [];
|
||
foreach ($conditions as $c) {
|
||
$group = $groupMap[intval($c["group"] ?? 0)];
|
||
$stateId = intval($c["state_id"] ?? 0);
|
||
$operator = strval($c["operator"] ?? "=");
|
||
$value = strval($c["value"] ?? "");
|
||
if ($stateId <= 0) {
|
||
throw new InvalidArgumentException("Eine Bedingung hat keinen Messwert.");
|
||
}
|
||
if (!in_array($operator, ["=", "!=", ">", "<", ">=", "<=",
|
||
"+", "-", ">=+", ">=-", "<+", "<-"], true)) {
|
||
throw new InvalidArgumentException("Unbekannter Operator: " . $operator);
|
||
}
|
||
$positions[$group] = ($positions[$group] ?? -1) + 1;
|
||
$position = $positions[$group];
|
||
$stmt->bind_param("iiiiss", $id, $group, $position, $stateId, $operator, $value);
|
||
$stmt->execute();
|
||
}
|
||
$stmt->close();
|
||
|
||
$stmtAction = $db->prepare("INSERT INTO automation_actions
|
||
(automation_id, position, command_id) VALUES (?,?,?)");
|
||
$stmtParam = $db->prepare("INSERT INTO automation_action_params
|
||
(action_id, parameter_id, value) VALUES (?,?,?)");
|
||
$position = 0;
|
||
foreach ($actions as $a) {
|
||
$commandId = intval($a["command_id"] ?? 0);
|
||
if ($commandId <= 0) {
|
||
throw new InvalidArgumentException("Eine Aktion hat kein Kommando.");
|
||
}
|
||
$stmtAction->bind_param("iii", $id, $position, $commandId);
|
||
$stmtAction->execute();
|
||
$actionId = $db->insert_id;
|
||
$position++;
|
||
|
||
$params = is_array($a["params"] ?? null) ? $a["params"] : [];
|
||
foreach ($params as $paramId => $value) {
|
||
$paramId = intval($paramId);
|
||
$value = strval($value);
|
||
if ($paramId <= 0) {
|
||
continue;
|
||
}
|
||
$stmtParam->bind_param("iis", $actionId, $paramId, $value);
|
||
$stmtParam->execute();
|
||
}
|
||
}
|
||
$stmtAction->close();
|
||
$stmtParam->close();
|
||
|
||
$db->commit();
|
||
} catch (Throwable $e) {
|
||
$db->rollback();
|
||
throw $e;
|
||
}
|
||
|
||
return $id;
|
||
}
|
||
|
||
/** "16:30" oder "16:30:00" auf das Spaltenformat bringen. */
|
||
function normalizeTime($value, $fallback)
|
||
{
|
||
if (preg_match('/^([01]\d|2[0-3]):([0-5]\d)(:([0-5]\d))?$/', trim(strval($value)), $t)) {
|
||
return $t[1] . ":" . $t[2] . ":" . (isset($t[4]) ? $t[4] : "00");
|
||
}
|
||
return $fallback;
|
||
}
|
||
|
||
/**
|
||
* Welcher Messwert des gerechneten Geraets "Automatiken" auf welche Automatik
|
||
* zeigt: state_id => automation_id.
|
||
*
|
||
* Die Zeilen legt der Runner an und fuehrt sie nach (ausloeser_nachfuehren()),
|
||
* hier werden sie nur gelesen. Gefuehrt wird nach der Kennung in der URL
|
||
* ("auto:15") und nicht nach dem Namen - wer umbenennt, soll die abhaengigen
|
||
* Bedingungen behalten.
|
||
*/
|
||
function ausloeserStates()
|
||
{
|
||
static $karte = null;
|
||
if ($karte !== null) {
|
||
return $karte;
|
||
}
|
||
$karte = [];
|
||
$db = meshDb();
|
||
$res = $db->query("SELECT s.id, s.url FROM actor_states s
|
||
JOIN actors a ON a.id = s.actor_id
|
||
WHERE a.url = 'Automatik'");
|
||
while ($row = $res->fetch_assoc()) {
|
||
if (preg_match('/^auto:(\d+)$/', strval($row["url"]), $t)) {
|
||
$karte[intval($row["id"])] = intval($t[1]);
|
||
}
|
||
}
|
||
return $karte;
|
||
}
|
||
|
||
/** automation_id => Liste der Automatiken, die sie ausloesen. */
|
||
function automationTriggers()
|
||
{
|
||
$states = ausloeserStates();
|
||
if (!$states) {
|
||
return [];
|
||
}
|
||
$db = meshDb();
|
||
$res = $db->query("SELECT automation_id, state_id FROM automation_conditions");
|
||
$karte = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$wer = intval($row["automation_id"]);
|
||
$davor = $states[intval($row["state_id"])] ?? null;
|
||
if ($davor !== null && $davor !== $wer) {
|
||
$karte[$wer][$davor] = true;
|
||
}
|
||
}
|
||
return array_map("array_keys", $karte);
|
||
}
|
||
|
||
/** Die Automatiken, die diese hier als Ausloeser benutzen. */
|
||
function automationFollowers($id)
|
||
{
|
||
$id = intval($id);
|
||
$liste = [];
|
||
foreach (automationTriggers() as $wer => $davor) {
|
||
if (in_array($id, $davor, true)) {
|
||
$liste[] = $wer;
|
||
}
|
||
}
|
||
if (!$liste) {
|
||
return [];
|
||
}
|
||
$db = meshDb();
|
||
$platz = implode(",", array_fill(0, count($liste), "?"));
|
||
$stmt = $db->prepare("SELECT id, name, floor, enabled FROM automations
|
||
WHERE id IN ($platz) ORDER BY name");
|
||
$stmt->bind_param(str_repeat("i", count($liste)), ...$liste);
|
||
$stmt->execute();
|
||
$res = $stmt->get_result();
|
||
$nachfolger = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$nachfolger[] = [
|
||
"id" => intval($row["id"]),
|
||
"name" => $row["name"],
|
||
"floor" => $row["floor"],
|
||
"enabled" => (bool)$row["enabled"],
|
||
];
|
||
}
|
||
$stmt->close();
|
||
return $nachfolger;
|
||
}
|
||
|
||
/**
|
||
* Wuerde diese Automatik sich mittelbar selbst ausloesen?
|
||
*
|
||
* Ein Kreis (A loest B loest A) ist kein Fehler, den man im Betrieb bemerkt:
|
||
* die Sperrzeit begrenzt ihn auf eine Ausloesung je lockout_secs, und im
|
||
* Protokoll sieht das aus wie eine Automatik, die halt oft laeuft. Deshalb
|
||
* wird er hier abgelehnt und nicht dort geheilt. Der Runner meldet einen
|
||
* Kreis trotzdem - falls doch einer an der Datenbank vorbei entsteht.
|
||
*/
|
||
function pruefeKreis($id, $conditions)
|
||
{
|
||
$id = intval($id);
|
||
if ($id <= 0) {
|
||
return; // eine neue Automatik kennt noch niemand
|
||
}
|
||
$states = ausloeserStates();
|
||
$davor = [];
|
||
foreach ($conditions as $c) {
|
||
$wer = $states[intval($c["state_id"] ?? 0)] ?? null;
|
||
if ($wer !== null) {
|
||
$davor[] = $wer;
|
||
}
|
||
}
|
||
if (!$davor) {
|
||
return;
|
||
}
|
||
if (in_array($id, $davor, true)) {
|
||
throw new InvalidArgumentException(
|
||
"Eine Automatik kann sich nicht selbst auslösen.");
|
||
}
|
||
$kanten = automationTriggers();
|
||
$kanten[$id] = $davor;
|
||
$offen = $davor;
|
||
$gesehen = [];
|
||
while ($offen) {
|
||
$aktuell = array_pop($offen);
|
||
if ($aktuell === $id) {
|
||
throw new InvalidArgumentException(
|
||
"Das ergäbe einen Kreis: die Automatik würde sich mittelbar "
|
||
. "selbst auslösen.");
|
||
}
|
||
if (isset($gesehen[$aktuell])) {
|
||
continue;
|
||
}
|
||
$gesehen[$aktuell] = true;
|
||
foreach ($kanten[$aktuell] ?? [] as $weiter) {
|
||
$offen[] = $weiter;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Loeschen - und was mit den Automatiken geschieht, die davon abhaengen.
|
||
*
|
||
* Nichts zu tun waere die schlechteste Wahl: `fk_cond_state` steht auf
|
||
* ON DELETE CASCADE, mit dem Messwert verschwaende also still die Bedingung
|
||
* des Nachfolgers. Bei der LETZTEN Bedingung faengt der Runner das ab (eine
|
||
* Automatik ohne Bedingung loest nie aus), bei einer von zweien niemand: aus
|
||
* "zehn Minuten nach dem Wecker UND es ist hell" wuerde ein blosses "es ist
|
||
* hell", und der Rollladen fuehre ab morgen jeden Tag bei Sonnenaufgang hoch.
|
||
*
|
||
* abhaengen die Bedingung wird entfernt und der Nachfolger pausiert.
|
||
* Eine pausierte Automatik mit sichtbarer Luecke ist besser
|
||
* als eine stille Regelaenderung.
|
||
* mitloeschen der Nachfolger geht mit, und dessen Nachfolger auch.
|
||
*/
|
||
function deleteAutomation($id, $modus = "abhaengen", &$gesehen = [])
|
||
{
|
||
$id = intval($id);
|
||
if (isset($gesehen[$id])) {
|
||
return; // Schutz gegen einen Kreis, den es nicht geben sollte
|
||
}
|
||
$gesehen[$id] = true;
|
||
|
||
$db = meshDb();
|
||
$nachfolger = automationFollowers($id);
|
||
$eigeneStates = array_keys(ausloeserStates(), $id, true);
|
||
|
||
if ($nachfolger && $modus === "mitloeschen") {
|
||
foreach ($nachfolger as $n) {
|
||
deleteAutomation($n["id"], "mitloeschen", $gesehen);
|
||
}
|
||
} elseif ($nachfolger && $eigeneStates) {
|
||
$platz = implode(",", array_fill(0, count($eigeneStates), "?"));
|
||
foreach ($nachfolger as $n) {
|
||
$args = array_merge([$n["id"]], $eigeneStates);
|
||
$stmt = $db->prepare("DELETE FROM automation_conditions
|
||
WHERE automation_id = ? AND state_id IN ($platz)");
|
||
$stmt->bind_param(str_repeat("i", count($args)), ...$args);
|
||
$stmt->execute();
|
||
$stmt->close();
|
||
$stmt = $db->prepare("UPDATE automations SET enabled = 0, changed = NOW()
|
||
WHERE id = ?");
|
||
$stmt->bind_param("i", $n["id"]);
|
||
$stmt->execute();
|
||
$stmt->close();
|
||
}
|
||
}
|
||
|
||
$stmt = $db->prepare("DELETE FROM automations WHERE id = ?");
|
||
$stmt->bind_param("i", $id);
|
||
$stmt->execute();
|
||
$stmt->close();
|
||
}
|
||
|
||
/** Pausieren oder wieder aufnehmen. Gibt den neuen Zustand zurueck. */
|
||
function toggleAutomation($id)
|
||
{
|
||
$db = meshDb();
|
||
$stmt = $db->prepare("UPDATE automations SET enabled = 1 - enabled WHERE id = ?");
|
||
$stmt->bind_param("i", $id);
|
||
$stmt->execute();
|
||
$stmt->close();
|
||
$stmt = $db->prepare("SELECT enabled FROM automations WHERE id = ?");
|
||
$stmt->bind_param("i", $id);
|
||
$stmt->execute();
|
||
$row = $stmt->get_result()->fetch_assoc();
|
||
$stmt->close();
|
||
return $row ? (bool)$row["enabled"] : false;
|
||
}
|
||
|
||
/**
|
||
* Alle Automatiken einer Etage, angereichert um lesbare Beschreibungen fuer
|
||
* die Uebersichtstabelle.
|
||
*/
|
||
function listAutomations($floor)
|
||
{
|
||
$db = meshDb();
|
||
$stmt = $db->prepare("SELECT id FROM automations WHERE floor = ? ORDER BY name");
|
||
$stmt->bind_param("s", $floor);
|
||
$stmt->execute();
|
||
$res = $stmt->get_result();
|
||
$ids = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$ids[] = intval($row["id"]);
|
||
}
|
||
$stmt->close();
|
||
|
||
$namesState = stateNames();
|
||
$namesCommand = commandNames();
|
||
$labelsParam = parameterLabels();
|
||
$kanten = automationTriggers();
|
||
|
||
$list = [];
|
||
foreach ($ids as $id) {
|
||
$auto = loadAutomation($id);
|
||
if (!$auto) {
|
||
continue;
|
||
}
|
||
$auto["conditionText"] = describeConditions($auto["conditions"], $namesState);
|
||
$auto["actionText"] = describeActions($auto["actions"], $namesCommand, $labelsParam);
|
||
// Woran diese Automatik haengt - die Uebersicht rueckt Nachfolger
|
||
// unter ihren Ausloeser ein, statt sie alphabetisch dazwischen zu
|
||
// streuen.
|
||
$auto["triggers"] = $kanten[$id] ?? [];
|
||
$list[] = $auto;
|
||
}
|
||
return $list;
|
||
}
|
||
|
||
/** id => "Geraet: Messwert" fuer alle Messwerte. */
|
||
function stateNames()
|
||
{
|
||
$db = meshDb();
|
||
$labels = geraeteBeschriftungen();
|
||
$res = $db->query("SELECT s.id, s.state_name, s.unit, s.actor_id, t.type
|
||
FROM actor_states s
|
||
LEFT JOIN state_types t ON s.state_type = t.id");
|
||
$names = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$names[intval($row["id"])] = [
|
||
"text" => ($labels[intval($row["actor_id"])] ?? "?") . ": " . $row["state_name"],
|
||
"unit" => $row["unit"],
|
||
"type" => $row["type"] ?: "string",
|
||
];
|
||
}
|
||
return $names;
|
||
}
|
||
|
||
/**
|
||
* id => [gespeicherter Wert => Bezeichnung] fuer alle Kommando-Parameter,
|
||
* die eine Auswahl haben. Gespeichert wird bei Parametern die Zahl, die das
|
||
* Geraet erwartet - in der Uebersicht soll aber "Blink" stehen und nicht "1".
|
||
*/
|
||
function parameterLabels()
|
||
{
|
||
$db = meshDb();
|
||
$res = $db->query("SELECT id, possible_values FROM command_parameters
|
||
WHERE possible_values <> '' AND possible_values <> '[]'");
|
||
$labels = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
foreach (optionList($row["possible_values"], false) as $option) {
|
||
$labels[intval($row["id"])][$option["value"]] = $option["label"];
|
||
}
|
||
}
|
||
return $labels;
|
||
}
|
||
|
||
/** id => "Geraet: Kommando" fuer alle Kommandos. */
|
||
function commandNames()
|
||
{
|
||
$db = meshDb();
|
||
$labels = geraeteBeschriftungen();
|
||
$res = $db->query("SELECT id, command_name, actor_id FROM actor_commands");
|
||
$names = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$names[intval($row["id"])] = ($labels[intval($row["actor_id"])] ?? "?")
|
||
. ": " . $row["command_name"];
|
||
}
|
||
return $names;
|
||
}
|
||
|
||
/**
|
||
* Die Bedingungen als Satz, so geklammert wie sie im Editor gezeichnet sind:
|
||
* "(A und B) oder C". Denselben Text zeigt der Editor unter den Bloecken und
|
||
* die Uebersicht in der Spalte "Ausloeser" - was man liest, ist damit genau
|
||
* das, was der Runner rechnet.
|
||
*/
|
||
function describeConditions($conditions, $names)
|
||
{
|
||
$groups = [];
|
||
foreach ($conditions as $c) {
|
||
$state = $names[$c["state_id"]]
|
||
?? ["text" => "Messwert " . $c["state_id"], "unit" => null, "type" => ""];
|
||
$text = $state["text"] . " " . operatorLabel($c["operator"], $state["type"]) . " " . $c["value"];
|
||
if ($state["unit"]) {
|
||
$text .= " " . $state["unit"];
|
||
}
|
||
$groups[$c["group"]][] = $text;
|
||
}
|
||
if (!$groups) {
|
||
return "";
|
||
}
|
||
$parts = [];
|
||
foreach ($groups as $group) {
|
||
$text = implode(" und ", $group);
|
||
if (count($groups) > 1 && count($group) > 1) {
|
||
$text = "(" . $text . ")";
|
||
}
|
||
$parts[] = $text;
|
||
}
|
||
return implode(" oder ", $parts);
|
||
}
|
||
|
||
function describeActions($actions, $names, $labels = [])
|
||
{
|
||
$parts = [];
|
||
foreach ($actions as $a) {
|
||
$text = $names[$a["command_id"]] ?? ("Kommando " . $a["command_id"]);
|
||
$values = [];
|
||
foreach ($a["params"] as $parameterId => $wert) {
|
||
// Wo es eine Auswahl gibt, die Bezeichnung nennen: "Blink" sagt
|
||
// mehr als die 1, die das Geraet tatsaechlich bekommt.
|
||
$values[] = $labels[intval($parameterId)][$wert] ?? $wert;
|
||
}
|
||
if ($values) {
|
||
$text .= " (" . implode(", ", $values) . ")";
|
||
}
|
||
$parts[] = $text;
|
||
}
|
||
return implode(", ", $parts);
|
||
}
|