From cbddd664e3478af4f5b4e51a02e0c07b53edb11c Mon Sep 17 00:00:00 2001 From: Moirtz Wagner Date: Wed, 2 Sep 2026 20:56:08 +0200 Subject: [PATCH] Runner zieht zum SolarManager um Der AutoAction-Runner ist ein Hintergrundprozess und gehoert damit zu den anderen, nicht ins Web-Verzeichnis. Er liegt jetzt unter /volume1/homes/wagner/SolarManager/autoActions und wird von dort zusammen mit dem Manager gestartet; versioniert ist er im Repository SolarManager. Browser und Runner reden ohnehin nur ueber die Datenbank homeMesh miteinander - das Web-UI kennt den Pfad nicht und muss ihn nicht kennen. Die Verweise in commands.php und homeMesh_automations.sql zeigen bereits auf den neuen Ort. Co-Authored-By: Claude Opus 5 --- restricted/autoActions/README.md | 215 ----- restricted/autoActions/autoaction_runner.py | 848 -------------------- restricted/autoActions/config.ini.example | 75 -- restricted/autoActions/fetch_calendar.py | 102 --- restricted/autoActions/transports.py | 626 --------------- 5 files changed, 1866 deletions(-) delete mode 100644 restricted/autoActions/README.md delete mode 100644 restricted/autoActions/autoaction_runner.py delete mode 100644 restricted/autoActions/config.ini.example delete mode 100644 restricted/autoActions/fetch_calendar.py delete mode 100644 restricted/autoActions/transports.py diff --git a/restricted/autoActions/README.md b/restricted/autoActions/README.md deleted file mode 100644 index 633ac77..0000000 --- a/restricted/autoActions/README.md +++ /dev/null @@ -1,215 +0,0 @@ -# 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. - -Zeit-Auslöser gibt es in drei Formen: - -| | wahr, wenn | -|---|---| -| `um 16:30` | genau in dieser Minute | -| `ab 16:30` | von da an bis Mitternacht | -| `vor 16:30` | bis dahin | - -Ausgelöst wird in allen drei Fällen nur einmal, eben wegen der Flanke. `ab` -ist trotzdem das robustere: fällt der Runner in genau der Minute aus, auf die -`um` zeigt, ist die Automatik für den Tag verloren — bei `ab` holt der nächste -Takt es nach. Wer `um` braucht und den Ausfall nicht riskieren will, hakt -zusätzlich `force_once` an. Die breiten Zeitfenster in `auto_watering.py` -folgen derselben Überlegung. - -Beim Sonnenauf- und -untergang ist der Wert ein Versatz, und der kann davor -oder danach liegen — deshalb dieselben drei Fälle mal zwei: `+ 00:30` eine -halbe Stunde nach Sonnenaufgang, `ab - 00:30` ab einer halben Stunde davor, -`vor + 00:30` bis eine halbe Stunde danach. - -Über den Tagesrand wird gerechnet, nicht abgeschnitten: „sechs Stunden vor -Sonnenaufgang" landet am Vorabend, und das ist so gewollt — abgeschnitten -wären solche Angaben gar nicht mehr formulierbar. Verglichen wird die Uhrzeit -innerhalb des Tages; ein Ziel jenseits von Mitternacht gilt als diese Uhrzeit -am selben Tag. Bei `+` und `-` ist das genau der gemeinte Zeitpunkt, bei `ab` -und `vor` verschiebt sich der wahre Bereich entsprechend mit. - -`force_once` („am Ende des Zeitraums auf jeden Fall ausführen") greift, wenn -das Fenster zugeht und in diesem Fenster noch nichts passiert ist. - -## Sperrzeit - -Die Flanke allein schützt nicht gegen einen Messwert, der um die Schwelle -**pendelt**: „Temperatur > 22" bei 22,1 / 21,9 / 22,1 °C ist jedes Mal eine -echte steigende Flanke, und über MQTT können die Werte im Sekundentakt -hereinkommen. `automations.lockout_secs` sagt, wie lange nach einer Auslösung -nicht wieder geschaltet wird. Der Editor bietet drei Stufen an: - -| | | gedacht für | -|---|---|---| -| Ohne | 0 s | volle Geschwindigkeit, jede Flanke schaltet | -| Kurz | 60 s | Licht, Farbe, Dimmwert | -| Lang | 900 s | Rollläden, Ventile, alles mit Motor | - -Gespeichert werden Sekunden, angeboten werden nur die drei Stufen — eine -vierte ist damit eine Zeile in `lockoutChoices()` und keine Wanderung durch -die Datenbank. - -Eine Flanke innerhalb der Sperrzeit wird **verworfen, nicht aufgehoben**. Ein -Rollladen, der eine Viertelstunde später doch noch losfährt, weil vor langer -Zeit einmal eine Schwelle gestreift wurde, wäre unangenehmer als einer, der -gar nicht fährt — und der nächste echte Anlass nach Ablauf der Sperre kommt -ohnehin durch. Verworfene Flanken stehen im Log auf `DEBUG`, nicht in -`automation_log`; bei einem zappelnden Sensor wäre die Tabelle sonst voll -davon. - -`force_once` ist von der Sperre nicht betroffen: es greift nur, wenn im -Fenster gar nichts gelaufen ist — dann ist auch keine Sperre aktiv. - -## Transporte - -Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`: - -| URL | Messwert (`actor_states.url`) | Kommando (`actor_commands.command_url`) | -|---|---|---| -| `mqtt://…` | vollständiges Topic, abonniert; bei mehreren Messwerten je Topic zusätzlich `value_path` | Nutzlast auf das Parameter-Topic | -| `http://…` | Feldname in der JSON-Antwort, gepollt | Abfrageargumente an die Geräte-URL (`turn=on`) | -| `wled://…` | Pfad in `/json/state` (`seg[0].col[0]`), gepollt | JSON-Vorlage mit Platzhaltern, als Ganzes gesendet | -| Tahoma | Statusname (`core:ClosureState`), gepollt | `exec/apply` an die Box | -| `Logic` | gerechnet: Uhrzeit, Datum, Sonne | – | - -Alle fünf stehen in `transports.py`. Eine sechste Geräteart kommt als weitere -Klasse dazu; sie braucht `passt()`, `zustaende_lesen()` und `senden()`. - -Tahoma ist der einzige, der nicht am URL-Schema erkannt wird, sondern an der -**Box-Kennung** in der URL. Das Schema beschreibt dort die Funkart, und -dieselbe Box liefert `io://` für die Jalousien, `rts://` für die Dachfenster -und `internal://` für die Alarmanlage. Ohne `pin` in der `config.ini` ist -niemand zuständig — dann meldet der Runner beim Auslösen „kein Transport", -statt still nichts zu tun. - -Mehrere Messwerte teilen sich oft **ein Topic**: der go-eCharger schickt -sechzehn Zahlen als JSON-Feld auf `…/nrg`, und erst das `value_template` der -Home-Assistant-Discovery sagt, dass „Strom L1" das fünfte Element ist. Diese -Angabe steht in `actor_states.value_path` — in derselben Schreibweise, die -auch WLED benutzt: `[4]`, `ssid`, `seg[0].col[0]`. Ohne Pfad gilt die ganze -Nutzlast. - -Gelesen wird nur der einfache Fall aus dem Template: ein Zugriff auf -`value_json` und was danach an Punkten und Klammern folgt. - -### Werttabellen - -Manche Geräte schicken eine Zahl und meinen einen Zustand: - -``` -{{ ['Unknown','Idle','Charging','WaitCar','Complete','Error'][value_json|int] }} -{{ ['Default','Eco','NextTrip'][value_json|int-3] }} -``` - -Das ist kein Pfad, sondern eine Übersetzung von Zahl nach Text. Sie landet in -`possible_values` — in der Schreibweise, die WLED für seine Effektliste schon -benutzt: eine Liste aus `{Wert: Bezeichnung}`. Ein Versatz im Ausdruck wandert -dabei in die Schlüssel, aus `[value_json|int-3]` wird also `{"3":"Default"}`. - -Der Runner übersetzt beim Lesen: aus der gesendeten `2` wird `Charging`. Eine -Bedingung vergleicht damit genau den Klartext, den der Editor zur Auswahl -stellt. Steht die Zahl nicht in der Tabelle, bleibt sie stehen — ein -erfundener Name wäre schlimmer als ein roher Wert. - -Auf beiden Seiten des Editors steckt dieselbe Tabelle, aber der gespeicherte -Wert ist ein anderer: - -| | angezeigt | gespeichert | -|---|---|---| -| **Messwert** (Bedingung) | `Charging` | `Charging` — der Runner hat schon übersetzt | -| **Parameter** (Aktion) | `Blink` | `1` — das Gerät will die Zahl | - -Bei WLED trägt die Kommando-Vorlage alles: `{"seg":[{"col":[[%red%,%green%,%blue%]]}]}` -wird mit den Parameterwerten gefüllt und am Stück geschickt. Deshalb haben die -Parameter dort keine eigene URL — ihr Name *ist* der Platzhalter. - -## 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 deleted file mode 100644 index 83da21e..0000000 --- a/restricted/autoActions/autoaction_runner.py +++ /dev/null @@ -1,848 +0,0 @@ -#!/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 gibt es in drei Formen: "um 16:30" ist genau in dieser Minute -wahr, "ab 16:30" von da an bis Mitternacht, "vor 16:30" bis dahin. -Ausgeloest wird in allen drei Faellen nur einmal, eben wegen der Flanke - -"ab" ist aber das robustere: faellt der Runner in der einen Minute aus, auf -die "um" zeigt, ist die Automatik fuer den Tag verloren; bei "ab" holt der -naechste Takt es nach. Wer "um" braucht und den Ausfall nicht riskieren -will, hakt zusaetzlich force_once an. Dieselbe Ueberlegung steht hinter den -breiten Zeitfenstern in auto_watering.py. - -Beim Sonnenauf- und -untergang traegt der Operator zusaetzlich das -Vorzeichen des Versatzes: "+ 00:30" eine halbe Stunde danach, ">=- 00:30" -ab einer halben Stunde davor, "<+ 00:30" bis eine halbe Stunde danach. - -Tabellen siehe homeMesh_automations.sql, Konfiguration siehe config.ini.example. -""" - -import argparse -import configparser -import json -import logging -import os -import queue -import sys -import threading -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, WLEDTransport) - -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.value_path, - s.current_value, s.possible_values, - 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" - # Werttabelle als flaches {gesendeter Wert: Bezeichnung}. In - # der Datenbank steht sie als Liste aus Ein-Schluessel- - # Objekten, weil der Editor sie so schon versteht. - row["wertetabelle"] = {} - try: - for eintrag in json.loads(row["possible_values"] or "[]"): - if isinstance(eintrag, dict): - for wert, name in eintrag.items(): - row["wertetabelle"][str(wert)] = name - except ValueError: - pass - 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) - if op == "=": return ist_m == soll_m - if op == "<": return ist_m < soll_m - return ist_m >= soll_m - - if typ == "deltatime": - # Der Messwert ist der Sonnenauf- bzw. -untergang, die Schwelle - # ein Versatz. Das Vorzeichen steckt im Operator, der Vergleich - # davor: ">=-" heisst "ab einer halben Stunde davor". - versatz = minuten(soll) - ziel = minuten(wert) + (-versatz if op.endswith("-") else versatz) - # Ueber den Tagesrand wird gerechnet, nicht abgeschnitten: "sechs - # Stunden vor Sonnenaufgang" ist eine gewollte Angabe und landet - # dann eben am Vorabend. Abgeschnitten waeren solche Faelle gar - # nicht mehr formulierbar. - # - # Verglichen wird die Uhrzeit innerhalb des Tages. Ein Ziel - # jenseits von Mitternacht gilt also als diese Uhrzeit am selben - # Tag - bei "um" ist das genau der gemeinte Zeitpunkt, bei "ab" - # und "vor" verschiebt sich der wahre Bereich entsprechend. - ziel %= 1440 - jetzt_m = jetzt.hour * 60 + jetzt.minute - if op.startswith(">="): return jetzt_m >= ziel - if op.startswith("<"): return jetzt_m < ziel - return jetzt_m == ziel - - if typ in ("date", "datetime"): - wandeln = als_datum if typ == "date" else als_zeitpunkt - ist_d, soll_d = wandeln(wert), wandeln(soll) - if ist_d is None or soll_d is None: - return False - if op == "=": return ist_d == soll_d - if op == "<": return ist_d < soll_d - return 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 - - -# =========================================================================== -# Versand -# =========================================================================== - -class Versand: - """ - Schickt Kommandos im Hintergrund - je Geraet der Reihe nach, ueber - Geraete hinweg nebeneinander. - - Ohne das blockiert ein einziges Kommando die ganze Auswertung: eine - Jalousie zuzufahren dauert ueber eine Minute, weil zwischen den beiden - Schwenkbefehlen auf das Ende der Fahrt gewartet werden muss (siehe - TahomaTransport). So lange kaeme kein Zeit-Ausloeser mehr durch, kein - Messwert wuerde zurueckgeschrieben, und mehrere Rollladen in einer - Automatik wuerden sich aufaddieren. - - Je Geraet eine Warteschlange mit einem eigenen Faden: zwei Kommandos an - dieselbe Jalousie duerfen sich nicht ueberholen - "Neigung 0" und - "Neigung 100" sind sonst wirkungslos oder vertauscht -, zwei Kommandos an - verschiedene Jalousien duerfen ruhig gleichzeitig laufen. - - Die Datenbank bleibt aussen vor: die Faeden melden ihr Ergebnis nur - zurueck, geschrieben wird im Hauptfaden. Eine pymysql-Verbindung ist - nicht fuer mehrere Faeden gedacht. - """ - - def __init__(self): - self.warteschlangen = {} # actor_url -> Queue - self.ergebnisse = queue.Queue() - self.laeuft = True - - def einreihen(self, automation_id, beschreibung, transport, auftrag): - schlange = self.warteschlangen.get(auftrag["actor_url"]) - if schlange is None: - schlange = queue.Queue() - self.warteschlangen[auftrag["actor_url"]] = schlange - faden = threading.Thread(target=self._arbeiten, args=(schlange,), - name="versand", daemon=True) - faden.start() - schlange.put((automation_id, beschreibung, transport, auftrag)) - - def _arbeiten(self, schlange): - while self.laeuft: - posten = schlange.get() - if posten is None: - return - automation_id, beschreibung, transport, auftrag = posten - try: - transport.senden(auftrag) - self.ergebnisse.put((automation_id, beschreibung, None)) - except Exception as fehler: - self.ergebnisse.put((automation_id, beschreibung, str(fehler))) - finally: - schlange.task_done() - - def abholen(self): - """Alles, was seit dem letzten Mal fertig geworden ist.""" - fertig = [] - while True: - try: - fertig.append(self.ergebnisse.get_nowait()) - except queue.Empty: - return fertig - - def offen(self): - return sum(s.unfinished_tasks for s in self.warteschlangen.values()) - - def beenden(self): - self.laeuft = False - for schlange in self.warteschlangen.values(): - schlange.put(None) - - -# =========================================================================== -# 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.versand = Versand() - self.mqtt = self._mqtt_verbinden() - self.transporte = [ - MQTTTransport(self.mqtt, self.dry_run), - WLEDTransport(requests, dry_run=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) - - # Welche Geraete Position und Neigung zusammen koennen. Nur die haben - # die Kugelschreiber-Mechanik, und nur bei ihnen wird ein "Zu" zum - # zweifachen Schwenken - siehe TahomaTransport. - kombi = {k["actor_url"] for k in self.regelwerk.kommandos.values() - if k["command_url"] == "setClosureAndOrientation"} - for transport in self.transporte: - if isinstance(transport, TahomaTransport): - transport.kombigeraete_setzen(kombi) - - # 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"], "value_path": s["value_path"], - "wertetabelle": s["wertetabelle"]} - 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): - """ - Die Aktionen einer Automatik in den Versand geben. - - Geschickt wird im Hintergrund - eine Jalousie zuzufahren dauert ueber - eine Minute, und so lange darf die Auswertung nicht stehen. Was dabei - schiefgeht, kommt spaeter ueber ergebnisse_verbuchen() ins Protokoll. - """ - fehlerText = [] - eingereiht = 0 - 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"]], - } - beschreibung = "%s: %s" % (kommando["actor_name"], kommando["command_name"]) - logger.info("%s: %s (unterwegs)", automatik["name"], beschreibung) - self.versand.einreihen(automatik["id"], beschreibung, transport, auftrag) - eingereiht += 1 - - ergebnis = "error" if fehlerText else anlass - detail = "; ".join(fehlerText)[:255] if fehlerText \ - else ("%d Kommando(s) unterwegs" % eingereiht) - 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 ergebnisse_verbuchen(self): - """ - Was der Versand inzwischen erledigt hat ins Protokoll schreiben. - - Nur Fehlschlaege bekommen eine eigene Zeile - der Lauf selbst steht - schon drin, und ein Protokoll, das jedes gelungene Kommando einzeln - auffuehrt, findet niemand mehr etwas darin. - """ - for automation_id, beschreibung, fehler in self.versand.abholen(): - if fehler is None: - logger.debug("erledigt: %s", beschreibung) - continue - logger.error("%s konnte nicht geschickt werden: %s", beschreibung, fehler) - try: - with self.db.cursor() as c: - c.execute("""INSERT INTO automation_log (automation_id, result, detail) - VALUES (%s, 'error', %s)""", - (automation_id, ("%s: %s" % (beschreibung, fehler))[:255])) - except Exception as schreibfehler: - logger.warning("Protokoll nicht schreibbar: %s", schreibfehler) - - 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"]: - if self.gesperrt(automatik, jetzt): - logger.debug("%s: Flanke faellt in die Sperrzeit, uebersprungen", - automatik["name"]) - else: - 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 gesperrt(automatik, jetzt): - """ - Liegt die letzte Ausloesung noch innerhalb der Sperrzeit? - - Gegen Messwerte, die um die Schwelle pendeln: "Temperatur > 22" bei - 22,1 / 21,9 / 22,1 Grad ist jedes Mal eine echte steigende Flanke, und - ueber MQTT koennen die Werte im Sekundentakt hereinkommen. - - Die Flanke wird dabei verworfen und nicht aufgehoben. Ein Rollladen, - der eine Viertelstunde spaeter doch noch losfaehrt, weil vor langer - Zeit einmal eine Schwelle gestreift wurde, waere unangenehmer als - einer, der gar nicht faehrt. Der naechste echte Anlass nach Ablauf - der Sperre kommt ohnehin durch. - - force_once ist davon nicht betroffen: es greift nur, wenn im Fenster - gar nichts gelaufen ist - dann ist auch keine Sperre aktiv. - """ - sperre = int(automatik.get("lockout_secs") or 0) - letzter = automatik.get("last_run") - if not sperre or not letzter: - return False - return (jetzt - letzter).total_seconds() < sperre - - @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.ergebnisse_verbuchen() - 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): - offen = self.versand.offen() - if offen: - logger.info("%d Kommando(s) noch unterwegs - wird nicht abgewartet", offen) - self.versand.beenden() - 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 deleted file mode 100644 index 6b44fdb..0000000 --- a/restricted/autoActions/config.ini.example +++ /dev/null @@ -1,75 +0,0 @@ -# ============================================================================ -# 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 deleted file mode 100644 index c653c60..0000000 --- a/restricted/autoActions/fetch_calendar.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/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 deleted file mode 100644 index e1e75f1..0000000 --- a/restricted/autoActions/transports.py +++ /dev/null @@ -1,626 +0,0 @@ -#!/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 - wled://... WLED-Lampen - io://, rts://, internal://, ogp:// Tahoma - das Schema haengt an der - Funkart des Geraets, deshalb wird dort nicht danach - entschieden, sondern an der Box-Kennung in der URL - Logic das gerechnete Geraet "Zeitpunkt" (Uhrzeit, Datum, Sonne) - -Alle liegen in einer Datei statt in einem Paket wie bei deviceDiscovery: es -sind fuenf kurze Klassen, und wer eine sechste 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, value_path und -id, `aktion` ein Dict mit actor_url, command_url und params (Liste aus -{url, name, wert}). -""" - -import json -import logging -import re -import time -import urllib3 -from datetime import datetime -from urllib.parse import quote - -logger = logging.getLogger("autoaction.transport") - -# Die Tahoma-Box hat ein selbst ausgestelltes Zertifikat auf einen Namen, den -# nur das Heimnetz kennt. Die Pruefung ist dort bewusst aus (wie in -# ajax/tahoma.php); ohne diese Zeile warnt urllib3 bei jeder einzelnen -# Abfrage und uebertoent das eigentliche Protokoll. -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - - -def wert_aus_pfad(daten, pfad): - """ - Einen Teilwert aus einer Nutzlast holen: "[3]", "ssid", "seg[0].col[0]". - Ein leerer Pfad heisst: die Nutzlast selbst. - - Gebraucht wird das an zwei Stellen. MQTT-Geraete legen mehrere Messwerte - auf ein Topic - der go-eCharger schickt sechzehn Zahlen als JSON-Feld -, - und WLED liefert seinen gesamten Zustand als ein Dokument. - """ - if not pfad: - return daten - for teil in pfad.split("."): - treffer = re.match(r"^([^\[]*)((?:\[\d+\])*)$", teil) - if not treffer: - raise KeyError(pfad) - if treffer.group(1): - daten = daten[treffer.group(1)] - for index in re.findall(r"\[(\d+)\]", treffer.group(2)): - daten = daten[int(index)] - return daten - - -def uebersetze(wert, tabelle): - """ - Aus der gesendeten Zahl den Zustandsnamen machen: aus "2" wird "Charging". - - Manche Geraete schicken einen Zahlencode und meinen einen Zustand. Welche - Zahl welchen Namen hat, steht in possible_values - derselben Spalte, aus - der auch der Editor seine Auswahlliste baut. Eine Bedingung vergleicht - damit genau den Klartext, den man dort ausgewaehlt hat. - - Steht die Zahl nicht in der Tabelle, bleibt sie stehen: ein erfundener - Name waere schlimmer als ein roher Wert. - """ - if not tabelle: - return wert - if wert in tabelle: - return tabelle[wert] - try: # "2.0" und "2" meinen dieselbe Stufe - ganz = str(int(float(wert))) - except (TypeError, ValueError): - return wert - return tabelle.get(ganz, wert) - - -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, value_path, wertetabelle), ...] - 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"], s.get("value_path"), s.get("wertetabelle") or {})) - for topic in self.topics: - self.client.subscribe(topic) - mehrfach = sum(1 for e in self.topics.values() if len(e) > 1) - logger.info("MQTT: %d Topics abonniert, %d davon mit mehreren Messwerten", - len(self.topics), mehrfach) - - def nachricht(self, topic, payload): - """Wird vom Runner aus dem on_message-Rueckruf gerufen.""" - eintraege = self.topics.get(topic, []) - if not eintraege: - return - text = payload.decode("utf-8", "replace").strip() if isinstance(payload, bytes) else str(payload) - try: - daten = json.loads(text) - except ValueError: - daten = None # kein JSON - dann gilt der Rohtext - for state_id, pfad, tabelle in eintraege: - wert = self._wert(text, daten, pfad, tabelle) - if wert is not None: - self.neu[state_id] = wert - - @staticmethod - def _wert(text, daten, pfad, tabelle=None): - """ - Aus der Nutzlast den Wert eines einzelnen Messwerts machen. - - Mehrere Messwerte teilen sich oft ein Topic; welcher Teil gemeint ist, - steht in value_path - gelesen aus dem value_template der - Home-Assistant-Discovery. Ohne Pfad gilt die ganze Nutzlast, und - JSON-Skalare werden ausgepackt: manche Geraete schicken 21.4 mit - Anfuehrungszeichen, andere true statt ON. - - Steht eine Werttabelle dabei, wird aus der gesendeten Zahl der - Zustandsname: aus 2 wird "Charging". - """ - if daten is None: - return uebersetze(text, tabelle) - try: - wert = wert_aus_pfad(daten, pfad) - except (KeyError, IndexError, TypeError): - logger.debug("Pfad %s nicht in der Nutzlast: %s", pfad, text[:80]) - return None - if isinstance(wert, bool): - wert = "true" if wert else "false" - elif isinstance(wert, (int, float, str)): - wert = str(wert) - else: - return json.dumps(wert, ensure_ascii=False) - return uebersetze(wert, tabelle) - - 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 wird die command_url als Abfrageargument an die actor_url - gehaengt ("turn=on") und die Parameter mit ihrem eigenen Namen dazu. - Frueher stand hier eine Uebersetzungstabelle, weil die Shelly-Kommandos - ohne URL in der Datenbank landeten - das ist im Discovery behoben, die - Zuordnung gehoert dorthin und nicht in den Runner. - """ - - schema = "http" - - 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 = {} - for teil in (aktion["command_url"] or "").split("&"): - if "=" in teil: - schluessel, wert = teil.split("=", 1) - argumente[schluessel] = wert - for p in aktion["params"]: - if p["url"]: - argumente[p["url"]] = p["wert"] - if not argumente: - raise RuntimeError("Kommando ohne URL und ohne Parameter - im " - "Geraetemodell fehlt die Angabe, was zu schicken ist") - 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"])) - - -# --------------------------------------------------------------------------- -# WLED -# --------------------------------------------------------------------------- - -class WLEDTransport(Transport): - """ - WLED-Lampen sprechen ueber eine einzige JSON-Schnittstelle: - GET http://IP/json/state liefert den Zustand, POST dorthin setzt ihn. - - Das Geraetemodell nutzt das elegant aus - die command_url ist eine - JSON-Vorlage mit Platzhaltern: - - {"bri":%brightness%} - {"seg":[{"col":[[%red%,%green%,%blue%]]}]} - - Gesendet wird also nicht Argument fuer Argument, sondern die ausgefuellte - Vorlage am Stueck. Deshalb haben die Parameter hier auch keine eigene URL: - ihr Name ist der Platzhalter. - - Die state_url ist ein Pfad in die Antwort ("on", "bri", - "seg[0].col[0]") - dieselbe Schreibweise, die auch in current_value steht. - """ - - schema = "wled://" - - def __init__(self, requests_modul, timeout=5, dry_run=False): - self.requests = requests_modul - self.timeout = timeout - self.dry_run = dry_run - self.states = [] - - @staticmethod - def _adresse(actor_url): - return "http://" + actor_url[len("wled://"):].rstrip("/") - - def zustaende_anmelden(self, states): - self.states = [s for s in states if s["state_url"]] - logger.info("WLED: %d Messwerte an %d Lampen", - len(self.states), len({s["actor_url"] for s in self.states})) - - def zustaende_lesen(self): - werte = {} - nach_lampe = {} - for s in self.states: - nach_lampe.setdefault(s["actor_url"], []).append(s) - for actor_url, states in nach_lampe.items(): - try: - antwort = self.requests.get(self._adresse(actor_url) + "/json/state", - timeout=self.timeout) - daten = antwort.json() - except Exception as fehler: - logger.debug("WLED %s nicht erreichbar: %s", actor_url, fehler) - continue - for s in states: - # Bei WLED ist die state_url selbst schon der Pfad. Ein - # gesetzter value_path hat trotzdem Vorrang, falls das - # Geraetemodell spaeter darauf umgestellt wird. - pfad = s.get("value_path") or s["state_url"] - try: - werte[s["id"]] = str(wert_aus_pfad(daten, pfad)) - except (KeyError, IndexError, TypeError): - logger.debug("WLED %s: Pfad %s nicht gefunden", actor_url, pfad) - return werte - - def senden(self, aktion): - vorlage = aktion["command_url"] - if not vorlage: - raise RuntimeError("WLED-Kommando ohne Vorlage") - for p in aktion["params"]: - vorlage = vorlage.replace("%" + p["name"] + "%", str(p["wert"])) - try: - rumpf = json.loads(vorlage) - except ValueError: - # Ein nicht ersetzter Platzhalter oder ein Textwert an einer - # Stelle, wo eine Zahl stehen muss. Lieber hier abbrechen als der - # Lampe etwas Unverstaendliches schicken. - raise RuntimeError("WLED-Vorlage ergibt kein gueltiges JSON: " + vorlage[:120]) - if self.dry_run: - logger.info("[dry-run] WLED %s <- %s", aktion["actor_url"], - json.dumps(rumpf, ensure_ascii=False)) - return - antwort = self.requests.post(self._adresse(aktion["actor_url"]) + "/json/state", - json=rumpf, timeout=self.timeout) - if antwort.status_code >= 400: - raise RuntimeError("WLED antwortete mit %d" % antwort.status_code) - - -# --------------------------------------------------------------------------- -# Tahoma -# --------------------------------------------------------------------------- - -class TahomaTransport(Transport): - """ - Die actor_url ist die deviceURL, 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". - - Zustaendig ist dieser Transport fuer alles, was die Kennung der eigenen - Box in der URL traegt. Am Schema laesst sich das nicht festmachen: es - beschreibt die Funkart, und dieselbe Box liefert io:// fuer die - Jalousien, rts:// fuer die Dachfenster und internal:// fuer die Alarm- - anlage. Ohne PIN in der config.ini ist niemand zustaendig - dann meldet - der Runner beim Ausloesen "kein Transport", statt still nichts zu tun. - """ - - schema = "io://" - - # Bis zu dieser Neigung fahren die Aussenjalousien direkt. - # - # Sie haben eine Kugelschreiber-Mechanik: beim Herunterfahren stehen die - # Lamellen bei etwa 30 %. In Richtung 0 % laesst sich von jeder Stellung - # aus direkt neigen; darueber hinaus muss die Mechanik erst einmal auf - # 0 % zurueck, sonst rastet sie nicht um - ein "Neigung 80 %" ohne - # diesen Umweg bleibt wirkungslos. - # - # Das gilt fuer jedes Kommando, das die Neigung setzt - "Neigung" ebenso - # wie "Position+Neigung". Erkannt wird es deshalb am Parameter und nicht - # am Kommandonamen: beide heissen ihren Neigungsparameter "Neigung". - # - # Dasselbe steht in restricted/commands.php: beide Versender brauchen es. - NEIGUNG_DIREKT_MAX = 30 - - # So heissen die beiden Parameter einer Jalousie im Geraetemodell. - NEIGUNG_PARAMETER = "Neigung" - POSITION_PARAMETER = "Position" - - # So lange wird hoechstens auf das Ende einer Fahrt gewartet. Gemessen: - # eine Neigung von 100 % auf 0 % dauert gut fuenfzehn Sekunden, eine - # volle Fahrt von oben nach unten rund sechzig. Die Grenze ist die - # Notbremse, nicht die uebliche Dauer. - JALOUSIE_WARTE_SEKUNDEN = 120 - - # So lange gilt ein "faehrt nicht" direkt nach dem Absenden als noch - # nicht aussagekraeftig: die Box meldet core:MovingState traege, kurz - # nach einem Kommando steht dort noch der alte Wert. - JALOUSIE_VORLAUF_SEKUNDEN = 8 - - 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 = [] - self.kombigeraete = set() - - def passt(self, actor_url): - return bool(self.pin) and ("://" + self.pin + "/") in actor_url - - @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 kombigeraete_setzen(self, urls): - """ - Welche Geraete Position und Neigung zusammen koennen - vom Runner aus - dem Geraetemodell gesetzt, damit der Transport dafuer nicht selbst in - die Datenbank greifen muss. - """ - self.kombigeraete = set(urls) - - 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 = aktion["command_url"] - parameter = [self._zahl(p["wert"]) for p in aktion["params"]] - neigung_index = None - position_index = None - for i, p in enumerate(aktion["params"]): - if p["name"] == self.NEIGUNG_PARAMETER: - neigung_index = i - elif p["name"] == self.POSITION_PARAMETER: - position_index = i - - # "Zu" allein macht diese Jalousien nicht dicht: sie faehrt herunter, - # die Lamellen bleiben durch die Mechanik aber bei etwa 30 % offen. - # Gemeint ist "ganz unten, Lamellen geschlossen" - also dasselbe wie - # Position 100 mit Neigung 100, und damit ein Fall fuer die Regel. - if befehl == "down" and self._kannKombi(aktion["actor_url"]): - befehl = "setClosureAndOrientation" - parameter = [100, 100] - position_index, neigung_index = 0, 1 - - # Kugelschreiber-Mechanik, siehe NEIGUNG_DIREKT_MAX. Geschickt wird - # derselbe Befehl zweimal - erst mit Neigung 0, dann mit dem - # gewuenschten Wert. Die Position bleibt dabei stehen, die Jalousie - # faehrt also nur einmal. - umweg = (neigung_index is not None - and isinstance(parameter[neigung_index], (int, float)) - and parameter[neigung_index] > self.NEIGUNG_DIREKT_MAX) - vorstufe = list(parameter) - if umweg: - vorstufe[neigung_index] = 0 - - if self.dry_run: - logger.info("[dry-run] Tahoma %s %s%s", befehl, parameter, - " (zuerst %s, dann warten)" % vorstufe if umweg else "") - return - - if umweg: - self._apply(aktion["actor_url"], befehl, vorstufe) - self._warteAufJalousie(aktion["actor_url"], 0, - None if position_index is None else int(parameter[position_index])) - self._apply(aktion["actor_url"], befehl, parameter) - - def _kannKombi(self, actor_url): - """ - Hat das Geraet ein Kommando fuer Position und Neigung zusammen? - Nur solche Geraete sind Jalousien mit der Kugelschreiber-Mechanik. - """ - return actor_url in self.kombigeraete - - def _apply(self, actor_url, name, parameter): - """Ein Kommando an die Box schicken.""" - rumpf = {"label": "AutoAction", - "actions": [{"deviceURL": actor_url, - "commands": [{"name": name, "parameters": parameter}]}]} - 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])) - - def _warteAufJalousie(self, actor_url, neigung_ziel, schliessung_ziel=None): - """ - Wartet, bis die Jalousie ihre Fahrt beendet hat und die Ziele zeigt. - - Zwei Auskuenfte zusammen, weil einzeln keine traegt: - core:MovingState taugt fuer die lange Fahrt hoch und runter, wird bei - kurzen Neigungsfahrten aber nie gesetzt; die Zustandswerte sind die - eigentliche Wahrheit, zeigen kurz nach dem Kommando aber noch den - alten Stand. Fertig ist die Fahrt, wenn nichts mehr faehrt, die Ziele - erreicht sind und entweder ein "faehrt" gesehen wurde oder der - Vorlauf um ist. - """ - start = time.time() - gestartet = False - while time.time() - start < self.JALOUSIE_WARTE_SEKUNDEN: - time.sleep(2) - try: - antwort = self.requests.get( - self.basis + "/setup/devices/" + quote(actor_url, safe="") + "/states", - headers=self._kopf(), timeout=self.timeout, verify=False) - z = {x.get("name"): x.get("value") for x in antwort.json()} - except Exception as fehler: - logger.debug("Zustand nicht lesbar: %s", fehler) - continue - if z.get("core:MovingState") is True: - gestartet = True - continue - neigung = z.get("core:SlateOrientationState") - schliessung = z.get("core:ClosureState") - # Ein fehlendes Feld darf nicht als 0 durchgehen - das waere - # ausgerechnet beim Ziel 0 ein falsches Erfolgssignal. - neigung_ok = neigung is not None and int(neigung) == neigung_ziel - schliessung_ok = (schliessung_ziel is None - or (schliessung is not None and int(schliessung) == schliessung_ziel)) - if neigung_ok and schliessung_ok and ( - gestartet or time.time() - start >= self.JALOUSIE_VORLAUF_SEKUNDEN): - return True - logger.warning("%s hat Neigung %s%% nicht innerhalb von %d s erreicht", - actor_url, neigung_ziel, self.JALOUSIE_WARTE_SEKUNDEN) - return False - - @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")