Compare commits

...
6 Commits
Author SHA1 Message Date
adminandClaude Opus 5 0cb1fc635e Etage ist Pflicht
Die Auswahlliste im Editor bot ein "-" an. Eine Automatik ohne Etage taucht
aber in keinem der drei Reiter auf und ist danach nur noch in der Datenbank
zu finden - anlegen und sofort verlieren.

Der Eintrag ist weg, und der Zustand ist jetzt an drei Stellen unmoeglich
statt nur unwahrscheinlich: die Liste kennt nur noch OG, EG und UG,
saveAutomation lehnt alles andere mit einer Meldung ab statt still auf ""
zurueckzufallen, und im Enum der Spalte gibt es den leeren Wert nicht mehr.

Die Reihenfolge in der Liste ist jetzt OG, EG, UG wie bei den Reitern -
damit trifft der Rueckfall auf den ersten Eintrag dieselbe Etage wie die
Startseite. Kennt die Liste die Etage eines Datensatzes nicht, faellt sie
darauf zurueck, statt leer stehen zu bleiben und beim Speichern "" zu
schicken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:32:58 +02:00
adminandClaude Opus 5 27e5c0d331 Sperrzeit je Automatik in drei Stufen
Die steigende Flanke allein schuetzt nicht gegen einen Messwert, der um die
Schwelle pendelt: "Temperatur > 22" bei 22,1 / 21,9 / 22,1 Grad ist jedes
Mal eine echte Flanke, und ueber MQTT koennen die Werte im Sekundentakt
hereinkommen. Gemessen: fuenf Kommandos in einer halben Minute.

automations.lockout_secs sagt jetzt, wie lange nach einer Ausloesung nicht
wieder geschaltet wird. Der Editor bietet drei Stufen an - ohne, eine
Minute, eine Viertelstunde -, weil die passende Wahl am Geraet haengt und
nicht an einer Zahl: ein Rollladen soll nicht alle zwanzig Sekunden
losfahren, eine Lichtfarbe darf das. Gespeichert werden Sekunden, damit eine
vierte Stufe eine Zeile in lockoutChoices() ist und keine Wanderung durch
die Datenbank. Vorbelegt ist eine Minute.

Eine Flanke in der Sperrzeit wird verworfen, nicht aufgehoben. Ein
Rollladen, der eine Viertelstunde spaeter doch noch losfaehrt, weil vor
langer Zeit einmal eine Schwelle gestreift wurde, waere unangenehmer als
einer, der gar nicht faehrt - und der naechste echte Anlass nach Ablauf der
Sperre kommt ohnehin durch. Verworfene Flanken stehen auf DEBUG und nicht in
automation_log, sonst waere die Tabelle bei einem zappelnden Sensor voll
davon.

force_once bleibt unberuehrt: es greift nur, wenn im Fenster gar nichts
gelaufen ist - dann ist auch keine Sperre aktiv.

Nachgemessen am pendelnden Sensor: mit 60 s Sperre ein Kommando, ohne Sperre
fuenf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:26:18 +02:00
adminandClaude Opus 5 25d3c47bd3 Sonnenstand: ueber die Tagesgrenze rechnen statt abschneiden
Ein Versatz, der ueber Mitternacht hinausreicht, wurde bisher am Tagesrand
abgeschnitten. Damit liessen sich "sechs Stunden vor Sonnenaufgang" oder
"fuenf Stunden nach Sonnenuntergang" gar nicht mehr formulieren - solche
Angaben sind aber gewollt und landen eben am Vorabend bzw. nach Mitternacht.

Gerechnet wird jetzt modulo Tag. Verglichen wird die Uhrzeit innerhalb des
Tages: ein Ziel jenseits von Mitternacht gilt als diese Uhrzeit am selben
Tag. Bei "+" und "-" ist das genau der gemeinte Zeitpunkt, bei "ab" und
"vor" verschiebt sich der wahre Bereich entsprechend mit - das steht so in
der README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:10:20 +02:00
adminandClaude Opus 5 ee417f302f Zeit-Ausloeser: "um" ergaenzt, Sonnenstand vervollstaendigt
Bei Uhrzeit und Datum gab es nur "ab" und "vor". Der haeufigste Fall - eine
Aktion genau um 16:30 - liess sich damit gar nicht ausdruecken. "um" ist
jetzt der erste Eintrag und damit die Vorbelegung.

Der Hinweis von frueher bleibt trotzdem richtig und steht in der README:
"um" trifft nur eine einzige Minute, faellt der Runner ausgerechnet in
dieser Minute aus, ist die Automatik fuer den Tag verloren. "ab" holt der
naechste Takt nach. Wer beides will, hakt force_once an.

Beim Sonnenauf- und -untergang fehlte die halbe Tabelle: der Versatz kann
davor oder danach liegen, und verglichen werden kann davor, danach oder
genau. Statt zwei gibt es jetzt sechs Operatoren - "+", "-", "ab +",
"ab -", "vor +", "vor -". Das Vorzeichen steckt im Operator, weil der
Editor eine einzige Auswahlliste zeigt und nicht zwei Bedienelemente fuer
eine Angabe.

Rutscht ein Versatz rechnerisch ueber den Tagesrand (Sonnenaufgang 05:34
minus sechs Stunden), bleibt es beim Tagesrand statt auf die andere Seite
von Mitternacht zu springen - "kurz vor Sonnenaufgang" soll nicht ploetzlich
gestern abend bedeuten.

Der Editor brauchte keine Aenderung: er liest Wert und Beschriftung der
Operatoren aus dem Geraetekatalog, statt sie selbst zu kennen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:30:31 +02:00
adminandClaude Opus 5 5f643b9844 AutoAction-Runner: Automatiken ausfuehren
Bisher konnte man Automatiken nur anlegen - ausgefuehrt hat sie niemand.
restricted/autoActions/autoaction_runner.py holt das nach.

Dauerlaeufer statt Cronjob, aus zwei Gruenden: Schwellwert-Ausloeser sollen
greifen, wenn die MQTT-Nachricht hereinkommt, und actor_states.current_value
wird sonst von niemandem fortgeschrieben - beim Discovery einmal gesetzt und
danach nie wieder. Ein zustandsloser Lauf haette gar nichts, womit er
vergleichen koennte. Der Runner pflegt den Wert nebenbei mit, wovon auch der
Editor profitiert: er zeigt neben jedem Messwert den aktuellen Stand.

Ausgeloest wird nur auf der steigenden Flanke (automations.cond_met), sonst
wuerde "Temperatur ueber 22 Grad" bei jedem Takt erneut feuern. Aus
demselben Grund heissen Zeit-Ausloeser jetzt "ab 16:30" statt "gleich
16:30": ein Gleichheitsvergleich waere nur in einer einzigen Minute wahr,
und ein Ausfall in genau dieser Minute kostet den ganzen Tag. Dieselbe
Ueberlegung steht hinter den breiten Zeitfenstern in auto_watering.py.

Der Weg zum Geraet haengt an der URL des Aktors: mqtt:// abonniert und
publiziert, http:// pollt und haengt Parameter an, io:// spricht mit der
Tahoma-Box, Logic rechnet Uhrzeit, Datum und Sonnenzeiten. Alle vier stehen
in transports.py; eine fuenfte Geraeteart ist eine weitere Klasse mit
passt(), zustaende_lesen() und senden().

fetch_calendar.py fuellt calendar_days aus openholidaysapi.org, damit "in
den Ferien" und "an Feiertagen" eine Grundlage haben. Einmal jaehrlich per
Cron.

Die Uebersicht blendet auf schmalen Schirmen Spalten aus, statt sie
wegzuschieben - auf dem Handy waren die Knoepfe zum Pausieren und Loeschen
sonst nur per Seitwaertsscrollen erreichbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 19:21:07 +02:00
adminandClaude Opus 5 eee3a89b4e AutoAction-Editor: Speichern, Laden und Loeschen
Der Editor las seine Geraete aus homeMesh, schrieb aber nach solarLog in
Tabellen, deren Fremdschluessel auf solarLog.actors/sensors zeigten. Jedes
INSERT lief damit gegen den Fremdschluessel - autoactionsSensors und
autoactionsActors sind deshalb leer geblieben, und die Aktor-Schleife war
ohnehin nie geschrieben worden.

Die Automatiken liegen jetzt in homeMesh neben dem Geraetemodell
(homeMesh_automations.sql). Eine Bedingung verweist mit einem einzigen
Fremdschluessel auf actor_states statt auf Geraet, State-URL und ein
unklares valID; Kommandoparameter stehen zeilenweise statt in vier festen
Spalten "Wert 1" bis "Wert 4". Verknuepft wird ueber group_no: gleiche
Nummer UND, verschiedene ODER, ausgewertet als any(all(gruppe)).

Im Editor ist eine Gruppe ein gerahmter Block mit eigenem "+ Bedingung",
dazwischen steht ODER - die Klammerung ist damit gezeichnet und nicht
vereinbart, und darunter steht derselbe Satz noch einmal in Worten. Die
Uebersicht zeigt ihn in der Spalte "Ausloeser" und ersetzt die bisher fest
verdrahtete Beispielzeile.

Nebenbei behoben: die Operator-Knoepfe schickten ihr innerHTML ("≠",
"&gt;") an eine Whitelist, die "!=" erwartete - jede Bedingung "ungleich"
wurde still zu "gleich". Ausgeblendete Wertfelder sendeten ihren Inhalt
trotzdem mit. Beides entfaellt, weil der Editor jetzt JSON aus einem
Modell schickt statt durchnummerierter Formularfelder.

