Regenbasierte automatische Gartenbewässerung + homeMesh-Anbindung für AutoAction-Regeleditor
- Neues stündliches Python-Script (restricted/gartenbewaesserung/auto_watering.py): holt Niederschlags- und Sonnenuntergangsdaten von Open-Meteo, wertet je Zone (Hochbeete/Tröge/Garten vorn) individuelle Regen-Schwellenwerte und Mindest-Bewässerungsdauern aus und startet bei Bedarf per MQTT den passenden Automatik-Modus der Ventilsteuerung im Zeitraum vor Sonnenuntergang; veröffentlicht den Regen-Status zusätzlich stündlich als retained MQTT-Nachricht je Zone. - Web-UI: neue Bewässerungs-Steuerung/-Anzeige (ajax/watering.php, js/solar/solarMQTT.js, assets/img/realtime.svg) inkl. Live-Status-Icons (aus/geplant/pausiert/aktiv per Farbe und Symbol). - AutoAction-Regeleditor (ajax/actorDetails.php, sensorDetails.php, fillActorDD.php, fillSensorDD.php, tahoma.php, AutoAction.php) liest Actor-/Sensor-Parameter jetzt live aus der homeMesh-Datenbank statt aus stark vereinfachten Altfeldern, inkl. zugehöriger Anpassungen am Device-Discovery-Tooling (restricted/deviceDiscovery/*, neues logic_module.py). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+266
-9
@@ -1,3 +1,6 @@
|
||||
let solarMqttClient;
|
||||
let solarSvgUpdatePending = false;
|
||||
|
||||
const solarMQTT = {
|
||||
getMQTT: function () {
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
@@ -9,6 +12,7 @@ const solarMQTT = {
|
||||
const client = mqtt.connect(connection, {
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
solarMqttClient = client;
|
||||
|
||||
client.on("message", messageReceived);
|
||||
client.on("connect", function () {
|
||||
@@ -24,6 +28,13 @@ const solarMQTT = {
|
||||
client.subscribe("go-eCharger/270003/att");
|
||||
client.subscribe("go-eCharger/270003/car");
|
||||
client.subscribe("weatherStation/#");
|
||||
client.subscribe("Gartenwasser/vorn/status");
|
||||
client.subscribe("Gartenwasser/vorn/Timers");
|
||||
client.subscribe("Gartenwasser/vorn/LastWatering");
|
||||
client.subscribe("Gartenwasser/hinten/status");
|
||||
client.subscribe("Gartenwasser/hinten/Timers");
|
||||
client.subscribe("Gartenwasser/hinten/LastWatering");
|
||||
client.subscribe("Gartenwasser/AutoWatering/#");
|
||||
});
|
||||
client.on("error", function (error) {
|
||||
//alert("MQTT Error: " + error);
|
||||
@@ -51,12 +62,30 @@ const solarMQTT = {
|
||||
|
||||
function messageReceived(topic, message) {
|
||||
mqttData[topic] = message;
|
||||
if (topic === "Gartenwasser/vorn/Timers" || topic === "Gartenwasser/hinten/Timers") {
|
||||
wateringTimersLastSeen[topic] = Date.now();
|
||||
}
|
||||
if (topic.startsWith("Gartenwasser/") && (topic.endsWith("/status") || topic.endsWith("/Timers") || topic.endsWith("/LastWatering") || topic.endsWith("/RainStatus"))) {
|
||||
updateWateringStatus();
|
||||
}
|
||||
if (topic == "solarManager/P_Load") {
|
||||
setTimeout(function () { solarSVG.updateValuesMQTT(mqttData) }, 200); //give the object tree some time to build up and receive all values
|
||||
if (!solarSvgUpdatePending) {
|
||||
solarSvgUpdatePending = true;
|
||||
setTimeout(function () {
|
||||
solarSvgUpdatePending = false;
|
||||
solarSVG.updateValuesMQTT(mqttData);
|
||||
}, 1000);
|
||||
}
|
||||
}else if(topic == "weatherStation/windDeg"){
|
||||
setTimeout(function () { updateValuesWeather() }, 200); //give the object tree some time to build up and receive all values
|
||||
}
|
||||
}
|
||||
},
|
||||
publish: function (topic, payload, callback) {
|
||||
if (!solarMqttClient || !solarMqttClient.connected) {
|
||||
throw new Error("MQTT ist noch nicht verbunden.");
|
||||
}
|
||||
solarMqttClient.publish(topic, typeof payload === "string" ? payload : JSON.stringify(payload), callback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +184,6 @@ const solarSVG = {
|
||||
htmlNode.getElementById("genText").innerHTML = "Autarkie: " + Math.round(aut) + " %";
|
||||
htmlNode.getElementById("genInfoText").innerHTML = "Wirkungsgrad: " + Math.round(eff) + " %";// powerToString(p_wr);
|
||||
htmlNode.getElementById("gridText").innerHTML = powerToString(grid);
|
||||
htmlNode.getElementById("waterText").innerHTML = Math.round(waterHeight) / 10 + " cm"
|
||||
htmlNode.getElementById("waterTemp").innerHTML = (waterTemp).toPrecision(3) + " °C"
|
||||
htmlNode.getElementById("waterState").setAttribute("y", (100 - (waterHeight / 1500) * 100) + "%");
|
||||
|
||||
for (i = 0; i < pvn.length; i++) {
|
||||
htmlNode.getElementById("det_pv" + i + "P").innerHTML = powerToString(pvn[i]);
|
||||
@@ -530,6 +556,7 @@ var chartSettings = {
|
||||
}
|
||||
|
||||
function openModal(type) {
|
||||
document.getElementById("modalSaveBtn").style.display = type === "watering" ? "none" : "";
|
||||
switch(type){
|
||||
case "CarEG":
|
||||
document.getElementById("modal-title").innerHTML = "Autoladung EG";
|
||||
@@ -543,6 +570,10 @@ var chartSettings = {
|
||||
document.getElementById("modal-title").innerHTML = "Steuerung Heizstab";
|
||||
contentURL = "ajax/heater.php";
|
||||
break;
|
||||
case "watering":
|
||||
document.getElementById("modal-title").innerHTML = "Gartenbewässerung";
|
||||
contentURL = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php";
|
||||
break;
|
||||
default:
|
||||
document.getElementById("modal-title").innerHTML = "Fehler";
|
||||
contentURL = "";
|
||||
@@ -665,6 +696,9 @@ var chartSettings = {
|
||||
}
|
||||
document.getElementById("modal-slider").dispatchEvent(new Event('input'));
|
||||
break;
|
||||
case "watering":
|
||||
initializeWateringModal();
|
||||
break;
|
||||
}
|
||||
|
||||
})
|
||||
@@ -673,6 +707,233 @@ var chartSettings = {
|
||||
});
|
||||
}
|
||||
|
||||
const wateringTimersLastSeen = {};
|
||||
const wateringZones = {
|
||||
troughs: { topic: "Gartenwasser/vorn", valve: 1 },
|
||||
frontGarden: { topic: "Gartenwasser/vorn", valve: 6 },
|
||||
raisedBeds: { topic: "Gartenwasser/hinten", valve: 6 }
|
||||
};
|
||||
|
||||
function wateringCommand(topic, command, directTopic = false) {
|
||||
const feedback = document.getElementById("watering-feedback");
|
||||
const targetTopic = directTopic ? topic : topic + "/set";
|
||||
const payload = typeof command === "string" ? command : JSON.stringify(command);
|
||||
const endpoint = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php";
|
||||
const showFeedback = (message, type) => {
|
||||
if (!feedback) return;
|
||||
feedback.textContent = message;
|
||||
feedback.className = "alert alert-" + type + " mb-3";
|
||||
feedback.style.whiteSpace = "pre-line";
|
||||
};
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ topic: targetTopic, payload: payload })
|
||||
}).then(async (response) => {
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.message || "Unbekannter MQTT-Fehler.");
|
||||
showFeedback("Befehl gesendet.\nTopic: " + result.topic + "\nInhalt: " + result.payload, "success");
|
||||
}).catch((error) => {
|
||||
showFeedback("Befehl konnte nicht gesendet werden: " + error.message + "\nTopic: " + targetTopic + "\nInhalt: " + payload, "danger");
|
||||
});
|
||||
}
|
||||
|
||||
function initializeWateringModal() {
|
||||
document.querySelectorAll("[data-watering-toggle]").forEach((button) => {
|
||||
const updateButton = () => {
|
||||
const zone = wateringZones[button.dataset.wateringZone];
|
||||
const state = wateringLabel(zone.topic, zone.valve);
|
||||
button.textContent = state === "aus" ? "Ein" : "Aus";
|
||||
button.classList.toggle("btn-success", state === "aus");
|
||||
button.classList.toggle("btn-danger", state !== "aus");
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
const zone = wateringZones[button.dataset.wateringZone];
|
||||
if (wateringLabel(zone.topic, zone.valve) === "aus") {
|
||||
const timerInput = document.querySelector(`[data-watering-timer='${button.dataset.wateringZone}']`);
|
||||
const timer = Math.min(120, Math.max(1, Number(timerInput.value) || 30));
|
||||
wateringCommand(zone.topic, { valve: zone.valve, set: "open", timer: timer });
|
||||
} else {
|
||||
wateringCommand(zone.topic, { valve: zone.valve, set: "close" });
|
||||
}
|
||||
});
|
||||
updateButton();
|
||||
});
|
||||
document.querySelectorAll("[data-watering-auto-toggle]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(",");
|
||||
const modes = button.dataset.wateringAuto.split(",");
|
||||
const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index]));
|
||||
topics.forEach((topic, index) => {
|
||||
wateringCommand(topic + "/auto", active ? "Stop" : modes[index], true);
|
||||
});
|
||||
});
|
||||
updateWateringAutoButton(button);
|
||||
});
|
||||
updateWateringStatus();
|
||||
}
|
||||
|
||||
function wateringJson(topic) {
|
||||
const value = mqttData[topic];
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(typeof value === "string" ? value : new TextDecoder().decode(value));
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function wateringHistory(topic, valve) {
|
||||
const data = wateringJson(topic + "/LastWatering");
|
||||
if (!data) return { dateTime: "--", duration: "--" };
|
||||
return {
|
||||
dateTime: data["wateringDateTime" + valve] || "--",
|
||||
duration: data["wateringDurationSecs" + valve] !== undefined
|
||||
? wateringTimeLabel(Number(data["wateringDurationSecs" + valve]))
|
||||
: "--"
|
||||
};
|
||||
}
|
||||
|
||||
function wateringAutoActive(topic, mode) {
|
||||
const timers = wateringJson(topic + "/Timers");
|
||||
return !!(timers && timers.auto === mode);
|
||||
}
|
||||
|
||||
function wateringValue(data, valve) {
|
||||
if (!data) return null;
|
||||
const candidates = [
|
||||
data.timers && data.timers[valve],
|
||||
data.timers && data.timers[String(valve)],
|
||||
data[valve],
|
||||
data[String(valve)],
|
||||
data.valves && data.valves[valve],
|
||||
data.valves && data.valves[String(valve)],
|
||||
data.status && data.status[valve],
|
||||
data.status && data.status[String(valve)],
|
||||
data.valveState && data.valveState[valve],
|
||||
data.valveState && data.valveState[String(valve)],
|
||||
data.setValveState && data.setValveState[valve],
|
||||
data.setValveState && data.setValveState[String(valve)]
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate !== undefined) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wateringState(value) {
|
||||
if (value && typeof value === "object") {
|
||||
return value.state ?? value.status ?? value.valveState ?? value.set;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function wateringSeconds(value) {
|
||||
if (value && typeof value === "object") {
|
||||
value = value.remaining ?? value.timer ?? value.seconds ?? value.time;
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function wateringLabel(topic, valve) {
|
||||
const timers = wateringJson(topic + "/Timers");
|
||||
const status = wateringJson(topic + "/status");
|
||||
const lastSeen = wateringTimersLastSeen[topic + "/Timers"];
|
||||
if (!lastSeen || Date.now() - lastSeen > 3000) return "aus";
|
||||
const remaining = wateringValue(timers, valve);
|
||||
if (remaining !== null) {
|
||||
const seconds = wateringSeconds(remaining);
|
||||
if (seconds !== 0) return wateringTimeLabel(Math.abs(seconds));
|
||||
return "aus";
|
||||
}
|
||||
const state = wateringState(wateringValue(status, valve));
|
||||
if (state === true || state === 1 || state === "1" || state === "open" || state === "OPEN") return "an";
|
||||
if (state === false || state === 0 || state === "0" || state === "close" || state === "closed" || state === "CLOSED") return "aus";
|
||||
return "--";
|
||||
}
|
||||
|
||||
function wateringTimeLabel(seconds) {
|
||||
if (seconds < 60) return seconds + "s";
|
||||
return Math.ceil(seconds / 60) + "m";
|
||||
}
|
||||
|
||||
function wateringIsPaused(topic, valve) {
|
||||
const timers = wateringJson(topic + "/Timers");
|
||||
const remaining = wateringValue(timers, valve);
|
||||
return remaining !== null && wateringSeconds(remaining) < 0;
|
||||
}
|
||||
|
||||
// Liest den von auto_watering.py stündlich veröffentlichten Regen-Status
|
||||
// (retained, siehe Gartenwasser/AutoWatering/<zone>/RainStatus) und sagt,
|
||||
// ob für die Zone heute noch eine automatische Bewässerung aussteht.
|
||||
function wateringAutoPlanned(autoZoneKey) {
|
||||
const status = wateringJson("Gartenwasser/AutoWatering/" + autoZoneKey + "/RainStatus");
|
||||
return !!(status && status.would_water && !status.watered_recently);
|
||||
}
|
||||
|
||||
function updateWateringStatus() {
|
||||
const indicators = [
|
||||
["wateringFrontStatus", "wateringFrontIcon", "Vorn", "Gartenwasser/vorn", 6, "garten_vorn"],
|
||||
["wateringTroughStatus", "wateringTroughIcon", "Tröge", "Gartenwasser/vorn", 1, "troege"],
|
||||
["wateringBedsStatus", "wateringBedsIcon", "Beete", "Gartenwasser/hinten", 6, "hochbeete"]
|
||||
];
|
||||
indicators.forEach(([statusId, iconId, name, topic, valve, autoZoneKey]) => {
|
||||
const status = document.getElementById(statusId);
|
||||
const icon = document.getElementById(iconId);
|
||||
if (!status || !icon) return;
|
||||
const state = wateringLabel(topic, valve);
|
||||
const planned = state === "aus" && wateringAutoPlanned(autoZoneKey);
|
||||
updateWateringIndicator(status, icon, name, state, wateringIsPaused(topic, valve), planned);
|
||||
});
|
||||
document.querySelectorAll("[data-watering-toggle]").forEach((button) => {
|
||||
const zone = wateringZones[button.dataset.wateringZone];
|
||||
const state = wateringLabel(zone.topic, zone.valve);
|
||||
button.textContent = state === "aus" ? "Ein" : "Aus";
|
||||
button.classList.toggle("btn-success", state === "aus");
|
||||
button.classList.toggle("btn-danger", state !== "aus");
|
||||
});
|
||||
document.querySelectorAll("[data-watering-auto-toggle]").forEach(updateWateringAutoButton);
|
||||
[
|
||||
["troughs", "Gartenwasser/vorn", 1],
|
||||
["frontGarden", "Gartenwasser/vorn", 6],
|
||||
["raisedBeds", "Gartenwasser/hinten", 6]
|
||||
].forEach(([zone, topic, valve]) => {
|
||||
const history = wateringHistory(topic, valve);
|
||||
const dateElement = document.querySelector(`[data-watering-last='${zone}']`);
|
||||
const durationElement = document.querySelector(`[data-watering-duration='${zone}']`);
|
||||
if (dateElement) dateElement.textContent = history.dateTime;
|
||||
if (durationElement) durationElement.textContent = history.duration;
|
||||
});
|
||||
}
|
||||
|
||||
function updateWateringAutoButton(button) {
|
||||
const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(",");
|
||||
const modes = button.dataset.wateringAuto.split(",");
|
||||
const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index]));
|
||||
if (!button.dataset.wateringLabel) {
|
||||
button.dataset.wateringLabel = button.textContent.replace(/^Automatik\s+/, "");
|
||||
}
|
||||
button.textContent = (active ? "Aus: " : "Ein: ") + button.dataset.wateringLabel;
|
||||
button.classList.toggle("btn-outline-primary", !active);
|
||||
button.classList.toggle("btn-outline-danger", active);
|
||||
}
|
||||
|
||||
function updateWateringIndicator(status, icon, name, state, paused, planned) {
|
||||
// "Aus" bleibt bei nicht geplant stehen; bei geplant ersetzt die
|
||||
// Stoppuhr (U+23F1, ohne Emoji-Variationsselektor -> einfarbig, folgt
|
||||
// der Icon-Farbe) den Text, um Platz auf der textPath zu sparen.
|
||||
status.textContent = state === "aus" ? (planned ? "⏱" : "Aus") : state;
|
||||
// Geplant-Blau ("#3A6292") entspricht dem Wassertropfen-Icon (id="water").
|
||||
const color = state !== "aus" ? (paused ? "#ffc107" : "#20c997") : planned ? "#3A6292" : "#6c757d";
|
||||
icon.setAttribute("color", color);
|
||||
icon.setAttribute("fill", color);
|
||||
icon.style.color = color;
|
||||
icon.querySelector("title").textContent = name + ": " + (state === "aus" && planned ? "Aus (Automatik heute noch geplant)" : state);
|
||||
}
|
||||
|
||||
window.setInterval(updateWateringStatus, 1000);
|
||||
|
||||
function submitFormAjax(event) {
|
||||
let xmlhttp = window.XMLHttpRequest ?
|
||||
new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP");
|
||||
@@ -729,10 +990,7 @@ var chartSettings = {
|
||||
chrt.options.plugins.annotation.annotations = await response2.json();
|
||||
}
|
||||
chrt.options.events = ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'];
|
||||
if(animation)
|
||||
chrt.update();
|
||||
else
|
||||
chrt.update("none");
|
||||
chrt.update("none");
|
||||
} catch (error) {
|
||||
console.log(error.message);
|
||||
}
|
||||
@@ -780,7 +1038,6 @@ var chartSettings = {
|
||||
} catch (error) {
|
||||
console.log(error.message);
|
||||
}
|
||||
setTimeout(function () { getData(chart, url, sunrise) }, 5 * 60 * 1000); //renew data every 5 min.
|
||||
}
|
||||
|
||||
function powerToString(power) {
|
||||
|
||||
Reference in New Issue
Block a user