Compare commits

..
3 Commits
Author SHA1 Message Date
adminandClaude Opus 5 32e571099b Ferien und Feiertage dreiwertig, dazu eine Tagessperre
"An Wochenenden und Feiertagen" liess sich bisher gar nicht schreiben.
Die Wochentagsmaske kennt nur Samstag und Sonntag, und ein Feiertag am
Dienstag ist fuer sie eben ein Dienstag; on_holiday war ja/nein und
konnte nur wegnehmen, nie hinzufuegen.

on_vacation und on_holiday tragen deshalb jetzt drei Werte: 0 nie,
1 egal (Vorgabe), 2 zusaetzlich. Der dritte zaehlt wie ein angehakter
Wochentag - Sa+So angehakt und "Feiertage: zusaetzlich" ergibt den
gewuenschten Fall, und mit gar keinem angehakten Tag sogar "nur an
Feiertagen".

In tag_passt() wird erst geoeffnet, dann gesperrt: ein Verbot schlaegt
eine Erweiterung. Wer in den Ferien nie laeuft und an Feiertagen
zusaetzlich, laeuft an einem Feiertag in den Ferien nicht - andersherum
liesse sich "nie" nicht mehr verlassen.

Dazu once_per_day: nach dem Ausloesen bis Mitternacht Ruhe. Fuer alles,
was man hinterher von Hand wieder anders stellt - ein Rollladen, den man
um acht zugezogen hat, soll nicht um neun von selbst wieder auffahren,
weil eine Wolke weiterzieht und die Helligkeitsschwelle ein zweites Mal
steigt. Die Sperrzeit taugte dafuer nicht: sie zaehlt Sekunden und
muesste auf 86400 stehen, womit eine Automatik, die heute um 07:00:05
lief, morgen um 07:00:00 noch gesperrt waere und einen ganzen Tag
ausfiele. Gefragt wird deshalb nach dem Datum von last_run - dieselbe
Funktion, die auch force_once benutzt, nur mit umgekehrtem Vorzeichen.

Die Spalten bleiben TINYINT 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. rahmen_erweitern.sql
setzt nur Kommentare und legt once_per_day an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:07:36 +02:00
adminandClaude Opus 5 09ce5c56c7 Automatiken koennen andere Automatiken ausloesen
Anlass: "zehn Minuten nach dem Wecker den Rollladen hoch, aber nur wenn
es dann schon hell ist". Als Verzoegerung an der Aktion war das nicht zu
haben - die Zusatzbedingung gilt erst zum spaeteren Zeitpunkt, und eine
verzoegerte Aktion, die selbst noch Bedingungen prueft, braeuchte ein
zweites Bedingungssystem neben dem ersten. Der zweite Schritt ist also
eine eigene Automatik; was ihr fehlte, war nur ein Bezug auf die erste.

Den gibt jetzt das gerechnete Geraet "Automatiken", Gegenstueck zum
vorhandenen "Zeitpunkt": jede Automatik ist dort ein Messwert, ihr Wert
der Zeitpunkt der letzten Ausloesung. Der Editor braucht dafuer keine
Zeile - er listet Geraete und deren Messwerte.

Der neue Datentyp `elapsed` verhaelt sich dazu wie `deltatime` zum
Sonnenaufgang: "+ 00:10", "ab + 00:10", "vor + 00:10". Ein Minus gibt es
nicht. Gerechnet wird mit dem echten Abstand statt mit der Uhrzeit
innerhalb des Tages - sonst machte ein Lauf von vorgestern die Bedingung
heute wahr. Die offene Form endet trotzdem am Tagesrand, genau wie
"ab 16:30".

Ausgewertet wird topologisch, Ausloeser vor Nachfolger; nur so wirkt ein
Versatz von null noch im selben Takt. Eine pausierte Automatik haelt ihre
Nachfolger mit an: geladen werden nur die aktiven, und der Transport
liefert fuer alle uebrigen einen leeren Wert.

Die Messwerte pflegt ausloeser_nachfuehren() - fuer JEDE Automatik, auch
fuer pausierte. Sonst loeschte ein Pausieren ueber fk_cond_state
ON DELETE CASCADE die Bedingung des Nachfolgers, still. Geloescht wird
nur, was keine Bedingung mehr benutzt.

automatik_ausloeser.sql legt Datentyp und Geraet an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:51:15 +02:00
adminandClaude Opus 5 1f1700391d Wecker als Automatik statt als eigener Prozess
wecker.py hat genau das getan, was der AutoAction-Runner ohnehin kann -
nur mit allem doppelt: eigener Datenbank alarm, eigenem Feiertags- und
Ferienkalender neben calendar_days, eigenem UDP-Log auf Port 13377,
eigener Endlosschleife und einem Startskript samt naechtlichem Neustart
um drei.

Punkt fuer Punkt hatte der Runner die bessere Fassung schon: die Zeit
als Bedingung "um 05:50" mit Nachholfenster statt eines Vergleichs im
20-Sekunden-Takt, weekdays als Bitmaske statt sieben Spalten,
on_holiday/on_vacation gegen calendar_days statt gegen einen zweiten
Kalender, die steigende Flanke statt einer wecked-Spalte, und den
Versand mit einem Faden je Geraet statt fuenf HTTP-Versuchen im Abstand
von vier Sekunden.

Umgezogen ist die eine Weckzeit, die scharf war: 05:50, Mo-Fr, nicht in
den Ferien, nicht an Feiertagen, WLED-Preset 5 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.

Die Datenbank alarm und der Abschnitt [alarm] in der config.ini bleiben
vorerst stehen - sie liest nur niemand mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:12:20 +02:00
10 changed files with 747 additions and 174 deletions
+38 -8
View File
@@ -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
View File
@@ -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
```
+251 -8
View File
@@ -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()
+54
View File
@@ -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';
+66
View File
@@ -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');
+89 -1
View File
@@ -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
View File
@@ -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.
-24
View File
@@ -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 &
-129
View File
@@ -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()
+76
View File
@@ -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";