Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
131d7cc46d | ||
|
|
d557a1651f | ||
|
|
1b9bbc9857 | ||
|
|
98a1da6cd5 | ||
|
|
bed1884b04 | ||
|
|
4b47372015 | ||
|
|
2ac504ca29 | ||
|
|
00f5944a7a |
@@ -9,9 +9,11 @@
|
|||||||
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
|
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
|
||||||
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
||||||
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
|
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
|
||||||
|
* GET ?action=rooms Zuordnung Geraet -> Raum zum Bearbeiten
|
||||||
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
|
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
|
||||||
* POST ?action=delete {"id": N}
|
* POST ?action=delete {"id": N}
|
||||||
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
||||||
|
* POST ?action=saveRooms {"<actor_id>": "OG/Bad", ...}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once("../helper.php");
|
require_once("../helper.php");
|
||||||
@@ -49,6 +51,8 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
jsonAntwort(["ok" => true]);
|
jsonAntwort(["ok" => true]);
|
||||||
case "toggle":
|
case "toggle":
|
||||||
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
||||||
|
case "saveRooms":
|
||||||
|
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
|
||||||
default:
|
default:
|
||||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||||
}
|
}
|
||||||
@@ -126,6 +130,73 @@ if ($action === "list") {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Geraete den Raeumen zuordnen ---------------------------------------
|
||||||
|
if ($action === "rooms") {
|
||||||
|
$labels = geraeteBeschriftungen();
|
||||||
|
$raeume = geraeteRaeume();
|
||||||
|
$vorschlag = raumVorschlaege();
|
||||||
|
$auswahl = raumListe();
|
||||||
|
|
||||||
|
$res = meshDb()->query("SELECT id, type FROM actors");
|
||||||
|
$geraete = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$id = intval($row["id"]);
|
||||||
|
$geraete[] = [
|
||||||
|
"id" => $id,
|
||||||
|
"label" => $labels[$id] ?? "?",
|
||||||
|
"type" => $row["type"],
|
||||||
|
"room" => $raeume[$id] ?? null,
|
||||||
|
"tipp" => $vorschlag[$id] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Unzugeordnete nach oben: das ist die Arbeit, die noch zu tun ist.
|
||||||
|
usort($geraete, function ($a, $b) {
|
||||||
|
$offen = (int)!empty($b["room"]) - (int)!empty($a["room"]);
|
||||||
|
return $offen !== 0 ? $offen : strcoll($a["label"], $b["label"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$offen = count(array_filter($geraete, function ($g) { return empty($g["room"]); }));
|
||||||
|
$tipps = count(array_filter($geraete, function ($g) {
|
||||||
|
return empty($g["room"]) && !empty($g["tipp"]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
echo "<div class='p-2'>";
|
||||||
|
echo "<p class='small text-body-secondary'>Die Zuordnung ordnet die Geräteliste im Editor "
|
||||||
|
. "nach Räumen statt nach Geräteart. Sie wird von Hand vergeben und bleibt bei "
|
||||||
|
. "einem neuen Geräte-Suchlauf erhalten.</p>";
|
||||||
|
if ($tipps) {
|
||||||
|
echo "<div class='alert alert-secondary py-2 small d-flex justify-content-between align-items-center'>"
|
||||||
|
. "<span>" . $tipps . " Gerät(e) verraten ihren Raum im MQTT-Topic.</span>"
|
||||||
|
. "<button type='button' class='btn btn-outline-secondary btn-sm' id='btnRoomTipps'>"
|
||||||
|
. "Vorschläge übernehmen</button></div>";
|
||||||
|
}
|
||||||
|
echo "<div class='table-responsive' style='max-height:60vh;overflow-y:auto'>";
|
||||||
|
echo "<table class='table table-sm table-hover align-middle'><thead><tr>"
|
||||||
|
. "<th>Gerät</th><th class='d-none d-sm-table-cell'>Art</th><th>Raum</th>"
|
||||||
|
. "</tr></thead><tbody>";
|
||||||
|
foreach ($geraete as $g) {
|
||||||
|
echo "<tr" . (empty($g["room"]) ? " class='table-active'" : "") . ">";
|
||||||
|
echo "<td>" . htmlspecialchars($g["label"]) . "</td>";
|
||||||
|
echo "<td class='d-none d-sm-table-cell small text-body-secondary'>"
|
||||||
|
. htmlspecialchars($g["type"]) . "</td>";
|
||||||
|
echo "<td><select class='form-select form-select-sm' data-actor='" . $g["id"] . "'"
|
||||||
|
. ($g["tipp"] ? " data-tipp='" . htmlspecialchars($g["tipp"], ENT_QUOTES) . "'" : "")
|
||||||
|
. ">";
|
||||||
|
echo "<option value=''" . (empty($g["room"]) ? " selected" : "") . ">–</option>";
|
||||||
|
foreach ($auswahl as $raum) {
|
||||||
|
echo "<option value='" . htmlspecialchars($raum["key"], ENT_QUOTES) . "'"
|
||||||
|
. ($g["room"] === $raum["key"] ? " selected" : "") . ">"
|
||||||
|
. htmlspecialchars($raum["label"]) . "</option>";
|
||||||
|
}
|
||||||
|
echo "</select></td></tr>";
|
||||||
|
}
|
||||||
|
echo "</tbody></table></div>";
|
||||||
|
echo "<p class='small text-body-secondary mb-0'>" . $offen . " von " . count($geraete)
|
||||||
|
. " Geräten noch ohne Raum.</p>";
|
||||||
|
echo "</div>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Editor --------------------------------------------------------------
|
// --- Editor --------------------------------------------------------------
|
||||||
$id = intval($_GET["id"] ?? 0);
|
$id = intval($_GET["id"] ?? 0);
|
||||||
$auto = $id > 0 ? loadAutomation($id) : null;
|
$auto = $id > 0 ? loadAutomation($id) : null;
|
||||||
@@ -141,6 +212,9 @@ $nutzdaten = json_encode([
|
|||||||
"automation" => $auto,
|
"automation" => $auto,
|
||||||
"devices" => deviceCatalog(),
|
"devices" => deviceCatalog(),
|
||||||
"lockouts" => lockoutChoices(),
|
"lockouts" => lockoutChoices(),
|
||||||
|
// Nur fuer die Reihenfolge der Raum-Gruppen: rooms.php gibt sie vor, und
|
||||||
|
// Etage fuer Etage liest sich besser als alphabetisch.
|
||||||
|
"rooms" => raumListe(),
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Raum-Modal: alles, was in einem Raum haengt, auf einer Seite.
|
||||||
|
*
|
||||||
|
* Loest das frühere Thermostat-Modal ab. Der Grundriss zeigt Raeume, also
|
||||||
|
* sollte ein Klick darauf den Raum oeffnen und nicht nur seine Heizung - die
|
||||||
|
* Rollladen im selben Zimmer waren bisher gar nicht erreichbar.
|
||||||
|
*
|
||||||
|
* Welche Geraete zu einem Raum gehoeren, sagt die Spalte actors.room (siehe
|
||||||
|
* den Zuordnungsdialog in der Karte "Automatismen").
|
||||||
|
*
|
||||||
|
* GET ?room=EG_Bad Inhalt des Modals
|
||||||
|
* POST ?action=command {"command_id": N, "params": {...}} - ein Kommando
|
||||||
|
* POST ?action=temp {"room": "EG_Bad", "temp": 21} - Solltemperatur
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Ein "Zu" an einer Jalousie faehrt herunter, wartet und neigt danach - das
|
||||||
|
// dauert in der Summe ueber eine Minute. Die Vorgabe von PHP reicht dafuer
|
||||||
|
// nicht; ajax/tahoma.php macht es an derselben Stelle genauso.
|
||||||
|
set_time_limit(180);
|
||||||
|
|
||||||
|
require_once("../helper.php");
|
||||||
|
require_once("../restricted/automations.php");
|
||||||
|
require_once("../restricted/commands.php");
|
||||||
|
require_once("./phpMQTT.php");
|
||||||
|
|
||||||
|
if (!checkLogin()) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function raumAntwort($data, $code = 200)
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
header("Content-Type: application/json; charset=utf-8");
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Schaltende Aufrufe ---------------------------------------------------
|
||||||
|
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||||
|
$body = json_decode(file_get_contents("php://input"), true);
|
||||||
|
if (!is_array($body)) {
|
||||||
|
raumAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (($_GET["action"] ?? "") === "command") {
|
||||||
|
$text = executeCommand(intval($body["command_id"] ?? 0),
|
||||||
|
is_array($body["params"] ?? null) ? $body["params"] : [],
|
||||||
|
!empty($body["probe"]));
|
||||||
|
raumAntwort(["ok" => $text]);
|
||||||
|
}
|
||||||
|
if (($_GET["action"] ?? "") === "temp") {
|
||||||
|
// Bewusst derselbe Weg wie im alten Thermostat-Modal: das Topic
|
||||||
|
// Raumtemp/<Etage>/<Raum>/changeSetTemp.
|
||||||
|
$raum = preg_replace('/[^A-Za-z0-9_]/', '', strval($body["room"] ?? ""));
|
||||||
|
$temp = intval($body["temp"] ?? 0);
|
||||||
|
if ($temp < 10 || $temp > 30) {
|
||||||
|
raumAntwort(["error" => "Temperatur außerhalb 10–30 °C."], 400);
|
||||||
|
}
|
||||||
|
$mqtt = new Bluerhinos\phpMQTT(MQTT_BROKER, MQTT_PORT, "RoomTempSetter_" . getmypid());
|
||||||
|
if (!$mqtt->connect(true, null, "", "")) {
|
||||||
|
raumAntwort(["error" => "Keine Verbindung zum MQTT-Broker."], 500);
|
||||||
|
}
|
||||||
|
$mqtt->publish("Raumtemp/" . str_replace("_", "/", $raum) . "/changeSetTemp",
|
||||||
|
strval($temp), 0, false);
|
||||||
|
$mqtt->close();
|
||||||
|
raumAntwort(["ok" => "Solltemperatur " . $temp . " °C"]);
|
||||||
|
}
|
||||||
|
raumAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
raumAntwort(["error" => $e->getMessage()], 400);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
raumAntwort(["error" => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inhalt des Modals ----------------------------------------------------
|
||||||
|
$schluessel = preg_replace('/[^A-Za-z0-9_]/', '', strval($_GET["room"] ?? ""));
|
||||||
|
$teile = explode("_", $schluessel, 2);
|
||||||
|
$etage = $teile[0] ?? "";
|
||||||
|
$name = $teile[1] ?? "";
|
||||||
|
$raumKey = $etage . "/" . $name;
|
||||||
|
|
||||||
|
// Hat der Raum ein Thermostat? Nur dann gibt es etwas zu heizen.
|
||||||
|
$hatThermostat = false;
|
||||||
|
foreach (allRooms() as $raum) {
|
||||||
|
if ($raum["floor"] === $etage && $raum["mqtt"] === $name) {
|
||||||
|
$hatThermostat = isset($raum["x"]); // Kachel = Thermostat vorhanden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$katalog = deviceCatalog();
|
||||||
|
$geraete = array_values(array_filter($katalog, function ($d) use ($raumKey) {
|
||||||
|
return $d["room"] === $raumKey && count($d["commands"]) > 0;
|
||||||
|
}));
|
||||||
|
$messwerte = array_values(array_filter($katalog, function ($d) use ($raumKey) {
|
||||||
|
return $d["room"] === $raumKey && count($d["states"]) > 0 && count($d["commands"]) === 0;
|
||||||
|
}));
|
||||||
|
|
||||||
|
echo "<div class='p-2' id='roomView' data-room='" . htmlspecialchars($schluessel, ENT_QUOTES) . "'>";
|
||||||
|
|
||||||
|
if ($hatThermostat) {
|
||||||
|
echo "<div class='card mb-2'><div class='card-body py-2'>";
|
||||||
|
echo "<label class='form-label mb-0'>Solltemperatur "
|
||||||
|
. "<span id='modal-slider-label' style='font-size:15px;color:#297195;display:inline-block'>–</span></label>";
|
||||||
|
echo "<input type='range' id='modal-slider' class='form-range range-color-track' min='10' max='30'>";
|
||||||
|
echo "<div class='d-flex justify-content-between small text-body-secondary'>"
|
||||||
|
. "<span>10 °C</span><span>30 °C</span></div>";
|
||||||
|
echo "</div></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$geraete && !$messwerte && !$hatThermostat) {
|
||||||
|
echo "<p class='text-body-secondary mb-0'>Diesem Raum ist noch nichts zugeordnet. "
|
||||||
|
. "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($geraete as $d) {
|
||||||
|
echo "<div class='card mb-2'><div class='card-body py-2'>";
|
||||||
|
echo "<div class='fw-bold small mb-2'>" . htmlspecialchars($d["label"])
|
||||||
|
. " <span class='fw-normal text-body-secondary'>" . htmlspecialchars($d["type"]) . "</span></div>";
|
||||||
|
|
||||||
|
// Kommandos ohne Parameter werden Knoepfe nebeneinander - "Auf", "Zu",
|
||||||
|
// "Stop" will man antippen und nicht erst einstellen. Die Reihenfolge in
|
||||||
|
// der Datenbank ist die des Geraete-Suchlaufs und damit Zufall; die
|
||||||
|
// haeufig gebrauchten kommen deshalb nach vorne.
|
||||||
|
$knoepfe = array_values(array_filter($d["commands"], function ($c) { return !count($c["params"]); }));
|
||||||
|
$vorne = ["Auf", "Zu", "Stop", "An", "Aus", "turn_on", "turn_off", "toggle"];
|
||||||
|
usort($knoepfe, function ($a, $b) use ($vorne) {
|
||||||
|
$ia = array_search($a["name"], $vorne, true);
|
||||||
|
$ib = array_search($b["name"], $vorne, true);
|
||||||
|
$ia = $ia === false ? 99 : $ia;
|
||||||
|
$ib = $ib === false ? 99 : $ib;
|
||||||
|
return $ia === $ib ? strcoll($a["name"], $b["name"]) : $ia - $ib;
|
||||||
|
});
|
||||||
|
if ($knoepfe) {
|
||||||
|
echo "<div class='d-flex flex-wrap gap-1 mb-2'>";
|
||||||
|
foreach ($knoepfe as $c) {
|
||||||
|
echo "<button type='button' class='btn btn-outline-primary btn-sm' data-cmd='" . $c["id"] . "'>"
|
||||||
|
. htmlspecialchars($c["name"]) . "</button>";
|
||||||
|
}
|
||||||
|
echo "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($d["commands"] as $c) {
|
||||||
|
if (!count($c["params"])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
echo "<div class='input-group input-group-sm mb-1 raum-cmd' data-cmdrow='" . $c["id"] . "'>";
|
||||||
|
echo "<span class='input-group-text'>" . htmlspecialchars($c["name"]) . "</span>";
|
||||||
|
foreach ($c["params"] as $p) {
|
||||||
|
if (count($c["params"]) > 1) {
|
||||||
|
echo "<span class='input-group-text'>" . htmlspecialchars($p["name"]) . "</span>";
|
||||||
|
}
|
||||||
|
if ($p["options"]) {
|
||||||
|
echo "<select class='form-select' data-param='" . $p["id"] . "'>";
|
||||||
|
foreach ($p["options"] as $o) {
|
||||||
|
echo "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>"
|
||||||
|
. htmlspecialchars($o["label"]) . "</option>";
|
||||||
|
}
|
||||||
|
echo "</select>";
|
||||||
|
} else {
|
||||||
|
echo "<input type='" . ($p["input"] === "number" ? "number" : "text")
|
||||||
|
. "' class='form-control' data-param='" . $p["id"] . "'"
|
||||||
|
. ($p["min"] !== null ? " min='" . $p["min"] . "'" : "")
|
||||||
|
. ($p["max"] !== null ? " max='" . $p["max"] . "'" : "")
|
||||||
|
. " value='" . ($p["min"] !== null ? intval($p["min"]) : 0) . "'>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "<button type='button' class='btn btn-outline-primary' data-cmd='" . $c["id"] . "'>"
|
||||||
|
. "<i class='bi bi-play-fill'></i></button>";
|
||||||
|
echo "</div>";
|
||||||
|
}
|
||||||
|
echo "</div></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geraete ohne Kommandos: nur anzeigen, was sie melden.
|
||||||
|
foreach ($messwerte as $d) {
|
||||||
|
echo "<div class='card mb-2'><div class='card-body py-2'>";
|
||||||
|
echo "<div class='fw-bold small mb-1'>" . htmlspecialchars($d["label"])
|
||||||
|
. " <span class='fw-normal text-body-secondary'>" . htmlspecialchars($d["type"]) . "</span></div>";
|
||||||
|
echo "<div class='small text-body-secondary'>";
|
||||||
|
$teile = [];
|
||||||
|
foreach (array_slice($d["states"], 0, 8) as $s) {
|
||||||
|
$teile[] = htmlspecialchars($s["name"]) . ": " . htmlspecialchars(strval($s["value"]))
|
||||||
|
. ($s["unit"] ? " " . htmlspecialchars($s["unit"]) : "");
|
||||||
|
}
|
||||||
|
echo implode(" · ", $teile);
|
||||||
|
if (count($d["states"]) > 8) {
|
||||||
|
echo " · … " . (count($d["states"]) - 8) . " weitere";
|
||||||
|
}
|
||||||
|
echo "</div></div></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "<div id='roomFeedback' class='small mt-2'></div>";
|
||||||
|
echo "</div>";
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
require_once("./phpMQTT.php");
|
|
||||||
require_once("../helper.php");
|
|
||||||
|
|
||||||
|
|
||||||
if (checkLogin()) {
|
|
||||||
$close = 0;
|
|
||||||
$hostname = "192.168.179.169";
|
|
||||||
$server = 'localhost'; // change if necessary
|
|
||||||
$port = 1883; // change if necessary
|
|
||||||
$username = ''; // set your username
|
|
||||||
$password = ''; // set your password
|
|
||||||
$client_id = 'RoomtempSetter'; // make sure this is unique for connecting to sever - you could use uniqid()
|
|
||||||
|
|
||||||
if (isset($_POST["setTemp"]) && isset($_GET["heater"])) {
|
|
||||||
$topic = "Raumtemp/".str_replace("_","/",$_GET["heater"])."/changeSetTemp";
|
|
||||||
$mqtt = new Bluerhinos\phpMQTT($server, $port, $client_id);
|
|
||||||
//send new value
|
|
||||||
$temp = intval($_POST["setTemp"]);
|
|
||||||
if($temp > 9 && $temp < 31){
|
|
||||||
if ($mqtt->connect(true, NULL, $username, $password)) {
|
|
||||||
$mqtt->publish($topic, $temp, 0, false);
|
|
||||||
$mqtt->close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$close = 1;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$close = 1;
|
|
||||||
}
|
|
||||||
if (!$close) {
|
|
||||||
echo <<<ENDE
|
|
||||||
<!--begin::Form-->
|
|
||||||
<form id="heater_form">
|
|
||||||
<div class="col-sm-11">
|
|
||||||
<div class="form-check">
|
|
||||||
<label class="form-label" for="addedCharge">Temperatur: </label>
|
|
||||||
<div style="font-size: 15px;color: #297195;display: inline-block;" id="modal-slider-label">0</div>
|
|
||||||
<input type="range" id="modal-slider" name="setTemp" class="form-range range-color-track" min="10" max="30" />
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">10 °C</span>
|
|
||||||
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">30 °C</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
ENDE;
|
|
||||||
}
|
|
||||||
@@ -85,3 +85,29 @@ input[type='range']::-webkit-slider-runnable-track {
|
|||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Editor der Automatiken. Eine Bedingungszeile traegt Geraet, Messwert,
|
||||||
|
Vergleich, Wert, Einheit und den Papierkorb nebeneinander - auf einem
|
||||||
|
Handy bleiben davon "Therm OG Bad" und "te" uebrig, weil die Felder in
|
||||||
|
der input-group einfach mitschrumpfen. Unter 576 px bekommen deshalb
|
||||||
|
Geraet und Messwert je eine eigene Zeile; Vergleich, Wert und Einheit
|
||||||
|
sind kurz genug, um zusammen in die naechste zu passen. */
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
/* Die input-group-Klasse muss mit in den Selektor: solar.css wird vor
|
||||||
|
AdminLTE geladen, und Bootstraps ".input-group > .form-select" haette
|
||||||
|
sonst dasselbe Gewicht und wuerde als spaeteres Blatt gewinnen. */
|
||||||
|
.input-group.auto-zeile > .auto-geraet,
|
||||||
|
.input-group.auto-zeile > .auto-messwert {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Raum-Modal. Ein Kommando mit mehreren Parametern ("Position+Neigung")
|
||||||
|
traegt Beschriftung, zwei Zahlenfelder und den Ausloeseknopf in einer
|
||||||
|
input-group - auf einem Handy schrumpfen die Felder dabei auf Nullbreite.
|
||||||
|
Eine Mindestbreite laesst die Zeile stattdessen umbrechen. */
|
||||||
|
.input-group.raum-cmd > .form-control,
|
||||||
|
.input-group.raum-cmd > .form-select {
|
||||||
|
min-width: 5rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ CREATE TABLE `actors` (
|
|||||||
`id` int(11) NOT NULL,
|
`id` int(11) NOT NULL,
|
||||||
`type` varchar(50) NOT NULL COMMENT 'Gerätetyp z.B. RollerShutter',
|
`type` varchar(50) NOT NULL COMMENT 'Gerätetyp z.B. RollerShutter',
|
||||||
`name` varchar(70) NOT NULL COMMENT 'Name des Geräts',
|
`name` varchar(70) NOT NULL COMMENT 'Name des Geräts',
|
||||||
`url` varchar(100) NOT NULL COMMENT 'Tahoma Device URL'
|
`url` varchar(100) NOT NULL COMMENT 'Tahoma Device URL',
|
||||||
|
`room` varchar(60) DEFAULT NULL COMMENT 'Raum aus restricted/rooms.php als "Etage/Raum" (z.B. OG/Bad) oder "aussen". Wird von Hand vergeben und deshalb vom Discovery nicht angefasst - der Upsert setzt nur type und name.'
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|||||||
+289
-71
@@ -20,6 +20,7 @@
|
|||||||
let autoModel = null;
|
let autoModel = null;
|
||||||
let autoDevices = [];
|
let autoDevices = [];
|
||||||
let autoLockouts = [];
|
let autoLockouts = [];
|
||||||
|
let autoRooms = [];
|
||||||
|
|
||||||
// --- Katalog-Helfer ------------------------------------------------------
|
// --- Katalog-Helfer ------------------------------------------------------
|
||||||
|
|
||||||
@@ -48,28 +49,221 @@ function operatorLabel(state, op) {
|
|||||||
return treffer ? treffer.label : op;
|
return treffer ? treffer.label : op;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Erster Messwert des ersten Geraets - Vorbelegung fuer eine neue Zeile. */
|
/**
|
||||||
function ersterMesswert() {
|
* Eine neue Zeile beginnt ohne Geraet.
|
||||||
const dev = autoDevices.find(d => d.states.length > 0);
|
*
|
||||||
return dev ? dev.states[0] : null;
|
* Frueher stand dort das erste Geraet mit seinem ersten Messwert - nach dem
|
||||||
|
* Geraete-Discovery also "Bad Links: Gesperrt = ", eine Bedingung, die
|
||||||
|
* niemand gemeint hat und die man erst wegklicken muss. Ohne Vorbelegung
|
||||||
|
* sagt die Zeile, was zu tun ist, und der Server lehnt eine unvollstaendige
|
||||||
|
* Bedingung ohnehin ab.
|
||||||
|
*/
|
||||||
|
function leereBedingung() {
|
||||||
|
return { state_id: 0, operator: "=", value: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ersterBefehl() {
|
function leereAktion() {
|
||||||
const dev = autoDevices.find(d => d.commands.length > 0);
|
return { command_id: 0, params: {} };
|
||||||
return dev ? dev.commands[0] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bausteine fuer die Oberflaeche --------------------------------------
|
// --- Bausteine fuer die Oberflaeche --------------------------------------
|
||||||
|
|
||||||
function optionListe(select, eintraege, gewaehlt) {
|
function optionListe(select, eintraege, gewaehlt) {
|
||||||
select.innerHTML = "";
|
select.innerHTML = "";
|
||||||
eintraege.forEach(e => {
|
eintraege.forEach(e => select.appendChild(option(e, gewaehlt)));
|
||||||
const opt = document.createElement("option");
|
}
|
||||||
opt.value = e.value;
|
|
||||||
opt.text = e.text;
|
function option(e, gewaehlt) {
|
||||||
if (String(e.value) === String(gewaehlt)) opt.selected = true;
|
const opt = document.createElement("option");
|
||||||
select.appendChild(opt);
|
opt.value = e.value;
|
||||||
|
opt.text = e.text;
|
||||||
|
if (String(e.value) === String(gewaehlt)) opt.selected = true;
|
||||||
|
return opt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geraeteliste gruppiert - nach Raum, wo zugeordnet, sonst nach Geraeteart.
|
||||||
|
*
|
||||||
|
* Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll..."), nicht
|
||||||
|
* in Geraetearten. Der Raum steht aber nur da, wo er von Hand vergeben wurde
|
||||||
|
* (siehe Zuordnung ueber das Zahnrad in der Karte "Automatismen"); solange
|
||||||
|
* das niemand getan hat, bleibt die Gruppierung nach Art - besser als eine
|
||||||
|
* einzige Gruppe "Nicht zugeordnet".
|
||||||
|
*/
|
||||||
|
function geraeteGruppen(geraete) {
|
||||||
|
if (!geraete.some(d => d.room)) {
|
||||||
|
return gruppiere(geraete, d => d.type, null, "Einzelne", true);
|
||||||
|
}
|
||||||
|
const gruppen = gruppiere(geraete, d => d.roomLabel, raumReihenfolge(),
|
||||||
|
"Nicht zugeordnet", false);
|
||||||
|
// Was noch keinen Raum hat, bleibt sonst ein einziger Eimer - solange erst
|
||||||
|
// ein Teil zugeordnet ist, waeren das die meisten Geraete. Innerhalb des
|
||||||
|
// Eimers gilt deshalb weiter die Ordnung nach Geraeteart.
|
||||||
|
const letzte = gruppen[gruppen.length - 1];
|
||||||
|
if (!letzte || !letzte.titel.startsWith("Nicht zugeordnet")) return gruppen;
|
||||||
|
gruppen.pop();
|
||||||
|
return gruppen.concat(
|
||||||
|
gruppiere(letzte.geraete, d => d.type, null, "Einzelne", true)
|
||||||
|
.map(g => ({ titel: "Ohne Raum · " + g.titel, geraete: g.geraete })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reihenfolge der Raum-Ueberschriften, wie in rooms.php notiert. */
|
||||||
|
function raumReihenfolge() {
|
||||||
|
return autoRooms.map(r => r.label).concat(["Nicht zugeordnet"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach einem Merkmal gruppieren.
|
||||||
|
*
|
||||||
|
* `einzelneSammeln` entscheidet, was mit Gruppen aus einem einzigen Geraet
|
||||||
|
* passiert. Bei Gerätearten sollen sie zusammenfallen - fuenfzehn
|
||||||
|
* Ueberschriften mit je einem Eintrag waeren unuebersichtlicher als die
|
||||||
|
* flache Liste. Bei Raeumen nicht: "OG · Bad" ist auch mit einem einzigen
|
||||||
|
* Geraet die Ueberschrift, die man sucht, und in der Wohnung haengt oft nur
|
||||||
|
* ein Thermostat je Raum.
|
||||||
|
*/
|
||||||
|
function gruppiere(geraete, merkmal, reihenfolge, restTitel, einzelneSammeln) {
|
||||||
|
const nach = new Map();
|
||||||
|
geraete.forEach(d => {
|
||||||
|
const schluessel = merkmal(d) || restTitel;
|
||||||
|
if (!nach.has(schluessel)) nach.set(schluessel, []);
|
||||||
|
nach.get(schluessel).push(d);
|
||||||
});
|
});
|
||||||
|
const titel = Array.from(nach.keys());
|
||||||
|
titel.sort((a, b) => {
|
||||||
|
if (reihenfolge) {
|
||||||
|
const ia = reihenfolge.indexOf(a), ib = reihenfolge.indexOf(b);
|
||||||
|
if (ia !== ib) return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
|
||||||
|
}
|
||||||
|
return a.localeCompare(b, "de");
|
||||||
|
});
|
||||||
|
const gruppen = [];
|
||||||
|
const rest = [];
|
||||||
|
titel.forEach(t => {
|
||||||
|
const liste = nach.get(t);
|
||||||
|
if (!einzelneSammeln || liste.length > 1 || t === restTitel) {
|
||||||
|
gruppen.push({ titel: t + " (" + liste.length + ")", geraete: liste });
|
||||||
|
} else {
|
||||||
|
rest.push(liste[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (rest.length) {
|
||||||
|
rest.sort((a, b) => a.label.localeCompare(b.label, "de"));
|
||||||
|
gruppen.push({ titel: restTitel + " (" + rest.length + ")", geraete: rest });
|
||||||
|
}
|
||||||
|
return gruppen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geraeteauswahl mit Suchfeld.
|
||||||
|
*
|
||||||
|
* Bei ueber fuenfzig Geraeten hilft Tippen mehr als Blaettern - und es hilft
|
||||||
|
* quer zur Gruppierung: "bad" findet das Thermostat im Bad und die Jalousie
|
||||||
|
* "Bad Links", "jalousie" findet alle elf auf einmal. Gesucht wird deshalb
|
||||||
|
* ueber Beschriftung und Geraeteart zugleich.
|
||||||
|
*
|
||||||
|
* Statt einer Auswahlliste steht ein Knopf in der Zeile; die Liste klappt
|
||||||
|
* darunter ueber die volle Breite auf. Auf dem Handy ist das mehr Platz als
|
||||||
|
* ein natives Auswahlfeld hergibt, und das Suchfeld waere darin ohnehin
|
||||||
|
* nicht unterzubringen.
|
||||||
|
*/
|
||||||
|
function geraeteWaehler(geraete, aktuelleId, beiAuswahl) {
|
||||||
|
const knopf = document.createElement("button");
|
||||||
|
knopf.type = "button";
|
||||||
|
knopf.className = "form-select text-start auto-geraet";
|
||||||
|
const dev = geraete.find(d => d.id === aktuelleId);
|
||||||
|
knopf.textContent = dev ? dev.label : "Gerät wählen …";
|
||||||
|
if (!dev) knopf.classList.add("text-body-secondary");
|
||||||
|
knopf.onclick = () => oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl);
|
||||||
|
return knopf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die aufgeklappte Liste - es ist immer hoechstens eine offen. */
|
||||||
|
function schliesseWaehler() {
|
||||||
|
const offen = document.querySelector(".geraete-waehler");
|
||||||
|
if (offen) offen.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl) {
|
||||||
|
const schonOffen = knopf.nextElementSibling
|
||||||
|
&& knopf.nextElementSibling.classList.contains("geraete-waehler");
|
||||||
|
schliesseWaehler();
|
||||||
|
if (schonOffen) return; // zweiter Klick schliesst wieder
|
||||||
|
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.className = "geraete-waehler border rounded p-2 mt-1";
|
||||||
|
const suche = document.createElement("input");
|
||||||
|
suche.type = "search";
|
||||||
|
suche.className = "form-control form-control-sm mb-2";
|
||||||
|
suche.placeholder = "Suchen – Name oder Geräteart";
|
||||||
|
const liste = document.createElement("div");
|
||||||
|
liste.className = "list-group list-group-flush";
|
||||||
|
liste.style.maxHeight = "15rem";
|
||||||
|
liste.style.overflowY = "auto";
|
||||||
|
panel.appendChild(suche);
|
||||||
|
panel.appendChild(liste);
|
||||||
|
// Hinter die ganze Zeile haengen, nicht in sie hinein: in einer
|
||||||
|
// input-group stuende die Liste sonst als weitere Spalte daneben.
|
||||||
|
knopf.closest(".input-group").after(panel);
|
||||||
|
|
||||||
|
const eintrag = (d) => {
|
||||||
|
const zeile = document.createElement("button");
|
||||||
|
zeile.type = "button";
|
||||||
|
zeile.className = "list-group-item list-group-item-action py-1"
|
||||||
|
+ (d.id === aktuelleId ? " active" : "");
|
||||||
|
zeile.innerHTML = "<span>" + textSicher(d.label) + "</span>"
|
||||||
|
+ "<small class='ms-2 opacity-75'>" + textSicher(d.type) + "</small>";
|
||||||
|
zeile.onclick = () => { schliesseWaehler(); beiAuswahl(d); };
|
||||||
|
return zeile;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zeichne = () => {
|
||||||
|
const suchtext = suche.value.trim().toLowerCase();
|
||||||
|
liste.innerHTML = "";
|
||||||
|
const passend = geraete.filter(d => !suchtext
|
||||||
|
|| d.label.toLowerCase().includes(suchtext)
|
||||||
|
|| d.type.toLowerCase().includes(suchtext));
|
||||||
|
if (!passend.length) {
|
||||||
|
liste.innerHTML = "<div class='p-2 small text-body-secondary'>Nichts gefunden.</div>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Beim Suchen die Zwischenueberschriften weglassen: die Treffer stehen
|
||||||
|
// dann quer ueber alle Arten, und die Art steht ohnehin an jeder Zeile.
|
||||||
|
if (suchtext) {
|
||||||
|
passend.forEach(d => liste.appendChild(eintrag(d)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
geraeteGruppen(passend).forEach(g => {
|
||||||
|
const kopf = document.createElement("div");
|
||||||
|
kopf.className = "px-2 pt-2 pb-1 small fw-bold text-body-secondary";
|
||||||
|
kopf.textContent = g.titel;
|
||||||
|
liste.appendChild(kopf);
|
||||||
|
g.geraete.forEach(d => liste.appendChild(eintrag(d)));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
suche.oninput = zeichne;
|
||||||
|
suche.onkeydown = e => {
|
||||||
|
if (e.key === "Escape") { schliesseWaehler(); knopf.focus(); }
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const erster = liste.querySelector("button.list-group-item");
|
||||||
|
if (erster) erster.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
zeichne();
|
||||||
|
suche.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klick daneben schliesst die Liste - sonst bliebe sie stehen, wenn man es
|
||||||
|
// sich anders ueberlegt.
|
||||||
|
document.addEventListener("click", e => {
|
||||||
|
if (e.target.closest(".geraete-waehler") || e.target.closest(".form-select.text-start")) return;
|
||||||
|
schliesseWaehler();
|
||||||
|
});
|
||||||
|
|
||||||
|
function textSicher(text) {
|
||||||
|
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,12 +345,7 @@ function renderConditions() {
|
|||||||
add.type = "button";
|
add.type = "button";
|
||||||
add.className = "btn btn-outline-success btn-sm mt-2";
|
add.className = "btn btn-outline-success btn-sm mt-2";
|
||||||
add.innerHTML = "<i class='bi bi-plus-lg'></i> Bedingung";
|
add.innerHTML = "<i class='bi bi-plus-lg'></i> Bedingung";
|
||||||
add.onclick = () => {
|
add.onclick = () => { gruppe.push(leereBedingung()); renderConditions(); };
|
||||||
const state = ersterMesswert();
|
|
||||||
if (!state) return;
|
|
||||||
gruppe.push({ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) });
|
|
||||||
renderConditions();
|
|
||||||
};
|
|
||||||
block.appendChild(add);
|
block.appendChild(add);
|
||||||
|
|
||||||
ziel.appendChild(block);
|
ziel.appendChild(block);
|
||||||
@@ -167,32 +356,26 @@ function renderConditions() {
|
|||||||
|
|
||||||
function conditionRow(gi, bi, bed) {
|
function conditionRow(gi, bi, bed) {
|
||||||
const zeile = document.createElement("div");
|
const zeile = document.createElement("div");
|
||||||
zeile.className = "input-group";
|
zeile.className = "input-group auto-zeile";
|
||||||
|
|
||||||
const treffer = stateById(bed.state_id);
|
const treffer = stateById(bed.state_id);
|
||||||
const dev = treffer ? treffer.device : null;
|
const dev = treffer ? treffer.device : null;
|
||||||
const state = treffer ? treffer.state : null;
|
const state = treffer ? treffer.state : null;
|
||||||
|
|
||||||
// Geraet
|
// Geraet
|
||||||
const geraet = document.createElement("select");
|
zeile.appendChild(geraeteWaehler(
|
||||||
geraet.className = "form-select";
|
autoDevices.filter(d => d.states.length), dev ? dev.id : 0,
|
||||||
optionListe(geraet, autoDevices.filter(d => d.states.length)
|
neu => {
|
||||||
.map(d => ({ value: d.id, text: d.name })),
|
bed.state_id = neu.states[0].id;
|
||||||
dev ? dev.id : "");
|
bed.operator = neu.states[0].operators[0].value;
|
||||||
geraet.onchange = () => {
|
bed.value = standardWert(neu.states[0]);
|
||||||
const neu = deviceById(Number(geraet.value));
|
renderConditions();
|
||||||
if (!neu || !neu.states.length) return;
|
}));
|
||||||
bed.state_id = neu.states[0].id;
|
|
||||||
bed.operator = neu.states[0].operators[0].value;
|
|
||||||
bed.value = standardWert(neu.states[0]);
|
|
||||||
renderConditions();
|
|
||||||
};
|
|
||||||
zeile.appendChild(geraet);
|
|
||||||
|
|
||||||
// Messwert - nur zeigen, wenn das Geraet mehr als einen hat
|
// Messwert - nur zeigen, wenn das Geraet mehr als einen hat
|
||||||
if (dev && dev.states.length > 1) {
|
if (dev && dev.states.length > 1) {
|
||||||
const messwert = document.createElement("select");
|
const messwert = document.createElement("select");
|
||||||
messwert.className = "form-select";
|
messwert.className = "form-select auto-messwert";
|
||||||
optionListe(messwert, dev.states.map(s => ({
|
optionListe(messwert, dev.states.map(s => ({
|
||||||
value: s.id,
|
value: s.id,
|
||||||
text: s.name + (s.value ? " (= " + s.value + ")" : "")
|
text: s.name + (s.value ? " (= " + s.value + ")" : "")
|
||||||
@@ -253,11 +436,13 @@ function conditionRow(gi, bi, bed) {
|
|||||||
* muss, sondern schwarz auf weiss liest.
|
* muss, sondern schwarz auf weiss liest.
|
||||||
*/
|
*/
|
||||||
function conditionText() {
|
function conditionText() {
|
||||||
const teile = autoModel.groups.filter(g => g.length).map(gruppe => {
|
const teile = autoModel.groups
|
||||||
|
.map(g => g.filter(bed => stateById(bed.state_id)))
|
||||||
|
.filter(g => g.length)
|
||||||
|
.map(gruppe => {
|
||||||
const text = gruppe.map(bed => {
|
const text = gruppe.map(bed => {
|
||||||
const treffer = stateById(bed.state_id);
|
const treffer = stateById(bed.state_id);
|
||||||
if (!treffer) return "?";
|
let s = treffer.device.label + ": " + treffer.state.name + " "
|
||||||
let s = treffer.device.name + ": " + treffer.state.name + " "
|
|
||||||
+ operatorLabel(treffer.state, bed.operator) + " " + bed.value;
|
+ operatorLabel(treffer.state, bed.operator) + " " + bed.value;
|
||||||
if (treffer.state.unit) s += " " + treffer.state.unit;
|
if (treffer.state.unit) s += " " + treffer.state.unit;
|
||||||
return s;
|
return s;
|
||||||
@@ -284,29 +469,23 @@ function renderActions() {
|
|||||||
|
|
||||||
function actionRow(ai, aktion) {
|
function actionRow(ai, aktion) {
|
||||||
const zeile = document.createElement("div");
|
const zeile = document.createElement("div");
|
||||||
zeile.className = "input-group mb-2";
|
zeile.className = "input-group mb-2 auto-zeile";
|
||||||
|
|
||||||
const treffer = commandById(aktion.command_id);
|
const treffer = commandById(aktion.command_id);
|
||||||
const dev = treffer ? treffer.device : null;
|
const dev = treffer ? treffer.device : null;
|
||||||
const cmd = treffer ? treffer.command : null;
|
const cmd = treffer ? treffer.command : null;
|
||||||
|
|
||||||
const geraet = document.createElement("select");
|
zeile.appendChild(geraeteWaehler(
|
||||||
geraet.className = "form-select";
|
autoDevices.filter(d => d.commands.length), dev ? dev.id : 0,
|
||||||
optionListe(geraet, autoDevices.filter(d => d.commands.length)
|
neu => {
|
||||||
.map(d => ({ value: d.id, text: d.name })),
|
aktion.command_id = neu.commands[0].id;
|
||||||
dev ? dev.id : "");
|
aktion.params = standardParams(neu.commands[0]);
|
||||||
geraet.onchange = () => {
|
renderActions();
|
||||||
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) {
|
if (dev && dev.commands.length > 1) {
|
||||||
const befehl = document.createElement("select");
|
const befehl = document.createElement("select");
|
||||||
befehl.className = "form-select";
|
befehl.className = "form-select auto-messwert";
|
||||||
optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id);
|
optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id);
|
||||||
befehl.onchange = () => {
|
befehl.onchange = () => {
|
||||||
const neu = dev.commands.find(c => c.id === Number(befehl.value));
|
const neu = dev.commands.find(c => c.id === Number(befehl.value));
|
||||||
@@ -411,6 +590,7 @@ function loadAutomatic() {
|
|||||||
const daten = JSON.parse(document.getElementById("autoActionData").textContent);
|
const daten = JSON.parse(document.getElementById("autoActionData").textContent);
|
||||||
autoDevices = daten.devices;
|
autoDevices = daten.devices;
|
||||||
autoLockouts = daten.lockouts;
|
autoLockouts = daten.lockouts;
|
||||||
|
autoRooms = daten.rooms || [];
|
||||||
|
|
||||||
const a = daten.automation;
|
const a = daten.automation;
|
||||||
autoModel = {
|
autoModel = {
|
||||||
@@ -438,18 +618,10 @@ function loadAutomatic() {
|
|||||||
});
|
});
|
||||||
autoModel.groups = Array.from(nachGruppe.values());
|
autoModel.groups = Array.from(nachGruppe.values());
|
||||||
|
|
||||||
// Eine neue Automatik startet mit einer leeren Bedingung und einer Aktion,
|
// Eine neue Automatik startet mit je einer leeren Zeile, sonst steht man
|
||||||
// sonst steht man vor einem leeren Formular ohne erkennbaren Anfang.
|
// vor einem leeren Formular ohne erkennbaren Anfang.
|
||||||
if (!autoModel.groups.length) {
|
if (!autoModel.groups.length) autoModel.groups = [[leereBedingung()]];
|
||||||
const state = ersterMesswert();
|
if (!autoModel.actions.length) autoModel.actions = [leereAktion()];
|
||||||
if (state) {
|
|
||||||
autoModel.groups = [[{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!autoModel.actions.length) {
|
|
||||||
const cmd = ersterBefehl();
|
|
||||||
if (cmd) autoModel.actions = [{ command_id: cmd.id, params: standardParams(cmd) }];
|
|
||||||
}
|
|
||||||
|
|
||||||
fuelleFormular();
|
fuelleFormular();
|
||||||
}
|
}
|
||||||
@@ -487,15 +659,11 @@ function openAutoActionModal(params) {
|
|||||||
modalBodyElement.innerHTML = html;
|
modalBodyElement.innerHTML = html;
|
||||||
loadAutomatic();
|
loadAutomatic();
|
||||||
document.getElementById("btnAddGroup").addEventListener("click", () => {
|
document.getElementById("btnAddGroup").addEventListener("click", () => {
|
||||||
const state = ersterMesswert();
|
autoModel.groups.push([leereBedingung()]);
|
||||||
if (!state) return;
|
|
||||||
autoModel.groups.push([{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]);
|
|
||||||
renderConditions();
|
renderConditions();
|
||||||
});
|
});
|
||||||
document.getElementById("btnAddActor").addEventListener("click", () => {
|
document.getElementById("btnAddActor").addEventListener("click", () => {
|
||||||
const cmd = ersterBefehl();
|
autoModel.actions.push(leereAktion());
|
||||||
if (!cmd) return;
|
|
||||||
autoModel.actions.push({ command_id: cmd.id, params: standardParams(cmd) });
|
|
||||||
renderActions();
|
renderActions();
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -565,6 +733,56 @@ function refreshAutomations(floor) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuordnung Geraet -> Raum. Eigenes Modal, weil es kein Teil einer einzelnen
|
||||||
|
* Automatik ist, sondern einmal fuer alle gilt.
|
||||||
|
*/
|
||||||
|
function openRoomModal() {
|
||||||
|
document.getElementById("modal-title").innerHTML = "Geräte den Räumen zuordnen";
|
||||||
|
const alt = document.getElementById("modalSaveBtn");
|
||||||
|
const btn = alt.cloneNode(true);
|
||||||
|
alt.replaceWith(btn);
|
||||||
|
btn.addEventListener("click", speichereRaeume);
|
||||||
|
|
||||||
|
const dialog = document.querySelector("#modalEV .modal-dialog");
|
||||||
|
dialog.classList.add("modal-lg");
|
||||||
|
document.getElementById("modalEV").addEventListener("hidden.bs.modal",
|
||||||
|
() => dialog.classList.remove("modal-lg"), { once: true });
|
||||||
|
|
||||||
|
const koerper = document.getElementById("modal-body");
|
||||||
|
koerper.innerHTML = loadingHTML("Wird geladen...");
|
||||||
|
modalEV.show();
|
||||||
|
|
||||||
|
fetch("./ajax/AutoAction.php?action=rooms", {
|
||||||
|
method: "GET", headers: { "Requested-With-Ajax": "ajax" }
|
||||||
|
})
|
||||||
|
.then(a => a.text())
|
||||||
|
.then(html => {
|
||||||
|
koerper.innerHTML = html;
|
||||||
|
const tipps = document.getElementById("btnRoomTipps");
|
||||||
|
if (tipps) {
|
||||||
|
tipps.addEventListener("click", () => {
|
||||||
|
koerper.querySelectorAll("select[data-tipp]").forEach(s => {
|
||||||
|
if (!s.value) s.value = s.dataset.tipp;
|
||||||
|
});
|
||||||
|
tipps.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(fehler => { koerper.innerHTML = "Konnte die Zuordnung nicht laden: " + fehler.message; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function speichereRaeume() {
|
||||||
|
const rooms = {};
|
||||||
|
document.querySelectorAll("#modal-body select[data-actor]").forEach(s => {
|
||||||
|
rooms[s.dataset.actor] = s.value;
|
||||||
|
});
|
||||||
|
automationRequest("saveRooms", { rooms: rooms }).then(antwort => {
|
||||||
|
if (antwort.error) alert(antwort.error);
|
||||||
|
else modalEV.hide();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toggleAutomation(id) {
|
function toggleAutomation(id) {
|
||||||
automationRequest("toggle", { id: id }).then(antwort => {
|
automationRequest("toggle", { id: id }).then(antwort => {
|
||||||
if (antwort.error) alert(antwort.error);
|
if (antwort.error) alert(antwort.error);
|
||||||
|
|||||||
+92
-30
@@ -151,7 +151,7 @@ function switchFloor(floor){
|
|||||||
var mqttData = {};
|
var mqttData = {};
|
||||||
|
|
||||||
function openHeaterSettings(heater){
|
function openHeaterSettings(heater){
|
||||||
openModal(heater)
|
openRoomView(heater);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('readystatechange', function () {
|
document.addEventListener('readystatechange', function () {
|
||||||
@@ -162,36 +162,98 @@ function switchFloor(floor){
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
function openModal(heater) {
|
/**
|
||||||
var contentURL = "./ajax/roomtemp.php?heater="+heater;
|
* Alles, was in einem Raum haengt, auf einer Seite: Heizung, Rollladen,
|
||||||
let heaterMQTT = heater.toString().split("_");
|
* Licht. Der Grundriss zeigt Raeume, also sollte ein Klick darauf den Raum
|
||||||
document.getElementById("modal-title").innerHTML = "Thermostat "+heater.replace("_"," ").replace("ue","ü").replace("ae","ä").replace("oe","ö");
|
* oeffnen - vorher kam nur der Temperaturregler, die Rollladen im selben
|
||||||
modalBodyElement = document.getElementById("modal-body");
|
* Zimmer waren von hier gar nicht erreichbar.
|
||||||
modalBodyElement.innerHTML = loadingHTML("Wird geladen...");
|
*
|
||||||
document.getElementById("modalSaveBtn").addEventListener("click", submitFormAjax);
|
* Welche Geraete dazugehoeren, sagt die Raumzuordnung (actors.room).
|
||||||
document.getElementById("modalSaveBtn").contentURL = contentURL;
|
*/
|
||||||
modalEV.show();
|
function openRoomView(raum) {
|
||||||
fetch(contentURL, {
|
const teile = raum.toString().split("_");
|
||||||
method: 'GET',
|
document.getElementById("modal-title").innerHTML =
|
||||||
headers: {
|
teile[0] + " · " + teile.slice(1).join(" ")
|
||||||
'X-Requested-From-Modal': 'a',
|
.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö");
|
||||||
'Requested-With-Ajax': 'ajax'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.text())
|
|
||||||
.then(html => {
|
|
||||||
modalBodyElement.innerHTML = html;
|
|
||||||
var slider = document.getElementById("modal-slider");
|
|
||||||
var span = document.getElementById("modal-slider-label");
|
|
||||||
bindModalSlider(slider, span, value => value + " °C");
|
|
||||||
|
|
||||||
if(typeof(mqttData["Raumtemp"][heaterMQTT[0]][heaterMQTT[1]]["Set Temp[degC]"])!= "undefined")
|
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
|
||||||
slider.setAttribute("value",mqttData["Raumtemp"][heaterMQTT[0]][heaterMQTT[1]]["Set Temp[degC]"]);
|
// Zuhoerer stellt sicher, dass nicht auch der Handler eines vorher
|
||||||
else
|
// geoeffneten Modals mitfeuert.
|
||||||
slider.setAttribute("value",10);
|
const alt = document.getElementById("modalSaveBtn");
|
||||||
slider.dispatchEvent(new Event('input'));
|
const btn = alt.cloneNode(true);
|
||||||
|
alt.replaceWith(btn);
|
||||||
|
btn.addEventListener("click", () => sendeSolltemperatur(raum));
|
||||||
|
|
||||||
|
const koerper = document.getElementById("modal-body");
|
||||||
|
koerper.innerHTML = loadingHTML("Wird geladen...");
|
||||||
|
modalEV.show();
|
||||||
|
|
||||||
|
fetch("./ajax/room.php?room=" + encodeURIComponent(raum), {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "X-Requested-From-Modal": "a", "Requested-With-Ajax": "ajax" }
|
||||||
|
})
|
||||||
|
.then(a => a.text())
|
||||||
|
.then(html => {
|
||||||
|
koerper.innerHTML = html;
|
||||||
|
|
||||||
|
const slider = document.getElementById("modal-slider");
|
||||||
|
if (slider) {
|
||||||
|
bindModalSlider(slider, document.getElementById("modal-slider-label"),
|
||||||
|
wert => wert + " °C");
|
||||||
|
const stand = mqttData["Raumtemp"]?.[teile[0]]?.[teile[1]]?.["Set Temp[degC]"];
|
||||||
|
slider.value = typeof stand !== "undefined" ? stand : 10;
|
||||||
|
slider.dispatchEvent(new Event("input"));
|
||||||
|
}
|
||||||
|
// Ohne Thermostat gibt es nichts zu speichern - dann waere der Knopf
|
||||||
|
// eine Falle.
|
||||||
|
btn.hidden = !slider;
|
||||||
|
|
||||||
|
koerper.querySelectorAll("button[data-cmd]").forEach(k => {
|
||||||
|
k.addEventListener("click", () => schalte(k));
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; });
|
||||||
modalBodyElement.innerHTML += error.message;
|
}
|
||||||
|
|
||||||
|
/** Ein Kommando des angeklickten Knopfes ausfuehren. */
|
||||||
|
function schalte(knopf) {
|
||||||
|
const zeile = knopf.closest("[data-cmdrow]");
|
||||||
|
const params = {};
|
||||||
|
if (zeile) {
|
||||||
|
zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; });
|
||||||
|
}
|
||||||
|
knopf.disabled = true;
|
||||||
|
raumRequest("command", { command_id: Number(knopf.dataset.cmd), params: params })
|
||||||
|
.then(antwort => {
|
||||||
|
knopf.disabled = false;
|
||||||
|
raumMeldung(antwort);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendeSolltemperatur(raum) {
|
||||||
|
const slider = document.getElementById("modal-slider");
|
||||||
|
if (!slider) return;
|
||||||
|
raumRequest("temp", { room: raum, temp: Number(slider.value) })
|
||||||
|
.then(antwort => {
|
||||||
|
raumMeldung(antwort);
|
||||||
|
if (!antwort.error) setTimeout(() => modalEV.hide(), 700);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function raumRequest(action, body) {
|
||||||
|
return fetch("./ajax/room.php?action=" + action, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
.then(a => a.json())
|
||||||
|
.catch(fehler => ({ error: fehler.message }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rueckmeldung im Modal statt als alert - man schaltet oft mehrmals. */
|
||||||
|
function raumMeldung(antwort) {
|
||||||
|
const ziel = document.getElementById("roomFeedback");
|
||||||
|
if (!ziel) return;
|
||||||
|
ziel.className = "small mt-2 " + (antwort.error ? "text-danger" : "text-body-secondary");
|
||||||
|
ziel.textContent = antwort.error ? antwort.error : "✓ " + antwort.ok;
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ import configparser
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
@@ -367,6 +369,79 @@ def tag_passt(automatik, jetzt, kalender):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Versand
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class Versand:
|
||||||
|
"""
|
||||||
|
Schickt Kommandos im Hintergrund - je Geraet der Reihe nach, ueber
|
||||||
|
Geraete hinweg nebeneinander.
|
||||||
|
|
||||||
|
Ohne das blockiert ein einziges Kommando die ganze Auswertung: eine
|
||||||
|
Jalousie zuzufahren dauert ueber eine Minute, weil zwischen den beiden
|
||||||
|
Schwenkbefehlen auf das Ende der Fahrt gewartet werden muss (siehe
|
||||||
|
TahomaTransport). So lange kaeme kein Zeit-Ausloeser mehr durch, kein
|
||||||
|
Messwert wuerde zurueckgeschrieben, und mehrere Rollladen in einer
|
||||||
|
Automatik wuerden sich aufaddieren.
|
||||||
|
|
||||||
|
Je Geraet eine Warteschlange mit einem eigenen Faden: zwei Kommandos an
|
||||||
|
dieselbe Jalousie duerfen sich nicht ueberholen - "Neigung 0" und
|
||||||
|
"Neigung 100" sind sonst wirkungslos oder vertauscht -, zwei Kommandos an
|
||||||
|
verschiedene Jalousien duerfen ruhig gleichzeitig laufen.
|
||||||
|
|
||||||
|
Die Datenbank bleibt aussen vor: die Faeden melden ihr Ergebnis nur
|
||||||
|
zurueck, geschrieben wird im Hauptfaden. Eine pymysql-Verbindung ist
|
||||||
|
nicht fuer mehrere Faeden gedacht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.warteschlangen = {} # actor_url -> Queue
|
||||||
|
self.ergebnisse = queue.Queue()
|
||||||
|
self.laeuft = True
|
||||||
|
|
||||||
|
def einreihen(self, automation_id, beschreibung, transport, auftrag):
|
||||||
|
schlange = self.warteschlangen.get(auftrag["actor_url"])
|
||||||
|
if schlange is None:
|
||||||
|
schlange = queue.Queue()
|
||||||
|
self.warteschlangen[auftrag["actor_url"]] = schlange
|
||||||
|
faden = threading.Thread(target=self._arbeiten, args=(schlange,),
|
||||||
|
name="versand", daemon=True)
|
||||||
|
faden.start()
|
||||||
|
schlange.put((automation_id, beschreibung, transport, auftrag))
|
||||||
|
|
||||||
|
def _arbeiten(self, schlange):
|
||||||
|
while self.laeuft:
|
||||||
|
posten = schlange.get()
|
||||||
|
if posten is None:
|
||||||
|
return
|
||||||
|
automation_id, beschreibung, transport, auftrag = posten
|
||||||
|
try:
|
||||||
|
transport.senden(auftrag)
|
||||||
|
self.ergebnisse.put((automation_id, beschreibung, None))
|
||||||
|
except Exception as fehler:
|
||||||
|
self.ergebnisse.put((automation_id, beschreibung, str(fehler)))
|
||||||
|
finally:
|
||||||
|
schlange.task_done()
|
||||||
|
|
||||||
|
def abholen(self):
|
||||||
|
"""Alles, was seit dem letzten Mal fertig geworden ist."""
|
||||||
|
fertig = []
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
fertig.append(self.ergebnisse.get_nowait())
|
||||||
|
except queue.Empty:
|
||||||
|
return fertig
|
||||||
|
|
||||||
|
def offen(self):
|
||||||
|
return sum(s.unfinished_tasks for s in self.warteschlangen.values())
|
||||||
|
|
||||||
|
def beenden(self):
|
||||||
|
self.laeuft = False
|
||||||
|
for schlange in self.warteschlangen.values():
|
||||||
|
schlange.put(None)
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Der Runner
|
# Der Runner
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -387,6 +462,7 @@ class Runner:
|
|||||||
self._kalender = (None, {"feiertag": False, "ferien": False})
|
self._kalender = (None, {"feiertag": False, "ferien": False})
|
||||||
self._letzte_saeuberung = None
|
self._letzte_saeuberung = None
|
||||||
|
|
||||||
|
self.versand = Versand()
|
||||||
self.mqtt = self._mqtt_verbinden()
|
self.mqtt = self._mqtt_verbinden()
|
||||||
self.transporte = [
|
self.transporte = [
|
||||||
MQTTTransport(self.mqtt, self.dry_run),
|
MQTTTransport(self.mqtt, self.dry_run),
|
||||||
@@ -427,6 +503,16 @@ class Runner:
|
|||||||
|
|
||||||
def regelwerk_laden(self):
|
def regelwerk_laden(self):
|
||||||
self.regelwerk = Regelwerk.laden(self.db)
|
self.regelwerk = Regelwerk.laden(self.db)
|
||||||
|
|
||||||
|
# Welche Geraete Position und Neigung zusammen koennen. Nur die haben
|
||||||
|
# die Kugelschreiber-Mechanik, und nur bei ihnen wird ein "Zu" zum
|
||||||
|
# zweifachen Schwenken - siehe TahomaTransport.
|
||||||
|
kombi = {k["actor_url"] for k in self.regelwerk.kommandos.values()
|
||||||
|
if k["command_url"] == "setClosureAndOrientation"}
|
||||||
|
for transport in self.transporte:
|
||||||
|
if isinstance(transport, TahomaTransport):
|
||||||
|
transport.kombigeraete_setzen(kombi)
|
||||||
|
|
||||||
# Jeder Transport bekommt die Messwerte, fuer die er zustaendig ist.
|
# Jeder Transport bekommt die Messwerte, fuer die er zustaendig ist.
|
||||||
for transport in self.transporte:
|
for transport in self.transporte:
|
||||||
passende = [{"id": s["id"], "actor_url": s["actor_url"],
|
passende = [{"id": s["id"], "actor_url": s["actor_url"],
|
||||||
@@ -523,7 +609,15 @@ class Runner:
|
|||||||
# --- Ausfuehren ------------------------------------------------------
|
# --- Ausfuehren ------------------------------------------------------
|
||||||
|
|
||||||
def ausloesen(self, automatik, anlass):
|
def ausloesen(self, automatik, anlass):
|
||||||
|
"""
|
||||||
|
Die Aktionen einer Automatik in den Versand geben.
|
||||||
|
|
||||||
|
Geschickt wird im Hintergrund - eine Jalousie zuzufahren dauert ueber
|
||||||
|
eine Minute, und so lange darf die Auswertung nicht stehen. Was dabei
|
||||||
|
schiefgeht, kommt spaeter ueber ergebnisse_verbuchen() ins Protokoll.
|
||||||
|
"""
|
||||||
fehlerText = []
|
fehlerText = []
|
||||||
|
eingereiht = 0
|
||||||
for aktion in automatik["aktionen"]:
|
for aktion in automatik["aktionen"]:
|
||||||
kommando = self.regelwerk.kommandos.get(aktion["command_id"])
|
kommando = self.regelwerk.kommandos.get(aktion["command_id"])
|
||||||
if kommando is None:
|
if kommando is None:
|
||||||
@@ -544,17 +638,14 @@ class Runner:
|
|||||||
"wert": aktion["werte"].get(p["id"], "")}
|
"wert": aktion["werte"].get(p["id"], "")}
|
||||||
for p in kommando["params"]],
|
for p in kommando["params"]],
|
||||||
}
|
}
|
||||||
try:
|
beschreibung = "%s: %s" % (kommando["actor_name"], kommando["command_name"])
|
||||||
transport.senden(auftrag)
|
logger.info("%s: %s (unterwegs)", automatik["name"], beschreibung)
|
||||||
logger.info("%s: %s -> %s", automatik["name"], kommando["actor_name"],
|
self.versand.einreihen(automatik["id"], beschreibung, transport, auftrag)
|
||||||
kommando["command_name"])
|
eingereiht += 1
|
||||||
except Exception as fehler:
|
|
||||||
fehlerText.append("%s: %s" % (kommando["command_name"], fehler))
|
|
||||||
logger.error("%s: %s konnte nicht geschickt werden: %s",
|
|
||||||
automatik["name"], kommando["command_name"], fehler)
|
|
||||||
|
|
||||||
ergebnis = "error" if fehlerText else anlass
|
ergebnis = "error" if fehlerText else anlass
|
||||||
detail = "; ".join(fehlerText)[:255]
|
detail = "; ".join(fehlerText)[:255] if fehlerText \
|
||||||
|
else ("%d Kommando(s) unterwegs" % eingereiht)
|
||||||
try:
|
try:
|
||||||
with self.db.cursor() as c:
|
with self.db.cursor() as c:
|
||||||
c.execute("""UPDATE automations SET last_run = NOW(), changed = changed
|
c.execute("""UPDATE automations SET last_run = NOW(), changed = changed
|
||||||
@@ -566,6 +657,27 @@ class Runner:
|
|||||||
automatik["last_run"] = datetime.now()
|
automatik["last_run"] = datetime.now()
|
||||||
self.lief_im_fenster[automatik["id"]] = True
|
self.lief_im_fenster[automatik["id"]] = True
|
||||||
|
|
||||||
|
def ergebnisse_verbuchen(self):
|
||||||
|
"""
|
||||||
|
Was der Versand inzwischen erledigt hat ins Protokoll schreiben.
|
||||||
|
|
||||||
|
Nur Fehlschlaege bekommen eine eigene Zeile - der Lauf selbst steht
|
||||||
|
schon drin, und ein Protokoll, das jedes gelungene Kommando einzeln
|
||||||
|
auffuehrt, findet niemand mehr etwas darin.
|
||||||
|
"""
|
||||||
|
for automation_id, beschreibung, fehler in self.versand.abholen():
|
||||||
|
if fehler is None:
|
||||||
|
logger.debug("erledigt: %s", beschreibung)
|
||||||
|
continue
|
||||||
|
logger.error("%s konnte nicht geschickt werden: %s", beschreibung, fehler)
|
||||||
|
try:
|
||||||
|
with self.db.cursor() as c:
|
||||||
|
c.execute("""INSERT INTO automation_log (automation_id, result, detail)
|
||||||
|
VALUES (%s, 'error', %s)""",
|
||||||
|
(automation_id, ("%s: %s" % (beschreibung, fehler))[:255]))
|
||||||
|
except Exception as schreibfehler:
|
||||||
|
logger.warning("Protokoll nicht schreibbar: %s", schreibfehler)
|
||||||
|
|
||||||
def flanke_merken(self, automatik, erfuellt):
|
def flanke_merken(self, automatik, erfuellt):
|
||||||
if bool(automatik["cond_met"]) == bool(erfuellt):
|
if bool(automatik["cond_met"]) == bool(erfuellt):
|
||||||
return
|
return
|
||||||
@@ -674,6 +786,7 @@ class Runner:
|
|||||||
|
|
||||||
self.werte_einsammeln(mit_pollen)
|
self.werte_einsammeln(mit_pollen)
|
||||||
self.durchlauf()
|
self.durchlauf()
|
||||||
|
self.ergebnisse_verbuchen()
|
||||||
self.protokoll_saeubern()
|
self.protokoll_saeubern()
|
||||||
|
|
||||||
if jetzt - letztes_pruefen >= neuladen:
|
if jetzt - letztes_pruefen >= neuladen:
|
||||||
@@ -690,6 +803,10 @@ class Runner:
|
|||||||
time.sleep(takt)
|
time.sleep(takt)
|
||||||
|
|
||||||
def beenden(self):
|
def beenden(self):
|
||||||
|
offen = self.versand.offen()
|
||||||
|
if offen:
|
||||||
|
logger.info("%d Kommando(s) noch unterwegs - wird nicht abgewartet", offen)
|
||||||
|
self.versand.beenden()
|
||||||
try:
|
try:
|
||||||
self.mqtt.loop_stop()
|
self.mqtt.loop_stop()
|
||||||
self.mqtt.disconnect()
|
self.mqtt.disconnect()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ id, `aktion` ein Dict mit actor_url, command_url und params (Liste aus
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import urllib3
|
import urllib3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@@ -382,6 +383,36 @@ class TahomaTransport(Transport):
|
|||||||
|
|
||||||
schema = "io://"
|
schema = "io://"
|
||||||
|
|
||||||
|
# Bis zu dieser Neigung fahren die Aussenjalousien direkt.
|
||||||
|
#
|
||||||
|
# Sie haben eine Kugelschreiber-Mechanik: beim Herunterfahren stehen die
|
||||||
|
# Lamellen bei etwa 30 %. In Richtung 0 % laesst sich von jeder Stellung
|
||||||
|
# aus direkt neigen; darueber hinaus muss die Mechanik erst einmal auf
|
||||||
|
# 0 % zurueck, sonst rastet sie nicht um - ein "Neigung 80 %" ohne
|
||||||
|
# diesen Umweg bleibt wirkungslos.
|
||||||
|
#
|
||||||
|
# Das gilt fuer jedes Kommando, das die Neigung setzt - "Neigung" ebenso
|
||||||
|
# wie "Position+Neigung". Erkannt wird es deshalb am Parameter und nicht
|
||||||
|
# am Kommandonamen: beide heissen ihren Neigungsparameter "Neigung".
|
||||||
|
#
|
||||||
|
# Dasselbe steht in restricted/commands.php: beide Versender brauchen es.
|
||||||
|
NEIGUNG_DIREKT_MAX = 30
|
||||||
|
|
||||||
|
# So heissen die beiden Parameter einer Jalousie im Geraetemodell.
|
||||||
|
NEIGUNG_PARAMETER = "Neigung"
|
||||||
|
POSITION_PARAMETER = "Position"
|
||||||
|
|
||||||
|
# So lange wird hoechstens auf das Ende einer Fahrt gewartet. Gemessen:
|
||||||
|
# eine Neigung von 100 % auf 0 % dauert gut fuenfzehn Sekunden, eine
|
||||||
|
# volle Fahrt von oben nach unten rund sechzig. Die Grenze ist die
|
||||||
|
# Notbremse, nicht die uebliche Dauer.
|
||||||
|
JALOUSIE_WARTE_SEKUNDEN = 120
|
||||||
|
|
||||||
|
# So lange gilt ein "faehrt nicht" direkt nach dem Absenden als noch
|
||||||
|
# nicht aussagekraeftig: die Box meldet core:MovingState traege, kurz
|
||||||
|
# nach einem Kommando steht dort noch der alte Wert.
|
||||||
|
JALOUSIE_VORLAUF_SEKUNDEN = 8
|
||||||
|
|
||||||
def __init__(self, requests_modul, pin, token, timeout=10, dry_run=False):
|
def __init__(self, requests_modul, pin, token, timeout=10, dry_run=False):
|
||||||
self.requests = requests_modul
|
self.requests = requests_modul
|
||||||
self.pin = pin
|
self.pin = pin
|
||||||
@@ -389,6 +420,7 @@ class TahomaTransport(Transport):
|
|||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.dry_run = dry_run
|
self.dry_run = dry_run
|
||||||
self.states = []
|
self.states = []
|
||||||
|
self.kombigeraete = set()
|
||||||
|
|
||||||
def passt(self, actor_url):
|
def passt(self, actor_url):
|
||||||
return bool(self.pin) and ("://" + self.pin + "/") in actor_url
|
return bool(self.pin) and ("://" + self.pin + "/") in actor_url
|
||||||
@@ -406,6 +438,14 @@ class TahomaTransport(Transport):
|
|||||||
logger.info("Tahoma: %d Messwerte an %d Geraeten",
|
logger.info("Tahoma: %d Messwerte an %d Geraeten",
|
||||||
len(self.states), len({s["actor_url"] for s in self.states}))
|
len(self.states), len({s["actor_url"] for s in self.states}))
|
||||||
|
|
||||||
|
def kombigeraete_setzen(self, urls):
|
||||||
|
"""
|
||||||
|
Welche Geraete Position und Neigung zusammen koennen - vom Runner aus
|
||||||
|
dem Geraetemodell gesetzt, damit der Transport dafuer nicht selbst in
|
||||||
|
die Datenbank greifen muss.
|
||||||
|
"""
|
||||||
|
self.kombigeraete = set(urls)
|
||||||
|
|
||||||
def zustaende_lesen(self):
|
def zustaende_lesen(self):
|
||||||
if not self.token:
|
if not self.token:
|
||||||
return {}
|
return {}
|
||||||
@@ -432,19 +472,106 @@ class TahomaTransport(Transport):
|
|||||||
raise RuntimeError("Kein Tahoma-Token in der config.ini")
|
raise RuntimeError("Kein Tahoma-Token in der config.ini")
|
||||||
# Die Reihenfolge der Parameter ist die aus command_parameters - bei
|
# Die Reihenfolge der Parameter ist die aus command_parameters - bei
|
||||||
# setClosureAndOrientation also erst Position, dann Winkel.
|
# setClosureAndOrientation also erst Position, dann Winkel.
|
||||||
befehl = {"name": aktion["command_url"],
|
befehl = aktion["command_url"]
|
||||||
"parameters": [self._zahl(p["wert"]) for p in aktion["params"]]}
|
parameter = [self._zahl(p["wert"]) for p in aktion["params"]]
|
||||||
rumpf = {"label": "AutoAction",
|
neigung_index = None
|
||||||
"actions": [{"deviceURL": aktion["actor_url"], "commands": [befehl]}]}
|
position_index = None
|
||||||
|
for i, p in enumerate(aktion["params"]):
|
||||||
|
if p["name"] == self.NEIGUNG_PARAMETER:
|
||||||
|
neigung_index = i
|
||||||
|
elif p["name"] == self.POSITION_PARAMETER:
|
||||||
|
position_index = i
|
||||||
|
|
||||||
|
# "Zu" allein macht diese Jalousien nicht dicht: sie faehrt herunter,
|
||||||
|
# die Lamellen bleiben durch die Mechanik aber bei etwa 30 % offen.
|
||||||
|
# Gemeint ist "ganz unten, Lamellen geschlossen" - also dasselbe wie
|
||||||
|
# Position 100 mit Neigung 100, und damit ein Fall fuer die Regel.
|
||||||
|
if befehl == "down" and self._kannKombi(aktion["actor_url"]):
|
||||||
|
befehl = "setClosureAndOrientation"
|
||||||
|
parameter = [100, 100]
|
||||||
|
position_index, neigung_index = 0, 1
|
||||||
|
|
||||||
|
# Kugelschreiber-Mechanik, siehe NEIGUNG_DIREKT_MAX. Geschickt wird
|
||||||
|
# derselbe Befehl zweimal - erst mit Neigung 0, dann mit dem
|
||||||
|
# gewuenschten Wert. Die Position bleibt dabei stehen, die Jalousie
|
||||||
|
# faehrt also nur einmal.
|
||||||
|
umweg = (neigung_index is not None
|
||||||
|
and isinstance(parameter[neigung_index], (int, float))
|
||||||
|
and parameter[neigung_index] > self.NEIGUNG_DIREKT_MAX)
|
||||||
|
vorstufe = list(parameter)
|
||||||
|
if umweg:
|
||||||
|
vorstufe[neigung_index] = 0
|
||||||
|
|
||||||
if self.dry_run:
|
if self.dry_run:
|
||||||
logger.info("[dry-run] Tahoma %s", json.dumps(rumpf, ensure_ascii=False))
|
logger.info("[dry-run] Tahoma %s %s%s", befehl, parameter,
|
||||||
|
" (zuerst %s, dann warten)" % vorstufe if umweg else "")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if umweg:
|
||||||
|
self._apply(aktion["actor_url"], befehl, vorstufe)
|
||||||
|
self._warteAufJalousie(aktion["actor_url"], 0,
|
||||||
|
None if position_index is None else int(parameter[position_index]))
|
||||||
|
self._apply(aktion["actor_url"], befehl, parameter)
|
||||||
|
|
||||||
|
def _kannKombi(self, actor_url):
|
||||||
|
"""
|
||||||
|
Hat das Geraet ein Kommando fuer Position und Neigung zusammen?
|
||||||
|
Nur solche Geraete sind Jalousien mit der Kugelschreiber-Mechanik.
|
||||||
|
"""
|
||||||
|
return actor_url in self.kombigeraete
|
||||||
|
|
||||||
|
def _apply(self, actor_url, name, parameter):
|
||||||
|
"""Ein Kommando an die Box schicken."""
|
||||||
|
rumpf = {"label": "AutoAction",
|
||||||
|
"actions": [{"deviceURL": actor_url,
|
||||||
|
"commands": [{"name": name, "parameters": parameter}]}]}
|
||||||
antwort = self.requests.post(self.basis + "/exec/apply", headers=self._kopf(),
|
antwort = self.requests.post(self.basis + "/exec/apply", headers=self._kopf(),
|
||||||
data=json.dumps(rumpf), timeout=self.timeout, verify=False)
|
data=json.dumps(rumpf), timeout=self.timeout, verify=False)
|
||||||
if antwort.status_code >= 400:
|
if antwort.status_code >= 400:
|
||||||
raise RuntimeError("Tahoma antwortete mit %d: %s"
|
raise RuntimeError("Tahoma antwortete mit %d: %s"
|
||||||
% (antwort.status_code, antwort.text[:120]))
|
% (antwort.status_code, antwort.text[:120]))
|
||||||
|
|
||||||
|
def _warteAufJalousie(self, actor_url, neigung_ziel, schliessung_ziel=None):
|
||||||
|
"""
|
||||||
|
Wartet, bis die Jalousie ihre Fahrt beendet hat und die Ziele zeigt.
|
||||||
|
|
||||||
|
Zwei Auskuenfte zusammen, weil einzeln keine traegt:
|
||||||
|
core:MovingState taugt fuer die lange Fahrt hoch und runter, wird bei
|
||||||
|
kurzen Neigungsfahrten aber nie gesetzt; die Zustandswerte sind die
|
||||||
|
eigentliche Wahrheit, zeigen kurz nach dem Kommando aber noch den
|
||||||
|
alten Stand. Fertig ist die Fahrt, wenn nichts mehr faehrt, die Ziele
|
||||||
|
erreicht sind und entweder ein "faehrt" gesehen wurde oder der
|
||||||
|
Vorlauf um ist.
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
gestartet = False
|
||||||
|
while time.time() - start < self.JALOUSIE_WARTE_SEKUNDEN:
|
||||||
|
time.sleep(2)
|
||||||
|
try:
|
||||||
|
antwort = self.requests.get(
|
||||||
|
self.basis + "/setup/devices/" + quote(actor_url, safe="") + "/states",
|
||||||
|
headers=self._kopf(), timeout=self.timeout, verify=False)
|
||||||
|
z = {x.get("name"): x.get("value") for x in antwort.json()}
|
||||||
|
except Exception as fehler:
|
||||||
|
logger.debug("Zustand nicht lesbar: %s", fehler)
|
||||||
|
continue
|
||||||
|
if z.get("core:MovingState") is True:
|
||||||
|
gestartet = True
|
||||||
|
continue
|
||||||
|
neigung = z.get("core:SlateOrientationState")
|
||||||
|
schliessung = z.get("core:ClosureState")
|
||||||
|
# Ein fehlendes Feld darf nicht als 0 durchgehen - das waere
|
||||||
|
# ausgerechnet beim Ziel 0 ein falsches Erfolgssignal.
|
||||||
|
neigung_ok = neigung is not None and int(neigung) == neigung_ziel
|
||||||
|
schliessung_ok = (schliessung_ziel is None
|
||||||
|
or (schliessung is not None and int(schliessung) == schliessung_ziel))
|
||||||
|
if neigung_ok and schliessung_ok and (
|
||||||
|
gestartet or time.time() - start >= self.JALOUSIE_VORLAUF_SEKUNDEN):
|
||||||
|
return True
|
||||||
|
logger.warning("%s hat Neigung %s%% nicht innerhalb von %d s erreicht",
|
||||||
|
actor_url, neigung_ziel, self.JALOUSIE_WARTE_SEKUNDEN)
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _zahl(wert):
|
def _zahl(wert):
|
||||||
"""Tahoma erwartet Zahlen als Zahlen, Text als Text."""
|
"""Tahoma erwartet Zahlen als Zahlen, Text als Text."""
|
||||||
|
|||||||
+180
-7
@@ -15,6 +15,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once(__DIR__ . "/mysql.php");
|
require_once(__DIR__ . "/mysql.php");
|
||||||
|
require_once(__DIR__ . "/rooms.php");
|
||||||
|
|
||||||
|
// Geraete, die zu keinem Raum der Wohnung gehoeren - Carport, Garage,
|
||||||
|
// Regensensor. Kein Raum in rooms.php, aber auch nicht "unzugeordnet".
|
||||||
|
const RAUM_AUSSEN = "aussen";
|
||||||
|
|
||||||
/** Verbindung zur Geraete- und Automatik-Datenbank. */
|
/** Verbindung zur Geraete- und Automatik-Datenbank. */
|
||||||
function meshDb()
|
function meshDb()
|
||||||
@@ -190,6 +195,162 @@ function operatorLabel($op, $type = "")
|
|||||||
return $op === "!=" ? "≠" : $op;
|
return $op === "!=" ? "≠" : $op;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Technische Zutaten aus einem Geraetenamen nehmen, ohne ihn in der
|
||||||
|
* Datenbank anzufassen.
|
||||||
|
*
|
||||||
|
* Die Discovery vergibt Namen, wie das Geraet sie meldet: "u_Wozi
|
||||||
|
* Terrassentuer" (das u_ stammt aus der Tahoma-Beschriftung),
|
||||||
|
* "Power_EG_EM_0" oder "Handtuchtrockner_OG_Relay_0" (die angehaengte Ziffer
|
||||||
|
* ist der Kanal, und es gibt nur einen). In der Auswahlliste stoert das nur.
|
||||||
|
*
|
||||||
|
* Bewusst nur die einstellige Endung: "go-eCharger_270003" traegt seine
|
||||||
|
* Seriennummer, die bleibt stehen.
|
||||||
|
*/
|
||||||
|
function anzeigeName($name)
|
||||||
|
{
|
||||||
|
$name = preg_replace('/^u_/', '', $name);
|
||||||
|
$name = preg_replace('/_\d$/', '', $name);
|
||||||
|
$name = str_replace('_', ' ', $name);
|
||||||
|
return trim(preg_replace('/\s+/', ' ', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waehlbare Raeume: die Raeume der Home-Ansicht plus "Aussen".
|
||||||
|
*
|
||||||
|
* Einzige Quelle bleibt restricted/rooms.php - ein neuer Raum ist dort eine
|
||||||
|
* Zeile und taucht hier automatisch auf. Der Schluessel ist "Etage/Raum",
|
||||||
|
* weil "Bad" allein auf drei Etagen vorkommt.
|
||||||
|
*/
|
||||||
|
function raumListe()
|
||||||
|
{
|
||||||
|
$liste = [];
|
||||||
|
foreach (allRooms() as $raum) {
|
||||||
|
$liste[] = [
|
||||||
|
"key" => $raum["floor"] . "/" . $raum["mqtt"],
|
||||||
|
"label" => $raum["floor"] . " · " . $raum["mqtt"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$liste[] = ["key" => RAUM_AUSSEN, "label" => "Außen"];
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzeigename eines gespeicherten Raumschluessels. */
|
||||||
|
function raumBeschriftung($key)
|
||||||
|
{
|
||||||
|
if (!$key) {
|
||||||
|
return "Nicht zugeordnet";
|
||||||
|
}
|
||||||
|
foreach (raumListe() as $raum) {
|
||||||
|
if ($raum["key"] === $key) {
|
||||||
|
return $raum["label"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Raum aus rooms.php entfernt - der Eintrag am Geraet bleibt stehen,
|
||||||
|
// damit eine Umbenennung dort keine Zuordnung stillschweigend loescht.
|
||||||
|
return $key . " (unbekannt)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raumvorschlaege aus den MQTT-Topics.
|
||||||
|
*
|
||||||
|
* Die Thermostate tragen ihren Raum im Topic: "Raumtemp/OG/Bad/Temp[degC]".
|
||||||
|
* Das ist keine Rateaufgabe, sondern dieselbe Schreibweise wie in rooms.php,
|
||||||
|
* und deckt dreizehn Geraete ohne Zutun ab. Bei allen anderen wird bewusst
|
||||||
|
* nicht geraten: "Bad Links" sagt nichts darueber, auf welcher der drei
|
||||||
|
* Etagen dieses Bad liegt.
|
||||||
|
*/
|
||||||
|
function raumVorschlaege()
|
||||||
|
{
|
||||||
|
$bekannt = [];
|
||||||
|
foreach (raumListe() as $raum) {
|
||||||
|
$bekannt[strtolower($raum["key"])] = $raum["key"];
|
||||||
|
}
|
||||||
|
$res = meshDb()->query("SELECT actor_id, url FROM actor_states
|
||||||
|
WHERE url LIKE 'Raumtemp/%/%/%'");
|
||||||
|
$vorschlag = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$teile = explode("/", $row["url"]);
|
||||||
|
if (count($teile) < 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = strtolower($teile[1] . "/" . $teile[2]);
|
||||||
|
if (isset($bekannt[$key])) {
|
||||||
|
$vorschlag[intval($row["actor_id"])] = $bekannt[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $vorschlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raum je Geraet, wie gespeichert. */
|
||||||
|
function geraeteRaeume()
|
||||||
|
{
|
||||||
|
$res = meshDb()->query("SELECT id, room FROM actors");
|
||||||
|
$raeume = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$raeume[intval($row["id"])] = $row["room"];
|
||||||
|
}
|
||||||
|
return $raeume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zuordnungen speichern. $zuordnung ist [actor_id => Raumschluessel|""]. */
|
||||||
|
function saveRooms($zuordnung)
|
||||||
|
{
|
||||||
|
$db = meshDb();
|
||||||
|
$erlaubt = array_column(raumListe(), "key");
|
||||||
|
$stmt = $db->prepare("UPDATE actors SET room = ? WHERE id = ?");
|
||||||
|
$anzahl = 0;
|
||||||
|
foreach ($zuordnung as $actorId => $key) {
|
||||||
|
$actorId = intval($actorId);
|
||||||
|
$key = strval($key);
|
||||||
|
if ($actorId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($key !== "" && !in_array($key, $erlaubt, true)) {
|
||||||
|
throw new InvalidArgumentException("Unbekannter Raum: " . $key);
|
||||||
|
}
|
||||||
|
$wert = $key === "" ? null : $key;
|
||||||
|
$stmt->bind_param("si", $wert, $actorId);
|
||||||
|
$stmt->execute();
|
||||||
|
$anzahl++;
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
return $anzahl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* id => Beschriftung fuer alle Geraete, geputzt und eindeutig.
|
||||||
|
*
|
||||||
|
* Eine Stelle fuer Editor und Uebersicht, damit in der Auswahlliste nicht
|
||||||
|
* "Wozi Terrassentuer" steht und im Satz darunter "u_Wozi Terrassentuer".
|
||||||
|
*/
|
||||||
|
function geraeteBeschriftungen()
|
||||||
|
{
|
||||||
|
static $labels = null;
|
||||||
|
if ($labels !== null) {
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
$res = meshDb()->query("SELECT id, name, type FROM actors");
|
||||||
|
$zeilen = [];
|
||||||
|
$haeufigkeit = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$row["label"] = anzeigeName($row["name"]);
|
||||||
|
$haeufigkeit[$row["label"]] = ($haeufigkeit[$row["label"]] ?? 0) + 1;
|
||||||
|
$zeilen[] = $row;
|
||||||
|
}
|
||||||
|
// Die Tahoma-Box vergibt Beschriftungen doppelt: "Terasse" ist einmal der
|
||||||
|
// Rollladen und einmal der Sonnenenergie-Sensor. Untereinander in der
|
||||||
|
// Liste waeren sie nicht auseinanderzuhalten, deshalb kommt bei
|
||||||
|
// Doppelungen die Geraeteart dazu.
|
||||||
|
$labels = [];
|
||||||
|
foreach ($zeilen as $row) {
|
||||||
|
$labels[intval($row["id"])] = $haeufigkeit[$row["label"]] > 1
|
||||||
|
? $row["label"] . " (" . $row["type"] . ")"
|
||||||
|
: $row["label"];
|
||||||
|
}
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle Geraete mit ihren Messwerten und Kommandos in einem Rutsch.
|
* Alle Geraete mit ihren Messwerten und Kommandos in einem Rutsch.
|
||||||
*
|
*
|
||||||
@@ -202,13 +363,24 @@ function deviceCatalog()
|
|||||||
$db = meshDb();
|
$db = meshDb();
|
||||||
$devices = [];
|
$devices = [];
|
||||||
|
|
||||||
$res = $db->query("SELECT id, name, type, url FROM actors ORDER BY name");
|
$labels = geraeteBeschriftungen();
|
||||||
|
$res = $db->query("SELECT id, name, type, url, room FROM actors");
|
||||||
|
$zeilen = [];
|
||||||
while ($row = $res->fetch_assoc()) {
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$zeilen[] = $row;
|
||||||
|
}
|
||||||
|
usort($zeilen, function ($a, $b) use ($labels) {
|
||||||
|
return strcoll($labels[intval($a["id"])], $labels[intval($b["id"])]);
|
||||||
|
});
|
||||||
|
foreach ($zeilen as $row) {
|
||||||
$devices[intval($row["id"])] = [
|
$devices[intval($row["id"])] = [
|
||||||
"id" => intval($row["id"]),
|
"id" => intval($row["id"]),
|
||||||
"name" => $row["name"],
|
"name" => $row["name"],
|
||||||
|
"label" => $labels[intval($row["id"])],
|
||||||
"type" => $row["type"],
|
"type" => $row["type"],
|
||||||
"url" => $row["url"],
|
"url" => $row["url"],
|
||||||
|
"room" => $row["room"],
|
||||||
|
"roomLabel" => raumBeschriftung($row["room"]),
|
||||||
"states" => [],
|
"states" => [],
|
||||||
"commands" => [],
|
"commands" => [],
|
||||||
];
|
];
|
||||||
@@ -614,14 +786,14 @@ function listAutomations($floor)
|
|||||||
function stateNames()
|
function stateNames()
|
||||||
{
|
{
|
||||||
$db = meshDb();
|
$db = meshDb();
|
||||||
$res = $db->query("SELECT s.id, s.state_name, s.unit, a.name AS actor_name, t.type
|
$labels = geraeteBeschriftungen();
|
||||||
|
$res = $db->query("SELECT s.id, s.state_name, s.unit, s.actor_id, t.type
|
||||||
FROM actor_states s
|
FROM actor_states s
|
||||||
JOIN actors a ON a.id = s.actor_id
|
|
||||||
LEFT JOIN state_types t ON s.state_type = t.id");
|
LEFT JOIN state_types t ON s.state_type = t.id");
|
||||||
$names = [];
|
$names = [];
|
||||||
while ($row = $res->fetch_assoc()) {
|
while ($row = $res->fetch_assoc()) {
|
||||||
$names[intval($row["id"])] = [
|
$names[intval($row["id"])] = [
|
||||||
"text" => $row["actor_name"] . ": " . $row["state_name"],
|
"text" => ($labels[intval($row["actor_id"])] ?? "?") . ": " . $row["state_name"],
|
||||||
"unit" => $row["unit"],
|
"unit" => $row["unit"],
|
||||||
"type" => $row["type"] ?: "string",
|
"type" => $row["type"] ?: "string",
|
||||||
];
|
];
|
||||||
@@ -652,11 +824,12 @@ function parameterLabels()
|
|||||||
function commandNames()
|
function commandNames()
|
||||||
{
|
{
|
||||||
$db = meshDb();
|
$db = meshDb();
|
||||||
$res = $db->query("SELECT c.id, c.command_name, a.name AS actor_name
|
$labels = geraeteBeschriftungen();
|
||||||
FROM actor_commands c JOIN actors a ON a.id = c.actor_id");
|
$res = $db->query("SELECT id, command_name, actor_id FROM actor_commands");
|
||||||
$names = [];
|
$names = [];
|
||||||
while ($row = $res->fetch_assoc()) {
|
while ($row = $res->fetch_assoc()) {
|
||||||
$names[intval($row["id"])] = $row["actor_name"] . ": " . $row["command_name"];
|
$names[intval($row["id"])] = ($labels[intval($row["actor_id"])] ?? "?")
|
||||||
|
. ": " . $row["command_name"];
|
||||||
}
|
}
|
||||||
return $names;
|
return $names;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Ein Kommando aus `actor_commands` ausfuehren.
|
||||||
|
*
|
||||||
|
* Das Gegenstueck zu restricted/autoActions/transports.py, nur fuer den
|
||||||
|
* Browser: dort schickt der Runner die Kommandos einer Automatik, hier
|
||||||
|
* schickt sie der Benutzer aus dem Raum-Modal. Die Zuordnung "welche URL
|
||||||
|
* gehoert zu welchem Weg" ist dieselbe und steht in beiden Dateien am selben
|
||||||
|
* Ort beschrieben:
|
||||||
|
*
|
||||||
|
* mqtt://... Nutzlast auf das Topic des Parameters
|
||||||
|
* wled://... JSON-Vorlage mit Platzhaltern an /json/state
|
||||||
|
* http://... Abfrageargumente an die Geraete-URL
|
||||||
|
* Tahoma exec/apply an die Box, erkannt an der Box-Kennung
|
||||||
|
*
|
||||||
|
* Zwei Umsetzungen derselben Sache sind nicht schoen. Sie in eine zu legen
|
||||||
|
* hiesse aber, entweder den Runner um eine HTTP-Schnittstelle zu erweitern
|
||||||
|
* (dann haengt die Bedienung daran, dass er laeuft) oder das Web-UI Python
|
||||||
|
* aufrufen zu lassen. Beides waere teurer als diese Datei.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once(__DIR__ . "/mysql.php");
|
||||||
|
require_once(__DIR__ . "/tahoma_EG.php");
|
||||||
|
require_once(__DIR__ . "/../ajax/phpMQTT.php");
|
||||||
|
|
||||||
|
const MQTT_BROKER = "localhost";
|
||||||
|
const MQTT_PORT = 1883;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bis zu dieser Neigung fahren die Aussenjalousien direkt.
|
||||||
|
*
|
||||||
|
* Sie haben eine Kugelschreiber-Mechanik: beim Herunterfahren stehen die
|
||||||
|
* Lamellen bei etwa 30 %. In Richtung 0 % laesst sich von jeder Stellung aus
|
||||||
|
* direkt neigen; ueber 30 % hinaus muss die Mechanik erst einmal auf 0 %
|
||||||
|
* zurueck, sonst rastet sie nicht um. Ein "Neigung 80 %" ohne diesen Umweg
|
||||||
|
* bleibt wirkungslos.
|
||||||
|
*
|
||||||
|
* Das gilt fuer jedes Kommando, das die Neigung setzt - "Neigung" ebenso wie
|
||||||
|
* "Position+Neigung". Erkannt wird es deshalb am Parameter und nicht am
|
||||||
|
* Kommandonamen: beide heissen ihren Neigungsparameter "Neigung".
|
||||||
|
*
|
||||||
|
* Dasselbe steht in restricted/autoActions/transports.py - beide Versender
|
||||||
|
* brauchen es, und beide muessen es wissen.
|
||||||
|
*/
|
||||||
|
const NEIGUNG_DIREKT_MAX = 30;
|
||||||
|
|
||||||
|
/** So heissen die beiden Parameter einer Jalousie im Geraetemodell. */
|
||||||
|
const NEIGUNG_PARAMETER = "Neigung";
|
||||||
|
const POSITION_PARAMETER = "Position";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* So lange wird hoechstens auf das Ende einer Fahrt gewartet.
|
||||||
|
*
|
||||||
|
* Gemessen: eine Neigung von 100 % auf 0 % dauert gut fuenfzehn Sekunden,
|
||||||
|
* eine volle Fahrt von oben nach unten rund sechzig. Die Grenze ist die
|
||||||
|
* Notbremse, nicht die uebliche Dauer.
|
||||||
|
*/
|
||||||
|
const JALOUSIE_WARTE_SEKUNDEN = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* So lange gilt ein "faehrt nicht" direkt nach dem Absenden als noch nicht
|
||||||
|
* aussagekraeftig.
|
||||||
|
*
|
||||||
|
* Die Box meldet core:MovingState traege: kurz nach einem Kommando steht
|
||||||
|
* dort noch der alte Wert. Ohne diesen Vorlauf haelt die Warteschleife die
|
||||||
|
* Fahrt fuer beendet, bevor sie begonnen hat - und das naechste Kommando
|
||||||
|
* schneidet sie ab.
|
||||||
|
*/
|
||||||
|
const JALOUSIE_VORLAUF_SEKUNDEN = 8;
|
||||||
|
|
||||||
|
/** Verbindung zur Geraetedatenbank. */
|
||||||
|
function commandDb()
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kommando mit Geraet und Parametern laden. */
|
||||||
|
function loadCommand($commandId)
|
||||||
|
{
|
||||||
|
$db = commandDb();
|
||||||
|
$stmt = $db->prepare("SELECT c.id, c.command_name, c.command_url,
|
||||||
|
a.name AS actor_name, a.url AS actor_url
|
||||||
|
FROM actor_commands c JOIN actors a ON a.id = c.actor_id
|
||||||
|
WHERE c.id = ?");
|
||||||
|
$stmt->bind_param("i", $commandId);
|
||||||
|
$stmt->execute();
|
||||||
|
$cmd = $stmt->get_result()->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
if (!$cmd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$stmt = $db->prepare("SELECT id, parameter_name, url FROM command_parameters
|
||||||
|
WHERE command_id = ? ORDER BY id");
|
||||||
|
$stmt->bind_param("i", $commandId);
|
||||||
|
$stmt->execute();
|
||||||
|
$res = $stmt->get_result();
|
||||||
|
$cmd["params"] = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$cmd["params"][] = $row;
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
return $cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kommando ausfuehren. $werte ist [parameter_id => Wert].
|
||||||
|
*
|
||||||
|
* Mit $probe = true wird nichts geschickt, nur beschrieben, was geschickt
|
||||||
|
* wuerde - zum Nachsehen, ohne einen Rollladen in Bewegung zu setzen.
|
||||||
|
*
|
||||||
|
* Zurueck kommt ein Text, der den Vorgang beschreibt.
|
||||||
|
*/
|
||||||
|
function executeCommand($commandId, $werte, $probe = false)
|
||||||
|
{
|
||||||
|
$cmd = loadCommand($commandId);
|
||||||
|
if (!$cmd) {
|
||||||
|
throw new InvalidArgumentException("Kommando " . $commandId . " gibt es nicht.");
|
||||||
|
}
|
||||||
|
$url = $cmd["actor_url"];
|
||||||
|
|
||||||
|
if (strpos($url, "mqtt://") === 0) {
|
||||||
|
return sendeMqtt($cmd, $werte, $probe);
|
||||||
|
}
|
||||||
|
if (strpos($url, "wled://") === 0) {
|
||||||
|
return sendeWled($cmd, $werte, $probe);
|
||||||
|
}
|
||||||
|
if ($GLOBALS["tahoma_PIN"] && strpos($url, "://" . $GLOBALS["tahoma_PIN"] . "/") !== false) {
|
||||||
|
return sendeTahoma($cmd, $werte, $probe);
|
||||||
|
}
|
||||||
|
if (strpos($url, "http") === 0) {
|
||||||
|
return sendeHttp($cmd, $werte, $probe);
|
||||||
|
}
|
||||||
|
throw new RuntimeException("Für " . $cmd["actor_name"] . " gibt es keinen Weg zum Gerät.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Werte in der Reihenfolge der Parameter, fehlende als leer. */
|
||||||
|
function parameterWerte($cmd, $werte)
|
||||||
|
{
|
||||||
|
$liste = [];
|
||||||
|
foreach ($cmd["params"] as $p) {
|
||||||
|
$liste[] = [
|
||||||
|
"name" => $p["parameter_name"],
|
||||||
|
"url" => $p["url"],
|
||||||
|
"wert" => strval($werte[$p["id"]] ?? $werte[strval($p["id"])] ?? ""),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MQTT: je Parameter eine Nachricht auf dessen Topic. */
|
||||||
|
function sendeMqtt($cmd, $werte, $probe)
|
||||||
|
{
|
||||||
|
$params = parameterWerte($cmd, $werte);
|
||||||
|
$nachrichten = [];
|
||||||
|
if (!$params) {
|
||||||
|
// Kommando ohne Parameter: das Kommando selbst ist die Nutzlast.
|
||||||
|
$nachrichten[] = [$cmd["command_url"], ""];
|
||||||
|
} else {
|
||||||
|
foreach ($params as $p) {
|
||||||
|
$nachrichten[] = [$p["url"] ?: $cmd["command_url"], $p["wert"]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($nachrichten as $n) {
|
||||||
|
if (!$n[0]) {
|
||||||
|
throw new RuntimeException("Kommando ohne Topic.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($probe) {
|
||||||
|
return "MQTT: " . implode(", ", array_map(function ($n) {
|
||||||
|
return $n[0] . " <- " . $n[1];
|
||||||
|
}, $nachrichten));
|
||||||
|
}
|
||||||
|
$mqtt = new Bluerhinos\phpMQTT(MQTT_BROKER, MQTT_PORT, "RoomControl_" . getmypid());
|
||||||
|
if (!$mqtt->connect(true, null, "", "")) {
|
||||||
|
throw new RuntimeException("Keine Verbindung zum MQTT-Broker.");
|
||||||
|
}
|
||||||
|
foreach ($nachrichten as $n) {
|
||||||
|
$mqtt->publish($n[0], $n[1], 0, false);
|
||||||
|
}
|
||||||
|
$mqtt->close();
|
||||||
|
return $cmd["actor_name"] . ": " . $cmd["command_name"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WLED: die JSON-Vorlage aus command_url fuellen und am Stueck schicken. */
|
||||||
|
function sendeWled($cmd, $werte, $probe)
|
||||||
|
{
|
||||||
|
$vorlage = $cmd["command_url"];
|
||||||
|
foreach (parameterWerte($cmd, $werte) as $p) {
|
||||||
|
$vorlage = str_replace("%" . $p["name"] . "%", $p["wert"], $vorlage);
|
||||||
|
}
|
||||||
|
$rumpf = json_decode($vorlage, true);
|
||||||
|
if ($rumpf === null) {
|
||||||
|
throw new RuntimeException("WLED-Vorlage ergibt kein gültiges JSON: " . $vorlage);
|
||||||
|
}
|
||||||
|
$ziel = "http://" . rtrim(substr($cmd["actor_url"], strlen("wled://")), "/") . "/json/state";
|
||||||
|
if ($probe) {
|
||||||
|
return "WLED: " . $ziel . " <- " . json_encode($rumpf, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
$ch = curl_init($ziel);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($rumpf));
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($code >= 400 || $code === 0) {
|
||||||
|
throw new RuntimeException("WLED antwortete mit " . $code);
|
||||||
|
}
|
||||||
|
return $cmd["actor_name"] . ": " . $cmd["command_name"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP: command_url und Parameter als Abfrageargumente anhaengen. */
|
||||||
|
function sendeHttp($cmd, $werte, $probe)
|
||||||
|
{
|
||||||
|
$argumente = [];
|
||||||
|
foreach (explode("&", strval($cmd["command_url"])) as $teil) {
|
||||||
|
if (strpos($teil, "=") !== false) {
|
||||||
|
list($k, $v) = explode("=", $teil, 2);
|
||||||
|
$argumente[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (parameterWerte($cmd, $werte) as $p) {
|
||||||
|
if ($p["url"]) {
|
||||||
|
$argumente[$p["url"]] = $p["wert"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$argumente) {
|
||||||
|
throw new RuntimeException("Kommando ohne URL und ohne Parameter.");
|
||||||
|
}
|
||||||
|
$ziel = $cmd["actor_url"] . (strpos($cmd["actor_url"], "?") === false ? "?" : "&")
|
||||||
|
. http_build_query($argumente);
|
||||||
|
if ($probe) {
|
||||||
|
return "HTTP: " . $ziel;
|
||||||
|
}
|
||||||
|
$ch = curl_init($ziel);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($code >= 400 || $code === 0) {
|
||||||
|
throw new RuntimeException("Gerät antwortete mit " . $code);
|
||||||
|
}
|
||||||
|
return $cmd["actor_name"] . ": " . $cmd["command_name"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Kommando an die Tahoma-Box schicken. */
|
||||||
|
function tahomaKommando($deviceUrl, $name, $parameter)
|
||||||
|
{
|
||||||
|
$rumpf = [
|
||||||
|
"label" => "Raumbedienung",
|
||||||
|
"actions" => [[
|
||||||
|
"deviceURL" => $deviceUrl,
|
||||||
|
"commands" => [["name" => $name, "parameters" => $parameter]],
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
$ch = curl_init("https://gateway-" . $GLOBALS["tahoma_PIN"]
|
||||||
|
. ":8443/enduser-mobile-web/1/enduserAPI/exec/apply");
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json",
|
||||||
|
"Authorization: Bearer " . $GLOBALS["tahoma_token"]]);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($rumpf));
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||||
|
$antwort = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($code >= 400 || $code === 0) {
|
||||||
|
throw new RuntimeException("Tahoma antwortete mit " . $code . ": " . substr(strval($antwort), 0, 120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen Zustandswert eines Tahoma-Geraets lesen, null wenn unbekannt. */
|
||||||
|
function tahomaZustand($deviceUrl, $feld)
|
||||||
|
{
|
||||||
|
$ch = curl_init("https://gateway-" . $GLOBALS["tahoma_PIN"]
|
||||||
|
. ":8443/enduser-mobile-web/1/enduserAPI/setup/devices/"
|
||||||
|
. rawurlencode($deviceUrl) . "/states");
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . $GLOBALS["tahoma_token"]]);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||||
|
$antwort = json_decode(curl_exec($ch), true);
|
||||||
|
curl_close($ch);
|
||||||
|
if (!is_array($antwort)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach ($antwort as $zustand) {
|
||||||
|
if (($zustand["name"] ?? "") === $feld) {
|
||||||
|
return $zustand["value"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wartet, bis die Jalousie ihre Fahrt beendet hat und die Zielwerte zeigt.
|
||||||
|
*
|
||||||
|
* Zwei Auskuenfte zusammen, weil einzeln keine traegt:
|
||||||
|
*
|
||||||
|
* core:MovingState taugt fuer die lange Fahrt hoch und runter, wird
|
||||||
|
* bei kurzen Neigungsfahrten aber nie gesetzt.
|
||||||
|
* die Zustandswerte sind die eigentliche Wahrheit, brauchen aber
|
||||||
|
* einen Vorlauf, weil sie kurz nach dem Kommando
|
||||||
|
* noch den alten Stand zeigen.
|
||||||
|
*
|
||||||
|
* Fertig ist die Fahrt, wenn nichts mehr faehrt, die Ziele erreicht sind und
|
||||||
|
* entweder ein "faehrt" gesehen wurde oder der Vorlauf um ist.
|
||||||
|
*
|
||||||
|
* $schliessungZiel darf null sein, wenn das Kommando die Position nicht
|
||||||
|
* anfasst.
|
||||||
|
*/
|
||||||
|
function warteAufJalousie($deviceUrl, $neigungZiel, $schliessungZiel = null)
|
||||||
|
{
|
||||||
|
$start = time();
|
||||||
|
$gestartet = false;
|
||||||
|
while (time() - $start < JALOUSIE_WARTE_SEKUNDEN) {
|
||||||
|
sleep(2);
|
||||||
|
$faehrt = tahomaZustand($deviceUrl, "core:MovingState");
|
||||||
|
$neigung = tahomaZustand($deviceUrl, "core:SlateOrientationState");
|
||||||
|
$schliessung = tahomaZustand($deviceUrl, "core:ClosureState");
|
||||||
|
if ($faehrt === true) {
|
||||||
|
$gestartet = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// null heisst "nicht gelesen". intval() daraus waere 0 - und damit
|
||||||
|
// ausgerechnet beim Ziel 0 ein falsches Erfolgssignal.
|
||||||
|
$neigungOk = $neigung !== null && intval($neigung) === $neigungZiel;
|
||||||
|
$schliessungOk = $schliessungZiel === null
|
||||||
|
|| ($schliessung !== null && intval($schliessung) === $schliessungZiel);
|
||||||
|
if ($neigungOk && $schliessungOk
|
||||||
|
&& ($gestartet || time() - $start >= JALOUSIE_VORLAUF_SEKUNDEN)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hat das Geraet ein Kommando, das Position und Neigung zusammen setzt?
|
||||||
|
* Nur solche Geraete sind Jalousien mit der Kugelschreiber-Mechanik.
|
||||||
|
*/
|
||||||
|
function jalousieKombiKommando($actorUrl)
|
||||||
|
{
|
||||||
|
$db = commandDb();
|
||||||
|
$stmt = $db->prepare("SELECT c.command_url FROM actor_commands c
|
||||||
|
JOIN actors a ON a.id = c.actor_id
|
||||||
|
WHERE a.url = ? AND c.command_url = 'setClosureAndOrientation'");
|
||||||
|
$stmt->bind_param("s", $actorUrl);
|
||||||
|
$stmt->execute();
|
||||||
|
$treffer = $stmt->get_result()->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
return $treffer ? $treffer["command_url"] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tahoma: exec/apply an die Box - dieselbe Form wie in ajax/tahoma.php, nur
|
||||||
|
* ohne dessen Sonderbehandlung fuer "faehrt gerade".
|
||||||
|
*/
|
||||||
|
function sendeTahoma($cmd, $werte, $probe)
|
||||||
|
{
|
||||||
|
$befehl = $cmd["command_url"];
|
||||||
|
$parameter = [];
|
||||||
|
$neigungIndex = null;
|
||||||
|
$positionIndex = null;
|
||||||
|
foreach (parameterWerte($cmd, $werte) as $i => $p) {
|
||||||
|
// Die Box erwartet Zahlen als Zahlen, Text als Text.
|
||||||
|
$parameter[] = is_numeric($p["wert"]) ? $p["wert"] + 0 : $p["wert"];
|
||||||
|
if ($p["name"] === NEIGUNG_PARAMETER) {
|
||||||
|
$neigungIndex = $i;
|
||||||
|
} elseif ($p["name"] === POSITION_PARAMETER) {
|
||||||
|
$positionIndex = $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Zu" allein macht diese Jalousien nicht dicht: sie faehrt herunter, die
|
||||||
|
// Lamellen bleiben durch die Mechanik aber bei etwa 30 % offen stehen.
|
||||||
|
// Gemeint ist "ganz unten, Lamellen geschlossen" - also dasselbe wie
|
||||||
|
// Position 100 mit Neigung 100, und damit ein Fall fuer die Regel unten.
|
||||||
|
if ($befehl === "down" && jalousieKombiKommando($cmd["actor_url"])) {
|
||||||
|
$befehl = "setClosureAndOrientation";
|
||||||
|
$parameter = [100, 100];
|
||||||
|
$positionIndex = 0;
|
||||||
|
$neigungIndex = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kugelschreiber-Mechanik: ueber NEIGUNG_DIREKT_MAX hinaus rastet sie nur
|
||||||
|
// um, wenn sie vorher einmal auf 0 % war. Geschickt wird deshalb derselbe
|
||||||
|
// Befehl zweimal - erst mit Neigung 0, dann mit dem gewuenschten Wert.
|
||||||
|
// Die Position bleibt dabei stehen, die Jalousie faehrt also nur einmal.
|
||||||
|
$umweg = $neigungIndex !== null
|
||||||
|
&& is_numeric($parameter[$neigungIndex])
|
||||||
|
&& $parameter[$neigungIndex] > NEIGUNG_DIREKT_MAX;
|
||||||
|
|
||||||
|
$vorstufe = $parameter;
|
||||||
|
if ($umweg) {
|
||||||
|
$vorstufe[$neigungIndex] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($probe) {
|
||||||
|
$text = "Tahoma: " . $befehl . " " . json_encode($parameter);
|
||||||
|
return $umweg
|
||||||
|
? $text . " (zuerst " . json_encode($vorstufe) . ", dann warten)"
|
||||||
|
: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($umweg) {
|
||||||
|
tahomaKommando($cmd["actor_url"], $befehl, $vorstufe);
|
||||||
|
warteAufJalousie($cmd["actor_url"], 0,
|
||||||
|
$positionIndex === null ? null : intval($parameter[$positionIndex]));
|
||||||
|
}
|
||||||
|
tahomaKommando($cmd["actor_url"], $befehl, $parameter);
|
||||||
|
return $cmd["actor_name"] . ": " . $cmd["command_name"]
|
||||||
|
. ($umweg ? " (über Neigung 0 gefahren)" : "");
|
||||||
|
}
|
||||||
+7
-1
@@ -133,6 +133,12 @@
|
|||||||
<div class="card-header pb-0 pt-1">
|
<div class="card-header pb-0 pt-1">
|
||||||
<h3 class="card-title">Automatismen</h3>
|
<h3 class="card-title">Automatismen</h3>
|
||||||
<div class="card-tools">
|
<div class="card-tools">
|
||||||
|
<!-- Zuordnung Geraet -> Raum: sie ordnet die Geraeteliste im
|
||||||
|
Editor nach Raeumen statt nach Geraeteart. -->
|
||||||
|
<button type="button" class="btn btn-tool" title="Geräte den Räumen zuordnen"
|
||||||
|
onclick="openRoomModal()">
|
||||||
|
<i class="bi bi-house-gear"></i>
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -193,7 +199,7 @@
|
|||||||
<script>
|
<script>
|
||||||
// Raumtabelle aus restricted/rooms.php - homeMQTT.js aktualisiert darueber
|
// Raumtabelle aus restricted/rooms.php - homeMQTT.js aktualisiert darueber
|
||||||
// die SVG-Kacheln, ohne die Raeume noch einmal aufzufuehren.
|
// die SVG-Kacheln, ohne die Raeume noch einmal aufzufuehren.
|
||||||
const homeRooms = <?= json_encode($rooms) ?>;
|
const homeRooms = <?= json_encode(roomsWithTile()) ?>;
|
||||||
<?php if ($_GET["floor"] != "OG" && in_array($_GET["floor"], $floors)): ?>
|
<?php if ($_GET["floor"] != "OG" && in_array($_GET["floor"], $floors)): ?>
|
||||||
document.addEventListener('readystatechange', function () {
|
document.addEventListener('readystatechange', function () {
|
||||||
if (event.target.readyState === 'complete') {
|
if (event.target.readyState === 'complete') {
|
||||||
|
|||||||
+33
-3
@@ -10,7 +10,14 @@
|
|||||||
* floor Etage; bestimmt zugleich den Zweig Raumtemp/<floor>/
|
* floor Etage; bestimmt zugleich den Zweig Raumtemp/<floor>/
|
||||||
* mqtt Name des Raums im MQTT-Baum: Raumtemp/<floor>/<mqtt>/...
|
* mqtt Name des Raums im MQTT-Baum: Raumtemp/<floor>/<mqtt>/...
|
||||||
* id Kürzel in den SVG-Element-IDs: <floor>_tmp_<id>, <floor>_<id>_heater
|
* id Kürzel in den SVG-Element-IDs: <floor>_tmp_<id>, <floor>_<id>_heater
|
||||||
* x, y Position der Kachel im SVG (viewBox 400x300)
|
* x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen
|
||||||
|
* ohne Kachel
|
||||||
|
*
|
||||||
|
* Ein Raum ohne x/y ist trotzdem ein Raum: Geräte lassen sich ihm zuordnen
|
||||||
|
* (siehe die Spalte actors.room und den Zuordnungsdialog in der Karte
|
||||||
|
* "Automatismen"), er erscheint nur nicht als Kachel im Grundriss. Das
|
||||||
|
* betrifft Räume ohne Thermostat - die Küche im EG, die Technikräume -,
|
||||||
|
* deren Kachel eine Temperatur anzeigen würde, die niemand misst.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Reihenfolge im Menü und in der Anzeige; OG ist die Standardetage.
|
// Reihenfolge im Menü und in der Anzeige; OG ist die Standardetage.
|
||||||
@@ -27,15 +34,38 @@ $rooms = [
|
|||||||
["floor" => "EG", "mqtt" => "Schlafen", "id" => "schlafen", "x" => 235, "y" => 80],
|
["floor" => "EG", "mqtt" => "Schlafen", "id" => "schlafen", "x" => 235, "y" => 80],
|
||||||
["floor" => "EG", "mqtt" => "Bad", "id" => "bad", "x" => 253, "y" => 130],
|
["floor" => "EG", "mqtt" => "Bad", "id" => "bad", "x" => 253, "y" => 130],
|
||||||
["floor" => "EG", "mqtt" => "Magdalena", "id" => "buero", "x" => 258, "y" => 180],
|
["floor" => "EG", "mqtt" => "Magdalena", "id" => "buero", "x" => 258, "y" => 180],
|
||||||
|
["floor" => "EG", "mqtt" => "Kueche", "id" => "kueche"],
|
||||||
|
["floor" => "EG", "mqtt" => "Technik", "id" => "technik"],
|
||||||
["floor" => "UG", "mqtt" => "Buero", "id" => "buero", "x" => 140, "y" => 140],
|
["floor" => "UG", "mqtt" => "Buero", "id" => "buero", "x" => 140, "y" => 140],
|
||||||
["floor" => "UG", "mqtt" => "Bad", "id" => "bad", "x" => 210, "y" => 80],
|
["floor" => "UG", "mqtt" => "Bad", "id" => "bad", "x" => 210, "y" => 80],
|
||||||
["floor" => "UG", "mqtt" => "Kueche", "id" => "kueche", "x" => 190, "y" => 195],
|
["floor" => "UG", "mqtt" => "Kueche", "id" => "kueche", "x" => 190, "y" => 195],
|
||||||
|
["floor" => "UG", "mqtt" => "Technik", "id" => "technik"],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Räume einer Etage, in der Reihenfolge der Tabelle. */
|
/**
|
||||||
|
* Räume einer Etage, die eine Kachel im Grundriss haben.
|
||||||
|
*
|
||||||
|
* Alles, was zeichnet oder aktualisiert, geht hierüber - ein Raum ohne
|
||||||
|
* Position hätte keine Stelle im SVG und keine Elemente, die sich füllen
|
||||||
|
* liessen.
|
||||||
|
*/
|
||||||
function roomsOnFloor($floor)
|
function roomsOnFloor($floor)
|
||||||
{
|
{
|
||||||
return array_values(array_filter($GLOBALS["rooms"], function ($room) use ($floor) {
|
return array_values(array_filter($GLOBALS["rooms"], function ($room) use ($floor) {
|
||||||
return $room["floor"] == $floor;
|
return $room["floor"] == $floor && isset($room["x"]);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Räume, auch die ohne Kachel - für die Geräte-Zuordnung. */
|
||||||
|
function allRooms()
|
||||||
|
{
|
||||||
|
return $GLOBALS["rooms"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur die Räume mit Kachel, für die Aktualisierungsschleife im Browser. */
|
||||||
|
function roomsWithTile()
|
||||||
|
{
|
||||||
|
return array_values(array_filter($GLOBALS["rooms"], function ($room) {
|
||||||
|
return isset($room["x"]);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user