From 5f643b9844db77a7713d1675b9a0c828036c8c33 Mon Sep 17 00:00:00 2001 From: Moirtz Wagner Date: Sun, 30 Aug 2026 19:21:07 +0200 Subject: [PATCH] AutoAction-Runner: Automatiken ausfuehren Bisher konnte man Automatiken nur anlegen - ausgefuehrt hat sie niemand. restricted/autoActions/autoaction_runner.py holt das nach. Dauerlaeufer statt Cronjob, aus zwei Gruenden: Schwellwert-Ausloeser sollen greifen, wenn die MQTT-Nachricht hereinkommt, und actor_states.current_value wird sonst von niemandem fortgeschrieben - beim Discovery einmal gesetzt und danach nie wieder. Ein zustandsloser Lauf haette gar nichts, womit er vergleichen koennte. Der Runner pflegt den Wert nebenbei mit, wovon auch der Editor profitiert: er zeigt neben jedem Messwert den aktuellen Stand. Ausgeloest wird nur auf der steigenden Flanke (automations.cond_met), sonst wuerde "Temperatur ueber 22 Grad" bei jedem Takt erneut feuern. Aus demselben Grund heissen Zeit-Ausloeser jetzt "ab 16:30" statt "gleich 16:30": ein Gleichheitsvergleich waere nur in einer einzigen Minute wahr, und ein Ausfall in genau dieser Minute kostet den ganzen Tag. Dieselbe Ueberlegung steht hinter den breiten Zeitfenstern in auto_watering.py. Der Weg zum Geraet haengt an der URL des Aktors: mqtt:// abonniert und publiziert, http:// pollt und haengt Parameter an, io:// spricht mit der Tahoma-Box, Logic rechnet Uhrzeit, Datum und Sonnenzeiten. Alle vier stehen in transports.py; eine fuenfte Geraeteart ist eine weitere Klasse mit passt(), zustaende_lesen() und senden(). fetch_calendar.py fuellt calendar_days aus openholidaysapi.org, damit "in den Ferien" und "an Feiertagen" eine Grundlage haben. Einmal jaehrlich per Cron. Die Uebersicht blendet auf schmalen Schirmen Spalten aus, statt sie wegzuschieben - auf dem Handy waren die Knoepfe zum Pausieren und Loeschen sonst nur per Seitwaertsscrollen erreichbar. Co-Authored-By: Claude Opus 5 --- ajax/AutoAction.php | 15 +- js/solar/autoActionFuncs.js | 19 +- restricted/autoActions/README.md | 120 ++++ restricted/autoActions/autoaction_runner.py | 667 ++++++++++++++++++++ restricted/autoActions/config.ini.example | 75 +++ restricted/autoActions/fetch_calendar.py | 102 +++ restricted/autoActions/transports.py | 325 ++++++++++ restricted/automations.php | 51 +- 8 files changed, 1353 insertions(+), 21 deletions(-) create mode 100644 restricted/autoActions/README.md create mode 100644 restricted/autoActions/autoaction_runner.py create mode 100644 restricted/autoActions/config.ini.example create mode 100644 restricted/autoActions/fetch_calendar.py create mode 100644 restricted/autoActions/transports.py diff --git a/ajax/AutoAction.php b/ajax/AutoAction.php index a585205..9875d12 100644 --- a/ajax/AutoAction.php +++ b/ajax/AutoAction.php @@ -67,8 +67,13 @@ if ($action === "list") { echo "

Für diese Etage ist noch nichts eingerichtet.

