Compare commits
3
Commits
cc96e990fa
...
32e571099b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e571099b | ||
|
|
09ce5c56c7 | ||
|
|
1f1700391d |
@@ -85,10 +85,8 @@ flowchart TB
|
||||
BROKER{{"<b>MQTT-Broker</b>"}}
|
||||
SOLARLOG[("<b>solarLog</b><br/><small>Verlauf</small>")]
|
||||
HOMEMESH[("<b>homeMesh</b><br/><small>Geräte · Automatiken</small>")]
|
||||
ALARM[("<b>alarm</b><br/><small>Weckzeiten</small>")]
|
||||
|
||||
RUNNER["<b>autoaction_runner.py</b><br/><small>SolarManager</small>"]
|
||||
WECKER["<b>wecker.py</b><br/><small>SolarManager</small>"]
|
||||
KALENDER["<b>fetch_calendar.py</b><br/><small>SolarManager · Cronjob, jährlich</small>"]
|
||||
DISCOVERY["<b>device_discovery.py</b><br/><small>Web · von Hand gestartet</small>"]
|
||||
AJAX["<b>ajax/*.php</b><br/><small>Web</small>"]
|
||||
@@ -103,10 +101,8 @@ flowchart TB
|
||||
RUNNER -.->|"HTTP · WLED · Tahoma"| HAUS
|
||||
BROKER -.->|"Gartenwasser/…/set"| VENTILE
|
||||
|
||||
ALARM --> WECKER
|
||||
FERIEN --> KALENDER
|
||||
KALENDER -->|"calendar_days"| HOMEMESH
|
||||
WECKER -.->|HTTP| HAUS
|
||||
|
||||
BROKER -->|"homeassistant/#"| DISCOVERY
|
||||
HAUS -->|"mDNS · Tahoma"| DISCOVERY
|
||||
@@ -121,8 +117,8 @@ flowchart TB
|
||||
classDef skript fill:#fff6e5,stroke:#d0a548
|
||||
classDef speicher fill:#eaf5ee,stroke:#6fa981
|
||||
classDef geraet fill:#eef4fb,stroke:#7f9dc0
|
||||
class RUNNER,WECKER,DISCOVERY,AJAX,BROWSER,KALENDER skript
|
||||
class BROKER,SOLARLOG,HOMEMESH,ALARM speicher
|
||||
class RUNNER,DISCOVERY,AJAX,BROWSER,KALENDER skript
|
||||
class BROKER,SOLARLOG,HOMEMESH speicher
|
||||
class HAUS,VENTILE,FERIEN geraet
|
||||
```
|
||||
|
||||
@@ -152,7 +148,6 @@ von Hand, wenn sich am Bestand etwas geändert hat.
|
||||
| `homeassistant/#` | die Geräte selbst | `device_discovery.py` |
|
||||
| **`solarLog`** | `solarManager.py` | `ajax/*.php`, `gatherWaterData` |
|
||||
| **`homeMesh`** | `device_discovery.py`, Runner, Web-Editor | Runner, `ajax/*.php` |
|
||||
| **`alarm`** | Web-Oberfläche | `wecker.py` |
|
||||
|
||||
## Was wann startet
|
||||
|
||||
@@ -163,7 +158,6 @@ von Hand, wenn sich am Bestand etwas geändert hat.
|
||||
| `gatherRainData.py` | dito |
|
||||
| `wsMQTTbridge.py` | `startMQTTbridge.sh` |
|
||||
| `wattpilot_bruecke.py` | `startWattpilotMQTT.sh` |
|
||||
| `wecker.py` | `startWecker.sh` |
|
||||
| `autoActions/fetch_calendar.py` | Cronjob, einmal im Jahr |
|
||||
| `device_discovery.py` (Web-Repo) | von Hand |
|
||||
|
||||
@@ -171,6 +165,42 @@ von Hand, wenn sich am Bestand etwas geändert hat.
|
||||
startet — beim Runner ist das wichtig, zwei Instanzen würden jedes Kommando
|
||||
doppelt schicken.
|
||||
|
||||
## Der Wecker ist keiner mehr
|
||||
|
||||
`wecker.py` gibt es nicht mehr. Es hat genau das getan, was der
|
||||
AutoAction-Runner ohnehin kann — nur mit allem doppelt: eigener Datenbank
|
||||
`alarm`, eigenem Feiertags- und Ferienkalender, eigenem UDP-Log auf Port
|
||||
13377, eigener Endlosschleife und einem eigenen Startskript samt nächtlichem
|
||||
Neustart um drei.
|
||||
|
||||
Punkt für Punkt hatte der Runner die bessere Fassung schon:
|
||||
|
||||
| `wecker.py` | Automatik |
|
||||
|---|---|
|
||||
| `alarmtime.time_1` | Bedingung „Uhrzeit um 05:50" |
|
||||
| Wochentagsspalten `mo` … `so` | `weekdays`, eine Bitmaske |
|
||||
| `is_holiday()` gegen `alarm.feiertage` | `on_holiday`, gegen `calendar_days` |
|
||||
| `is_school_holiday()` gegen `alarm.ferien` | `on_vacation`, ebenso |
|
||||
| `alarmtime.wecked = heute` | `cond_met`, die steigende Flanke |
|
||||
| fünf HTTP-Versuche im Abstand von vier Sekunden | der `Versand`, ein Faden je Gerät |
|
||||
| `send_log()` per UDP | `automation_log` und `autoActions.log` |
|
||||
|
||||
Der Kalender war der auffälligste Teil davon: `alarm.feiertage` und
|
||||
`alarm.ferien` standen neben `calendar_days`, das `fetch_calendar.py` einmal
|
||||
im Jahr von openholidaysapi.org holt. Zwei Kalender, die dasselbe wissen
|
||||
müssen, gehen früher oder später auseinander.
|
||||
|
||||
Umgezogen ist die eine Weckzeit, die scharf war: 05:50, Montag bis Freitag,
|
||||
nicht in den Ferien, nicht an Feiertagen, WLED-Preset 5 („Wakeup") auf
|
||||
`MenasHimmel`. Die drei anderen Zeilen in `alarmtime` standen auf
|
||||
`onoff = -1`. `wecker_zu_automatik.sql` legt die Automatik an und beschreibt
|
||||
im Kopf, wie dasselbe im Dashboard von Hand geht.
|
||||
|
||||
Damit ist die Weckzeit dort einstellbar, wo alles andere auch eingestellt
|
||||
wird — unter „Automatismen", Etage EG. Die Datenbank `alarm` liest niemand
|
||||
mehr; sie steht noch da, mitsamt dem Abschnitt `[alarm]` in der `config.ini`,
|
||||
und kann weg, sobald der erste Morgen ohne `wecker.py` durch ist.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Zugangsdaten und Standort stehen in `config.ini` (Vorlage:
|
||||
|
||||
+171
-2
@@ -30,7 +30,7 @@ Eine Aktion ist ein Kommando (`actor_commands`) mit einem Wert je Parameter
|
||||
sondern so viele Zeilen, wie das Gerät Parameter hat.
|
||||
|
||||
Die Rahmenbedingungen (Wochentage, Zeitfenster, Ferien, Feiertage) sagen, wann
|
||||
die Automatik überhaupt hinsehen darf.
|
||||
die Automatik überhaupt hinsehen darf — siehe [Der Rahmen](#der-rahmen).
|
||||
|
||||
## Warum ein Dauerläufer und kein Cronjob
|
||||
|
||||
@@ -101,6 +101,9 @@ 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.
|
||||
|
||||
Dieselben drei Formen trägt die Verkettung einer Automatik mit der nächsten,
|
||||
dort aber nur mit Plus — siehe „Eine Automatik löst die nächste aus".
|
||||
|
||||
Ü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
|
||||
@@ -111,6 +114,55 @@ 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.
|
||||
|
||||
## Der Rahmen
|
||||
|
||||
Vor jeder Auswertung fragt `tag_passt()`, ob der heutige Tag überhaupt zählt.
|
||||
Drei Dinge entscheiden das: die Wochentagsmaske (`weekdays`, ein Bit je Tag,
|
||||
Montag ist Bit 0), und Ferien und Feiertage aus `calendar_days`.
|
||||
|
||||
Ferien und Feiertage sind **dreiwertig**, nicht ja/nein:
|
||||
|
||||
| Editor | `on_vacation` / `on_holiday` | heißt |
|
||||
|---|---|---|
|
||||
| **nie** | `0` | an solchen Tagen läuft die Automatik nicht |
|
||||
| **egal** | `1` (Vorgabe) | der Tag ändert nichts |
|
||||
| **immer** | `2` | zählt wie ein angehakter Wochentag |
|
||||
|
||||
Die ersten beiden gab es immer; „werktags" ist `weekdays = 31` und
|
||||
`on_holiday = 0`. Der dritte Wert kam dazu, weil sich **„an Wochenenden und
|
||||
Feiertagen" sonst gar nicht schreiben ließ**: die Wochentagsmaske kennt nur
|
||||
Samstag und Sonntag, und ein Feiertag am Dienstag ist für sie eben ein
|
||||
Dienstag. Mit `2` genügt Sa+So angehakt und „Feiertage: immer", der Dienstag
|
||||
kommt dann über den Kalender herein. Und weil `2` allein trägt, ergibt
|
||||
`weekdays = 0` mit „Feiertage: immer" ein **nur an Feiertagen** — das ging
|
||||
vorher gar nicht.
|
||||
|
||||
Erst wird geöffnet, dann gesperrt: **ein Verbot schlägt eine Erweiterung.**
|
||||
Wer in den Ferien nie läuft und an Feiertagen zusätzlich, läuft an einem
|
||||
Feiertag in den Ferien nicht. Andersherum ließe sich „nie" nicht mehr
|
||||
verlassen.
|
||||
|
||||
Die Spalten sind `TINYINT` geblieben und tragen die `2` ohne Weiteres; die
|
||||
vorhandenen Zeilen stehen auf `0` oder `1` und behalten damit genau ihre
|
||||
bisherige Bedeutung. Umzurechnen gibt es nichts, die Migration
|
||||
(`rahmen_erweitern.sql`) setzt nur Kommentare und legt `once_per_day` an.
|
||||
|
||||
## Nur einmal am Tag
|
||||
|
||||
`once_per_day` sperrt eine Automatik nach dem Auslösen bis Mitternacht.
|
||||
|
||||
Gedacht für alles, was man hinterher von Hand wieder anders stellt. Der
|
||||
Rollladen fährt morgens auf; um acht zieht man ihn nochmal zu, weil die Sonne
|
||||
blendet — und um neun zieht eine Wolke weiter, die Helligkeitsschwelle steigt
|
||||
ein zweites Mal, und die Automatik funkt dazwischen. Genau das verhindert der
|
||||
Haken.
|
||||
|
||||
Die Sperrzeit taugt dafür nicht. Sie zählt Sekunden und müsste auf 86 400
|
||||
stehen; eine Automatik, die heute um 07:00:05 lief, wäre morgen um 07:00:00
|
||||
noch gesperrt und fiele einen ganzen Tag aus. Gefragt wird deshalb nach dem
|
||||
**Datum** von `last_run`, nicht nach dem Abstand — dieselbe Funktion
|
||||
(`lief_heute`), die auch `force_once` benutzt, nur mit umgekehrtem Vorzeichen.
|
||||
|
||||
## Sperrzeit
|
||||
|
||||
Die Flanke allein schützt nicht gegen einen Messwert, der um die Schwelle
|
||||
@@ -140,6 +192,108 @@ 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.
|
||||
|
||||
## Eine Automatik löst die nächste aus
|
||||
|
||||
Der Anlass war: *zehn Minuten nach dem Wecker den Rollladen hoch — aber nur,
|
||||
wenn es dann schon hell ist.*
|
||||
|
||||
Der naheliegende Weg wäre eine Verzögerung an der Aktion gewesen. Er trägt
|
||||
nicht: die Zusatzbedingung gilt erst zum **späteren** Zeitpunkt, und eine
|
||||
verzögerte Aktion, die selbst noch Bedingungen prüfen muss, ist keine Aktion
|
||||
mehr — sie bräuchte ein zweites Bedingungssystem neben dem ersten. Der zweite
|
||||
Schritt ist also eine eigene Automatik mit eigenen Rahmenbedingungen, und was
|
||||
ihr fehlte, war nur ein Weg, sich auf die erste zu beziehen.
|
||||
|
||||
Den gibt jetzt das gerechnete Gerät **„Automatiken"**, Gegenstück zum
|
||||
vorhandenen „Zeitpunkt". Jede Automatik ist dort ein Messwert, ihr Wert ist
|
||||
der Zeitpunkt der letzten Auslösung:
|
||||
|
||||
```
|
||||
Wecker Magdalena um 05:50 → Licht auf Wakeup
|
||||
Rollladen Magdalena Wecker Magdalena + 00:10
|
||||
UND Sonnenaufgang ab + 00:00 → Rollladen auf
|
||||
```
|
||||
|
||||
Der Editor braucht dafür keine Zeile Änderung. Er listet Geräte und deren
|
||||
Messwerte — „Automatiken" ist dann ein Gerät wie jedes andere.
|
||||
|
||||
### Der Datentyp `elapsed`
|
||||
|
||||
Er verhält sich zum Auslösezeitpunkt wie `deltatime` zum Sonnenaufgang: der
|
||||
Messwert ist der Bezugspunkt, die Schwelle der Versatz, das Vorzeichen steckt
|
||||
im Operator. Drei Formen, alle „danach" — ein Minus gibt es nicht, vor dem
|
||||
Auslöser kann nichts liegen, das erst der Auslöser anstößt:
|
||||
|
||||
| | wahr, wenn |
|
||||
|---|---|
|
||||
| `+ 00:10` | zehn Minuten nach der Auslösung, noch `catchup_minutes` lang |
|
||||
| `ab + 00:10` | von da an bis Mitternacht |
|
||||
| `vor + 00:10` | in den ersten zehn Minuten danach |
|
||||
|
||||
Gerechnet wird mit dem **echten Abstand**, nicht mit der Uhrzeit innerhalb des
|
||||
Tages. Das ist der eine Unterschied zu `deltatime`, und er ist nötig: sonst
|
||||
machte ein Lauf von vorgestern um 05:50 die Bedingung heute um 06:00 wahr, an
|
||||
einem Tag, an dem der Auslöser gar nicht lief.
|
||||
|
||||
Die offene Form `ab +` endet trotzdem am Tagesrand — sonst wäre sie morgen
|
||||
früh immer noch wahr, obwohl seither nichts geschehen ist. Ein Auslöser um
|
||||
23:55 trägt seinen Nachfolger deshalb nicht über Mitternacht; dieselbe
|
||||
Einschränkung hat `ab 23:55` auch. Die Punktform `+` braucht den
|
||||
Tagesvergleich nicht und trägt darüber hinweg.
|
||||
|
||||
### Reihenfolge, Pause, Kreise
|
||||
|
||||
**Ausgewertet wird topologisch**, Auslöser vor Nachfolger. Nur damit wirkt ein
|
||||
Versatz von null noch im selben Takt; bei jedem anderen Versatz wäre die
|
||||
Reihenfolge gleichgültig, zehn Minuten sind länger als ein Takt. Dazu trägt
|
||||
`ausloesen()` den eigenen Auslösewert sofort nach, statt bis zum nächsten
|
||||
`werte_einsammeln()` zu warten.
|
||||
|
||||
**Eine pausierte Automatik hält ihre Nachfolger mit an.** `Regelwerk.laden()`
|
||||
holt nur `enabled = 1`, der Transport liefert für alle übrigen einen leeren
|
||||
Wert, und der gilt jeder Bedingung als unerfüllt. Wer den Wecker pausiert,
|
||||
will morgens auch den Rollladen unten lassen.
|
||||
|
||||
**Kreise lehnt der Editor beim Speichern ab** (`pruefeKreis()` in
|
||||
`restricted/automations.php`). Im Betrieb wären sie kaum zu bemerken: die
|
||||
Sperrzeit begrenzt sie auf eine Auslösung je `lockout_secs`, und im Protokoll
|
||||
sieht das aus wie eine Automatik, die halt oft läuft. Der Runner meldet einen
|
||||
Kreis trotzdem — falls doch einer an der Datenbank vorbei entsteht — und
|
||||
wertet die Beteiligten dann in ihrer ursprünglichen Reihenfolge aus. Sie
|
||||
stillzulegen wäre schlimmer: eine wortlos abgeschaltete Automatik fällt
|
||||
niemandem auf.
|
||||
|
||||
### Löschen ist die gefährliche Stelle
|
||||
|
||||
`fk_cond_state` steht auf `ON DELETE CASCADE`. Verschwindet der Messwert,
|
||||
verschwindet die Bedingung — still. Bei der **letzten** Bedingung fängt
|
||||
`gruppen_erfuellt()` das ab („eine Automatik ohne Bedingungen löst nie aus").
|
||||
Bei **einer von zweien** fängt es niemand: aus *zehn Minuten nach dem Wecker
|
||||
UND es ist hell* würde ein bloßes *es ist hell*, und der Rollladen führe ab
|
||||
morgen jeden Tag bei Sonnenaufgang hoch.
|
||||
|
||||
Deshalb fragt der Editor vor dem Löschen, und der Runner räumt nur auf, was
|
||||
niemand mehr benutzt:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Abhängen** | die Bedingung wird entfernt **und der Nachfolger pausiert**. Eine pausierte Automatik mit sichtbarer Lücke ist besser als eine stille Regeländerung. |
|
||||
| **Alle löschen** | der Nachfolger geht mit, und dessen Nachfolger auch. |
|
||||
|
||||
Automatisch mitzulöschen wäre die falsche Vorgabe — „Wecker weg, Rollladen
|
||||
still weg" bemerkt man erst im Winter.
|
||||
|
||||
### Die Messwerte pflegt der Runner
|
||||
|
||||
`ausloeser_nachfuehren()` legt bei jedem Laden des Regelwerks für **jede**
|
||||
Automatik einen Messwert an, auch für pausierte, und zieht den Namen nach.
|
||||
Geführt wird nach der Kennung (`auto:15`) und nicht nach dem Namen: wer
|
||||
umbenennt, soll die abhängigen Bedingungen behalten.
|
||||
|
||||
Für pausierte muss der Messwert stehen bleiben, sonst löschte ein Pausieren
|
||||
über dieselbe Kaskade die Bedingung des Nachfolgers, und beim Fortsetzen wäre
|
||||
sie weg.
|
||||
|
||||
## Transporte
|
||||
|
||||
Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`:
|
||||
@@ -151,8 +305,9 @@ Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`:
|
||||
| `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 | – |
|
||||
| `Automatik` | gerechnet: je Automatik ihre letzte Auslösung | – |
|
||||
|
||||
Alle fünf stehen in `transports.py`. Eine sechste Geräteart kommt als weitere
|
||||
Alle sechs stehen in `transports.py`. Eine siebte 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
|
||||
@@ -217,10 +372,22 @@ Parameter dort keine eigene URL — ihr Name *ist* der Platzhalter.
|
||||
|
||||
```bash
|
||||
cp config.ini.example config.ini # ausfüllen: Datenbank, MQTT, Tahoma
|
||||
mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < automatik_ausloeser.sql
|
||||
mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < rahmen_erweitern.sql
|
||||
python3 fetch_calendar.py # Feiertage und Ferien holen
|
||||
python3 autoaction_runner.py --once --dry-run --verbose # Probelauf
|
||||
```
|
||||
|
||||
`automatik_ausloeser.sql` legt den Datentyp `elapsed` und das gerechnete
|
||||
Gerät „Automatiken" an — beides braucht die Verkettung, siehe oben. Die
|
||||
Messwerte darunter legt der Runner selbst an. Ohne das Skript läuft alles
|
||||
Übrige weiter, es fehlt nur die Möglichkeit, eine Automatik als Auslöser zu
|
||||
wählen.
|
||||
|
||||
`rahmen_erweitern.sql` gehört zum Rahmen: es beschriftet `on_vacation` und
|
||||
`on_holiday` mit ihren drei Bedeutungen und legt `once_per_day` an. Beide
|
||||
Skripte sind idempotent — ein zweiter Lauf schadet nicht.
|
||||
|
||||
`--dry-run` schaltet nichts, protokolliert aber jedes Kommando, das geschickt
|
||||
würde. `--once` macht einen einzigen Durchlauf.
|
||||
|
||||
@@ -237,6 +404,8 @@ nicht im Web-Verzeichnis:
|
||||
├── autoaction_runner.py
|
||||
├── transports.py
|
||||
├── fetch_calendar.py
|
||||
├── automatik_ausloeser.sql einmalig, siehe Einrichten
|
||||
├── rahmen_erweitern.sql einmalig, siehe Einrichten
|
||||
└── config.ini Zugangsdaten, nicht im Git
|
||||
```
|
||||
|
||||
|
||||
@@ -48,6 +48,15 @@ Beim Sonnenauf- und -untergang traegt der Operator zusaetzlich das
|
||||
Vorzeichen des Versatzes: "+ 00:30" eine halbe Stunde danach, ">=- 00:30"
|
||||
ab einer halben Stunde davor, "<+ 00:30" bis eine halbe Stunde danach.
|
||||
|
||||
Dieselbe Schreibweise traegt die Verkettung: eine Automatik kann eine andere
|
||||
ausloesen, indem sie deren letzte Ausloesung als Messwert abfragt - "Wecker
|
||||
Magdalena + 00:10". Dafuer gibt es das gerechnete Geraet "Automatiken"
|
||||
(AutomatikTransport) und den Datentyp `elapsed`. Der Nachfolger bleibt dabei
|
||||
eine vollwertige Automatik mit eigenen Rahmenbedingungen; genau darum geht es
|
||||
ja - "zehn Minuten spaeter, aber nur wenn es dann schon hell ist" waere als
|
||||
blosse Verzoegerung an einer Aktion nicht formulierbar, weil die
|
||||
Zusatzbedingung erst zum spaeteren Zeitpunkt gilt.
|
||||
|
||||
Tabellen siehe homeMesh_automations.sql, Konfiguration siehe config.ini.example.
|
||||
"""
|
||||
|
||||
@@ -67,8 +76,10 @@ import pymysql
|
||||
import requests
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from transports import (HTTPTransport, LogicTransport, MQTTTransport,
|
||||
TahomaTransport, WLEDTransport, ist_topic)
|
||||
from transports import (AUTOMATIK_URL, AutomatikTransport, HTTPTransport,
|
||||
LogicTransport, MQTTTransport, TahomaTransport,
|
||||
WLEDTransport, ausloeser_kennung, ausloeser_url,
|
||||
ist_topic)
|
||||
|
||||
logger = logging.getLogger("autoaction")
|
||||
|
||||
@@ -422,6 +433,37 @@ def bedingung_erfuellt(bedingung, state, wert, jetzt, fenster=NACHHOLFENSTER):
|
||||
if op.startswith("<"): return jetzt_m < ziel
|
||||
return im_nachholfenster(jetzt_m, ziel, fenster)
|
||||
|
||||
if typ == "elapsed":
|
||||
# Der Messwert ist der Zeitpunkt, zu dem eine andere Automatik
|
||||
# zuletzt gelaufen ist; die Schwelle der Versatz danach.
|
||||
#
|
||||
# Anders als bei `deltatime` wird hier NICHT mit der Uhrzeit
|
||||
# innerhalb des Tages gerechnet, sondern mit dem echten Abstand.
|
||||
# Sonst machte ein Lauf von vorgestern um 05:50 die Bedingung
|
||||
# heute um 06:00 wahr, an einem Tag, an dem der Ausloeser gar
|
||||
# nicht gelaufen ist.
|
||||
letzter = als_zeitpunkt(wert)
|
||||
if letzter is None or letzter > jetzt:
|
||||
return False
|
||||
verstrichen = (jetzt - letzter).total_seconds() / 60.0
|
||||
ziel = minuten(soll)
|
||||
if op.startswith(">="):
|
||||
# "ab + 00:10" laeuft sonst unbegrenzt weiter - morgen frueh
|
||||
# waere es immer noch wahr, obwohl der Ausloeser seither
|
||||
# nichts getan hat. Begrenzt wird wie bei "ab 16:30": bis
|
||||
# Mitternacht. Ein Ausloeser um 23:55 traegt seinen
|
||||
# Nachfolger deshalb nicht ueber den Tagesrand - dieselbe
|
||||
# Einschraenkung hat "ab 23:55" auch.
|
||||
if letzter.date() != jetzt.date():
|
||||
return False
|
||||
return verstrichen >= ziel
|
||||
if op.startswith("<"):
|
||||
return verstrichen < ziel
|
||||
# "um + 00:10": die Punktform, begrenzt durch das Nachholfenster.
|
||||
# Sie braucht den Tagesvergleich nicht und traegt deshalb auch
|
||||
# ueber Mitternacht.
|
||||
return ziel <= verstrichen < ziel + max(1, fenster)
|
||||
|
||||
if typ in ("date", "datetime"):
|
||||
wandeln = als_datum if typ == "date" else als_zeitpunkt
|
||||
ist_d, soll_d = wandeln(wert), wandeln(soll)
|
||||
@@ -469,12 +511,41 @@ def im_zeitfenster(jetzt, von, bis):
|
||||
return a <= m <= b if a <= b else (m >= a or m <= b)
|
||||
|
||||
|
||||
#: Ferien und Feiertage sind dreiwertig, nicht ja/nein.
|
||||
NIE, EGAL, ZUSAETZLICH = 0, 1, 2
|
||||
|
||||
|
||||
def tag_passt(automatik, jetzt, kalender):
|
||||
if not automatik["weekdays"] & (1 << jetzt.weekday()):
|
||||
"""
|
||||
Faellt der heutige Tag in den Rahmen der Automatik?
|
||||
|
||||
Die Wochentage sind eine Maske, Ferien und Feiertage haben je drei
|
||||
Zustaende. Zwei davon gab es immer: EGAL (der Tag aendert nichts, die
|
||||
Vorgabe) und NIE (an solchen Tagen laeuft die Automatik nicht - so ist
|
||||
"werktags" gebaut). ZUSAETZLICH ist der dritte und zaehlt wie ein
|
||||
passender Wochentag.
|
||||
|
||||
Der Grund fuer den dritten: "Wochenenden und Feiertage" liess sich vorher
|
||||
gar nicht schreiben. Die Wochentagsmaske kennt nur Samstag und Sonntag,
|
||||
und ein Feiertag am Dienstag ist eben ein Dienstag. Mit ZUSAETZLICH
|
||||
genuegt Sa+So angehakt und "Feiertage: zusaetzlich" - der Dienstag kommt
|
||||
dann ueber den Kalender herein.
|
||||
|
||||
Reihenfolge: erst wird geoeffnet, dann gesperrt. Wer in den Ferien nie
|
||||
laufen soll und an Feiertagen zusaetzlich, laeuft an einem Feiertag in
|
||||
den Ferien nicht - ein Verbot schlaegt eine Erweiterung. Anders herum
|
||||
liesse sich "nie" nicht mehr verlassen.
|
||||
"""
|
||||
passt = bool(automatik["weekdays"] & (1 << jetzt.weekday()))
|
||||
if kalender["feiertag"] and automatik["on_holiday"] == ZUSAETZLICH:
|
||||
passt = True
|
||||
if kalender["ferien"] and automatik["on_vacation"] == ZUSAETZLICH:
|
||||
passt = True
|
||||
if not passt:
|
||||
return False
|
||||
if kalender["feiertag"] and not automatik["on_holiday"]:
|
||||
if kalender["feiertag"] and automatik["on_holiday"] == NIE:
|
||||
return False
|
||||
if kalender["ferien"] and not automatik["on_vacation"]:
|
||||
if kalender["ferien"] and automatik["on_vacation"] == NIE:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -649,6 +720,8 @@ class Runner:
|
||||
self._sonne = (None, "00:00", "00:00") # (datum, aufgang, untergang)
|
||||
self._kalender = (None, {"feiertag": False, "ferien": False})
|
||||
self._letzte_saeuberung = None
|
||||
self.reihenfolge = [] # automation_id, Ausloeser vor Nachfolger
|
||||
self.ausloeser_states = {} # automation_id -> state_id des Ausloesers
|
||||
|
||||
self.versand = Versand()
|
||||
self.sammler = Sammler()
|
||||
@@ -661,6 +734,7 @@ class Runner:
|
||||
config.text("tahoma", "token"),
|
||||
config.zahl("tahoma", "timeout", 10), self.dry_run),
|
||||
LogicTransport(self.sonnenzeiten),
|
||||
AutomatikTransport(self.ausloesezeiten),
|
||||
]
|
||||
self.regelwerk = None
|
||||
|
||||
@@ -711,6 +785,10 @@ class Runner:
|
||||
return self.transport_fuer(state["actor_url"])
|
||||
|
||||
def regelwerk_laden(self):
|
||||
# Vor dem Laden, damit eine neu angelegte Automatik sofort als
|
||||
# Ausloeser zur Verfuegung steht und ihre Zeile schon in der
|
||||
# Signatur steckt - sonst laedt der naechste Takt gleich noch einmal.
|
||||
self.ausloeser_nachfuehren()
|
||||
self.regelwerk = Regelwerk.laden(self.db)
|
||||
|
||||
# Welche Geraete Position und Neigung zusammen koennen. Nur die haben
|
||||
@@ -746,6 +824,142 @@ class Runner:
|
||||
# noch einmal hineingeschrieben werden.
|
||||
self.geschrieben.setdefault(s["id"], s["current_value"])
|
||||
|
||||
self.ausloeser_states = {
|
||||
ausloeser_kennung(s["state_url"]): s["id"]
|
||||
for s in self.regelwerk.states.values()
|
||||
if s["actor_url"] == AUTOMATIK_URL
|
||||
and ausloeser_kennung(s["state_url"]) is not None}
|
||||
self.reihenfolge_bestimmen()
|
||||
|
||||
def ausloeser_nachfuehren(self):
|
||||
"""
|
||||
Je Automatik einen Messwert am gerechneten Geraet "Automatiken".
|
||||
|
||||
Damit taucht jede Automatik im Editor als Messwert auf und laesst sich
|
||||
als Ausloeser einer anderen waehlen, ohne dass der Editor davon etwas
|
||||
wissen muesste - er listet Geraete und deren Messwerte, mehr nicht.
|
||||
|
||||
Gefuehrt wird nach der Kennung, nicht nach dem Namen: wer umbenennt,
|
||||
soll die abhaengigen Bedingungen nicht verlieren. Der Name wird
|
||||
nachgezogen, damit im Editor das Richtige steht.
|
||||
|
||||
Angelegt wird fuer JEDE Automatik, auch fuer pausierte. Sonst loeschte
|
||||
ein Pausieren ueber `fk_cond_state ON DELETE CASCADE` die Bedingung
|
||||
des Nachfolgers - still, und beim Fortsetzen waere sie weg.
|
||||
|
||||
Geloescht wird nur, was niemand mehr benutzt. Zeigt noch eine
|
||||
Bedingung darauf, bleibt der Messwert stehen und es gibt eine Warnung:
|
||||
die Kaskade wuerde sonst eine einzelne Bedingung aus einer Gruppe
|
||||
entfernen und aus "zehn Minuten nach dem Wecker UND es ist hell" ein
|
||||
blosses "es ist hell" machen. Bei der LETZTEN Bedingung faengt
|
||||
gruppen_erfuellt() das ab, bei einer von zweien niemand.
|
||||
"""
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.execute("SELECT id FROM actors WHERE url = %s", (AUTOMATIK_URL,))
|
||||
zeile = c.fetchone()
|
||||
if not zeile:
|
||||
logger.debug("Geraet \"Automatiken\" gibt es nicht - "
|
||||
"automatik_ausloeser.sql noch nicht eingespielt")
|
||||
return
|
||||
aktor = zeile["id"]
|
||||
c.execute("SELECT id FROM state_types WHERE type = 'elapsed'")
|
||||
typ = c.fetchone()
|
||||
typ_id = typ["id"] if typ else None
|
||||
|
||||
c.execute("SELECT id, name FROM automations")
|
||||
gewuenscht = {ausloeser_url(r["id"]): r["name"] for r in c.fetchall()}
|
||||
|
||||
c.execute("""SELECT s.id, s.state_name, s.url,
|
||||
(SELECT COUNT(*) FROM automation_conditions b
|
||||
WHERE b.state_id = s.id) AS benutzt
|
||||
FROM actor_states s WHERE s.actor_id = %s""", (aktor,))
|
||||
vorhanden = {r["url"]: r for r in c.fetchall()}
|
||||
|
||||
for url, name in gewuenscht.items():
|
||||
alt = vorhanden.get(url)
|
||||
if alt is None:
|
||||
c.execute("""INSERT INTO actor_states
|
||||
(actor_id, state_name, state_type, url,
|
||||
possible_values)
|
||||
VALUES (%s, %s, %s, %s, '')""",
|
||||
(aktor, name, typ_id, url))
|
||||
elif alt["state_name"] != name:
|
||||
c.execute("UPDATE actor_states SET state_name = %s WHERE id = %s",
|
||||
(name, alt["id"]))
|
||||
|
||||
for url, alt in vorhanden.items():
|
||||
if url in gewuenscht:
|
||||
continue
|
||||
if alt["benutzt"]:
|
||||
logger.warning(
|
||||
"Ausloeser %r zeigt auf eine geloeschte Automatik, wird "
|
||||
"aber noch von %d Bedingung(en) benutzt - bleibt stehen",
|
||||
alt["state_name"], alt["benutzt"])
|
||||
continue
|
||||
c.execute("DELETE FROM actor_states WHERE id = %s", (alt["id"],))
|
||||
except Exception as fehler:
|
||||
logger.warning("Ausloeser nicht nachfuehrbar: %r", fehler)
|
||||
|
||||
def reihenfolge_bestimmen(self):
|
||||
"""
|
||||
Ausloeser vor Nachfolger auswerten.
|
||||
|
||||
Nur damit wirkt ein Versatz von null noch im selben Takt. Bei jedem
|
||||
anderen Versatz waere die Reihenfolge gleichgueltig - zehn Minuten
|
||||
sind laenger als ein Takt.
|
||||
|
||||
Ein Kreis (A loest B loest A) waere ein Fehler im Regelwerk; der
|
||||
Editor lehnt ihn beim Speichern ab. Hier wird er nur gemeldet und die
|
||||
Beteiligten laufen in ihrer urspruenglichen Reihenfolge weiter. Sie
|
||||
deswegen stillzulegen waere schlimmer: die Sperrzeit begrenzt den
|
||||
Schaden ohnehin auf eine Ausloesung je lockout_secs, eine wortlos
|
||||
abgeschaltete Automatik dagegen faellt niemandem auf.
|
||||
"""
|
||||
automatiken = self.regelwerk.automatiken
|
||||
vorgaenger = {aid: set() for aid in automatiken}
|
||||
for aid, auto in automatiken.items():
|
||||
for bedingungen in auto["gruppen"].values():
|
||||
for b in bedingungen:
|
||||
state = self.regelwerk.states.get(b["state_id"])
|
||||
if not state or state["actor_url"] != AUTOMATIK_URL:
|
||||
continue
|
||||
davor = ausloeser_kennung(state["state_url"])
|
||||
if davor == aid:
|
||||
logger.error("%s loest sich selbst aus - Bedingung wird "
|
||||
"nie wahr", auto["name"])
|
||||
elif davor in automatiken:
|
||||
vorgaenger[aid].add(davor)
|
||||
|
||||
reihenfolge, offen = [], dict(vorgaenger)
|
||||
while offen:
|
||||
frei = sorted(aid for aid, davor in offen.items()
|
||||
if not davor & set(offen))
|
||||
if not frei:
|
||||
logger.error("Automatiken loesen sich im Kreis aus: %s",
|
||||
", ".join(automatiken[aid]["name"] for aid in offen))
|
||||
reihenfolge.extend(offen)
|
||||
break
|
||||
reihenfolge.extend(frei)
|
||||
for aid in frei:
|
||||
del offen[aid]
|
||||
self.reihenfolge = reihenfolge
|
||||
|
||||
def ausloesezeiten(self):
|
||||
"""
|
||||
Wann jede Automatik zuletzt gelaufen ist - die Werte des gerechneten
|
||||
Geraets "Automatiken".
|
||||
|
||||
Es stehen nur die aktiven darin: `Regelwerk.laden` holt sich
|
||||
`WHERE enabled = 1`. Eine pausierte Automatik liefert damit keinen
|
||||
Zeitpunkt, und ihre Nachfolger stehen mit still. Das ist gewollt -
|
||||
wer den Wecker pausiert, will morgens auch den Rollladen unten lassen.
|
||||
"""
|
||||
if not self.regelwerk:
|
||||
return {}
|
||||
return {aid: auto.get("last_run")
|
||||
for aid, auto in self.regelwerk.automatiken.items()}
|
||||
|
||||
# --- Umgebung --------------------------------------------------------
|
||||
|
||||
def sonnenzeiten(self):
|
||||
@@ -820,7 +1034,8 @@ class Runner:
|
||||
"""
|
||||
neu = {}
|
||||
for transport in self.transporte:
|
||||
billig = isinstance(transport, (MQTTTransport, LogicTransport))
|
||||
billig = isinstance(transport, (MQTTTransport, LogicTransport,
|
||||
AutomatikTransport))
|
||||
if billig or auch_geraete:
|
||||
neu.update(transport.zustaende_lesen())
|
||||
neu.update(self.sammler.abholen())
|
||||
@@ -906,6 +1121,13 @@ class Runner:
|
||||
logger.warning("Protokoll nicht schreibbar: %r", fehler)
|
||||
automatik["last_run"] = datetime.now()
|
||||
self.lief_im_fenster[automatik["id"]] = True
|
||||
# Den eigenen Ausloeserwert gleich mitfuehren, statt bis zum naechsten
|
||||
# werte_einsammeln() zu warten. Zusammen mit der topologischen
|
||||
# Reihenfolge greift ein Nachfolger mit Versatz null dadurch noch im
|
||||
# selben Takt.
|
||||
state_id = self.ausloeser_states.get(automatik["id"])
|
||||
if state_id is not None:
|
||||
self.werte[state_id] = automatik["last_run"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def ergebnisse_verbuchen(self):
|
||||
"""
|
||||
@@ -943,7 +1165,10 @@ class Runner:
|
||||
jetzt = datetime.now()
|
||||
kalender = self.kalender()
|
||||
|
||||
for automatik in self.regelwerk.automatiken.values():
|
||||
for automation_id in self.reihenfolge:
|
||||
automatik = self.regelwerk.automatiken.get(automation_id)
|
||||
if automatik is None:
|
||||
continue
|
||||
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)
|
||||
@@ -954,7 +1179,10 @@ class Runner:
|
||||
erfuellt = gruppen_erfuellt(automatik, self.regelwerk, self.werte,
|
||||
jetzt, fenster)
|
||||
if erfuellt and not automatik["cond_met"]:
|
||||
if self.gesperrt(automatik, jetzt):
|
||||
if automatik.get("once_per_day") and self.lief_heute(automatik, jetzt):
|
||||
logger.debug("%s: lief heute schon, bis Mitternacht gesperrt",
|
||||
automatik["name"])
|
||||
elif self.gesperrt(automatik, jetzt):
|
||||
logger.debug("%s: Flanke faellt in die Sperrzeit, uebersprungen",
|
||||
automatik["name"])
|
||||
else:
|
||||
@@ -1004,6 +1232,21 @@ class Runner:
|
||||
|
||||
@staticmethod
|
||||
def lief_heute(automatik, jetzt):
|
||||
"""
|
||||
Hat die Automatik heute schon ausgeloest?
|
||||
|
||||
Zwei Stellen fragen danach. force_once will wissen, ob es am Ende des
|
||||
Fensters noch etwas nachzuholen gibt. once_per_day will das Gegenteil:
|
||||
einmal am Tag genuegt, danach ist bis Mitternacht Ruhe.
|
||||
|
||||
Letzteres ist fuer alles gedacht, was man hinterher von Hand wieder
|
||||
anders stellt. Ein Rollladen, den man um acht nochmal zugezogen hat,
|
||||
soll nicht um neun von selbst wieder auffahren, nur weil eine Wolke
|
||||
weiterzieht und die Helligkeitsschwelle ein zweites Mal steigt. Die
|
||||
Sperrzeit taugt dafuer nicht: sie zaehlt Sekunden und muesste auf
|
||||
einen Tag stehen, womit sie am naechsten Morgen den Termin knapp
|
||||
verfehlen wuerde.
|
||||
"""
|
||||
letzter = automatik.get("last_run")
|
||||
return bool(letzter) and letzter.date() == jetzt.date()
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- ===========================================================================
|
||||
-- Automatiken als Ausloeser fuer andere Automatiken
|
||||
--
|
||||
-- Anlass: "zehn Minuten nach dem Wecker den Rollladen hoch, aber nur wenn es
|
||||
-- dann schon hell ist". Die Zusatzbedingung gilt erst zum spaeteren
|
||||
-- Zeitpunkt - als blosse Verzoegerung an einer Aktion waere das nicht
|
||||
-- formulierbar, dafuer braeuchte die Aktion ein eigenes Bedingungssystem.
|
||||
-- Der zweite Schritt ist also eine eigene Automatik, und was ihr fehlte, war
|
||||
-- nur ein Weg, sich auf die erste zu beziehen.
|
||||
--
|
||||
-- Dieses Skript legt dafuer zwei Dinge an:
|
||||
--
|
||||
-- 1. den Datentyp `elapsed` - "so lange ist es her". Er verhaelt sich zu
|
||||
-- einem Zeitpunkt wie `deltatime` zum Sonnenaufgang: der Messwert ist
|
||||
-- der Bezugspunkt, die Schwelle der Versatz, das Vorzeichen steckt im
|
||||
-- Operator. Gerechnet wird aber mit dem echten Abstand und nicht mit
|
||||
-- der Uhrzeit innerhalb des Tages - sonst machte ein Lauf von
|
||||
-- vorgestern die Bedingung heute wahr.
|
||||
--
|
||||
-- 2. das gerechnete Geraet "Automatiken", Gegenstueck zum schon
|
||||
-- vorhandenen "Zeitpunkt". Seine Messwerte legt der Runner selbst an
|
||||
-- und fuehrt sie nach: je Automatik einen, benannt wie sie, mit
|
||||
-- "auto:<id>" als URL. Deshalb steht hier nur der Aktor.
|
||||
--
|
||||
-- Der Editor braucht dafuer keine Zeile Aenderung, um das anzubieten: er
|
||||
-- listet Geraete und deren Messwerte, und "Automatiken" ist dann ein Geraet
|
||||
-- wie jedes andere.
|
||||
--
|
||||
-- Einspielen:
|
||||
-- mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < automatik_ausloeser.sql
|
||||
--
|
||||
-- Danach den Runner neu starten (startSolarServer.sh) - die Regelwerks-
|
||||
-- Nachladung holt nur Daten, keinen neuen Code.
|
||||
-- ===========================================================================
|
||||
|
||||
-- Die IDs in state_types sind feste Kennungen, keine Auto-Increment-Werte
|
||||
-- (0 = bool, 5 = time, 7 = deltatime ...). 10 ist die naechste freie.
|
||||
INSERT INTO state_types (id, type)
|
||||
SELECT 10, 'elapsed'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM state_types WHERE type = 'elapsed');
|
||||
|
||||
-- Kein Raum und keine Etage: das Geraet steht nirgends im Haus, genau wie
|
||||
-- "Zeitpunkt". Der Geraetewaehler im Editor sortiert es dadurch zu den
|
||||
-- uebrigen ortlosen.
|
||||
INSERT INTO actors (type, name, url, room)
|
||||
SELECT 'LOGIC', 'Automatiken', 'Automatik', NULL
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM actors WHERE url = 'Automatik');
|
||||
|
||||
SELECT a.id AS aktor, a.name, a.url,
|
||||
(SELECT id FROM state_types WHERE type = 'elapsed') AS typ_elapsed,
|
||||
(SELECT COUNT(*) FROM actor_states WHERE actor_id = a.id) AS messwerte
|
||||
FROM actors a WHERE a.url = 'Automatik';
|
||||
@@ -0,0 +1,66 @@
|
||||
-- ===========================================================================
|
||||
-- Rahmenbedingungen erweitern: Ferien und Feiertage dreiwertig,
|
||||
-- dazu die Tagessperre.
|
||||
--
|
||||
-- Einspielen:
|
||||
-- mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < rahmen_erweitern.sql
|
||||
--
|
||||
-- Danach den Runner neu starten (startSolarServer.sh) - Daten laedt er
|
||||
-- selbst nach, neuen Code nicht.
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. on_vacation / on_holiday sind jetzt dreiwertig
|
||||
--
|
||||
-- 0 = nie an solchen Tagen laeuft die Automatik nicht
|
||||
-- 1 = egal der Tag aendert nichts (Vorgabe, wie bisher)
|
||||
-- 2 = zusaetzlich zaehlt wie ein passender Wochentag
|
||||
--
|
||||
-- Die Spalten bleiben, wie sie sind - TINYINT traegt die 2 ohne Weiteres,
|
||||
-- und die vorhandenen Zeilen stehen bereits auf 0 oder 1 und behalten damit
|
||||
-- genau ihre bisherige Bedeutung. Es gibt nichts umzurechnen.
|
||||
--
|
||||
-- Gebraucht wird die 2 fuer den Fall, den man vorher gar nicht schreiben
|
||||
-- konnte: "an Wochenenden und Feiertagen". Die Wochentagsmaske kennt nur
|
||||
-- Samstag und Sonntag, ein Feiertag am Dienstag ist eben ein Dienstag. Mit
|
||||
-- der 2 genuegt Sa+So angehakt und "Feiertage: zusaetzlich".
|
||||
--
|
||||
-- 2. once_per_day - nach dem Auslösen bis Mitternacht Ruhe
|
||||
--
|
||||
-- Fuer alles, was man hinterher von Hand wieder anders stellt. Ein Rollladen,
|
||||
-- den man um acht nochmal zugezogen hat, soll nicht um neun von selbst wieder
|
||||
-- auffahren, nur weil eine Wolke weiterzieht und die Helligkeitsschwelle ein
|
||||
-- zweites Mal steigt.
|
||||
--
|
||||
-- Die Sperrzeit (lockout_secs) taugt dafuer nicht: sie zaehlt Sekunden und
|
||||
-- muesste auf 86400 stehen, womit sie am naechsten Morgen den Termin knapp
|
||||
-- verfehlen wuerde - eine Automatik, die heute um 07:00:05 lief, waere
|
||||
-- morgen um 07:00:00 noch gesperrt und fiele einen ganzen Tag aus.
|
||||
-- ===========================================================================
|
||||
|
||||
ALTER TABLE automations
|
||||
MODIFY COLUMN on_vacation TINYINT(1) NOT NULL DEFAULT 1
|
||||
COMMENT '0=nie, 1=egal, 2=zusaetzlich (zaehlt wie passender Wochentag)',
|
||||
MODIFY COLUMN on_holiday TINYINT(1) NOT NULL DEFAULT 1
|
||||
COMMENT '0=nie, 1=egal, 2=zusaetzlich (zaehlt wie passender Wochentag)';
|
||||
|
||||
-- Idempotent: beim zweiten Lauf steht die Spalte schon da.
|
||||
SET @vorhanden := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'automations'
|
||||
AND COLUMN_NAME = 'once_per_day');
|
||||
|
||||
SET @sql := IF(@vorhanden = 0,
|
||||
'ALTER TABLE automations ADD COLUMN once_per_day TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT ''nach dem Auslösen bis Mitternacht gesperrt''
|
||||
AFTER force_once',
|
||||
'DO 0');
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SELECT COLUMN_NAME, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'automations'
|
||||
AND COLUMN_NAME IN ('on_vacation', 'on_holiday', 'once_per_day');
|
||||
@@ -13,6 +13,9 @@ URL des Aktors in der Tabelle `actors`:
|
||||
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)
|
||||
Automatik das gerechnete Geraet "Automatiken" - jede Automatik ist
|
||||
dort ein Messwert, ihr Wert der Zeitpunkt der letzten
|
||||
Ausloesung
|
||||
|
||||
Eine Ausnahme gibt es beim Lesen: meldet ein Geraet seine Messwerte an den
|
||||
Broker, obwohl es ueber HTTP geschaltet wird, so steht in `actor_states.url`
|
||||
@@ -21,7 +24,7 @@ Runner.transport_fuer_messwert(). Das betrifft die Shellys der zweiten
|
||||
Generation; die aelteren koennen kein MQTT und bleiben ganz bei HTTP.
|
||||
|
||||
Alle liegen in einer Datei statt in einem Paket wie bei deviceDiscovery: es
|
||||
sind fuenf kurze Klassen, und wer eine sechste Geraeteart anschliesst, sieht
|
||||
sind sechs kurze Klassen, und wer eine siebte Geraeteart anschliesst, sieht
|
||||
hier auf einen Blick, was dafuer zu tun ist.
|
||||
|
||||
Jeder Transport hat zwei Haelften:
|
||||
@@ -707,3 +710,88 @@ class LogicTransport(Transport):
|
||||
|
||||
def senden(self, aktion):
|
||||
raise RuntimeError("Das Geraet \"Zeitpunkt\" kann nichts schalten")
|
||||
|
||||
|
||||
AUTOMATIK_URL = "Automatik"
|
||||
|
||||
|
||||
def ausloeser_url(automation_id):
|
||||
"""
|
||||
Wie eine Automatik in actor_states.url steht: "auto:15".
|
||||
|
||||
Die Kennung und nicht der Name, damit ein Umbenennen die Bedingungen der
|
||||
abhaengigen Automatiken nicht ins Leere zeigen laesst. Kein Schema und
|
||||
kein Schraegstrich - sonst hielte ist_topic() das fuer ein MQTT-Topic und
|
||||
der MQTT-Transport waere zustaendig.
|
||||
"""
|
||||
return "auto:%d" % int(automation_id)
|
||||
|
||||
|
||||
def ausloeser_kennung(state_url):
|
||||
"""Die Kennung zurueck aus "auto:15". None, wenn es keine ist."""
|
||||
text = str(state_url or "")
|
||||
if not text.startswith("auto:"):
|
||||
return None
|
||||
try:
|
||||
return int(text[5:])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class AutomatikTransport(Transport):
|
||||
"""
|
||||
Automatiken als Ausloeser fuer andere Automatiken.
|
||||
|
||||
Jede Automatik ist hier ein Messwert, und ihr Wert ist der Zeitpunkt, zu
|
||||
dem sie zuletzt gelaufen ist. Eine Bedingung darauf liest sich dann als
|
||||
"zehn Minuten nach dem Wecker"; den Abstand rechnet der Datentyp
|
||||
`elapsed`, siehe bedingung_erfuellt() im Runner.
|
||||
|
||||
Warum ueberhaupt ein Transport und keine eigene Art von Bedingung: so
|
||||
braucht der Editor keine Zeile Aenderung, um das anzubieten. Er listet
|
||||
Geraete und deren Messwerte - "Automatiken" ist dann ein Geraet wie jedes
|
||||
andere, die Automatiken sind dessen Messwerte. Dieselbe Ueberlegung steht
|
||||
hinter dem gerechneten Geraet "Zeitpunkt".
|
||||
|
||||
Geschaltet wird hier nichts. Eine Automatik, die eine andere aufruft, ist
|
||||
ausdruecklich nicht vorgesehen: die Verkettung laeuft immer ueber die
|
||||
Bedingung, also von hinten nach vorn. Sonst gaebe es zwei Wege zum selben
|
||||
Ziel, und einen davon koennte der Editor nicht anzeigen.
|
||||
"""
|
||||
|
||||
schema = AUTOMATIK_URL
|
||||
|
||||
def __init__(self, ausloesezeiten):
|
||||
"""ausloesezeiten: Funktion() -> {automation_id: datetime oder None}."""
|
||||
self.ausloesezeiten = ausloesezeiten
|
||||
self.states = []
|
||||
|
||||
def passt(self, actor_url):
|
||||
return actor_url == AUTOMATIK_URL
|
||||
|
||||
def zustaende_anmelden(self, states):
|
||||
self.states = states
|
||||
logger.info("Automatik: %d Ausloeser", len(states))
|
||||
|
||||
def zustaende_lesen(self):
|
||||
"""
|
||||
Leerer Text heisst "noch nicht gelaufen" - und gilt jeder Bedingung
|
||||
als unerfuellt.
|
||||
|
||||
Er steht auch dann da, wenn es die Automatik nicht mehr gibt oder sie
|
||||
pausiert ist: der Runner laedt nur die aktiven, und eine pausierte
|
||||
Automatik soll ihre Nachfolger mit anhalten. Genau deshalb wird hier
|
||||
jeder Messwert bei jedem Takt gesetzt und nicht nur der geaenderte -
|
||||
sonst bliebe nach dem Pausieren der letzte bekannte Zeitpunkt stehen
|
||||
und der Nachfolger liefe noch einmal.
|
||||
"""
|
||||
zeiten = self.ausloesezeiten()
|
||||
werte = {}
|
||||
for s in self.states:
|
||||
kennung = ausloeser_kennung(s["state_url"])
|
||||
letzter = zeiten.get(kennung) if kennung is not None else None
|
||||
werte[s["id"]] = letzter.strftime("%Y-%m-%d %H:%M:%S") if letzter else ""
|
||||
return werte
|
||||
|
||||
def senden(self, aktion):
|
||||
raise RuntimeError("Das Geraet \"Automatiken\" kann nichts schalten")
|
||||
|
||||
+2
-2
@@ -22,7 +22,6 @@
|
||||
/volume1/homes/wagner/SolarManager/rainOutput.log
|
||||
/volume1/homes/wagner/SolarManager/wattpilotshell.log
|
||||
/volume1/homes/wagner/SolarManager/wsMQTTbridge.log
|
||||
/volume1/homes/wagner/SolarManager/wecker.log
|
||||
{
|
||||
monthly
|
||||
# Zwei Wochen zurueck. Weiter zurueck hat noch nie jemand gesucht, und
|
||||
@@ -34,7 +33,8 @@
|
||||
maxsize 10M
|
||||
compress
|
||||
# Eine leere Datei zu rotieren bringt nichts ausser vierzehn leeren
|
||||
# Archiven - der Wecker meldet an den meisten Tagen gar nichts.
|
||||
# Archiven, und mehrere dieser Prozesse melden an den meisten Tagen
|
||||
# gar nichts.
|
||||
notifempty
|
||||
# Fehlt eine Datei, ist das kein Fehler: nicht jeder Prozess laeuft
|
||||
# auf jedem System.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "Starting..."
|
||||
# Change the name of the script here
|
||||
SCRIPT_NAME="wecker.py"
|
||||
LOG_FILE="wecker.log"
|
||||
|
||||
# Find the process ID of any running instance of the script
|
||||
PID=$(ps aux | grep "$SCRIPT_NAME" | grep -v grep | awk '{print $2}')
|
||||
|
||||
# If a running instance was found, kill it
|
||||
if [[ -n "$PID" ]]; then
|
||||
echo "Killing process $PID"
|
||||
kill "$PID"
|
||||
fi
|
||||
|
||||
|
||||
# Start the script
|
||||
cd "/volume1/homes/wagner/SolarManager/"
|
||||
# Angehaengt statt ueberschrieben. Dieses Skript laeuft jede Nacht um drei,
|
||||
# und mit "&>" war die Datei danach jedes Mal leer - was der Wecker am
|
||||
# Vortag gemeldet hat, war morgens nicht mehr nachzulesen. Ausserdem braucht
|
||||
# logrotate den Anhaengemodus: es leert die Datei mit copytruncate, waehrend
|
||||
# der Prozess sie offen haelt.
|
||||
/usr/bin/python3 "$SCRIPT_NAME" >> "$LOG_FILE" 2>&1 &
|
||||
@@ -1,129 +0,0 @@
|
||||
import sys
|
||||
import mysql.connector as pymysql
|
||||
from mysql.connector import connect, Error
|
||||
import requests
|
||||
import datetime
|
||||
import time
|
||||
import socket
|
||||
import traceback
|
||||
import konfig
|
||||
|
||||
# MySQL-Datenbankverbindung. Zugangsdaten stehen in config.ini, siehe konfig.py.
|
||||
DB_CONFIG = konfig.datenbank("alarm")
|
||||
|
||||
wled_url = "http://192.168.179.139/json" # URL zur WLED JSON API
|
||||
LOG_SERVER = ("localhost", 13377) # UDP Logserver
|
||||
|
||||
# UDP Logging Funktion
|
||||
def send_log(message):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
log_message = f"{datetime.datetime.now().strftime('%b %d %H:%M:%S')} alarm_scheduler: {message}"
|
||||
sock.sendto(log_message.encode(), LOG_SERVER)
|
||||
sock.close()
|
||||
|
||||
def is_holiday():
|
||||
today_str = datetime.datetime.today().strftime("%Y-%m-%d")
|
||||
|
||||
conn = pymysql.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT COUNT(*) FROM feiertage WHERE datetime = %s"
|
||||
cursor.execute(query, (today_str,))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
return result[0] > 0
|
||||
|
||||
def is_school_holiday():
|
||||
|
||||
today_str = datetime.datetime.today().strftime("%Y-%m-%d")
|
||||
|
||||
conn = pymysql.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT COUNT(*) FROM ferien WHERE date_from <= %s AND date_to >= %s"
|
||||
cursor.execute(query, (today_str, today_str))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
return result[0] > 0
|
||||
|
||||
def get_active_alarms():
|
||||
today = datetime.datetime.today()
|
||||
today_str = today.strftime("%Y-%m-%d")
|
||||
weekday_map = {0: "mo", 1: "di", 2: "mi", 3: "do", 4: "fr", 5: "sa", 6: "so"}
|
||||
weekday_col = weekday_map[today.weekday()]
|
||||
holiday = is_holiday()
|
||||
school_holiday = is_school_holiday()
|
||||
|
||||
conn = pymysql.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = f"""
|
||||
SELECT id, TIME_FORMAT(time_1, '%H:%i:%s'), mode FROM alarmtime
|
||||
WHERE onoff != -1 AND {weekday_col} != -1 AND (wecked IS NULL OR wecked != '{today_str}')
|
||||
"""
|
||||
|
||||
if holiday:
|
||||
query += " AND mode NOT IN ('arbeit', 'schule')"
|
||||
elif school_holiday:
|
||||
query += " AND mode != 'schule'"
|
||||
|
||||
cursor.execute(query)
|
||||
alarms = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return [(alarm[0], alarm[1]) for alarm in alarms]
|
||||
|
||||
def trigger_wled_playlist():
|
||||
data = {"ps": 5} # Beispiel: Playlist 1 aktivieren
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
for attempt in range(5):
|
||||
try:
|
||||
response = requests.post(wled_url, json=data, headers=headers)
|
||||
send_log(f"WLED Response (Attempt {attempt + 1}): {response.text}")
|
||||
try:
|
||||
if response.json().get("success"):
|
||||
return
|
||||
except ValueError:
|
||||
send_log(f"Value error: {response.text}") # Falls die Antwort kein gültiges JSON ist, weitermachen
|
||||
except:
|
||||
send_log(f"No response (Attempt {attempt + 1}): {traceback.print_exc()}")
|
||||
time.sleep(4) # Kurze Wartezeit zwischen Versuchen
|
||||
|
||||
def mark_alarm_as_executed(alarm_id):
|
||||
today_str = datetime.datetime.today().strftime("%Y-%m-%d")
|
||||
conn = pymysql.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "UPDATE alarmtime SET wecked = %s WHERE id = %s"
|
||||
cursor.execute(query, (today_str, alarm_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def main():
|
||||
last_db_check = 0
|
||||
active_alarms = []
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
# Nur alle 5 Minuten die Datenbankabfrage ausführen
|
||||
if now - last_db_check >= 300:
|
||||
active_alarms = get_active_alarms()
|
||||
last_db_check = now
|
||||
|
||||
current_time = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
for alarm_id, alarm_time in active_alarms:
|
||||
if current_time >= alarm_time:
|
||||
send_log(f"Triggering WLED Playlist at {alarm_time}")
|
||||
trigger_wled_playlist()
|
||||
mark_alarm_as_executed(alarm_id)
|
||||
active_alarms = get_active_alarms()
|
||||
|
||||
time.sleep(20) # Überprüfung alle 20 sec
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,76 @@
|
||||
-- ===========================================================================
|
||||
-- Der Wecker als Automatik
|
||||
--
|
||||
-- Ersetzt wecker.py. Das Skript hat dasselbe getan, was der AutoAction-Runner
|
||||
-- ohnehin kann, nur mit eigener Datenbank (`alarm`), eigenem Feiertags- und
|
||||
-- Ferienkalender, eigenem UDP-Log und einer eigenen Endlosschleife:
|
||||
--
|
||||
-- wecker.py Automatik
|
||||
-- --------------------------------- ----------------------------------
|
||||
-- alarmtime.time_1 = 05:50:00 Bedingung "Uhrzeit = 05:50"
|
||||
-- alarmtime.mo..fr != -1 weekdays = 31 (Bit 0..4, Mo bis Fr)
|
||||
-- mode = schule, is_holiday() on_holiday = 0
|
||||
-- mode = schule, is_school_holiday() on_vacation = 0
|
||||
-- alarmtime.wecked = heute cond_met, die steigende Flanke
|
||||
-- POST {"ps": 5} an 192.168.179.139 Kommando "Preset" des Aktors
|
||||
-- MenasHimmel, Parameter preset = 5
|
||||
--
|
||||
-- Preset 5 heisst am Geraet "Wakeup".
|
||||
--
|
||||
-- Von den vier Zeilen in `alarmtime` war nur eine scharf (id 3, 05:50, Modus
|
||||
-- schule); die drei anderen standen auf onoff = -1. Nur diese eine zieht hier
|
||||
-- also um.
|
||||
--
|
||||
-- Einspielen:
|
||||
-- mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < wecker_zu_automatik.sql
|
||||
--
|
||||
-- Dasselbe geht auch ohne SQL: im Dashboard unter "Automatismen", Etage EG,
|
||||
-- neue Automatik anlegen - Bedingung "Uhrzeit um 05:50", Aktion
|
||||
-- "MenasHimmel / Preset" mit Wakeup, Wochentage Mo-Fr, die beiden Haken
|
||||
-- "auch in den Ferien" und "auch an Feiertagen" heraus. Der Runner liest das
|
||||
-- Regelwerk alle 30 Sekunden neu, ein Neustart ist so oder so nicht noetig.
|
||||
-- ===========================================================================
|
||||
|
||||
INSERT INTO automations
|
||||
(name, floor, enabled, window_from, window_to, weekdays,
|
||||
on_vacation, on_holiday, force_once, lockout_secs)
|
||||
SELECT Wecker Magdalena, EG, 1, 00:00:00, 23:59:00, 31, 0, 0, 0, 900
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM automations WHERE name = Wecker Magdalena);
|
||||
|
||||
SET @auto = (SELECT id FROM automations WHERE name = Wecker Magdalena);
|
||||
|
||||
-- Bedingung: state_id 1 ist der gerechnete Messwert "Uhrzeit" des
|
||||
-- Logic-Aktors. Operator = heisst "um", also ab dieser Minute noch
|
||||
-- catchup_minutes lang wahr - ausgeloest wird trotzdem nur einmal, dafuer
|
||||
-- sorgt die Flanke.
|
||||
INSERT INTO automation_conditions
|
||||
(automation_id, group_no, position, state_id, operator, value)
|
||||
SELECT @auto, 0, 0, 1, =, 05:50
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM automation_conditions
|
||||
WHERE automation_id = @auto AND state_id = 1);
|
||||
|
||||
-- Aktion: command_id 20 ist "Preset" des WLED-Aktors MenasHimmel
|
||||
-- (wled://192.168.179.139), Vorlage {"ps":%preset%}.
|
||||
INSERT INTO automation_actions (automation_id, position, command_id)
|
||||
SELECT @auto, 0, 20
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM automation_actions
|
||||
WHERE automation_id = @auto AND command_id = 20);
|
||||
|
||||
SET @aktion = (SELECT id FROM automation_actions
|
||||
WHERE automation_id = @auto AND command_id = 20);
|
||||
|
||||
-- parameter_id 16 ist "preset" zu diesem Kommando.
|
||||
INSERT INTO automation_action_params (action_id, parameter_id, value)
|
||||
VALUES (@aktion, 16, "5")
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value);
|
||||
|
||||
SELECT a.id, a.name, a.weekdays, a.on_vacation, a.on_holiday,
|
||||
c.operator, c.value AS uhrzeit, p.value AS preset
|
||||
FROM automations a
|
||||
JOIN automation_conditions c ON c.automation_id = a.id
|
||||
JOIN automation_actions ak ON ak.automation_id = a.id
|
||||
JOIN automation_action_params p ON p.action_id = ak.id
|
||||
WHERE a.name = "Wecker Magdalena";
|
||||
Reference in New Issue
Block a user