Compare commits

..
8 Commits
Author SHA1 Message Date
adminandClaude Opus 5 131d7cc46d Jalousien: Zu heisst zweifach schwenken, Versand laeuft im Hintergrund
Die Kugelschreiber-Mechanik betrifft jedes Kommando, nicht nur
setOrientation, und sie betrifft auch das Schliessen: ein "Zu" faehrt die
Jalousie zwar herunter, die Lamellen bleiben aber bei etwa 30 % offen
stehen. Dicht wird sie erst durch das zweifache Schwenken.

Die Regel lautet jetzt einheitlich: derselbe Befehl zweimal - erst mit
Neigung 0, dann warten, bis die Fahrt steht, dann mit dem gewuenschten Wert.
Die Position bleibt dabei erhalten, die Jalousie faehrt also nur einmal.

    Zu                     -> [100,0] warten [100,100]
    Neigung 20             -> direkt, unter der Schwelle
    Neigung 80             -> [0] warten [80]
    Position 40 Neigung 50 -> [40,0] warten [40,50]

"Zu" wird dabei nur umgeschrieben, wenn das Geraet Position und Neigung
zusammen setzen kann - der einfache Rollladen "Terasse" hat keine Lamellen
und bekommt weiter das rohe down.

Gewartet wird auf zwei Auskuenfte zusammen, weil einzeln keine traegt:
core:MovingState trug die lange Fahrt (gemessen 61 s), wird bei kurzen
Neigungsfahrten aber nie gesetzt; die Zustandswerte sind die Wahrheit,
zeigen direkt nach dem Kommando aber noch den alten Stand. Fertig heisst:
nichts faehrt mehr, die Ziele stimmen, und es wurde entweder ein "faehrt"
gesehen oder der Vorlauf von acht Sekunden ist um.

Und weil ein "Zu" damit ueber eine Minute dauert, schickt der Runner nicht
mehr selbst: der neue Versand nimmt die Kommandos entgegen und arbeitet sie
in eigenen Faeden ab - je Geraet der Reihe nach, ueber Geraete hinweg
nebeneinander. Sonst haette eine einzige Jalousie die ganze Auswertung fuer
eine Minute angehalten: keine Zeit-Ausloeser, keine Messwerte, und mehrere
Rollladen in einer Automatik haetten sich aufaddiert. Die Datenbank bleibt
dabei im Hauptfaden - die Faeden melden nur ihr Ergebnis zurueck, eine
pymysql-Verbindung ist nicht fuer mehrere Faeden gedacht.

Nachgemessen: Einreihen kehrt sofort zurueck, zwei Kommandos an dasselbe
Geraet laufen nacheinander (6 s, dann 12 s), drei an verschiedene Geraete
gleichzeitig, Fehler kommen zurueck, und die Schleife lief in 20 Sekunden
20 Takte durch.

Die Bedienung im Modal wartet weiterhin - dort sitzt ein Mensch vor einem
Fortschritt und klickt einmal. ajax/room.php hebt dafuer die Zeitgrenze auf
180 Sekunden an, wie es ajax/tahoma.php an derselben Stelle auch tut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:29:40 +02:00
adminandClaude Opus 5 d557a1651f Neigungs-Umweg gilt fuer jedes Kommando, nicht nur setOrientation
Die Kugelschreiber-Mechanik haengt nicht am Kommando, sondern an der
Neigung selbst - "Position+Neigung" braucht den Umweg ueber 0 % genauso wie
"Neigung". Erkannt wird er deshalb am Parameter und nicht am Kommandonamen:
beide Kommandos nennen ihren Neigungsparameter "Neigung", das
Geraetemodell liefert die Zuordnung also mit.

Geprueft im Probelauf in beiden Versendern: Position+Neigung mit 20 % geht
direkt raus, mit 90 % mit vorgeschaltetem Umweg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:14:05 +02:00
adminandClaude Opus 5 1b9bbc9857 Jalousien: Neigung ueber 30 % nur mit Umweg ueber 0 %
Die Aussenjalousien 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 rastet die Mechanik nur um, wenn
sie vorher einmal auf 0 % war. Ein "Neigung 80 %" ohne diesen Umweg bleibt
wirkungslos.

