Regenbasierte automatische Gartenbewässerung + homeMesh-Anbindung für AutoAction-Regeleditor

- Neues stündliches Python-Script (restricted/gartenbewaesserung/auto_watering.py): holt Niederschlags-
  und Sonnenuntergangsdaten von Open-Meteo, wertet je Zone (Hochbeete/Tröge/Garten vorn) individuelle
  Regen-Schwellenwerte und Mindest-Bewässerungsdauern aus und startet bei Bedarf per MQTT den passenden
  Automatik-Modus der Ventilsteuerung im Zeitraum vor Sonnenuntergang; veröffentlicht den Regen-Status
  zusätzlich stündlich als retained MQTT-Nachricht je Zone.
- Web-UI: neue Bewässerungs-Steuerung/-Anzeige (ajax/watering.php, js/solar/solarMQTT.js,
  assets/img/realtime.svg) inkl. Live-Status-Icons (aus/geplant/pausiert/aktiv per Farbe und Symbol).
- AutoAction-Regeleditor (ajax/actorDetails.php, sensorDetails.php, fillActorDD.php, fillSensorDD.php,
  tahoma.php, AutoAction.php) liest Actor-/Sensor-Parameter jetzt live aus der homeMesh-Datenbank statt
  aus stark vereinfachten Altfeldern, inkl. zugehöriger Anpassungen am Device-Discovery-Tooling
  (restricted/deviceDiscovery/*, neues logic_module.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 20:19:27 +02:00
co-authored by Claude Sonnet 5
parent 4de95c1a29
commit 29f752eafe
22 changed files with 1693 additions and 234 deletions
+20 -5
View File
@@ -156,9 +156,10 @@ input[type='range']::-webkit-slider-runnable-track {
</select> </select>
<label for="paramSelect1">Messwert</label> <label for="paramSelect1">Messwert</label>
</div> </div>
<button class="btn btn-outline-secondary " type="button" id="btnOperator1" name="btnOperator1">&gt;</button> <button class="btn btn-outline-secondary" style="min-width: 40px;" type="button" id="btnOperator1" name="btnOperator1">&gt;</button>
<div class="form-floating" id="valBlock1"> <div class="form-floating" id="valBlock1">
<input type="number" class="form-control" id="threshold1" placeholder="0" value="0" name="threshold1"></input> <input type="number" class="form-control" id="threshold1" placeholder="0" value="0" name="threshold1"></input>
<select class="form-select" id="thresholdOpt1" name="thresholdOpt1" placeholder="0" value="0"></select>
<label for="threshold1">Wert/Schwelle</label> <label for="threshold1">Wert/Schwelle</label>
</div> </div>
</div> </div>
@@ -247,10 +248,24 @@ input[type='range']::-webkit-slider-runnable-track {
</select> </select>
<label for="paramSelect1">Eigenschaft</label> <label for="paramSelect1">Eigenschaft</label>
</div> </div>
<button class="btn btn-outline-secondary " type="button" id="btnActOperator1" name="btnActOperator1">&gt;</button> <button class="btn btn-outline-secondary" style="min-width: 40px;" type="button" id="btnActOperator1" name="btnActOperator1">&gt;</button>
<div class="form-floating" id="actValBlock1"> <div class="form-floating" id="act1ValBlock1">
<input type="number" class="form-control" id="actValue1" name="actValue1" placeholder="0" value="0"></input> <input type="number" class="form-control" id="act1Value1" name="act1Value1" value="0"></input>
<label for="actValue1">Sollwert</label> <select class="form-select" id="act1ValueOpt1" name="act1ValueOpt1"></select>
<label for="act1Value1">Wert 1</label>
</div>
<div class="form-floating" id="act2ValBlock1">
<input type="number" class="form-control" id="act2Value1" name="act2Value1" value="0"></input>
<label for="act2Value1">Wert 2</label>
</div>
<div class="form-floating" id="act3ValBlock1">
<input type="number" class="form-control" id="act3Value1" name="act3Value1" value="0"></input>
<label for="act3Value1">Wert 3</label>
</div>
<div class="form-floating" id="act4ValBlock1">
<input type="number" class="form-control" id="act4Value1" name="act4Value1" value="0"></input>
<label for="act4Value1">Wert 4</label>
</div>
</div> </div>
</div> </div>
</div> </div>
+70 -1
View File
@@ -1,5 +1,74 @@
<?php <?php
require_once("../helper.php"); require_once("../helper.php");
if(isset($_GET["actorID"])){
$actorID = intval($_GET["actorID"]);
$qry = "SELECT command_name, command_url, parameter_type, min_value, max_value, possible_values, command_parameters.url AS parameter_url, parameter_name FROM actor_commands LEFT JOIN command_parameters ON actor_commands.id = command_id LEFT JOIN state_types ON parameter_type = state_types.id WHERE actor_id=".$actorID;
$qry = "SELECT id, command_name, command_url FROM actor_commands WHERE actor_id = ".$actorID;
$mysql = new mysqli($mysql_server, $mysql_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$result = mysqli_query($mysql, $qry);
$operators = "";
//while($row = $result->fetch_assoc()){
// print_r($row);
// echo "<br />";
//}
echo '{"commands": [';
$iterated = 0;
while($row = $result->fetch_assoc()){
$items = "[]";
$qry2 = "SELECT parameter_type, min_value, max_value, possible_values, command_parameters.url AS parameter_url, parameter_name FROM actor_commands LEFT JOIN command_parameters ON actor_commands.id = command_id LEFT JOIN state_types ON parameter_type = state_types.id WHERE command_id = ".$row["id"];
$result2 = mysqli_query($mysql, $qry2);
if($iterated++){
echo ", ";
}
echo '{"name" : "'.$row["command_name"].'", "operators" : ["="], "parameters": [';
$iterated2 = 0;
$type = "hidden";
while($row2 = $result2->fetch_assoc()){
switch($row2["parameter_type"]){
case "integer":
if($row2["possible_values"]){
$items = $row2["possible_values"];
$type = "dropdown";
}else{
$type = "number";
}
break;
case "float":
$type = "number";
break;
case "time":
$type = "time";
break;
case "bool":
$type = "button";
break;
case "string":
if($row2["possible_values"]){
$items = $row2["possible_values"];
$type = "dropdown";
}else{
$type = "text";
}
break;
case "":
$type = "hidden";
default:
$type = "text";
break;
}
if($iterated2++){
echo ", ";
}
echo '{"name" : "'.$row2["parameter_name"].'", "url" : "'.$row2["parameter_url"].'", "type" : "'.$type.'", "options" : '.$items.'}';
}
echo '], "type" : "'.$type.'"}';
}
echo "]}";
}
/*
require_once("../helper.php");
if(isset($_GET["actorID"])){ if(isset($_GET["actorID"])){
$ID = intval($_GET["actorID"]); $ID = intval($_GET["actorID"]);
$qry = "SELECT parameters FROM actors WHERE id=".$ID; $qry = "SELECT parameters FROM actors WHERE id=".$ID;
@@ -8,5 +77,5 @@ if(isset($_GET["actorID"])){
echo $result->fetch_array()[0]; echo $result->fetch_array()[0];
} }
*/
?> ?>
+2 -2
View File
@@ -1,7 +1,7 @@
<?php <?php
require_once("../helper.php"); require_once("../helper.php");
$qry = "SELECT name, id FROM actors"; $qry = "SELECT name, actors.id FROM actors INNER JOIN actor_commands ON actor_id = actors.id GROUP BY actors.id";
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB); $mysql = new mysqli($mysql_server, $mysql_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$result = mysqli_query($mysql, $qry); $result = mysqli_query($mysql, $qry);
while($row = $result->fetch_assoc()){ while($row = $result->fetch_assoc()){
+2 -2
View File
@@ -1,7 +1,7 @@
<?php <?php
require_once("../helper.php"); require_once("../helper.php");
$qry = "SELECT name, id FROM sensors"; $qry = "SELECT name, actors.id FROM actors INNER JOIN actor_states ON actor_id = actors.id GROUP BY actors.id";
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB); $mysql = new mysqli($mysql_server, $mysql_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$result = mysqli_query($mysql, $qry); $result = mysqli_query($mysql, $qry);
while($row = $result->fetch_assoc()){ while($row = $result->fetch_assoc()){
+60 -3
View File
@@ -2,10 +2,67 @@
require_once("../helper.php"); require_once("../helper.php");
if(isset($_GET["sensorID"])){ if(isset($_GET["sensorID"])){
$sensorID = intval($_GET["sensorID"]); $sensorID = intval($_GET["sensorID"]);
$qry = "SELECT parameters FROM sensors WHERE id=".$sensorID; $qry = "SELECT state_name, url, state_types.type, unit, current_value, possible_values FROM actor_states LEFT JOIN state_types ON state_type = state_types.id WHERE actor_id=".$sensorID;
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB); $mysql = new mysqli($mysql_server, $mysql_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$result = mysqli_query($mysql, $qry); $result = mysqli_query($mysql, $qry);
echo $result->fetch_array()[0]; $operators = "";
echo '{"parameters": [';
$iterated = 0;
$items = "[]";
while($row = $result->fetch_assoc()){
switch($row["type"]){
case "integer":
$operators = '["=","≠","&gt;","&lt;"]';
$type = "number";
break;
case "float":
$operators = '["=","≠","&gt;","&lt;"]';
$type = "number";
break;
case "time":
$operators = '["=","≠"]';
$type = "time";
break;
case "deltatime":
$operators = '["+","-"]';
$type = "time";
break;
case "date":
$operators = '["=","≠"]';
$type = "date";
break;
case "datetime":
$operators = '["=","≠"]';
$type = "datetime-local";
break;
case "bool":
$operators = '["= JA","= NEIN"]';
$type = "button";
break;
case "string":
$operators = '["=","≠"]';
if($row["possible_values"]){
$items = $row["possible_values"];
$type = "dropdown";
}else{
$operators = '["=","≠"]';
$type = "text";
}
break;
default:
$operators = '["=","≠"]';
$type = "text";
break;
}
if($iterated++){
echo ", ";
}
if($row["current_value"]){
$row["state_name"] .= " (=".$row["current_value"].")";
}
echo '{"name" : "'.$row["state_name"].'", "operators" : '.$operators.', "url" : "'.$row["url"].'", "type" : "'.$type.'", "options" : '.$items.'}';
}
echo "]}";
} }
+9 -7
View File
@@ -50,6 +50,7 @@ function filter_devs($arr,$filter,$prefix=""){
foreach($arr as $key => $dev){ foreach($arr as $key => $dev){
$start = strpos(strtolower($dev["name"]),strtolower($filter)); $start = strpos(strtolower($dev["name"]),strtolower($filter));
if($start !== false){ if($start !== false){
$dev["name"] = str_replace("u_","",$dev["name"]);
$ret[$i]["name"] = $prefix.substr($dev["name"],strlen($filter)); $ret[$i]["name"] = $prefix.substr($dev["name"],strlen($filter));
$ret[$i]["id"] = $key; $ret[$i]["id"] = $key;
$i++; $i++;
@@ -60,7 +61,7 @@ function filter_devs($arr,$filter,$prefix=""){
function searchForDevice($device){ function searchForDevice($device){
$dev = false; $dev = false;
$devices = json_decode(file_get_contents('../restricted/tahoma_devices.json'), true); $devices = json_decode(file_get_contents($GLOBALS["tahoma_devlist"]), true);
if (strlen($device) < 3 && intval($device) >= 0 && intval($device) < sizeof($devices)) { if (strlen($device) < 3 && intval($device) >= 0 && intval($device) < sizeof($devices)) {
$dev = $devices[intval($device)]; $dev = $devices[intval($device)];
} else if ($key = array_search($device, array_column($devices, "name"))) { } else if ($key = array_search($device, array_column($devices, "name"))) {
@@ -87,12 +88,13 @@ if ($_GET["action"] == "devlist") {
echo $device["label"] . "(".$device["deviceURL"].")<br />"; echo $device["label"] . "(".$device["deviceURL"].")<br />";
} }
} }
$file = fopen('../restricted/tahoma_devices.json', 'w'); //$file = fopen($tahoma_devlist, 'w');
fwrite($file, json_encode($jalousien)); //fwrite($file, json_encode($jalousien));
fclose($file); //fclose($file);
echo json_encode($jalousien);
}elseif ($_GET["action"] == "pos" && isset($_GET["device"])) { }elseif ($_GET["action"] == "pos" && isset($_GET["device"])) {
$dev = searchForDevice($_GET["device"])["id"]; $dev = searchForDevice($_GET["device"])["id"];
$devices = json_decode(file_get_contents('../restricted/tahoma_devices.json'), true); $devices = json_decode(file_get_contents($tahoma_devlist), true);
$jalousien = array(); $jalousien = array();
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states"; $url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states";
$ret = getSSLPage($url,$tahoma_token); $ret = getSSLPage($url,$tahoma_token);
@@ -115,7 +117,7 @@ if ($_GET["action"] == "devlist") {
fastcgi_finish_request(); fastcgi_finish_request();
}elseif ($_GET["action"] == "moving" && isset($_GET["device"])) { }elseif ($_GET["action"] == "moving" && isset($_GET["device"])) {
$dev = searchForDevice($_GET["device"])["id"]; $dev = searchForDevice($_GET["device"])["id"];
$devices = json_decode(file_get_contents('../restricted/tahoma_devices.json'), true); $devices = json_decode(file_get_contents($tahoma_devlist), true);
$jalousien = array(); $jalousien = array();
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states/core:MovingState"; $url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states/core:MovingState";
$ret = getSSLPage($url,$tahoma_token); $ret = getSSLPage($url,$tahoma_token);
@@ -201,7 +203,7 @@ if ($_GET["action"] == "myactors") {
$host = gethostbyaddr("192.168.179.32"); $host = gethostbyaddr("192.168.179.32");
$host = str_replace("-", "", strtolower(substr($host, 0, strpos($host, ".")))); $host = str_replace("-", "", strtolower(substr($host, 0, strpos($host, "."))));
} }
$devices = json_decode(file_get_contents('../restricted/tahoma_devices.json'), true); $devices = json_decode(file_get_contents($tahoma_devlist), true);
switch ($host) { switch ($host) {
case "tmpegbad": case "tmpegbad":
echo json_encode(filter_devs($devices, "bad ")); echo json_encode(filter_devs($devices, "bad "));
+98
View File
@@ -0,0 +1,98 @@
<?php
require_once("./phpMQTT.php");
require_once("../helper.php");
if ($_SERVER["REQUEST_METHOD"] === "POST") {
header("Content-Type: application/json; charset=utf-8");
if (!checkLogin()) {
http_response_code(401);
echo json_encode(["success" => false, "message" => "Nicht angemeldet."]);
exit;
}
$topic = $_POST["topic"] ?? "";
$payload = $_POST["payload"] ?? "";
$allowedTopics = [
"Gartenwasser/vorn/set",
"Gartenwasser/vorn/auto",
"Gartenwasser/hinten/set",
"Gartenwasser/hinten/auto"
];
if (!in_array($topic, $allowedTopics, true)) {
http_response_code(400);
echo json_encode(["success" => false, "message" => "Nicht erlaubtes MQTT-Topic."]);
exit;
}
if (str_ends_with($topic, "/set")) {
$command = json_decode($payload, true);
$validValve = is_array($command)
&& isset($command["valve"], $command["set"])
&& in_array((int) $command["valve"], [1, 6], true)
&& in_array($command["set"], ["open", "close"], true);
if ($validValve && $command["set"] === "open") {
$validValve = isset($command["timer"])
&& is_numeric($command["timer"])
&& (int) $command["timer"] >= 1
&& (int) $command["timer"] <= 120;
}
if (!$validValve) {
http_response_code(400);
echo json_encode(["success" => false, "message" => "Ungültiger Ventilbefehl."]);
exit;
}
$payload = json_encode($command, JSON_THROW_ON_ERROR);
} elseif (!in_array($payload, ["Hoch", "Vorn", "Trog", "Stop"], true)) {
http_response_code(400);
echo json_encode(["success" => false, "message" => "Ungültiger Automatikbefehl."]);
exit;
}
try {
$mqtt = new Bluerhinos\phpMQTT("localhost", 1883, "WateringWeb-" . bin2hex(random_bytes(4)));
if (!$mqtt->connect(true, null, "", "")) {
http_response_code(503);
echo json_encode(["success" => false, "message" => "MQTT-Broker nicht erreichbar."]);
exit;
}
$mqtt->publish($topic, $payload, 0, false);
$mqtt->close();
} catch (Throwable $error) {
http_response_code(503);
echo json_encode(["success" => false, "message" => "MQTT-Fehler: " . $error->getMessage()]);
exit;
}
echo json_encode(["success" => true, "topic" => $topic, "payload" => $payload]);
exit;
}
if (!checkLogin()) {
exit;
}
echo <<<HTML
<div class="table-responsive p-3">
<div id="watering-feedback" class="alert d-none mb-3" role="status"></div>
<div class="mb-3">
<h6>Automatik</h6>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary" data-watering-auto-toggle data-watering-auto="Trog,Hoch" data-watering-topics="Gartenwasser/vorn,Gartenwasser/hinten" data-watering-label="Tröge und Hochbeete">Tröge und Hochbeete</button>
<button type="button" class="btn btn-outline-primary" data-watering-auto-toggle data-watering-auto="Trog" data-watering-topic="Gartenwasser/vorn" data-watering-label="Tröge">Tröge</button>
<button type="button" class="btn btn-outline-primary" data-watering-auto-toggle data-watering-auto="Hoch" data-watering-topic="Gartenwasser/hinten" data-watering-label="Hochbeete">Hochbeete</button>
<button type="button" class="btn btn-outline-primary" data-watering-auto-toggle data-watering-auto="Vorn" data-watering-topic="Gartenwasser/vorn" data-watering-label="Garten vorn">Garten vorn</button>
</div>
</div>
<h6>Manuell</h6>
<table class="table align-middle mb-3">
<thead><tr><th>Zone</th><th>Dauer (Min.)</th><th>Letzte Bewässerung</th><th>Dauer</th><th>Steuerung</th></tr></thead>
<tbody>
<tr><td>Tröge</td><td><input class="form-control" type="number" min="1" max="120" value="5" data-watering-timer="troughs"></td><td data-watering-last="troughs">--</td><td data-watering-duration="troughs">--</td><td><button type="button" class="btn btn-success" data-watering-toggle data-watering-zone="troughs">Ein</button></td></tr>
<tr><td>Beete</td><td><input class="form-control" type="number" min="1" max="120" value="30" data-watering-timer="raisedBeds"></td><td data-watering-last="raisedBeds">--</td><td data-watering-duration="raisedBeds">--</td><td><button type="button" class="btn btn-success" data-watering-toggle data-watering-zone="raisedBeds">Ein</button></td></tr>
<tr><td>Vorn</td><td><input class="form-control" type="number" min="1" max="120" value="30" data-watering-timer="frontGarden"></td><td data-watering-last="frontGarden">--</td><td data-watering-duration="frontGarden">--</td><td><button type="button" class="btn btn-success" data-watering-toggle data-watering-zone="frontGarden">Ein</button></td></tr>
</tbody>
</table>
</div>
HTML;
+32 -9
View File
@@ -240,6 +240,21 @@
c-0.28,2.361-0.421,4.584-0.421,6.607c0,64.629,45.966,120.77,109.298,133.484c2.607,0.525,4.5,2.795,4.545,5.455 c-0.28,2.361-0.421,4.584-0.421,6.607c0,64.629,45.966,120.77,109.298,133.484c2.607,0.525,4.5,2.795,4.545,5.455
C222.575,351.396,220.761,353.729,218.171,354.342z" /> C222.575,351.396,220.761,353.729,218.171,354.342z" />
</g> </g>
<g id="raisedBedIcon" filter="url(#shadow)">
<path d="M2 8h20v12H2V8Z" fill="none" stroke="currentColor" stroke-width="1.8" />
<path d="M4 12h16M5 16h14" fill="none" stroke="currentColor" stroke-width="1.4" />
<path d="M7 8V4m0 2L5 4m2 2 2-2M13 8V3m0 3-2-2m2 2 2-2M18 8V4m0 2-2-2m2 2 2-2" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</g>
<g id="flowerPotIcon" filter="url(#shadow)">
<path d="M3 8h18l-2 4H5L3 8Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
<path d="M6 12h12l-2 8H8l-2-8Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
<path d="M12 8V3m0 3L9 4m3 2 3-2" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</g>
<g id="gardenPlantIcon" filter="url(#shadow)">
<path d="M12 19V10m0 4-4-3m4 1 4-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 3C9 3 7 5 7 8c-3 0-5 2-5 5s2 5 5 5h10c3 0 5-2 5-5s-2-5-5-5c0-3-2-5-5-5Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
<path d="M5 22h14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</g>
<g id="voltage"> <g id="voltage">
<path stroke="#212529" stroke-width="0.2px" d="M11.46,18V13.58H9.86L12.54,8v3.89h1.6Z" /> <path stroke="#212529" stroke-width="0.2px" d="M11.46,18V13.58H9.86L12.54,8v3.89h1.6Z" />
@@ -566,21 +581,32 @@
style='display:none;mix-blend-mode: difference;fill:#70CCBB;' href="#heater" /> style='display:none;mix-blend-mode: difference;fill:#70CCBB;' href="#heater" />
</g> </g>
<g transform="translate(330,280), scale(0.70,0.70)" id="waterInfo"> <g transform="translate(330,280), scale(0.70,0.70)" id="waterInfo" class="clickable" onclick="openModal('watering')">
<animateMotion begin="0.5s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1" <animateMotion begin="0.5s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" /> keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" />
<animateMotion begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1" <animateMotion begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".6,0,.27,1" path="M340 0 L120 -300" fill="freeze" /> keySplines=".6,0,.27,1" path="M340 0 L120 -300" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#182C43" /> <use href="#ElementBG" stroke="#182C43" />
<path filter="url(#fillpartialWater)" fill="#212529" stroke="#182C43" stroke-width="4" <path filter="url(#fillpartialWater)" fill="#212529" stroke="#182C43" stroke-width="4"
d="M 99.863863 22.00012 A 78 78 0 1 0 100 22" /> d="M 99.863863 22.00012 A 78 78 0 1 0 100 22" />
<use href="#water" transform="translate(60,55), scale(0.20,0.20)" fill="#3A6292" /> <use href="#water" transform="translate(60,55), scale(0.20,0.20)" fill="#3A6292" />
<!--</g>--> <!-- Wasserstand und Temperatur werden von der Bewässerungssteuerung nicht unterstützt.
<text id="waterText" filter="url(#shadowMiddle)" class="powerTextClass" text-anchor="middle" x="100" y="55">--.- cm</text>
<text id="waterTemp" filter="url(#shadowMiddle)" class="powerTextClass" text-anchor="middle" x="100" y="88">-.- °C</text>
-->
<use id="wateringFrontIcon" href="#gardenPlantIcon" transform="translate(152,162),rotate(310),scale(1.3)" fill="#6c757d" color="#6c757d">
<title>Vorn: aus</title>
</use>
<use id="wateringTroughIcon" href="#flowerPotIcon" transform="translate(18,100),rotate(75),scale(1.3)" fill="#6c757d" color="#6c757d">
<title>Tröge: aus</title>
</use>
<use id="wateringBedsIcon" href="#raisedBedIcon" transform="translate(66,174),rotate(15),scale(1.3)" fill="#6c757d" color="#6c757d">
<title>Beete: aus</title>
</use>
<text filter="url(#shadowMiddle)" class="powerTextClass"> <text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="waterText" href="#powerTextPath" startOffset="25%">--.- cm</textPath> <textPath id="wateringFrontStatus" href="#infoTextPath" startOffset="46%">Aus </textPath>
<textPath id="waterTemp" href="#infoTextPath" startOffset="26%">-.- °C</textPath> <textPath id="wateringTroughStatus" href="#infoTextPath" startOffset="10%">Aus </textPath>
<textPath id="wateringBedsStatus" href="#infoTextPath" startOffset="28%">Aus </textPath>
</text> </text>
</g> </g>
@@ -589,13 +615,10 @@
keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" /> keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" />
<animateMotion begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1" <animateMotion begin="1.2s" dur=".7s" repeatCount="1" calcMode="spline" keyTimes="0;1"
keySplines=".6,0,.27,1" path="M340 0 L500 -300" fill="freeze" /> keySplines=".6,0,.27,1" path="M340 0 L500 -300" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#18423B" /> <use href="#ElementBG" stroke="#18423B" />
<use href="#ogIcon" /> <use href="#ogIcon" />
<path id="ogArc" fill="none" stroke="#70CCBB" stroke-width="30" /> <path id="ogArc" fill="none" stroke="#70CCBB" stroke-width="30" />
<use href="#circGrid" style="stroke:#18423B;" /> <use href="#circGrid" style="stroke:#18423B;" />
<!--</g>-->
<text filter="url(#shadowMiddle)" class="powerTextClass"> <text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="ogText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath> <textPath id="ogText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
</text> </text>
+1 -1
View File
@@ -108,7 +108,7 @@ if (isset($_GET["addUser"])) {
case "solar": case "solar":
$resource_content .= str_replace("%%INSERTSVG%%", file_get_contents('assets/img/realtime.svg'), file_get_contents('restricted/solar.html')); $resource_content .= str_replace("%%INSERTSVG%%", file_get_contents('assets/img/realtime.svg'), file_get_contents('restricted/solar.html'));
$resource_content .= file_get_contents('restricted/footer.html'); $resource_content .= file_get_contents('restricted/footer.html');
$resource_content .= "<script src='js/solar/solarMQTT.js'></script>"; $resource_content .= "<script src='js/solar/solarMQTT.js?v=" . filemtime('js/solar/solarMQTT.js') . "'></script>";
break; break;
case "home": case "home":
include "restricted/home.php"; include "restricted/home.php";
+146 -51
View File
@@ -1,23 +1,129 @@
function addOptions(dropdownID, opts){
opts.forEach((opt, index) => {
var option = document.createElement("option");
if(opt.name){ //parameter could be command in case of actor.
option.text = opt.name;
option.value = opt.url;
if(opt.operators)
option.setAttribute("data-tglstates",JSON.stringify(opt.operators));
if(opt.type)
option.setAttribute("data-type",opt.type);
if(opt.options)
option.setAttribute("data-parameters",JSON.stringify([opt]));
if(opt.parameters){ //we have an actor with more than one parameters, then the parameters are actually nested inside the command
option.setAttribute("data-parameters",JSON.stringify(opt.parameters));
}
}else if(typeof opt === 'object'){
option.value = Object.keys(opt)[0];
option.text = opt[option.value];
}else {
option.text = opt;
option.value = opt;
}
if(typeof dropdownID === 'object'){
dropdownID.appendChild(option);
}else{
document.getElementById(dropdownID).appendChild(option);
}
});
}
function clearOptions(dropdownID){
if(typeof dropdownID === 'object'){
dropdownID.innerHTML = "";
}else{
document.getElementById(dropdownID).innerHTML = "";
}
}
function changeValueType(event){ function changeValueType(event){
dropdown = event.currentTarget; dropdown = event.currentTarget;
type = dropdown.options[dropdown.selectedIndex].dataset.type; type = dropdown.options[dropdown.selectedIndex].dataset.type;
id = dropdown.id.match(/\d+$/); id = dropdown.id.match(/\d+$/);
if(dropdown.id.search("act") > -1){ if(dropdown.id.search("act") > -1){
opBtnID = "btnActOperator"+id; opBtnID = document.getElementById("btnActOperator"+id);
valBlockID = "actValBlock"+id; valBlockID = document.getElementById("act1ValBlock"+id);
valElemID = "actValue"+id; valElemID = document.getElementById("act1Value"+id);
valOptID = document.getElementById("act1ValueOpt"+id);
val2BlockID = document.getElementById("act2ValBlock"+id);
val3BlockID = document.getElementById("act3ValBlock"+id);
val4BlockID = document.getElementById("act4ValBlock"+id);
}else{ }else{
opBtnID = "btnOperator"+id; opBtnID = document.getElementById("btnOperator"+id);
valBlockID ="valBlock"+id; valBlockID =document.getElementById("valBlock"+id);
valElemID = "threshold"+id; valElemID = document.getElementById("threshold"+id);
valOptID = document.getElementById("thresholdOpt"+id);
val2BlockID = null;
} }
if(type =="hidden"){ if(type =="hidden"){
document.getElementById(opBtnID).hidden = true; opBtnID.hidden = true;
document.getElementById(valBlockID).hidden = true; valBlockID.hidden = true;
if(val2BlockID){ //if this is an actor hide all additional inputs, as only one dropdown is supported
val2BlockID.hidden = true;
val3BlockID.hidden = true;
val4BlockID.hidden = true;
}
}else{ }else{
document.getElementById(opBtnID).hidden = false; opBtnID.setAttribute("data-tglstates",dropdown.options[dropdown.selectedIndex].dataset.tglstates);
document.getElementById(valBlockID).hidden = false; opBtnID.innerHTML = JSON.parse(dropdown.options[dropdown.selectedIndex].dataset.tglstates)[0];
document.getElementById(valElemID).type = type; opBtnID.hidden = false;
valBlockID.hidden = false;
if(type == "dropdown"){
valElemID.type = "text";
valOptID.hidden = false;
valElemID.hidden = true;
if(val2BlockID){ //if this is an actor hide all additional inputs, as only one dropdown is supported
val2BlockID.hidden = true;
val3BlockID.hidden = true;
val4BlockID.hidden = true;
}
clearOptions(valOptID);
addOptions(valOptID,JSON.parse(dropdown.options[dropdown.selectedIndex].dataset.parameters)[0].options);
}else if(type == "button" || type == "bool"){
valElemID.type = "text";
valOptID.hidden = true;
valBlockID.hidden = true;
if(val2BlockID){ //if this is an actor hide all additional inputs, as only one dropdown is supported
val2BlockID.hidden = true;
val3BlockID.hidden = true;
val4BlockID.hidden = true;
}
}else{
valElemID.type = type;
if(type == "time")
valElemID.value = "00:00";
else if(type == "date")
valElemID.value = new Date().toISOString().split('T')[0]
else if(type == "text")
valElemID.value = "";
else
valElemID.value = "0";
valOptID.hidden = true;
valElemID.hidden = false;
if(val2BlockID){
parameters = JSON.parse(dropdown.options[dropdown.selectedIndex].dataset.parameters);
val2BlockID.hidden = true;
val3BlockID.hidden = true;
val4BlockID.hidden = true;
theAssociatedLabel = valElemID.parentNode.querySelector("label[for='" + valElemID.id + "']");
theAssociatedLabel.innerHTML = parameters[0].name;
switch(parameters.length){
case 4:
theAssociatedLabel = val4BlockID.querySelector("label[for='" + val4BlockID.id.replace("Block","ue") + "']");
theAssociatedLabel.innerHTML = parameters[3].name;
val4BlockID.hidden = false;
case 3:
theAssociatedLabel = val3BlockID.querySelector("label[for='" + val3BlockID.id.replace("Block","ue") + "']");
theAssociatedLabel.innerHTML = parameters[2].name;
val3BlockID.hidden = false;
case 2:
theAssociatedLabel = val2BlockID.querySelector("label[for='" + val2BlockID.id.replace("Block","ue") + "']");
theAssociatedLabel.innerHTML = parameters[1].name;
val2BlockID.hidden = false;
}
}
}
} }
} }
@@ -104,9 +210,10 @@ function changeValueType(event){
</select> </select>
<label>Messwert</label> <label>Messwert</label>
</div> </div>
<button class="btn btn-outline-secondary " type="button" id="btnOperator${nextID}" name="btnOperator${nextID}">&gt;</button> <button class="btn btn-outline-secondary" style="min-width: 40px;" type="button" id="btnOperator${nextID}" name="btnOperator${nextID}">&gt;</button>
<div class="form-floating" id="valBlock${nextID}"> <div class="form-floating" id="valBlock${nextID}">
<input type="number" class="form-control" id="threshold${nextID}" name="threshold${nextID}" placeholder="0" value="0"></input> <input type="number" class="form-control" id="threshold${nextID}" name="threshold${nextID}" placeholder="0" value="0"></input>
<select class="form-select" id="thresholdOpt${nextID}" name="thresholdOpt${nextID}" placeholder="0" value="0"></select>
<label>Wert/Schwelle</label> <label>Wert/Schwelle</label>
</div> </div>
<button class='btn btn-outline-danger' type='button' id='btnDel${nextID}' data-delid="sensorSettings${nextID}" onclick="delAutoEntry(event)"><i class="bi bi-trash3"></i></button> <button class='btn btn-outline-danger' type='button' id='btnDel${nextID}' data-delid="sensorSettings${nextID}" onclick="delAutoEntry(event)"><i class="bi bi-trash3"></i></button>
@@ -132,10 +239,23 @@ function changeValueType(event){
</select> </select>
<label>Eigenschaft</label> <label>Eigenschaft</label>
</div> </div>
<button class="btn btn-outline-secondary " type="button" id="btnActOperator${nextID}" name="btnActOperator${nextID}">&gt;</button> <button class="btn btn-outline-secondary" style="min-width: 40px;" type="button" id="btnActOperator${nextID}" name="btnActOperator${nextID}">&gt;</button>
<div class="form-floating" id="actValBlock${nextID}"> <div class="form-floating" id="act1ValBlock${nextID}">
<input type="number" class="form-control" id="actValue${nextID}" name="actValue${nextID}" placeholder="0" value="0"></input> <input type="number" class="form-control" id="act1Value${nextID}" name="act1Value${nextID}" placeholder="0" value="0"></input>
<label>Sollwert</label> <select class="form-select" id="act1ValueOpt${nextID}" name="act1ValueOpt${nextID}"></select>
<label for="act1Value${nextID}">Wert 1</label>
</div>
<div class="form-floating" id="act2ValBlock{nextID}">
<input type="number" class="form-control" id="act2Value${nextID}" name="act2Value${nextID}" value="0"></input>
<label for="act2Value${nextID}">Wert 2</label>
</div>
<div class="form-floating" id="act3ValBlock{nextID}">
<input type="number" class="form-control" id="act3Value${nextID}" name="act3Value${nextID}" value="0"></input>
<label for="act3Value${nextID}">Wert 3</label>
</div>
<div class="form-floating" id="act4ValBlock{nextID}">
<input type="number" class="form-control" id="act4Value${nextID}" name="act4Value${nextID}" value="0"></input>
<label for="act4Value${nextID}">Wert 4</label>
</div> </div>
<button class='btn btn-outline-danger' type='button' id='btnActDel${nextID}' data-delid="actorSettings${nextID}" onclick="delAutoEntry(event)"><i class="bi bi-trash3"></i></button>`; <button class='btn btn-outline-danger' type='button' id='btnActDel${nextID}' data-delid="actorSettings${nextID}" onclick="delAutoEntry(event)"><i class="bi bi-trash3"></i></button>`;
@@ -175,25 +295,17 @@ function changeValueType(event){
.then(response => response.text()) .then(response => response.text())
.then(html => { .then(html => {
params = JSON.parse(html); params = JSON.parse(html);
document.getElementById("paramSelect"+ID).innerHTML = ""; if(params.parameters.length == 1){
if(params.Parameters.length == 1){
document.getElementById("paramBlock"+ID).hidden = true; document.getElementById("paramBlock"+ID).hidden = true;
}else{ }else{
document.getElementById("paramBlock"+ID).hidden = false; document.getElementById("paramBlock"+ID).hidden = false;
} }
params.Parameters.forEach((param, index) => {
var option = document.createElement("option");
option.text = param.name;
option.value = index;
option.setAttribute("data-type",param.type);
document.getElementById("paramSelect"+ID).appendChild(option);
document.getElementById("btnOperator"+ID).setAttribute("data-tglstates",JSON.stringify(param.operators));
document.getElementById("btnOperator"+ID).innerHTML = param.operators[0];
document.getElementById("btnOperator"+ID).addEventListener("click", tglOperators); document.getElementById("btnOperator"+ID).addEventListener("click", tglOperators);
clearOptions("paramSelect"+ID);
addOptions("paramSelect"+ID,params.parameters);
if(document.getElementById("btnLogic"+ID)) if(document.getElementById("btnLogic"+ID))
document.getElementById("btnLogic"+ID).addEventListener("click", tglOperators); document.getElementById("btnLogic"+ID).addEventListener("click", tglOperators);
document.getElementById("threshold"+ID).setAttribute("type",param.type);
});
document.getElementById("paramSelect"+ID).dispatchEvent(new Event('change')); document.getElementById("paramSelect"+ID).dispatchEvent(new Event('change'));
}) })
.catch(error => { .catch(error => {
@@ -204,9 +316,9 @@ function changeValueType(event){
function arrangeActorInputs(event){ function arrangeActorInputs(event){
ID = Number(event.currentTarget.id.replace("actorSelect","")); ID = Number(event.currentTarget.id.replace("actorSelect",""));
sensorID = document.getElementById("actorSelect"+ID).value; actorID = document.getElementById("actorSelect"+ID).value;
fetch("./ajax/actorDetails.php?actorID="+sensorID, { fetch("./ajax/actorDetails.php?actorID="+actorID, {
method: 'GET', method: 'GET',
headers: { headers: {
'X-Requested-From-Modal': 'a', 'X-Requested-From-Modal': 'a',
@@ -217,31 +329,14 @@ function changeValueType(event){
.then(html => { .then(html => {
params = JSON.parse(html); params = JSON.parse(html);
document.getElementById("actParamSelect"+ID).innerHTML = ""; document.getElementById("actParamSelect"+ID).innerHTML = "";
if(params.Parameters.length == 1){ if(params.commands.length == 1){
document.getElementById("actParamBlock"+ID).hidden = true; document.getElementById("actParamBlock"+ID).hidden = true;
}else{ }else{
document.getElementById("actParamBlock"+ID).hidden = false; document.getElementById("actParamBlock"+ID).hidden = false;
} }
params.Parameters.forEach((param, index) => {
var option = document.createElement("option"); clearOptions("actParamSelect"+ID);
option.text = param.name; addOptions("actParamSelect"+ID,params.commands);
option.value = index;
option.setAttribute("data-type",param.type);
document.getElementById("actParamSelect"+ID).appendChild(option);
if(param.operators === undefined){
document.getElementById("btnActOperator"+ID).dataset.tglstates = "[\"=\"]";
document.getElementById("btnActOperator"+ID).innerHTML = "=";
}else{
document.getElementById("btnActOperator"+ID).dataset.tglstates = JSON.stringify(param.operators);
document.getElementById("btnActOperator"+ID).innerHTML = param.operators[0];
}
document.getElementById("btnActOperator"+ID).addEventListener("click", tglOperators);
if(document.getElementById("btnActLogic"+ID)){
document.getElementById("btnActLogic"+ID).addEventListener("click", tglOperators);
}
//if(document.getElementById("actParamSelect"+ID).value == param.id)
// document.getElementById("actValue"+ID).setAttribute("type",param.type);
});
document.getElementById("actParamSelect"+ID).dispatchEvent(new Event('change')); document.getElementById("actParamSelect"+ID).dispatchEvent(new Event('change'));
}) })
.catch(error => { .catch(error => {
+265 -8
View File
@@ -1,3 +1,6 @@
let solarMqttClient;
let solarSvgUpdatePending = false;
const solarMQTT = { const solarMQTT = {
getMQTT: function () { getMQTT: function () {
const id = Math.random().toString(36).substring(7); const id = Math.random().toString(36).substring(7);
@@ -9,6 +12,7 @@ const solarMQTT = {
const client = mqtt.connect(connection, { const client = mqtt.connect(connection, {
rejectUnauthorized: false, rejectUnauthorized: false,
}); });
solarMqttClient = client;
client.on("message", messageReceived); client.on("message", messageReceived);
client.on("connect", function () { client.on("connect", function () {
@@ -24,6 +28,13 @@ const solarMQTT = {
client.subscribe("go-eCharger/270003/att"); client.subscribe("go-eCharger/270003/att");
client.subscribe("go-eCharger/270003/car"); client.subscribe("go-eCharger/270003/car");
client.subscribe("weatherStation/#"); client.subscribe("weatherStation/#");
client.subscribe("Gartenwasser/vorn/status");
client.subscribe("Gartenwasser/vorn/Timers");
client.subscribe("Gartenwasser/vorn/LastWatering");
client.subscribe("Gartenwasser/hinten/status");
client.subscribe("Gartenwasser/hinten/Timers");
client.subscribe("Gartenwasser/hinten/LastWatering");
client.subscribe("Gartenwasser/AutoWatering/#");
}); });
client.on("error", function (error) { client.on("error", function (error) {
//alert("MQTT Error: " + error); //alert("MQTT Error: " + error);
@@ -51,12 +62,30 @@ const solarMQTT = {
function messageReceived(topic, message) { function messageReceived(topic, message) {
mqttData[topic] = message; mqttData[topic] = message;
if (topic === "Gartenwasser/vorn/Timers" || topic === "Gartenwasser/hinten/Timers") {
wateringTimersLastSeen[topic] = Date.now();
}
if (topic.startsWith("Gartenwasser/") && (topic.endsWith("/status") || topic.endsWith("/Timers") || topic.endsWith("/LastWatering") || topic.endsWith("/RainStatus"))) {
updateWateringStatus();
}
if (topic == "solarManager/P_Load") { if (topic == "solarManager/P_Load") {
setTimeout(function () { solarSVG.updateValuesMQTT(mqttData) }, 200); //give the object tree some time to build up and receive all values if (!solarSvgUpdatePending) {
solarSvgUpdatePending = true;
setTimeout(function () {
solarSvgUpdatePending = false;
solarSVG.updateValuesMQTT(mqttData);
}, 1000);
}
}else if(topic == "weatherStation/windDeg"){ }else if(topic == "weatherStation/windDeg"){
setTimeout(function () { updateValuesWeather() }, 200); //give the object tree some time to build up and receive all values setTimeout(function () { updateValuesWeather() }, 200); //give the object tree some time to build up and receive all values
} }
} }
},
publish: function (topic, payload, callback) {
if (!solarMqttClient || !solarMqttClient.connected) {
throw new Error("MQTT ist noch nicht verbunden.");
}
solarMqttClient.publish(topic, typeof payload === "string" ? payload : JSON.stringify(payload), callback);
} }
} }
@@ -155,9 +184,6 @@ const solarSVG = {
htmlNode.getElementById("genText").innerHTML = "Autarkie: " + Math.round(aut) + "%"; htmlNode.getElementById("genText").innerHTML = "Autarkie: " + Math.round(aut) + "%";
htmlNode.getElementById("genInfoText").innerHTML = "Wirkungsgrad: " + Math.round(eff) + "%";// powerToString(p_wr); htmlNode.getElementById("genInfoText").innerHTML = "Wirkungsgrad: " + Math.round(eff) + "%";// powerToString(p_wr);
htmlNode.getElementById("gridText").innerHTML = powerToString(grid); htmlNode.getElementById("gridText").innerHTML = powerToString(grid);
htmlNode.getElementById("waterText").innerHTML = Math.round(waterHeight) / 10 + "cm"
htmlNode.getElementById("waterTemp").innerHTML = (waterTemp).toPrecision(3) + " °C"
htmlNode.getElementById("waterState").setAttribute("y", (100 - (waterHeight / 1500) * 100) + "%");
for (i = 0; i < pvn.length; i++) { for (i = 0; i < pvn.length; i++) {
htmlNode.getElementById("det_pv" + i + "P").innerHTML = powerToString(pvn[i]); htmlNode.getElementById("det_pv" + i + "P").innerHTML = powerToString(pvn[i]);
@@ -530,6 +556,7 @@ var chartSettings = {
} }
function openModal(type) { function openModal(type) {
document.getElementById("modalSaveBtn").style.display = type === "watering" ? "none" : "";
switch(type){ switch(type){
case "CarEG": case "CarEG":
document.getElementById("modal-title").innerHTML = "Autoladung EG"; document.getElementById("modal-title").innerHTML = "Autoladung EG";
@@ -543,6 +570,10 @@ var chartSettings = {
document.getElementById("modal-title").innerHTML = "Steuerung Heizstab"; document.getElementById("modal-title").innerHTML = "Steuerung Heizstab";
contentURL = "ajax/heater.php"; contentURL = "ajax/heater.php";
break; break;
case "watering":
document.getElementById("modal-title").innerHTML = "Gartenbewässerung";
contentURL = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php";
break;
default: default:
document.getElementById("modal-title").innerHTML = "Fehler"; document.getElementById("modal-title").innerHTML = "Fehler";
contentURL = ""; contentURL = "";
@@ -665,6 +696,9 @@ var chartSettings = {
} }
document.getElementById("modal-slider").dispatchEvent(new Event('input')); document.getElementById("modal-slider").dispatchEvent(new Event('input'));
break; break;
case "watering":
initializeWateringModal();
break;
} }
}) })
@@ -673,6 +707,233 @@ var chartSettings = {
}); });
} }
const wateringTimersLastSeen = {};
const wateringZones = {
troughs: { topic: "Gartenwasser/vorn", valve: 1 },
frontGarden: { topic: "Gartenwasser/vorn", valve: 6 },
raisedBeds: { topic: "Gartenwasser/hinten", valve: 6 }
};
function wateringCommand(topic, command, directTopic = false) {
const feedback = document.getElementById("watering-feedback");
const targetTopic = directTopic ? topic : topic + "/set";
const payload = typeof command === "string" ? command : JSON.stringify(command);
const endpoint = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php";
const showFeedback = (message, type) => {
if (!feedback) return;
feedback.textContent = message;
feedback.className = "alert alert-" + type + " mb-3";
feedback.style.whiteSpace = "pre-line";
};
fetch(endpoint, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ topic: targetTopic, payload: payload })
}).then(async (response) => {
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.message || "Unbekannter MQTT-Fehler.");
showFeedback("Befehl gesendet.\nTopic: " + result.topic + "\nInhalt: " + result.payload, "success");
}).catch((error) => {
showFeedback("Befehl konnte nicht gesendet werden: " + error.message + "\nTopic: " + targetTopic + "\nInhalt: " + payload, "danger");
});
}
function initializeWateringModal() {
document.querySelectorAll("[data-watering-toggle]").forEach((button) => {
const updateButton = () => {
const zone = wateringZones[button.dataset.wateringZone];
const state = wateringLabel(zone.topic, zone.valve);
button.textContent = state === "aus" ? "Ein" : "Aus";
button.classList.toggle("btn-success", state === "aus");
button.classList.toggle("btn-danger", state !== "aus");
};
button.addEventListener("click", () => {
const zone = wateringZones[button.dataset.wateringZone];
if (wateringLabel(zone.topic, zone.valve) === "aus") {
const timerInput = document.querySelector(`[data-watering-timer='${button.dataset.wateringZone}']`);
const timer = Math.min(120, Math.max(1, Number(timerInput.value) || 30));
wateringCommand(zone.topic, { valve: zone.valve, set: "open", timer: timer });
} else {
wateringCommand(zone.topic, { valve: zone.valve, set: "close" });
}
});
updateButton();
});
document.querySelectorAll("[data-watering-auto-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(",");
const modes = button.dataset.wateringAuto.split(",");
const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index]));
topics.forEach((topic, index) => {
wateringCommand(topic + "/auto", active ? "Stop" : modes[index], true);
});
});
updateWateringAutoButton(button);
});
updateWateringStatus();
}
function wateringJson(topic) {
const value = mqttData[topic];
if (!value) return null;
try {
return JSON.parse(typeof value === "string" ? value : new TextDecoder().decode(value));
} catch (error) {
return null;
}
}
function wateringHistory(topic, valve) {
const data = wateringJson(topic + "/LastWatering");
if (!data) return { dateTime: "--", duration: "--" };
return {
dateTime: data["wateringDateTime" + valve] || "--",
duration: data["wateringDurationSecs" + valve] !== undefined
? wateringTimeLabel(Number(data["wateringDurationSecs" + valve]))
: "--"
};
}
function wateringAutoActive(topic, mode) {
const timers = wateringJson(topic + "/Timers");
return !!(timers && timers.auto === mode);
}
function wateringValue(data, valve) {
if (!data) return null;
const candidates = [
data.timers && data.timers[valve],
data.timers && data.timers[String(valve)],
data[valve],
data[String(valve)],
data.valves && data.valves[valve],
data.valves && data.valves[String(valve)],
data.status && data.status[valve],
data.status && data.status[String(valve)],
data.valveState && data.valveState[valve],
data.valveState && data.valveState[String(valve)],
data.setValveState && data.setValveState[valve],
data.setValveState && data.setValveState[String(valve)]
];
for (const candidate of candidates) {
if (candidate !== undefined) return candidate;
}
return null;
}
function wateringState(value) {
if (value && typeof value === "object") {
return value.state ?? value.status ?? value.valveState ?? value.set;
}
return value;
}
function wateringSeconds(value) {
if (value && typeof value === "object") {
value = value.remaining ?? value.timer ?? value.seconds ?? value.time;
}
return Number(value);
}
function wateringLabel(topic, valve) {
const timers = wateringJson(topic + "/Timers");
const status = wateringJson(topic + "/status");
const lastSeen = wateringTimersLastSeen[topic + "/Timers"];
if (!lastSeen || Date.now() - lastSeen > 3000) return "aus";
const remaining = wateringValue(timers, valve);
if (remaining !== null) {
const seconds = wateringSeconds(remaining);
if (seconds !== 0) return wateringTimeLabel(Math.abs(seconds));
return "aus";
}
const state = wateringState(wateringValue(status, valve));
if (state === true || state === 1 || state === "1" || state === "open" || state === "OPEN") return "an";
if (state === false || state === 0 || state === "0" || state === "close" || state === "closed" || state === "CLOSED") return "aus";
return "--";
}
function wateringTimeLabel(seconds) {
if (seconds < 60) return seconds + "s";
return Math.ceil(seconds / 60) + "m";
}
function wateringIsPaused(topic, valve) {
const timers = wateringJson(topic + "/Timers");
const remaining = wateringValue(timers, valve);
return remaining !== null && wateringSeconds(remaining) < 0;
}
// Liest den von auto_watering.py stündlich veröffentlichten Regen-Status
// (retained, siehe Gartenwasser/AutoWatering/<zone>/RainStatus) und sagt,
// ob für die Zone heute noch eine automatische Bewässerung aussteht.
function wateringAutoPlanned(autoZoneKey) {
const status = wateringJson("Gartenwasser/AutoWatering/" + autoZoneKey + "/RainStatus");
return !!(status && status.would_water && !status.watered_recently);
}
function updateWateringStatus() {
const indicators = [
["wateringFrontStatus", "wateringFrontIcon", "Vorn", "Gartenwasser/vorn", 6, "garten_vorn"],
["wateringTroughStatus", "wateringTroughIcon", "Tröge", "Gartenwasser/vorn", 1, "troege"],
["wateringBedsStatus", "wateringBedsIcon", "Beete", "Gartenwasser/hinten", 6, "hochbeete"]
];
indicators.forEach(([statusId, iconId, name, topic, valve, autoZoneKey]) => {
const status = document.getElementById(statusId);
const icon = document.getElementById(iconId);
if (!status || !icon) return;
const state = wateringLabel(topic, valve);
const planned = state === "aus" && wateringAutoPlanned(autoZoneKey);
updateWateringIndicator(status, icon, name, state, wateringIsPaused(topic, valve), planned);
});
document.querySelectorAll("[data-watering-toggle]").forEach((button) => {
const zone = wateringZones[button.dataset.wateringZone];
const state = wateringLabel(zone.topic, zone.valve);
button.textContent = state === "aus" ? "Ein" : "Aus";
button.classList.toggle("btn-success", state === "aus");
button.classList.toggle("btn-danger", state !== "aus");
});
document.querySelectorAll("[data-watering-auto-toggle]").forEach(updateWateringAutoButton);
[
["troughs", "Gartenwasser/vorn", 1],
["frontGarden", "Gartenwasser/vorn", 6],
["raisedBeds", "Gartenwasser/hinten", 6]
].forEach(([zone, topic, valve]) => {
const history = wateringHistory(topic, valve);
const dateElement = document.querySelector(`[data-watering-last='${zone}']`);
const durationElement = document.querySelector(`[data-watering-duration='${zone}']`);
if (dateElement) dateElement.textContent = history.dateTime;
if (durationElement) durationElement.textContent = history.duration;
});
}
function updateWateringAutoButton(button) {
const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(",");
const modes = button.dataset.wateringAuto.split(",");
const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index]));
if (!button.dataset.wateringLabel) {
button.dataset.wateringLabel = button.textContent.replace(/^Automatik\s+/, "");
}
button.textContent = (active ? "Aus: " : "Ein: ") + button.dataset.wateringLabel;
button.classList.toggle("btn-outline-primary", !active);
button.classList.toggle("btn-outline-danger", active);
}
function updateWateringIndicator(status, icon, name, state, paused, planned) {
// "Aus" bleibt bei nicht geplant stehen; bei geplant ersetzt die
// Stoppuhr (U+23F1, ohne Emoji-Variationsselektor -> einfarbig, folgt
// der Icon-Farbe) den Text, um Platz auf der textPath zu sparen.
status.textContent = state === "aus" ? (planned ? "⏱" : "Aus") : state;
// Geplant-Blau ("#3A6292") entspricht dem Wassertropfen-Icon (id="water").
const color = state !== "aus" ? (paused ? "#ffc107" : "#20c997") : planned ? "#3A6292" : "#6c757d";
icon.setAttribute("color", color);
icon.setAttribute("fill", color);
icon.style.color = color;
icon.querySelector("title").textContent = name + ": " + (state === "aus" && planned ? "Aus (Automatik heute noch geplant)" : state);
}
window.setInterval(updateWateringStatus, 1000);
function submitFormAjax(event) { function submitFormAjax(event) {
let xmlhttp = window.XMLHttpRequest ? let xmlhttp = window.XMLHttpRequest ?
new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP"); new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP");
@@ -729,9 +990,6 @@ var chartSettings = {
chrt.options.plugins.annotation.annotations = await response2.json(); chrt.options.plugins.annotation.annotations = await response2.json();
} }
chrt.options.events = ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove']; chrt.options.events = ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'];
if(animation)
chrt.update();
else
chrt.update("none"); chrt.update("none");
} catch (error) { } catch (error) {
console.log(error.message); console.log(error.message);
@@ -780,7 +1038,6 @@ var chartSettings = {
} catch (error) { } catch (error) {
console.log(error.message); console.log(error.message);
} }
setTimeout(function () { getData(chart, url, sunrise) }, 5 * 60 * 1000); //renew data every 5 min.
} }
function powerToString(power) { function powerToString(power) {
+116 -27
View File
@@ -19,6 +19,7 @@ from modules.tahoma_module import TahomaModule
from modules.wled_module import WLEDModule from modules.wled_module import WLEDModule
from modules.mqtt_module import MQTTModule from modules.mqtt_module import MQTTModule
from modules.shelly_module import ShellyModule from modules.shelly_module import ShellyModule
from modules.logic_module import LogicModule #nicht Vergessen neue Module auch in Zeile 546 einzufügen!!
# Logging # Logging
logging.basicConfig( logging.basicConfig(
@@ -142,6 +143,14 @@ class Config:
return 2 return 2
return self._get_int('wled', 'timeout', 2) return self._get_int('wled', 'timeout', 2)
# LOGIC
@property
def logic_enable(self) -> bool:
if not self.config.has_section('logic'):
return False
return self._get_bool('logic', 'enable', False)
# MQTT # MQTT
@property @property
def mqtt_enable(self) -> bool: def mqtt_enable(self) -> bool:
@@ -236,6 +245,33 @@ class Config:
class DatabaseManager: class DatabaseManager:
"""Zentrale Datenbank-Verwaltung - ALLE DB-Operationen hier""" """Zentrale Datenbank-Verwaltung - ALLE DB-Operationen hier"""
PARAMETER_TYPES = {
0: "bool",
1: "integer", # Integer value
2: "float", # Floating point number
3: "string", # Text string
4: "undefined",
5: "time",
6: "array",
7: "deltatime",
8: "date",
9: "datetime",
}
PARAMETER_TYPES_MAPPING = {
"bool": 0,
"boolean" : 0,
"integer" : 1, # Integer value
"float" : 2, # Floating point number
"number" : 2, # Floating point number
"string" : 3, # Text string
"time" : 5,
"array": 6,
"deltatime" : 7,
"date" : 8,
"datetime" : 9,
}
def __init__(self, host: str, database: str, user: str, password: str, port: int = 3306): def __init__(self, host: str, database: str, user: str, password: str, port: int = 3306):
self.host = host self.host = host
self.database = database self.database = database
@@ -272,13 +308,13 @@ class DatabaseManager:
try: try:
cursor = self.connection.cursor() cursor = self.connection.cursor()
cursor.execute("SET FOREIGN_KEY_CHECKS=0") cursor.execute("SET FOREIGN_KEY_CHECKS=0")
cursor.execute("TRUNCATE state_types")
cursor.execute("DELETE FROM command_parameters") cursor.execute("TRUNCATE command_parameters")
cursor.execute("DELETE FROM actor_commands") cursor.execute("TRUNCATE actor_commands")
cursor.execute("DELETE FROM actor_states") cursor.execute("TRUNCATE actor_states")
cursor.execute("DELETE FROM actors") cursor.execute("TRUNCATE actors")
cursor.execute("DELETE FROM sensor_states") cursor.execute("TRUNCATE sensor_states")
cursor.execute("DELETE FROM sensors") cursor.execute("TRUNCATE sensors")
cursor.execute("SET FOREIGN_KEY_CHECKS=1") cursor.execute("SET FOREIGN_KEY_CHECKS=1")
@@ -289,6 +325,8 @@ class DatabaseManager:
logger.error(f"✗ Fehler beim Leeren der Tabellen: {e}") logger.error(f"✗ Fehler beim Leeren der Tabellen: {e}")
self.connection.rollback() self.connection.rollback()
def insert_actor(self, device_type: str, name: str, url: str, def insert_actor(self, device_type: str, name: str, url: str,
commands: list, states: list) -> bool: commands: list, states: list) -> bool:
""" """
@@ -306,22 +344,35 @@ class DatabaseManager:
# Actor einfügen # Actor einfügen
query = """ query = """
INSERT INTO actors (type, name, parameters, url) INSERT INTO actors (type, name, url)
VALUES (%s, %s, NULL, %s) VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
type = VALUES(type),
name = VALUES(name)
""" """
cursor.execute(query, (device_type, name, url)) cursor.execute(query, (device_type, name, url))
actor_id = cursor.lastrowid query = """
SELECT ID FROM actors where url = %s
"""
cursor.execute(query, (url))
actor_id = cursor.fetchone()[0]
# Commands einfügen # Commands einfügen
for cmd in commands: for cmd in commands:
command_name = cmd.get('command', '') command_name = cmd.get('command', '')
command_url = cmd.get('url', '')
cmd_query = """ cmd_query = """
INSERT INTO actor_commands (actor_id, command_name) INSERT INTO actor_commands (actor_id, command_name, command_url)
VALUES (%s, %s) VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
command_name = VALUES(command_name)
""" """
cursor.execute(cmd_query, (actor_id, command_name)) cursor.execute(cmd_query, (actor_id, command_name, command_url))
command_id = cursor.lastrowid query = """
SELECT ID FROM actor_commands where actor_id = %s and command_url = %s
"""
cursor.execute(query, (actor_id, command_url))
command_id = cursor.fetchone()[0]
# Parameter einfügen # Parameter einfügen
for param in cmd.get('parameters', []): for param in cmd.get('parameters', []):
@@ -329,12 +380,18 @@ class DatabaseManager:
INSERT INTO command_parameters INSERT INTO command_parameters
(command_id, parameter_name, parameter_type, min_value, max_value, possible_values, url) (command_id, parameter_name, parameter_type, min_value, max_value, possible_values, url)
VALUES (%s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
parameter_name = VALUES(parameter_name),
parameter_type = VALUES(parameter_type),
min_value = VALUES(min_value),
max_value = VALUES(max_value),
possible_values = VALUES(possible_values)
""" """
param_name = param.get('name', '') param_name = param.get('name', '')
param_type = param.get('type', '') param_type = param.get('type', '')
min_val = param.get('min') min_val = param.get('min')
max_val = param.get('max') max_val = param.get('max')
possible_vals = json.dumps(param.get('values')) if 'values' in param else None possible_vals = json.dumps(param.get('values')) if 'values' in param else ""
param_url = param.get('url') param_url = param.get('url')
cursor.execute(param_query, cursor.execute(param_query,
@@ -344,16 +401,22 @@ class DatabaseManager:
for state in states: for state in states:
state_query = """ state_query = """
INSERT INTO actor_states INSERT INTO actor_states
(actor_id, state_name, state_type, current_value, unit, url) (actor_id, state_name, state_type, current_value, unit, url, possible_values)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
state_name = VALUES(state_name),
state_type = VALUES(state_type),
current_value = VALUES(current_value),
possible_values = VALUES(possible_values),
unit = VALUES(unit)
""" """
state_name = state.get('name', '') state_name = state.get('name', '')
state_type = state.get('type', 0) state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4)
current_value = str(state.get('current_value', '')) if 'current_value' in state else None current_value = str(state.get('current_value', '')) if 'current_value' in state else None
unit = state.get('unit') unit = state.get('unit')
state_url = state.get('url') state_url = state.get('url')
possible_vals = json.dumps(state.get('values')) if 'values' in state else ""
cursor.execute(state_query, (actor_id, state_name, state_type, current_value, unit, state_url)) cursor.execute(state_query, (actor_id, state_name, state_type, current_value, unit, state_url, possible_vals))
self.connection.commit() self.connection.commit()
cursor.close() cursor.close()
@@ -364,6 +427,17 @@ class DatabaseManager:
self.connection.rollback() self.connection.rollback()
return False return False
def fillVariableTypes(self):
cursor = self.connection.cursor()
query = """
INSERT INTO state_types (id, type)
VALUES (%s, %s)
ON DUPLICATE KEY UPDATE
type = VALUES(type)
"""
for id, parameter_type in self.PARAMETER_TYPES.items():
cursor.execute(query, (id, parameter_type))
def insert_sensor(self, device_type: str, name: str, url: str, states: list) -> bool: def insert_sensor(self, device_type: str, name: str, url: str, states: list) -> bool:
""" """
Fügt einen Sensor in die Datenbank ein Fügt einen Sensor in die Datenbank ein
@@ -379,21 +453,34 @@ class DatabaseManager:
# Sensor einfügen # Sensor einfügen
query = """ query = """
INSERT INTO sensors (type, name, parameters, url) INSERT INTO actors (type, name, url)
VALUES (%s, %s, NULL, %s) VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
type = VALUES(type),
name = VALUES(name)
""" """
cursor.execute(query, (device_type, name, url)) cursor.execute(query, (device_type, name, url))
sensor_id = cursor.lastrowid sensor_id = cursor.lastrowid
query = """
SELECT ID FROM actors where url = %s
"""
cursor.execute(query, (url))
sensor_id = cursor.fetchone()[0]
# States einfügen # States einfügen
for state in states: for state in states:
state_query = """ state_query = """
INSERT INTO sensor_states INSERT INTO actor_states
(sensor_id, state_name, state_type, current_value, unit, url) (actor_id, state_name, state_type, current_value, unit, url)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
state_name = VALUES(state_name),
state_type = VALUES(state_type),
current_value = VALUES(current_value),
unit = VALUES(unit)
""" """
state_name = state.get('name', '') state_name = state.get('name', '')
state_type = state.get('type', 0) state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4)
current_value = str(state.get('current_value', '')) if 'current_value' in state else None current_value = str(state.get('current_value', '')) if 'current_value' in state else None
unit = state.get('unit') unit = state.get('unit')
state_url = state.get('url') state_url = state.get('url')
@@ -461,12 +548,14 @@ def main():
total_actors = 0 total_actors = 0
total_sensors = 0 total_sensors = 0
db.fillVariableTypes()
# Module initialisieren # Module initialisieren
modules = [ modules = [
TahomaModule(config), TahomaModule(config),
WLEDModule(config), WLEDModule(config),
MQTTModule(config), MQTTModule(config),
ShellyModule(config) ShellyModule(config),
LogicModule(config)
] ]
# Jedes Modul durchlaufen # Jedes Modul durchlaufen
@@ -52,6 +52,7 @@ class BaseModule(ABC):
'type': str, 'type': str,
'min': float (optional), 'min': float (optional),
'max': float (optional), 'max': float (optional),
'url': str, # Eindeutige ID/URL
'values': list (optional) 'values': list (optional)
} }
] ]
@@ -77,6 +78,7 @@ class BaseModule(ABC):
'name': str, 'name': str,
'type': int/str, 'type': int/str,
'current_value': any, 'current_value': any,
'url': str, # Eindeutige ID/URL
'unit': str (optional) 'unit': str (optional)
} }
] ]
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
WLED Module
Enthält NUR Logik-spezifische Geräte-Discovery Logik
KEINE Datenbank-Operationen!
"""
import requests
import socket
import concurrent.futures
import logging
from typing import List, Dict, Optional, Tuple
from modules.base_module import BaseModule
logger = logging.getLogger(__name__)
class LogicModule(BaseModule):
"""
Logic Modul - Implementiert BaseModule Interface
Gibt nur Actors zurück, KEINE DB-Operationen
"""
def is_enabled(self) -> bool:
"""Prüft ob Logic aktiviert ist"""
return self.config.logic_enable
def discover(self) -> Tuple[List[Dict], List[Dict]]:
"""
Erzeugt Einträge für Sensorunabhängige Funktionen (Timer, Sonnenauf/untergang, Uhrzeiten, etc.)
Returns:
Tuple (actors, sensors) - WLED sind immer Actors
"""
logger.info("\n" + "=" * 60)
logger.info("LOGIC-GERÄTE WERDEN ERZEUGT")
logger.info("=" * 60)
actors = []
sensors = []
states = []
states.append({
'name': 'Uhrzeit',
'url': 'time',
'type': 'time',
'current_value': '13:45'
})
states.append({
'name': 'Datum',
'url': 'date',
'type': 'date',
'current_value': '20.03.2026'
})
states.append({
'name': 'Sonnenaufgang',
'url': 'sunrise',
'type': 'deltatime',
'current_value': '00:00'
})
states.append({
'name': 'Sonnenuntergang',
'url': 'sunset',
'type': 'deltatime',
'current_value': '00:00'
})
sensors.append({'type': 'LOGIC',
'name': "Zeitpunkt",
'url': f"Logic",
'commands': [],
'states': states})
return actors, sensors
@@ -255,10 +255,13 @@ class MQTTDeviceConverter:
device_info = device_data.get('device_info') device_info = device_data.get('device_info')
# Gerätename vom ersten Entity oder aus device_info # Gerätename vom ersten Entity oder aus device_info
device_name = device_info.get('name', device_id) device_name = device_info.get('name', device_id)
if not device_name or device_name == device_id: device_mf = device_info.get('model', device_info.get('mdl', device_info.get('mf','MQTT')))
if(device_info.get('mf','MQTT') == "OpenDTU"):
device_mf = "Wechselrichter"
#if not device_name or device_name == device_id:
# Fallback: Name vom ersten Entity # Fallback: Name vom ersten Entity
if entities: # if entities:
device_name = entities[0]['config'].get('name', device_id) # device_name = entities[0]['config'].get('name', device_id)
# Device URL # Device URL
device_url = f"mqtt://{device_id}" device_url = f"mqtt://{device_id}"
@@ -279,11 +282,13 @@ class MQTTDeviceConverter:
component = entity['component'] component = entity['component']
object_id = entity.get('object_id', 'unknown') object_id = entity.get('object_id', 'unknown')
config = entity['config'] config = entity['config']
if(device_mf == "MQTT"):
device_mf = config.get('device', device_info.get('dev',{'model': 'MQTT'})).get('model', device_info.get('mdl', device_info.get('mf','MQTT')))
# Command aus der Entity erstellen # Command aus der Entity erstellen
command_entry = MQTTDeviceConverter._entity_to_command(component, object_id, config) command_entry = MQTTDeviceConverter._entity_to_commands(component, object_id, config)
if command_entry: if command_entry:
commands.append(command_entry) commands.extend(command_entry)
# States aus der Entity extrahieren # States aus der Entity extrahieren
entity_states = MQTTDeviceConverter._entity_to_states(component, object_id, config) entity_states = MQTTDeviceConverter._entity_to_states(component, object_id, config)
@@ -292,7 +297,7 @@ class MQTTDeviceConverter:
if commands: # Nur Actor erstellen wenn Commands vorhanden if commands: # Nur Actor erstellen wenn Commands vorhanden
actor_result = { actor_result = {
'name': device_name, 'name': device_name,
'type': f"mqtt_device", # Allgemeiner Typ für Multi-Entity-Geräte 'type': device_mf, # Allgemeiner Typ für Multi-Entity-Geräte
'url': device_url, 'url': device_url,
'commands': commands, 'commands': commands,
'states': states 'states': states
@@ -306,7 +311,8 @@ class MQTTDeviceConverter:
component = entity['component'] component = entity['component']
object_id = entity.get('object_id', 'unknown') object_id = entity.get('object_id', 'unknown')
config = entity['config'] config = entity['config']
if(device_mf == "MQTT"):
device_mf = device_info.get('model', device_info.get('mdl', device_info.get('mf','MQTT')))
# States aus der Entity extrahieren # States aus der Entity extrahieren
entity_states = MQTTDeviceConverter._entity_to_states(component, object_id, config) entity_states = MQTTDeviceConverter._entity_to_states(component, object_id, config)
states.extend(entity_states) states.extend(entity_states)
@@ -314,7 +320,7 @@ class MQTTDeviceConverter:
if states: # Nur Sensor erstellen wenn States vorhanden if states: # Nur Sensor erstellen wenn States vorhanden
sensor_result = { sensor_result = {
'name': device_name, 'name': device_name,
'type': f"mqtt_device", 'type': device_mf,
'url': device_url, 'url': device_url,
'states': states 'states': states
} }
@@ -322,7 +328,7 @@ class MQTTDeviceConverter:
return actor_result, sensor_result return actor_result, sensor_result
@staticmethod @staticmethod
def _entity_to_command(component: str, object_id: str, config: Dict) -> Optional[Dict]: def _entity_to_commands(component: str, object_id: str, config: Dict) -> Optional[Dict]:
""" """
Konvertiert eine MQTT Entity in ein Command Konvertiert eine MQTT Entity in ein Command
@@ -336,19 +342,21 @@ class MQTTDeviceConverter:
""" """
# Command Topic - verschiedene mögliche Feldnamen # Command Topic - verschiedene mögliche Feldnamen
command_topic = config.get('command_topic') or config.get('cmd_t') or config.get('temperature_command_topic') command_topic = config.get('command_topic') or config.get('cmd_t') or config.get('temperature_command_topic')
base_topic = config.get('~','')
command_topic = command_topic.replace("~",base_topic)
if not command_topic: if not command_topic:
return None return None
# Command-Name aus object_id ableiten # Command-Name aus object_id ableiten
command_name = object_id.replace('_', ' ').title().replace(' ', '') #command_name =
# Oder aus dem Namen # Oder aus dem Namen
entity_name = config.get('name', object_id) command_name = config.get('name', object_id.replace('_', ' ').title().replace(' ', ''))
command_entry = { command_entry = [{
'command': command_name, 'command': command_name,
'parameters': [] 'parameters': [],
} 'url': command_topic
}]
# Parameter basierend auf Component-Typ # Parameter basierend auf Component-Typ
if component == 'number': if component == 'number':
@@ -375,7 +383,7 @@ class MQTTDeviceConverter:
if unit: if unit:
param['unit'] = unit param['unit'] = unit
command_entry['parameters'].append(param) command_entry[0]['parameters'].append(param)
elif component == 'select': elif component == 'select':
# Select hat Optionen # Select hat Optionen
@@ -389,7 +397,7 @@ class MQTTDeviceConverter:
if options: if options:
param['values'] = options param['values'] = options
command_entry['parameters'].append(param) command_entry[0]['parameters'].append(param)
elif component in ['switch', 'light']: elif component in ['switch', 'light']:
# Switch/Light haben on/off # Switch/Light haben on/off
@@ -402,7 +410,7 @@ class MQTTDeviceConverter:
config.get('payload_off', config.get('pl_off', 'OFF')) config.get('payload_off', config.get('pl_off', 'OFF'))
] ]
} }
command_entry['parameters'].append(param) command_entry[0]['parameters'].append(param)
# Brightness für Light # Brightness für Light
brightness_cmd_topic = ( brightness_cmd_topic = (
@@ -410,7 +418,7 @@ class MQTTDeviceConverter:
config.get('bri_cmd_t') config.get('bri_cmd_t')
) )
if component == 'light' and brightness_cmd_topic: if component == 'light' and brightness_cmd_topic:
command_entry['parameters'].append({ command_entry[0]['parameters'].append({
'name': 'brightness', 'name': 'brightness',
'type': 'integer', 'type': 'integer',
'min': 0, 'min': 0,
@@ -432,7 +440,7 @@ class MQTTDeviceConverter:
'max': 100, 'max': 100,
'url': set_pos_topic 'url': set_pos_topic
} }
command_entry['parameters'].append(param) command_entry[0]['parameters'].append(param)
else: else:
# Nur open/close/stop # Nur open/close/stop
param = { param = {
@@ -441,7 +449,7 @@ class MQTTDeviceConverter:
'url': command_topic, 'url': command_topic,
'values': ['OPEN', 'CLOSE', 'STOP'] 'values': ['OPEN', 'CLOSE', 'STOP']
} }
command_entry['parameters'].append(param) command_entry[0]['parameters'].append(param)
elif component == 'button': elif component == 'button':
# Button hat normalerweise keinen Parameter, nur das Topic # Button hat normalerweise keinen Parameter, nur das Topic
@@ -450,7 +458,7 @@ class MQTTDeviceConverter:
'type': 'trigger', 'type': 'trigger',
'url': command_topic 'url': command_topic
} }
command_entry['parameters'].append(param) command_entry[0]['parameters'].append(param)
elif component == 'climate': elif component == 'climate':
# Climate hat Temperatur-Setpoint # Climate hat Temperatur-Setpoint
@@ -469,8 +477,9 @@ class MQTTDeviceConverter:
param['min'] = config['min_temp'] param['min'] = config['min_temp']
if 'max_temp' in config: if 'max_temp' in config:
param['max'] = config['max_temp'] param['max'] = config['max_temp']
command_entry[0]['command'] = "Solltemperatur"
command_entry[0]['parameters'].append(param)
command_entry['parameters'].append(param)
mode_cmd_topic = ( mode_cmd_topic = (
config.get('mode_command_topic') or config.get('mode_command_topic') or
config.get('mode_cmd_t') config.get('mode_cmd_t')
@@ -482,7 +491,10 @@ class MQTTDeviceConverter:
'url': mode_cmd_topic, 'url': mode_cmd_topic,
'values': config.get('modes', []) 'values': config.get('modes', [])
} }
command_entry['parameters'].append(param) command_entry.append({
'command': "Modus",
'parameters': [param]
})
else: else:
# Generischer Command mit dem Topic # Generischer Command mit dem Topic
param = { param = {
@@ -512,8 +524,7 @@ class MQTTDeviceConverter:
# State Topic - verschiedene mögliche Feldnamen prüfen # State Topic - verschiedene mögliche Feldnamen prüfen
state_topic = ( state_topic = (
config.get('state_topic') or config.get('state_topic') or
config.get('stat_t') or # Abkürzung config.get('stat_t')
config.get('~') and config.get('stat_t') # Mit Base Topic
) )
# Bei number/select: oft kein separates state_topic, dann command_topic verwenden # Bei number/select: oft kein separates state_topic, dann command_topic verwenden
@@ -523,6 +534,8 @@ class MQTTDeviceConverter:
state_topic = config.get('command_topic') or config.get('cmd_t') state_topic = config.get('command_topic') or config.get('cmd_t')
if state_topic: if state_topic:
base_topic = config.get('~','')
state_topic = state_topic.replace("~",base_topic)
state_entry = { state_entry = {
'name': object_id, 'name': object_id,
'type': 'string', 'type': 'string',
@@ -654,7 +667,6 @@ class MQTTModule(BaseModule):
actor_data, sensor_data = MQTTDeviceConverter.convert_device_to_actors_and_sensors( actor_data, sensor_data = MQTTDeviceConverter.convert_device_to_actors_and_sensors(
device_id, device_data device_id, device_data
) )
if actor_data: if actor_data:
actors.append(actor_data) actors.append(actor_data)
@@ -11,6 +11,7 @@ KEINE Datenbank-Operationen!
import json import json
import requests import requests
import socket import socket
import concurrent.futures
import logging import logging
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
from modules.base_module import BaseModule from modules.base_module import BaseModule
@@ -28,22 +29,47 @@ class ShellyDiscovery:
SHELLY_MDNS_SERVICE = "_http._tcp.local." SHELLY_MDNS_SERVICE = "_http._tcp.local."
COMMON_PORTS = [80] COMMON_PORTS = [80]
def __init__(self, network_range: str = "192.168.1"): def __init__(self):
self.network_range = network_range
self.devices = [] self.devices = []
def scan_network(self, start_ip: int = 2, end_ip: int = 254, timeout: float = 0.3) -> List[str]:
"""
Scannt das Netzwerk nach aktiven Hosts
Args: def scan_network(self, network: str = None, max_threads: int = 50) -> List[str]:
start_ip: Start IP (letztes Oktett) """Scannt das Netzwerk nach Shelly-Geräten"""
end_ip: End IP (letztes Oktett) if network is None:
timeout: Timeout für Socket-Verbindung try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
network_prefix = '.'.join(local_ip.split('.')[:-1])
except:
logger.warning("Konnte lokale IP nicht ermitteln, verwende 192.168.1.x")
network_prefix = "192.168.1"
else:
network_prefix = '.'.join(network.split('.')[:3])
logger.info(f"Scanne Netzwerk {network_prefix}.0/24 nach Shelly-Geräten...")
def check_ip(ip):
device = ShellyDiscovery.is_shelly_device(ip)
if device:
return device
return None
shelly_devices = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
futures = [executor.submit(check_ip, f"{network_prefix}.{i}")
for i in range(1, 255)]
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result:
shelly_devices.append(result)
#logger.info(f"Shelly-Gerät gefunden: {result}")
return shelly_devices
"""def scan_network(self, start_ip: int = 2, end_ip: int = 254, timeout: float = 0.3) -> List[str]:
Returns:
Liste von erreichbaren IP-Adressen
"""
active_hosts = [] active_hosts = []
logger.info(f"Scanne Netzwerk {self.network_range}.{start_ip}-{end_ip}...") logger.info(f"Scanne Netzwerk {self.network_range}.{start_ip}-{end_ip}...")
@@ -64,8 +90,8 @@ class ShellyDiscovery:
logger.info(f"{len(active_hosts)} aktive Hosts gefunden") logger.info(f"{len(active_hosts)} aktive Hosts gefunden")
return active_hosts return active_hosts
"""
def is_shelly_device(self, ip: str) -> Optional[Dict]: def is_shelly_device(ip: str) -> Optional[Dict]:
""" """
Prüft ob ein Host ein Shelly-Gerät ist Prüft ob ein Host ein Shelly-Gerät ist
@@ -75,7 +101,7 @@ class ShellyDiscovery:
Returns: Returns:
Device Info Dict wenn Shelly, sonst None Device Info Dict wenn Shelly, sonst None
""" """
logger.info(f"Suche Shelly Getät unter: {ip}") #logger.info(f"Suche Shelly Getät unter: {ip}")
try: try:
# Versuche Gen2 API (neuere Shelly-Geräte) # Versuche Gen2 API (neuere Shelly-Geräte)
response = requests.get( response = requests.get(
@@ -96,13 +122,13 @@ class ShellyDiscovery:
try: try:
# Versuche Gen1 API (ältere Shelly-Geräte) # Versuche Gen1 API (ältere Shelly-Geräte)
response = requests.get( response = requests.get(
f"http://{ip}/shelly", f"http://{ip}/settings",
timeout=2 timeout=2
) )
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
if 'type' in data and (data['type'].startswith('SHELLY') or data['type'].startswith('SHSW')): if 'device' in data and (data['device']['type'].startswith('SHELLY') or data['device']['type'].startswith('SHSW')):
logger.info(f"Shelly Gen1 Gerät gefunden: {ip} - {data.get('type', 'Unknown')}") logger.info(f"Shelly Gen1 Gerät gefunden: {ip} - {data['device'].get('type', 'Unknown')}")
return { return {
'ip': ip, 'ip': ip,
'generation': 1, 'generation': 1,
@@ -147,21 +173,18 @@ class ShellyDiscovery:
return None return None
def discover_devices(self, start_ip: int = 1, end_ip: int = 254) -> List[Dict]: def discover_devices(self) -> List[Dict]:
""" """
Entdeckt alle Shelly-Geräte im Netzwerk Entdeckt alle Shelly-Geräte im Netzwerk
Returns: Returns:
Liste von Shelly-Geräten mit Status Liste von Shelly-Geräten mit Status
""" """
active_hosts = self.scan_network(start_ip, end_ip) self.devices = self.scan_network()
for ip in active_hosts: for idx, device in enumerate(self.devices):
device = self.is_shelly_device(ip)
if device:
status = self.get_device_status(device) status = self.get_device_status(device)
device['status'] = status self.devices[idx]['status'] = status
self.devices.append(device)
logger.info(f"Insgesamt {len(self.devices)} Shelly-Geräte entdeckt") logger.info(f"Insgesamt {len(self.devices)} Shelly-Geräte entdeckt")
return self.devices return self.devices
@@ -197,11 +220,8 @@ class ShellyModule(BaseModule):
sensors = [] sensors = []
try: try:
discovery = ShellyDiscovery(network_range=self.config.shelly_network_range) discovery = ShellyDiscovery()
devices = discovery.discover_devices( devices = discovery.discover_devices()
start_ip=self.config.shelly_start_ip,
end_ip=self.config.shelly_end_ip
)
if not devices: if not devices:
logger.info("Keine Shelly-Geräte gefunden") logger.info("Keine Shelly-Geräte gefunden")
@@ -266,7 +286,7 @@ class ShellyModule(BaseModule):
temp_data = status.get(f'temperature:{i}', {}) temp_data = status.get(f'temperature:{i}', {})
sensors.append({ sensors.append({
'type': 'ShellyTemperatureSensor', 'type': 'Temperatur',
'name': f"{device_name}_Temp_{i}", 'name': f"{device_name}_Temp_{i}",
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}", 'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}",
'states': [ 'states': [
@@ -274,6 +294,7 @@ class ShellyModule(BaseModule):
'name': 'temperature', 'name': 'temperature',
'type': 'number', 'type': 'number',
'current_value': temp_data.get('tC'), 'current_value': temp_data.get('tC'),
'url': f"tC",
'unit': '°C' 'unit': '°C'
} }
] ]
@@ -283,7 +304,7 @@ class ShellyModule(BaseModule):
for i in range(em_count): for i in range(em_count):
em_data = status.get(f'em:{i}', {}) em_data = status.get(f'em:{i}', {})
sensors.append({ sensors.append({
'type': 'ShellyEnergyMeter', 'type': 'Stromzähler',
'name': f"{device_name}_EM_{i}", 'name': f"{device_name}_EM_{i}",
'url': f"http://{ip}/rpc/em.GetStatus?id={i}", 'url': f"http://{ip}/rpc/em.GetStatus?id={i}",
'states': [] 'states': []
@@ -413,7 +434,7 @@ class ShellyModule(BaseModule):
ip = device['ip'] ip = device['ip']
device_name = info.get('name', f"Shelly_{info.get('type', ip)}") device_name = info.get('name', f"Shelly_{info.get('type', ip)}")
device_type = info.get('type', 'Unknown') device_type = info['device'].get('type', 'Unknown')
# Relays als Aktoren # Relays als Aktoren
relays = status.get('relays', []) relays = status.get('relays', [])
@@ -440,7 +461,7 @@ class ShellyModule(BaseModule):
temp_data = status.get('tmp', {}) temp_data = status.get('tmp', {})
if temp_data and 'tC' in temp_data: if temp_data and 'tC' in temp_data:
sensors.append({ sensors.append({
'type': 'ShellyTemperatureSensor', 'type': 'Temperatur',
'name': f"{device_name}_Temp", 'name': f"{device_name}_Temp",
'url': f"http://{ip}/status", 'url': f"http://{ip}/status",
'states': [ 'states': [
@@ -51,47 +51,166 @@ class DeviceClassifier:
"""Original DeviceClassifier - unverändert""" """Original DeviceClassifier - unverändert"""
ACTOR_TYPES = { ACTOR_TYPES = {
'RollerShutter', 'ExteriorScreen', 'Awning', 'Blind', 'RollerShutter': 'Rollladen',
'GarageDoor', 'Window', 'Light', 'OnOff', 'DimmableLight', 'ExteriorScreen': 'Außenrollo',
'HeatingSystem', 'Valve', 'Switch', 'Door', 'Curtain', 'Awning': 'Markise',
'VenetianBlind', 'PergolaScreen' 'ExteriorVenetianBlind':'Außenjalousie',
'Blind': 'Jalousie',
'GarageDoor': 'Garagentor',
'Window': 'Fenster',
'Light': 'Beleuchtung',
'OnOff': 'Schalter',
'DimmableLight': 'Dimmbare Beleuchtung',
'HeatingSystem': 'Heizungssystem',
'Valve': 'Ventil',
'Switch': 'Schalter',
'Door': 'Tür',
'Curtain': 'Vorhang',
'VenetianBlind': 'Jalousie',
'PergolaScreen': 'Pergola-Rollo'
} }
SENSOR_TYPES = { SENSOR_TYPES = {
'TemperatureSensor', 'LightSensor', 'HumiditySensor', 'TemperatureSensor': 'Temperatur',
'ContactSensor', 'OccupancySensor', 'SmokeSensor', 'LightSensor': 'Licht',
'WaterDetectionSensor', 'WindowHandle', 'MotionSensor', 'HumiditySensor': 'Feuchtigkeit',
'SunSensor', 'WindSensor', 'RainSensor', 'ConsumptionSensor' 'ContactSensor': 'Kontakt',
'OccupancySensor': 'Anwesenheit',
'SmokeSensor': 'Rauch',
'WaterDetectionSensor': 'Wasser',
'WindowHandle': 'Fenstergriff',
'MotionSensor': 'Bewegung',
'SunSensor': 'Sonne',
'WindSensor': 'Wind',
'RainSensor': 'Regen',
'ConsumptionSensor': 'Verbrauch'
}
STATE_NAMES = {
"core:BatteryLevelState": 'Ladestand',
"core:BatteryState": 'Batteriezustand',
"core:ClosureState": 'Position',
"core:CommandLockLevelsState": 'Gesperrt',
"core:Memorized1OrientationState": 'Gespeicherte Neigung',
"core:Memorized1PositionState": 'Gespeicherte Position',
"core:MovingState": 'Fährt gerade',
"core:OpenClosedState": 'Geöffnet/Geschlossen',
"core:PriorityLockTimerState": 'Verriegelungstimer',
"core:DiscreteRSSILevelState": 'RSSI',
"core:SlateOrientationState": 'Lamellenausrichtung',
"core:StatusState": 'Status',
"core:LuminanceState": 'Helligkeit',
"core:SmokeState": 'Rauch',
"core:SunEnergyState": 'Sonnenenergie',
"core:TemperatureState": 'Temperatur',
"io:MaintenanceRadioPartBatteryState": 'Ladestand Funkmodul',
"io:MaintenanceSensorPartBatteryState": 'Ladestand Sensor',
"core:SensorDefectState": 'Sensor defekt'
}
STATE_ENUMS = {
"core:BatteryLevelState": ["full","normal","low","verylow"],
"core:BatteryState": ["full","normal","low","verylow"],
"core:ClosureState": '',
"core:CommandLockLevelsState": '',
"core:Memorized1OrientationState": '',
"core:Memorized1PositionState": '',
"core:MovingState": ["true","false"],
"core:OpenClosedState": ["open","close"],
"core:PriorityLockTimerState": '',
"core:DiscreteRSSILevelState": ["good","normal","low"],
"core:SlateOrientationState": '',
"core:StatusState": ["available","unavailable"],
"core:TargetClosureState": '',
"core:LuminanceState": 'Helligkeit',
"core:SmokeState": ["notDetected","detected"],
"core:SunEnergyState": '',
"core:TemperatureState": '',
"io:MaintenanceRadioPartBatteryState": ["full","normal","low","verylow"],
"io:MaintenanceSensorPartBatteryState": ["full","normal","low","verylow"],
"core:SensorDefectState": ["true","false"]
}
"""
"core:TargetClosureState": 'Zielposition',
"io:PriorityLockOriginatorState": 'Sperrendes Gerät',
"core:ManufacturerDiagnosticsState": 'Diagnose',
"core:OpenClosedUnknownState": 'Statusanzeige wenn unbekannt',
"core:ManufacturerSettingsState": 'Einstellungen',
"core:RSSILevelState": 'RSSI',
"core:NameState": 'Name',
"core:SecuredPositionState": 'Sicherer Zustand',
"""
@classmethod
def get_sensor_type_translation(cls, sensor):
return cls.SENSOR_TYPES.get(sensor, sensor)
@classmethod
def get_actor_type_translation(cls, actor_type):
return cls.ACTOR_TYPES.get(actor_type, actor_type)
PARAMETER_TYPES = {
0: "bool",
1: "integer", # Integer value
2: "float", # Floating point number
3: "string", # Text string
4: "undefined",
6: "bool",
11: "array"
} }
# Tahoma Commands mit Parametern
TAHOMA_COMMANDS = { TAHOMA_COMMANDS = {
"setClosure": [{"name": "position", "type": "integer", "min": 0, "max": 100}], "setClosure": 'Position',
"setClosureAndOrientation": "Position+Neigung",
"setOrientation": "Neigung",
"up": "Auf",
"down": "Zu",
"my": "my-Position anfahren",
"stop": "Stop",
"refresh": "Aktualisieren",
"wink": "Winken",
"setMyPosition": "my-Position einstellen",
"on": "Anschalten",
"off": "Ausschalten",
"toggle": "Toggle",
"setIntensity": "Intensität",
"setColor": "Farbe",
"setColorTemperature": "Farbtemperatur",
"setTargetTemperature": "Solltemperatur",
"setMode": "Modus",
"pulse": "Impuls",
"setLevel": "Wert",
"trigger": "Trigger",
}
# Tahoma Commands mit Parametern
TAHOMA_COMMAND_PARAMS = {
"setClosure": [{"name": "Position","url":"0", "type": "integer", "min": 0, "max": 100}],
"setClosureAndOrientation": [ "setClosureAndOrientation": [
{"name": "position", "type": "integer", "min": 0, "max": 100}, {"name": "Position", "url":"0", "type": "integer", "min": 0, "max": 100},
{"name": "neigung", "type": "integer", "min": 0, "max": 100} {"name": "Neigung", "url":"1", "type": "integer", "min": 0, "max": 100}
], ],
"setOrientation": [{"name": "neigung", "type": "integer", "min": 0, "max": 100}], "setOrientation": [{"name": "Neigung", "url":"0", "type": "integer", "min": 0, "max": 100}],
"up": [], "down": [], "my": [], "stop": [], "refresh": [], "wink":[], "up": [], "down": [], "my": [], "stop": [], "refresh": [], "wink":[],
"setMyPosition": [{"name": "position", "type": "integer", "min": 0, "max": 100}], "setMyPosition": [{"name": "Position", "url":"0", "type": "integer", "min": 0, "max": 100}],
"on": [], "off": [], "toggle": [], "on": [], "off": [], "toggle": [],
"setIntensity": [{"name": "helligkeit", "type": "integer", "min": 0, "max": 100}], "setIntensity": [{"name": "Helligkeit", "url":"0", "type": "integer", "min": 0, "max": 100}],
"setColor": [ "setColor": [
{"name": "farbton", "type": "integer", "min": 0, "max": 360}, {"name": "Farbton", "url":"0", "type": "integer", "min": 0, "max": 360},
{"name": "sättigung", "type": "integer", "min": 0, "max": 100} {"name": "Sättigung", "url":"1", "type": "integer", "min": 0, "max": 100}
], ],
"setColorTemperature": [{"name": "farbtemperatur", "type": "integer", "min": 2000, "max": 6500}], "setColorTemperature": [{"name": "Farbtemperatur", "url":"0", "type": "integer", "min": 2000, "max": 6500}],
"setTargetTemperature": [{"name": "temperatur", "type": "float", "min": 5.0, "max": 30.0}], "setTargetTemperature": [{"name": "Temperatur", "url":"0", "type": "float", "min": 5.0, "max": 30.0}],
"setMode": [{"name": "betriebsart", "type": "string"}], "setMode": [{"name": "Betriebsart", "url":"0", "type": "string"}],
"pulse": [{"name": "impuls_dauer", "type": "integer", "min": 1, "max": 3600}], "pulse": [{"name": "Impulsdauer", "url":"0", "type": "integer", "min": 1, "max": 3600}],
"setLevel": [{"name": "ausgangs_level", "type": "integer", "min": 0, "max": 100}], "setLevel": [{"name": "Ausgangslevel", "url":"0", "type": "integer", "min": 0, "max": 100}],
"trigger": [], "trigger": [],
} }
@classmethod @classmethod
def is_actor(cls, device: Dict) -> bool: def is_actor(cls, device: Dict) -> bool:
"""Prüft ob Gerät ein Aktor ist""" """Prüft ob Gerät ein Aktor ist"""
device_type = device.get('controllableName', device.get('uiClass', '')) device_type = device.get('definition').get('uiClass', '')
if device_type in cls.ACTOR_TYPES: if device_type in cls.ACTOR_TYPES:
return True return True
@@ -108,7 +227,7 @@ class DeviceClassifier:
@classmethod @classmethod
def is_sensor(cls, device: Dict) -> bool: def is_sensor(cls, device: Dict) -> bool:
"""Prüft ob Gerät ein Sensor ist""" """Prüft ob Gerät ein Sensor ist"""
device_type = device.get('controllableName', device.get('uiClass', '')) device_type = device.get('definition').get('uiClass', '')
if device_type in cls.SENSOR_TYPES: if device_type in cls.SENSOR_TYPES:
return True return True
@@ -130,11 +249,13 @@ class DeviceClassifier:
cmd_definitions = device.get('definition', {}).get('commands', []) cmd_definitions = device.get('definition', {}).get('commands', [])
for cmd in cmd_definitions: for cmd in cmd_definitions:
command_name = cmd.get('commandName', '') command_url = cmd.get('commandName', '')
cmd_params = cls.TAHOMA_COMMANDS.get(command_name, "Not in List") command_name = cls.TAHOMA_COMMANDS.get(command_url, command_url)
cmd_params = cls.TAHOMA_COMMAND_PARAMS.get(command_url, "Not in List")
if(cmd_params != "Not in List"): #only append command, if it is on of the listed commands, to prevent flooding the DB with bullshit. if(cmd_params != "Not in List"): #only append command, if it is on of the listed commands, to prevent flooding the DB with bullshit.
command_entry = { command_entry = {
'command': command_name, 'command': command_name,
'url': command_url,
'parameters': [] 'parameters': []
} }
@@ -149,7 +270,8 @@ class DeviceClassifier:
param_detail['max'] = cmd_param['max'] param_detail['max'] = cmd_param['max']
if 'values' in cmd_param: if 'values' in cmd_param:
param_detail['values'] = cmd_param['values'] param_detail['values'] = cmd_param['values']
if 'url' in cmd_param:
param_detail['url'] = cmd_param['url']
if param_detail['name']: if param_detail['name']:
command_entry['parameters'].append(param_detail) command_entry['parameters'].append(param_detail)
@@ -158,11 +280,15 @@ class DeviceClassifier:
# States extrahieren # States extrahieren
state_definitions = device.get('states', []) state_definitions = device.get('states', [])
for state in state_definitions: for state in state_definitions:
state_name = state.get('name', '') state_url = state.get('name', '')
state_name = cls.STATE_NAMES.get(state_url, '')
state_enums = cls.STATE_ENUMS.get(state_url,'')
if state_name: if state_name:
state_entry = { state_entry = {
'name': state_name, 'name': state_name,
'type': state.get('type', 0) 'url': state_url,
'type': cls.PARAMETER_TYPES.get(state.get('type', 4)),
'values': state_enums
} }
if 'value' in state: if 'value' in state:
state_entry['current_value'] = state['value'] state_entry['current_value'] = state['value']
@@ -177,11 +303,15 @@ class DeviceClassifier:
state_definitions = device.get('states', []) state_definitions = device.get('states', [])
for state in state_definitions: for state in state_definitions:
state_name = state.get('name', '') state_url = state.get('name', '')
state_name = cls.STATE_NAMES.get(state_url, '')
state_enums = cls.STATE_ENUMS.get(state_url,'')
if state_name: if state_name:
state_entry = { state_entry = {
'name': state_name, 'name': state_name,
'type': state.get('type', 0) 'url': state_url,
'type': cls.PARAMETER_TYPES.get(state.get('type', 4)),
'values': state_enums
} }
if 'value' in state: if 'value' in state:
state_entry['current_value'] = state['value'] state_entry['current_value'] = state['value']
@@ -254,6 +384,9 @@ class TahomaModule(BaseModule):
main_name = main_device.get('label', 'Unbekannt') if main_device else 'Unbekannt' main_name = main_device.get('label', 'Unbekannt') if main_device else 'Unbekannt'
for device in group_devices: for device in group_devices:
if(device.get('label','').startswith("IO (") == False):
actor, sensor = self._process_device(device, device.get('label',main_name))
else:
actor, sensor = self._process_device(device, main_name) actor, sensor = self._process_device(device, main_name)
if actor: if actor:
actors.append(actor) actors.append(actor)
@@ -280,7 +413,7 @@ class TahomaModule(BaseModule):
Tuple (actor_dict or None, sensor_dict or None) Tuple (actor_dict or None, sensor_dict or None)
""" """
device_url = device.get('deviceURL', '') device_url = device.get('deviceURL', '')
device_type = device.get('controllableName', device.get('uiClass', 'Unknown')) device_type = device.get('definition').get('uiClass', 'Unknown')
is_actor = DeviceClassifier.is_actor(device) is_actor = DeviceClassifier.is_actor(device)
is_sensor = DeviceClassifier.is_sensor(device) is_sensor = DeviceClassifier.is_sensor(device)
@@ -290,6 +423,7 @@ class TahomaModule(BaseModule):
if is_actor: if is_actor:
commands, states = DeviceClassifier.extract_actor_data(device) commands, states = DeviceClassifier.extract_actor_data(device)
device_type = DeviceClassifier.get_actor_type_translation(device_type)
actor = { actor = {
'type': device_type, 'type': device_type,
'name': device_name, 'name': device_name,
@@ -300,6 +434,7 @@ class TahomaModule(BaseModule):
elif is_sensor: elif is_sensor:
states = DeviceClassifier.extract_sensor_data(device) states = DeviceClassifier.extract_sensor_data(device)
device_type = DeviceClassifier.get_sensor_type_translation(device_type)
sensor = { sensor = {
'type': device_type, 'type': device_type,
'name': device_name, 'name': device_name,
@@ -106,7 +106,7 @@ class WLEDDiscovery:
return wled_devices return wled_devices
@staticmethod @staticmethod
def is_wled_device(ip: str, timeout: float = 1.0) -> bool: def is_wled_device(ip: str, timeout: float = 2.0) -> bool:
"""Prüft ob IP ein WLED-Gerät ist""" """Prüft ob IP ein WLED-Gerät ist"""
try: try:
response = requests.get( response = requests.get(
@@ -190,16 +190,16 @@ class WLEDAPI:
eff_values = self.get_effects() eff_values = self.get_effects()
commands = [ commands = [
{'command': 'on', 'parameters': []}, {'command': 'An', 'url': '{"on":true}', 'parameters': []},
{'command': 'off', 'parameters': []}, {'command': 'Aus', 'url': '{"on":false}', 'parameters': []},
{ {
'command': 'setBrightness', 'command': 'Helligkeit', 'url': '{"bri":%brightness%}',
'parameters': [ 'parameters': [
{'name': 'brightness', 'type': 'integer', 'min': 0, 'max': 255} {'name': 'brightness', 'type': 'integer', 'min': 0, 'max': 255}
] ]
}, },
{ {
'command': 'setColor', 'command': 'Farbe', 'url': '{"seg":[{"col":[[%red%,%green%,%blue%]]}]}',
'parameters': [ 'parameters': [
{'name': 'red', 'type': 'integer', 'min': 0, 'max': 255}, {'name': 'red', 'type': 'integer', 'min': 0, 'max': 255},
{'name': 'green', 'type': 'integer', 'min': 0, 'max': 255}, {'name': 'green', 'type': 'integer', 'min': 0, 'max': 255},
@@ -207,13 +207,13 @@ class WLEDAPI:
] ]
}, },
{ {
'command': 'setEffect', 'command': 'Effekt', 'url': '{"seg":[{"fx":%effect%}]}',
'parameters': [ 'parameters': [
{'name': 'effect', 'type': 'integer', 'min': 0, 'max': 255, 'values': eff_values} {'name': 'effect', 'type': 'integer', 'min': 0, 'max': 255, 'values': eff_values}
] ]
}, },
{ {
'command': 'setPreset', 'command': 'Preset', 'url': '{"ps":%preset%}',
'parameters': [ 'parameters': [
{'name': 'preset', 'type': 'integer', 'min': 1, 'max': 250, 'values': preset_values} {'name': 'preset', 'type': 'integer', 'min': 1, 'max': 250, 'values': preset_values}
] ]
@@ -223,12 +223,14 @@ class WLEDAPI:
states = [] states = []
if state: if state:
states.append({ states.append({
'name': 'power', 'name': 'An',
'url': 'on',
'type': 'boolean', 'type': 'boolean',
'current_value': state.get('on', False) 'current_value': state.get('on', False)
}) })
states.append({ states.append({
'name': 'brightness', 'name': 'Helligkeit',
'url': 'bri',
'type': 'integer', 'type': 'integer',
'current_value': state.get('bri', 0) 'current_value': state.get('bri', 0)
}) })
@@ -238,7 +240,8 @@ class WLEDAPI:
colors = segments[0].get('col', [[0,0,0]]) colors = segments[0].get('col', [[0,0,0]])
if colors and len(colors) > 0: if colors and len(colors) > 0:
states.append({ states.append({
'name': 'color_rgb', 'name': 'Farbe',
'url': 'seg[0].col[0]',
'type': 'array', 'type': 'array',
'current_value': colors[0] 'current_value': colors[0]
}) })
+2 -1
View File
@@ -129,7 +129,8 @@ function tooltipFooter (tooltipItems){
} }
Chart.defaults.font.size = 10; Chart.defaults.font.size = 10;
Chart.defaults.animations = true; Chart.defaults.animations = false;
Chart.defaults.animation = false;
Chart.defaults.plugins.legend.position = "bottom"; Chart.defaults.plugins.legend.position = "bottom";
Chart.defaults.plugins.legend.labels.pointStyleWidth = 10; Chart.defaults.plugins.legend.labels.pointStyleWidth = 10;
Chart.defaults.plugins.legend.labels.usePointStyle = true; Chart.defaults.plugins.legend.labels.usePointStyle = true;
@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""
Automatische Gartenbewässerung anhand von Regendaten (Open-Meteo)
Wird stündlich per Cronjob gestartet. Bei jedem Lauf werden die aktuellen
Niederschlagsdaten von Open-Meteo geholt und je Zone (Hochbeete, Tröge,
Garten vorn) als retained MQTT-Nachricht veröffentlicht (Regenmenge,
Schwellenwert, ob bewässert werden würde) - unabhängig von der Uhrzeit, damit
andere Systeme (z.B. das Web-UI) jederzeit den aktuellen Stand abfragen
können.
Tatsächlich ausgelöst (Automatik-Modus per MQTT gestartet) wird die
Bewässerung nur im Zeitraum zwischen dem Zielzeitpunkt (WATERING_LEAD_MINUTES
Minuten vor Sonnenuntergang) und dem Sonnenuntergang selbst - dieser Zeitraum
ist bewusst offen nach vorne (keine feste Fensterbreite): jeder stündliche
Lauf, der irgendwann in diesem Zeitraum passiert, löst aus. Das macht die
Auslösung robust gegen Jitter und sogar gegen einen komplett ausgefallenen
Lauf (z.B. NAS-Neustart) - ein späterer Lauf holt es nach, solange der
Sonnenuntergang noch nicht erreicht ist. Mehr Puffer gegen Ausfälle gibt es
einfach durch einen größeren WATERING_LEAD_MINUTES-Wert. Zusätzlich wird eine
Zone übersprungen, wenn sie laut dem retained "LastWatering"-MQTT-Topic der
Ventilsteuerung bereits innerhalb der letzten X Stunden bewässert wurde.
MQTT-Schnittstelle der Ventilsteuerung siehe README.md des Firmware-Projekts
(ESP32 Ventilsteuerung, Topics "Gartenwasser/vorn" bzw. "Gartenwasser/hinten").
"""
import argparse
import json
import logging
import sys
import time
from datetime import datetime, timedelta
import paho.mqtt.client as mqtt
import requests
# ============================================================================
# EINSTELLUNGEN (Standardwerte - können unten per Kommandozeilenparameter
# überschrieben werden, siehe --help)
# ============================================================================
# Standort für die Regendaten (Open-Meteo)
# Mühlenweg 7, Untermaiselstein, 87549 Rettenberg (Allgäu)
LATITUDE = 47.5781
LONGITUDE = 10.2628
TIMEZONE = "Europe/Berlin"
# Wie viele Stunden zurück geprüft wird, ob eine Zone bereits bewässert wurde
# (manuell oder automatisch) - falls ja, wird die Automatik für diese Zone
# in diesem Lauf übersprungen. Bewässerungen, die kürzer als der jeweilige
# "min_duration_minutes"-Wert der Zone liefen (siehe ZONES), zählen dabei
# NICHT als "bereits bewässert" und werden ignoriert (z.B. kurzes manuelles
# Antesten).
RECENT_WATERING_LOOKBACK_HOURS = 12
# Zielzeitpunkt für den Bewässerungsstart: so viele Minuten vor
# Sonnenuntergang. Ausgelöst wird bei jedem Lauf zwischen diesem
# Zielzeitpunkt und dem Sonnenuntergang selbst (kein festes Zeitfenster,
# siehe Modul-Docstring) - je größer der Wert, desto mehr Puffer gibt es,
# falls ein stündlicher Lauf ausfällt oder sich verzögert.
WATERING_LEAD_MINUTES = 70
# MQTT-Broker der Hausautomation
MQTT_BROKER = "nas.fritz.box"
MQTT_PORT = 1883
MQTT_USERNAME = None
MQTT_PASSWORD = None
# Wie lange (Sekunden) auf die retained LastWatering-Nachrichten gewartet wird
MQTT_WAIT_TIMEOUT = 5
# Topic-Vorlage für die stündlich veröffentlichten Regen-Status-Nachrichten
# je Zone (retained). {zone_key} wird durch den jeweiligen Zonen-Key ersetzt.
RAIN_STATUS_TOPIC_TEMPLATE = "Gartenwasser/AutoWatering/{zone_key}/RainStatus"
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
# Zonen-Konfiguration: je Zone eigener Schwellenwert, eigenes MQTT-Auto-Topic
# (siehe README: /auto-Topic mit Klartext-Payload "Hoch"/"Trog"/"Vorn") und
# das zugehörige LastWatering-Topic + Ventilnummer zur Erkennung kürzlich
# erfolgter Bewässerung. Bewässert wird, wenn die letzten "threshold_days"
# Tage zusammen weniger als "threshold_mm" mm Regen gebracht haben.
ZONES = {
"hochbeete": {
"label": "Hochbeete",
"auto_topic": "Gartenwasser/hinten/auto",
"auto_payload": "Hoch",
"lastwatering_topic": "Gartenwasser/hinten/LastWatering",
"valve": 6,
# Schon nach 2 Tagen ohne (nennenswerten) Regen bewässern.
"threshold_days": 2,
"threshold_mm": 1.0,
# Manuelle Bewässerungen unter 10 Minuten zählen nicht als "bereits
# bewässert" (z.B. kurzes Antesten).
"min_duration_minutes": 10,
},
"troege": {
"label": "Tröge",
"auto_topic": "Gartenwasser/vorn/auto",
"auto_payload": "Trog",
"lastwatering_topic": "Gartenwasser/vorn/LastWatering",
"valve": 1,
# Tröge stehen unter Dach (bekommen kaum natürlichen Regen ab) und
# sollen daher praktisch immer bewässert werden - nur bei Starkregen
# (hoher mm-Schwellenwert, 1 Tag) wird ausgesetzt.
"threshold_days": 1,
"threshold_mm": 20.0,
# Bei den Trögen reicht schon eine kurze manuelle Bewässerung (3 Min.).
"min_duration_minutes": 3,
},
"garten_vorn": {
"label": "Garten vorn",
"auto_topic": "Gartenwasser/vorn/auto",
"auto_payload": "Vorn",
"lastwatering_topic": "Gartenwasser/vorn/LastWatering",
"valve": 6,
# Erst nach 5 Tagen ohne (nennenswerten) Regen bewässern.
"threshold_days": 5,
"threshold_mm": 1.0,
# Manuelle Bewässerungen unter 10 Minuten zählen nicht als "bereits
# bewässert" (z.B. kurzes Antesten).
"min_duration_minutes": 10,
},
}
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger("auto_watering")
# ============================================================================
# Kommandozeilenparameter
# ============================================================================
def parse_args():
parser = argparse.ArgumentParser(
description="Automatische Gartenbewässerung anhand von Regendaten (Open-Meteo)."
)
parser.add_argument("--lat", type=float, default=LATITUDE, help="Breitengrad (Standard: %(default)s)")
parser.add_argument("--lon", type=float, default=LONGITUDE, help="Längengrad (Standard: %(default)s)")
parser.add_argument("--timezone", default=TIMEZONE, help="Zeitzone für Open-Meteo (Standard: %(default)s)")
parser.add_argument("--days", type=int, default=None,
help="Überschreibt die Anzahl Tage, über die der Regen aufsummiert wird, für ALLE Zonen "
"ohne zonenspezifischen Override (Standard: je Zone eigener Wert, siehe ZONES)")
parser.add_argument("--mm", type=float, default=None,
help="Überschreibt die Regenmenge in mm, unterhalb derer bewässert wird, für ALLE Zonen "
"ohne zonenspezifischen Override (Standard: je Zone eigener Wert, siehe ZONES)")
for zone_key, zone in ZONES.items():
flag_prefix = zone_key.replace("_", "-")
parser.add_argument(f"--{flag_prefix}-days", type=int, default=None,
help=f"Schwellenwert Tage nur für Zone '{zone['label']}' (überschreibt --days)")
parser.add_argument(f"--{flag_prefix}-mm", type=float, default=None,
help=f"Schwellenwert mm nur für Zone '{zone['label']}' (überschreibt --mm)")
parser.add_argument(f"--{flag_prefix}-min-duration-minutes", type=float, default=None,
help=f"Mindestdauer (Min.) nur für Zone '{zone['label']}', unterhalb derer eine "
"manuelle Bewässerung ignoriert wird (überschreibt --min-duration-minutes)")
parser.add_argument("--lookback-hours", type=float, default=RECENT_WATERING_LOOKBACK_HOURS,
help="Zone wird übersprungen, wenn innerhalb dieser Stundenzahl bereits bewässert "
"wurde (Standard: %(default)s)")
parser.add_argument("--min-duration-minutes", type=float, default=None,
help="Überschreibt die Mindestdauer (Min.), unterhalb derer eine Bewässerung nicht als "
"'bereits bewässert' zählt, für ALLE Zonen ohne zonenspezifischen Override "
"(Standard: je Zone eigener Wert, siehe ZONES)")
parser.add_argument("--watering-lead-minutes", type=float, default=WATERING_LEAD_MINUTES,
help="Ausgelöst wird zwischen (Sonnenuntergang - watering-lead-minutes) und "
"Sonnenuntergang - ein größerer Wert gibt mehr Puffer gegen ausgefallene/"
"verzögerte Läufe (Standard: %(default)s)")
parser.add_argument("--zones", default=",".join(ZONES.keys()),
help=f"Kommagetrennte Liste der zu prüfenden Zonen aus {list(ZONES.keys())} "
"(Standard: alle)")
parser.add_argument("--mqtt-broker", default=MQTT_BROKER, help="MQTT-Broker (Standard: %(default)s)")
parser.add_argument("--mqtt-port", type=int, default=MQTT_PORT, help="MQTT-Port (Standard: %(default)s)")
parser.add_argument("--mqtt-username", default=MQTT_USERNAME, help="MQTT-Benutzername (optional)")
parser.add_argument("--mqtt-password", default=MQTT_PASSWORD, help="MQTT-Passwort (optional)")
parser.add_argument("--dry-run", action="store_true",
help="Nur auswerten und loggen, keine MQTT-Befehle senden")
parser.add_argument("-v", "--verbose", action="store_true", help="Debug-Logging aktivieren")
args = parser.parse_args()
if args.zones.strip():
selected = [z.strip() for z in args.zones.split(",") if z.strip()]
else:
selected = list(ZONES.keys())
unknown = [z for z in selected if z not in ZONES]
if unknown:
parser.error(f"Unbekannte Zone(n): {', '.join(unknown)} - gültig sind {list(ZONES.keys())}")
args.selected_zones = selected
return args
def build_zone_config(args):
"""Erstellt die effektive Zonen-Konfiguration unter Berücksichtigung der CLI-Overrides."""
zones = {}
for zone_key in args.selected_zones:
zone = dict(ZONES[zone_key])
flag_attr = zone_key.replace("-", "_")
days_override = getattr(args, f"{flag_attr}_days")
mm_override = getattr(args, f"{flag_attr}_mm")
min_duration_override = getattr(args, f"{flag_attr}_min_duration_minutes")
if days_override is not None:
zone["threshold_days"] = days_override
elif args.days is not None:
zone["threshold_days"] = args.days
if mm_override is not None:
zone["threshold_mm"] = mm_override
elif args.mm is not None:
zone["threshold_mm"] = args.mm
if min_duration_override is not None:
zone["min_duration_minutes"] = min_duration_override
elif args.min_duration_minutes is not None:
zone["min_duration_minutes"] = args.min_duration_minutes
zones[zone_key] = zone
return zones
# ============================================================================
# Regendaten (Open-Meteo)
# ============================================================================
def fetch_weather_data(lat, lon, timezone, past_days):
"""
Holt von Open-Meteo sowohl die Tages-Niederschlagssummen der letzten
`past_days` abgeschlossenen Tage (heute ausgeschlossen, da der Tag noch
nicht vorbei ist) als auch den heutigen Sonnenuntergang.
Returns:
(daily_precipitation, sunset_today)
daily_precipitation: Liste von (date, mm) Tupeln, aufsteigend
sortiert, ohne den heutigen Tag.
sunset_today: naive datetime des heutigen Sonnenuntergangs, lokale
Zeit in der übergebenen Zeitzone (wird mit datetime.now() ohne
Zeitzonen-Info verglichen - das System muss daher in derselben
Zeitzone laufen).
"""
params = {
"latitude": lat,
"longitude": lon,
"daily": "precipitation_sum,sunset",
"timezone": timezone,
# +1, da Open-Meteo den heutigen (noch laufenden) Tag mitliefert,
# der unten aus den Niederschlagsdaten explizit herausgefiltert wird.
"past_days": min(past_days + 1, 92),
"forecast_days": 1,
}
response = requests.get(OPEN_METEO_URL, params=params, timeout=15)
response.raise_for_status()
data = response.json()
dates = data["daily"]["time"]
sums = data["daily"]["precipitation_sum"]
sunsets = data["daily"]["sunset"]
today = datetime.now().date()
daily = []
sunset_today = None
for date_str, mm, sunset_str in zip(dates, sums, sunsets):
day = datetime.strptime(date_str, "%Y-%m-%d").date()
if day == today:
sunset_today = datetime.fromisoformat(sunset_str)
if day >= today:
continue
daily.append((day, mm if mm is not None else 0.0))
daily.sort(key=lambda entry: entry[0])
if sunset_today is None:
raise ValueError("Open-Meteo hat keinen Sonnenuntergang für heute geliefert.")
return daily, sunset_today
def accumulated_rain(daily_precipitation, days):
"""Summe der letzten `days` abgeschlossenen Tage aus daily_precipitation."""
relevant = daily_precipitation[-days:] if days > 0 else []
if len(relevant) < days:
logger.warning(
"Nur %d von %d angeforderten Tagen an Regendaten verfügbar - Auswertung erfolgt trotzdem.",
len(relevant), days,
)
return sum(mm for _, mm in relevant)
def watering_window(sunset_today, lead_minutes):
"""
Berechnet den Zeitraum, in dem ausgelöst werden darf: vom Zielzeitpunkt
(lead_minutes vor Sonnenuntergang) bis zum Sonnenuntergang selbst - ohne
Deckelung nach oben, damit auch ein verspäteter/nachgeholter Lauf noch
auslösen kann, solange die Sonne noch nicht untergegangen ist.
"""
target = sunset_today - timedelta(minutes=lead_minutes)
return target, sunset_today
def in_watering_window(now, window_start, window_end):
return window_start <= now <= window_end
# ============================================================================
# MQTT-Session: LastWatering lesen, Regen-Status + Auslöse-Befehle senden
# ============================================================================
def open_mqtt_session(broker, port, username, password, subscribe_topics, wait_timeout):
"""
Baut eine MQTT-Verbindung auf, abonniert `subscribe_topics` (um retained
Nachrichten, z.B. LastWatering, einzusammeln) und lässt den Client danach
für weitere publish()-Aufrufe verbunden.
Returns:
(client, received) - received wird im Hintergrund weiter befüllt,
falls auf einem abonnierten Topic erneut publiziert wird.
"""
received = {}
def on_connect(client, userdata, flags, reason_code, properties=None):
if reason_code != 0:
logger.error("MQTT-Verbindung fehlgeschlagen (reason_code=%s)", reason_code)
return
for topic in subscribe_topics:
client.subscribe(topic)
def on_message(client, userdata, msg):
try:
received[msg.topic] = json.loads(msg.payload.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as error:
logger.warning("Konnte Payload von %s nicht als JSON lesen: %s", msg.topic, error)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
client_id=f"AutoWatering-{datetime.now():%Y%m%d%H%M%S}")
if username:
client.username_pw_set(username, password)
client.on_connect = on_connect
client.on_message = on_message
client.connect(broker, port, keepalive=wait_timeout + 30)
client.loop_start()
time.sleep(wait_timeout)
return client, received
def publish_rain_status(client, zone_key, zone, rain_sum, recently_watered, last_start, now):
payload = {
"zone": zone["label"],
"rain_mm": round(rain_sum, 1),
"threshold_mm": zone["threshold_mm"],
"threshold_days": zone["threshold_days"],
"would_water": rain_sum < zone["threshold_mm"],
"watered_recently": recently_watered,
"last_watering": last_start.strftime("%d.%m.%Y %H:%M") if last_start else None,
"updated": now.strftime("%d.%m.%Y %H:%M:%S"),
}
topic = RAIN_STATUS_TOPIC_TEMPLATE.format(zone_key=zone_key)
client.publish(topic, json.dumps(payload), qos=1, retain=True)
logger.debug("Regen-Status veröffentlicht auf %s: %s", topic, payload)
def watered_recently(last_watering_data, zone, lookback_hours, now):
"""
Prüft anhand der gesammelten LastWatering-Daten, ob die Zone innerhalb der
letzten `lookback_hours` Stunden bereits bewässert wurde. Bewässerungen,
die kürzer als `zone["min_duration_minutes"]` liefen (z.B. kurzes
manuelles Antesten), werden dabei ignoriert.
Returns:
(bool watered_recently, datetime|None last_watering_start)
"""
payload = last_watering_data.get(zone["lastwatering_topic"])
if not payload:
return False, None
valve = zone["valve"]
date_key = f"wateringDateTime{valve}"
date_str = payload.get(date_key)
if not date_str:
return False, None
try:
last_start = datetime.strptime(date_str, "%d.%m.%y %H:%M")
except ValueError:
logger.warning("Konnte Zeitstempel '%s' aus %s nicht parsen (Format TT.MM.JJ HH:MM erwartet).",
date_str, zone["lastwatering_topic"])
return False, None
duration_key = f"wateringDurationSecs{valve}"
duration_secs = payload.get(duration_key)
min_duration_secs = zone["min_duration_minutes"] * 60
if isinstance(duration_secs, (int, float)) and duration_secs < min_duration_secs:
logger.debug(
"%s: letzte Bewässerung am %s dauerte nur %.0fs (< %.0f Min. Mindestdauer) - wird ignoriert.",
zone["label"], last_start.strftime("%d.%m.%Y %H:%M"), duration_secs, zone["min_duration_minutes"],
)
return False, None
age = now - last_start
return age < timedelta(hours=lookback_hours), last_start
# ============================================================================
# Automatik-Modus starten
# ============================================================================
def start_auto_mode(client, zone, dry_run):
if dry_run:
logger.debug("[DRY-RUN] Würde MQTT '%s' -> '%s' senden", zone["auto_topic"], zone["auto_payload"])
return
result = client.publish(zone["auto_topic"], zone["auto_payload"], qos=1, retain=False)
result.wait_for_publish(timeout=5)
logger.debug("MQTT gesendet: '%s' -> '%s'", zone["auto_topic"], zone["auto_payload"])
# ============================================================================
# Hauptprogramm
# ============================================================================
def main():
args = parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
zones = build_zone_config(args)
max_days = max(zone["threshold_days"] for zone in zones.values())
try:
daily_precipitation, sunset_today = fetch_weather_data(args.lat, args.lon, args.timezone, max_days)
except (requests.RequestException, KeyError, ValueError) as error:
logger.critical("Wetterdaten konnten nicht von Open-Meteo abgerufen werden, breche ab: %s", error)
sys.exit(1)
logger.debug("Tages-Niederschlag (letzte %d Tage): %s", max_days, daily_precipitation)
now = datetime.now()
window_start, window_end = watering_window(sunset_today, args.watering_lead_minutes)
trigger_allowed = in_watering_window(now, window_start, window_end)
logger.info(
"Sonnenuntergang %s | Auslöse-Zeitraum %s-%s (%s)",
sunset_today.strftime("%H:%M"), window_start.strftime("%H:%M"), window_end.strftime("%H:%M"),
"aktiv" if trigger_allowed else "inaktiv",
)
lastwatering_topics = sorted({zone["lastwatering_topic"] for zone in zones.values()})
try:
client, last_watering_data = open_mqtt_session(
args.mqtt_broker, args.mqtt_port, args.mqtt_username, args.mqtt_password,
lastwatering_topics, MQTT_WAIT_TIMEOUT,
)
except OSError as error:
logger.critical("MQTT-Verbindung zu %s:%s fehlgeschlagen, breche ab: %s",
args.mqtt_broker, args.mqtt_port, error)
sys.exit(1)
triggered = []
skipped = []
try:
for zone_key, zone in zones.items():
rain_sum = accumulated_rain(daily_precipitation, zone["threshold_days"])
recently_watered, last_start = watered_recently(
last_watering_data, zone, args.lookback_hours, now
)
publish_rain_status(client, zone_key, zone, rain_sum, recently_watered, last_start, now)
needs_water = rain_sum < zone["threshold_mm"]
if not needs_water:
status = "kein Bedarf"
elif recently_watered:
status = f"übersprungen (bewässert {last_start:%H:%M})"
skipped.append(zone_key)
elif not trigger_allowed:
status = "übersprungen (außerhalb Zeitraum)"
skipped.append(zone_key)
else:
start_auto_mode(client, zone, args.dry_run)
status = "bewässert" + (" [DRY-RUN]" if args.dry_run else "")
triggered.append(zone_key)
logger.info(
"%-12s %5.1f/%4.1f mm (%dT) -> %s",
zone["label"], rain_sum, zone["threshold_mm"], zone["threshold_days"], status,
)
finally:
client.loop_stop()
client.disconnect()
logger.info(
"Ergebnis: %d bewässert, %d übersprungen, %d ohne Bedarf",
len(triggered), len(skipped), len(zones) - len(triggered) - len(skipped),
)
if __name__ == "__main__":
main()
+5 -3
View File
@@ -1,5 +1,7 @@
<?php <?php
$tahoma_PIN = "2040-3358-7811"; //$tahoma_PIN = "2040-3358-7811"; //NEU
$tahoma_token = "693e87db02b3667c519d"; $tahoma_PIN = "1215-2900-8489"; //CLASSIC
//$tahoma_token = "693e87db02b3667c519d"; //NEU
$tahoma_token = "67ebd23e3a61763386d9"; //CLASSIC
$tahoma_devlist = "../restricted/tahoma_devices_classic.json";
?> ?>
+1 -1
View File
@@ -1 +1 @@
[{"name":"K\u00fcche T\u00fcre","id":"io:\/\/1215-2900-8489\/4860861"},{"name":"u_Wozi Schiebet\u00fcr","id":"io:\/\/1215-2900-8489\/332806"},{"name":"K\u00fcche Fenster gro\u00df","id":"io:\/\/1215-2900-8489\/12250152"},{"name":"u_K\u00fcche Fenster","id":"io:\/\/1215-2900-8489\/332898"},{"name":"u_K\u00fcche T\u00fcr","id":"io:\/\/1215-2900-8489\/332906"},{"name":"K\u00fcchen T\u00fcre","id":"io:\/\/1215-2900-8489\/10236312"},{"name":"Wohnzimmer Links","id":"io:\/\/1215-2900-8489\/10656452"},{"name":"u_Florian T\u00fcr","id":"io:\/\/1215-2900-8489\/332662"},{"name":"u_Magdalena T\u00fcr","id":"io:\/\/1215-2900-8489\/332291"},{"name":"u_Bad Rechts","id":"io:\/\/1215-2900-8489\/330508"},{"name":"TerrasseLinks","id":"io:\/\/1215-2900-8489\/5930007"},{"name":"Kinderzimmer","id":"io:\/\/1215-2900-8489\/14452811"},{"name":"K\u00fcche Fenster","id":"io:\/\/1215-2900-8489\/817067"},{"name":"Bad T\u00fcre","id":"io:\/\/1215-2900-8489\/16024062"},{"name":"Kinderzimmer","id":"io:\/\/1215-2900-8489\/15667355"},{"name":"TerrasseRechts","id":"io:\/\/1215-2900-8489\/10632998"},{"name":"K\u00fcchenfenster gr","id":"io:\/\/1215-2900-8489\/3245041"},{"name":"Leinwand","id":"io:\/\/1215-2900-8489\/4290619"},{"name":"u_Schlafzimmer T\u00fcr","id":"io:\/\/1215-2900-8489\/330504"},{"name":"Wohnzimmer Rechts","id":"io:\/\/1215-2900-8489\/6652408"},{"name":"u_Wozi Fensterfront","id":"io:\/\/1215-2900-8489\/332812"},{"name":"u_Magdalena Fenster","id":"io:\/\/1215-2900-8489\/332695"},{"name":"Schalfzimmer","id":"io:\/\/1215-2900-8489\/331541"},{"name":"u_Wozi Terrassent\u00fcr","id":"io:\/\/1215-2900-8489\/332649"},{"name":"Bad links","id":"io:\/\/1215-2900-8489\/332783"},{"name":"B\u00fcro","id":"io:\/\/1215-2900-8489\/332850"},{"name":"Wohnzimmer T\u00fcre","id":"io:\/\/1215-2900-8489\/14531428"},{"name":"u_Bad Links","id":"io:\/\/1215-2900-8489\/330634"}] [{"name":"u_Schlafzimmer T\u00fcr","id":"io:\/\/1215-2900-8489\/330504"},{"name":"u_K\u00fcche Fenster","id":"io:\/\/1215-2900-8489\/332906"},{"name":"u_Magdalena Fenster","id":"io:\/\/1215-2900-8489\/332695"},{"name":"Florian T\u00fcr","id":"io:\/\/1215-2900-8489\/3859164"},{"name":"u_Bad Links","id":"io:\/\/1215-2900-8489\/330634"},{"name":"u_Bad Rechts","id":"io:\/\/1215-2900-8489\/330508"},{"name":"u_Magdalena T\u00fcr","id":"io:\/\/1215-2900-8489\/332291"},{"name":"u_K\u00fcche T\u00fcr","id":"io:\/\/1215-2900-8489\/332898"},{"name":"u_Wozi Terrassent\u00fcr","id":"io:\/\/1215-2900-8489\/332649"},{"name":"u_Wozi Fensterfront","id":"io:\/\/1215-2900-8489\/332812"},{"name":"u_Wozi Schiebet\u00fcr","id":"io:\/\/1215-2900-8489\/332806"}]