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>
426 lines
16 KiB
PHP
426 lines
16 KiB
PHP
<?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)" : "");
|
|
}
|