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 ("≠",
">") 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>
This commit is contained in:
2026-08-30 19:07:29 +02:00
co-authored by Claude Opus 5
parent fd27fe168e
commit eee3a89b4e
10 changed files with 1875 additions and 870 deletions
+182 -206
View File
@@ -1,263 +1,239 @@
<?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;
if (!checkLogin()) {
http_response_code(403);
exit;
}
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:
$floor = "";
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
}
}else{
$floor = "";
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
} catch (Throwable $e) {
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
}
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";
// --- 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 {
$weight = "and";
echo "<div class='table-responsive'><table class='table table-hover align-middle'>";
echo "<thead><tr><th>Name</th><th>Auslöser</th><th>Aktion</th><th>Wann</th>"
. "<th>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];
}
}else{
$weight = "and";
}
if (in_array($_POST["btnOperator".$num], array('+','-','=','&lt;','&gt;','!='))) {
$cond = $_POST["btnOperator".$num];
}else{
$cond = "=";
echo $_POST["btnOperator".$num];
$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"];
}
echo $_POST["btnOperator".$num];
$valID = intval($_POST["paramSelect".$num]);
if($_POST["paramSelect".$num] != "0" && $id == 0){
$valID = -1;
if (!$a["on_holiday"]) {
$wann .= ", nicht an Feiertagen";
}
if (!$a["on_vacation"]) {
$wann .= ", nicht in den Ferien";
}
$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++;
$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>" . htmlspecialchars($a["actionText"]) . "</td>";
echo "<td class='text-nowrap'>" . htmlspecialchars($wann) . "</td>";
echo "<td class='text-nowrap'>" . $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>";
}
$close=1;
echo "</tbody></table></div>";
}
} else {
$close = 1;
exit;
}
if (!$close) {
echo <<<ENDE
<!--begin::Form-->
<form id="heater_form">
// --- 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(),
], 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>
</div>
<div class="col-4">
<div class="form-floating">
<select class="form-select" id="autoFloor">
<option value=""></option>
<option value="UG">UG</option>
<option value="EG">EG</option>
<option value="OG">OG</option>
</select>
<label for="autoFloor">Etage</label>
</div>
</div>
</div>
<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">
<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" 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 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>
<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">
<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" aria-labelledby="headingTwo" data-bs-parent="#actionAccordion">
<div id="collapseTwo" class="accordion-collapse collapse" 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="input-group mb-3">
<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>
<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" name="tSpanTo" placeholder="0" value="23:59"></input>
<label>Bis</label>
<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="timespan">
<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-1" type="checkbox" value="1" id="actMo" name="actMo"><label class="form-check-label" for="actMo">Mo</label>
<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-1" type="checkbox" value="1" id="actDi" name="actDi"><label class="form-check-label" for="actDi">Di</label>
<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-0">
<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>
<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" id="headingThree">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseThree" aria-expanded="false" aria-controls="collapseThree">
<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" aria-labelledby="headingThree" data-bs-parent="#actionAccordion">
<div id="collapseThree" class="accordion-collapse collapse" 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 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>
</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 */;
+133
View File
@@ -0,0 +1,133 @@
-- ---------------------------------------------------------------------------
-- 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 '' COMMENT 'Reiter in der Uebersicht',
`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',
`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 Knoepfe im Editor zeigen Sonderzeichen und werden beim Speichern normalisiert',
`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;
+522 -347
View File
@@ -1,390 +1,565 @@
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 = [];
// --- 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(op) {
return op === "!=" ? "≠" : 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 = "";
/**
* 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 {
document.getElementById(dropdownID).innerHTML = "";
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;
}
/** 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);
}
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: 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];
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];
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, text: operatorLabel(o) })), 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 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;
}
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 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;
}
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'
/**
* 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(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 ");
}
})
.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 = "";
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);
});
}
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'
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;
}
})
.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 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 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 fuelleFormular() {
document.getElementById("autoName").value = autoModel.name;
document.getElementById("autoFloor").value = autoModel.floor;
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();
}
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);
/** 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;
}
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>`;
// --- Modal ---------------------------------------------------------------
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 loadAutomatic() {
const daten = JSON.parse(document.getElementById("autoActionData").textContent);
autoDevices = daten.devices;
function arrangeSensorInputs(event){
ID = Number(event.currentTarget.id.replace("sensorSelect",""));
sensorID = document.getElementById("sensorSelect"+ID).value;
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,
groups: [],
actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) }))
};
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 = "";
// 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: standardWert(state) }]];
}
}
if (!autoModel.actions.length) {
const cmd = ersterBefehl();
if (cmd) autoModel.actions = [{ command_id: cmd.id, params: standardParams(cmd) }];
}
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");
}
fuelleFormular();
}
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');
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...");
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'
}
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);
loadAutomatic();
document.getElementById("btnAddGroup").addEventListener("click", () => {
const state = ersterMesswert();
if (!state) return;
autoModel.groups.push([{ state_id: state.id, operator: state.operators[0], 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 += error.message;
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,
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 => { ziel.innerHTML = html; })
.catch(() => { ziel.innerHTML = "<p class='text-danger'>Liste konnte nicht geladen werden.</p>"; });
});
}
function toggleAutomation(id) {
automationRequest("toggle", { id: id }).then(antwort => {
if (antwort.error) alert(antwort.error);
else refreshAutomations(null);
});
}
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);
});
}
document.addEventListener("DOMContentLoaded", () => refreshAutomations(null));
+566
View File
@@ -0,0 +1,566 @@
<?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".
*/
function operatorsForType($type)
{
switch ($type) {
case "integer":
case "float":
return ["=", "!=", ">", "<"];
case "deltatime": // Sonnenauf-/-untergang: Wert ist ein Versatz
return ["+", "-"];
case "bool":
return ["="]; // JA/NEIN steht im Wertfeld, nicht im Operator
default:
return ["=", "!="];
}
}
/** 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. */
function operatorLabel($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" => operatorsForType($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" => "",
"floor" => $floor,
"enabled" => true,
"window_from" => "00:00",
"window_to" => "23:59",
"weekdays" => 127,
"on_vacation" => true,
"on_holiday" => true,
"force_once" => false,
"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"],
"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)) {
$floor = "";
}
$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;
$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 = ?
WHERE id = ?");
$stmt->bind_param("ssissiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $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)
VALUES (?,?,?,?,?,?,?,?,?)");
$stmt->bind_param("ssissiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce);
$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
FROM actor_states s JOIN actors a ON a.id = s.actor_id");
$names = [];
while ($row = $res->fetch_assoc()) {
$names[intval($row["id"])] = [
"text" => $row["actor_name"] . ": " . $row["state_name"],
"unit" => $row["unit"],
];
}
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];
$text = $state["text"] . " " . operatorLabel($c["operator"]) . " " . $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>