Beide Versender wissen das jetzt: restricted/commands.php fuer die
Bedienung im Raum-Modal, transports.py fuer den Runner. Bei setOrientation
mit einem Ziel ueber 30 % geht erst eine 0 raus, dann wird gewartet, bis die
Box diese 0 auch meldet, und erst danach der eigentliche Wert. Der Umweg
gehoert dorthin und nicht in die Bedienung - sonst muesste ihn jeder kennen,
der eine Jalousie anspricht, auch beim Anlegen einer Automatik.

Gewartet wird auf core:SlateOrientationState und nicht auf
core:MovingState: bei Neigungsfahrten meldet die Box waehrend der ganzen
Bewegung "faehrt nicht". Genau darauf war ich vorher hereingefallen.

Zwei Messungen aus der Erprobung stecken in den Konstanten: von 100 % auf
0 % braucht eine Jalousie gut fuenfzehn Sekunden, deshalb die Grenze von
25 s. Im Versuch ging ein "Neigung 100 %" von 79 % aus nach 14 s durch -
echtes Warten, nicht die Notbremse.

Dabei ein Fehler, den ausgerechnet das Ziel 0 verdeckt haette: die
PHP-Leseroutine liefert null, wenn die Abfrage fehlschlaegt, und
intval(null) ist 0. Die Warteschleife hielt einen fehlgeschlagenen
Lesevorgang damit fuer "Ziel erreicht" und brach sofort ab - der Umweg fand
also gar nicht statt, es sah nur so aus. In der Python-Fassung ist
dieselbe Stelle jetzt ebenfalls ausdruecklich gegen ein fehlendes Feld
abgesichert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:18:55 +02:00
adminandClaude Opus 5 98a1da6cd5 Raum-Modal: Heizung, Rollladen und Licht auf einer Seite
Der Grundriss zeigt Raeume, ein Klick darauf zeigte aber nur den
Temperaturregler. Die Rollladen im selben Zimmer waren von dort gar nicht
erreichbar, obwohl die Karte genau sagt, wo sie haengen.

Das Modal zeigt jetzt alles, was dem Raum zugeordnet ist: die
Solltemperatur wie bisher, dazu je Geraet seine Kommandos. Kommandos ohne
Parameter werden Knoepfe nebeneinander - "Auf", "Zu", "Stop" will man
antippen und nicht erst einstellen -, Kommandos mit Parametern bekommen
ihre Eingabefelder und einen Ausloeser. Geraete ohne Kommandos zeigen ihre
Messwerte. Die Reihenfolge der Knoepfe kommt nicht mehr aus dem
Geraete-Suchlauf: die haeufig gebrauchten stehen vorn.

Ausgefuehrt wird ueber restricted/commands.php - das Gegenstueck zu
transports.py, nur fuer den Browser. Dieselbe Zuordnung "welche URL gehoert
zu welchem Weg", nur eben in PHP:

    mqtt://   Nutzlast auf das Topic des Parameters
    wled://   JSON-Vorlage mit Platzhaltern an /json/state
    http://   Abfrageargumente an die Geraete-URL
    Tahoma    exec/apply, erkannt an der Box-Kennung in der URL

Zwei Umsetzungen derselben Sache sind nicht schoen. Die Alternative waere,
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; der Kommentar sagt das an
beiden Stellen.

Der Endpunkt kennt einen Probelauf: mit "probe" beschreibt er, was er
schicken wuerde, ohne es zu schicken. Damit sind alle vier Wege geprueft,
ohne einen Rollladen in Bewegung zu setzen - Tahoma mit und ohne Parameter,
WLED mit gefuellter Vorlage, Shelly Gen1 und MQTT.