"; } else { echo "
"; - echo "" - . ""; + // Je schmaler der Schirm, desto weniger Spalten: auf dem Handy bleiben + // Name, Ausloeser und die Knoepfe. Sonst muss man seitwaerts scrollen, + // um eine Automatik ueberhaupt pausieren zu koennen. + echo "" + . "" + . "" + . ""; foreach ($list as $a) { // Nur die aktiven Tage nennen. Sieben Haekchen untereinander // haben die Zeile frueher unnoetig hoch gemacht. @@ -98,9 +103,9 @@ if ($action === "list") { echo ""; echo ""; echo ""; - echo ""; - echo ""; - echo ""; + echo ""; + echo ""; + echo ""; echo "
NameAuslöserAktionWannZuletzt
NameAuslöserAktionWannZuletzt
" . htmlspecialchars($a["name"]) . "" . htmlspecialchars($a["conditionText"]) . "" . htmlspecialchars($a["actionText"]) . "" . htmlspecialchars($wann) . "" . $zuletzt . "" . htmlspecialchars($a["actionText"]) . "" . htmlspecialchars($wann) . "" . $zuletzt . "" . " " diff --git a/js/solar/autoActionFuncs.js b/js/solar/autoActionFuncs.js index 8cedca9..2414c09 100644 --- a/js/solar/autoActionFuncs.js +++ b/js/solar/autoActionFuncs.js @@ -42,8 +42,9 @@ function commandById(id) { return null; } -function operatorLabel(op) { - return op === "!=" ? "≠" : op; +function operatorLabel(state, op) { + const treffer = state.operators.find(o => o.value === op); + return treffer ? treffer.label : op; } /** Erster Messwert des ersten Geraets - Vorbelegung fuer eine neue Zeile. */ @@ -150,7 +151,7 @@ function renderConditions() { add.onclick = () => { const state = ersterMesswert(); if (!state) return; - gruppe.push({ state_id: state.id, operator: state.operators[0], value: standardWert(state) }); + gruppe.push({ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }); renderConditions(); }; block.appendChild(add); @@ -179,7 +180,7 @@ function conditionRow(gi, bi, bed) { const neu = deviceById(Number(geraet.value)); if (!neu || !neu.states.length) return; bed.state_id = neu.states[0].id; - bed.operator = neu.states[0].operators[0]; + bed.operator = neu.states[0].operators[0].value; bed.value = standardWert(neu.states[0]); renderConditions(); }; @@ -196,7 +197,7 @@ function conditionRow(gi, bi, bed) { messwert.onchange = () => { const neu = dev.states.find(s => s.id === Number(messwert.value)); bed.state_id = neu.id; - bed.operator = neu.operators[0]; + bed.operator = neu.operators[0].value; bed.value = standardWert(neu); renderConditions(); }; @@ -212,7 +213,7 @@ function conditionRow(gi, bi, bed) { // schiebt er das Zeichen aus dem sichtbaren Bereich. op.className = "form-select flex-grow-0 text-center"; op.style.flex = "0 0 5.5rem"; - optionListe(op, state.operators.map(o => ({ value: o, text: operatorLabel(o) })), bed.operator); + optionListe(op, state.operators.map(o => ({ value: o.value, text: o.label })), bed.operator); op.onchange = () => { bed.operator = op.value; renderSummary(); }; zeile.appendChild(op); @@ -254,7 +255,7 @@ function conditionText() { const treffer = stateById(bed.state_id); if (!treffer) return "?"; let s = treffer.device.name + ": " + treffer.state.name + " " - + operatorLabel(bed.operator) + " " + bed.value; + + operatorLabel(treffer.state, bed.operator) + " " + bed.value; if (treffer.state.unit) s += " " + treffer.state.unit; return s; }).join(" und "); @@ -426,7 +427,7 @@ function loadAutomatic() { if (!autoModel.groups.length) { const state = ersterMesswert(); if (state) { - autoModel.groups = [[{ state_id: state.id, operator: state.operators[0], value: standardWert(state) }]]; + autoModel.groups = [[{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]]; } } if (!autoModel.actions.length) { @@ -472,7 +473,7 @@ function openAutoActionModal(params) { document.getElementById("btnAddGroup").addEventListener("click", () => { const state = ersterMesswert(); if (!state) return; - autoModel.groups.push([{ state_id: state.id, operator: state.operators[0], value: standardWert(state) }]); + autoModel.groups.push([{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]); renderConditions(); }); document.getElementById("btnAddActor").addEventListener("click", () => { diff --git a/restricted/autoActions/README.md b/restricted/autoActions/README.md new file mode 100644 index 0000000..ffa11b3 --- /dev/null +++ b/restricted/autoActions/README.md @@ -0,0 +1,120 @@ +# AutoActions + +Automatiken, die im Web-UI unter „Automatismen" angelegt werden und hier +ausgeführt werden: *wenn Bedingung, dann Kommando*. + +``` +Browser Datenbank homeMesh Runner +────────────────────── ───────────────────────── ──────────────────── +Karte „Automatismen" ──▶ automations ──▶ autoaction_runner.py +ajax/AutoAction.php automation_conditions MQTT / HTTP / Tahoma +restricted/automations.php automation_actions ──▶ Geräte +js/solar/autoActionFuncs.js automation_action_params + automation_log + calendar_days ◀── fetch_calendar.py +``` + +## Wie eine Automatik aufgebaut ist + +Eine Automatik hat **Auslöser**, **Rahmenbedingungen** und **Aktionen**. + +Ein Auslöser vergleicht einen Messwert (`actor_states`) mit einer Schwelle. +Mehrere Auslöser werden über `group_no` verknüpft: gleiche Nummer heißt UND, +verschiedene Nummern heißen ODER — ausgewertet wird `any(all(gruppe))`. Im +Editor ist eine Gruppe ein gerahmter Block mit eigenem „+ Bedingung", zwischen +den Blöcken steht ein ODER. Die Klammerung ist damit gezeichnet und nicht bloß +vereinbart, und darunter steht derselbe Ausdruck noch einmal als Satz. + +Eine Aktion ist ein Kommando (`actor_commands`) mit einem Wert je Parameter +(`command_parameters`) — nicht vier feste Spalten „Wert 1" bis „Wert 4", +sondern so viele Zeilen, wie das Gerät Parameter hat. + +Die Rahmenbedingungen (Wochentage, Zeitfenster, Ferien, Feiertage) sagen, wann +die Automatik überhaupt hinsehen darf. + +## Warum ein Dauerläufer und kein Cronjob + +Zwei Gründe: + +* Schwellwert-Auslöser sollen greifen, wenn die MQTT-Nachricht hereinkommt, + nicht erst im nächsten Minutenraster. +* `actor_states.current_value` wird sonst von niemandem fortgeschrieben — es + wird beim Geräte-Discovery einmal gesetzt und danach nie wieder. Ein + zustandsloser Cronjob hätte gar nichts, womit er vergleichen könnte. Der + Runner pflegt den Wert nebenbei mit (gedrosselt auf einmal je Minute), wovon + auch der Editor profitiert: er zeigt neben jedem Messwert den aktuellen Stand. + +## Nur die steigende Flanke + +`automations.cond_met` hält fest, ob die Bedingung beim letzten Durchlauf schon +erfüllt war. Ohne das würde „Temperatur über 22 Grad" bei jedem Takt erneut +feuern. Verlässt die Automatik ihr Zeitfenster, wird die Flanke +zurückgesetzt, damit sie im nächsten Fenster wieder steigen kann. + +Aus demselben Grund heißen Zeit-Auslöser **„ab 16:30"** und nicht „gleich +16:30". Ein Gleichheitsvergleich wäre nur in genau einer Minute wahr — fällt +der Runner in dieser Minute aus, ist die Automatik für den Tag verloren. „Ab +16:30" bleibt bis Mitternacht wahr, ausgelöst wird trotzdem nur einmal. Die +breiten Zeitfenster in `auto_watering.py` folgen derselben Überlegung. + +`force_once` („am Ende des Zeitraums auf jeden Fall ausführen") greift, wenn +das Fenster zugeht und in diesem Fenster noch nichts passiert ist. + +## Transporte + +Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`: + +| URL | Messwert (`actor_states.url`) | Kommando | +|-------------|-------------------------------|-------------------------------------| +| `mqtt://…` | vollständiges Topic, abonniert | publiziert auf das Parameter-Topic | +| `http://…` | Feldname in der JSON-Antwort, gepollt | Parameter als Abfrageargumente | +| `io://…` | Tahoma-Statusname, gepollt | `exec/apply` an die Tahoma-Box | +| `Logic` | gerechnet: Uhrzeit, Datum, Sonne | – | + +Alle vier stehen in `transports.py`. Eine fünfte Geräteart kommt als weitere +Klasse dazu; sie braucht `passt()`, `zustaende_lesen()` und `senden()`. + +Bei HTTP heißen die Schaltbefehle nicht so wie in der Datenbank — dafür gibt es +in `HTTPTransport` eine kleine Übersetzungstabelle (`turn_on` → `on=true`). +Das ist die Stelle, die beim Anschluss neuer HTTP-Geräte wächst. + +## Voraussetzungen + +* Python 3 mit `pymysql`, `requests`, `paho-mqtt` +* `homeMesh_automations.sql` einmal eingespielt +* In `../deviceDiscovery/config.ini` muss **`clear_tables = false`** stehen. + Discovery schreibt mit `ON DUPLICATE KEY UPDATE` auf den URLs, das Leeren ist + unnötig — ein `TRUNCATE` würde dagegen die Geräte-IDs neu vergeben, und die + Automatiken zeigen per Fremdschlüssel genau auf diese IDs. + +## Einrichten + +```bash +cp config.ini.example config.ini # ausfüllen: Datenbank, MQTT, Tahoma +python3 fetch_calendar.py # Feiertage und Ferien holen +python3 autoaction_runner.py --once --dry-run --verbose # Probelauf +``` + +`--dry-run` schaltet nichts, protokolliert aber jedes Kommando, das geschickt +würde. `--once` macht einen einzigen Durchlauf. + +Im Dauerbetrieb wird `autoaction_runner.py` beim Booten gestartet (auf der +Synology über den Aufgabenplaner, Ereignis „Hochfahren", als root). Er +verbindet sich selbst neu, wenn MQTT wegbricht, und lädt das Regelwerk nach, +sobald im Browser etwas gespeichert wurde — ein Neustart nach jeder Änderung +ist nicht nötig. + +`fetch_calendar.py` gehört einmal jährlich in den Cron: + +``` +0 4 1 1 * /usr/bin/python3 /volume1/web/smart/restricted/autoActions/fetch_calendar.py +``` + +Ein zusätzlicher Lauf im Herbst schadet nicht — die Ferientermine des +übernächsten Schuljahres stehen erst später fest. + +## Nachsehen, was passiert ist + +`automation_log` hält je Auslösung fest, ob sie durchlief (`fired`), wegen +`force_once` nachgeholt wurde (`forced`) oder scheiterte (`error`, mit Grund in +`detail`). Einträge älter als 30 Tage räumt der Runner selbst weg. diff --git a/restricted/autoActions/autoaction_runner.py b/restricted/autoActions/autoaction_runner.py new file mode 100644 index 0000000..4543463 --- /dev/null +++ b/restricted/autoActions/autoaction_runner.py @@ -0,0 +1,667 @@ +#!/usr/bin/env python3 +""" +AutoAction-Runner - fuehrt die im Web-UI angelegten Automatiken aus. + +Laeuft als Dauerprozess, nicht als Cronjob. Zwei Gruende: + + * Schwellwert-Ausloeser ("Temperatur ueber 22 Grad") sollen sofort greifen, + wenn die Nachricht hereinkommt, und nicht bis zum naechsten Minutenraster + warten. + * `actor_states.current_value` wird sonst von niemandem fortgeschrieben - + beim Geraete-Discovery einmal gesetzt und danach nie wieder. Ein + zustandsloser Cronjob haette also gar nichts, womit er vergleichen + koennte. Der Runner pflegt den Wert nebenbei mit, wodurch auch der Editor + im Browser aktuelle Zahlen anzeigt. + +Ablauf: + + Start Regelwerk und Geraetemodell laden, MQTT-Topics abonnieren + Ereignis MQTT-Nachricht -> Wert merken -> im naechsten Takt auswerten + Takt alle tick_seconds: Uhrzeit/Datum/Sonnenzeiten neu rechnen, + HTTP- und Tahoma-Geraete pollen, Regelwerk auf Aenderung pruefen + Pruefen aktiv? (enabled, Wochentag, Ferien/Feiertag, Zeitfenster) + -> Bedingungen auswerten -> steigende Flanke -> Aktionen + +Nur die steigende Flanke loest aus: `automations.cond_met` haelt fest, ob die +Bedingung beim letzten Durchlauf schon erfuellt war. Ohne das wuerde +"Temperatur ueber 22 Grad" bei jedem Takt erneut feuern. + +Zeit-Ausloeser heissen "ab 16:30" und nicht "gleich 16:30" - damit bleibt die +Bedingung bis Mitternacht wahr und ein verpasster Takt kostet nicht den +ganzen Tag. Ausgeloest wird trotzdem nur einmal, eben wegen der Flanke. +Dieselbe Ueberlegung steht hinter den breiten Zeitfenstern in +auto_watering.py. + +Tabellen siehe homeMesh_automations.sql, Konfiguration siehe config.ini.example. +""" + +import argparse +import configparser +import logging +import os +import sys +import time +from datetime import date, datetime, timedelta + +import pymysql +import requests +import paho.mqtt.client as mqtt + +from transports import (HTTPTransport, LogicTransport, MQTTTransport, + TahomaTransport) + +logger = logging.getLogger("autoaction") + +# Wie lange ein Messwert in der Datenbank stehen bleiben darf, bevor er +# aufgefrischt wird. Der Wert dient nur der Anzeige im Editor; jede Nachricht +# sofort zu schreiben waere bei einem gespraechigen Sensor sinnlose Last. +SCHREIB_ABSTAND = timedelta(seconds=60) + +# Aelteres im Protokoll interessiert niemanden mehr. +LOG_AUFBEWAHRUNG_TAGE = 30 + + +# =========================================================================== +# Konfiguration +# =========================================================================== + +class Config: + def __init__(self, dateiname="config.ini"): + pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), dateiname) + if not os.path.exists(pfad): + raise FileNotFoundError( + "%s fehlt - config.ini.example kopieren und ausfuellen." % pfad) + self.cfg = configparser.ConfigParser() + self.cfg.read(pfad, encoding="utf-8") + + def text(self, sektion, schluessel, vorgabe=""): + return self.cfg.get(sektion, schluessel, fallback=vorgabe).strip() + + def zahl(self, sektion, schluessel, vorgabe=0): + try: + return self.cfg.getint(sektion, schluessel, fallback=vorgabe) + except ValueError: + return vorgabe + + def ja(self, sektion, schluessel, vorgabe=False): + return self.text(sektion, schluessel, str(vorgabe)).lower() in ("true", "1", "yes", "on") + + +# =========================================================================== +# Datenbank +# =========================================================================== + +def verbinden(config, sektion="database"): + return pymysql.connect( + host=config.text("database", "host", "localhost"), + port=config.zahl("database", "port", 3306), + user=config.text(sektion, "user") or config.text("database", "user"), + password=config.text(sektion, "password") or config.text("database", "password"), + database=config.text(sektion, "database"), + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=True) + + +class Regelwerk: + """ + Das geladene Abbild der Datenbank: Automatiken mit ihren Bedingungen und + Aktionen, dazu die Messwerte und Kommandos, die sie benutzen. + """ + + def __init__(self, automatiken, states, kommandos, signatur): + self.automatiken = automatiken + self.states = states # state_id -> Beschreibung + self.kommandos = kommandos # command_id -> Beschreibung + self.signatur = signatur + + @staticmethod + def signatur_lesen(db): + """ + Woran der Runner merkt, dass er neu laden muss. `changed` allein + genuegt nicht: eine geloeschte Automatik veraendert den groessten + Zeitstempel nicht. Deshalb zaehlen die Zeilen mit - auch die der + Geraetetabellen, damit ein Discovery-Lauf ebenfalls durchschlaegt. + """ + with db.cursor() as c: + c.execute("""SELECT (SELECT COUNT(*) FROM automations) AS a, + (SELECT UNIX_TIMESTAMP(MAX(changed)) FROM automations) AS t, + (SELECT COUNT(*) FROM automation_conditions) AS b, + (SELECT COUNT(*) FROM automation_actions) AS c, + (SELECT COUNT(*) FROM actor_states) AS d, + (SELECT COUNT(*) FROM actor_commands) AS e""") + return tuple(sorted(c.fetchone().items())) + + @classmethod + def laden(cls, db): + signatur = cls.signatur_lesen(db) + + states = {} + with db.cursor() as c: + c.execute("""SELECT s.id, s.state_name, s.url AS state_url, s.current_value, + a.url AS actor_url, a.name AS actor_name, t.type + FROM actor_states s + JOIN actors a ON a.id = s.actor_id + LEFT JOIN state_types t ON s.state_type = t.id""") + for row in c.fetchall(): + row["type"] = row["type"] or "string" + states[row["id"]] = row + + kommandos = {} + with db.cursor() as c: + c.execute("""SELECT k.id, k.command_name, k.command_url, + a.url AS actor_url, a.name AS actor_name + FROM actor_commands k JOIN actors a ON a.id = k.actor_id""") + for row in c.fetchall(): + row["params"] = [] + kommandos[row["id"]] = row + with db.cursor() as c: + c.execute("""SELECT id, command_id, parameter_name, url FROM command_parameters + ORDER BY command_id, id""") + for row in c.fetchall(): + if row["command_id"] in kommandos: + kommandos[row["command_id"]]["params"].append(row) + + automatiken = {} + with db.cursor() as c: + c.execute("SELECT * FROM automations WHERE enabled = 1") + for row in c.fetchall(): + row["gruppen"] = {} + row["aktionen"] = [] + automatiken[row["id"]] = row + with db.cursor() as c: + c.execute("""SELECT * FROM automation_conditions + ORDER BY automation_id, group_no, position, id""") + for row in c.fetchall(): + auto = automatiken.get(row["automation_id"]) + if auto is None: + continue + if row["state_id"] not in states: + logger.warning("Automatik %s: Messwert %s gibt es nicht mehr", + auto["name"], row["state_id"]) + continue + auto["gruppen"].setdefault(row["group_no"], []).append(row) + with db.cursor() as c: + c.execute("""SELECT a.id, a.automation_id, a.command_id, p.parameter_id, p.value + FROM automation_actions a + LEFT JOIN automation_action_params p ON p.action_id = a.id + ORDER BY a.automation_id, a.position, a.id""") + gesammelt = {} + for row in c.fetchall(): + auto = automatiken.get(row["automation_id"]) + if auto is None: + continue + aktion = gesammelt.get(row["id"]) + if aktion is None: + aktion = {"command_id": row["command_id"], "werte": {}} + gesammelt[row["id"]] = aktion + auto["aktionen"].append(aktion) + if row["parameter_id"] is not None: + aktion["werte"][row["parameter_id"]] = row["value"] + + logger.info("Regelwerk geladen: %d Automatiken, %d Messwerte, %d Kommandos", + len(automatiken), len(states), len(kommandos)) + return cls(automatiken, states, kommandos, signatur) + + +# =========================================================================== +# Auswertung +# =========================================================================== + +def minuten(text): + """"16:30" oder "16:30:00" als Minuten seit Mitternacht.""" + teile = str(text).strip().split(":") + return int(teile[0]) * 60 + int(teile[1]) + + +def als_datum(text): + for form in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%y"): + try: + return datetime.strptime(str(text).strip(), form).date() + except ValueError: + continue + return None + + +def als_zeitpunkt(text): + """Datum mit Uhrzeit. Das Web-Feld liefert "2026-08-30T16:30".""" + roh = str(text).strip().replace("T", " ") + for form in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", + "%d.%m.%Y %H:%M:%S", "%d.%m.%Y %H:%M"): + try: + return datetime.strptime(roh, form) + except ValueError: + continue + tag = als_datum(roh.split(" ")[0]) + return datetime.combine(tag, datetime.min.time()) if tag else None + + +WAHR = {"true", "1", "on", "ja", "yes", "an"} + + +def bedingung_erfuellt(bedingung, state, wert, jetzt): + """ + Ein einzelner Vergleich. `wert` ist der aktuelle Messwert als Text, so wie + er vom Geraet kam; `bedingung["value"]` die eingestellte Schwelle. + Unbekannter Wert heisst nicht erfuellt - lieber nicht schalten als auf + Verdacht schalten. + """ + if wert is None or wert == "": + return False + typ = state["type"] + op = bedingung["operator"] + soll = bedingung["value"] + + try: + if typ in ("integer", "float"): + ist_z, soll_z = float(str(wert).replace(",", ".")), float(str(soll).replace(",", ".")) + if op == "=": return ist_z == soll_z + if op == "!=": return ist_z != soll_z + if op == ">": return ist_z > soll_z + if op == "<": return ist_z < soll_z + if op == ">=": return ist_z >= soll_z + if op == "<=": return ist_z <= soll_z + return False + + if typ == "time": + ist_m, soll_m = minuten(wert), minuten(soll) + return ist_m >= soll_m if op == ">=" else ist_m < soll_m + + if typ == "deltatime": + # Der Messwert ist der Sonnenauf- bzw. -untergang, die Schwelle + # ein Versatz davor oder danach. Verglichen wird gegen die Uhr. + versatz = minuten(soll) + ziel = minuten(wert) + (versatz if op == "+" else -versatz) + return jetzt.hour * 60 + jetzt.minute >= ziel + + if typ == "date": + ist_d, soll_d = als_datum(wert), als_datum(soll) + if ist_d is None or soll_d is None: + return False + return ist_d >= soll_d if op == ">=" else ist_d < soll_d + + if typ == "datetime": + ist_d, soll_d = als_zeitpunkt(wert), als_zeitpunkt(soll) + if ist_d is None or soll_d is None: + return False + return ist_d >= soll_d if op == ">=" else ist_d < soll_d + + if typ == "bool": + ist_b = str(wert).strip().lower() in WAHR + soll_b = str(soll).strip().lower() in WAHR + return ist_b == soll_b if op == "=" else ist_b != soll_b + + # string und alles Uebrige + if op == "=": + return str(wert).strip() == str(soll).strip() + return str(wert).strip() != str(soll).strip() + + except (ValueError, IndexError) as fehler: + logger.debug("Vergleich %s %s %s nicht moeglich: %s", wert, op, soll, fehler) + return False + + +def gruppen_erfuellt(automatik, regelwerk, werte, jetzt): + """ + Gleiche group_no = UND, verschiedene = ODER. Eine Automatik ohne + Bedingungen loest nie aus - sonst wuerde sie nach einem Discovery-Lauf, + der ihren Messwert entfernt hat, ploetzlich dauernd feuern. + """ + if not automatik["gruppen"]: + return False + for bedingungen in automatik["gruppen"].values(): + if all(bedingung_erfuellt(b, regelwerk.states[b["state_id"]], + werte.get(b["state_id"]), jetzt) + for b in bedingungen): + return True + return False + + +def im_zeitfenster(jetzt, von, bis): + """von > bis heisst: das Fenster reicht ueber Mitternacht.""" + m = jetzt.hour * 60 + jetzt.minute + a, b = minuten(von), minuten(bis) + return a <= m <= b if a <= b else (m >= a or m <= b) + + +def tag_passt(automatik, jetzt, kalender): + if not automatik["weekdays"] & (1 << jetzt.weekday()): + return False + if kalender["feiertag"] and not automatik["on_holiday"]: + return False + if kalender["ferien"] and not automatik["on_vacation"]: + return False + return True + + +# =========================================================================== +# Der Runner +# =========================================================================== + +class Runner: + + def __init__(self, config, dry_run=False): + self.config = config + self.dry_run = dry_run or config.ja("runner", "dry_run") + self.db = verbinden(config) + self.solar = verbinden(config, "solar") + + self.werte = {} # state_id -> letzter bekannter Wert + self.geschrieben = {} # state_id -> wann zuletzt in die DB + self.war_aktiv = {} # automation_id -> war im Zeitfenster + self.lief_im_fenster = {} # automation_id -> hat im Fenster ausgeloest + self._sonne = (None, "00:00", "00:00") # (datum, aufgang, untergang) + self._kalender = (None, {"feiertag": False, "ferien": False}) + self._letzte_saeuberung = None + + self.mqtt = self._mqtt_verbinden() + self.transporte = [ + MQTTTransport(self.mqtt, self.dry_run), + HTTPTransport(requests, dry_run=self.dry_run), + TahomaTransport(requests, config.text("tahoma", "pin"), + config.text("tahoma", "token"), + config.zahl("tahoma", "timeout", 10), self.dry_run), + LogicTransport(self.sonnenzeiten), + ] + self.regelwerk = None + + # --- Aufbau ---------------------------------------------------------- + + def _mqtt_verbinden(self): + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, + client_id=self.config.text("mqtt", "client_id", "autoaction_runner")) + benutzer = self.config.text("mqtt", "username") + if benutzer: + client.username_pw_set(benutzer, self.config.text("mqtt", "password")) + client.on_message = self._mqtt_nachricht + client.connect(self.config.text("mqtt", "broker", "localhost"), + self.config.zahl("mqtt", "port", 1883), 60) + client.loop_start() + return client + + def _mqtt_nachricht(self, client, userdata, nachricht): + # Der Client laeuft schon, waehrend __init__ noch die Transporte baut. + for transport in getattr(self, "transporte", []): + if isinstance(transport, MQTTTransport): + transport.nachricht(nachricht.topic, nachricht.payload) + + def transport_fuer(self, actor_url): + for transport in self.transporte: + if transport.passt(actor_url): + return transport + return None + + def regelwerk_laden(self): + self.regelwerk = Regelwerk.laden(self.db) + # Jeder Transport bekommt die Messwerte, fuer die er zustaendig ist. + for transport in self.transporte: + passende = [{"id": s["id"], "actor_url": s["actor_url"], "state_url": s["state_url"]} + for s in self.regelwerk.states.values() + if transport.passt(s["actor_url"])] + transport.zustaende_anmelden(passende) + # Der zuletzt bekannte Wert aus der Datenbank ist besser als gar + # keiner: nach einem Neustart steht sonst jede Bedingung auf "unklar", + # bis das Geraet zufaellig etwas schickt. + for s in self.regelwerk.states.values(): + if s["id"] not in self.werte and s["current_value"] is not None: + self.werte[s["id"]] = s["current_value"] + + # --- Umgebung -------------------------------------------------------- + + def sonnenzeiten(self): + """Aus solarLog.daylight, einmal je Tag geholt.""" + heute = date.today() + if self._sonne[0] == heute: + return self._sonne[1], self._sonne[2] + auf, unter = "00:00", "00:00" + try: + with self.solar.cursor() as c: + c.execute("SELECT sunrise, sunset FROM daylight WHERE date = %s", (heute,)) + zeile = c.fetchone() + if zeile: + auf = self._als_uhrzeit(zeile["sunrise"]) + unter = self._als_uhrzeit(zeile["sunset"]) + else: + logger.warning("Kein Eintrag in daylight fuer %s", heute) + except Exception as fehler: + logger.warning("Sonnenzeiten nicht lesbar: %s", fehler) + self._sonne = (heute, auf, unter) + return auf, unter + + @staticmethod + def _als_uhrzeit(wert): + """TIME-Spalten liefert pymysql als timedelta, nicht als Text.""" + if isinstance(wert, timedelta): + minute = int(wert.total_seconds()) // 60 + return "%02d:%02d" % (minute // 60 % 24, minute % 60) + return str(wert)[:5] + + def kalender(self): + """Ferien und Feiertage von heute, einmal je Tag geholt.""" + heute = date.today() + if self._kalender[0] == heute: + return self._kalender[1] + stand = {"feiertag": False, "ferien": False} + try: + with self.db.cursor() as c: + c.execute("SELECT holiday, vacation FROM calendar_days WHERE date = %s", (heute,)) + zeile = c.fetchone() + if zeile: + stand = {"feiertag": bool(zeile["holiday"]), "ferien": bool(zeile["vacation"])} + except Exception as fehler: + logger.warning("Kalender nicht lesbar: %s", fehler) + self._kalender = (heute, stand) + return stand + + # --- Werte ----------------------------------------------------------- + + def werte_einsammeln(self, mit_pollen): + neu = {} + for transport in self.transporte: + if isinstance(transport, MQTTTransport) or mit_pollen: + neu.update(transport.zustaende_lesen()) + if neu: + self.werte.update(neu) + self.werte_zurueckschreiben(neu) + return neu + + def werte_zurueckschreiben(self, neu): + """ + current_value nachfuehren, damit der Editor im Browser aktuelle Zahlen + zeigt ("Temperatur (= 21,4 °C)"). Gedrosselt, sonst schreibt ein + gespraechiger Sensor die Tabelle im Sekundentakt voll. + """ + jetzt = datetime.now() + faellig = [(w, i) for i, w in neu.items() + if self.geschrieben.get(i, datetime.min) + SCHREIB_ABSTAND <= jetzt] + if not faellig: + return + try: + with self.db.cursor() as c: + c.executemany("UPDATE actor_states SET current_value = %s WHERE id = %s", faellig) + for _, state_id in faellig: + self.geschrieben[state_id] = jetzt + except Exception as fehler: + logger.warning("current_value nicht schreibbar: %s", fehler) + + # --- Ausfuehren ------------------------------------------------------ + + def ausloesen(self, automatik, anlass): + fehlerText = [] + for aktion in automatik["aktionen"]: + kommando = self.regelwerk.kommandos.get(aktion["command_id"]) + if kommando is None: + fehlerText.append("Kommando %s gibt es nicht mehr" % aktion["command_id"]) + logger.error("%s: Kommando %s gibt es nicht mehr", + automatik["name"], aktion["command_id"]) + continue + transport = self.transport_fuer(kommando["actor_url"]) + if transport is None: + fehlerText.append("Kein Transport fuer %s" % kommando["actor_url"]) + logger.error("%s: fuer %s (%s) gibt es keinen Transport", + automatik["name"], kommando["actor_name"], kommando["actor_url"]) + continue + auftrag = { + "actor_url": kommando["actor_url"], + "command_url": kommando["command_url"], + "params": [{"url": p["url"], "name": p["parameter_name"], + "wert": aktion["werte"].get(p["id"], "")} + for p in kommando["params"]], + } + try: + transport.senden(auftrag) + logger.info("%s: %s -> %s", automatik["name"], kommando["actor_name"], + kommando["command_name"]) + except Exception as fehler: + fehlerText.append("%s: %s" % (kommando["command_name"], fehler)) + logger.error("%s: %s konnte nicht geschickt werden: %s", + automatik["name"], kommando["command_name"], fehler) + + ergebnis = "error" if fehlerText else anlass + detail = "; ".join(fehlerText)[:255] + try: + with self.db.cursor() as c: + c.execute("""UPDATE automations SET last_run = NOW(), changed = changed + WHERE id = %s""", (automatik["id"],)) + c.execute("""INSERT INTO automation_log (automation_id, result, detail) + VALUES (%s, %s, %s)""", (automatik["id"], ergebnis, detail)) + except Exception as fehler: + logger.warning("Protokoll nicht schreibbar: %s", fehler) + automatik["last_run"] = datetime.now() + self.lief_im_fenster[automatik["id"]] = True + + def flanke_merken(self, automatik, erfuellt): + if bool(automatik["cond_met"]) == bool(erfuellt): + return + automatik["cond_met"] = 1 if erfuellt else 0 + try: + with self.db.cursor() as c: + c.execute("""UPDATE automations SET cond_met = %s, changed = changed + WHERE id = %s""", (automatik["cond_met"], automatik["id"])) + except Exception as fehler: + logger.warning("cond_met nicht schreibbar: %s", fehler) + + def durchlauf(self): + jetzt = datetime.now() + kalender = self.kalender() + + for automatik in self.regelwerk.automatiken.values(): + aktiv = (tag_passt(automatik, jetzt, kalender) + and im_zeitfenster(jetzt, automatik["window_from"], automatik["window_to"])) + vorher_aktiv = self.war_aktiv.get(automatik["id"], aktiv) + + if aktiv: + if not vorher_aktiv: + self.lief_im_fenster[automatik["id"]] = False + erfuellt = gruppen_erfuellt(automatik, self.regelwerk, self.werte, jetzt) + if erfuellt and not automatik["cond_met"]: + self.ausloesen(automatik, "fired") + self.flanke_merken(automatik, erfuellt) + else: + # Das Fenster ist gerade zugegangen. Wer "auf jeden Fall" + # angehakt hat, bekommt jetzt seinen Lauf - aber nur, wenn in + # diesem Fenster noch keiner stattgefunden hat. Nach einem + # Neustart mitten im Fenster weiss der Runner das nicht mehr + # aus dem Speicher, deshalb zaehlt zusaetzlich last_run. + if vorher_aktiv and automatik["force_once"] and not self.lief_im_fenster.get(automatik["id"]): + if not self.lief_heute(automatik, jetzt): + logger.info("%s: Zeitfenster vorbei, wird trotzdem ausgefuehrt", + automatik["name"]) + self.ausloesen(automatik, "forced") + # Ausserhalb des Fensters die Flanke zuruecksetzen, sonst + # koennte sie im naechsten Fenster nicht mehr steigen. + self.flanke_merken(automatik, False) + self.lief_im_fenster[automatik["id"]] = False + + self.war_aktiv[automatik["id"]] = aktiv + + @staticmethod + def lief_heute(automatik, jetzt): + letzter = automatik.get("last_run") + return bool(letzter) and letzter.date() == jetzt.date() + + def protokoll_saeubern(self): + heute = date.today() + if self._letzte_saeuberung == heute: + return + self._letzte_saeuberung = heute + try: + with self.db.cursor() as c: + c.execute("DELETE FROM automation_log WHERE ts < NOW() - INTERVAL %s DAY", + (LOG_AUFBEWAHRUNG_TAGE,)) + except Exception as fehler: + logger.warning("Protokoll nicht aufraeumbar: %s", fehler) + + # --- Hauptschleife --------------------------------------------------- + + def laufen(self, nur_einmal=False): + self.regelwerk_laden() + takt = self.config.zahl("runner", "tick_seconds", 30) + poll = self.config.zahl("runner", "poll_seconds", 60) + neuladen = self.config.zahl("runner", "reload_seconds", 30) + letztes_pollen = 0.0 + letztes_pruefen = 0.0 + + while True: + jetzt = time.monotonic() + mit_pollen = jetzt - letztes_pollen >= poll + if mit_pollen: + letztes_pollen = jetzt + + self.werte_einsammeln(mit_pollen) + self.durchlauf() + self.protokoll_saeubern() + + if jetzt - letztes_pruefen >= neuladen: + letztes_pruefen = jetzt + try: + if Regelwerk.signatur_lesen(self.db) != self.regelwerk.signatur: + logger.info("Regelwerk hat sich geaendert, wird neu geladen") + self.regelwerk_laden() + except Exception as fehler: + logger.warning("Regelwerk nicht pruefbar: %s", fehler) + + if nur_einmal: + return + time.sleep(takt) + + def beenden(self): + try: + self.mqtt.loop_stop() + self.mqtt.disconnect() + except Exception: + pass + + +# =========================================================================== + +def main(): + parser = argparse.ArgumentParser(description="Fuehrt die Automatiken aus dem Web-UI aus.") + parser.add_argument("--dry-run", action="store_true", + help="nichts wirklich schalten, nur protokollieren") + parser.add_argument("--once", action="store_true", + help="einen einzigen Durchlauf, dann beenden") + parser.add_argument("--verbose", action="store_true", help="DEBUG-Ausgaben") + parser.add_argument("--config", default="config.ini") + args = parser.parse_args() + + config = Config(args.config) + logging.basicConfig( + level=logging.DEBUG if args.verbose else getattr( + logging, config.text("runner", "log_level", "INFO").upper(), logging.INFO), + format="%(asctime)s - %(levelname)s - %(message)s") + + runner = Runner(config, args.dry_run) + if runner.dry_run: + logger.info("Probelauf: es wird nichts geschaltet.") + try: + runner.laufen(args.once) + except KeyboardInterrupt: + logger.info("Abbruch durch Benutzer") + finally: + runner.beenden() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/restricted/autoActions/config.ini.example b/restricted/autoActions/config.ini.example new file mode 100644 index 0000000..6b44fdb --- /dev/null +++ b/restricted/autoActions/config.ini.example @@ -0,0 +1,75 @@ +# ============================================================================ +# AutoAction-Runner - Konfiguration +# ============================================================================ +# Kopieren nach config.ini und ausfuellen. config.ini ist nicht versioniert +# (siehe .gitignore), weil hier Zugangsdaten stehen. + +# ============================================================================ +# DATENBANK - Geraete und Automatiken (homeMesh) +# ============================================================================ +[database] +host = nas.fritz.box +port = 3310 +database = homeMesh +user = homeMesh +password = + +# ============================================================================ +# DATENBANK - Messwerte (solarLog) +# ============================================================================ +# Nur fuer die Tabelle `daylight`, aus der Sonnenauf- und -untergang kommen. +# Leer lassen, wenn dieselben Zugangsdaten wie oben gelten. +[solar] +database = solarLog +user = +password = + +# ============================================================================ +# MQTT +# ============================================================================ +[mqtt] +broker = nas.fritz.box +port = 1883 +username = +password = +client_id = autoaction_runner + +# ============================================================================ +# TAHOMA +# ============================================================================ +# Fuer Aktoren, deren URL mit io:// beginnt. Ohne Token bleiben sie stumm - +# der Runner protokolliert das dann als Fehler, statt still nichts zu tun. +[tahoma] +pin = +token = +timeout = 10 + +# ============================================================================ +# KALENDER +# ============================================================================ +# Fuer fetch_calendar.py, das Feiertage und Schulferien in calendar_days +# eintraegt. Regionscodes siehe openholidaysapi.org, Bayern ist DE-BY. +[calendar] +country = DE +subdivision = DE-BY + +# ============================================================================ +# LAUFZEIT +# ============================================================================ +[runner] +# Abstand der Auswertung in Sekunden. MQTT-Nachrichten werden sofort +# verarbeitet; der Takt ist fuer die Zeit-Ausloeser und fuer das Pollen. +tick_seconds = 30 + +# Wie oft Geraete abgefragt werden, die nichts von sich aus melden +# (Shelly ueber HTTP, Tahoma). In Sekunden. +poll_seconds = 60 + +# Wie oft der Runner nachsieht, ob sich das Regelwerk geaendert hat. +reload_seconds = 30 + +# Nichts wirklich schalten, nur protokollieren. Zum Ausprobieren neuer Regeln. +dry_run = false + +# DEBUG, INFO, WARNING, ERROR +log_level = INFO diff --git a/restricted/autoActions/fetch_calendar.py b/restricted/autoActions/fetch_calendar.py new file mode 100644 index 0000000..c653c60 --- /dev/null +++ b/restricted/autoActions/fetch_calendar.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +Fuellt die Tabelle `calendar_days` mit Feiertagen und Schulferien. + +Die Automatiken koennen mit "an Feiertagen ausfuehren" und "in den Ferien +ausfuehren" auf besondere Tage reagieren; woher das Wissen kommt, steht hier. +Nur besondere Tage werden eingetragen - ein fehlendes Datum ist ein +gewoehnlicher Tag. + +Quelle ist openholidaysapi.org: ein offenes Verzeichnis der EU-Kommission, das +gesetzliche Feiertage und Schulferien fuer alle deutschen Bundeslaender +liefert. Ein Aufruf je Jahr genuegt, deshalb ist das ein Cronjob und kein +Dauerlaeufer: + + 0 4 1 1 * /usr/bin/python3 /pfad/zu/fetch_calendar.py + +Ein zusaetzlicher Lauf im Herbst schadet nicht - die Ferientermine des +uebernaechsten Schuljahres stehen erst spaeter fest. + +Bereits eingetragene Tage werden ueberschrieben, nie doppelt angelegt. +""" + +import argparse +import logging +import sys +from datetime import date, timedelta + +import pymysql +import requests + +from autoaction_runner import Config, verbinden + +logger = logging.getLogger("kalender") + +BASIS = "https://openholidaysapi.org" + + +def zeitraum(api, land, region, von, bis): + """Eintraege einer Art (PublicHolidays oder SchoolHolidays) abholen.""" + antwort = requests.get( + BASIS + "/" + api, + params={"countryIsoCode": land, "subdivisionCode": region, + "languageIsoCode": "DE", "validFrom": von.isoformat(), + "validTo": bis.isoformat()}, + headers={"Accept": "application/json"}, timeout=20) + antwort.raise_for_status() + return antwort.json() + + +def name(eintrag): + for teil in eintrag.get("name", []): + if teil.get("text"): + return teil["text"][:80] + return "?" + + +def tage(eintrag): + """Ferien erstrecken sich ueber Wochen; hier wird daraus Tag fuer Tag.""" + start = date.fromisoformat(eintrag["startDate"]) + ende = date.fromisoformat(eintrag["endDate"]) + while start <= ende: + yield start + start += timedelta(days=1) + + +def main(): + parser = argparse.ArgumentParser(description="Feiertage und Ferien in calendar_days schreiben.") + parser.add_argument("--jahr", type=int, default=date.today().year) + parser.add_argument("--jahre", type=int, default=2, help="wie viele Jahre ab --jahr") + parser.add_argument("--config", default="config.ini") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + config = Config(args.config) + land = config.text("calendar", "country", "DE") + region = config.text("calendar", "subdivision", "DE-BY") + + von = date(args.jahr, 1, 1) + bis = date(args.jahr + args.jahre - 1, 12, 31) + logger.info("Hole %s bis %s fuer %s", von, bis, region) + + gesammelt = {} + for eintrag in zeitraum("PublicHolidays", land, region, von, bis): + for tag in tage(eintrag): + gesammelt.setdefault(tag, {})["holiday"] = name(eintrag) + for eintrag in zeitraum("SchoolHolidays", land, region, von, bis): + for tag in tage(eintrag): + gesammelt.setdefault(tag, {})["vacation"] = name(eintrag) + + db = verbinden(config) + with db.cursor() as c: + c.executemany( + """INSERT INTO calendar_days (date, holiday, vacation) VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE holiday = VALUES(holiday), vacation = VALUES(vacation)""", + [(tag, eintrag.get("holiday"), eintrag.get("vacation")) + for tag, eintrag in sorted(gesammelt.items())]) + logger.info("%d Tage eingetragen", len(gesammelt)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/restricted/autoActions/transports.py b/restricted/autoActions/transports.py new file mode 100644 index 0000000..17d9de8 --- /dev/null +++ b/restricted/autoActions/transports.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +""" +Transporte fuer den AutoAction-Runner. + +Ein Transport weiss, wie man mit einer Sorte Geraet redet - er liest deren +Messwerte und schickt deren Kommandos. Welcher zustaendig ist, entscheidet die +URL des Aktors in der Tabelle `actors`: + + mqtt://... MQTT-Geraete (Home-Assistant-Discovery) + http://... Shelly und andere HTTP-Geraete + io://... Tahoma + Logic das gerechnete Geraet "Zeitpunkt" (Uhrzeit, Datum, Sonne) + +Alle liegen in einer Datei statt in einem Paket wie bei deviceDiscovery: es +sind vier kurze Klassen, und wer eine fuenfte Geraeteart anschliesst, sieht +hier auf einen Blick, was dafuer zu tun ist. + +Jeder Transport hat zwei Haelften: + + zustaende_anmelden(states) einmalig beim Start + zustaende_lesen() liefert {state_id: wert} - nur was neu ist + senden(aktion) fuehrt ein Kommando aus + +`states` ist eine Liste von Dicts mit actor_url, state_url und id, +`aktion` ein Dict mit actor_url, command_url und params (Liste aus +{url, name, wert}). +""" + +import json +import logging +from datetime import datetime +from urllib.parse import quote + +logger = logging.getLogger("autoaction.transport") + + +class Transport: + """Gemeinsame Form. Wer nichts zu lesen hat, erbt die leeren Methoden.""" + + schema = "" + + def passt(self, actor_url): + return actor_url.startswith(self.schema) + + def zustaende_anmelden(self, states): + pass + + def zustaende_lesen(self): + return {} + + def senden(self, aktion): + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# MQTT +# --------------------------------------------------------------------------- + +class MQTTTransport(Transport): + """ + Die state_url ist hier das vollstaendige Topic, die parameter_url das + Kommando-Topic. Werte kommen von selbst herein und landen in einem + Zwischenspeicher, den der Runner im Takt abholt. + """ + + schema = "mqtt://" + + def __init__(self, client, dry_run=False): + self.client = client + self.dry_run = dry_run + self.topics = {} # topic -> [state_id, ...] + self.neu = {} # state_id -> wert + + def zustaende_anmelden(self, states): + self.topics = {} + for s in states: + if not s["state_url"]: + continue + self.topics.setdefault(s["state_url"], []).append(s["id"]) + for topic in self.topics: + self.client.subscribe(topic) + logger.info("MQTT: %d Topics abonniert", len(self.topics)) + + def nachricht(self, topic, payload): + """Wird vom Runner aus dem on_message-Rueckruf gerufen.""" + for state_id in self.topics.get(topic, []): + self.neu[state_id] = self._wert(payload) + + @staticmethod + def _wert(payload): + """ + Rohtext, aber JSON-Skalare werden ausgepackt: manche Geraete schicken + "21.4", andere 21.4 mit Anfuehrungszeichen, wieder andere true statt + ON. Objekte bleiben wie sie sind - welches Feld gemeint ist, weiss die + Datenbank nicht, und Raten waere schlimmer als Nichtstun. + """ + text = payload.decode("utf-8", "replace").strip() if isinstance(payload, bytes) else str(payload) + try: + wert = json.loads(text) + except ValueError: + return text + if isinstance(wert, bool): + return "true" if wert else "false" + if isinstance(wert, (int, float, str)): + return str(wert) + return text + + def zustaende_lesen(self): + werte, self.neu = self.neu, {} + return werte + + def senden(self, aktion): + if not aktion["params"]: + # Kommando ohne Parameter: das Kommando selbst ist die Nutzlast. + self._publish(aktion["command_url"], "") + return + for p in aktion["params"]: + self._publish(p["url"] or aktion["command_url"], p["wert"]) + + def _publish(self, topic, nutzlast): + if not topic: + raise ValueError("Kommando ohne Topic") + if self.dry_run: + logger.info("[dry-run] MQTT %s <- %s", topic, nutzlast) + return + ergebnis = self.client.publish(topic, nutzlast, qos=1, retain=False) + ergebnis.wait_for_publish(timeout=5) + + +# --------------------------------------------------------------------------- +# HTTP (Shelly und Verwandte) +# --------------------------------------------------------------------------- + +class HTTPTransport(Transport): + """ + Die actor_url ist der Endpunkt, die state_url ein Feldname in dessen + JSON-Antwort ("tC", "a_voltage"). Solche Geraete melden sich nicht von + selbst, sie werden im Poll-Takt gefragt. + + Beim Senden haengen die Parameter als Abfrageargumente an die actor_url. + Die Schaltbefehle von Shelly heissen aber nicht so, wie sie in der + Datenbank stehen - dafuer steht unten eine kleine Uebersetzungstabelle. + Wer ein anderes HTTP-Geraet anschliesst, erweitert genau die. + """ + + schema = "http" + + SHELLY_BEFEHLE = { + "turn_on": {"on": "true"}, + "turn_off": {"on": "false"}, + "toggle": {"toggle": "true"}, + } + + def __init__(self, requests_modul, timeout=5, dry_run=False): + self.requests = requests_modul + self.timeout = timeout + self.dry_run = dry_run + self.states = [] + + def zustaende_anmelden(self, states): + self.states = [s for s in states if s["state_url"]] + logger.info("HTTP: %d Messwerte an %d Endpunkten", + len(self.states), len({s["actor_url"] for s in self.states})) + + def zustaende_lesen(self): + werte = {} + # Je Endpunkt eine Anfrage, auch wenn mehrere Messwerte daran haengen. + nach_url = {} + for s in self.states: + nach_url.setdefault(s["actor_url"], []).append(s) + for url, states in nach_url.items(): + try: + antwort = self.requests.get(url, timeout=self.timeout) + daten = antwort.json() + except Exception as fehler: + logger.debug("HTTP %s nicht erreichbar: %s", url, fehler) + continue + for s in states: + if isinstance(daten, dict) and s["state_url"] in daten: + werte[s["id"]] = str(daten[s["state_url"]]) + return werte + + def senden(self, aktion): + argumente = dict(self.SHELLY_BEFEHLE.get(aktion["command_url"], {})) + for p in aktion["params"]: + if p["url"]: + argumente[p["url"]] = p["wert"] + if not argumente and aktion["command_url"]: + # Kein bekannter Schaltbefehl und keine Parameter: das Kommando + # als Argument anhaengen, mehr laesst sich hier nicht ableiten. + argumente[aktion["command_url"]] = "true" + if self.dry_run: + logger.info("[dry-run] HTTP %s %s", aktion["actor_url"], argumente) + return + antwort = self.requests.get(aktion["actor_url"], params=argumente, timeout=self.timeout) + if antwort.status_code >= 400: + raise RuntimeError("HTTP %d von %s" % (antwort.status_code, aktion["actor_url"])) + + +# --------------------------------------------------------------------------- +# Tahoma +# --------------------------------------------------------------------------- + +class TahomaTransport(Transport): + """ + Die actor_url ist die deviceURL (io://...), die state_url ein Statusname + ("core:ClosureState"), die command_url ein Kommandoname ("setClosure"). + Geschickt wird ueber exec/apply - genauso wie in ajax/tahoma.php, nur ohne + die dortige Sonderbehandlung fuer "faehrt gerade". + """ + + schema = "io://" + + def __init__(self, requests_modul, pin, token, timeout=10, dry_run=False): + self.requests = requests_modul + self.pin = pin + self.token = token + self.timeout = timeout + self.dry_run = dry_run + self.states = [] + + @property + def basis(self): + return "https://gateway-%s:8443/enduser-mobile-web/1/enduserAPI" % self.pin + + def _kopf(self): + return {"Content-Type": "application/json", + "Authorization": "Bearer " + self.token} + + def zustaende_anmelden(self, states): + self.states = [s for s in states if s["state_url"]] + logger.info("Tahoma: %d Messwerte an %d Geraeten", + len(self.states), len({s["actor_url"] for s in self.states})) + + def zustaende_lesen(self): + if not self.token: + return {} + werte = {} + nach_geraet = {} + for s in self.states: + nach_geraet.setdefault(s["actor_url"], []).append(s) + for geraet, states in nach_geraet.items(): + try: + antwort = self.requests.get( + self.basis + "/setup/devices/" + quote(geraet, safe="") + "/states", + headers=self._kopf(), timeout=self.timeout, verify=False) + zustaende = {z["name"]: z.get("value") for z in antwort.json()} + except Exception as fehler: + logger.debug("Tahoma %s nicht erreichbar: %s", geraet, fehler) + continue + for s in states: + if s["state_url"] in zustaende: + werte[s["id"]] = str(zustaende[s["state_url"]]) + return werte + + def senden(self, aktion): + if not self.token: + raise RuntimeError("Kein Tahoma-Token in der config.ini") + # Die Reihenfolge der Parameter ist die aus command_parameters - bei + # setClosureAndOrientation also erst Position, dann Winkel. + befehl = {"name": aktion["command_url"], + "parameters": [self._zahl(p["wert"]) for p in aktion["params"]]} + rumpf = {"label": "AutoAction", + "actions": [{"deviceURL": aktion["actor_url"], "commands": [befehl]}]} + if self.dry_run: + logger.info("[dry-run] Tahoma %s", json.dumps(rumpf, ensure_ascii=False)) + return + antwort = self.requests.post(self.basis + "/exec/apply", headers=self._kopf(), + data=json.dumps(rumpf), timeout=self.timeout, verify=False) + if antwort.status_code >= 400: + raise RuntimeError("Tahoma antwortete mit %d: %s" + % (antwort.status_code, antwort.text[:120])) + + @staticmethod + def _zahl(wert): + """Tahoma erwartet Zahlen als Zahlen, Text als Text.""" + try: + return int(wert) + except (TypeError, ValueError): + pass + try: + return float(wert) + except (TypeError, ValueError): + return wert + + +# --------------------------------------------------------------------------- +# Logic - das gerechnete Geraet +# --------------------------------------------------------------------------- + +class LogicTransport(Transport): + """ + Uhrzeit, Datum, Sonnenauf- und -untergang. Es gibt nichts zu abonnieren und + nichts zu schalten, die Werte entstehen im Takt. Sonnenzeiten kommen aus + solarLog.daylight, dieselbe Tabelle, aus der auch ajax/getSunrise.php liest. + """ + + schema = "Logic" + + def __init__(self, sonnenzeiten): + """sonnenzeiten: Funktion() -> (sonnenaufgang, sonnenuntergang) als "HH:MM".""" + self.sonnenzeiten = sonnenzeiten + self.states = [] + + def passt(self, actor_url): + return actor_url == "Logic" + + def zustaende_anmelden(self, states): + self.states = states + logger.info("Logic: %d Messwerte", len(states)) + + def zustaende_lesen(self): + jetzt = datetime.now() + auf, unter = self.sonnenzeiten() + tabelle = { + "time": jetzt.strftime("%H:%M"), + "date": jetzt.strftime("%d.%m.%Y"), + "sunrise": auf, + "sunset": unter, + } + return {s["id"]: tabelle[s["state_url"]] + for s in self.states if s["state_url"] in tabelle} + + def senden(self, aktion): + raise RuntimeError("Das Geraet \"Zeitpunkt\" kann nichts schalten") diff --git a/restricted/automations.php b/restricted/automations.php index 36cb1e2..3e99906 100644 --- a/restricted/automations.php +++ b/restricted/automations.php @@ -33,6 +33,13 @@ function meshDb() * Zeichen macht erst die Anzeige daraus. Frueher schickte der Editor sein * innerHTML ("≠", ">") an den Server, wo eine Whitelist mit "!=" stand: * jede Bedingung "ungleich" wurde still zu "gleich". + * + * Bei Zeit und Datum gibt es bewusst kein "gleich", sondern "ab" und "vor". + * "Uhrzeit = 16:30" waere nur in genau der einen Minute wahr - faellt der + * Runner in dieser Minute aus, ist die Automatik fuer den Tag verloren. "ab + * 16:30" bleibt bis Mitternacht wahr; ausgeloest wird trotzdem nur einmal, + * weil nur die steigende Flanke zaehlt. Dieselbe Ueberlegung steht hinter den + * breiten Zeitfenstern in auto_watering.py. */ function operatorsForType($type) { @@ -40,6 +47,10 @@ function operatorsForType($type) case "integer": case "float": return ["=", "!=", ">", "<"]; + case "time": + case "date": + case "datetime": + return [">=", "<"]; case "deltatime": // Sonnenauf-/-untergang: Wert ist ein Versatz return ["+", "-"]; case "bool": @@ -49,6 +60,16 @@ function operatorsForType($type) } } +/** Operatoren mit ihrer Beschriftung, so wie der Editor sie anbietet. */ +function operatorChoices($type) +{ + $liste = []; + foreach (operatorsForType($type) as $op) { + $liste[] = ["value" => $op, "label" => operatorLabel($op, $type)]; + } + return $liste; +} + /** Wie das Wertfeld im Editor aussieht. */ function inputForType($type, $hasOptions) { @@ -74,9 +95,21 @@ function inputForType($type, $hasOptions) } } -/** Anzeigeform eines Operators. */ -function operatorLabel($op) +/** + * Anzeigeform eines Operators. Bei Zeitangaben liest sich "ab"/"vor" + * verstaendlicher als ">="/"<", und bei Sonnenauf-/-untergang steht der + * Versatz dahinter: "ab + 00:30" heisst eine halbe Stunde danach. + */ +function operatorLabel($op, $type = "") { + if ($type === "time" || $type === "date" || $type === "datetime") { + if ($op === ">=") return "ab"; + if ($op === "<") return "vor"; + } + if ($type === "deltatime") { + if ($op === "+") return "ab +"; + if ($op === "-") return "ab -"; + } return $op === "!=" ? "≠" : $op; } @@ -124,7 +157,7 @@ function deviceCatalog() "name" => $row["state_name"], "type" => $type, "input" => inputForType($type, count($options) > 0), - "operators" => operatorsForType($type), + "operators" => operatorChoices($type), "options" => array_values($options), "unit" => $row["unit"], "value" => $row["current_value"], @@ -495,13 +528,16 @@ function listAutomations($floor) function stateNames() { $db = meshDb(); - $res = $db->query("SELECT s.id, s.state_name, s.unit, a.name AS actor_name - FROM actor_states s JOIN actors a ON a.id = s.actor_id"); + $res = $db->query("SELECT s.id, s.state_name, s.unit, a.name AS actor_name, t.type + FROM actor_states s + JOIN actors a ON a.id = s.actor_id + LEFT JOIN state_types t ON s.state_type = t.id"); $names = []; while ($row = $res->fetch_assoc()) { $names[intval($row["id"])] = [ "text" => $row["actor_name"] . ": " . $row["state_name"], "unit" => $row["unit"], + "type" => $row["type"] ?: "string", ]; } return $names; @@ -530,8 +566,9 @@ function describeConditions($conditions, $names) { $groups = []; foreach ($conditions as $c) { - $state = $names[$c["state_id"]] ?? ["text" => "Messwert " . $c["state_id"], "unit" => null]; - $text = $state["text"] . " " . operatorLabel($c["operator"]) . " " . $c["value"]; + $state = $names[$c["state_id"]] + ?? ["text" => "Messwert " . $c["state_id"], "unit" => null, "type" => ""]; + $text = $state["text"] . " " . operatorLabel($c["operator"], $state["type"]) . " " . $c["value"]; if ($state["unit"]) { $text .= " " . $state["unit"]; }