-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
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