Die vier Endpunkte fillSensorDD, fillActorDD, sensorDetails und
actorDetails entfallen: der Geraetekatalog reist einmal mit dem Editor
mit, statt je Bedingungszeile zwei Anfragen nachzuladen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 19:07:29 +02:00
15 changed files with 3389 additions and 870 deletions
+244 -249
View File
@@ -1,263 +1,258 @@
<?php
/**
* Endpunkt fuer die Automatiken: Editor, Uebersicht, Speichern, Loeschen.
*
* Alles laeuft ueber ?action=... , damit Formular und Uebersicht dieselbe
* Modell-Schicht (restricted/automations.php) benutzen und nicht zwei
* Wahrheiten ueber denselben Datensatz entstehen.
*
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
* POST ?action=delete {"id": N}
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
*/
require_once("../helper.php");
$close = 0;
require_once("../restricted/automations.php");
if (checkLogin()) {
if (isset($_POST["sensorSelect1"])) { //if form war sent add an action
if(isset($_POST["floor"])){
switch($_POST["floor"]){
case "UG":
$floor = "UG";
break;
case "EG":
$floor = "EG";
break;
case "OG":
$floor = "OG";
break;
default:
$floor = "";
}
}else{
$floor = "";
}
if(isset($_POST["tSpanFrom"])){
$tFrom = date("Y-m-d H:i:s",strtotime($_POST["tSpanFrom"]));
}else{
$tFrom = "00:10:00";
}
if(isset($_POST["tSpanTo"])){
$tTo = date("Y-m-d H:i:s",strtotime($_POST["tSpanTo"]));
}else{
$tTo = "23:59:00";
}
if(isset($_POST["runOnce"])){
$force = true;
}else{
$force = false;
}
if(isset($_POST["actMo"])){$mo = true;}else{$mo = false;}
if(isset($_POST["actDi"])){$di = true;}else{$di = false;}
if(isset($_POST["actMi"])){$mi = true;}else{$mi = false;}
if(isset($_POST["actDo"])){$do = true;}else{$do = false;}
if(isset($_POST["actFr"])){$fr = true;}else{$fr = false;}
if(isset($_POST["actSa"])){$sa = true;}else{$sa = false;}
if(isset($_POST["actSo"])){$so = true;}else{$so = false;}
if(isset($_POST["actFerien"])){$ferien = true;}else{$ferien = false;}
if(isset($_POST["actFeier"])){$feiertag = true;}else{$feiertag = false;}
$close = 0;
if(isset($_POST["changeID"])){
$id = intval($_POST["changeID"]);
if($_POST["changeID"] != "0" && $id == 0){
$id = -1;
}
}else{
$id = -1;
}
$id = 2;
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
if($id > -1){
$qry = "UPDATE autoActions SET
`floor` = '".$floor."', `window_from`= '".$tFrom."', `window_to` = '".$tTo."',
`force_once` = '".$force."', `mo` = '".$mo."', `di` = '".$di."', `mi` = '".$mi."',
`do` = '".$do."', `fr` = '".$fr."', `sa` = '".$sa."', `so` = '".$so."',
`ferien` = '".$ferien."', `feiertag` = '".$feiertag."' WHERE id=".$id.";";
mysqli_query($mysql, $qry);
mysqli_query($mysql, "DELETE FROM autoactionsActors WHERE actionID=".$id.";");
mysqli_query($mysql, "DELETE FROM autoactionsSensors WHERE actionID=".$id.";");
$actionID = $id;
}else{
$qry = "INSERT into autoActions
(`id`, `floor`, `window_from`, `window_to`, `force_once`, `mo`, `di`, `mi`, `do`, `fr`, `sa`, `so`, `ferien`, `feiertag`, `last_run`)
VALUES (NULL, '".$floor."', '".$tFrom."', '".$tTo."', '".$force."', '".$mo."', '".$di."', '".$mi."', '".$do."', '".$fr."', '".$sa."', '".$so."', '".$ferien."', '".$feiertag."', '2020-01-01 12:00:00.000000')";
mysqli_query($mysql, $qry);
$actionID = mysqli_insert_id($mysql);
}
//Get all Sensors:
$num=1;
while(isset($_POST["sensorSelect".$num])){
$id = intval($_POST["sensorSelect".$num]);
if($_POST["sensorSelect".$num] != "0" && $id == 0){
$id = -1;
}
if(isset($_POST["btnLogic".$num])){
if(strtolower($_POST["btnLogic".$num]) == "oder"){
$weight = "or";
}else{
$weight = "and";
}
}else{
$weight = "and";
}
if (in_array($_POST["btnOperator".$num], array('+','-','=','&lt;','&gt;','!='))) {
$cond = $_POST["btnOperator".$num];
}else{
$cond = "=";
echo $_POST["btnOperator".$num];
}
echo $_POST["btnOperator".$num];
$valID = intval($_POST["paramSelect".$num]);
if($_POST["paramSelect".$num] != "0" && $id == 0){
$valID = -1;
}
$state = mysqli_real_escape_string($mysql, $_POST["threshold".$num]);
$qry = "INSERT INTO `autoactionsSensors`
(`id`, `sensorID`, `state`, `valID`, `condType`, `link`, `actionID`)
VALUES (NULL, '".$id."', '".$state."', '".$valID."', '".$cond."', '".$weight."', '".$actionID."')";
mysqli_query($mysql, $qry);
$num++;
}
$close=1;
}
} else {
$close = 1;
if (!checkLogin()) {
http_response_code(403);
exit;
}
if (!$close) {
echo <<<ENDE
<!--begin::Form-->
<form id="heater_form">
<div class="accordion" id="actionAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="headingOne">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
<i class="bi bi-lightning-fill"></i> &nbsp;&nbsp;Auslöser
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse show" aria-labelledby="headingOne" data-bs-parent="#actionAccordion">
<div class="accordion-body" id="sensorsList">
<div class="input-group mb-1" id="sensorSettings1">
<div class="form-floating">
<select class="form-select" id="sensorSelect1" name="sensorSelect1" aria-label="Default select example">
</select>
<label for="sensorSelect1">Sensor</label>
</div>
<div class="form-floating" id="paramBlock1">
<select class="form-select" id="paramSelect1" name="paramSelect1" aria-label="Default select example">
</select>
<label for="paramSelect1">Messwert</label>
</div>
<button class="btn btn-outline-secondary" style="min-width: 40px;" type="button" id="btnOperator1" name="btnOperator1">&gt;</button>
<div class="form-floating" id="valBlock1">
<input type="number" class="form-control" id="threshold1" placeholder="0" value="0" name="threshold1"></input>
<select class="form-select" id="thresholdOpt1" name="thresholdOpt1" placeholder="0" value="0"></select>
<label for="threshold1">Wert/Schwelle</label>
</div>
</div>
function jsonAntwort($data, $code = 200)
{
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
$action = $_GET["action"] ?? "editor";
$floor = in_array($_GET["floor"] ?? "", ["UG", "EG", "OG"], true) ? $_GET["floor"] : "";
// --- Schreibende Aufrufe -------------------------------------------------
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$body = json_decode(file_get_contents("php://input"), true);
if (!is_array($body)) {
jsonAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
}
try {
switch ($action) {
case "save":
$id = saveAutomation($body);
jsonAntwort(["id" => $id]);
case "delete":
deleteAutomation(intval($body["id"] ?? 0));
jsonAntwort(["ok" => true]);
case "toggle":
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
}
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
} catch (Throwable $e) {
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
}
}
// --- Uebersichtstabelle --------------------------------------------------
if ($action === "list") {
$tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
$list = listAutomations($floor);
if (!$list) {
echo "<p class='text-body-secondary mb-2'>Für diese Etage ist noch nichts eingerichtet.</p>";
} else {
echo "<div class='table-responsive'><table class='table table-hover align-middle'>";
// Je schmaler der Schirm, desto weniger Spalten: auf dem Handy bleiben
// Name, Ausloeser und die Knoepfe. Sonst muss man seitwaerts scrollen,
// um eine Automatik ueberhaupt pausieren zu koennen.
echo "<thead><tr><th>Name</th><th>Auslöser</th>"
. "<th class='d-none d-sm-table-cell'>Aktion</th>"
. "<th class='d-none d-md-table-cell'>Wann</th>"
. "<th class='d-none d-md-table-cell'>Zuletzt</th><th></th></tr></thead><tbody>";
foreach ($list as $a) {
// Nur die aktiven Tage nennen. Sieben Haekchen untereinander
// haben die Zeile frueher unnoetig hoch gemacht.
$aktiveTage = [];
for ($i = 0; $i < 7; $i++) {
if ($a["weekdays"] & (1 << $i)) {
$aktiveTage[] = $tage[$i];
}
}
$wann = count($aktiveTage) === 7 ? "täglich" : implode(" ", $aktiveTage);
if ($a["window_from"] !== "00:00" || $a["window_to"] !== "23:59") {
$wann .= ", " . $a["window_from"] . "–" . $a["window_to"];
}
if (!$a["on_holiday"]) {
$wann .= ", nicht an Feiertagen";
}
if (!$a["on_vacation"]) {
$wann .= ", nicht in den Ferien";
}
if ($a["lockout_secs"] > 0) {
$wann .= ", frühestens alle "
. ($a["lockout_secs"] >= 60
? intdiv($a["lockout_secs"], 60) . " Min."
: $a["lockout_secs"] . " Sek.");
}
$zuletzt = $a["last_run"] ? date("d.m. H:i", strtotime($a["last_run"])) : "–";
$klasse = $a["enabled"] ? "" : " class='opacity-50'";
$pause = $a["enabled"] ? "bi-pause-fill" : "bi-play-fill";
$titel = $a["enabled"] ? "Pausieren" : "Fortsetzen";
$id = intval($a["id"]);
echo "<tr" . $klasse . ">";
echo "<td>" . htmlspecialchars($a["name"]) . "</td>";
echo "<td>" . htmlspecialchars($a["conditionText"]) . "</td>";
echo "<td class='d-none d-sm-table-cell'>" . htmlspecialchars($a["actionText"]) . "</td>";
echo "<td class='text-nowrap d-none d-md-table-cell'>" . htmlspecialchars($wann) . "</td>";
echo "<td class='text-nowrap d-none d-md-table-cell'>" . $zuletzt . "</td>";
echo "<td class='text-nowrap'>"
. "<button type='button' class='btn btn-info btn-sm' title='" . $titel . "'"
. " onclick='toggleAutomation(" . $id . ")'><i class='bi " . $pause . "'></i></button> "
. "<button type='button' class='btn btn-warning btn-sm' title='Bearbeiten'"
. " onclick=\"openAutoActionModal('?action=editor&id=" . $id . "')\"><i class='bi bi-pencil-square'></i></button> "
. "<button type='button' class='btn btn-danger btn-sm' title='Löschen'"
. " onclick='deleteAutomation(" . $id . ", \"" . htmlspecialchars($a["name"], ENT_QUOTES) . "\")'><i class='bi bi-trash3'></i></button>"
. "</td></tr>";
}
echo "</tbody></table></div>";
}
exit;
}
// --- Editor --------------------------------------------------------------
$id = intval($_GET["id"] ?? 0);
$auto = $id > 0 ? loadAutomation($id) : null;
if (!$auto) {
$auto = emptyAutomation($floor);
}
// Geraetekatalog und Datensatz reisen im selben Dokument mit. Frueher holte
// der Editor die Geraeteliste und je Zeile noch einmal deren Details nach -
// zwei zusaetzliche Anfragen pro Bedingung, und die Dropdowns fuellten sich
// asynchron, weshalb ein gespeicherter Wert nicht zuverlaessig ankam.
$nutzdaten = json_encode([
"automation" => $auto,
"devices" => deviceCatalog(),
"lockouts" => lockoutChoices(),
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
?>
<script type="application/json" id="autoActionData"><?= $nutzdaten ?></script>
<form id="autoActionForm" class="p-2">
<div class="row g-2 mb-2">
<div class="col-8">
<div class="form-floating">
<input type="text" class="form-control" id="autoName" placeholder="Name" maxlength="100">
<label for="autoName">Name der Automatik</label>
</div>
<button class="btn btn-outline-success btn-sm" type="button" id="btnAddSensor"><i class="bi bi-plus-lg"></i></button>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingTwo">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
<i class="bi bi-calendar-check-fill"></i> &nbsp;&nbsp;Bedingungen
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<div class="input-group mb-3" id="timespan">
<span class="input-group-text">Aktiver Zeitraum: </span>
<div class="form-floating">
<input type="time" class="form-control" id="tSpanFrom" name="tSpanFrom" placeholder="0" value="00:00"></input>
<label>Von</label>
</div>
<div class="form-floating">
<input type="time" class="form-control" id="tSpanTo" name="tSpanTo" placeholder="0" value="23:59"></input>
<label>Bis</label>
</div>
</div>
Aktive Wochentage:
<div class="input-group mb-3" id="timespan">
<div class="input-group-text">
<input class="form-check-input mt-0 me-1" type="checkbox" value="1" id="actMo" name="actMo"><label class="form-check-label" for="actMo">Mo</label>
</div>
<div class="input-group-text">
<input class="form-check-input mt-0 me-1" type="checkbox" value="1" id="actDi" name="actDi"><label class="form-check-label" for="actDi">Di</label>
</div>
<div class="input-group-text">
<input class="form-check-input mt-0 me-1" type="checkbox" value="1" id="actMi" name="actMi"><label class="form-check-label" for="actMi">Mi</label>
</div>
<div class="input-group-text">
<input class="form-check-input mt-0 me-1" type="checkbox" value="1" id="actDo" name="actDo"><label class="form-check-label" for="actDo">Do</label>
</div>
<div class="input-group-text">
<input class="form-check-input mt-0 me-1" type="checkbox" value="1" id="actFr" name="actFr"><label class="form-check-label" for="actFr">Fr</label>
</div>
<div class="input-group-text">
<input class="form-check-input mt-0 me-1" type="checkbox" value="1" id="actSa" name="actSa"><label class="form-check-label" for="actSa">Sa</label>
</div>
<div class="input-group-text">
<input class="form-check-input mt-0 me-1" type="checkbox" value="1" id="actSo" name="actSo"><label class="form-check-label" for="actSo">So</label>
</div>
</div>
Ferien/Feiertage:
<div class="input-group mb-3" id="timespan">
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" value="1" id="actFerien" name="actFerien"><label class="form-check-label" for="actFerien">In den Ferien ausführen</label>
</div>
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" value="1" id="actFeier" name="actFeier"><label class="form-check-label" for="actFeier">An Feiertagen ausführen</label>
</div>
</div>
Falls die Bedingung bis zuletzt nicht erfüllt wurde:
<div class="input-group mb-3" id="timespan">
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" value="1" id="runOnce" name="runOnce"><label class="form-check-label" for="runOnce">Auf jeden Fall zu Ende des aktiven Zeitraums ausführen</label>
</div>
</div>
<div class="col-4">
<div class="form-floating">
<!-- Ohne Etage waere die Automatik in keinem der drei Reiter zu
sehen und nur noch in der Datenbank auffindbar. Reihenfolge wie
die Reiter, damit der Rueckfall auf den ersten Eintrag dieselbe
Etage trifft wie die Startseite. -->
<select class="form-select" id="autoFloor">
<option value="OG">OG</option>
<option value="EG">EG</option>
<option value="UG">UG</option>
</select>
<label for="autoFloor">Etage</label>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingThree">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseThree" aria-expanded="false" aria-controls="collapseThree">
<i class="bi bi-play-fill"></i> &nbsp;&nbsp;Aktionen
</button>
</h2>
<div id="collapseThree" class="accordion-collapse collapse" aria-labelledby="headingThree" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<div class="accordion-body" id="actorsList">
<div class="input-group mb-3" id="actorSettings1">
<div class="form-floating">
<select class="form-select" id="actorSelect1" name="actorSelect1">
</select>
<label for="actorSelect1">Aktor</label>
</div>
<div class="form-floating" id="actParamBlock1">
<select class="form-select" id="actParamSelect1" name="actParamSelect1">
</select>
<label for="paramSelect1">Eigenschaft</label>
</div>
<button class="btn btn-outline-secondary" style="min-width: 40px;" type="button" id="btnActOperator1" name="btnActOperator1">&gt;</button>
<div class="form-floating" id="act1ValBlock1">
<input type="number" class="form-control" id="act1Value1" name="act1Value1" value="0"></input>
<select class="form-select" id="act1ValueOpt1" name="act1ValueOpt1"></select>
<label for="act1Value1">Wert 1</label>
</div>
<div class="form-floating" id="act2ValBlock1">
<input type="number" class="form-control" id="act2Value1" name="act2Value1" value="0"></input>
<label for="act2Value1">Wert 2</label>
</div>
<div class="form-floating" id="act3ValBlock1">
<input type="number" class="form-control" id="act3Value1" name="act3Value1" value="0"></input>
<label for="act3Value1">Wert 3</label>
</div>
<div class="form-floating" id="act4ValBlock1">
<input type="number" class="form-control" id="act4Value1" name="act4Value1" value="0"></input>
<label for="act4Value1">Wert 4</label>
</div>
</div>
</div>
</div>
<button class="btn btn-outline-success btn-sm" type="button" id="btnAddActor"><i class="bi bi-plus-lg"></i></button>
<div class="accordion" id="actionAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseOne" aria-expanded="true">
<i class="bi bi-lightning-fill"></i>&nbsp;&nbsp;Auslöser
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse show" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<!-- Ein Block ist eine UND-Gruppe, zwischen den Bloecken steht ODER.
Die Klammerung ist damit gezeichnet und nicht bloss vereinbart. -->
<div id="condBlocks"></div>
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" id="btnAddGroup">
<i class="bi bi-plus-lg"></i> Alternative (oder)
</button>
<div class="alert alert-secondary mt-3 mb-0 py-2 small" id="condSummary"></div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseTwo" aria-expanded="false">
<i class="bi bi-calendar-check-fill"></i>&nbsp;&nbsp;Bedingungen
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<div class="input-group mb-3">
<span class="input-group-text">Aktiver Zeitraum</span>
<div class="form-floating">
<input type="time" class="form-control" id="tSpanFrom" value="00:00">
<label for="tSpanFrom">Von</label>
</div>
<div class="form-floating">
<input type="time" class="form-control" id="tSpanTo" value="23:59">
<label for="tSpanTo">Bis</label>
</div>
</div>
Aktive Wochentage:
<div class="input-group mb-3" id="weekdayGroup"></div>
<div class="input-group mb-3">
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFerien">
<label class="form-check-label" for="actFerien">In den Ferien ausführen</label>
</div>
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFeier">
<label class="form-check-label" for="actFeier">An Feiertagen ausführen</label>
</div>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Sperrzeit nach dem Auslösen</span>
<select class="form-select" id="lockoutSecs"></select>
</div>
<div class="input-group mb-0">
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" id="runOnce">
<label class="form-check-label" for="runOnce">
Am Ende des Zeitraums auf jeden Fall ausführen, falls der Auslöser nie zutraf
</label>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseThree" aria-expanded="false">
<i class="bi bi-play-fill"></i>&nbsp;&nbsp;Aktionen
</button>
</h2>
<div id="collapseThree" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<div id="actionList"></div>
<button class="btn btn-outline-success btn-sm mt-2" type="button" id="btnAddActor">
<i class="bi bi-plus-lg"></i> Aktion
</button>
</div>
</div>
</div>
</div>
</div>
</form>
ENDE;
}
-88
View File
@@ -1,88 +0,0 @@
<?php
require_once("../helper.php");
// Der Endpunkt gibt Geraetenamen und Steuer-URLs aus der homeMesh-
// Datenbank aus und war bisher ohne Anmeldung erreichbar.
if (!checkLogin()) {
http_response_code(403);
exit;
}
if(isset($_GET["actorID"])){
$actorID = intval($_GET["actorID"]);
$qry = "SELECT command_name, command_url, parameter_type, min_value, max_value, possible_values, command_parameters.url AS parameter_url, parameter_name FROM actor_commands LEFT JOIN command_parameters ON actor_commands.id = command_id LEFT JOIN state_types ON parameter_type = state_types.id WHERE actor_id=".$actorID;
$qry = "SELECT id, command_name, command_url FROM actor_commands WHERE actor_id = ".$actorID;
$mysql = new mysqli($mysql_server, $mysql_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$result = mysqli_query($mysql, $qry);
$operators = "";
//while($row = $result->fetch_assoc()){
// print_r($row);
// echo "<br />";
//}
echo '{"commands": [';
$iterated = 0;
while($row = $result->fetch_assoc()){
$items = "[]";
$qry2 = "SELECT parameter_type, min_value, max_value, possible_values, command_parameters.url AS parameter_url, parameter_name FROM actor_commands LEFT JOIN command_parameters ON actor_commands.id = command_id LEFT JOIN state_types ON parameter_type = state_types.id WHERE command_id = ".$row["id"];
$result2 = mysqli_query($mysql, $qry2);
if($iterated++){
echo ", ";
}
echo '{"name" : "'.$row["command_name"].'", "operators" : ["="], "parameters": [';
$iterated2 = 0;
$type = "hidden";
while($row2 = $result2->fetch_assoc()){
switch($row2["parameter_type"]){
case "integer":
if($row2["possible_values"]){
$items = $row2["possible_values"];
$type = "dropdown";
}else{
$type = "number";
}
break;
case "float":
$type = "number";
break;
case "time":
$type = "time";
break;
case "bool":
$type = "button";
break;
case "string":
if($row2["possible_values"]){
$items = $row2["possible_values"];
$type = "dropdown";
}else{
$type = "text";
}
break;
case "":
$type = "hidden";
default:
$type = "text";
break;
}
if($iterated2++){
echo ", ";
}
echo '{"name" : "'.$row2["parameter_name"].'", "url" : "'.$row2["parameter_url"].'", "type" : "'.$type.'", "options" : '.$items.'}';
}
echo '], "type" : "'.$type.'"}';
}
echo "]}";
}
/*
require_once("../helper.php");
if(isset($_GET["actorID"])){
$ID = intval($_GET["actorID"]);
$qry = "SELECT parameters FROM actors WHERE id=".$ID;
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$result = mysqli_query($mysql, $qry);
echo $result->fetch_array()[0];
}
*/
?>
-18
View File
@@ -1,18 +0,0 @@
<?php
require_once("../helper.php");
// Der Endpunkt gibt Geraetenamen und Steuer-URLs aus der homeMesh-
// Datenbank aus und war bisher ohne Anmeldung erreichbar.
if (!checkLogin()) {
http_response_code(403);
exit;
}
$qry = "SELECT name, actors.id FROM actors INNER JOIN actor_commands ON actor_id = actors.id GROUP BY actors.id";
$mysql = new mysqli($mysql_server, $mysql_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$result = mysqli_query($mysql, $qry);
while($row = $result->fetch_assoc()){
echo "<option value='".$row["id"]."'>".$row["name"]."</option>";
}
?>
-18
View File
@@ -1,18 +0,0 @@
<?php
require_once("../helper.php");
// Der Endpunkt gibt Geraetenamen und Steuer-URLs aus der homeMesh-
// Datenbank aus und war bisher ohne Anmeldung erreichbar.
if (!checkLogin()) {
http_response_code(403);
exit;
}
$qry = "SELECT name, actors.id FROM actors INNER JOIN actor_states ON actor_id = actors.id GROUP BY actors.id";
$mysql = new mysqli($mysql_server, $mysql_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$result = mysqli_query($mysql, $qry);
while($row = $result->fetch_assoc()){
echo "<option value='".$row["id"]."'>".$row["name"]."</option>";
}
?>
-76
View File
@@ -1,76 +0,0 @@
<?php
require_once("../helper.php");
// Der Endpunkt gibt Geraetenamen und Steuer-URLs aus der homeMesh-
// Datenbank aus und war bisher ohne Anmeldung erreichbar.
if (!checkLogin()) {
http_response_code(403);
exit;
}
if(isset($_GET["sensorID"])){
$sensorID = intval($_GET["sensorID"]);
$qry = "SELECT state_name, url, state_types.type, unit, current_value, possible_values FROM actor_states LEFT JOIN state_types ON state_type = state_types.id WHERE actor_id=".$sensorID;
$mysql = new mysqli($mysql_server, $mysql_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$result = mysqli_query($mysql, $qry);
$operators = "";
echo '{"parameters": [';
$iterated = 0;
$items = "[]";
while($row = $result->fetch_assoc()){
switch($row["type"]){
case "integer":
$operators = '["=","≠","&gt;","&lt;"]';
$type = "number";
break;
case "float":
$operators = '["=","≠","&gt;","&lt;"]';
$type = "number";
break;
case "time":
$operators = '["=","≠"]';
$type = "time";
break;
case "deltatime":
$operators = '["+","-"]';
$type = "time";
break;
case "date":
$operators = '["=","≠"]';
$type = "date";
break;
case "datetime":
$operators = '["=","≠"]';
$type = "datetime-local";
break;
case "bool":
$operators = '["= JA","= NEIN"]';
$type = "button";
break;
case "string":
$operators = '["=","≠"]';
if($row["possible_values"]){
$items = $row["possible_values"];
$type = "dropdown";
}else{
$operators = '["=","≠"]';
$type = "text";
}
break;
default:
$operators = '["=","≠"]';
$type = "text";
break;
}
if($iterated++){
echo ", ";
}
if($row["current_value"]){
$row["state_name"] .= " (=".$row["current_value"].")";
}
echo '{"name" : "'.$row["state_name"].'", "operators" : '.$operators.', "url" : "'.$row["url"].'", "type" : "'.$type.'", "options" : '.$items.'}';
}
echo "]}";
}
?>
+373
View File
@@ -0,0 +1,373 @@
-- phpMyAdmin SQL Dump
-- version 5.2.1
-- https://www.phpmyadmin.net/
--
-- Host: 192.168.179.174:3310
-- Erstellungszeit: 30. Aug 2026 um 16:22
-- Server-Version: 11.3.2-MariaDB-1:11.3.2+maria~ubu2204
-- PHP-Version: 8.2.17
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
--
-- Datenbank: `homeMesh`
--
-- --------------------------------------------------------
--
-- Tabellenstruktur für Tabelle `actors`
--
CREATE TABLE `actors` (
`id` int(11) NOT NULL,
`type` varchar(50) NOT NULL COMMENT 'Gerätetyp z.B. RollerShutter',
`name` varchar(70) NOT NULL COMMENT 'Name des Geräts',
`url` varchar(100) NOT NULL COMMENT 'Tahoma Device URL'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
--
-- Daten für Tabelle `actors`
--
INSERT INTO `actors` (`id`, `type`, `name`, `url`) VALUES
(1, 'LOGIC', 'Zeitpunkt', 'Logic');
-- --------------------------------------------------------
--
-- Tabellenstruktur für Tabelle `actor_commands`
--
CREATE TABLE `actor_commands` (
`id` int(11) NOT NULL,
`actor_id` int(11) NOT NULL COMMENT 'Referenz zum Aktor',
`command_name` varchar(100) NOT NULL COMMENT 'Name des Commands z.B. setPosition, open, close',
`command_url` varchar(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- --------------------------------------------------------
--
-- Tabellenstruktur für Tabelle `actor_states`
--
CREATE TABLE `actor_states` (
`id` int(11) NOT NULL,
`actor_id` int(11) NOT NULL COMMENT 'Referenz zum Aktor',
`state_name` varchar(100) NOT NULL COMMENT 'Name des State z.B. core:ClosureState',
`state_type` int(11) DEFAULT NULL COMMENT 'State-Typ Code aus Tahoma API',
`current_value` varchar(255) DEFAULT NULL COMMENT 'Aktueller Wert des State',
`unit` varchar(20) DEFAULT NULL COMMENT 'Einheit falls vorhanden',
`url` varchar(255) DEFAULT NULL COMMENT 'MQTT Topic oder URL zum State',
`last_updated` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`possible_values` text NOT NULL COMMENT 'JSON array with possible enum values.'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
--
-- Daten für Tabelle `actor_states`
--
INSERT INTO `actor_states` (`id`, `actor_id`, `state_name`, `state_type`, `current_value`, `unit`, `url`, `last_updated`, `possible_values`) VALUES
(1, 1, 'Uhrzeit', 5, '13:45', NULL, 'time', '2026-03-16 18:16:44', ''),
(2, 1, 'Datum', 8, '20.03.2026', NULL, 'date', '2026-03-16 18:16:44', ''),
(3, 1, 'Sonnenaufgang', 7, '00:00', NULL, 'sunrise', '2026-03-16 18:16:44', ''),
(4, 1, 'Sonnenuntergang', 7, '00:00', NULL, 'sunset', '2026-03-16 18:16:44', '');
-- --------------------------------------------------------
--
-- Tabellenstruktur für Tabelle `command_parameters`
--
CREATE TABLE `command_parameters` (
`id` int(11) NOT NULL,
`command_id` int(11) NOT NULL COMMENT 'Referenz zum Command',
`parameter_name` varchar(100) NOT NULL COMMENT 'Name des Parameters z.B. position',
`parameter_type` varchar(50) DEFAULT NULL COMMENT 'Datentyp z.B. integer, string',
`min_value` decimal(10,2) DEFAULT NULL COMMENT 'Minimaler Wert (falls numerisch)',
`max_value` decimal(10,2) DEFAULT NULL COMMENT 'Maximaler Wert (falls numerisch)',
`possible_values` text DEFAULT NULL COMMENT 'JSON Array mit möglichen Werten (für Enums)',
`url` varchar(255) DEFAULT NULL COMMENT 'MQTT Topic oder URL zum Parameter'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- --------------------------------------------------------
--
-- Tabellenstruktur für Tabelle `sensors`
--
CREATE TABLE `sensors` (
`id` int(11) NOT NULL,
`type` varchar(50) NOT NULL COMMENT 'Sensortyp z.B. TemperatureSensor',
`name` varchar(70) NOT NULL COMMENT 'Name des Sensors',
`url` varchar(100) NOT NULL COMMENT 'Tahoma Device URL'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- --------------------------------------------------------
--
-- Tabellenstruktur für Tabelle `sensor_states`
--
CREATE TABLE `sensor_states` (
`id` int(11) NOT NULL,
`sensor_id` int(11) NOT NULL COMMENT 'Referenz zum Sensor',
`state_name` varchar(100) NOT NULL COMMENT 'Name des State z.B. core:TemperatureState',
`state_type` int(11) DEFAULT NULL COMMENT 'State-Typ Code aus Tahoma API',
`current_value` varchar(255) DEFAULT NULL COMMENT 'Aktueller Wert des State',
`unit` varchar(20) DEFAULT NULL COMMENT 'Einheit z.B. °C, %, lux',
`url` varchar(255) DEFAULT NULL COMMENT 'MQTT Topic oder URL zum State',
`last_updated` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- --------------------------------------------------------
--
-- Tabellenstruktur für Tabelle `state_types`
--
CREATE TABLE `state_types` (
`id` int(11) NOT NULL,
`type` varchar(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
--
-- Daten für Tabelle `state_types`
--
INSERT INTO `state_types` (`id`, `type`) VALUES
(6, 'array'),
(0, 'bool'),
(8, 'date'),
(9, 'datetime'),
(7, 'deltatime'),
(2, 'float'),
(1, 'integer'),
(3, 'string'),
(5, 'time'),
(4, 'undefined');
-- --------------------------------------------------------
--
-- Stellvertreter-Struktur des Views `view_actors_with_commands`
-- (Siehe unten für die tatsächliche Ansicht)
--
CREATE TABLE `view_actors_with_commands` (
`actor_id` int(11)
,`actor_name` varchar(70)
,`actor_type` varchar(50)
,`actor_url` varchar(100)
,`command_id` int(11)
,`command_name` varchar(100)
,`parameter_name` varchar(100)
,`parameter_type` varchar(50)
,`min_value` decimal(10,2)
,`max_value` decimal(10,2)
,`possible_values` text
);
-- --------------------------------------------------------
--
-- Stellvertreter-Struktur des Views `view_all_devices`
-- (Siehe unten für die tatsächliche Ansicht)
--
CREATE TABLE `view_all_devices` (
`device_category` varchar(6)
,`id` int(11)
,`type` varchar(50)
,`name` varchar(70)
,`url` varchar(100)
);
-- --------------------------------------------------------
--
-- Stellvertreter-Struktur des Views `view_sensors_with_states`
-- (Siehe unten für die tatsächliche Ansicht)
--
CREATE TABLE `view_sensors_with_states` (
`sensor_id` int(11)
,`sensor_name` varchar(70)
,`sensor_type` varchar(50)
,`sensor_url` varchar(100)
,`state_name` varchar(100)
,`state_type` int(11)
,`current_value` varchar(255)
,`unit` varchar(20)
,`last_updated` timestamp
);
-- --------------------------------------------------------
--
-- Struktur des Views `view_actors_with_commands`
--
DROP TABLE IF EXISTS `view_actors_with_commands`;
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `view_actors_with_commands` AS SELECT `a`.`id` AS `actor_id`, `a`.`name` AS `actor_name`, `a`.`type` AS `actor_type`, `a`.`url` AS `actor_url`, `ac`.`id` AS `command_id`, `ac`.`command_name` AS `command_name`, `cp`.`parameter_name` AS `parameter_name`, `cp`.`parameter_type` AS `parameter_type`, `cp`.`min_value` AS `min_value`, `cp`.`max_value` AS `max_value`, `cp`.`possible_values` AS `possible_values` FROM ((`actors` `a` left join `actor_commands` `ac` on(`a`.`id` = `ac`.`actor_id`)) left join `command_parameters` `cp` on(`ac`.`id` = `cp`.`command_id`)) ORDER BY `a`.`id` ASC, `ac`.`id` ASC, `cp`.`id` ASC ;
-- --------------------------------------------------------
--
-- Struktur des Views `view_all_devices`
--
DROP TABLE IF EXISTS `view_all_devices`;
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `view_all_devices` AS SELECT 'actor' AS `device_category`, `actors`.`id` AS `id`, `actors`.`type` AS `type`, `actors`.`name` AS `name`, `actors`.`url` AS `url` FROM `actors`union all select 'sensor' AS `device_category`,`sensors`.`id` AS `id`,`sensors`.`type` AS `type`,`sensors`.`name` AS `name`,`sensors`.`url` AS `url` from `sensors` order by `device_category`,`name` ;
-- --------------------------------------------------------
--
-- Struktur des Views `view_sensors_with_states`
--
DROP TABLE IF EXISTS `view_sensors_with_states`;
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `view_sensors_with_states` AS SELECT `s`.`id` AS `sensor_id`, `s`.`name` AS `sensor_name`, `s`.`type` AS `sensor_type`, `s`.`url` AS `sensor_url`, `ss`.`state_name` AS `state_name`, `ss`.`state_type` AS `state_type`, `ss`.`current_value` AS `current_value`, `ss`.`unit` AS `unit`, `ss`.`last_updated` AS `last_updated` FROM (`sensors` `s` left join `sensor_states` `ss` on(`s`.`id` = `ss`.`sensor_id`)) ORDER BY `s`.`id` ASC, `ss`.`id` ASC ;
--
-- Indizes der exportierten Tabellen
--
--
-- Indizes für die Tabelle `actors`
--
ALTER TABLE `actors`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `url` (`url`),
ADD KEY `idx_actors_type` (`type`);
--
-- Indizes für die Tabelle `actor_commands`
--
ALTER TABLE `actor_commands`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `actor_id_2` (`actor_id`,`command_name`),
ADD KEY `actor_id` (`actor_id`),
ADD KEY `idx_actor_commands_name` (`command_name`);
--
-- Indizes für die Tabelle `actor_states`
--
ALTER TABLE `actor_states`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `actor_id_2` (`actor_id`,`url`),
ADD KEY `actor_id` (`actor_id`),
ADD KEY `idx_actor_states_name` (`state_name`),
ADD KEY `state_type` (`state_type`);
--
-- Indizes für die Tabelle `command_parameters`
--
ALTER TABLE `command_parameters`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `command_id_2` (`command_id`,`url`),
ADD KEY `command_id` (`command_id`);
--
-- Indizes für die Tabelle `sensors`
--
ALTER TABLE `sensors`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `url` (`url`),
ADD KEY `idx_sensors_type` (`type`);
--
-- Indizes für die Tabelle `sensor_states`
--
ALTER TABLE `sensor_states`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `sensor_id_2` (`sensor_id`,`url`),
ADD KEY `sensor_id` (`sensor_id`),
ADD KEY `idx_sensor_states_name` (`state_name`);
--
-- Indizes für die Tabelle `state_types`
--
ALTER TABLE `state_types`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `type` (`type`);
--
-- AUTO_INCREMENT für exportierte Tabellen
--
--
-- AUTO_INCREMENT für Tabelle `actors`
--
ALTER TABLE `actors`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
--
-- AUTO_INCREMENT für Tabelle `actor_commands`
--
ALTER TABLE `actor_commands`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT für Tabelle `actor_states`
--
ALTER TABLE `actor_states`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5;
--
-- AUTO_INCREMENT für Tabelle `command_parameters`
--
ALTER TABLE `command_parameters`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT für Tabelle `sensors`
--
ALTER TABLE `sensors`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT für Tabelle `sensor_states`
--
ALTER TABLE `sensor_states`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- Constraints der exportierten Tabellen
--
--
-- Constraints der Tabelle `actor_commands`
--
ALTER TABLE `actor_commands`
ADD CONSTRAINT `fk_actor_commands_actor` FOREIGN KEY (`actor_id`) REFERENCES `actors` (`id`) ON DELETE CASCADE;
--
-- Constraints der Tabelle `actor_states`
--
ALTER TABLE `actor_states`
ADD CONSTRAINT `fk_actor_states_actor` FOREIGN KEY (`actor_id`) REFERENCES `actors` (`id`) ON DELETE CASCADE,
ADD CONSTRAINT `fk_type_states_type` FOREIGN KEY (`state_type`) REFERENCES `state_types` (`id`) ON DELETE CASCADE;
--
-- Constraints der Tabelle `command_parameters`
--
ALTER TABLE `command_parameters`
ADD CONSTRAINT `fk_command_parameters_command` FOREIGN KEY (`command_id`) REFERENCES `actor_commands` (`id`) ON DELETE CASCADE;
--
-- Constraints der Tabelle `sensor_states`
--
ALTER TABLE `sensor_states`
ADD CONSTRAINT `fk_sensor_states_sensor` FOREIGN KEY (`sensor_id`) REFERENCES `sensors` (`id`) ON DELETE CASCADE;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
+134
View File
@@ -0,0 +1,134 @@
-- ---------------------------------------------------------------------------
-- Automatiken (AutoActions) fuer die Datenbank `homeMesh`
-- ---------------------------------------------------------------------------
-- Bewusst hier und nicht in `solarLog`: der Editor liest Geraete, Messwerte
-- und Kommandos ohnehin aus homeMesh, und nur innerhalb einer Datenbank
-- koennen Fremdschluessel greifen. Die alten Tabellen autoActions,
-- autoactionsSensors und autoactionsActors in solarLog zeigten auf
-- solarLog.actors bzw. solarLog.sensors, waehrend das Formular homeMesh-IDs
-- lieferte - jedes INSERT lief gegen den Fremdschluessel, deshalb sind die
-- beiden Kindtabellen dort leer geblieben.
--
-- Ein Geraetebegriff: device_discovery.py legt auch reine Sensoren in
-- `actors`/`actor_states` ab (siehe insert_sensor()), die Tabellen
-- `sensors`/`sensor_states` sind unbenutzt. `actor_states` ist damit alles
-- Lesbare, `actor_commands` alles Schaltbare.
--
-- Voraussetzung: in restricted/deviceDiscovery/config.ini muss
-- clear_tables = false stehen. Discovery schreibt mit ON DUPLICATE KEY UPDATE
-- auf den URLs, das Leeren ist unnoetig - ein TRUNCATE wuerde dagegen die
-- Geraete-IDs neu vergeben und alle Regeln auf falsche Geraete zeigen lassen.
-- ---------------------------------------------------------------------------
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET NAMES utf8mb4;
-- ---------------------------------------------------------------------------
-- Die Automatik selbst: der Rahmen, in dem sie ueberhaupt greifen darf.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `automations` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT 'Anzeigename, z.B. Kinder zu',
`floor` enum('UG','EG','OG') NOT NULL DEFAULT 'OG' COMMENT 'Reiter in der Uebersicht. Ohne Etage waere die Automatik in keinem davon zu sehen, deshalb gibt es keinen leeren Wert',
`enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Aktiv oder pausiert',
`window_from` time NOT NULL DEFAULT '00:00:00' COMMENT 'Aktiver Zeitraum, Beginn',
`window_to` time NOT NULL DEFAULT '23:59:00' COMMENT 'Aktiver Zeitraum, Ende. Kleiner als window_from = ueber Mitternacht',
`weekdays` tinyint(3) unsigned NOT NULL DEFAULT 127 COMMENT 'Bitmaske: Bit 0 = Montag ... Bit 6 = Sonntag',
`on_vacation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch in den Ferien ausfuehren',
`on_holiday` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch an Feiertagen ausfuehren',
`force_once` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Am Ende des Zeitraums auf jeden Fall ausfuehren',
`lockout_secs` int(11) NOT NULL DEFAULT 60 COMMENT 'Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet. Gegen Messwerte, die um die Schwelle pendeln - jedes Ueberschreiten waere sonst eine echte Flanke. Der Editor bietet 0, 60 und 900 an',
`cond_met` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'War die Bedingung beim letzten Durchlauf erfuellt? Nur die steigende Flanke loest aus, sonst wuerde Temperatur groesser 22 im Sekundentakt feuern',
`last_run` datetime DEFAULT NULL COMMENT 'Zuletzt ausgeloest, NULL = noch nie',
`changed` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT 'Signal an den Runner, das Regelwerk neu zu laden',
PRIMARY KEY (`id`),
KEY `idx_automations_floor` (`floor`),
KEY `idx_automations_changed` (`changed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- ---------------------------------------------------------------------------
-- Ausloeser. Eine Zeile ist ein Vergleich auf genau einen Messwert.
--
-- `state_id` zeigt direkt auf actor_states und traegt damit Geraet, Messwert,
-- Datentyp, Einheit und MQTT-Topic in einem Feld - frueher war das auf
-- sensorID, eine state-URL und ein unklares valID verteilt.
--
-- Verknuepft wird ueber `group_no`: gleiche Nummer = UND, verschiedene
-- Nummern = ODER. Ausgewertet wird also any(all(gruppe)). Im Editor ist eine
-- Gruppe ein gerahmter Block und zwischen den Bloecken steht ein ODER - die
-- Klammerung ist damit gezeichnet und nicht bloss vereinbart. Innerhalb einer
-- Gruppe wie zwischen den Gruppen ist die Reihenfolge fuer das Ergebnis egal,
-- `position` ist reine Lesehilfe.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `automation_conditions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`automation_id` int(11) NOT NULL,
`group_no` int(11) NOT NULL DEFAULT 0 COMMENT 'UND-Block. Gleiche Nummer = UND, verschiedene Nummern = ODER',
`position` int(11) NOT NULL DEFAULT 0 COMMENT 'Reihenfolge innerhalb des Blocks, nur Darstellung',
`state_id` int(11) NOT NULL COMMENT 'Referenz auf actor_states: Geraet und Messwert in einem',
`operator` enum('=','!=','>','<','>=','<=','+','-','>=+','>=-','<+','<-') NOT NULL DEFAULT '=' COMMENT 'Immer ASCII, die Beschriftung macht erst die Anzeige daraus. Bei Zeit und Datum: = um, >= ab, < vor. Beim Sonnenstand traegt der Operator zusaetzlich das Vorzeichen des Versatzes: + genau danach, >=- ab davor, <+ bis danach',
`value` varchar(255) NOT NULL DEFAULT '' COMMENT 'Schwelle als Text, der Datentyp steckt im Messwert',
PRIMARY KEY (`id`),
KEY `idx_cond_automation` (`automation_id`,`group_no`,`position`),
KEY `idx_cond_state` (`state_id`),
CONSTRAINT `fk_cond_automation` FOREIGN KEY (`automation_id`) REFERENCES `automations` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_cond_state` FOREIGN KEY (`state_id`) REFERENCES `actor_states` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- ---------------------------------------------------------------------------
-- Aktionen: ein Kommando eines Aktors, das bei Ausloesung geschickt wird.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `automation_actions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`automation_id` int(11) NOT NULL,
`position` int(11) NOT NULL DEFAULT 0 COMMENT 'Ausfuehrungsreihenfolge',
`command_id` int(11) NOT NULL COMMENT 'Referenz auf actor_commands: Geraet und Kommando in einem',
PRIMARY KEY (`id`),
KEY `idx_action_automation` (`automation_id`,`position`),
KEY `idx_action_command` (`command_id`),
CONSTRAINT `fk_action_automation` FOREIGN KEY (`automation_id`) REFERENCES `automations` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_action_command` FOREIGN KEY (`command_id`) REFERENCES `actor_commands` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- ---------------------------------------------------------------------------
-- Werte fuer die Parameter eines Kommandos. Eine Zeile je Parameter statt
-- fester Spalten Wert 1 bis Wert 4: wie viele es sind, sagt
-- command_parameters, nicht die Tabellenbreite.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `automation_action_params` (
`action_id` int(11) NOT NULL,
`parameter_id` int(11) NOT NULL COMMENT 'Referenz auf command_parameters',
`value` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`action_id`,`parameter_id`),
KEY `idx_actionparam_parameter` (`parameter_id`),
CONSTRAINT `fk_actionparam_action` FOREIGN KEY (`action_id`) REFERENCES `automation_actions` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_actionparam_parameter` FOREIGN KEY (`parameter_id`) REFERENCES `command_parameters` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- ---------------------------------------------------------------------------
-- Lauf-Protokoll. Speist die Spalte "zuletzt ausgeloest" in der Uebersicht
-- und macht Fehlschlaege sichtbar, die sonst still blieben. Der Runner
-- raeumt Eintraege aelter als 30 Tage selbst weg.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `automation_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`automation_id` int(11) NOT NULL,
`ts` datetime NOT NULL DEFAULT current_timestamp(),
`result` enum('fired','forced','error') NOT NULL DEFAULT 'fired',
`detail` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
KEY `idx_log_automation` (`automation_id`,`ts`),
CONSTRAINT `fk_log_automation` FOREIGN KEY (`automation_id`) REFERENCES `automations` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- ---------------------------------------------------------------------------
-- Ferien und Feiertage. Nur besondere Tage stehen drin, ein fehlendes Datum
-- ist ein gewoehnlicher Tag. Gefuellt von
-- restricted/autoActions/fetch_calendar.py, einmal jaehrlich per Cron.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `calendar_days` (
`date` date NOT NULL,
`holiday` varchar(80) DEFAULT NULL COMMENT 'Name des Feiertags, NULL = keiner',
`vacation` varchar(80) DEFAULT NULL COMMENT 'Name der Ferien, NULL = keine',
PRIMARY KEY (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+564 -373
View File
@@ -1,390 +1,581 @@
function addOptions(dropdownID, opts){
opts.forEach((opt, index) => {
var option = document.createElement("option");
if(opt.name){ //parameter could be command in case of actor.
option.text = opt.name;
option.value = opt.url;
if(opt.operators)
option.setAttribute("data-tglstates",JSON.stringify(opt.operators));
if(opt.type)
option.setAttribute("data-type",opt.type);
if(opt.options)
option.setAttribute("data-parameters",JSON.stringify([opt]));
if(opt.parameters){ //we have an actor with more than one parameters, then the parameters are actually nested inside the command
option.setAttribute("data-parameters",JSON.stringify(opt.parameters));
}
}else if(typeof opt === 'object'){
option.value = Object.keys(opt)[0];
option.text = opt[option.value];
}else {
option.text = opt;
option.value = opt;
}
if(typeof dropdownID === 'object'){
dropdownID.appendChild(option);
}else{
document.getElementById(dropdownID).appendChild(option);
}
/**
* Editor und Uebersicht fuer die Automatiken.
*
* Der Editor arbeitet auf einem Modell und zeichnet daraus die Oberflaeche -
* nicht umgekehrt. Frueher stand die erste Bedingungszeile als festes HTML im
* PHP und alle weiteren wurden im JavaScript zusammengesetzt; beide Fassungen
* liefen auseinander, und beim Speichern musste der Server aus durchnummerierten
* Feldnamen wieder eine Struktur raten.
*
* Modell:
* autoModel.groups = [ [ {state_id, operator, value}, ... ], ... ]
* eine innere Liste ist ein UND-Block, die aeussere
* verknuepft die Bloecke mit ODER
* autoModel.actions = [ {command_id, params: {parameterID: wert}}, ... ]
*
* Der Geraetekatalog kommt zusammen mit dem Datensatz im selben Dokument
* (siehe ajax/AutoAction.php), es gibt also keine Nachladerei je Zeile.
*/
let autoModel = null;
let autoDevices = [];
let autoLockouts = [];
// --- Katalog-Helfer ------------------------------------------------------
function deviceById(id) {
return autoDevices.find(d => d.id === id) || null;
}
function stateById(id) {
for (const dev of autoDevices) {
const state = dev.states.find(s => s.id === id);
if (state) return { device: dev, state: state };
}
return null;
}
function commandById(id) {
for (const dev of autoDevices) {
const cmd = dev.commands.find(c => c.id === id);
if (cmd) return { device: dev, command: cmd };
}
return null;
}
function operatorLabel(state, op) {
const treffer = state.operators.find(o => o.value === op);
return treffer ? treffer.label : op;
}
/** Erster Messwert des ersten Geraets - Vorbelegung fuer eine neue Zeile. */
function ersterMesswert() {
const dev = autoDevices.find(d => d.states.length > 0);
return dev ? dev.states[0] : null;
}
function ersterBefehl() {
const dev = autoDevices.find(d => d.commands.length > 0);
return dev ? dev.commands[0] : null;
}
// --- Bausteine fuer die Oberflaeche --------------------------------------
function optionListe(select, eintraege, gewaehlt) {
select.innerHTML = "";
eintraege.forEach(e => {
const opt = document.createElement("option");
opt.value = e.value;
opt.text = e.text;
if (String(e.value) === String(gewaehlt)) opt.selected = true;
select.appendChild(opt);
});
}
function clearOptions(dropdownID){
if(typeof dropdownID === 'object'){
dropdownID.innerHTML = "";
}else{
document.getElementById(dropdownID).innerHTML = "";
/**
* Wertfeld passend zum Datentyp des Messwerts oder Parameters.
* Liefert das fertige Element; der Aufrufer haengt seinen Listener an.
*/
function wertFeld(spec, wert) {
let feld;
if (spec.input === "bool") {
feld = document.createElement("select");
feld.className = "form-select";
optionListe(feld, [{ value: "true", text: "JA" }, { value: "false", text: "NEIN" }],
wert === "" ? "true" : wert);
} else if (spec.input === "select") {
feld = document.createElement("select");
feld.className = "form-select";
optionListe(feld, spec.options.map(o => ({ value: o, text: o })), wert);
} else {
feld = document.createElement("input");
feld.className = "form-control";
feld.type = spec.input;
if (spec.min !== undefined && spec.min !== null) feld.min = spec.min;
if (spec.max !== undefined && spec.max !== null) feld.max = spec.max;
feld.value = wert !== "" ? wert
: spec.input === "time" ? "00:00"
: spec.input === "date" ? new Date().toISOString().split("T")[0]
: spec.input === "number" ? "0" : "";
}
return feld;
}
function changeValueType(event){
dropdown = event.currentTarget;
type = dropdown.options[dropdown.selectedIndex].dataset.type;
id = dropdown.id.match(/\d+$/);
if(dropdown.id.search("act") > -1){
opBtnID = document.getElementById("btnActOperator"+id);
valBlockID = document.getElementById("act1ValBlock"+id);
valElemID = document.getElementById("act1Value"+id);
valOptID = document.getElementById("act1ValueOpt"+id);
val2BlockID = document.getElementById("act2ValBlock"+id);
val3BlockID = document.getElementById("act3ValBlock"+id);
val4BlockID = document.getElementById("act4ValBlock"+id);
}else{
opBtnID = document.getElementById("btnOperator"+id);
valBlockID =document.getElementById("valBlock"+id);
valElemID = document.getElementById("threshold"+id);
valOptID = document.getElementById("thresholdOpt"+id);
val2BlockID = null;
/** Standardwert, wenn eine Zeile auf einen anderen Messwert umgestellt wird. */
function standardWert(spec) {
if (spec.input === "bool") return "true";
if (spec.input === "select") return spec.options.length ? spec.options[0] : "";
if (spec.input === "time") return "00:00";
if (spec.input === "date") return new Date().toISOString().split("T")[0];
if (spec.input === "number") return "0";
return "";
}
// --- Ausloeser -----------------------------------------------------------
/**
* Zeichnet alle UND-Bloecke. Jeder Block hat sein eigenes "+ Bedingung", denn
* mit einem einzigen Knopf ganz unten kaeme man immer nur an den letzten
* Block heran und koennte eine bestehende Alternative nie mehr ergaenzen.
*/
function renderConditions() {
const ziel = document.getElementById("condBlocks");
ziel.innerHTML = "";
autoModel.groups.forEach((gruppe, gi) => {
if (gi > 0) {
const trenner = document.createElement("div");
trenner.className = "d-flex align-items-center my-2 text-body-secondary";
trenner.innerHTML = "<hr class='flex-grow-1 m-0'>"
+ "<span class='px-3 small fw-bold'>ODER</span>"
+ "<hr class='flex-grow-1 m-0'>";
ziel.appendChild(trenner);
}
if(type =="hidden"){
opBtnID.hidden = true;
valBlockID.hidden = true;
if(val2BlockID){ //if this is an actor hide all additional inputs, as only one dropdown is supported
val2BlockID.hidden = true;
val3BlockID.hidden = true;
val4BlockID.hidden = true;
}
}else{
opBtnID.setAttribute("data-tglstates",dropdown.options[dropdown.selectedIndex].dataset.tglstates);
opBtnID.innerHTML = JSON.parse(dropdown.options[dropdown.selectedIndex].dataset.tglstates)[0];
opBtnID.hidden = false;
valBlockID.hidden = false;
if(type == "dropdown"){
valElemID.type = "text";
valOptID.hidden = false;
valElemID.hidden = true;
if(val2BlockID){ //if this is an actor hide all additional inputs, as only one dropdown is supported
val2BlockID.hidden = true;
val3BlockID.hidden = true;
val4BlockID.hidden = true;
}
clearOptions(valOptID);
addOptions(valOptID,JSON.parse(dropdown.options[dropdown.selectedIndex].dataset.parameters)[0].options);
}else if(type == "button" || type == "bool"){
valElemID.type = "text";
valOptID.hidden = true;
valBlockID.hidden = true;
if(val2BlockID){ //if this is an actor hide all additional inputs, as only one dropdown is supported
val2BlockID.hidden = true;
val3BlockID.hidden = true;
val4BlockID.hidden = true;
}
}else{
valElemID.type = type;
if(type == "time")
valElemID.value = "00:00";
else if(type == "date")
valElemID.value = new Date().toISOString().split('T')[0]
else if(type == "text")
valElemID.value = "";
else
valElemID.value = "0";
valOptID.hidden = true;
valElemID.hidden = false;
if(val2BlockID){
parameters = JSON.parse(dropdown.options[dropdown.selectedIndex].dataset.parameters);
val2BlockID.hidden = true;
val3BlockID.hidden = true;
val4BlockID.hidden = true;
theAssociatedLabel = valElemID.parentNode.querySelector("label[for='" + valElemID.id + "']");
theAssociatedLabel.innerHTML = parameters[0].name;
switch(parameters.length){
case 4:
theAssociatedLabel = val4BlockID.querySelector("label[for='" + val4BlockID.id.replace("Block","ue") + "']");
theAssociatedLabel.innerHTML = parameters[3].name;
val4BlockID.hidden = false;
case 3:
theAssociatedLabel = val3BlockID.querySelector("label[for='" + val3BlockID.id.replace("Block","ue") + "']");
theAssociatedLabel.innerHTML = parameters[2].name;
val3BlockID.hidden = false;
case 2:
theAssociatedLabel = val2BlockID.querySelector("label[for='" + val2BlockID.id.replace("Block","ue") + "']");
theAssociatedLabel.innerHTML = parameters[1].name;
val2BlockID.hidden = false;
}
}
const block = document.createElement("div");
block.className = "border rounded p-2";
gruppe.forEach((bed, bi) => {
if (bi > 0) {
const und = document.createElement("div");
und.className = "small fw-bold text-body-secondary ms-1 my-1";
und.textContent = "und";
block.appendChild(und);
}
block.appendChild(conditionRow(gi, bi, bed));
});
const add = document.createElement("button");
add.type = "button";
add.className = "btn btn-outline-success btn-sm mt-2";
add.innerHTML = "<i class='bi bi-plus-lg'></i> Bedingung";
add.onclick = () => {
const state = ersterMesswert();
if (!state) return;
gruppe.push({ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) });
renderConditions();
};
block.appendChild(add);
ziel.appendChild(block);
});
renderSummary();
}
function conditionRow(gi, bi, bed) {
const zeile = document.createElement("div");
zeile.className = "input-group";
const treffer = stateById(bed.state_id);
const dev = treffer ? treffer.device : null;
const state = treffer ? treffer.state : null;
// Geraet
const geraet = document.createElement("select");
geraet.className = "form-select";
optionListe(geraet, autoDevices.filter(d => d.states.length)
.map(d => ({ value: d.id, text: d.name })),
dev ? dev.id : "");
geraet.onchange = () => {
const neu = deviceById(Number(geraet.value));
if (!neu || !neu.states.length) return;
bed.state_id = neu.states[0].id;
bed.operator = neu.states[0].operators[0].value;
bed.value = standardWert(neu.states[0]);
renderConditions();
};
zeile.appendChild(geraet);
// Messwert - nur zeigen, wenn das Geraet mehr als einen hat
if (dev && dev.states.length > 1) {
const messwert = document.createElement("select");
messwert.className = "form-select";
optionListe(messwert, dev.states.map(s => ({
value: s.id,
text: s.name + (s.value ? " (= " + s.value + ")" : "")
})), bed.state_id);
messwert.onchange = () => {
const neu = dev.states.find(s => s.id === Number(messwert.value));
bed.state_id = neu.id;
bed.operator = neu.operators[0].value;
bed.value = standardWert(neu);
renderConditions();
};
zeile.appendChild(messwert);
}
if (state) {
// Operator. Eine Auswahlliste statt des frueheren Umschalt-Knopfes: der
// zeigte nur seinen aktuellen Zustand, welche Vergleiche es sonst noch
// gibt, fand man erst durch Klicken heraus.
const op = document.createElement("select");
// Feste, schmale Breite: der Pfeil der Auswahlliste braucht Platz, sonst
// schiebt er das Zeichen aus dem sichtbaren Bereich.
op.className = "form-select flex-grow-0 text-center";
op.style.flex = "0 0 5.5rem";
optionListe(op, state.operators.map(o => ({ value: o.value, text: o.label })), bed.operator);
op.onchange = () => { bed.operator = op.value; renderSummary(); };
zeile.appendChild(op);
const wert = wertFeld(state, bed.value);
wert.onchange = () => { bed.value = wert.value; renderSummary(); };
zeile.appendChild(wert);
if (state.unit) {
const einheit = document.createElement("span");
einheit.className = "input-group-text";
einheit.textContent = state.unit;
zeile.appendChild(einheit);
}
}
function fillSensorDD(ID){
var elem = document.getElementById("sensorSelect"+ID);
fetch("./ajax/fillSensorDD.php", {
method: 'GET',
headers: {
'X-Requested-From-Modal': 'a',
'Requested-With-Ajax': 'ajax'
}
const weg = document.createElement("button");
weg.type = "button";
weg.className = "btn btn-outline-danger";
weg.innerHTML = "<i class='bi bi-trash3'></i>";
weg.onclick = () => {
autoModel.groups[gi].splice(bi, 1);
// Ein leerer Block hat keine Bedeutung mehr und verschwindet mit.
if (!autoModel.groups[gi].length) autoModel.groups.splice(gi, 1);
renderConditions();
};
zeile.appendChild(weg);
return zeile;
}
/**
* Derselbe Satz, den auch die Uebersicht und der Runner benutzen. Er steht
* unter den Bloecken, damit man die Klammerung nicht aus der Optik erschliessen
* muss, sondern schwarz auf weiss liest.
*/
function conditionText() {
const teile = autoModel.groups.filter(g => g.length).map(gruppe => {
const text = gruppe.map(bed => {
const treffer = stateById(bed.state_id);
if (!treffer) return "?";
let s = treffer.device.name + ": " + treffer.state.name + " "
+ operatorLabel(treffer.state, bed.operator) + " " + bed.value;
if (treffer.state.unit) s += " " + treffer.state.unit;
return s;
}).join(" und ");
return (autoModel.groups.length > 1 && gruppe.length > 1) ? "(" + text + ")" : text;
});
return teile.join(" oder ");
}
function renderSummary() {
const text = conditionText();
document.getElementById("condSummary").innerHTML = text
? "Löst aus, wenn <strong>" + text.replace(/&/g, "&amp;").replace(/</g, "&lt;") + "</strong>."
: "Noch kein Auslöser – die Automatik würde nie starten.";
}
// --- Aktionen ------------------------------------------------------------
function renderActions() {
const ziel = document.getElementById("actionList");
ziel.innerHTML = "";
autoModel.actions.forEach((aktion, ai) => ziel.appendChild(actionRow(ai, aktion)));
}
function actionRow(ai, aktion) {
const zeile = document.createElement("div");
zeile.className = "input-group mb-2";
const treffer = commandById(aktion.command_id);
const dev = treffer ? treffer.device : null;
const cmd = treffer ? treffer.command : null;
const geraet = document.createElement("select");
geraet.className = "form-select";
optionListe(geraet, autoDevices.filter(d => d.commands.length)
.map(d => ({ value: d.id, text: d.name })),
dev ? dev.id : "");
geraet.onchange = () => {
const neu = deviceById(Number(geraet.value));
if (!neu || !neu.commands.length) return;
aktion.command_id = neu.commands[0].id;
aktion.params = standardParams(neu.commands[0]);
renderActions();
};
zeile.appendChild(geraet);
if (dev && dev.commands.length > 1) {
const befehl = document.createElement("select");
befehl.className = "form-select";
optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id);
befehl.onchange = () => {
const neu = dev.commands.find(c => c.id === Number(befehl.value));
aktion.command_id = neu.id;
aktion.params = standardParams(neu);
renderActions();
};
zeile.appendChild(befehl);
}
// Je Parameter ein Feld - wie viele es sind, sagt das Geraet. Frueher gab es
// vier feste Felder "Wert 1" bis "Wert 4", die je nach Kommando versteckt
// wurden, ihren Inhalt aber trotzdem mitschickten.
if (cmd) {
cmd.params.forEach(p => {
const beschriftung = document.createElement("span");
beschriftung.className = "input-group-text";
beschriftung.textContent = p.name;
zeile.appendChild(beschriftung);
const wert = wertFeld(p, aktion.params[p.id] !== undefined ? aktion.params[p.id] : standardWert(p));
wert.onchange = () => { aktion.params[p.id] = wert.value; };
zeile.appendChild(wert);
});
}
const weg = document.createElement("button");
weg.type = "button";
weg.className = "btn btn-outline-danger";
weg.innerHTML = "<i class='bi bi-trash3'></i>";
weg.onclick = () => { autoModel.actions.splice(ai, 1); renderActions(); };
zeile.appendChild(weg);
return zeile;
}
function standardParams(cmd) {
const params = {};
cmd.params.forEach(p => { params[p.id] = standardWert(p); });
return params;
}
// --- Rahmenbedingungen ---------------------------------------------------
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function renderWeekdays() {
const ziel = document.getElementById("weekdayGroup");
ziel.innerHTML = "";
WOCHENTAGE.forEach((tag, i) => {
const feld = document.createElement("div");
feld.className = "input-group-text";
feld.innerHTML = "<input class='form-check-input mt-0 me-1' type='checkbox' id='day" + i + "'"
+ (autoModel.weekdays & (1 << i) ? " checked" : "") + ">"
+ "<label class='form-check-label' for='day" + i + "'>" + tag + "</label>";
feld.querySelector("input").onchange = e => {
if (e.target.checked) autoModel.weekdays |= (1 << i);
else autoModel.weekdays &= ~(1 << i);
};
ziel.appendChild(feld);
});
}
function fuelleFormular() {
const sperre = document.getElementById("lockoutSecs");
optionListe(sperre, autoLockouts.map(o => ({ value: o.value, text: o.label })),
autoModel.lockout_secs);
document.getElementById("autoName").value = autoModel.name;
// Kennt die Liste die Etage nicht, bleibt eine Auswahlliste sonst leer
// stehen und schickt beim Speichern "" mit.
const etage = document.getElementById("autoFloor");
etage.value = autoModel.floor;
if (etage.selectedIndex < 0) {
etage.selectedIndex = 0;
autoModel.floor = etage.value;
}
document.getElementById("tSpanFrom").value = autoModel.window_from;
document.getElementById("tSpanTo").value = autoModel.window_to;
document.getElementById("actFerien").checked = autoModel.on_vacation;
document.getElementById("actFeier").checked = autoModel.on_holiday;
document.getElementById("runOnce").checked = autoModel.force_once;
renderWeekdays();
renderConditions();
renderActions();
}
/** Die Felder, die direkt am Formular haengen, ins Modell zurueckholen. */
function leseFormular() {
autoModel.name = document.getElementById("autoName").value.trim();
autoModel.floor = document.getElementById("autoFloor").value;
autoModel.window_from = document.getElementById("tSpanFrom").value;
autoModel.window_to = document.getElementById("tSpanTo").value;
autoModel.on_vacation = document.getElementById("actFerien").checked;
autoModel.on_holiday = document.getElementById("actFeier").checked;
autoModel.force_once = document.getElementById("runOnce").checked;
autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value);
}
// --- Modal ---------------------------------------------------------------
function loadAutomatic() {
const daten = JSON.parse(document.getElementById("autoActionData").textContent);
autoDevices = daten.devices;
autoLockouts = daten.lockouts;
const a = daten.automation;
autoModel = {
id: a.id,
name: a.name,
floor: a.floor,
enabled: a.enabled,
window_from: a.window_from,
window_to: a.window_to,
weekdays: a.weekdays,
on_vacation: a.on_vacation,
on_holiday: a.on_holiday,
force_once: a.force_once,
lockout_secs: a.lockout_secs,
groups: [],
actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) }))
};
// Die flache Liste aus der Datenbank wieder in Bloecke fassen. Die
// Gruppennummern kommen aufsteigend und dicht, siehe saveAutomation().
const nachGruppe = new Map();
a.conditions.forEach(c => {
if (!nachGruppe.has(c.group)) nachGruppe.set(c.group, []);
nachGruppe.get(c.group).push({ state_id: c.state_id, operator: c.operator, value: c.value });
});
autoModel.groups = Array.from(nachGruppe.values());
// Eine neue Automatik startet mit einer leeren Bedingung und einer Aktion,
// sonst steht man vor einem leeren Formular ohne erkennbaren Anfang.
if (!autoModel.groups.length) {
const state = ersterMesswert();
if (state) {
autoModel.groups = [[{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]];
}
}
if (!autoModel.actions.length) {
const cmd = ersterBefehl();
if (cmd) autoModel.actions = [{ command_id: cmd.id, params: standardParams(cmd) }];
}
fuelleFormular();
}
function openAutoActionModal(params) {
const contentURL = "./ajax/AutoAction.php" + params;
document.getElementById("modal-title").innerHTML =
params.search("id=") > 0 ? "Automatik bearbeiten" : "Neue Automatik anlegen";
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
// Zuhoerer stellt sicher, dass nicht auch noch der Handler eines vorher
// geoeffneten Modals mitfeuert.
const alt = document.getElementById("modalSaveBtn");
const btn = alt.cloneNode(true);
alt.replaceWith(btn);
btn.addEventListener("click", submitAutoAction);
// Der Editor braucht mehr Breite als die Ladedialoge, gibt sie aber wieder
// her, damit die anderen Modals unveraendert aussehen.
const dialog = document.querySelector("#modalEV .modal-dialog");
dialog.classList.add("modal-xl");
document.getElementById("modalEV").addEventListener("hidden.bs.modal",
() => dialog.classList.remove("modal-xl"), { once: true });
const modalBodyElement = document.getElementById("modal-body");
modalBodyElement.innerHTML = loadingHTML("Wird geladen...");
modalEV.show();
fetch(contentURL, {
method: "GET",
headers: { "X-Requested-From-Modal": "a", "Requested-With-Ajax": "ajax" }
})
.then(response => response.text())
.then(html => {
modalBodyElement.innerHTML = html;
loadAutomatic();
document.getElementById("btnAddGroup").addEventListener("click", () => {
const state = ersterMesswert();
if (!state) return;
autoModel.groups.push([{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]);
renderConditions();
});
document.getElementById("btnAddActor").addEventListener("click", () => {
const cmd = ersterBefehl();
if (!cmd) return;
autoModel.actions.push({ command_id: cmd.id, params: standardParams(cmd) });
renderActions();
});
})
.catch(error => {
modalBodyElement.innerHTML = "Konnte den Editor nicht laden: " + error.message;
});
}
function submitAutoAction() {
leseFormular();
const nutzlast = {
id: autoModel.id,
name: autoModel.name,
floor: autoModel.floor,
enabled: autoModel.enabled,
window_from: autoModel.window_from,
window_to: autoModel.window_to,
weekdays: autoModel.weekdays,
on_vacation: autoModel.on_vacation,
on_holiday: autoModel.on_holiday,
force_once: autoModel.force_once,
lockout_secs: autoModel.lockout_secs,
conditions: [],
actions: autoModel.actions
};
autoModel.groups.forEach((gruppe, gi) => {
gruppe.forEach(bed => {
nutzlast.conditions.push({ group: gi, state_id: bed.state_id, operator: bed.operator, value: bed.value });
});
});
automationRequest("save", nutzlast).then(antwort => {
if (antwort.error) {
alert(antwort.error);
return;
}
modalEV.hide();
refreshAutomations(nutzlast.floor);
});
}
// --- Uebersicht ----------------------------------------------------------
function automationRequest(action, body) {
return fetch("./ajax/AutoAction.php?action=" + action, {
method: "POST",
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
body: JSON.stringify(body)
})
.then(response => response.json())
.catch(error => ({ error: error.message }));
}
/** Die Tabelle einer Etage neu holen. Ohne Etage alle drei. */
function refreshAutomations(floor) {
const etagen = floor ? [floor] : ["UG", "EG", "OG"];
etagen.forEach(etage => {
const ziel = document.getElementById("actions-" + etage + "-list");
if (!ziel) return;
fetch("./ajax/AutoAction.php?action=list&floor=" + etage, {
method: "GET",
headers: { "Requested-With-Ajax": "ajax" }
})
.then(response => response.text())
.then(html => {
elem.innerHTML = html;
elem.addEventListener("change", arrangeSensorInputs);
document.getElementById("paramSelect"+ID).addEventListener("change", changeValueType);
elem.dispatchEvent(new Event('change'));
})
.catch(error => {
elem.innerHTML = "";
});
}
.then(html => { ziel.innerHTML = html; })
.catch(() => { ziel.innerHTML = "<p class='text-danger'>Liste konnte nicht geladen werden.</p>"; });
});
}
function fillActorDD(ID){
var elem = document.getElementById("actorSelect"+ID);
fetch("./ajax/fillActorDD.php", {
method: 'GET',
headers: {
'X-Requested-From-Modal': 'a',
'Requested-With-Ajax': 'ajax'
}
})
.then(response => response.text())
.then(html => {
elem.innerHTML = html;
elem.addEventListener("change", arrangeActorInputs);
document.getElementById("actParamSelect"+ID).addEventListener("change", changeValueType);
document.getElementById("actorSelect"+ID).dispatchEvent(new Event('change'));
})
.catch(error => {
elem.innerHTML = "";
});
}
function toggleAutomation(id) {
automationRequest("toggle", { id: id }).then(antwort => {
if (antwort.error) alert(antwort.error);
else refreshAutomations(null);
});
}
function changeAllIDs (parentNode, newID) {
for (var i = 0; i < parentNode.childNodes.length; i++) {
var child = parentNode.childNodes[i];
changeAllIDs(child, newID);
}
if(parentNode.id){
parentNode.id = parentNode.id.replace(/\d+$/, newID);
}
if(parentNode.dataset !== undefined){
if(parentNode.dataset.delid)
parentNode.dataset.delid = parentNode.dataset.delid.replace(/\d+$/, newID);
}
}
function deleteAutomation(id, name) {
if (!confirm("Automatik \"" + name + "\" wirklich löschen?")) return;
automationRequest("delete", { id: id }).then(antwort => {
if (antwort.error) alert(antwort.error);
else refreshAutomations(null);
});
}
function delAutoEntry(event){
ID = event.currentTarget.dataset.delid;
document.getElementById(ID).remove();
ID = ID.replace(/\d+$/, function(n){ return ++n });
while(sensor= document.getElementById(ID)){
changeAllIDs(sensor, ID.match(/\d+$/)-1);
ID = ID.replace(/\d+$/, function(n){ return ++n });
}
}
function addSensor(event){
var t = document.getElementById('sensorsList').children;
//get the second last element (ignore "add" button)
nextID = Number(t[t.length-1].id.replace("sensorSettings","")) + 1;
var div = document.createElement('div');
div.className = "input-group mt-3";
div.id = "sensorSettings"+String(nextID);
div.innerHTML = `<button class='btn btn-outline-secondary' type='button' id='btnLogic${nextID}' name='btnLogic${nextID}' data-tglstates='["und","oder"]' >und</button>
<div class="form-floating">
<select class="form-select" id="sensorSelect${nextID}" name="sensorSelect${nextID}" aria-label="Default select example">
</select>
<label>Sensor</label>
</div>
<div class="form-floating" id="paramBlock${nextID}">
<select class="form-select" id="paramSelect${nextID}" name="paramSelect${nextID}" aria-label="Default select example">
</select>
<label>Messwert</label>
</div>
<button class="btn btn-outline-secondary" style="min-width: 40px;" type="button" id="btnOperator${nextID}" name="btnOperator${nextID}">&gt;</button>
<div class="form-floating" id="valBlock${nextID}">
<input type="number" class="form-control" id="threshold${nextID}" name="threshold${nextID}" placeholder="0" value="0"></input>
<select class="form-select" id="thresholdOpt${nextID}" name="thresholdOpt${nextID}" placeholder="0" value="0"></select>
<label>Wert/Schwelle</label>
</div>
<button class='btn btn-outline-danger' type='button' id='btnDel${nextID}' data-delid="sensorSettings${nextID}" onclick="delAutoEntry(event)"><i class="bi bi-trash3"></i></button>
</div>`;
document.getElementById("sensorsList").appendChild(div);
fillSensorDD(nextID);
}
function addActor(event){
var t = document.getElementById('actorsList').children;
//get the second last element (ignore "add" button)
nextID = Number(t[t.length-1].id.replace("actorSettings","")) + 1;
var div = document.createElement('div');
div.className = "input-group mt-3";
div.id = "actorSettings"+String(nextID);
div.innerHTML = `<div class="form-floating">
<select class="form-select" id="actorSelect${nextID}" name="actorSelect${nextID}" aria-label="Default select example">
</select>
<label>Aktor</label>
</div>
<div class="form-floating" id="actParamBlock${nextID}">
<select class="form-select" id="actParamSelect${nextID}" name="actParamSelect${nextID}" aria-label="Default select example">
</select>
<label>Eigenschaft</label>
</div>
<button class="btn btn-outline-secondary" style="min-width: 40px;" type="button" id="btnActOperator${nextID}" name="btnActOperator${nextID}">&gt;</button>
<div class="form-floating" id="act1ValBlock${nextID}">
<input type="number" class="form-control" id="act1Value${nextID}" name="act1Value${nextID}" placeholder="0" value="0"></input>
<select class="form-select" id="act1ValueOpt${nextID}" name="act1ValueOpt${nextID}"></select>
<label for="act1Value${nextID}">Wert 1</label>
</div>
<div class="form-floating" id="act2ValBlock{nextID}">
<input type="number" class="form-control" id="act2Value${nextID}" name="act2Value${nextID}" value="0"></input>
<label for="act2Value${nextID}">Wert 2</label>
</div>
<div class="form-floating" id="act3ValBlock{nextID}">
<input type="number" class="form-control" id="act3Value${nextID}" name="act3Value${nextID}" value="0"></input>
<label for="act3Value${nextID}">Wert 3</label>
</div>
<div class="form-floating" id="act4ValBlock{nextID}">
<input type="number" class="form-control" id="act4Value${nextID}" name="act4Value${nextID}" value="0"></input>
<label for="act4Value${nextID}">Wert 4</label>
</div>
<button class='btn btn-outline-danger' type='button' id='btnActDel${nextID}' data-delid="actorSettings${nextID}" onclick="delAutoEntry(event)"><i class="bi bi-trash3"></i></button>`;
document.getElementById("actorsList").appendChild(div);
/*"afterend",`<div class='input-group mb-3' id='actorSettings${nextID}'>
<div class="form-floating">
<select class="form-select" id="actorSelect${nextID}" aria-label="Default select example">
</select>
<label>Aktor</label>
</div>
<div class="form-floating" id="actParamBlock${nextID}">
<select class="form-select" id="actParamSelect${nextID}" aria-label="Default select example">
</select>
<label>Eigenschaft</label>
</div>
<button class="btn btn-outline-secondary " type="button" id="btnActOperator${nextID}">&gt;</button>
<div class="form-floating" id="actValBlock${nextID}">
<input type="number" class="form-control" id="actValue${nextID}" placeholder="0" value="0"></input>
<label>Sollwert</label>
</div>
<button class='btn btn-outline-danger' type='button' id='btnActDel${nextID}' data-delid="actorSettings${nextID}" onclick="delAutoEntry(event)"><i class="bi bi-trash3"></i></button>
</div>`);*/
fillActorDD(nextID);
}
function arrangeSensorInputs(event){
ID = Number(event.currentTarget.id.replace("sensorSelect",""));
sensorID = document.getElementById("sensorSelect"+ID).value;
fetch("./ajax/sensorDetails.php?sensorID="+sensorID, {
method: 'GET',
headers: {
'X-Requested-From-Modal': 'a',
'Requested-With-Ajax': 'ajax'
}
})
.then(response => response.text())
.then(html => {
params = JSON.parse(html);
if(params.parameters.length == 1){
document.getElementById("paramBlock"+ID).hidden = true;
}else{
document.getElementById("paramBlock"+ID).hidden = false;
}
document.getElementById("btnOperator"+ID).addEventListener("click", tglOperators);
clearOptions("paramSelect"+ID);
addOptions("paramSelect"+ID,params.parameters);
if(document.getElementById("btnLogic"+ID))
document.getElementById("btnLogic"+ID).addEventListener("click", tglOperators);
document.getElementById("paramSelect"+ID).dispatchEvent(new Event('change'));
})
.catch(error => {
alert(error);
document.getElementById("paramSelect"+ID).innerHTML = "";
});
}
function arrangeActorInputs(event){
ID = Number(event.currentTarget.id.replace("actorSelect",""));
actorID = document.getElementById("actorSelect"+ID).value;
fetch("./ajax/actorDetails.php?actorID="+actorID, {
method: 'GET',
headers: {
'X-Requested-From-Modal': 'a',
'Requested-With-Ajax': 'ajax'
}
})
.then(response => response.text())
.then(html => {
params = JSON.parse(html);
document.getElementById("actParamSelect"+ID).innerHTML = "";
if(params.commands.length == 1){
document.getElementById("actParamBlock"+ID).hidden = true;
}else{
document.getElementById("actParamBlock"+ID).hidden = false;
}
clearOptions("actParamSelect"+ID);
addOptions("actParamSelect"+ID,params.commands);
document.getElementById("actParamSelect"+ID).dispatchEvent(new Event('change'));
})
.catch(error => {
document.getElementById("actParamSelect"+ID).innerHTML = "";
});
}
function tglOperators(event){
btn = event.currentTarget;
operators = JSON.parse(btn.dataset.tglstates);
current = btn.innerHTML;
btn.innerHTML = operators[(operators.indexOf(current)+1) % operators.length];
}
function loadAutomatic(params){
if(params.search("action=new")>0){
fillSensorDD("1");
fillActorDD("1");
}
}
function openAutoActionModal(params) {
let contentURL = "./ajax/AutoAction.php"+params;
if(params.search("action=new")>0)
document.getElementById("modal-title").innerHTML = "Neue Automatik anlegen";
else
document.getElementById("modal-title").innerHTML = "Automatik Einstellungen";
modalBodyElement = document.getElementById('modal-body');
modalBodyElement.innerHTML = loadingHTML("Wird geladen...");
document.getElementById("modalSaveBtn").addEventListener("click", submitFormAjax);
document.getElementById("modalSaveBtn").contentURL = contentURL;
modalEV.show();
fetch(contentURL, {
method: 'GET',
headers: {
'X-Requested-From-Modal': 'a',
'Requested-With-Ajax': 'ajax'
}
})
.then(response => response.text())
.then(html => {
modalBodyElement.innerHTML = html;
loadAutomatic(params);
document.getElementById("btnAddSensor").addEventListener("click", addSensor);
document.getElementById("btnAddActor").addEventListener("click", addActor);
})
.catch(error => {
modalBodyElement.innerHTML += error.message;
});
}
document.addEventListener("DOMContentLoaded", () => refreshAutomations(null));
+170
View File
@@ -0,0 +1,170 @@
# AutoActions
Automatiken, die im Web-UI unter „Automatismen" angelegt werden und hier
ausgeführt werden: *wenn Bedingung, dann Kommando*.
```
Browser Datenbank homeMesh Runner
────────────────────── ───────────────────────── ────────────────────
Karte „Automatismen" ──▶ automations ──▶ autoaction_runner.py
ajax/AutoAction.php automation_conditions MQTT / HTTP / Tahoma
restricted/automations.php automation_actions ──▶ Geräte
js/solar/autoActionFuncs.js automation_action_params
automation_log
calendar_days ◀── fetch_calendar.py
```
## Wie eine Automatik aufgebaut ist
Eine Automatik hat **Auslöser**, **Rahmenbedingungen** und **Aktionen**.
Ein Auslöser vergleicht einen Messwert (`actor_states`) mit einer Schwelle.
Mehrere Auslöser werden über `group_no` verknüpft: gleiche Nummer heißt UND,
verschiedene Nummern heißen ODER — ausgewertet wird `any(all(gruppe))`. Im
Editor ist eine Gruppe ein gerahmter Block mit eigenem „+ Bedingung", zwischen
den Blöcken steht ein ODER. Die Klammerung ist damit gezeichnet und nicht bloß
vereinbart, und darunter steht derselbe Ausdruck noch einmal als Satz.
Eine Aktion ist ein Kommando (`actor_commands`) mit einem Wert je Parameter
(`command_parameters`) — nicht vier feste Spalten „Wert 1" bis „Wert 4",
sondern so viele Zeilen, wie das Gerät Parameter hat.
Die Rahmenbedingungen (Wochentage, Zeitfenster, Ferien, Feiertage) sagen, wann
die Automatik überhaupt hinsehen darf.
## Warum ein Dauerläufer und kein Cronjob
Zwei Gründe:
* Schwellwert-Auslöser sollen greifen, wenn die MQTT-Nachricht hereinkommt,
nicht erst im nächsten Minutenraster.
* `actor_states.current_value` wird sonst von niemandem fortgeschrieben — es
wird beim Geräte-Discovery einmal gesetzt und danach nie wieder. Ein
zustandsloser Cronjob hätte gar nichts, womit er vergleichen könnte. Der
Runner pflegt den Wert nebenbei mit (gedrosselt auf einmal je Minute), wovon
auch der Editor profitiert: er zeigt neben jedem Messwert den aktuellen Stand.
## Nur die steigende Flanke
`automations.cond_met` hält fest, ob die Bedingung beim letzten Durchlauf schon
erfüllt war. Ohne das würde „Temperatur über 22 Grad" bei jedem Takt erneut
feuern. Verlässt die Automatik ihr Zeitfenster, wird die Flanke
zurückgesetzt, damit sie im nächsten Fenster wieder steigen kann.
Zeit-Auslöser gibt es in drei Formen:
| | wahr, wenn |
|---|---|
| `um 16:30` | genau in dieser Minute |
| `ab 16:30` | von da an bis Mitternacht |
| `vor 16:30` | bis dahin |
Ausgelöst wird in allen drei Fällen nur einmal, eben wegen der Flanke. `ab`
ist trotzdem das robustere: fällt der Runner in genau der Minute aus, auf die
`um` zeigt, ist die Automatik für den Tag verloren — bei `ab` holt der nächste
Takt es nach. Wer `um` braucht und den Ausfall nicht riskieren will, hakt
zusätzlich `force_once` an. Die breiten Zeitfenster in `auto_watering.py`
folgen derselben Überlegung.
Beim Sonnenauf- und -untergang ist der Wert ein Versatz, und der kann davor
oder danach liegen — deshalb dieselben drei Fälle 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.
Über den Tagesrand wird gerechnet, nicht abgeschnitten: „sechs Stunden vor
Sonnenaufgang" landet am Vorabend, und das ist so gewollt — abgeschnitten
wären solche Angaben gar nicht mehr formulierbar. Verglichen wird die Uhrzeit
innerhalb des Tages; ein Ziel jenseits von Mitternacht gilt als diese Uhrzeit
am selben Tag. Bei `+` und `-` ist das genau der gemeinte Zeitpunkt, bei `ab`
und `vor` verschiebt sich der wahre Bereich entsprechend mit.
`force_once` („am Ende des Zeitraums auf jeden Fall ausführen") greift, wenn
das Fenster zugeht und in diesem Fenster noch nichts passiert ist.
## Sperrzeit
Die Flanke allein schützt nicht gegen einen Messwert, der um die Schwelle
**pendelt**: „Temperatur > 22" bei 22,1 / 21,9 / 22,1 °C ist jedes Mal eine
echte steigende Flanke, und über MQTT können die Werte im Sekundentakt
hereinkommen. `automations.lockout_secs` sagt, wie lange nach einer Auslösung
nicht wieder geschaltet wird. Der Editor bietet drei Stufen an:
| | | gedacht für |
|---|---|---|
| Ohne | 0 s | volle Geschwindigkeit, jede Flanke schaltet |
| Kurz | 60 s | Licht, Farbe, Dimmwert |
| Lang | 900 s | Rollläden, Ventile, alles mit Motor |
Gespeichert werden Sekunden, angeboten werden nur die drei Stufen — eine
vierte ist damit eine Zeile in `lockoutChoices()` und keine Wanderung durch
die Datenbank.
Eine Flanke innerhalb der Sperrzeit wird **verworfen, nicht aufgehoben**. Ein
Rollladen, der eine Viertelstunde später doch noch losfährt, weil vor langer
Zeit einmal eine Schwelle gestreift wurde, wäre unangenehmer als einer, der
gar nicht fährt — und der nächste echte Anlass nach Ablauf der Sperre kommt
ohnehin durch. Verworfene Flanken stehen im Log auf `DEBUG`, nicht in
`automation_log`; bei einem zappelnden Sensor wäre die Tabelle sonst voll
davon.
`force_once` ist von der Sperre nicht betroffen: es greift nur, wenn im
Fenster gar nichts gelaufen ist — dann ist auch keine Sperre aktiv.
## Transporte
Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`:
| URL | Messwert (`actor_states.url`) | Kommando |
|-------------|-------------------------------|-------------------------------------|
| `mqtt://…` | vollständiges Topic, abonniert | publiziert auf das Parameter-Topic |
| `http://…` | Feldname in der JSON-Antwort, gepollt | Parameter als Abfrageargumente |
| `io://…` | Tahoma-Statusname, gepollt | `exec/apply` an die Tahoma-Box |
| `Logic` | gerechnet: Uhrzeit, Datum, Sonne | – |
Alle vier stehen in `transports.py`. Eine fünfte Geräteart kommt als weitere
Klasse dazu; sie braucht `passt()`, `zustaende_lesen()` und `senden()`.
Bei HTTP heißen die Schaltbefehle nicht so wie in der Datenbank — dafür gibt es
in `HTTPTransport` eine kleine Übersetzungstabelle (`turn_on` → `on=true`).
Das ist die Stelle, die beim Anschluss neuer HTTP-Geräte wächst.
## Voraussetzungen
* Python 3 mit `pymysql`, `requests`, `paho-mqtt`
* `homeMesh_automations.sql` einmal eingespielt
* In `../deviceDiscovery/config.ini` muss **`clear_tables = false`** stehen.
Discovery schreibt mit `ON DUPLICATE KEY UPDATE` auf den URLs, das Leeren ist
unnötig — ein `TRUNCATE` würde dagegen die Geräte-IDs neu vergeben, und die
Automatiken zeigen per Fremdschlüssel genau auf diese IDs.
## Einrichten
```bash
cp config.ini.example config.ini # ausfüllen: Datenbank, MQTT, Tahoma
python3 fetch_calendar.py # Feiertage und Ferien holen
python3 autoaction_runner.py --once --dry-run --verbose # Probelauf
```
`--dry-run` schaltet nichts, protokolliert aber jedes Kommando, das geschickt
würde. `--once` macht einen einzigen Durchlauf.
Im Dauerbetrieb wird `autoaction_runner.py` beim Booten gestartet (auf der
Synology über den Aufgabenplaner, Ereignis „Hochfahren", als root). Er
verbindet sich selbst neu, wenn MQTT wegbricht, und lädt das Regelwerk nach,
sobald im Browser etwas gespeichert wurde — ein Neustart nach jeder Änderung
ist nicht nötig.
`fetch_calendar.py` gehört einmal jährlich in den Cron:
```
0 4 1 1 * /usr/bin/python3 /volume1/web/smart/restricted/autoActions/fetch_calendar.py
```
Ein zusätzlicher Lauf im Herbst schadet nicht — die Ferientermine des
übernächsten Schuljahres stehen erst später fest.
## Nachsehen, was passiert ist
`automation_log` hält je Auslösung fest, ob sie durchlief (`fired`), wegen
`force_once` nachgeholt wurde (`forced`) oder scheiterte (`error`, mit Grund in
`detail`). Einträge älter als 30 Tage räumt der Runner selbst weg.
+715
View File
@@ -0,0 +1,715 @@
#!/usr/bin/env python3
"""
AutoAction-Runner - fuehrt die im Web-UI angelegten Automatiken aus.
Laeuft als Dauerprozess, nicht als Cronjob. Zwei Gruende:
* Schwellwert-Ausloeser ("Temperatur ueber 22 Grad") sollen sofort greifen,
wenn die Nachricht hereinkommt, und nicht bis zum naechsten Minutenraster
warten.
* `actor_states.current_value` wird sonst von niemandem fortgeschrieben -
beim Geraete-Discovery einmal gesetzt und danach nie wieder. Ein
zustandsloser Cronjob haette also gar nichts, womit er vergleichen
koennte. Der Runner pflegt den Wert nebenbei mit, wodurch auch der Editor
im Browser aktuelle Zahlen anzeigt.
Ablauf:
Start Regelwerk und Geraetemodell laden, MQTT-Topics abonnieren
Ereignis MQTT-Nachricht -> Wert merken -> im naechsten Takt auswerten
Takt alle tick_seconds: Uhrzeit/Datum/Sonnenzeiten neu rechnen,
HTTP- und Tahoma-Geraete pollen, Regelwerk auf Aenderung pruefen
Pruefen aktiv? (enabled, Wochentag, Ferien/Feiertag, Zeitfenster)
-> Bedingungen auswerten -> steigende Flanke -> Aktionen
Nur die steigende Flanke loest aus: `automations.cond_met` haelt fest, ob die
Bedingung beim letzten Durchlauf schon erfuellt war. Ohne das wuerde
"Temperatur ueber 22 Grad" bei jedem Takt erneut feuern.
Zeit-Ausloeser gibt es in drei Formen: "um 16:30" ist genau in dieser Minute
wahr, "ab 16:30" von da an bis Mitternacht, "vor 16:30" bis dahin.
Ausgeloest wird in allen drei Faellen nur einmal, eben wegen der Flanke -
"ab" ist aber das robustere: faellt der Runner in der einen Minute aus, auf
die "um" zeigt, ist die Automatik fuer den Tag verloren; bei "ab" holt der
naechste Takt es nach. Wer "um" braucht und den Ausfall nicht riskieren
will, hakt zusaetzlich force_once an. Dieselbe Ueberlegung steht hinter den
breiten Zeitfenstern in auto_watering.py.
Beim Sonnenauf- und -untergang traegt der Operator zusaetzlich das
Vorzeichen des Versatzes: "+ 00:30" eine halbe Stunde danach, ">=- 00:30"
ab einer halben Stunde davor, "<+ 00:30" bis eine halbe Stunde danach.
Tabellen siehe homeMesh_automations.sql, Konfiguration siehe config.ini.example.
"""
import argparse
import configparser
import logging
import os
import sys
import time
from datetime import date, datetime, timedelta
import pymysql
import requests
import paho.mqtt.client as mqtt
from transports import (HTTPTransport, LogicTransport, MQTTTransport,
TahomaTransport)
logger = logging.getLogger("autoaction")
# Wie lange ein Messwert in der Datenbank stehen bleiben darf, bevor er
# aufgefrischt wird. Der Wert dient nur der Anzeige im Editor; jede Nachricht
# sofort zu schreiben waere bei einem gespraechigen Sensor sinnlose Last.
SCHREIB_ABSTAND = timedelta(seconds=60)
# Aelteres im Protokoll interessiert niemanden mehr.
LOG_AUFBEWAHRUNG_TAGE = 30
# ===========================================================================
# Konfiguration
# ===========================================================================
class Config:
def __init__(self, dateiname="config.ini"):
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), dateiname)
if not os.path.exists(pfad):
raise FileNotFoundError(
"%s fehlt - config.ini.example kopieren und ausfuellen." % pfad)
self.cfg = configparser.ConfigParser()
self.cfg.read(pfad, encoding="utf-8")
def text(self, sektion, schluessel, vorgabe=""):
return self.cfg.get(sektion, schluessel, fallback=vorgabe).strip()
def zahl(self, sektion, schluessel, vorgabe=0):
try:
return self.cfg.getint(sektion, schluessel, fallback=vorgabe)
except ValueError:
return vorgabe
def ja(self, sektion, schluessel, vorgabe=False):
return self.text(sektion, schluessel, str(vorgabe)).lower() in ("true", "1", "yes", "on")
# ===========================================================================
# Datenbank
# ===========================================================================
def verbinden(config, sektion="database"):
return pymysql.connect(
host=config.text("database", "host", "localhost"),
port=config.zahl("database", "port", 3306),
user=config.text(sektion, "user") or config.text("database", "user"),
password=config.text(sektion, "password") or config.text("database", "password"),
database=config.text(sektion, "database"),
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True)
class Regelwerk:
"""
Das geladene Abbild der Datenbank: Automatiken mit ihren Bedingungen und
Aktionen, dazu die Messwerte und Kommandos, die sie benutzen.
"""
def __init__(self, automatiken, states, kommandos, signatur):
self.automatiken = automatiken
self.states = states # state_id -> Beschreibung
self.kommandos = kommandos # command_id -> Beschreibung
self.signatur = signatur
@staticmethod
def signatur_lesen(db):
"""
Woran der Runner merkt, dass er neu laden muss. `changed` allein
genuegt nicht: eine geloeschte Automatik veraendert den groessten
Zeitstempel nicht. Deshalb zaehlen die Zeilen mit - auch die der
Geraetetabellen, damit ein Discovery-Lauf ebenfalls durchschlaegt.
"""
with db.cursor() as c:
c.execute("""SELECT (SELECT COUNT(*) FROM automations) AS a,
(SELECT UNIX_TIMESTAMP(MAX(changed)) FROM automations) AS t,
(SELECT COUNT(*) FROM automation_conditions) AS b,
(SELECT COUNT(*) FROM automation_actions) AS c,
(SELECT COUNT(*) FROM actor_states) AS d,
(SELECT COUNT(*) FROM actor_commands) AS e""")
return tuple(sorted(c.fetchone().items()))
@classmethod
def laden(cls, db):
signatur = cls.signatur_lesen(db)
states = {}
with db.cursor() as c:
c.execute("""SELECT s.id, s.state_name, s.url AS state_url, s.current_value,
a.url AS actor_url, a.name AS actor_name, t.type
FROM actor_states s
JOIN actors a ON a.id = s.actor_id
LEFT JOIN state_types t ON s.state_type = t.id""")
for row in c.fetchall():
row["type"] = row["type"] or "string"
states[row["id"]] = row
kommandos = {}
with db.cursor() as c:
c.execute("""SELECT k.id, k.command_name, k.command_url,
a.url AS actor_url, a.name AS actor_name
FROM actor_commands k JOIN actors a ON a.id = k.actor_id""")
for row in c.fetchall():
row["params"] = []
kommandos[row["id"]] = row
with db.cursor() as c:
c.execute("""SELECT id, command_id, parameter_name, url FROM command_parameters
ORDER BY command_id, id""")
for row in c.fetchall():
if row["command_id"] in kommandos:
kommandos[row["command_id"]]["params"].append(row)
automatiken = {}
with db.cursor() as c:
c.execute("SELECT * FROM automations WHERE enabled = 1")
for row in c.fetchall():
row["gruppen"] = {}
row["aktionen"] = []
automatiken[row["id"]] = row
with db.cursor() as c:
c.execute("""SELECT * FROM automation_conditions
ORDER BY automation_id, group_no, position, id""")
for row in c.fetchall():
auto = automatiken.get(row["automation_id"])
if auto is None:
continue
if row["state_id"] not in states:
logger.warning("Automatik %s: Messwert %s gibt es nicht mehr",
auto["name"], row["state_id"])
continue
auto["gruppen"].setdefault(row["group_no"], []).append(row)
with db.cursor() as c:
c.execute("""SELECT a.id, a.automation_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
ORDER BY a.automation_id, a.position, a.id""")
gesammelt = {}
for row in c.fetchall():
auto = automatiken.get(row["automation_id"])
if auto is None:
continue
aktion = gesammelt.get(row["id"])
if aktion is None:
aktion = {"command_id": row["command_id"], "werte": {}}
gesammelt[row["id"]] = aktion
auto["aktionen"].append(aktion)
if row["parameter_id"] is not None:
aktion["werte"][row["parameter_id"]] = row["value"]
logger.info("Regelwerk geladen: %d Automatiken, %d Messwerte, %d Kommandos",
len(automatiken), len(states), len(kommandos))
return cls(automatiken, states, kommandos, signatur)
# ===========================================================================
# Auswertung
# ===========================================================================
def minuten(text):
""""16:30" oder "16:30:00" als Minuten seit Mitternacht."""
teile = str(text).strip().split(":")
return int(teile[0]) * 60 + int(teile[1])
def als_datum(text):
for form in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%y"):
try:
return datetime.strptime(str(text).strip(), form).date()
except ValueError:
continue
return None
def als_zeitpunkt(text):
"""Datum mit Uhrzeit. Das Web-Feld liefert "2026-08-30T16:30"."""
roh = str(text).strip().replace("T", " ")
for form in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M",
"%d.%m.%Y %H:%M:%S", "%d.%m.%Y %H:%M"):
try:
return datetime.strptime(roh, form)
except ValueError:
continue
tag = als_datum(roh.split(" ")[0])
return datetime.combine(tag, datetime.min.time()) if tag else None
WAHR = {"true", "1", "on", "ja", "yes", "an"}
def bedingung_erfuellt(bedingung, state, wert, jetzt):
"""
Ein einzelner Vergleich. `wert` ist der aktuelle Messwert als Text, so wie
er vom Geraet kam; `bedingung["value"]` die eingestellte Schwelle.
Unbekannter Wert heisst nicht erfuellt - lieber nicht schalten als auf
Verdacht schalten.
"""
if wert is None or wert == "":
return False
typ = state["type"]
op = bedingung["operator"]
soll = bedingung["value"]
try:
if typ in ("integer", "float"):
ist_z, soll_z = float(str(wert).replace(",", ".")), float(str(soll).replace(",", "."))
if op == "=": return ist_z == soll_z
if op == "!=": return ist_z != soll_z
if op == ">": return ist_z > soll_z
if op == "<": return ist_z < soll_z
if op == ">=": return ist_z >= soll_z
if op == "<=": return ist_z <= soll_z
return False
if typ == "time":
ist_m, soll_m = minuten(wert), minuten(soll)
if op == "=": return ist_m == soll_m
if op == "<": return ist_m < soll_m
return ist_m >= soll_m
if typ == "deltatime":
# Der Messwert ist der Sonnenauf- bzw. -untergang, die Schwelle
# ein Versatz. Das Vorzeichen steckt im Operator, der Vergleich
# davor: ">=-" heisst "ab einer halben Stunde davor".
versatz = minuten(soll)
ziel = minuten(wert) + (-versatz if op.endswith("-") else versatz)
# Ueber den Tagesrand wird gerechnet, nicht abgeschnitten: "sechs
# Stunden vor Sonnenaufgang" ist eine gewollte Angabe und landet
# dann eben am Vorabend. Abgeschnitten waeren solche Faelle gar
# nicht mehr formulierbar.
#
# Verglichen wird die Uhrzeit innerhalb des Tages. Ein Ziel
# jenseits von Mitternacht gilt also als diese Uhrzeit am selben
# Tag - bei "um" ist das genau der gemeinte Zeitpunkt, bei "ab"
# und "vor" verschiebt sich der wahre Bereich entsprechend.
ziel %= 1440
jetzt_m = jetzt.hour * 60 + jetzt.minute
if op.startswith(">="): return jetzt_m >= ziel
if op.startswith("<"): return jetzt_m < ziel
return jetzt_m == ziel
if typ in ("date", "datetime"):
wandeln = als_datum if typ == "date" else als_zeitpunkt
ist_d, soll_d = wandeln(wert), wandeln(soll)
if ist_d is None or soll_d is None:
return False
if op == "=": return ist_d == soll_d
if op == "<": return ist_d < soll_d
return ist_d >= soll_d
if typ == "bool":
ist_b = str(wert).strip().lower() in WAHR
soll_b = str(soll).strip().lower() in WAHR
return ist_b == soll_b if op == "=" else ist_b != soll_b
# string und alles Uebrige
if op == "=":
return str(wert).strip() == str(soll).strip()
return str(wert).strip() != str(soll).strip()
except (ValueError, IndexError) as fehler:
logger.debug("Vergleich %s %s %s nicht moeglich: %s", wert, op, soll, fehler)
return False
def gruppen_erfuellt(automatik, regelwerk, werte, jetzt):
"""
Gleiche group_no = UND, verschiedene = ODER. Eine Automatik ohne
Bedingungen loest nie aus - sonst wuerde sie nach einem Discovery-Lauf,
der ihren Messwert entfernt hat, ploetzlich dauernd feuern.
"""
if not automatik["gruppen"]:
return False
for bedingungen in automatik["gruppen"].values():
if all(bedingung_erfuellt(b, regelwerk.states[b["state_id"]],
werte.get(b["state_id"]), jetzt)
for b in bedingungen):
return True
return False
def im_zeitfenster(jetzt, von, bis):
"""von > bis heisst: das Fenster reicht ueber Mitternacht."""
m = jetzt.hour * 60 + jetzt.minute
a, b = minuten(von), minuten(bis)
return a <= m <= b if a <= b else (m >= a or m <= b)
def tag_passt(automatik, jetzt, kalender):
if not automatik["weekdays"] & (1 << jetzt.weekday()):
return False
if kalender["feiertag"] and not automatik["on_holiday"]:
return False
if kalender["ferien"] and not automatik["on_vacation"]:
return False
return True
# ===========================================================================
# Der Runner
# ===========================================================================
class Runner:
def __init__(self, config, dry_run=False):
self.config = config
self.dry_run = dry_run or config.ja("runner", "dry_run")
self.db = verbinden(config)
self.solar = verbinden(config, "solar")
self.werte = {} # state_id -> letzter bekannter Wert
self.geschrieben = {} # state_id -> wann zuletzt in die DB
self.war_aktiv = {} # automation_id -> war im Zeitfenster
self.lief_im_fenster = {} # automation_id -> hat im Fenster ausgeloest
self._sonne = (None, "00:00", "00:00") # (datum, aufgang, untergang)
self._kalender = (None, {"feiertag": False, "ferien": False})
self._letzte_saeuberung = None
self.mqtt = self._mqtt_verbinden()
self.transporte = [
MQTTTransport(self.mqtt, self.dry_run),
HTTPTransport(requests, dry_run=self.dry_run),
TahomaTransport(requests, config.text("tahoma", "pin"),
config.text("tahoma", "token"),
config.zahl("tahoma", "timeout", 10), self.dry_run),
LogicTransport(self.sonnenzeiten),
]
self.regelwerk = None
# --- Aufbau ----------------------------------------------------------
def _mqtt_verbinden(self):
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
client_id=self.config.text("mqtt", "client_id", "autoaction_runner"))
benutzer = self.config.text("mqtt", "username")
if benutzer:
client.username_pw_set(benutzer, self.config.text("mqtt", "password"))
client.on_message = self._mqtt_nachricht
client.connect(self.config.text("mqtt", "broker", "localhost"),
self.config.zahl("mqtt", "port", 1883), 60)
client.loop_start()
return client
def _mqtt_nachricht(self, client, userdata, nachricht):
# Der Client laeuft schon, waehrend __init__ noch die Transporte baut.
for transport in getattr(self, "transporte", []):
if isinstance(transport, MQTTTransport):
transport.nachricht(nachricht.topic, nachricht.payload)
def transport_fuer(self, actor_url):
for transport in self.transporte:
if transport.passt(actor_url):
return transport
return None
def regelwerk_laden(self):
self.regelwerk = Regelwerk.laden(self.db)
# Jeder Transport bekommt die Messwerte, fuer die er zustaendig ist.
for transport in self.transporte:
passende = [{"id": s["id"], "actor_url": s["actor_url"], "state_url": s["state_url"]}
for s in self.regelwerk.states.values()
if transport.passt(s["actor_url"])]
transport.zustaende_anmelden(passende)
# Der zuletzt bekannte Wert aus der Datenbank ist besser als gar
# keiner: nach einem Neustart steht sonst jede Bedingung auf "unklar",
# bis das Geraet zufaellig etwas schickt.
for s in self.regelwerk.states.values():
if s["id"] not in self.werte and s["current_value"] is not None:
self.werte[s["id"]] = s["current_value"]
# --- Umgebung --------------------------------------------------------
def sonnenzeiten(self):
"""Aus solarLog.daylight, einmal je Tag geholt."""
heute = date.today()
if self._sonne[0] == heute:
return self._sonne[1], self._sonne[2]
auf, unter = "00:00", "00:00"
try:
with self.solar.cursor() as c:
c.execute("SELECT sunrise, sunset FROM daylight WHERE date = %s", (heute,))
zeile = c.fetchone()
if zeile:
auf = self._als_uhrzeit(zeile["sunrise"])
unter = self._als_uhrzeit(zeile["sunset"])
else:
logger.warning("Kein Eintrag in daylight fuer %s", heute)
except Exception as fehler:
logger.warning("Sonnenzeiten nicht lesbar: %s", fehler)
self._sonne = (heute, auf, unter)
return auf, unter
@staticmethod
def _als_uhrzeit(wert):
"""TIME-Spalten liefert pymysql als timedelta, nicht als Text."""
if isinstance(wert, timedelta):
minute = int(wert.total_seconds()) // 60
return "%02d:%02d" % (minute // 60 % 24, minute % 60)
return str(wert)[:5]
def kalender(self):
"""Ferien und Feiertage von heute, einmal je Tag geholt."""
heute = date.today()
if self._kalender[0] == heute:
return self._kalender[1]
stand = {"feiertag": False, "ferien": False}
try:
with self.db.cursor() as c:
c.execute("SELECT holiday, vacation FROM calendar_days WHERE date = %s", (heute,))
zeile = c.fetchone()
if zeile:
stand = {"feiertag": bool(zeile["holiday"]), "ferien": bool(zeile["vacation"])}
except Exception as fehler:
logger.warning("Kalender nicht lesbar: %s", fehler)
self._kalender = (heute, stand)
return stand
# --- Werte -----------------------------------------------------------
def werte_einsammeln(self, mit_pollen):
neu = {}
for transport in self.transporte:
if isinstance(transport, MQTTTransport) or mit_pollen:
neu.update(transport.zustaende_lesen())
if neu:
self.werte.update(neu)
self.werte_zurueckschreiben(neu)
return neu
def werte_zurueckschreiben(self, neu):
"""
current_value nachfuehren, damit der Editor im Browser aktuelle Zahlen
zeigt ("Temperatur (= 21,4 °C)"). Gedrosselt, sonst schreibt ein
gespraechiger Sensor die Tabelle im Sekundentakt voll.
"""
jetzt = datetime.now()
faellig = [(w, i) for i, w in neu.items()
if self.geschrieben.get(i, datetime.min) + SCHREIB_ABSTAND <= jetzt]
if not faellig:
return
try:
with self.db.cursor() as c:
c.executemany("UPDATE actor_states SET current_value = %s WHERE id = %s", faellig)
for _, state_id in faellig:
self.geschrieben[state_id] = jetzt
except Exception as fehler:
logger.warning("current_value nicht schreibbar: %s", fehler)
# --- Ausfuehren ------------------------------------------------------
def ausloesen(self, automatik, anlass):
fehlerText = []
for aktion in automatik["aktionen"]:
kommando = self.regelwerk.kommandos.get(aktion["command_id"])
if kommando is None:
fehlerText.append("Kommando %s gibt es nicht mehr" % aktion["command_id"])
logger.error("%s: Kommando %s gibt es nicht mehr",
automatik["name"], aktion["command_id"])
continue
transport = self.transport_fuer(kommando["actor_url"])
if transport is None:
fehlerText.append("Kein Transport fuer %s" % kommando["actor_url"])
logger.error("%s: fuer %s (%s) gibt es keinen Transport",
automatik["name"], kommando["actor_name"], kommando["actor_url"])
continue
auftrag = {
"actor_url": kommando["actor_url"],
"command_url": kommando["command_url"],
"params": [{"url": p["url"], "name": p["parameter_name"],
"wert": aktion["werte"].get(p["id"], "")}
for p in kommando["params"]],
}
try:
transport.senden(auftrag)
logger.info("%s: %s -> %s", automatik["name"], kommando["actor_name"],
kommando["command_name"])
except Exception as fehler:
fehlerText.append("%s: %s" % (kommando["command_name"], fehler))
logger.error("%s: %s konnte nicht geschickt werden: %s",
automatik["name"], kommando["command_name"], fehler)
ergebnis = "error" if fehlerText else anlass
detail = "; ".join(fehlerText)[:255]
try:
with self.db.cursor() as c:
c.execute("""UPDATE automations SET last_run = NOW(), changed = changed
WHERE id = %s""", (automatik["id"],))
c.execute("""INSERT INTO automation_log (automation_id, result, detail)
VALUES (%s, %s, %s)""", (automatik["id"], ergebnis, detail))
except Exception as fehler:
logger.warning("Protokoll nicht schreibbar: %s", fehler)
automatik["last_run"] = datetime.now()
self.lief_im_fenster[automatik["id"]] = True
def flanke_merken(self, automatik, erfuellt):
if bool(automatik["cond_met"]) == bool(erfuellt):
return
automatik["cond_met"] = 1 if erfuellt else 0
try:
with self.db.cursor() as c:
c.execute("""UPDATE automations SET cond_met = %s, changed = changed
WHERE id = %s""", (automatik["cond_met"], automatik["id"]))
except Exception as fehler:
logger.warning("cond_met nicht schreibbar: %s", fehler)
def durchlauf(self):
jetzt = datetime.now()
kalender = self.kalender()
for automatik in self.regelwerk.automatiken.values():
aktiv = (tag_passt(automatik, jetzt, kalender)
and im_zeitfenster(jetzt, automatik["window_from"], automatik["window_to"]))
vorher_aktiv = self.war_aktiv.get(automatik["id"], aktiv)
if aktiv:
if not vorher_aktiv:
self.lief_im_fenster[automatik["id"]] = False
erfuellt = gruppen_erfuellt(automatik, self.regelwerk, self.werte, jetzt)
if erfuellt and not automatik["cond_met"]:
if self.gesperrt(automatik, jetzt):
logger.debug("%s: Flanke faellt in die Sperrzeit, uebersprungen",
automatik["name"])
else:
self.ausloesen(automatik, "fired")
self.flanke_merken(automatik, erfuellt)
else:
# Das Fenster ist gerade zugegangen. Wer "auf jeden Fall"
# angehakt hat, bekommt jetzt seinen Lauf - aber nur, wenn in
# diesem Fenster noch keiner stattgefunden hat. Nach einem
# Neustart mitten im Fenster weiss der Runner das nicht mehr
# aus dem Speicher, deshalb zaehlt zusaetzlich last_run.
if vorher_aktiv and automatik["force_once"] and not self.lief_im_fenster.get(automatik["id"]):
if not self.lief_heute(automatik, jetzt):
logger.info("%s: Zeitfenster vorbei, wird trotzdem ausgefuehrt",
automatik["name"])
self.ausloesen(automatik, "forced")
# Ausserhalb des Fensters die Flanke zuruecksetzen, sonst
# koennte sie im naechsten Fenster nicht mehr steigen.
self.flanke_merken(automatik, False)
self.lief_im_fenster[automatik["id"]] = False
self.war_aktiv[automatik["id"]] = aktiv
@staticmethod
def gesperrt(automatik, jetzt):
"""
Liegt die letzte Ausloesung noch innerhalb der Sperrzeit?
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
ueber MQTT koennen die Werte im Sekundentakt hereinkommen.
Die Flanke wird dabei verworfen und nicht aufgehoben. Ein Rollladen,
der eine Viertelstunde spaeter doch noch losfaehrt, weil vor langer
Zeit einmal eine Schwelle gestreift wurde, waere unangenehmer als
einer, der gar nicht faehrt. Der naechste echte Anlass nach Ablauf
der Sperre kommt ohnehin durch.
force_once ist davon nicht betroffen: es greift nur, wenn im Fenster
gar nichts gelaufen ist - dann ist auch keine Sperre aktiv.
"""
sperre = int(automatik.get("lockout_secs") or 0)
letzter = automatik.get("last_run")
if not sperre or not letzter:
return False
return (jetzt - letzter).total_seconds() < sperre
@staticmethod
def lief_heute(automatik, jetzt):
letzter = automatik.get("last_run")
return bool(letzter) and letzter.date() == jetzt.date()
def protokoll_saeubern(self):
heute = date.today()
if self._letzte_saeuberung == heute:
return
self._letzte_saeuberung = heute
try:
with self.db.cursor() as c:
c.execute("DELETE FROM automation_log WHERE ts < NOW() - INTERVAL %s DAY",
(LOG_AUFBEWAHRUNG_TAGE,))
except Exception as fehler:
logger.warning("Protokoll nicht aufraeumbar: %s", fehler)
# --- Hauptschleife ---------------------------------------------------
def laufen(self, nur_einmal=False):
self.regelwerk_laden()
takt = self.config.zahl("runner", "tick_seconds", 30)
poll = self.config.zahl("runner", "poll_seconds", 60)
neuladen = self.config.zahl("runner", "reload_seconds", 30)
letztes_pollen = 0.0
letztes_pruefen = 0.0
while True:
jetzt = time.monotonic()
mit_pollen = jetzt - letztes_pollen >= poll
if mit_pollen:
letztes_pollen = jetzt
self.werte_einsammeln(mit_pollen)
self.durchlauf()
self.protokoll_saeubern()
if jetzt - letztes_pruefen >= neuladen:
letztes_pruefen = jetzt
try:
if Regelwerk.signatur_lesen(self.db) != self.regelwerk.signatur:
logger.info("Regelwerk hat sich geaendert, wird neu geladen")
self.regelwerk_laden()
except Exception as fehler:
logger.warning("Regelwerk nicht pruefbar: %s", fehler)
if nur_einmal:
return
time.sleep(takt)
def beenden(self):
try:
self.mqtt.loop_stop()
self.mqtt.disconnect()
except Exception:
pass
# ===========================================================================
def main():
parser = argparse.ArgumentParser(description="Fuehrt die Automatiken aus dem Web-UI aus.")
parser.add_argument("--dry-run", action="store_true",
help="nichts wirklich schalten, nur protokollieren")
parser.add_argument("--once", action="store_true",
help="einen einzigen Durchlauf, dann beenden")
parser.add_argument("--verbose", action="store_true", help="DEBUG-Ausgaben")
parser.add_argument("--config", default="config.ini")
args = parser.parse_args()
config = Config(args.config)
logging.basicConfig(
level=logging.DEBUG if args.verbose else getattr(
logging, config.text("runner", "log_level", "INFO").upper(), logging.INFO),
format="%(asctime)s - %(levelname)s - %(message)s")
runner = Runner(config, args.dry_run)
if runner.dry_run:
logger.info("Probelauf: es wird nichts geschaltet.")
try:
runner.laufen(args.once)
except KeyboardInterrupt:
logger.info("Abbruch durch Benutzer")
finally:
runner.beenden()
return 0
if __name__ == "__main__":
sys.exit(main())
+75
View File
@@ -0,0 +1,75 @@
# ============================================================================
# AutoAction-Runner - Konfiguration
# ============================================================================
# Kopieren nach config.ini und ausfuellen. config.ini ist nicht versioniert
# (siehe .gitignore), weil hier Zugangsdaten stehen.
# ============================================================================
# DATENBANK - Geraete und Automatiken (homeMesh)
# ============================================================================
[database]
host = nas.fritz.box
port = 3310
database = homeMesh
user = homeMesh
password =
# ============================================================================
# DATENBANK - Messwerte (solarLog)
# ============================================================================
# Nur fuer die Tabelle `daylight`, aus der Sonnenauf- und -untergang kommen.
# Leer lassen, wenn dieselben Zugangsdaten wie oben gelten.
[solar]
database = solarLog
user =
password =
# ============================================================================
# MQTT
# ============================================================================
[mqtt]
broker = nas.fritz.box
port = 1883
username =
password =
client_id = autoaction_runner
# ============================================================================
# TAHOMA
# ============================================================================
# Fuer Aktoren, deren URL mit io:// beginnt. Ohne Token bleiben sie stumm -
# der Runner protokolliert das dann als Fehler, statt still nichts zu tun.
[tahoma]
pin =
token =
timeout = 10
# ============================================================================
# KALENDER
# ============================================================================
# Fuer fetch_calendar.py, das Feiertage und Schulferien in calendar_days
# eintraegt. Regionscodes siehe openholidaysapi.org, Bayern ist DE-BY.
[calendar]
country = DE
subdivision = DE-BY
# ============================================================================
# LAUFZEIT
# ============================================================================
[runner]
# Abstand der Auswertung in Sekunden. MQTT-Nachrichten werden sofort
# verarbeitet; der Takt ist fuer die Zeit-Ausloeser und fuer das Pollen.
tick_seconds = 30
# Wie oft Geraete abgefragt werden, die nichts von sich aus melden
# (Shelly ueber HTTP, Tahoma). In Sekunden.
poll_seconds = 60
# Wie oft der Runner nachsieht, ob sich das Regelwerk geaendert hat.
reload_seconds = 30
# Nichts wirklich schalten, nur protokollieren. Zum Ausprobieren neuer Regeln.
dry_run = false
# DEBUG, INFO, WARNING, ERROR
log_level = INFO
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Fuellt die Tabelle `calendar_days` mit Feiertagen und Schulferien.
Die Automatiken koennen mit "an Feiertagen ausfuehren" und "in den Ferien
ausfuehren" auf besondere Tage reagieren; woher das Wissen kommt, steht hier.
Nur besondere Tage werden eingetragen - ein fehlendes Datum ist ein
gewoehnlicher Tag.
Quelle ist openholidaysapi.org: ein offenes Verzeichnis der EU-Kommission, das
gesetzliche Feiertage und Schulferien fuer alle deutschen Bundeslaender
liefert. Ein Aufruf je Jahr genuegt, deshalb ist das ein Cronjob und kein
Dauerlaeufer:
0 4 1 1 * /usr/bin/python3 /pfad/zu/fetch_calendar.py
Ein zusaetzlicher Lauf im Herbst schadet nicht - die Ferientermine des
uebernaechsten Schuljahres stehen erst spaeter fest.
Bereits eingetragene Tage werden ueberschrieben, nie doppelt angelegt.
"""
import argparse
import logging
import sys
from datetime import date, timedelta
import pymysql
import requests
from autoaction_runner import Config, verbinden
logger = logging.getLogger("kalender")
BASIS = "https://openholidaysapi.org"
def zeitraum(api, land, region, von, bis):
"""Eintraege einer Art (PublicHolidays oder SchoolHolidays) abholen."""
antwort = requests.get(
BASIS + "/" + api,
params={"countryIsoCode": land, "subdivisionCode": region,
"languageIsoCode": "DE", "validFrom": von.isoformat(),
"validTo": bis.isoformat()},
headers={"Accept": "application/json"}, timeout=20)
antwort.raise_for_status()
return antwort.json()
def name(eintrag):
for teil in eintrag.get("name", []):
if teil.get("text"):
return teil["text"][:80]
return "?"
def tage(eintrag):
"""Ferien erstrecken sich ueber Wochen; hier wird daraus Tag fuer Tag."""
start = date.fromisoformat(eintrag["startDate"])
ende = date.fromisoformat(eintrag["endDate"])
while start <= ende:
yield start
start += timedelta(days=1)
def main():
parser = argparse.ArgumentParser(description="Feiertage und Ferien in calendar_days schreiben.")
parser.add_argument("--jahr", type=int, default=date.today().year)
parser.add_argument("--jahre", type=int, default=2, help="wie viele Jahre ab --jahr")
parser.add_argument("--config", default="config.ini")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
config = Config(args.config)
land = config.text("calendar", "country", "DE")
region = config.text("calendar", "subdivision", "DE-BY")
von = date(args.jahr, 1, 1)
bis = date(args.jahr + args.jahre - 1, 12, 31)
logger.info("Hole %s bis %s fuer %s", von, bis, region)
gesammelt = {}
for eintrag in zeitraum("PublicHolidays", land, region, von, bis):
for tag in tage(eintrag):
gesammelt.setdefault(tag, {})["holiday"] = name(eintrag)
for eintrag in zeitraum("SchoolHolidays", land, region, von, bis):
for tag in tage(eintrag):
gesammelt.setdefault(tag, {})["vacation"] = name(eintrag)
db = verbinden(config)
with db.cursor() as c:
c.executemany(
"""INSERT INTO calendar_days (date, holiday, vacation) VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE holiday = VALUES(holiday), vacation = VALUES(vacation)""",
[(tag, eintrag.get("holiday"), eintrag.get("vacation"))
for tag, eintrag in sorted(gesammelt.items())])
logger.info("%d Tage eingetragen", len(gesammelt))
return 0
if __name__ == "__main__":
sys.exit(main())
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
Transporte fuer den AutoAction-Runner.
Ein Transport weiss, wie man mit einer Sorte Geraet redet - er liest deren
Messwerte und schickt deren Kommandos. Welcher zustaendig ist, entscheidet die
URL des Aktors in der Tabelle `actors`:
mqtt://... MQTT-Geraete (Home-Assistant-Discovery)
http://... Shelly und andere HTTP-Geraete
io://... Tahoma
Logic das gerechnete Geraet "Zeitpunkt" (Uhrzeit, Datum, Sonne)
Alle liegen in einer Datei statt in einem Paket wie bei deviceDiscovery: es
sind vier kurze Klassen, und wer eine fuenfte Geraeteart anschliesst, sieht
hier auf einen Blick, was dafuer zu tun ist.
Jeder Transport hat zwei Haelften:
zustaende_anmelden(states) einmalig beim Start
zustaende_lesen() liefert {state_id: wert} - nur was neu ist
senden(aktion) fuehrt ein Kommando aus
`states` ist eine Liste von Dicts mit actor_url, state_url und id,
`aktion` ein Dict mit actor_url, command_url und params (Liste aus
{url, name, wert}).
"""
import json
import logging
from datetime import datetime
from urllib.parse import quote
logger = logging.getLogger("autoaction.transport")
class Transport:
"""Gemeinsame Form. Wer nichts zu lesen hat, erbt die leeren Methoden."""
schema = ""
def passt(self, actor_url):
return actor_url.startswith(self.schema)
def zustaende_anmelden(self, states):
pass
def zustaende_lesen(self):
return {}
def senden(self, aktion):
raise NotImplementedError
# ---------------------------------------------------------------------------
# MQTT
# ---------------------------------------------------------------------------
class MQTTTransport(Transport):
"""
Die state_url ist hier das vollstaendige Topic, die parameter_url das
Kommando-Topic. Werte kommen von selbst herein und landen in einem
Zwischenspeicher, den der Runner im Takt abholt.
"""
schema = "mqtt://"
def __init__(self, client, dry_run=False):
self.client = client
self.dry_run = dry_run
self.topics = {} # topic -> [state_id, ...]
self.neu = {} # state_id -> wert
def zustaende_anmelden(self, states):
self.topics = {}
for s in states:
if not s["state_url"]:
continue
self.topics.setdefault(s["state_url"], []).append(s["id"])
for topic in self.topics:
self.client.subscribe(topic)
logger.info("MQTT: %d Topics abonniert", len(self.topics))
def nachricht(self, topic, payload):
"""Wird vom Runner aus dem on_message-Rueckruf gerufen."""
for state_id in self.topics.get(topic, []):
self.neu[state_id] = self._wert(payload)
@staticmethod
def _wert(payload):
"""
Rohtext, aber JSON-Skalare werden ausgepackt: manche Geraete schicken
"21.4", andere 21.4 mit Anfuehrungszeichen, wieder andere true statt
ON. Objekte bleiben wie sie sind - welches Feld gemeint ist, weiss die
Datenbank nicht, und Raten waere schlimmer als Nichtstun.
"""
text = payload.decode("utf-8", "replace").strip() if isinstance(payload, bytes) else str(payload)
try:
wert = json.loads(text)
except ValueError:
return text
if isinstance(wert, bool):
return "true" if wert else "false"
if isinstance(wert, (int, float, str)):
return str(wert)
return text
def zustaende_lesen(self):
werte, self.neu = self.neu, {}
return werte
def senden(self, aktion):
if not aktion["params"]:
# Kommando ohne Parameter: das Kommando selbst ist die Nutzlast.
self._publish(aktion["command_url"], "")
return
for p in aktion["params"]:
self._publish(p["url"] or aktion["command_url"], p["wert"])
def _publish(self, topic, nutzlast):
if not topic:
raise ValueError("Kommando ohne Topic")
if self.dry_run:
logger.info("[dry-run] MQTT %s <- %s", topic, nutzlast)
return
ergebnis = self.client.publish(topic, nutzlast, qos=1, retain=False)
ergebnis.wait_for_publish(timeout=5)
# ---------------------------------------------------------------------------
# HTTP (Shelly und Verwandte)
# ---------------------------------------------------------------------------
class HTTPTransport(Transport):
"""
Die actor_url ist der Endpunkt, die state_url ein Feldname in dessen
JSON-Antwort ("tC", "a_voltage"). Solche Geraete melden sich nicht von
selbst, sie werden im Poll-Takt gefragt.
Beim Senden haengen die Parameter als Abfrageargumente an die actor_url.
Die Schaltbefehle von Shelly heissen aber nicht so, wie sie in der
Datenbank stehen - dafuer steht unten eine kleine Uebersetzungstabelle.
Wer ein anderes HTTP-Geraet anschliesst, erweitert genau die.
"""
schema = "http"
SHELLY_BEFEHLE = {
"turn_on": {"on": "true"},
"turn_off": {"on": "false"},
"toggle": {"toggle": "true"},
}
def __init__(self, requests_modul, timeout=5, dry_run=False):
self.requests = requests_modul
self.timeout = timeout
self.dry_run = dry_run
self.states = []
def zustaende_anmelden(self, states):
self.states = [s for s in states if s["state_url"]]
logger.info("HTTP: %d Messwerte an %d Endpunkten",
len(self.states), len({s["actor_url"] for s in self.states}))
def zustaende_lesen(self):
werte = {}
# Je Endpunkt eine Anfrage, auch wenn mehrere Messwerte daran haengen.
nach_url = {}
for s in self.states:
nach_url.setdefault(s["actor_url"], []).append(s)
for url, states in nach_url.items():
try:
antwort = self.requests.get(url, timeout=self.timeout)
daten = antwort.json()
except Exception as fehler:
logger.debug("HTTP %s nicht erreichbar: %s", url, fehler)
continue
for s in states:
if isinstance(daten, dict) and s["state_url"] in daten:
werte[s["id"]] = str(daten[s["state_url"]])
return werte
def senden(self, aktion):
argumente = dict(self.SHELLY_BEFEHLE.get(aktion["command_url"], {}))
for p in aktion["params"]:
if p["url"]:
argumente[p["url"]] = p["wert"]
if not argumente and aktion["command_url"]:
# Kein bekannter Schaltbefehl und keine Parameter: das Kommando
# als Argument anhaengen, mehr laesst sich hier nicht ableiten.
argumente[aktion["command_url"]] = "true"
if self.dry_run:
logger.info("[dry-run] HTTP %s %s", aktion["actor_url"], argumente)
return
antwort = self.requests.get(aktion["actor_url"], params=argumente, timeout=self.timeout)
if antwort.status_code >= 400:
raise RuntimeError("HTTP %d von %s" % (antwort.status_code, aktion["actor_url"]))
# ---------------------------------------------------------------------------
# Tahoma
# ---------------------------------------------------------------------------
class TahomaTransport(Transport):
"""
Die actor_url ist die deviceURL (io://...), die state_url ein Statusname
("core:ClosureState"), die command_url ein Kommandoname ("setClosure").
Geschickt wird ueber exec/apply - genauso wie in ajax/tahoma.php, nur ohne
die dortige Sonderbehandlung fuer "faehrt gerade".
"""
schema = "io://"
def __init__(self, requests_modul, pin, token, timeout=10, dry_run=False):
self.requests = requests_modul
self.pin = pin
self.token = token
self.timeout = timeout
self.dry_run = dry_run
self.states = []
@property
def basis(self):
return "https://gateway-%s:8443/enduser-mobile-web/1/enduserAPI" % self.pin
def _kopf(self):
return {"Content-Type": "application/json",
"Authorization": "Bearer " + self.token}
def zustaende_anmelden(self, states):
self.states = [s for s in states if s["state_url"]]
logger.info("Tahoma: %d Messwerte an %d Geraeten",
len(self.states), len({s["actor_url"] for s in self.states}))
def zustaende_lesen(self):
if not self.token:
return {}
werte = {}
nach_geraet = {}
for s in self.states:
nach_geraet.setdefault(s["actor_url"], []).append(s)
for geraet, states in nach_geraet.items():
try:
antwort = self.requests.get(
self.basis + "/setup/devices/" + quote(geraet, safe="") + "/states",
headers=self._kopf(), timeout=self.timeout, verify=False)
zustaende = {z["name"]: z.get("value") for z in antwort.json()}
except Exception as fehler:
logger.debug("Tahoma %s nicht erreichbar: %s", geraet, fehler)
continue
for s in states:
if s["state_url"] in zustaende:
werte[s["id"]] = str(zustaende[s["state_url"]])
return werte
def senden(self, aktion):
if not self.token:
raise RuntimeError("Kein Tahoma-Token in der config.ini")
# Die Reihenfolge der Parameter ist die aus command_parameters - bei
# setClosureAndOrientation also erst Position, dann Winkel.
befehl = {"name": aktion["command_url"],
"parameters": [self._zahl(p["wert"]) for p in aktion["params"]]}
rumpf = {"label": "AutoAction",
"actions": [{"deviceURL": aktion["actor_url"], "commands": [befehl]}]}
if self.dry_run:
logger.info("[dry-run] Tahoma %s", json.dumps(rumpf, ensure_ascii=False))
return
antwort = self.requests.post(self.basis + "/exec/apply", headers=self._kopf(),
data=json.dumps(rumpf), timeout=self.timeout, verify=False)
if antwort.status_code >= 400:
raise RuntimeError("Tahoma antwortete mit %d: %s"
% (antwort.status_code, antwort.text[:120]))
@staticmethod
def _zahl(wert):
"""Tahoma erwartet Zahlen als Zahlen, Text als Text."""
try:
return int(wert)
except (TypeError, ValueError):
pass
try:
return float(wert)
except (TypeError, ValueError):
return wert
# ---------------------------------------------------------------------------
# Logic - das gerechnete Geraet
# ---------------------------------------------------------------------------
class LogicTransport(Transport):
"""
Uhrzeit, Datum, Sonnenauf- und -untergang. Es gibt nichts zu abonnieren und
nichts zu schalten, die Werte entstehen im Takt. Sonnenzeiten kommen aus
solarLog.daylight, dieselbe Tabelle, aus der auch ajax/getSunrise.php liest.
"""
schema = "Logic"
def __init__(self, sonnenzeiten):
"""sonnenzeiten: Funktion() -> (sonnenaufgang, sonnenuntergang) als "HH:MM"."""
self.sonnenzeiten = sonnenzeiten
self.states = []
def passt(self, actor_url):
return actor_url == "Logic"
def zustaende_anmelden(self, states):
self.states = states
logger.info("Logic: %d Messwerte", len(states))
def zustaende_lesen(self):
jetzt = datetime.now()
auf, unter = self.sonnenzeiten()
tabelle = {
"time": jetzt.strftime("%H:%M"),
"date": jetzt.strftime("%d.%m.%Y"),
"sunrise": auf,
"sunset": unter,
}
return {s["id"]: tabelle[s["state_url"]]
for s in self.states if s["state_url"] in tabelle}
def senden(self, aktion):
raise RuntimeError("Das Geraet \"Zeitpunkt\" kann nichts schalten")
+657
View File
@@ -0,0 +1,657 @@
<?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__ . "/mysql.php");
/** Verbindung zur Geraete- und Automatik-Datenbank. */
function meshDb()
{
static $db = null;
if ($db === null) {
$db = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_MeshUser"],
$GLOBALS["mysql_MeshPass"], $GLOBALS["mysql_MeshDB"]);
$db->set_charset("utf8mb4");
}
return $db;
}
/**
* Operatoren, die zu einem Datentyp passen. Immer ASCII - die huebschen
* Zeichen macht erst die Anzeige daraus. Frueher schickte der Editor sein
* innerHTML ("≠", "&gt;") 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 genau in dieser Minute. Der uebliche Fall - einmal taeglich
* zu einem festen Zeitpunkt.
* 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. Dieselbe
* Ueberlegung steht hinter den breiten Zeitfenstern in
* auto_watering.py.
* 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.
*/
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 "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;
}
/**
* 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"],
];
}
/** 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":
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];
}
}
return $op === "!=" ? "≠" : $op;
}
/**
* 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 = [];
$res = $db->query("SELECT id, name, type, url FROM actors ORDER BY name");
while ($row = $res->fetch_assoc()) {
$devices[intval($row["id"])] = [
"id" => intval($row["id"]),
"name" => $row["name"],
"type" => $row["type"],
"url" => $row["url"],
"states" => [],
"commands" => [],
];
}
$res = $db->query("SELECT s.id, s.actor_id, s.state_name, s.unit, s.current_value,
s.url, 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 = json_decode($row["possible_values"], true);
if (!is_array($options)) {
$options = [];
}
$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" => array_values($options),
"unit" => $row["unit"],
"value" => $row["current_value"],
"url" => $row["url"],
];
}
$res = $db->query("SELECT id, actor_id, command_name 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"],
"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 = json_decode($row["possible_values"], true);
if (!is_array($options)) {
$options = [];
}
$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" => array_values($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, ["UG", "EG", "OG"], true) ? $floor : "OG",
"enabled" => true,
"window_from" => "00:00",
"window_to" => "23:59",
"weekdays" => 127,
"on_vacation" => true,
"on_holiday" => true,
"force_once" => 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" => (bool)$row["on_vacation"],
"on_holiday" => (bool)$row["on_holiday"],
"force_once" => (bool)$row["force_once"],
"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, ["UG", "EG", "OG"], 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.");
}
$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 = !empty($data["on_vacation"]) ? 1 : 0;
$onHoliday = !empty($data["on_holiday"]) ? 1 : 0;
$forceOnce = !empty($data["force_once"]) ? 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.");
}
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
window_from = ?, window_to = ?, weekdays = ?,
on_vacation = ?, on_holiday = ?, force_once = ?,
lockout_secs = ?
WHERE id = ?");
$stmt->bind_param("ssissiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $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, force_once, lockout_secs)
VALUES (?,?,?,?,?,?,?,?,?,?)");
$stmt->bind_param("ssissiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $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;
}
function deleteAutomation($id)
{
$db = meshDb();
$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();
$list = [];
foreach ($ids as $id) {
$auto = loadAutomation($id);
if (!$auto) {
continue;
}
$auto["conditionText"] = describeConditions($auto["conditions"], $namesState);
$auto["actionText"] = describeActions($auto["actions"], $namesCommand);
$list[] = $auto;
}
return $list;
}
/** id => "Geraet: Messwert" fuer alle Messwerte. */
function stateNames()
{
$db = meshDb();
$res = $db->query("SELECT s.id, s.state_name, s.unit, a.name AS actor_name, t.type
FROM actor_states s
JOIN actors a ON a.id = s.actor_id
LEFT JOIN state_types t ON s.state_type = t.id");
$names = [];
while ($row = $res->fetch_assoc()) {
$names[intval($row["id"])] = [
"text" => $row["actor_name"] . ": " . $row["state_name"],
"unit" => $row["unit"],
"type" => $row["type"] ?: "string",
];
}
return $names;
}
/** id => "Geraet: Kommando" fuer alle Kommandos. */
function commandNames()
{
$db = meshDb();
$res = $db->query("SELECT c.id, c.command_name, a.name AS actor_name
FROM actor_commands c JOIN actors a ON a.id = c.actor_id");
$names = [];
while ($row = $res->fetch_assoc()) {
$names[intval($row["id"])] = $row["actor_name"] . ": " . $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)
{
$parts = [];
foreach ($actions as $a) {
$text = $names[$a["command_id"]] ?? ("Kommando " . $a["command_id"]);
$values = array_values($a["params"]);
if ($values) {
$text .= " (" . implode(", ", $values) . ")";
}
$parts[] = $text;
}
return implode(", ", $parts);
}
+15 -33
View File
@@ -155,43 +155,25 @@
</div>
<div class="tab-content" id="actions-tabContent">
<div class="tab-pane fade <?php if($_GET["floor"] == "OG") echo "active show"; ?>" id="actions-OG" role="tabpanel" aria-labelledby="actions-OG-tab">
<table class="table table-hover">
<thead><tr><th>Name</th><th>Aktiv</th><th>Auslöser</th><th>Zeitfenster</th><th>Aktion</th><th>Wochentage</th><th></th></tr></thead>
<tbody>
<tr><td>Kinder zu</td>
<td><input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" disabled checked></td>
<td>Uhrzeit: 16:30</td>
<td>n/a</td>
<td>Rollo "Magdalena Tür" schließen<br />Rollo "Magdalena Fenster" schließen</td>
<td>
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" disabled checked /> <label class="form-check-label" for="flexCheckDefault">Mo.</label><br />
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" disabled checked /> <label class="form-check-label" for="flexCheckDefault">Di.</label><br />
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" disabled checked /> <label class="form-check-label" for="flexCheckDefault">Mi.</label><br />
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" disabled checked /> <label class="form-check-label" for="flexCheckDefault">Do.</label><br />
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" disabled checked /> <label class="form-check-label" for="flexCheckDefault">Fr.</label><br />
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" disabled checked /> <label class="form-check-label" for="flexCheckDefault">Sa.</label><br />
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" disabled checked /> <label class="form-check-label" for="flexCheckDefault">So.</label><br />
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" disabled checked /> <label class="form-check-label" for="flexCheckDefault">Feiertag</label><br />
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" disabled checked /> <label class="form-check-label" for="flexCheckDefault">Ferien</label><br />
</td>
<td>
<div class="tools">
<button type="button" class="btn btn-info btn-sm"><i class="bi bi-pause-fill"></i></button>
<button type="button" class="btn btn-warning btn-sm"><i class="bi bi-pencil-square"></i></button>
<button type="button" class="btn btn-danger btn-sm"><i class="bi bi-trash3"></i></button>
</div>
</td>
</tr>
</tbody>
</table>
<button type="button" class="btn btn-success" onclick="openAutoActionModal('?action=new&floor=OG')"><i class="bi bi-plus-square"></i></button>
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
refreshAutomations() in js/solar/autoActionFuncs.js -->
<div id="actions-OG-list">Wird geladen...</div>
<button type="button" class="btn btn-success" title="Neue Automatik"
onclick="openAutoActionModal('?action=editor&floor=OG')"><i class="bi bi-plus-square"></i></button>
</div>
<div class="tab-pane fade <?php if($_GET["floor"] == "EG") echo "active show"; ?>" id="actions-EG" role="tabpanel" aria-labelledby="actions-EG-tab">
<button class="btn btn-primary" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasWithBackdrop" aria-controls="offcanvasWithBackdrop">Enable backdrop (default)</button>
Mauris tincidunt mi at erat gravida, eget tristique urna bibendum. Mauris pharetra purus ut ligula tempor, et vulputate metus facilisis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Maecenas sollicitudin, nisi a luctus interdum, nisl ligula placerat mi, quis posuere purus ligula eu lectus. Donec nunc tellus, elementum sit amet ultricies at, posuere nec nunc. Nunc euismod pellentesque diam.
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
refreshAutomations() in js/solar/autoActionFuncs.js -->
<div id="actions-EG-list">Wird geladen...</div>
<button type="button" class="btn btn-success" title="Neue Automatik"
onclick="openAutoActionModal('?action=editor&floor=EG')"><i class="bi bi-plus-square"></i></button>
</div>
<div class="tab-pane fade <?php if($_GET["floor"] == "UG") echo "active show"; ?>" id="actions-UG" role="tabpanel" aria-labelledby="actions-UG-tab">
Morbi turpis dolor, vulputate vitae felis non, tincidunt congue mauris. Phasellus volutpat augue id mi placerat mollis. Vivamus faucibus eu massa eget condimentum. Fusce nec hendrerit sem, ac tristique nulla. Integer vestibulum orci odio. Cras nec augue ipsum. Suspendisse ut velit condimentum, mattis urna a, malesuada nunc. Curabitur eleifend facilisis velit finibus tristique. Nam vulputate, eros non luctus efficitur, ipsum odio volutpat massa, sit amet sollicitudin est libero sed ipsum. Nulla lacinia, ex vitae gravida fermentum, lectus ipsum gravida arcu, id fermentum metus arcu vel metus. Curabitur eget sem eu risus tincidunt eleifend ac ornare magna.
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
refreshAutomations() in js/solar/autoActionFuncs.js -->
<div id="actions-UG-list">Wird geladen...</div>
<button type="button" class="btn btn-success" title="Neue Automatik"
onclick="openAutoActionModal('?action=editor&floor=UG')"><i class="bi bi-plus-square"></i></button>
</div>
</div>
</div>