ajax/roomtemp.php entfaellt, es ging darin ausschliesslich um den Regler,
den das Raum-Modal jetzt enthaelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:43:21 +02:00
adminandClaude Opus 5 bed1884b04 Raeume ohne Kachel: Kueche EG und die beiden Technikraeume
Die Zuordnung der restlichen Geraete lief gegen eine Luecke im Raummodell:
rooms.php kannte nur Raeume, die eine Kachel im Grundriss haben - also
Raeume mit Thermostat. Die Kueche im EG hat keines, und die Stromzaehler
und der Wasserzaehler sitzen in Technikraeumen, die dort ebenfalls fehlten.

Ein Raum ohne x/y ist jetzt trotzdem ein Raum: Geraete lassen sich ihm
zuordnen, er erscheint nur nicht im Grundriss. roomsOnFloor() liefert
weiterhin nur Raeume mit Kachel - alles, was zeichnet oder aktualisiert,
geht darueber -, allRooms() liefert fuer die Zuordnung alle. Die
Aktualisierungsschleife im Browser bekommt ausdruecklich nur die
Kachel-Raeume; ein Raum ohne Position haette dort keine Elemente zu fuellen.

Neu eingetragen: EG/Kueche, EG/Technik, UG/Technik.

Damit sind 49 der 54 Geraete einem Raum zugeordnet. Offen bleiben mit
Absicht: Alarm (hausweit), Zeitpunkt (gerechnet), Thermostat EG
Unkonfiguriert (laut eigenem Namen) und die beiden Dachfenster, deren Raum
noch niemand genannt hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:33:34 +02:00
adminandClaude Opus 5 4b47372015 Geraete den Raeumen zuordnen
Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll ..."), nicht
in Geraetearten. Ein Raummodell gibt es schon - restricted/rooms.php, die
einzige Quelle fuer die Home-Ansicht -, nur fehlte die Zuordnung Geraet ->
Raum.

Die neue Spalte actors.room haelt sie als "Etage/Raum" (OG/Bad), dazu
"aussen" fuer Carport, Garage und Regensensor. Etage und Raum zusammen,
weil "Bad" allein auf drei Etagen vorkommt. Discovery fasst die Spalte nicht
an - der Upsert setzt nur type und name -, die Zuordnung ueberlebt also
jeden Suchlauf.

Zugeordnet wird ueber das Haus-Symbol in der Karte "Automatismen". Wo der
Raum aus dem MQTT-Topic hervorgeht ("Raumtemp/OG/Bad/Temp[degC]"), schlaegt
die Ansicht ihn vor und uebernimmt ihn auf Knopfdruck - das sind die
dreizehn Thermostate, ohne jedes Raten. Bei allen anderen wird bewusst nicht
geraten: "Bad Links" sagt nichts darueber, auf welcher der drei Etagen
dieses Bad liegt. Noch nicht zugeordnete Geraete stehen in der Tabelle oben.

