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>
<label for="paramSelect1">Messwert</label>
</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">
<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>
</div>
</div>
@@ -247,10 +248,24 @@ input[type='range']::-webkit-slider-runnable-track {
</select>
<label for="paramSelect1">Eigenschaft</label>
</div>
<button class="btn btn-outline-secondary " type="button" id="btnActOperator1" name="btnActOperator1">&gt;</button>
<div class="form-floating" id="actValBlock1">
<input type="number" class="form-control" id="actValue1" name="actValue1" placeholder="0" value="0"></input>
<label for="actValue1">Sollwert</label>
<button class="btn btn-outline-secondary" style="min-width: 40px;" type="button" id="btnActOperator1" name="btnActOperator1">&gt;</button>
<div class="form-floating" id="act1ValBlock1">
<input type="number" class="form-control" id="act1Value1" name="act1Value1" value="0"></input>
<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>
+70 -1
View File
@@ -1,5 +1,74 @@
<?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"])){
$ID = intval($_GET["actorID"]);
$qry = "SELECT parameters FROM actors WHERE id=".$ID;
@@ -8,5 +77,5 @@ if(isset($_GET["actorID"])){
echo $result->fetch_array()[0];
}
*/
?>
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
require_once("../helper.php");
$qry = "SELECT name, id FROM actors";
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$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_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$result = mysqli_query($mysql, $qry);
while($row = $result->fetch_assoc()){
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
require_once("../helper.php");
$qry = "SELECT name, id FROM sensors";
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$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_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$result = mysqli_query($mysql, $qry);
while($row = $result->fetch_assoc()){
+60 -3
View File
@@ -2,10 +2,67 @@
require_once("../helper.php");
if(isset($_GET["sensorID"])){
$sensorID = intval($_GET["sensorID"]);
$qry = "SELECT parameters FROM sensors WHERE id=".$sensorID;
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$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_MeshUser, $mysql_MeshPass, $mysql_MeshDB);
$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){
$start = strpos(strtolower($dev["name"]),strtolower($filter));
if($start !== false){
$dev["name"] = str_replace("u_","",$dev["name"]);
$ret[$i]["name"] = $prefix.substr($dev["name"],strlen($filter));
$ret[$i]["id"] = $key;
$i++;
@@ -60,7 +61,7 @@ function filter_devs($arr,$filter,$prefix=""){
function searchForDevice($device){
$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)) {
$dev = $devices[intval($device)];
} else if ($key = array_search($device, array_column($devices, "name"))) {
@@ -87,12 +88,13 @@ if ($_GET["action"] == "devlist") {
echo $device["label"] . "(".$device["deviceURL"].")<br />";
}
}
$file = fopen('../restricted/tahoma_devices.json', 'w');
fwrite($file, json_encode($jalousien));
fclose($file);
//$file = fopen($tahoma_devlist, 'w');
//fwrite($file, json_encode($jalousien));
//fclose($file);
echo json_encode($jalousien);
}elseif ($_GET["action"] == "pos" && isset($_GET["device"])) {
$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();
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states";
$ret = getSSLPage($url,$tahoma_token);
@@ -115,7 +117,7 @@ if ($_GET["action"] == "devlist") {
fastcgi_finish_request();
}elseif ($_GET["action"] == "moving" && isset($_GET["device"])) {
$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();
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states/core:MovingState";
$ret = getSSLPage($url,$tahoma_token);
@@ -201,7 +203,7 @@ if ($_GET["action"] == "myactors") {
$host = gethostbyaddr("192.168.179.32");
$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) {
case "tmpegbad":
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
C222.575,351.396,220.761,353.729,218.171,354.342z" />
</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">
<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" />
</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"
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"
keySplines=".6,0,.27,1" path="M340 0 L120 -300" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#182C43" />
<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" />
<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">
<textPath id="waterText" href="#powerTextPath" startOffset="25%">--.- cm</textPath>
<textPath id="waterTemp" href="#infoTextPath" startOffset="26%">-.- °C</textPath>
<textPath id="wateringFrontStatus" href="#infoTextPath" startOffset="46%">Aus </textPath>
<textPath id="wateringTroughStatus" href="#infoTextPath" startOffset="10%">Aus </textPath>
<textPath id="wateringBedsStatus" href="#infoTextPath" startOffset="28%">Aus </textPath>
</text>
</g>
@@ -589,13 +615,10 @@
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"
keySplines=".6,0,.27,1" path="M340 0 L500 -300" fill="freeze" />
<!--<g
filter="url(#glow)">-->
<use href="#ElementBG" stroke="#18423B" />
<use href="#ogIcon" />
<path id="ogArc" fill="none" stroke="#70CCBB" stroke-width="30" />
<use href="#circGrid" style="stroke:#18423B;" />
<!--</g>-->
<text filter="url(#shadowMiddle)" class="powerTextClass">
<textPath id="ogText" href="#powerTextPath" startOffset="15%">--.-- kW</textPath>
</text>
+1 -1
View File
@@ -108,7 +108,7 @@ if (isset($_GET["addUser"])) {
case "solar":
$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 .= "<script src='js/solar/solarMQTT.js'></script>";
$resource_content .= "<script src='js/solar/solarMQTT.js?v=" . filemtime('js/solar/solarMQTT.js') . "'></script>";
break;
case "home":
include "restricted/home.php";
+149 -54
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){
dropdown = event.currentTarget;
type = dropdown.options[dropdown.selectedIndex].dataset.type;
id = dropdown.id.match(/\d+$/);
if(dropdown.id.search("act") > -1){
opBtnID = "btnActOperator"+id;
valBlockID = "actValBlock"+id;
valElemID = "actValue"+id;
opBtnID = document.getElementById("btnActOperator"+id);
valBlockID = document.getElementById("act1ValBlock"+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{
opBtnID = "btnOperator"+id;
valBlockID ="valBlock"+id;
valElemID = "threshold"+id;
opBtnID = document.getElementById("btnOperator"+id);
valBlockID =document.getElementById("valBlock"+id);
valElemID = document.getElementById("threshold"+id);
valOptID = document.getElementById("thresholdOpt"+id);
val2BlockID = null;
}
if(type =="hidden"){
document.getElementById(opBtnID).hidden = true;
document.getElementById(valBlockID).hidden = true;
opBtnID.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{
document.getElementById(opBtnID).hidden = false;
document.getElementById(valBlockID).hidden = false;
document.getElementById(valElemID).type = type;
opBtnID.setAttribute("data-tglstates",dropdown.options[dropdown.selectedIndex].dataset.tglstates);
opBtnID.innerHTML = JSON.parse(dropdown.options[dropdown.selectedIndex].dataset.tglstates)[0];
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>
<label>Messwert</label>
</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}">
<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>
</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>
@@ -132,11 +239,24 @@ function changeValueType(event){
</select>
<label>Eigenschaft</label>
</div>
<button class="btn btn-outline-secondary " type="button" id="btnActOperator${nextID}" name="btnActOperator${nextID}">&gt;</button>
<div class="form-floating" id="actValBlock${nextID}">
<input type="number" class="form-control" id="actValue${nextID}" name="actValue${nextID}" placeholder="0" value="0"></input>
<label>Sollwert</label>
<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="act1ValBlock${nextID}">
<input type="number" class="form-control" id="act1Value${nextID}" name="act1Value${nextID}" placeholder="0" value="0"></input>
<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>
<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>`;
document.getElementById("actorsList").appendChild(div);
@@ -175,25 +295,17 @@ function changeValueType(event){
.then(response => response.text())
.then(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;
}else{
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);
if(document.getElementById("btnLogic"+ID))
document.getElementById("btnLogic"+ID).addEventListener("click", tglOperators);
document.getElementById("threshold"+ID).setAttribute("type",param.type);
});
document.getElementById("btnOperator"+ID).addEventListener("click", tglOperators);
clearOptions("paramSelect"+ID);
addOptions("paramSelect"+ID,params.parameters);
if(document.getElementById("btnLogic"+ID))
document.getElementById("btnLogic"+ID).addEventListener("click", tglOperators);
document.getElementById("paramSelect"+ID).dispatchEvent(new Event('change'));
})
.catch(error => {
@@ -204,9 +316,9 @@ function changeValueType(event){
function arrangeActorInputs(event){
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',
headers: {
'X-Requested-From-Modal': 'a',
@@ -217,31 +329,14 @@ function changeValueType(event){
.then(html => {
params = JSON.parse(html);
document.getElementById("actParamSelect"+ID).innerHTML = "";
if(params.Parameters.length == 1){
if(params.commands.length == 1){
document.getElementById("actParamBlock"+ID).hidden = true;
}else{
document.getElementById("actParamBlock"+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("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);
});
clearOptions("actParamSelect"+ID);
addOptions("actParamSelect"+ID,params.commands);
document.getElementById("actParamSelect"+ID).dispatchEvent(new Event('change'));
})
.catch(error => {
+266 -9
View File
@@ -1,3 +1,6 @@
let solarMqttClient;
let solarSvgUpdatePending = false;
const solarMQTT = {
getMQTT: function () {
const id = Math.random().toString(36).substring(7);
@@ -9,6 +12,7 @@ const solarMQTT = {
const client = mqtt.connect(connection, {
rejectUnauthorized: false,
});
solarMqttClient = client;
client.on("message", messageReceived);
client.on("connect", function () {
@@ -24,6 +28,13 @@ const solarMQTT = {
client.subscribe("go-eCharger/270003/att");
client.subscribe("go-eCharger/270003/car");
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) {
//alert("MQTT Error: " + error);
@@ -51,12 +62,30 @@ const solarMQTT = {
function messageReceived(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") {
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"){
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("genInfoText").innerHTML = "Wirkungsgrad: " + Math.round(eff) + "%";// powerToString(p_wr);
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++) {
htmlNode.getElementById("det_pv" + i + "P").innerHTML = powerToString(pvn[i]);
@@ -530,6 +556,7 @@ var chartSettings = {
}
function openModal(type) {
document.getElementById("modalSaveBtn").style.display = type === "watering" ? "none" : "";
switch(type){
case "CarEG":
document.getElementById("modal-title").innerHTML = "Autoladung EG";
@@ -543,6 +570,10 @@ var chartSettings = {
document.getElementById("modal-title").innerHTML = "Steuerung Heizstab";
contentURL = "ajax/heater.php";
break;
case "watering":
document.getElementById("modal-title").innerHTML = "Gartenbewässerung";
contentURL = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php";
break;
default:
document.getElementById("modal-title").innerHTML = "Fehler";
contentURL = "";
@@ -665,6 +696,9 @@ var chartSettings = {
}
document.getElementById("modal-slider").dispatchEvent(new Event('input'));
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) {
let xmlhttp = window.XMLHttpRequest ?
new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP");
@@ -729,10 +990,7 @@ var chartSettings = {
chrt.options.plugins.annotation.annotations = await response2.json();
}
chrt.options.events = ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'];
if(animation)
chrt.update();
else
chrt.update("none");
chrt.update("none");
} catch (error) {
console.log(error.message);
}
@@ -780,7 +1038,6 @@ var chartSettings = {
} catch (error) {
console.log(error.message);
}
setTimeout(function () { getData(chart, url, sunrise) }, 5 * 60 * 1000); //renew data every 5 min.
}
function powerToString(power) {
+117 -28
View File
@@ -19,6 +19,7 @@ from modules.tahoma_module import TahomaModule
from modules.wled_module import WLEDModule
from modules.mqtt_module import MQTTModule
from modules.shelly_module import ShellyModule
from modules.logic_module import LogicModule #nicht Vergessen neue Module auch in Zeile 546 einzufügen!!
# Logging
logging.basicConfig(
@@ -142,6 +143,14 @@ class Config:
return 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
@property
def mqtt_enable(self) -> bool:
@@ -236,6 +245,33 @@ class Config:
class DatabaseManager:
"""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):
self.host = host
self.database = database
@@ -272,13 +308,13 @@ class DatabaseManager:
try:
cursor = self.connection.cursor()
cursor.execute("SET FOREIGN_KEY_CHECKS=0")
cursor.execute("DELETE FROM command_parameters")
cursor.execute("DELETE FROM actor_commands")
cursor.execute("DELETE FROM actor_states")
cursor.execute("DELETE FROM actors")
cursor.execute("DELETE FROM sensor_states")
cursor.execute("DELETE FROM sensors")
cursor.execute("TRUNCATE state_types")
cursor.execute("TRUNCATE command_parameters")
cursor.execute("TRUNCATE actor_commands")
cursor.execute("TRUNCATE actor_states")
cursor.execute("TRUNCATE actors")
cursor.execute("TRUNCATE sensor_states")
cursor.execute("TRUNCATE sensors")
cursor.execute("SET FOREIGN_KEY_CHECKS=1")
@@ -289,6 +325,8 @@ class DatabaseManager:
logger.error(f"✗ Fehler beim Leeren der Tabellen: {e}")
self.connection.rollback()
def insert_actor(self, device_type: str, name: str, url: str,
commands: list, states: list) -> bool:
"""
@@ -306,22 +344,35 @@ class DatabaseManager:
# Actor einfügen
query = """
INSERT INTO actors (type, name, parameters, url)
VALUES (%s, %s, NULL, %s)
INSERT INTO actors (type, name, url)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
type = VALUES(type),
name = VALUES(name)
"""
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
for cmd in commands:
command_name = cmd.get('command', '')
command_url = cmd.get('url', '')
cmd_query = """
INSERT INTO actor_commands (actor_id, command_name)
VALUES (%s, %s)
INSERT INTO actor_commands (actor_id, command_name, command_url)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
command_name = VALUES(command_name)
"""
cursor.execute(cmd_query, (actor_id, command_name))
command_id = cursor.lastrowid
cursor.execute(cmd_query, (actor_id, command_name, command_url))
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
for param in cmd.get('parameters', []):
@@ -329,12 +380,18 @@ class DatabaseManager:
INSERT INTO command_parameters
(command_id, parameter_name, parameter_type, min_value, max_value, possible_values, url)
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_type = param.get('type', '')
min_val = param.get('min')
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')
cursor.execute(param_query,
@@ -344,16 +401,22 @@ class DatabaseManager:
for state in states:
state_query = """
INSERT INTO actor_states
(actor_id, state_name, state_type, current_value, unit, url)
VALUES (%s, %s, %s, %s, %s, %s)
(actor_id, state_name, state_type, current_value, unit, url, possible_values)
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_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
unit = state.get('unit')
state_url = state.get('url')
cursor.execute(state_query, (actor_id, state_name, state_type, current_value, unit, state_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, possible_vals))
self.connection.commit()
cursor.close()
@@ -363,6 +426,17 @@ class DatabaseManager:
logger.error(f"✗ Fehler beim Einfügen des Aktors {name}: {e}")
self.connection.rollback()
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:
"""
@@ -379,21 +453,34 @@ class DatabaseManager:
# Sensor einfügen
query = """
INSERT INTO sensors (type, name, parameters, url)
VALUES (%s, %s, NULL, %s)
INSERT INTO actors (type, name, url)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
type = VALUES(type),
name = VALUES(name)
"""
cursor.execute(query, (device_type, name, url))
sensor_id = cursor.lastrowid
query = """
SELECT ID FROM actors where url = %s
"""
cursor.execute(query, (url))
sensor_id = cursor.fetchone()[0]
# States einfügen
for state in states:
state_query = """
INSERT INTO sensor_states
(sensor_id, state_name, state_type, current_value, unit, url)
INSERT INTO actor_states
(actor_id, state_name, state_type, current_value, unit, url)
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_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
unit = state.get('unit')
state_url = state.get('url')
@@ -460,13 +547,15 @@ def main():
total_actors = 0
total_sensors = 0
db.fillVariableTypes()
# Module initialisieren
modules = [
TahomaModule(config),
WLEDModule(config),
MQTTModule(config),
ShellyModule(config)
ShellyModule(config),
LogicModule(config)
]
# Jedes Modul durchlaufen
@@ -52,6 +52,7 @@ class BaseModule(ABC):
'type': str,
'min': float (optional),
'max': float (optional),
'url': str, # Eindeutige ID/URL
'values': list (optional)
}
]
@@ -77,6 +78,7 @@ class BaseModule(ABC):
'name': str,
'type': int/str,
'current_value': any,
'url': str, # Eindeutige ID/URL
'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')
# Gerätename vom ersten Entity oder aus device_info
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
if entities:
device_name = entities[0]['config'].get('name', device_id)
# if entities:
# device_name = entities[0]['config'].get('name', device_id)
# Device URL
device_url = f"mqtt://{device_id}"
@@ -279,11 +282,13 @@ class MQTTDeviceConverter:
component = entity['component']
object_id = entity.get('object_id', 'unknown')
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_entry = MQTTDeviceConverter._entity_to_command(component, object_id, config)
command_entry = MQTTDeviceConverter._entity_to_commands(component, object_id, config)
if command_entry:
commands.append(command_entry)
commands.extend(command_entry)
# States aus der Entity extrahieren
entity_states = MQTTDeviceConverter._entity_to_states(component, object_id, config)
@@ -292,7 +297,7 @@ class MQTTDeviceConverter:
if commands: # Nur Actor erstellen wenn Commands vorhanden
actor_result = {
'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,
'commands': commands,
'states': states
@@ -306,7 +311,8 @@ class MQTTDeviceConverter:
component = entity['component']
object_id = entity.get('object_id', 'unknown')
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
entity_states = MQTTDeviceConverter._entity_to_states(component, object_id, config)
states.extend(entity_states)
@@ -314,7 +320,7 @@ class MQTTDeviceConverter:
if states: # Nur Sensor erstellen wenn States vorhanden
sensor_result = {
'name': device_name,
'type': f"mqtt_device",
'type': device_mf,
'url': device_url,
'states': states
}
@@ -322,7 +328,7 @@ class MQTTDeviceConverter:
return actor_result, sensor_result
@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
@@ -336,19 +342,21 @@ class MQTTDeviceConverter:
"""
# Command Topic - verschiedene mögliche Feldnamen
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:
return None
# Command-Name aus object_id ableiten
command_name = object_id.replace('_', ' ').title().replace(' ', '')
#command_name =
# 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,
'parameters': []
}
'parameters': [],
'url': command_topic
}]
# Parameter basierend auf Component-Typ
if component == 'number':
@@ -375,7 +383,7 @@ class MQTTDeviceConverter:
if unit:
param['unit'] = unit
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
elif component == 'select':
# Select hat Optionen
@@ -389,7 +397,7 @@ class MQTTDeviceConverter:
if options:
param['values'] = options
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
elif component in ['switch', 'light']:
# Switch/Light haben on/off
@@ -402,7 +410,7 @@ class MQTTDeviceConverter:
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_cmd_topic = (
@@ -410,7 +418,7 @@ class MQTTDeviceConverter:
config.get('bri_cmd_t')
)
if component == 'light' and brightness_cmd_topic:
command_entry['parameters'].append({
command_entry[0]['parameters'].append({
'name': 'brightness',
'type': 'integer',
'min': 0,
@@ -432,7 +440,7 @@ class MQTTDeviceConverter:
'max': 100,
'url': set_pos_topic
}
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
else:
# Nur open/close/stop
param = {
@@ -441,7 +449,7 @@ class MQTTDeviceConverter:
'url': command_topic,
'values': ['OPEN', 'CLOSE', 'STOP']
}
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
elif component == 'button':
# Button hat normalerweise keinen Parameter, nur das Topic
@@ -450,7 +458,7 @@ class MQTTDeviceConverter:
'type': 'trigger',
'url': command_topic
}
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
elif component == 'climate':
# Climate hat Temperatur-Setpoint
@@ -469,8 +477,9 @@ class MQTTDeviceConverter:
param['min'] = config['min_temp']
if 'max_temp' in config:
param['max'] = config['max_temp']
command_entry['parameters'].append(param)
command_entry[0]['command'] = "Solltemperatur"
command_entry[0]['parameters'].append(param)
mode_cmd_topic = (
config.get('mode_command_topic') or
config.get('mode_cmd_t')
@@ -482,7 +491,10 @@ class MQTTDeviceConverter:
'url': mode_cmd_topic,
'values': config.get('modes', [])
}
command_entry['parameters'].append(param)
command_entry.append({
'command': "Modus",
'parameters': [param]
})
else:
# Generischer Command mit dem Topic
param = {
@@ -512,8 +524,7 @@ class MQTTDeviceConverter:
# State Topic - verschiedene mögliche Feldnamen prüfen
state_topic = (
config.get('state_topic') or
config.get('stat_t') or # Abkürzung
config.get('~') and config.get('stat_t') # Mit Base Topic
config.get('stat_t')
)
# 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')
if state_topic:
base_topic = config.get('~','')
state_topic = state_topic.replace("~",base_topic)
state_entry = {
'name': object_id,
'type': 'string',
@@ -654,7 +667,6 @@ class MQTTModule(BaseModule):
actor_data, sensor_data = MQTTDeviceConverter.convert_device_to_actors_and_sensors(
device_id, device_data
)
if actor_data:
actors.append(actor_data)
@@ -11,6 +11,7 @@ KEINE Datenbank-Operationen!
import json
import requests
import socket
import concurrent.futures
import logging
from typing import List, Dict, Optional, Tuple
from modules.base_module import BaseModule
@@ -28,22 +29,47 @@ class ShellyDiscovery:
SHELLY_MDNS_SERVICE = "_http._tcp.local."
COMMON_PORTS = [80]
def __init__(self, network_range: str = "192.168.1"):
self.network_range = network_range
def __init__(self):
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
def scan_network(self, network: str = None, max_threads: int = 50) -> List[str]:
"""Scannt das Netzwerk nach Shelly-Geräten"""
if network is None:
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])
Args:
start_ip: Start IP (letztes Oktett)
end_ip: End IP (letztes Oktett)
timeout: Timeout für Socket-Verbindung
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)]
Returns:
Liste von erreichbaren IP-Adressen
"""
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]:
active_hosts = []
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")
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
@@ -75,7 +101,7 @@ class ShellyDiscovery:
Returns:
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:
# Versuche Gen2 API (neuere Shelly-Geräte)
response = requests.get(
@@ -96,13 +122,13 @@ class ShellyDiscovery:
try:
# Versuche Gen1 API (ältere Shelly-Geräte)
response = requests.get(
f"http://{ip}/shelly",
f"http://{ip}/settings",
timeout=2
)
if response.status_code == 200:
data = response.json()
if 'type' in data and (data['type'].startswith('SHELLY') or data['type'].startswith('SHSW')):
logger.info(f"Shelly Gen1 Gerät gefunden: {ip} - {data.get('type', 'Unknown')}")
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['device'].get('type', 'Unknown')}")
return {
'ip': ip,
'generation': 1,
@@ -146,22 +172,19 @@ class ShellyDiscovery:
logger.error(f"Fehler beim Abrufen des Status von {ip}: {e}")
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
Returns:
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:
device = self.is_shelly_device(ip)
if device:
status = self.get_device_status(device)
device['status'] = status
self.devices.append(device)
for idx, device in enumerate(self.devices):
status = self.get_device_status(device)
self.devices[idx]['status'] = status
logger.info(f"Insgesamt {len(self.devices)} Shelly-Geräte entdeckt")
return self.devices
@@ -197,11 +220,8 @@ class ShellyModule(BaseModule):
sensors = []
try:
discovery = ShellyDiscovery(network_range=self.config.shelly_network_range)
devices = discovery.discover_devices(
start_ip=self.config.shelly_start_ip,
end_ip=self.config.shelly_end_ip
)
discovery = ShellyDiscovery()
devices = discovery.discover_devices()
if not devices:
logger.info("Keine Shelly-Geräte gefunden")
@@ -266,7 +286,7 @@ class ShellyModule(BaseModule):
temp_data = status.get(f'temperature:{i}', {})
sensors.append({
'type': 'ShellyTemperatureSensor',
'type': 'Temperatur',
'name': f"{device_name}_Temp_{i}",
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}",
'states': [
@@ -274,6 +294,7 @@ class ShellyModule(BaseModule):
'name': 'temperature',
'type': 'number',
'current_value': temp_data.get('tC'),
'url': f"tC",
'unit': '°C'
}
]
@@ -283,7 +304,7 @@ class ShellyModule(BaseModule):
for i in range(em_count):
em_data = status.get(f'em:{i}', {})
sensors.append({
'type': 'ShellyEnergyMeter',
'type': 'Stromzähler',
'name': f"{device_name}_EM_{i}",
'url': f"http://{ip}/rpc/em.GetStatus?id={i}",
'states': []
@@ -413,7 +434,7 @@ class ShellyModule(BaseModule):
ip = device['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 = status.get('relays', [])
@@ -440,7 +461,7 @@ class ShellyModule(BaseModule):
temp_data = status.get('tmp', {})
if temp_data and 'tC' in temp_data:
sensors.append({
'type': 'ShellyTemperatureSensor',
'type': 'Temperatur',
'name': f"{device_name}_Temp",
'url': f"http://{ip}/status",
'states': [
@@ -51,47 +51,166 @@ class DeviceClassifier:
"""Original DeviceClassifier - unverändert"""
ACTOR_TYPES = {
'RollerShutter', 'ExteriorScreen', 'Awning', 'Blind',
'GarageDoor', 'Window', 'Light', 'OnOff', 'DimmableLight',
'HeatingSystem', 'Valve', 'Switch', 'Door', 'Curtain',
'VenetianBlind', 'PergolaScreen'
'RollerShutter': 'Rollladen',
'ExteriorScreen': 'Außenrollo',
'Awning': 'Markise',
'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 = {
'TemperatureSensor', 'LightSensor', 'HumiditySensor',
'ContactSensor', 'OccupancySensor', 'SmokeSensor',
'WaterDetectionSensor', 'WindowHandle', 'MotionSensor',
'SunSensor', 'WindSensor', 'RainSensor', 'ConsumptionSensor'
'TemperatureSensor': 'Temperatur',
'LightSensor': 'Licht',
'HumiditySensor': 'Feuchtigkeit',
'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'
}
# Tahoma Commands mit Parametern
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 = {
"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": [
{"name": "position", "type": "integer", "min": 0, "max": 100},
{"name": "neigung", "type": "integer", "min": 0, "max": 100}
{"name": "Position", "url":"0", "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":[],
"setMyPosition": [{"name": "position", "type": "integer", "min": 0, "max": 100}],
"setMyPosition": [{"name": "Position", "url":"0", "type": "integer", "min": 0, "max": 100}],
"on": [], "off": [], "toggle": [],
"setIntensity": [{"name": "helligkeit", "type": "integer", "min": 0, "max": 100}],
"setIntensity": [{"name": "Helligkeit", "url":"0", "type": "integer", "min": 0, "max": 100}],
"setColor": [
{"name": "farbton", "type": "integer", "min": 0, "max": 360},
{"name": "sättigung", "type": "integer", "min": 0, "max": 100}
{"name": "Farbton", "url":"0", "type": "integer", "min": 0, "max": 360},
{"name": "Sättigung", "url":"1", "type": "integer", "min": 0, "max": 100}
],
"setColorTemperature": [{"name": "farbtemperatur", "type": "integer", "min": 2000, "max": 6500}],
"setTargetTemperature": [{"name": "temperatur", "type": "float", "min": 5.0, "max": 30.0}],
"setMode": [{"name": "betriebsart", "type": "string"}],
"pulse": [{"name": "impuls_dauer", "type": "integer", "min": 1, "max": 3600}],
"setLevel": [{"name": "ausgangs_level", "type": "integer", "min": 0, "max": 100}],
"setColorTemperature": [{"name": "Farbtemperatur", "url":"0", "type": "integer", "min": 2000, "max": 6500}],
"setTargetTemperature": [{"name": "Temperatur", "url":"0", "type": "float", "min": 5.0, "max": 30.0}],
"setMode": [{"name": "Betriebsart", "url":"0", "type": "string"}],
"pulse": [{"name": "Impulsdauer", "url":"0", "type": "integer", "min": 1, "max": 3600}],
"setLevel": [{"name": "Ausgangslevel", "url":"0", "type": "integer", "min": 0, "max": 100}],
"trigger": [],
}
@classmethod
def is_actor(cls, device: Dict) -> bool:
"""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:
return True
@@ -108,7 +227,7 @@ class DeviceClassifier:
@classmethod
def is_sensor(cls, device: Dict) -> bool:
"""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:
return True
@@ -130,11 +249,13 @@ class DeviceClassifier:
cmd_definitions = device.get('definition', {}).get('commands', [])
for cmd in cmd_definitions:
command_name = cmd.get('commandName', '')
cmd_params = cls.TAHOMA_COMMANDS.get(command_name, "Not in List")
command_url = cmd.get('commandName', '')
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.
command_entry = {
'command': command_name,
'url': command_url,
'parameters': []
}
@@ -149,7 +270,8 @@ class DeviceClassifier:
param_detail['max'] = cmd_param['max']
if 'values' in cmd_param:
param_detail['values'] = cmd_param['values']
if 'url' in cmd_param:
param_detail['url'] = cmd_param['url']
if param_detail['name']:
command_entry['parameters'].append(param_detail)
@@ -158,11 +280,15 @@ class DeviceClassifier:
# States extrahieren
state_definitions = device.get('states', [])
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:
state_entry = {
'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:
state_entry['current_value'] = state['value']
@@ -177,11 +303,15 @@ class DeviceClassifier:
state_definitions = device.get('states', [])
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:
state_entry = {
'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:
state_entry['current_value'] = state['value']
@@ -254,7 +384,10 @@ class TahomaModule(BaseModule):
main_name = main_device.get('label', 'Unbekannt') if main_device else 'Unbekannt'
for device in group_devices:
actor, sensor = self._process_device(device, main_name)
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)
if actor:
actors.append(actor)
if sensor:
@@ -280,7 +413,7 @@ class TahomaModule(BaseModule):
Tuple (actor_dict or None, sensor_dict or None)
"""
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_sensor = DeviceClassifier.is_sensor(device)
@@ -290,6 +423,7 @@ class TahomaModule(BaseModule):
if is_actor:
commands, states = DeviceClassifier.extract_actor_data(device)
device_type = DeviceClassifier.get_actor_type_translation(device_type)
actor = {
'type': device_type,
'name': device_name,
@@ -300,6 +434,7 @@ class TahomaModule(BaseModule):
elif is_sensor:
states = DeviceClassifier.extract_sensor_data(device)
device_type = DeviceClassifier.get_sensor_type_translation(device_type)
sensor = {
'type': device_type,
'name': device_name,
@@ -106,7 +106,7 @@ class WLEDDiscovery:
return wled_devices
@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"""
try:
response = requests.get(
@@ -190,16 +190,16 @@ class WLEDAPI:
eff_values = self.get_effects()
commands = [
{'command': 'on', 'parameters': []},
{'command': 'off', 'parameters': []},
{'command': 'An', 'url': '{"on":true}', 'parameters': []},
{'command': 'Aus', 'url': '{"on":false}', 'parameters': []},
{
'command': 'setBrightness',
'command': 'Helligkeit', 'url': '{"bri":%brightness%}',
'parameters': [
{'name': 'brightness', 'type': 'integer', 'min': 0, 'max': 255}
]
},
{
'command': 'setColor',
'command': 'Farbe', 'url': '{"seg":[{"col":[[%red%,%green%,%blue%]]}]}',
'parameters': [
{'name': 'red', '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': [
{'name': 'effect', 'type': 'integer', 'min': 0, 'max': 255, 'values': eff_values}
]
},
{
'command': 'setPreset',
'command': 'Preset', 'url': '{"ps":%preset%}',
'parameters': [
{'name': 'preset', 'type': 'integer', 'min': 1, 'max': 250, 'values': preset_values}
]
@@ -223,12 +223,14 @@ class WLEDAPI:
states = []
if state:
states.append({
'name': 'power',
'name': 'An',
'url': 'on',
'type': 'boolean',
'current_value': state.get('on', False)
})
states.append({
'name': 'brightness',
'name': 'Helligkeit',
'url': 'bri',
'type': 'integer',
'current_value': state.get('bri', 0)
})
@@ -238,7 +240,8 @@ class WLEDAPI:
colors = segments[0].get('col', [[0,0,0]])
if colors and len(colors) > 0:
states.append({
'name': 'color_rgb',
'name': 'Farbe',
'url': 'seg[0].col[0]',
'type': 'array',
'current_value': colors[0]
})
+2 -1
View File
@@ -129,7 +129,8 @@ function tooltipFooter (tooltipItems){
}
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.labels.pointStyleWidth = 10;
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
$tahoma_PIN = "2040-3358-7811";
$tahoma_token = "693e87db02b3667c519d";
//$tahoma_PIN = "2040-3358-7811"; //NEU
$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"}]