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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user