Die Geraeteauswahl im Editor gruppiert danach nach Raum, in der Reihenfolge
aus rooms.php. Solange niemand zugeordnet hat, bleibt es bei der Gruppierung
nach Geraeteart - eine einzige Gruppe "Nicht zugeordnet" waere ein
Rueckschritt. Und weil auch nach der ersten Zuordnung noch viel ohne Raum
ist, wird dieser Rest weiter nach Geraeteart unterteilt ("Ohne Raum ·
Aussenjalousie (11)").

Anders als bei den Gerätearten behalten Raeume mit nur einem Geraet ihre
Ueberschrift: "OG · Bad" ist auch mit einem einzigen Thermostat genau die
Zeile, die man sucht - und mehr als eines haengt selten in einem Raum.

Die dreizehn Thermostate sind bereits zugeordnet, weil das ihre eigenen
Topics hergeben; alles andere steht auf "-" und will von Hand vergeben
werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:16:48 +02:00
adminandClaude Opus 5 2ac504ca29 Geraeteauswahl: Suchfeld statt Auswahlliste
Die Gruppierung nach Geraeteart hat die Liste geordnet, aber nicht kuerzer
gemacht: elf Jalousien bleiben elf Zeilen. Statt der Auswahlliste steht
jetzt ein Knopf in der Zeile, der eine durchsuchbare Liste ueber die volle
Breite aufklappt.

Gesucht wird ueber Beschriftung UND Geraeteart zugleich, also quer zur
Gruppierung: "bad" findet die drei Thermostate im Bad und die zwei
Jalousien "Bad Links"/"Bad Rechts", "jalousie" alle elf auf einmal. Ohne
Suchtext bleiben die Zwischenueberschriften stehen; sobald gefiltert wird,
faellt die Gruppierung weg, weil die Treffer dann quer ueber alle Arten
stehen und die Art ohnehin an jeder Zeile mitlaeuft.

Die aufgeklappte Liste haengt unter der ganzen Zeile und nicht in ihr - in
einer input-group stuende sie sonst als weitere Spalte daneben. Escape
schliesst, Enter nimmt den ersten Treffer, ein Klick daneben schliesst
ebenfalls. Es ist immer hoechstens eine offen.

Dabei zwei Sachen mitgenommen:

Eine neue Zeile beginnt jetzt ohne Geraet ("Gerät wählen …"). Bisher 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 musste. Der Satz darunter
sagt in dem Fall "Noch kein Ausloeser", und der Server lehnt eine
unvollstaendige Bedingung ohnehin ab.

Auf dem Handy quetschten sich Geraet, Messwert, Vergleich, Wert, Einheit
und Papierkorb in eine Zeile - uebrig blieben "Therm OG Bad" und "te".
Unter 576 px bekommen Geraet und Messwert jetzt je eine eigene Zeile. Die
Regel braucht die input-group-Klasse im Selektor: solar.css wird vor
AdminLTE geladen, und Bootstraps ".input-group > .form-select" haette sonst
dasselbe Gewicht und wuerde als spaeteres Blatt gewinnen.

Geprueft bei 375 px und am Rechner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:10:59 +02:00
adminandClaude Opus 5 00f5944a7a Geraeteauswahl: nach Art gruppiert, Namen geputzt
Nach dem Discovery-Lauf stehen 51 Geraete in der Ausloeser-Liste und 40 in
der Aktions-Liste, flach und alphabetisch. Dreizehn Thermostate und elf
Jalousien sind darin vierundzwanzig gleich aussehende Zeilen.

Die Liste ist jetzt nach Geraeteart gruppiert (optgroup, das koennen auch
die Handy-Browser nativ). Arten mit nur einem Geraet bekommen keine eigene
Ueberschrift - fuenfzehn Gruppen mit je einem Eintrag waeren
unuebersichtlicher als die flache Liste -, sie sammeln sich am Ende unter
"Einzelne".

Dazu ein Namensputz fuer die Anzeige, ohne die Datenbank anzufassen: das
u_-Praefix aus den Tahoma-Beschriftungen faellt weg, ebenso eine
angehaengte einstellige Kanalnummer, und Unterstriche werden zu Leerzeichen.
Aus "u_Wozi Terrassentuer" wird "Wozi Terrassentuer", aus "Power_EG_EM_0"
wird "Power EG EM". Bewusst nur die einstellige Endung - "go-eCharger_270003"
traegt seine Seriennummer und behaelt sie.

Beschriftungen, die danach doppelt waeren, bekommen die Geraeteart dazu: die
Tahoma-Box nennt sowohl den Rollladen als auch den Sonnenenergie-Sensor
"Terasse", in der Liste untereinander waren sie nicht zu unterscheiden.
Daraus wird "Terasse (Rollladen)" und "Terasse (Licht)".

Die Beschriftung entsteht an einer Stelle und wird von Editor und
Uebersicht geteilt - sonst stuende in der Auswahlliste "Wozi Terrassentuer"
und im Satz darunter weiter "u_Wozi Terrassentuer".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:02:49 +02:00
13 changed files with 1582 additions and 175 deletions
+74
View File
@@ -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
View File
@@ -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>";
-48
View File
@@ -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;
}
+26
View File
@@ -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;
}
+2 -1
View File
@@ -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
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
} }
/** /**
@@ -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
View File
@@ -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;
}
+126 -9
View File
@@ -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()
+132 -5
View File
@@ -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
View File
@@ -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;
} }
+425
View File
@@ -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
View File
@@ -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
View File
@@ -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"]);
})); }));
} }