Compare commits
8
Commits
9f8c9016c2
...
131d7cc46d
| 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&floor=OG Editor fuer eine neue Automatik
|
||||
* 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=delete {"id": N}
|
||||
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
||||
* POST ?action=saveRooms {"<actor_id>": "OG/Bad", ...}
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
@@ -49,6 +51,8 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
jsonAntwort(["ok" => true]);
|
||||
case "toggle":
|
||||
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
||||
case "saveRooms":
|
||||
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
|
||||
default:
|
||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
}
|
||||
@@ -126,6 +130,73 @@ if ($action === "list") {
|
||||
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 --------------------------------------------------------------
|
||||
$id = intval($_GET["id"] ?? 0);
|
||||
$auto = $id > 0 ? loadAutomation($id) : null;
|
||||
@@ -141,6 +212,9 @@ $nutzdaten = json_encode([
|
||||
"automation" => $auto,
|
||||
"devices" => deviceCatalog(),
|
||||
"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);
|
||||
|
||||
?>
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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,
|
||||
`type` varchar(50) NOT NULL COMMENT 'Gerätetyp z.B. RollerShutter',
|
||||
`name` varchar(70) NOT NULL COMMENT 'Name des Geräts',
|
||||
`url` varchar(100) NOT NULL COMMENT 'Tahoma Device URL'
|
||||
`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;
|
||||
|
||||
--
|
||||
|
||||
+278
-60
@@ -20,6 +20,7 @@
|
||||
let autoModel = null;
|
||||
let autoDevices = [];
|
||||
let autoLockouts = [];
|
||||
let autoRooms = [];
|
||||
|
||||
// --- Katalog-Helfer ------------------------------------------------------
|
||||
|
||||
@@ -48,28 +49,221 @@ function operatorLabel(state, op) {
|
||||
return treffer ? treffer.label : op;
|
||||
}
|
||||
|
||||
/** Erster Messwert des ersten Geraets - Vorbelegung fuer eine neue Zeile. */
|
||||
function ersterMesswert() {
|
||||
const dev = autoDevices.find(d => d.states.length > 0);
|
||||
return dev ? dev.states[0] : null;
|
||||
/**
|
||||
* Eine neue Zeile beginnt ohne Geraet.
|
||||
*
|
||||
* 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() {
|
||||
const dev = autoDevices.find(d => d.commands.length > 0);
|
||||
return dev ? dev.commands[0] : null;
|
||||
function leereAktion() {
|
||||
return { command_id: 0, params: {} };
|
||||
}
|
||||
|
||||
// --- Bausteine fuer die Oberflaeche --------------------------------------
|
||||
|
||||
function optionListe(select, eintraege, gewaehlt) {
|
||||
select.innerHTML = "";
|
||||
eintraege.forEach(e => {
|
||||
eintraege.forEach(e => select.appendChild(option(e, gewaehlt)));
|
||||
}
|
||||
|
||||
function option(e, gewaehlt) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = e.value;
|
||||
opt.text = e.text;
|
||||
if (String(e.value) === String(gewaehlt)) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
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.className = "btn btn-outline-success btn-sm mt-2";
|
||||
add.innerHTML = "<i class='bi bi-plus-lg'></i> Bedingung";
|
||||
add.onclick = () => {
|
||||
const state = ersterMesswert();
|
||||
if (!state) return;
|
||||
gruppe.push({ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) });
|
||||
renderConditions();
|
||||
};
|
||||
add.onclick = () => { gruppe.push(leereBedingung()); renderConditions(); };
|
||||
block.appendChild(add);
|
||||
|
||||
ziel.appendChild(block);
|
||||
@@ -167,32 +356,26 @@ function renderConditions() {
|
||||
|
||||
function conditionRow(gi, bi, bed) {
|
||||
const zeile = document.createElement("div");
|
||||
zeile.className = "input-group";
|
||||
zeile.className = "input-group auto-zeile";
|
||||
|
||||
const treffer = stateById(bed.state_id);
|
||||
const dev = treffer ? treffer.device : null;
|
||||
const state = treffer ? treffer.state : null;
|
||||
|
||||
// Geraet
|
||||
const geraet = document.createElement("select");
|
||||
geraet.className = "form-select";
|
||||
optionListe(geraet, autoDevices.filter(d => d.states.length)
|
||||
.map(d => ({ value: d.id, text: d.name })),
|
||||
dev ? dev.id : "");
|
||||
geraet.onchange = () => {
|
||||
const neu = deviceById(Number(geraet.value));
|
||||
if (!neu || !neu.states.length) return;
|
||||
zeile.appendChild(geraeteWaehler(
|
||||
autoDevices.filter(d => d.states.length), dev ? dev.id : 0,
|
||||
neu => {
|
||||
bed.state_id = neu.states[0].id;
|
||||
bed.operator = neu.states[0].operators[0].value;
|
||||
bed.value = standardWert(neu.states[0]);
|
||||
renderConditions();
|
||||
};
|
||||
zeile.appendChild(geraet);
|
||||
}));
|
||||
|
||||
// Messwert - nur zeigen, wenn das Geraet mehr als einen hat
|
||||
if (dev && dev.states.length > 1) {
|
||||
const messwert = document.createElement("select");
|
||||
messwert.className = "form-select";
|
||||
messwert.className = "form-select auto-messwert";
|
||||
optionListe(messwert, dev.states.map(s => ({
|
||||
value: s.id,
|
||||
text: s.name + (s.value ? " (= " + s.value + ")" : "")
|
||||
@@ -253,11 +436,13 @@ function conditionRow(gi, bi, bed) {
|
||||
* muss, sondern schwarz auf weiss liest.
|
||||
*/
|
||||
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 treffer = stateById(bed.state_id);
|
||||
if (!treffer) return "?";
|
||||
let s = treffer.device.name + ": " + treffer.state.name + " "
|
||||
let s = treffer.device.label + ": " + treffer.state.name + " "
|
||||
+ operatorLabel(treffer.state, bed.operator) + " " + bed.value;
|
||||
if (treffer.state.unit) s += " " + treffer.state.unit;
|
||||
return s;
|
||||
@@ -284,29 +469,23 @@ function renderActions() {
|
||||
|
||||
function actionRow(ai, aktion) {
|
||||
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 dev = treffer ? treffer.device : null;
|
||||
const cmd = treffer ? treffer.command : null;
|
||||
|
||||
const geraet = document.createElement("select");
|
||||
geraet.className = "form-select";
|
||||
optionListe(geraet, autoDevices.filter(d => d.commands.length)
|
||||
.map(d => ({ value: d.id, text: d.name })),
|
||||
dev ? dev.id : "");
|
||||
geraet.onchange = () => {
|
||||
const neu = deviceById(Number(geraet.value));
|
||||
if (!neu || !neu.commands.length) return;
|
||||
zeile.appendChild(geraeteWaehler(
|
||||
autoDevices.filter(d => d.commands.length), dev ? dev.id : 0,
|
||||
neu => {
|
||||
aktion.command_id = neu.commands[0].id;
|
||||
aktion.params = standardParams(neu.commands[0]);
|
||||
renderActions();
|
||||
};
|
||||
zeile.appendChild(geraet);
|
||||
}));
|
||||
|
||||
if (dev && dev.commands.length > 1) {
|
||||
const befehl = document.createElement("select");
|
||||
befehl.className = "form-select";
|
||||
befehl.className = "form-select auto-messwert";
|
||||
optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id);
|
||||
befehl.onchange = () => {
|
||||
const neu = dev.commands.find(c => c.id === Number(befehl.value));
|
||||
@@ -411,6 +590,7 @@ function loadAutomatic() {
|
||||
const daten = JSON.parse(document.getElementById("autoActionData").textContent);
|
||||
autoDevices = daten.devices;
|
||||
autoLockouts = daten.lockouts;
|
||||
autoRooms = daten.rooms || [];
|
||||
|
||||
const a = daten.automation;
|
||||
autoModel = {
|
||||
@@ -438,18 +618,10 @@ function loadAutomatic() {
|
||||
});
|
||||
autoModel.groups = Array.from(nachGruppe.values());
|
||||
|
||||
// Eine neue Automatik startet mit einer leeren Bedingung und einer Aktion,
|
||||
// sonst steht man vor einem leeren Formular ohne erkennbaren Anfang.
|
||||
if (!autoModel.groups.length) {
|
||||
const state = ersterMesswert();
|
||||
if (state) {
|
||||
autoModel.groups = [[{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]];
|
||||
}
|
||||
}
|
||||
if (!autoModel.actions.length) {
|
||||
const cmd = ersterBefehl();
|
||||
if (cmd) autoModel.actions = [{ command_id: cmd.id, params: standardParams(cmd) }];
|
||||
}
|
||||
// Eine neue Automatik startet mit je einer leeren Zeile, sonst steht man
|
||||
// vor einem leeren Formular ohne erkennbaren Anfang.
|
||||
if (!autoModel.groups.length) autoModel.groups = [[leereBedingung()]];
|
||||
if (!autoModel.actions.length) autoModel.actions = [leereAktion()];
|
||||
|
||||
fuelleFormular();
|
||||
}
|
||||
@@ -487,15 +659,11 @@ function openAutoActionModal(params) {
|
||||
modalBodyElement.innerHTML = html;
|
||||
loadAutomatic();
|
||||
document.getElementById("btnAddGroup").addEventListener("click", () => {
|
||||
const state = ersterMesswert();
|
||||
if (!state) return;
|
||||
autoModel.groups.push([{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]);
|
||||
autoModel.groups.push([leereBedingung()]);
|
||||
renderConditions();
|
||||
});
|
||||
document.getElementById("btnAddActor").addEventListener("click", () => {
|
||||
const cmd = ersterBefehl();
|
||||
if (!cmd) return;
|
||||
autoModel.actions.push({ command_id: cmd.id, params: standardParams(cmd) });
|
||||
autoModel.actions.push(leereAktion());
|
||||
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) {
|
||||
automationRequest("toggle", { id: id }).then(antwort => {
|
||||
if (antwort.error) alert(antwort.error);
|
||||
|
||||
+92
-30
@@ -151,7 +151,7 @@ function switchFloor(floor){
|
||||
var mqttData = {};
|
||||
|
||||
function openHeaterSettings(heater){
|
||||
openModal(heater)
|
||||
openRoomView(heater);
|
||||
}
|
||||
|
||||
document.addEventListener('readystatechange', function () {
|
||||
@@ -162,36 +162,98 @@ function switchFloor(floor){
|
||||
});
|
||||
|
||||
|
||||
function openModal(heater) {
|
||||
var contentURL = "./ajax/roomtemp.php?heater="+heater;
|
||||
let heaterMQTT = heater.toString().split("_");
|
||||
document.getElementById("modal-title").innerHTML = "Thermostat "+heater.replace("_"," ").replace("ue","ü").replace("ae","ä").replace("oe","ö");
|
||||
modalBodyElement = document.getElementById("modal-body");
|
||||
modalBodyElement.innerHTML = loadingHTML("Wird geladen...");
|
||||
document.getElementById("modalSaveBtn").addEventListener("click", submitFormAjax);
|
||||
document.getElementById("modalSaveBtn").contentURL = contentURL;
|
||||
modalEV.show();
|
||||
fetch(contentURL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-From-Modal': 'a',
|
||||
'Requested-With-Ajax': 'ajax'
|
||||
}
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
modalBodyElement.innerHTML = html;
|
||||
var slider = document.getElementById("modal-slider");
|
||||
var span = document.getElementById("modal-slider-label");
|
||||
bindModalSlider(slider, span, value => value + " °C");
|
||||
/**
|
||||
* Alles, was in einem Raum haengt, auf einer Seite: Heizung, Rollladen,
|
||||
* Licht. Der Grundriss zeigt Raeume, also sollte ein Klick darauf den Raum
|
||||
* oeffnen - vorher kam nur der Temperaturregler, die Rollladen im selben
|
||||
* Zimmer waren von hier gar nicht erreichbar.
|
||||
*
|
||||
* Welche Geraete dazugehoeren, sagt die Raumzuordnung (actors.room).
|
||||
*/
|
||||
function openRoomView(raum) {
|
||||
const teile = raum.toString().split("_");
|
||||
document.getElementById("modal-title").innerHTML =
|
||||
teile[0] + " · " + teile.slice(1).join(" ")
|
||||
.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö");
|
||||
|
||||
if(typeof(mqttData["Raumtemp"][heaterMQTT[0]][heaterMQTT[1]]["Set Temp[degC]"])!= "undefined")
|
||||
slider.setAttribute("value",mqttData["Raumtemp"][heaterMQTT[0]][heaterMQTT[1]]["Set Temp[degC]"]);
|
||||
else
|
||||
slider.setAttribute("value",10);
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
|
||||
// Zuhoerer stellt sicher, dass nicht auch der Handler eines vorher
|
||||
// geoeffneten Modals mitfeuert.
|
||||
const alt = document.getElementById("modalSaveBtn");
|
||||
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" }
|
||||
})
|
||||
.catch(error => {
|
||||
modalBodyElement.innerHTML += error.message;
|
||||
.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(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.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 logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
@@ -367,6 +369,79 @@ def tag_passt(automatik, jetzt, kalender):
|
||||
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
|
||||
# ===========================================================================
|
||||
@@ -387,6 +462,7 @@ class Runner:
|
||||
self._kalender = (None, {"feiertag": False, "ferien": False})
|
||||
self._letzte_saeuberung = None
|
||||
|
||||
self.versand = Versand()
|
||||
self.mqtt = self._mqtt_verbinden()
|
||||
self.transporte = [
|
||||
MQTTTransport(self.mqtt, self.dry_run),
|
||||
@@ -427,6 +503,16 @@ class Runner:
|
||||
|
||||
def regelwerk_laden(self):
|
||||
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.
|
||||
for transport in self.transporte:
|
||||
passende = [{"id": s["id"], "actor_url": s["actor_url"],
|
||||
@@ -523,7 +609,15 @@ class Runner:
|
||||
# --- Ausfuehren ------------------------------------------------------
|
||||
|
||||
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 = []
|
||||
eingereiht = 0
|
||||
for aktion in automatik["aktionen"]:
|
||||
kommando = self.regelwerk.kommandos.get(aktion["command_id"])
|
||||
if kommando is None:
|
||||
@@ -544,17 +638,14 @@ class Runner:
|
||||
"wert": aktion["werte"].get(p["id"], "")}
|
||||
for p in kommando["params"]],
|
||||
}
|
||||
try:
|
||||
transport.senden(auftrag)
|
||||
logger.info("%s: %s -> %s", automatik["name"], kommando["actor_name"],
|
||||
kommando["command_name"])
|
||||
except Exception as fehler:
|
||||
fehlerText.append("%s: %s" % (kommando["command_name"], fehler))
|
||||
logger.error("%s: %s konnte nicht geschickt werden: %s",
|
||||
automatik["name"], kommando["command_name"], fehler)
|
||||
beschreibung = "%s: %s" % (kommando["actor_name"], kommando["command_name"])
|
||||
logger.info("%s: %s (unterwegs)", automatik["name"], beschreibung)
|
||||
self.versand.einreihen(automatik["id"], beschreibung, transport, auftrag)
|
||||
eingereiht += 1
|
||||
|
||||
ergebnis = "error" if fehlerText else anlass
|
||||
detail = "; ".join(fehlerText)[:255]
|
||||
detail = "; ".join(fehlerText)[:255] if fehlerText \
|
||||
else ("%d Kommando(s) unterwegs" % eingereiht)
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.execute("""UPDATE automations SET last_run = NOW(), changed = changed
|
||||
@@ -566,6 +657,27 @@ class Runner:
|
||||
automatik["last_run"] = datetime.now()
|
||||
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):
|
||||
if bool(automatik["cond_met"]) == bool(erfuellt):
|
||||
return
|
||||
@@ -674,6 +786,7 @@ class Runner:
|
||||
|
||||
self.werte_einsammeln(mit_pollen)
|
||||
self.durchlauf()
|
||||
self.ergebnisse_verbuchen()
|
||||
self.protokoll_saeubern()
|
||||
|
||||
if jetzt - letztes_pruefen >= neuladen:
|
||||
@@ -690,6 +803,10 @@ class Runner:
|
||||
time.sleep(takt)
|
||||
|
||||
def beenden(self):
|
||||
offen = self.versand.offen()
|
||||
if offen:
|
||||
logger.info("%d Kommando(s) noch unterwegs - wird nicht abgewartet", offen)
|
||||
self.versand.beenden()
|
||||
try:
|
||||
self.mqtt.loop_stop()
|
||||
self.mqtt.disconnect()
|
||||
|
||||
@@ -32,6 +32,7 @@ id, `aktion` ein Dict mit actor_url, command_url und params (Liste aus
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urllib3
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
@@ -382,6 +383,36 @@ class TahomaTransport(Transport):
|
||||
|
||||
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):
|
||||
self.requests = requests_modul
|
||||
self.pin = pin
|
||||
@@ -389,6 +420,7 @@ class TahomaTransport(Transport):
|
||||
self.timeout = timeout
|
||||
self.dry_run = dry_run
|
||||
self.states = []
|
||||
self.kombigeraete = set()
|
||||
|
||||
def passt(self, 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",
|
||||
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):
|
||||
if not self.token:
|
||||
return {}
|
||||
@@ -432,19 +472,106 @@ class TahomaTransport(Transport):
|
||||
raise RuntimeError("Kein Tahoma-Token in der config.ini")
|
||||
# Die Reihenfolge der Parameter ist die aus command_parameters - bei
|
||||
# setClosureAndOrientation also erst Position, dann Winkel.
|
||||
befehl = {"name": aktion["command_url"],
|
||||
"parameters": [self._zahl(p["wert"]) for p in aktion["params"]]}
|
||||
rumpf = {"label": "AutoAction",
|
||||
"actions": [{"deviceURL": aktion["actor_url"], "commands": [befehl]}]}
|
||||
befehl = aktion["command_url"]
|
||||
parameter = [self._zahl(p["wert"]) for p in aktion["params"]]
|
||||
neigung_index = None
|
||||
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:
|
||||
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
|
||||
|
||||
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(),
|
||||
data=json.dumps(rumpf), timeout=self.timeout, verify=False)
|
||||
if antwort.status_code >= 400:
|
||||
raise RuntimeError("Tahoma antwortete mit %d: %s"
|
||||
% (antwort.status_code, antwort.text[:120]))
|
||||
|
||||
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
|
||||
def _zahl(wert):
|
||||
"""Tahoma erwartet Zahlen als Zahlen, Text als Text."""
|
||||
|
||||
+180
-7
@@ -15,6 +15,11 @@
|
||||
*/
|
||||
|
||||
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. */
|
||||
function meshDb()
|
||||
@@ -190,6 +195,162 @@ function operatorLabel($op, $type = "")
|
||||
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.
|
||||
*
|
||||
@@ -202,13 +363,24 @@ function deviceCatalog()
|
||||
$db = meshDb();
|
||||
$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()) {
|
||||
$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"])] = [
|
||||
"id" => intval($row["id"]),
|
||||
"name" => $row["name"],
|
||||
"label" => $labels[intval($row["id"])],
|
||||
"type" => $row["type"],
|
||||
"url" => $row["url"],
|
||||
"room" => $row["room"],
|
||||
"roomLabel" => raumBeschriftung($row["room"]),
|
||||
"states" => [],
|
||||
"commands" => [],
|
||||
];
|
||||
@@ -614,14 +786,14 @@ function listAutomations($floor)
|
||||
function stateNames()
|
||||
{
|
||||
$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
|
||||
JOIN actors a ON a.id = s.actor_id
|
||||
LEFT JOIN state_types t ON s.state_type = t.id");
|
||||
$names = [];
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
$names[intval($row["id"])] = [
|
||||
"text" => $row["actor_name"] . ": " . $row["state_name"],
|
||||
"text" => ($labels[intval($row["actor_id"])] ?? "?") . ": " . $row["state_name"],
|
||||
"unit" => $row["unit"],
|
||||
"type" => $row["type"] ?: "string",
|
||||
];
|
||||
@@ -652,11 +824,12 @@ function parameterLabels()
|
||||
function commandNames()
|
||||
{
|
||||
$db = meshDb();
|
||||
$res = $db->query("SELECT c.id, c.command_name, a.name AS actor_name
|
||||
FROM actor_commands c JOIN actors a ON a.id = c.actor_id");
|
||||
$labels = geraeteBeschriftungen();
|
||||
$res = $db->query("SELECT id, command_name, actor_id FROM actor_commands");
|
||||
$names = [];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<h3 class="card-title">Automatismen</h3>
|
||||
<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">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
@@ -193,7 +199,7 @@
|
||||
<script>
|
||||
// Raumtabelle aus restricted/rooms.php - homeMQTT.js aktualisiert darueber
|
||||
// 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)): ?>
|
||||
document.addEventListener('readystatechange', function () {
|
||||
if (event.target.readyState === 'complete') {
|
||||
|
||||
+33
-3
@@ -10,7 +10,14 @@
|
||||
* floor Etage; bestimmt zugleich den Zweig Raumtemp/<floor>/
|
||||
* mqtt Name des Raums im MQTT-Baum: Raumtemp/<floor>/<mqtt>/...
|
||||
* 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.
|
||||
@@ -27,15 +34,38 @@ $rooms = [
|
||||
["floor" => "EG", "mqtt" => "Schlafen", "id" => "schlafen", "x" => 235, "y" => 80],
|
||||
["floor" => "EG", "mqtt" => "Bad", "id" => "bad", "x" => 253, "y" => 130],
|
||||
["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" => "Bad", "id" => "bad", "x" => 210, "y" => 80],
|
||||
["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)
|
||||
{
|
||||
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