SolarManager unter Versionsverwaltung
Erster Stand der Hintergrundprozesse, die auf der Synology unter /volume1/homes/wagner/SolarManager laufen: der Manager selbst, die Sammler je Geraet, die MQTT-Bruecke, der Wecker und - neu hinzugezogen - der AutoAction-Runner, der als Hintergrundprozess hierher gehoert und nicht ins Web-Verzeichnis. Zugangsdaten stehen nicht mehr im Quelltext, sondern in config.ini, die nicht mit eingecheckt wird. Vorlage ist config.ini.example, gelesen wird sie von konfig.py. Betroffen waren solarManager.py (Datenbank und Wattpilot), zeit.py, gatherWaterData.py, wecker.py und skoda_testdaten.py, das sich das Passwort bisher aus dem Quelltext eines anderen Moduls herausgesucht hat. Die Kia-Anbindung ist mit dem Fahrzeug entfallen: kiaTest.py, gatherCarData.py und hyundai_kia_connect_api sind nicht mehr dabei, ebenso gatherInverterData.py, auf das nur noch eine auskommentierte Zeile zeigte. Die mitgelieferten Bibliotheken bleiben im Repository - die NAS hat kein pip, sie muessen neben den Skripten liegen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
# 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 — nur wenn er sich geändert hat und
|
||||
höchstens einmal je Minute —, wovon auch der Editor profitiert: er zeigt
|
||||
neben jedem Messwert den aktuellen Stand.
|
||||
|
||||
## Was worauf wartet
|
||||
|
||||
Die Hauptschleife wartet auf nichts. Alles, was ein Netz braucht, läuft
|
||||
daneben:
|
||||
|
||||
| | wo | Takt |
|
||||
|---|---|---|
|
||||
| MQTT-Nachrichten | kommen von selbst, Zwischenspeicher im Transport | sofort |
|
||||
| Uhrzeit, Datum | im Runner gerechnet | jeder Takt |
|
||||
| Sonnenauf-/-untergang | `solarLog.daylight` | einmal je Tag |
|
||||
| HTTP- und WLED-Geräte | `Sammler`, eigener Faden | `poll_http`, `poll_wled` |
|
||||
| Tahoma | `Sammler`, eigener Faden | `poll_tahoma`, Vorgabe 5 Minuten |
|
||||
| Kommandos senden | `Versand`, ein Faden je Gerät | wenn etwas ansteht |
|
||||
|
||||
Das war nicht immer so. Vorher wurden die Geräte mitten in der Schleife
|
||||
abgefragt — neunzehn Tahoma-Geräte nacheinander, jedes mit bis zu zehn
|
||||
Sekunden Zeitlimit. Eine Runde dauerte dadurch rund fünfundvierzig statt
|
||||
dreißig Sekunden, gepollt wurde erst jede zweite Runde, also alle neunzig
|
||||
Sekunden. Und weil die Uhr an derselben Abfrage hing, kam jede dritte Minute
|
||||
nie vor: `um 18:26` wurde nie wahr.
|
||||
|
||||
Die Fäden fassen die Datenbank nicht an. Sie legen ihre Ergebnisse in eine
|
||||
Queue, geschrieben wird im Hauptfaden — eine pymysql-Verbindung gehört einem
|
||||
Faden.
|
||||
|
||||
## 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` | ab dieser Minute, noch `catchup_minutes` lang |
|
||||
| `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.
|
||||
|
||||
Das Nachholfenster bei `um` ist der Ersatz für die früher verlangte
|
||||
Punktgenauigkeit. Solange `um 16:30` nur in genau dieser Minute wahr war,
|
||||
kostete jeder Aussetzer die Automatik für den ganzen Tag — und Aussetzer gab
|
||||
es reichlich, weil die Uhr am Geräte-Poll hing und jede dritte Minute
|
||||
übersprang. Jetzt gilt die Bedingung fünf Minuten lang (einstellbar), die
|
||||
Flanke sorgt weiterhin für genau einen Lauf, und ein Neustart mitten im
|
||||
Fenster holt den Lauf nach. 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
|
||||
* Im Web-Verzeichnis muss in `restricted/deviceDiscovery/config.ini`
|
||||
**`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.
|
||||
|
||||
## Wo das läuft
|
||||
|
||||
Der Runner ist ein Hintergrundprozess und wohnt deshalb beim SolarManager,
|
||||
nicht im Web-Verzeichnis:
|
||||
|
||||
```
|
||||
/volume1/homes/wagner/SolarManager/
|
||||
├── solarManager.py
|
||||
├── startSolarServer.sh startet beide, siehe unten
|
||||
└── autoActions/
|
||||
├── autoaction_runner.py
|
||||
├── transports.py
|
||||
├── fetch_calendar.py
|
||||
└── config.ini Zugangsdaten, nicht im Git
|
||||
```
|
||||
|
||||
Das Web-UI kennt diesen Pfad nicht — Browser und Runner reden ausschließlich
|
||||
über die Datenbank `homeMesh` miteinander. Der Runner lädt das Regelwerk nach,
|
||||
sobald im Browser etwas gespeichert wurde; ein Neustart nach jeder Änderung ist
|
||||
nicht nötig.
|
||||
|
||||
`startSolarServer.sh` startet `solarManager.py` und den Runner gemeinsam und
|
||||
beendet vorher, was schon läuft. Aufgerufen wird es beim Booten (auf der
|
||||
Synology über den Aufgabenplaner, Ereignis „Hochfahren", als root); dasselbe
|
||||
Skript von Hand aufzurufen ist der normale Weg, den Runner neu zu starten.
|
||||
Zwei Instanzen dürfen nie gleichzeitig laufen — sie würden jedes Kommando
|
||||
doppelt schicken und sich gegenseitig vom MQTT-Broker werfen, weil beide
|
||||
dieselbe Client-Kennung benutzen. Genau davor schützt das Beenden am Anfang.
|
||||
|
||||
Ausgabe landet in `autoActions.log` neben `solarOutput.log`. `SIGTERM` fängt
|
||||
der Runner ab und fährt geordnet herunter.
|
||||
|
||||
`fetch_calendar.py` gehört einmal jährlich in den Cron:
|
||||
|
||||
```
|
||||
0 4 1 1 * /usr/bin/python3 /volume1/homes/wagner/SolarManager/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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
# ============================================================================
|
||||
# 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. Die Schleife wartet auf nichts mehr -
|
||||
# Geraete fragt der Sammler in eigenen Faeden ab, geschaltet wird im Versand -,
|
||||
# deshalb darf der Takt kurz sein. Er bestimmt nur noch, wie schnell eine
|
||||
# hereingekommene MQTT-Nachricht ausgewertet wird.
|
||||
tick_seconds = 10
|
||||
|
||||
# Wie oft Geraete abgefragt werden, die nichts von sich aus melden. Getrennt
|
||||
# je Transport, weil sie unterschiedlich teuer sind: eine Shelly-Abfrage ist
|
||||
# in Millisekunden zurueck, die Tahoma-Box braucht fuer ihre Geraete
|
||||
# nacheinander gut zehn Sekunden. In Sekunden.
|
||||
poll_http = 60
|
||||
poll_wled = 60
|
||||
poll_tahoma = 300
|
||||
|
||||
# Wie lange ein punktgenauer Ausloeser ("um 16:30", "Sonnenaufgang + 00:30")
|
||||
# nachtraeglich noch gilt, in Minuten. Faellt die Auswertung in dieser Minute
|
||||
# aus - Neustart, haengendes Geraet -, holt der naechste Takt sie nach.
|
||||
# Ausgeloest wird trotzdem nur einmal, weil nur die steigende Flanke zaehlt.
|
||||
catchup_minutes = 5
|
||||
|
||||
# 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,626 @@
|
||||
#!/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")
|
||||
Reference in New Issue
Block a user