diff --git a/ajax/AutoAction.php b/ajax/AutoAction.php index 8ddf8a0..3af2ca6 100644 --- a/ajax/AutoAction.php +++ b/ajax/AutoAction.php @@ -156,9 +156,10 @@ input[type='range']::-webkit-slider-runnable-track { - +
+
@@ -247,10 +248,24 @@ input[type='range']::-webkit-slider-runnable-track { - -
- - + +
+ + + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/ajax/actorDetails.php b/ajax/actorDetails.php index 59e7bff..e7ca81a 100644 --- a/ajax/actorDetails.php +++ b/ajax/actorDetails.php @@ -1,5 +1,74 @@ fetch_assoc()){ + // print_r($row); + // echo "
"; + //} + 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]; } - +*/ ?> \ No newline at end of file diff --git a/ajax/fillActorDD.php b/ajax/fillActorDD.php index c11045a..d129140 100644 --- a/ajax/fillActorDD.php +++ b/ajax/fillActorDD.php @@ -1,7 +1,7 @@ fetch_assoc()){ diff --git a/ajax/fillSensorDD.php b/ajax/fillSensorDD.php index 35f3447..5c47f7e 100644 --- a/ajax/fillSensorDD.php +++ b/ajax/fillSensorDD.php @@ -1,7 +1,7 @@ fetch_assoc()){ diff --git a/ajax/sensorDetails.php b/ajax/sensorDetails.php index 09b6dd6..36a33a6 100644 --- a/ajax/sensorDetails.php +++ b/ajax/sensorDetails.php @@ -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 = '["=","≠",">","<"]'; + $type = "number"; + break; + case "float": + $operators = '["=","≠",">","<"]'; + $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 "]}"; } diff --git a/ajax/tahoma.php b/ajax/tahoma.php index 7a41eca..99c0d64 100644 --- a/ajax/tahoma.php +++ b/ajax/tahoma.php @@ -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"].")
"; } } - $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 ")); diff --git a/ajax/watering.php b/ajax/watering.php new file mode 100644 index 0000000..4c39e4d --- /dev/null +++ b/ajax/watering.php @@ -0,0 +1,98 @@ + 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 << +
+
+
Automatik
+
+ + + + +
+
+
Manuell
+ + + + + + + +
ZoneDauer (Min.)Letzte BewässerungDauerSteuerung
Tröge----
Beete----
Vorn----
+ +HTML; diff --git a/assets/img/realtime.svg b/assets/img/realtime.svg index 41d1cd9..cea4d20 100644 --- a/assets/img/realtime.svg +++ b/assets/img/realtime.svg @@ -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" /> + + + + + + + + + + + + + + + @@ -566,21 +581,32 @@ style='display:none;mix-blend-mode: difference;fill:#70CCBB;' href="#heater" /> - + - - + + + Vorn: aus + + + Tröge: aus + + + Beete: aus + - --.- cm - -.- °C + Aus + Aus + Aus @@ -589,13 +615,10 @@ keySplines=".1,0,.27,1" path="M0 0 L340 0" fill="freeze" /> - - --.-- kW diff --git a/index.php b/index.php index 8e7265b..7b022f8 100644 --- a/index.php +++ b/index.php @@ -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 .= ""; + $resource_content .= ""; break; case "home": include "restricted/home.php"; diff --git a/js/solar/autoActionFuncs.js b/js/solar/autoActionFuncs.js index 831923c..aca7167 100644 --- a/js/solar/autoActionFuncs.js +++ b/js/solar/autoActionFuncs.js @@ -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){ - +
+
@@ -132,11 +239,24 @@ function changeValueType(event){ - -
- - + +
+ + +
+
+ + +
+
+ + +
+
+ + +
`; 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 => { diff --git a/js/solar/solarMQTT.js b/js/solar/solarMQTT.js index a6cf6f9..09bbc8c 100644 --- a/js/solar/solarMQTT.js +++ b/js/solar/solarMQTT.js @@ -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//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) { diff --git a/restricted/deviceDiscovery/device_discovery.py b/restricted/deviceDiscovery/device_discovery.py index 8ea6dab..6c8580e 100644 --- a/restricted/deviceDiscovery/device_discovery.py +++ b/restricted/deviceDiscovery/device_discovery.py @@ -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 diff --git a/restricted/deviceDiscovery/modules/base_module.py b/restricted/deviceDiscovery/modules/base_module.py index 84213a8..bede186 100644 --- a/restricted/deviceDiscovery/modules/base_module.py +++ b/restricted/deviceDiscovery/modules/base_module.py @@ -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) } ] diff --git a/restricted/deviceDiscovery/modules/logic_module.py b/restricted/deviceDiscovery/modules/logic_module.py new file mode 100644 index 0000000..137a6c7 --- /dev/null +++ b/restricted/deviceDiscovery/modules/logic_module.py @@ -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 diff --git a/restricted/deviceDiscovery/modules/mqtt_module.py b/restricted/deviceDiscovery/modules/mqtt_module.py index 00dce50..b23ddd3 100644 --- a/restricted/deviceDiscovery/modules/mqtt_module.py +++ b/restricted/deviceDiscovery/modules/mqtt_module.py @@ -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) diff --git a/restricted/deviceDiscovery/modules/shelly_module.py b/restricted/deviceDiscovery/modules/shelly_module.py index eb82b03..537de4c 100644 --- a/restricted/deviceDiscovery/modules/shelly_module.py +++ b/restricted/deviceDiscovery/modules/shelly_module.py @@ -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': [ diff --git a/restricted/deviceDiscovery/modules/tahoma_module.py b/restricted/deviceDiscovery/modules/tahoma_module.py index a9e57f5..5a2a97c 100644 --- a/restricted/deviceDiscovery/modules/tahoma_module.py +++ b/restricted/deviceDiscovery/modules/tahoma_module.py @@ -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, diff --git a/restricted/deviceDiscovery/modules/wled_module.py b/restricted/deviceDiscovery/modules/wled_module.py index 06b1715..43af000 100644 --- a/restricted/deviceDiscovery/modules/wled_module.py +++ b/restricted/deviceDiscovery/modules/wled_module.py @@ -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] }) diff --git a/restricted/footer.html b/restricted/footer.html index 8a74e1d..1c09d56 100644 --- a/restricted/footer.html +++ b/restricted/footer.html @@ -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; diff --git a/restricted/gartenbewaesserung/auto_watering.py b/restricted/gartenbewaesserung/auto_watering.py new file mode 100644 index 0000000..bc7ca2f --- /dev/null +++ b/restricted/gartenbewaesserung/auto_watering.py @@ -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() diff --git a/restricted/tahoma_EG.php b/restricted/tahoma_EG.php index 7db8762..23a88bf 100644 --- a/restricted/tahoma_EG.php +++ b/restricted/tahoma_EG.php @@ -1,5 +1,7 @@ \ No newline at end of file diff --git a/restricted/tahoma_devices_classic.json b/restricted/tahoma_devices_classic.json index 1107074..bde6889 100644 --- a/restricted/tahoma_devices_classic.json +++ b/restricted/tahoma_devices_classic.json @@ -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"}] \ No newline at end of file +[{"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"}] \ No newline at end of file