Compare commits

...
6 Commits
Author SHA1 Message Date
adminandClaude Opus 5 8340e8356e Runner: Kalender am Vorabend fuer den folgenden Tag pruefen
Mit next_day = 1 gelten Wochentage, Ferien und Feiertage fuer morgen
(gemeinter_tag). kalender() merkt sich dafuer heute und morgen und haelt
einen fehlgeschlagenen Abruf nicht mehr den ganzen Tag fest. Uhrzeit,
Zeitfenster und once_per_day bleiben beim heutigen Tag. vorabend.sql legt
die Spalte an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:33:59 +02:00
adminandClaude Opus 5 8c62455b2c Skoda: mehrere Schluessel, Wallbox-Ladeverlauf, Stecker als 1/0
- gatherSkodaData.py liest bis zu drei API-Schluessel und nutzt sie reihum;
  das Kontingent gilt gemessen fuers ganze Konto, daher feste Aufteilung
  14 Abruf / 4 Befehle / 2 Reserve
- nach einem Befehl aus der Weboberflaeche wird einmal ausser der Reihe
  nachgesehen
- Wallbox-Verlauf waehrend einer Ladung nach skoda_ladepunkte, dazu
  skoda_ladepunkte_nachtragen.py fuer Ladungen aus EnergyFlow
- evPlug ist jetzt ein Wahrheitswert: bool("no car") war immer True
- skoda.conf.example beschreibt das gemeinsame Format

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:29:57 +02:00
adminandClaude Opus 5 153658df81 Laderate im Log in Watt statt in Prozent angeben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:29:57 +02:00
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
17 changed files with 1702 additions and 314 deletions
+38 -8
View File
@@ -85,10 +85,8 @@ flowchart TB
BROKER{{"<b>MQTT-Broker</b>"}} BROKER{{"<b>MQTT-Broker</b>"}}
SOLARLOG[("<b>solarLog</b><br/><small>Verlauf</small>")] SOLARLOG[("<b>solarLog</b><br/><small>Verlauf</small>")]
HOMEMESH[("<b>homeMesh</b><br/><small>Geräte · Automatiken</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>"] 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>"] 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>"] DISCOVERY["<b>device_discovery.py</b><br/><small>Web · von Hand gestartet</small>"]
AJAX["<b>ajax/*.php</b><br/><small>Web</small>"] AJAX["<b>ajax/*.php</b><br/><small>Web</small>"]
@@ -103,10 +101,8 @@ flowchart TB
RUNNER -.->|"HTTP · WLED · Tahoma"| HAUS RUNNER -.->|"HTTP · WLED · Tahoma"| HAUS
BROKER -.->|"Gartenwasser/…/set"| VENTILE BROKER -.->|"Gartenwasser/…/set"| VENTILE
ALARM --> WECKER
FERIEN --> KALENDER FERIEN --> KALENDER
KALENDER -->|"calendar_days"| HOMEMESH KALENDER -->|"calendar_days"| HOMEMESH
WECKER -.->|HTTP| HAUS
BROKER -->|"homeassistant/#"| DISCOVERY BROKER -->|"homeassistant/#"| DISCOVERY
HAUS -->|"mDNS · Tahoma"| DISCOVERY HAUS -->|"mDNS · Tahoma"| DISCOVERY
@@ -121,8 +117,8 @@ flowchart TB
classDef skript fill:#fff6e5,stroke:#d0a548 classDef skript fill:#fff6e5,stroke:#d0a548
classDef speicher fill:#eaf5ee,stroke:#6fa981 classDef speicher fill:#eaf5ee,stroke:#6fa981
classDef geraet fill:#eef4fb,stroke:#7f9dc0 classDef geraet fill:#eef4fb,stroke:#7f9dc0
class RUNNER,WECKER,DISCOVERY,AJAX,BROWSER,KALENDER skript class RUNNER,DISCOVERY,AJAX,BROWSER,KALENDER skript
class BROKER,SOLARLOG,HOMEMESH,ALARM speicher class BROKER,SOLARLOG,HOMEMESH speicher
class HAUS,VENTILE,FERIEN geraet 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` | | `homeassistant/#` | die Geräte selbst | `device_discovery.py` |
| **`solarLog`** | `solarManager.py` | `ajax/*.php`, `gatherWaterData` | | **`solarLog`** | `solarManager.py` | `ajax/*.php`, `gatherWaterData` |
| **`homeMesh`** | `device_discovery.py`, Runner, Web-Editor | Runner, `ajax/*.php` | | **`homeMesh`** | `device_discovery.py`, Runner, Web-Editor | Runner, `ajax/*.php` |
| **`alarm`** | Web-Oberfläche | `wecker.py` |
## Was wann startet ## Was wann startet
@@ -163,7 +158,6 @@ von Hand, wenn sich am Bestand etwas geändert hat.
| `gatherRainData.py` | dito | | `gatherRainData.py` | dito |
| `wsMQTTbridge.py` | `startMQTTbridge.sh` | | `wsMQTTbridge.py` | `startMQTTbridge.sh` |
| `wattpilot_bruecke.py` | `startWattpilotMQTT.sh` | | `wattpilot_bruecke.py` | `startWattpilotMQTT.sh` |
| `wecker.py` | `startWecker.sh` |
| `autoActions/fetch_calendar.py` | Cronjob, einmal im Jahr | | `autoActions/fetch_calendar.py` | Cronjob, einmal im Jahr |
| `device_discovery.py` (Web-Repo) | von Hand | | `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 startet — beim Runner ist das wichtig, zwei Instanzen würden jedes Kommando
doppelt schicken. 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 ## Konfiguration
Zugangsdaten und Standort stehen in `config.ini` (Vorlage: Zugangsdaten und Standort stehen in `config.ini` (Vorlage:
+184 -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. sondern so viele Zeilen, wie das Gerät Parameter hat.
Die Rahmenbedingungen (Wochentage, Zeitfenster, Ferien, Feiertage) sagen, wann 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 ## 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, halbe Stunde nach Sonnenaufgang, `ab - 00:30` ab einer halben Stunde davor,
`vor + 00:30` bis eine halbe Stunde danach. `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 Über den Tagesrand wird gerechnet, nicht abgeschnitten: „sechs Stunden vor
Sonnenaufgang" landet am Vorabend, und das ist so gewollt — abgeschnitten Sonnenaufgang" landet am Vorabend, und das ist so gewollt — abgeschnitten
wären solche Angaben gar nicht mehr formulierbar. Verglichen wird die Uhrzeit wären solche Angaben gar nicht mehr formulierbar. Verglichen wird die Uhrzeit
@@ -111,6 +114,68 @@ und `vor` verschiebt sich der wahre Bereich entsprechend mit.
`force_once` („am Ende des Zeitraums auf jeden Fall ausführen") greift, wenn `force_once` („am Ende des Zeitraums auf jeden Fall ausführen") greift, wenn
das Fenster zugeht und in diesem Fenster noch nichts passiert ist. 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.
### Am Vorabend
Mit `next_day = 1` gelten Wochentage, Ferien und Feiertage für **morgen**
(`gemeinter_tag()`). Gedacht für alles, was abends für den nächsten Tag
geschieht: „Kinderrollos zu, wenn morgen Schule ist“ heißt dann Mo–Fr,
Ferien nie, Feiertage nie — genau wie der Wecker. Mit dem heutigen Tag ließ
sich das nur annähern (So–Do, heute keine Ferien) und ging am letzten
Ferientag, am Abend vor einem Feiertag und am Abend eines Feiertags daneben.
Nur der Rahmen verschiebt sich. Uhrzeit, Zeitfenster und „nur einmal am Tag“
bleiben beim heutigen Tag, denn die Automatik läuft ja heute Abend. Die
Spalte legt `vorabend.sql` 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 ## Sperrzeit
Die Flanke allein schützt nicht gegen einen Messwert, der um die Schwelle Die Flanke allein schützt nicht gegen einen Messwert, der um die Schwelle
@@ -140,6 +205,108 @@ davon.
`force_once` ist von der Sperre nicht betroffen: es greift nur, wenn im `force_once` ist von der Sperre nicht betroffen: es greift nur, wenn im
Fenster gar nichts gelaufen ist — dann ist auch keine Sperre aktiv. 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 ## Transporte
Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`: Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`:
@@ -151,8 +318,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 | | `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 | | Tahoma | Statusname (`core:ClosureState`), gepollt | `exec/apply` an die Box |
| `Logic` | gerechnet: Uhrzeit, Datum, Sonne | – | | `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()`. Klasse dazu; sie braucht `passt()`, `zustaende_lesen()` und `senden()`.
Tahoma ist der einzige, der nicht am URL-Schema erkannt wird, sondern an der Tahoma ist der einzige, der nicht am URL-Schema erkannt wird, sondern an der
@@ -217,10 +385,22 @@ Parameter dort keine eigene URL — ihr Name *ist* der Platzhalter.
```bash ```bash
cp config.ini.example config.ini # ausfüllen: Datenbank, MQTT, Tahoma 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 fetch_calendar.py # Feiertage und Ferien holen
python3 autoaction_runner.py --once --dry-run --verbose # Probelauf 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 `--dry-run` schaltet nichts, protokolliert aber jedes Kommando, das geschickt
würde. `--once` macht einen einzigen Durchlauf. würde. `--once` macht einen einzigen Durchlauf.
@@ -237,6 +417,8 @@ nicht im Web-Verzeichnis:
├── autoaction_runner.py ├── autoaction_runner.py
├── transports.py ├── transports.py
├── fetch_calendar.py ├── fetch_calendar.py
├── automatik_ausloeser.sql einmalig, siehe Einrichten
├── rahmen_erweitern.sql einmalig, siehe Einrichten
└── config.ini Zugangsdaten, nicht im Git └── config.ini Zugangsdaten, nicht im Git
``` ```
+292 -19
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" Vorzeichen des Versatzes: "+ 00:30" eine halbe Stunde danach, ">=- 00:30"
ab einer halben Stunde davor, "<+ 00:30" bis eine halbe Stunde danach. 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. Tabellen siehe homeMesh_automations.sql, Konfiguration siehe config.ini.example.
""" """
@@ -67,8 +76,10 @@ import pymysql
import requests import requests
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from transports import (HTTPTransport, LogicTransport, MQTTTransport, from transports import (AUTOMATIK_URL, AutomatikTransport, HTTPTransport,
TahomaTransport, WLEDTransport, ist_topic) LogicTransport, MQTTTransport, TahomaTransport,
WLEDTransport, ausloeser_kennung, ausloeser_url,
ist_topic)
logger = logging.getLogger("autoaction") logger = logging.getLogger("autoaction")
@@ -422,6 +433,37 @@ def bedingung_erfuellt(bedingung, state, wert, jetzt, fenster=NACHHOLFENSTER):
if op.startswith("<"): return jetzt_m < ziel if op.startswith("<"): return jetzt_m < ziel
return im_nachholfenster(jetzt_m, ziel, fenster) 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"): if typ in ("date", "datetime"):
wandeln = als_datum if typ == "date" else als_zeitpunkt wandeln = als_datum if typ == "date" else als_zeitpunkt
ist_d, soll_d = wandeln(wert), wandeln(soll) ist_d, soll_d = wandeln(wert), wandeln(soll)
@@ -469,12 +511,62 @@ def im_zeitfenster(jetzt, von, bis):
return a <= m <= b if a <= b else (m >= a or m <= b) return a <= m <= b if a <= b else (m >= a or m <= b)
def tag_passt(automatik, jetzt, kalender): #: Ferien und Feiertage sind dreiwertig, nicht ja/nein.
if not automatik["weekdays"] & (1 << jetzt.weekday()): NIE, EGAL, ZUSAETZLICH = 0, 1, 2
def gemeinter_tag(automatik, jetzt):
"""
Fuer welchen Tag gelten Wochentage, Ferien und Feiertage?
Normalerweise fuer heute. Mit `next_day` fuer morgen - die Vorabend-Form:
"Kinderrollos zu, wenn morgen Schule ist" heisst Mo-Fr, Ferien nie,
Feiertage nie, und der Runner schaut dafuer auf den folgenden Tag. Mit
dem heutigen Tag liess sich das nur annaehern (So-Do, heute keine
Ferien), und das ging am letzten Ferientag, am Abend vor einem Feiertag
und am Abend eines Feiertags daneben.
Nur der Rahmen verschiebt sich. Uhrzeit, Zeitfenster und "einmal am Tag"
bleiben beim heutigen Tag - die Automatik laeuft ja heute Abend. Ein
Zeitfenster ueber Mitternacht meint nach Mitternacht deshalb schon den
uebernaechsten Tag; fuer eine Vorabend-Regel ist das kein sinnvoller Fall.
"""
heute = jetzt.date()
return heute + timedelta(days=1) if automatik.get("next_day") else heute
def tag_passt(automatik, tag, kalender):
"""
Faellt der Tag in den Rahmen der Automatik? `tag` ist heute oder morgen,
siehe gemeinter_tag(); `kalender` gehoert zu genau diesem Tag.
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 << tag.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 return False
if kalender["feiertag"] and not automatik["on_holiday"]: if kalender["feiertag"] and automatik["on_holiday"] == NIE:
return False return False
if kalender["ferien"] and not automatik["on_vacation"]: if kalender["ferien"] and automatik["on_vacation"] == NIE:
return False return False
return True return True
@@ -647,8 +739,10 @@ class Runner:
self.war_aktiv = {} # automation_id -> war im Zeitfenster self.war_aktiv = {} # automation_id -> war im Zeitfenster
self.lief_im_fenster = {} # automation_id -> hat im Fenster ausgeloest self.lief_im_fenster = {} # automation_id -> hat im Fenster ausgeloest
self._sonne = (None, "00:00", "00:00") # (datum, aufgang, untergang) self._sonne = (None, "00:00", "00:00") # (datum, aufgang, untergang)
self._kalender = (None, {"feiertag": False, "ferien": False}) self._kalender = {} # datum -> {"feiertag": .., "ferien": ..}
self._letzte_saeuberung = None self._letzte_saeuberung = None
self.reihenfolge = [] # automation_id, Ausloeser vor Nachfolger
self.ausloeser_states = {} # automation_id -> state_id des Ausloesers
self.versand = Versand() self.versand = Versand()
self.sammler = Sammler() self.sammler = Sammler()
@@ -661,6 +755,7 @@ class Runner:
config.text("tahoma", "token"), config.text("tahoma", "token"),
config.zahl("tahoma", "timeout", 10), self.dry_run), config.zahl("tahoma", "timeout", 10), self.dry_run),
LogicTransport(self.sonnenzeiten), LogicTransport(self.sonnenzeiten),
AutomatikTransport(self.ausloesezeiten),
] ]
self.regelwerk = None self.regelwerk = None
@@ -711,6 +806,10 @@ class Runner:
return self.transport_fuer(state["actor_url"]) return self.transport_fuer(state["actor_url"])
def regelwerk_laden(self): 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) self.regelwerk = Regelwerk.laden(self.db)
# Welche Geraete Position und Neigung zusammen koennen. Nur die haben # Welche Geraete Position und Neigung zusammen koennen. Nur die haben
@@ -746,6 +845,142 @@ class Runner:
# noch einmal hineingeschrieben werden. # noch einmal hineingeschrieben werden.
self.geschrieben.setdefault(s["id"], s["current_value"]) 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 -------------------------------------------------------- # --- Umgebung --------------------------------------------------------
def sonnenzeiten(self): def sonnenzeiten(self):
@@ -777,21 +1012,30 @@ class Runner:
return "%02d:%02d" % (minute // 60 % 24, minute % 60) return "%02d:%02d" % (minute // 60 % 24, minute % 60)
return str(wert)[:5] return str(wert)[:5]
def kalender(self): def kalender(self, tag):
"""Ferien und Feiertage von heute, einmal je Tag geholt.""" """
heute = date.today() Ferien und Feiertage eines Tages, je Tag einmal geholt.
if self._kalender[0] == heute:
return self._kalender[1] Gefragt wird nach heute und - fuer Vorabend-Regeln - nach morgen.
Gemerkt werden deshalb beide; was vor heute liegt, fliegt raus.
"""
if tag in self._kalender:
return self._kalender[tag]
stand = {"feiertag": False, "ferien": False} stand = {"feiertag": False, "ferien": False}
try: try:
with self.db.cursor() as c: with self.db.cursor() as c:
c.execute("SELECT holiday, vacation FROM calendar_days WHERE date = %s", (heute,)) c.execute("SELECT holiday, vacation FROM calendar_days WHERE date = %s", (tag,))
zeile = c.fetchone() zeile = c.fetchone()
if zeile: if zeile:
stand = {"feiertag": bool(zeile["holiday"]), "ferien": bool(zeile["vacation"])} stand = {"feiertag": bool(zeile["holiday"]), "ferien": bool(zeile["vacation"])}
except Exception as fehler: except Exception as fehler:
# Nicht merken: beim naechsten Durchlauf noch einmal fragen,
# statt den ganzen Tag mit "kein Feiertag" weiterzurechnen.
logger.warning("Kalender nicht lesbar: %r", fehler) logger.warning("Kalender nicht lesbar: %r", fehler)
self._kalender = (heute, stand) return stand
heute = date.today()
self._kalender = {t: s for t, s in self._kalender.items() if t >= heute}
self._kalender[tag] = stand
return stand return stand
# --- Werte ----------------------------------------------------------- # --- Werte -----------------------------------------------------------
@@ -820,7 +1064,8 @@ class Runner:
""" """
neu = {} neu = {}
for transport in self.transporte: for transport in self.transporte:
billig = isinstance(transport, (MQTTTransport, LogicTransport)) billig = isinstance(transport, (MQTTTransport, LogicTransport,
AutomatikTransport))
if billig or auch_geraete: if billig or auch_geraete:
neu.update(transport.zustaende_lesen()) neu.update(transport.zustaende_lesen())
neu.update(self.sammler.abholen()) neu.update(self.sammler.abholen())
@@ -906,6 +1151,13 @@ class Runner:
logger.warning("Protokoll nicht schreibbar: %r", fehler) logger.warning("Protokoll nicht schreibbar: %r", fehler)
automatik["last_run"] = datetime.now() automatik["last_run"] = datetime.now()
self.lief_im_fenster[automatik["id"]] = True 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): def ergebnisse_verbuchen(self):
""" """
@@ -941,10 +1193,13 @@ class Runner:
def durchlauf(self, fenster=NACHHOLFENSTER): def durchlauf(self, fenster=NACHHOLFENSTER):
jetzt = datetime.now() jetzt = datetime.now()
kalender = self.kalender()
for automatik in self.regelwerk.automatiken.values(): for automation_id in self.reihenfolge:
aktiv = (tag_passt(automatik, jetzt, kalender) automatik = self.regelwerk.automatiken.get(automation_id)
if automatik is None:
continue
tag = gemeinter_tag(automatik, jetzt)
aktiv = (tag_passt(automatik, tag, self.kalender(tag))
and im_zeitfenster(jetzt, automatik["window_from"], automatik["window_to"])) and im_zeitfenster(jetzt, automatik["window_from"], automatik["window_to"]))
vorher_aktiv = self.war_aktiv.get(automatik["id"], aktiv) vorher_aktiv = self.war_aktiv.get(automatik["id"], aktiv)
@@ -954,7 +1209,10 @@ class Runner:
erfuellt = gruppen_erfuellt(automatik, self.regelwerk, self.werte, erfuellt = gruppen_erfuellt(automatik, self.regelwerk, self.werte,
jetzt, fenster) jetzt, fenster)
if erfuellt and not automatik["cond_met"]: 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", logger.debug("%s: Flanke faellt in die Sperrzeit, uebersprungen",
automatik["name"]) automatik["name"])
else: else:
@@ -1004,6 +1262,21 @@ class Runner:
@staticmethod @staticmethod
def lief_heute(automatik, jetzt): 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") letzter = automatik.get("last_run")
return bool(letzter) and letzter.date() == jetzt.date() 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 Funkart des Geraets, deshalb wird dort nicht danach
entschieden, sondern an der Box-Kennung in der URL entschieden, sondern an der Box-Kennung in der URL
Logic das gerechnete Geraet "Zeitpunkt" (Uhrzeit, Datum, Sonne) 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 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` 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. Generation; die aelteren koennen kein MQTT und bleiben ganz bei HTTP.
Alle liegen in einer Datei statt in einem Paket wie bei deviceDiscovery: es 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. hier auf einen Blick, was dafuer zu tun ist.
Jeder Transport hat zwei Haelften: Jeder Transport hat zwei Haelften:
@@ -707,3 +710,88 @@ class LogicTransport(Transport):
def senden(self, aktion): def senden(self, aktion):
raise RuntimeError("Das Geraet \"Zeitpunkt\" kann nichts schalten") 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")
+41
View File
@@ -0,0 +1,41 @@
-- ===========================================================================
-- Vorabend: Wochentage, Ferien und Feiertage fuer den folgenden Tag.
--
-- Einspielen:
-- mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < vorabend.sql
--
-- Danach den Runner neu starten (startSolarServer.sh) - Daten laedt er
-- selbst nach, neuen Code nicht.
--
-- Gebraucht fuer alles, was abends fuer den naechsten Tag geschieht:
-- "Kinderrollos zu, wenn morgen Schule ist". Mit dem heutigen Tag liess sich
-- das nur annaehern (So-Do, heute keine Ferien) und ging am letzten
-- Ferientag, am Abend vor einem Feiertag und am Abend eines Feiertags
-- daneben. Mit next_day = 1 heisst dieselbe Regel einfach Mo-Fr, Ferien nie,
-- Feiertage nie - wie der Wecker.
--
-- Nur der Rahmen verschiebt sich; Uhrzeit, Zeitfenster und "einmal am Tag"
-- bleiben beim heutigen Tag. Siehe gemeinter_tag() im Runner.
-- ===========================================================================
-- 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 = 'next_day');
SET @sql := IF(@vorhanden = 0,
'ALTER TABLE automations ADD COLUMN next_day TINYINT(1) NOT NULL DEFAULT 0
COMMENT ''Wochentage, Ferien und Feiertage gelten fuer morgen (Vorabend)''
AFTER on_holiday',
'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 = 'next_day';
+2 -2
View File
@@ -284,13 +284,13 @@ async def get_runtime_data(estProduction, sunset) -> InverterData:
eval(regs.setChargeLimits).value = 1 #set charge rate: bit1 set discharge rate: bit 2 eval(regs.setChargeLimits).value = 1 #set charge rate: bit1 set discharge rate: bit 2
eval(regs.limitChrgRate).write() eval(regs.limitChrgRate).write()
eval(regs.setChargeLimits).write() eval(regs.setChargeLimits).write()
_LOGGER.debug("Increasing charge rate to buffer feed peaks to " + str(int((ret.crgMaxPct*100+200)/2.04)) + "%") _LOGGER.debug("Increasing charge rate to buffer feed peaks to " + str(int(ret.crgMaxPct*100+500)) + "W")
if datetime.datetime.now().hour >= int(sunset.strftime('%H'))-4 and ret.soc < 70 and ret.crgMaxPct < 90: if datetime.datetime.now().hour >= int(sunset.strftime('%H'))-4 and ret.soc < 70 and ret.crgMaxPct < 90:
eval(regs.limitChrgRate).value = int((10000)/2.04) eval(regs.limitChrgRate).value = int((10000)/2.04)
eval(regs.setChargeLimits).value = 1 #set charge rate: bit1 set discharge rate: bit 2 eval(regs.setChargeLimits).value = 1 #set charge rate: bit1 set discharge rate: bit 2
eval(regs.limitChrgRate).write() eval(regs.limitChrgRate).write()
eval(regs.setChargeLimits).write() eval(regs.setChargeLimits).write()
_LOGGER.debug("Increasing charge rate because of too low SOC " + str(int((10000)/2.04)) + "%") _LOGGER.debug("Increasing charge rate because of too low SOC " + str(int(10000)) + "W")
return ret return ret
#print(ret.p_wr) #print(ret.p_wr)
#print("-----") #print("-----")
+388 -83
View File
@@ -18,13 +18,24 @@ Parkposition und Klima. Spec: https://public.api.connect.skoda-auto.cz/docs
Zwei Dinge unterscheiden das Modul von den uebrigen Sammlern: Zwei Dinge unterscheiden das Modul von den uebrigen Sammlern:
Es ist eine Cloud-API mit knappem Kontingent, kein Geraet im Haus: laut Es ist eine Cloud-API mit knappem Kontingent, kein Geraet im Haus:
Doku 20 Anfragen je Stunde und Schluessel, ausdruecklich vorlaeufig. Der 20 Anfragen je Stunde, ausdruecklich vorlaeufig. Die Doku schreibt "je
Manager ruft gatherData() im 3-Sekunden-Takt auf, angefragt wird aber nur, Schluessel", gemessen gilt der Zaehler aber fuer alle Schluessel dieses
wenn das eigene Intervall abgelaufen ist - alle vier Minuten waehrend des Kontos zusammen - siehe die Rechnung bei _LIMIT. Ein Abruf alle drei
Ladens, stuendlich beim Parken. Darueber liegen eine eigene Stundenbilanz Minuten ist damit das Aeusserste, und die Intervalle unten sind daran
und die RateLimit-Header der Antwort, die als massgebliche Quelle gelten. bemessen.
Zieht Skoda das Kontingent enger, folgt das Modul von selbst.
In skoda.conf duerfen trotzdem bis zu drei Schluessel stehen (API_KEY,
API_KEY2, API_KEY3), und sie werden reihum benutzt. Das bringt keinen
dichteren Abruf, sondern Ausfallsicherheit: laeuft einer ab oder wird er
in der App widerrufen, tragen die anderen weiter, statt dass das Fahrzeug
aus der Anzeige faellt, bis es jemand im Log bemerkt.
Der Manager ruft gatherData() im 3-Sekunden-Takt auf, angefragt wird aber
nur, wenn das eigene Intervall abgelaufen und ein Schluessel frei ist.
Ueber allem liegen die RateLimit-Header der Antwort, die als massgebliche
Quelle gelten: zieht Skoda das Kontingent enger, folgt das Modul von
selbst.
Ladebeginn und Ladeende stossen einen Abruf ausser der Reihe an. Die Ladebeginn und Ladeende stossen einen Abruf ausser der Reihe an. Die
Wallbox merkt beides sofort, das Fahrzeug erst beim naechsten Abruf - und Wallbox merkt beides sofort, das Fahrzeug erst beim naechsten Abruf - und
@@ -32,6 +43,11 @@ Zwei Dinge unterscheiden das Modul von den uebrigen Sammlern:
wertvollsten: der Ladestand davor und danach traegt die wertvollsten: der Ladestand davor und danach traegt die
Kapazitaetsrechnung. Kapazitaetsrechnung.
Ebenso ein Befehl aus der Weboberflaeche. Wer auf "Klimatisierung starten"
drueckt, will nicht bis zum naechsten regulaeren Abruf warten, um zu sehen,
ob es geklappt hat - beim Parken waeren das 20 Minuten. _NACH_BEFEHL
Sekunden danach wird deshalb einmal nachgesehen.
Die Antwort kann lange dauern. Deshalb blockiert gatherData() nie: der Die Antwort kann lange dauern. Deshalb blockiert gatherData() nie: der
Abruf laeuft als Hintergrund-Task, zurueckgegeben wird immer sofort der Abruf laeuft als Hintergrund-Task, zurueckgegeben wird immer sofort der
zuletzt bekannte Stand. Eine haengende Cloud-Verbindung kann den zuletzt bekannte Stand. Eine haengende Cloud-Verbindung kann den
@@ -48,7 +64,7 @@ import collections
import time import time
import datetime import datetime
from typing import List, Optional from typing import List, Optional
from dataclasses import dataclass, field from dataclasses import dataclass
import mysql.connector as mc import mysql.connector as mc
@@ -69,39 +85,73 @@ _URL = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/"
# gelesen, ein Neustart des Managers ist dafuer nicht noetig. # gelesen, ein Neustart des Managers ist dafuer nicht noetig.
_KONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skoda.conf") _KONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skoda.conf")
# Das Kontingent ist der enge Punkt dieser Schnittstelle: laut Doku # Nach einem Befehl aus der Weboberflaeche einmal ausser der Reihe nachsehen.
# 20 Anfragen je Stunde und Schluessel, ausdruecklich nicht endgueltig. Ein # ajax/skodaCmd.php beruehrt dazu eine Datei, sobald die API den Befehl mit
# Abruf alle drei Minuten ist damit schon das Aeusserste. Alle Intervalle # 202 angenommen hat; deren Aenderungszeit ist der Zeitpunkt des Befehls. Mehr
# unten sind daran bemessen; _BUDGET zieht zusaetzlich eine eigene Grenze # braucht es nicht: die beiden Prozesse teilen sich sonst nichts, und ein stat
# knapp darunter, damit fuer einen Fehlversuch noch Luft bleibt. # alle drei Sekunden ist billiger als jede Verbindung, die dafuer offen bleiben
# muesste - und faellt der Webserver aus, faellt nichts mit ihm aus.
#
# Die Liste muss zu ZAEHLER_ORTE in ajax/skodaCmd.php passen: dieselben zwei
# Verzeichnisse in derselben Reihenfolge, das erste, das der Webserver
# beschreiben darf, gewinnt.
_ANSTOSS_ORTE = ("/volume1/web/smart/tiles/.skoda_abruf",
"/var/services/tmp/smart-tiles/.skoda_abruf")
# Wie lange nach dem Befehl gefragt wird. Die API nimmt ihn mit 202 an und
# reicht ihn ans Fahrzeug weiter; bis der Zustand nachzieht, vergehen
# Sekunden. Zu frueh gefragt liefert den alten Stand und gibt den Abruf
# umsonst aus - das Kontingent traegt keine zweite Runde.
_NACH_BEFEHL = 30.0
# Das Kontingent ist der enge Punkt dieser Schnittstelle: 20 Anfragen je
# Stunde, ausdruecklich nicht endgueltig.
#
# Die Doku schreibt "je Schluessel". Das stimmt nicht. Am 12.09.2026 drei
# Anfragen hintereinander, jede mit einem anderen Schluessel desselben Kontos,
# innerhalb einer Sekunde:
#
# CMD_API_KEY RateLimit-Remaining 18, Reset 3547
# API_KEY3 RateLimit-Remaining 17, Reset 3546
# CMD_API_KEY RateLimit-Remaining 16, Reset 3546
#
# Ein Zaehler, ein Fenster, fuer alle Schluessel zusammen. Ob die API nach
# Konto, nach Fahrzeug oder nach Absender zaehlt, laesst sich von hier nicht
# unterscheiden - fuer diese Anlage laeuft es auf dasselbe hinaus.
#
# Mehrere Schluessel bringen deshalb keinen dichteren Abruf. Sie bleiben
# trotzdem sinnvoll: laeuft einer ab oder wird er in der App widerrufen,
# tragen die anderen weiter (siehe _Schluessel.sperre).
# #
# Fehlerantworten ab 500 zaehlen laut Doku mit, 401 und 403 nicht. # Fehlerantworten ab 500 zaehlen laut Doku mit, 401 und 403 nicht.
_LIMIT = 20 # bekanntes Kontingent je Stunde _LIMIT = 20 # gemessenes Kontingent je Stunde, alle Schluessel
_MAX_KEYS = 3 # mehr werden aus skoda.conf nicht gelesen
# Die API begrenzt je Schluessel. Steht in skoda.conf ein eigener CMD_API_KEY, # Was davon dieses Modul nehmen darf. Der Rest ist fuer die Weboberflaeche,
# schickt die Weboberflaeche ihre Befehle darueber und nimmt diesem Modul # die ihre Befehle ueber dieselbe Grenze schickt (4 je Stunde, siehe
# nichts weg - dann darf es fast das ganze Kontingent nutzen. Ohne zweiten # ajax/skodaCmd.php), und zwei Anfragen Reserve. Die beiden Prozesse haben
# Schluessel teilen sich beide eines, und weil sie in getrennten Prozessen # keinen gemeinsamen Zaehler, deshalb die feste Aufteilung:
# laufen und keinen gemeinsamen Zaehler haben, ist es fest aufgeteilt:
# #
# eigener CMD_API_KEY nur API_KEY # dieses Modul 14 / Stunde
# dieses Modul 18 / Stunde 14 / Stunde # Befehle 4 / Stunde (ajax/skodaCmd.php)
# Befehle 15 / Stunde 4 / Stunde (ajax/skodaCmd.php) # Reserve 2 / Stunde
# Reserve 2 / Stunde 2 / Stunde
# #
# Umgestellt wird von selbst, sobald der zweite Schluessel auftaucht - von # Ein eigener CMD_API_KEY aendert daran nichts mehr - er trennt, wer womit
# Hand ist hier nichts nachzuziehen. # fragt, nicht wie viel. Sinn hat er trotzdem: widerrufen laesst er sich
_BUDGET_ALLEIN = 18 # einzeln, ohne den Abruf mitzunehmen.
_BUDGET_GETEILT = 14 _BUDGET = 14
_FENSTER = 3600 # Bezugszeitraum des Kontingents _FENSTER = 3600 # Bezugszeitraum des Kontingents
# Abrufintervalle in Sekunden. # Abrufintervalle in Sekunden. 14 Anfragen je Stunde sind eine alle 257 s -
# so dicht wie beim Laden gefragt wird, und dichter geht nicht. Beim Parken
# weit darueber, weil sich dort ohnehin nichts aendert und jede Anfrage, die
# dort verbraucht wird, bei der naechsten Fahrt fehlt.
_I_MIN = 200 # harte Untergrenze, egal was sonst gilt _I_MIN = 200 # harte Untergrenze, egal was sonst gilt
_I_LADEN = 240 # laedt gerade - 15 Abrufe je Stunde _I_LADEN = 260 # laedt gerade - knapp 14 Abrufe je Stunde
_I_FAHRT = 300 # unterwegs _I_FAHRT = 260 # unterwegs
_I_GESTECKT = 900 # Kabel steckt, laedt aber nicht _I_GESTECKT = 300 # Kabel steckt, laedt aber nicht
_I_AKTIV = 1800 # steht, hat sich zuletzt aber noch geruehrt _I_AKTIV = 600 # steht, hat sich zuletzt aber noch geruehrt
_I_RUHE = 3600 # seit Stunden unveraendert _I_RUHE = 1200 # seit Stunden unveraendert
_HEARTBEAT = 3600 # auch ohne Aenderung so oft eine Zeile schreiben _HEARTBEAT = 3600 # auch ohne Aenderung so oft eine Zeile schreiben
_RUHE_AB = 7200 # ab so langer Unveraendertheit gilt _I_RUHE _RUHE_AB = 7200 # ab so langer Unveraendertheit gilt _I_RUHE
@@ -197,8 +247,10 @@ class SkodaData:
error:int = 0 # aufeinanderfolgende Fehlversuche error:int = 0 # aufeinanderfolgende Fehlversuche
httpStatus:int = 0 httpStatus:int = 0
apiErrors:str = "" # Fehlerliste der Antwort, kommagetrennt apiErrors:str = "" # Fehlerliste der Antwort, kommagetrennt
rlRemaining:int = -1 # Restkontingent laut RateLimit-Header keys:int = 0 # nutzbare Schluessel, nur zur Anzeige
keyExpires:Optional[datetime.datetime] = None rlRemaining:int = -1 # Restkontingent laut RateLimit-Header,
# ueber alle Schluessel zusammengezaehlt
keyExpires:Optional[datetime.datetime] = None # der zuerst ablaeuft
lastOk:float = 0.0 # Zeitpunkt der letzten guten Antwort lastOk:float = 0.0 # Zeitpunkt der letzten guten Antwort
alter:float = 0.0 # Sekunden seit der letzten guten Antwort alter:float = 0.0 # Sekunden seit der letzten guten Antwort
@@ -212,21 +264,47 @@ _st = {
"letzteAend": 0.0, # wann sich zuletzt etwas am Fahrzeug ruehrte "letzteAend": 0.0, # wann sich zuletzt etwas am Fahrzeug ruehrte
"letzteZeile": 0.0, # wann zuletzt eine Zeile geschrieben wurde "letzteZeile": 0.0, # wann zuletzt eine Zeile geschrieben wurde
"signatur": None, # Fingerabdruck der zuletzt geschriebenen Zeile "signatur": None, # Fingerabdruck der zuletzt geschriebenen Zeile
"keyGewarnt": False,
"konfMtime": 0.0, "konfMtime": 0.0,
"apiKey": "",
"vin": "", "vin": "",
"ladenVorher": False, # Hausseite lieferte beim letzten Aufruf Strom "ladenVorher": False, # Hausseite lieferte beim letzten Aufruf Strom
"anstoss": None, # Stand der Anstossdatei, None = noch nie gesehen
"eigenerCmdKey": False, # Steuerung hat einen eigenen Schluessel "eigenerCmdKey": False, # Steuerung hat einen eigenen Schluessel
"zuletzt": -1, # Index des zuletzt benutzten Schluessels
"sperre": 0.0, # vor diesem Zeitpunkt gar nicht fragen
} }
# Zeitpunkte der Anfragen der letzten Stunde. Eigene Buchfuehrung neben den # Zeitpunkte der Anfragen der letzten Stunde - fuer alle Schluessel zusammen,
# weil das Kontingent fuer alle zusammen gilt. Eigene Buchfuehrung neben den
# RateLimit-Headern: die kommen erst mit der Antwort, und ihr Reset-Wert # RateLimit-Headern: die kommen erst mit der Antwort, und ihr Reset-Wert
# schrumpft ueber das Fenster, sodass sich gegen Ende ein Schwall erlauben # schrumpft ueber das Fenster, sodass sich gegen Ende ein Schwall erlauben
# liesse, der zu Beginn des naechsten Fensters sofort auflaeuft. Die eigene # liesse, der zu Beginn des naechsten Fensters sofort auflaeuft. Die eigene
# Liste haelt den Abstand ueber jede Fenstergrenze hinweg. # Liste haelt den Abstand ueber jede Fenstergrenze hinweg.
_verbrauch = collections.deque() _verbrauch = collections.deque()
@dataclass
class _Schluessel:
"""Ein API-Schluessel.
Das Kontingent haengt nicht an ihm, sondern am Konto (siehe _LIMIT) - was
hier steht, ist alles, was ihn allein betrifft: sein Ablaufdatum und eine
Sperre fuer den Fall, dass die API gerade ihn abweist. Ein abgelaufener
oder widerrufener Schluessel legt damit nur sich selbst still, die uebrigen
tragen weiter.
"""
name:str # API_KEY, API_KEY2 ... fuer Meldungen
wert:str
sperre:float = 0.0 # vorher nicht wieder benutzen
rest:int = -1 # RateLimit-Remaining der letzten Antwort
ablauf:Optional[datetime.datetime] = None
gewarnt:bool = False # vor dem Ablauf wurde schon gewarnt
# Die Schluessel in der Reihenfolge, in der sie in skoda.conf stehen. Wird aus
# der Datei aufgebaut und bei jeder Aenderung erneuert, wobei ein
# unveraenderter Schluessel seine Bilanz behaelt.
_schluessel:List[_Schluessel] = []
# Hausseitige Werte im Moment des Abrufs. Der Manager reicht sie bei jedem # Hausseitige Werte im Moment des Abrufs. Der Manager reicht sie bei jedem
# Aufruf herein; der Hintergrund-Task greift auf den letzten Stand zu. # Aufruf herein; der Hintergrund-Task greift auf den letzten Stand zu.
_haus = {"wbKw":0.0, "wbPlug":False, "wbogKw":0.0, "wbogPlug":False, _haus = {"wbKw":0.0, "wbPlug":False, "wbogKw":0.0, "wbogPlug":False,
@@ -289,18 +367,23 @@ def _konfig() -> bool:
"""Schluessel und VIN aus skoda.conf lesen, wenn die Datei sich geaendert hat. """Schluessel und VIN aus skoda.conf lesen, wenn die Datei sich geaendert hat.
Format, eine Zuweisung je Zeile: API_KEY=... und VIN=... Format, eine Zuweisung je Zeile: API_KEY=... und VIN=...
Der Schluessel laeuft ab. Weil die Datei bei jeder Aenderung neu gelesen Dazu wahlweise API_KEY2 und API_KEY3 sowie CMD_API_KEY fuer die
wird, genuegt zum Tausch das Ueberschreiben - der Manager laeuft weiter. Weboberflaeche; skoda.conf.example zeigt das Ganze.
Die Schluessel laufen ab. Weil die Datei bei jeder Aenderung neu gelesen
wird, genuegt zum Tausch das Ueberschreiben - der Manager laeuft weiter,
und ein Schluessel, der dabei unveraendert bleibt, behaelt seine
Stundenbilanz.
""" """
try: try:
mtime = os.path.getmtime(_KONFIG) mtime = os.path.getmtime(_KONFIG)
except OSError: except OSError:
if _st["apiKey"]: if _schluessel:
_LOGGER.warning("skoda.conf nicht mehr lesbar, benutze den letzten Stand.") _LOGGER.warning("skoda.conf nicht mehr lesbar, benutze den letzten Stand.")
return True return True
return False return False
if mtime == _st["konfMtime"]: if mtime == _st["konfMtime"]:
return bool(_st["apiKey"] and _st["vin"]) return bool(_schluessel and _st["vin"])
werte = {} werte = {}
try: try:
with open(_KONFIG, "r") as f: with open(_KONFIG, "r") as f:
@@ -312,23 +395,82 @@ def _konfig() -> bool:
werte[k.strip().upper()] = v.strip().strip('"').strip("'") werte[k.strip().upper()] = v.strip().strip('"').strip("'")
except OSError as e: except OSError as e:
_LOGGER.error("skoda.conf nicht lesbar: "+str(e)) _LOGGER.error("skoda.conf nicht lesbar: "+str(e))
return bool(_st["apiKey"] and _st["vin"]) return bool(_schluessel and _st["vin"])
_st["konfMtime"] = mtime _st["konfMtime"] = mtime
_st["apiKey"] = werte.get("API_KEY", "")
_st["vin"] = werte.get("VIN", "") _st["vin"] = werte.get("VIN", "")
_st["eigenerCmdKey"] = bool(werte.get("CMD_API_KEY")) _st["eigenerCmdKey"] = bool(werte.get("CMD_API_KEY"))
_db["passwd"] = werte.get("DB_PASSWORD", _db["passwd"]) _db["passwd"] = werte.get("DB_PASSWORD", _db["passwd"])
_st["keyGewarnt"] = False _schluesselUebernehmen(werte)
if not _st["apiKey"] or not _st["vin"]: if not _schluessel or not _st["vin"]:
_LOGGER.error("skoda.conf braucht API_KEY und VIN.") _LOGGER.error("skoda.conf braucht API_KEY und VIN.")
return False return False
ret.vin = _st["vin"] ret.vin = _st["vin"]
ret.keys = len(_schluessel)
_LOGGER.info("skoda.conf gelesen, VIN endet auf "+_st["vin"][-4:] _LOGGER.info("skoda.conf gelesen, VIN endet auf "+_st["vin"][-4:]
+(", eigener Schluessel fuer die Steuerung" +", "+str(len(_schluessel))+" Schluessel"
if _st["eigenerCmdKey"] else ", ein Schluessel fuer alles")) +(" (API_KEY auch fuer die Steuerung)"
if not _st["eigenerCmdKey"] else "")
+", "+str(_BUDGET)+" Abrufe je Stunde, beim Laden alle "
+str(_I_LADEN)+" s")
return True return True
def _schluesselUebernehmen(werte:dict):
"""Die Schluessel aus der Konfiguration in _schluessel spiegeln.
Gelesen werden API_KEY, API_KEY2 und API_KEY3. Ein Schluessel, dessen Wert
sich nicht geaendert hat, behaelt sein Objekt und damit seine Bilanz -
sonst liesse sich das Kontingent umgehen, indem man skoda.conf anfasst,
und der Tausch des dritten Schluessels wuerfe die Buchfuehrung der beiden
anderen weg.
"""
alt = dict((s.wert, s) for s in _schluessel)
neu = []
for nr in range(1, _MAX_KEYS+1):
name = "API_KEY" if nr == 1 else "API_KEY"+str(nr)
wert = werte.get(name, "")
# Derselbe Schluessel zweimal eingetragen bringt kein zweites
# Kontingent - die API zaehlt den Schluessel, nicht die Zeile.
if not wert or any(s.wert == wert for s in neu):
continue
sch = alt.get(wert) or _Schluessel(name=name, wert=wert)
sch.name = name
neu.append(sch)
_schluessel[:] = neu
_st["zuletzt"] = min(_st["zuletzt"], len(neu)-1)
def _anstossPruefen():
"""Nach einem Befehl aus der Weboberflaeche einen Abruf vorziehen.
Gelesen wird nur die Aenderungszeit der Datei, die ajax/skodaCmd.php nach
einem angenommenen Befehl beruehrt - ihr Inhalt spielt keine Rolle.
Beim ersten Blick wird sie nur gemerkt und nichts nachgeholt: nach einem
Neustart des Managers liegt der letzte Befehl womoeglich Tage zurueck, und
dafuer einen Abruf auszugeben, waere Verschwendung.
Vorgezogen wird nur, nie verschoben. Ein Befehl kann den Abruf also
frueher stattfinden lassen, aber keinen verhindern - und die Sperre je
Schluessel gilt weiter: ist nichts frei, wird der Anstoss so behandelt wie
jeder andere Abruf auch und wartet.
"""
neu = 0.0
for pfad in _ANSTOSS_ORTE:
try:
neu = max(neu, os.path.getmtime(pfad))
except OSError:
continue
if neu <= 0.0:
return
if _st["anstoss"] is None:
_st["anstoss"] = neu
return
if neu > _st["anstoss"]:
_st["anstoss"] = neu
_st["naechster"] = min(_st["naechster"], neu + _NACH_BEFEHL)
def setDbPasswort(pw:str): def setDbPasswort(pw:str):
"""Datenbank-Passwort vom Manager uebernehmen. """Datenbank-Passwort vom Manager uebernehmen.
@@ -529,14 +671,14 @@ def _intervall() -> float:
# bringt haeufiges Klopfen nichts und kostet nur Kontingent. # bringt haeufiges Klopfen nichts und kostet nur Kontingent.
return min(_I_RUHE, 60.0*(2**min(ret.error-1, 5))) return min(_I_RUHE, 60.0*(2**min(ret.error-1, 5)))
if ret.chgState == "CHARGING" or (_haus["wbPlug"] and _haus["wbKw"] > 0.5): if ret.chgState == "CHARGING" or (_haus["wbPlug"] and _haus["wbKw"] > 0.5):
return _I_LADEN return float(_I_LADEN)
if ret.parkState == "IN_MOTION": if ret.parkState == "IN_MOTION":
return _I_FAHRT return float(_I_FAHRT)
if _haus["wbPlug"] or ret.chgState in _GESTECKT: if _haus["wbPlug"] or ret.chgState in _GESTECKT:
return _I_GESTECKT return float(_I_GESTECKT)
if time.time() - _st["letzteAend"] > _RUHE_AB: if time.time() - _st["letzteAend"] > _RUHE_AB:
return _I_RUHE return float(_I_RUHE)
return _I_AKTIV return float(_I_AKTIV)
def _gezaehlt(jetzt:float): def _gezaehlt(jetzt:float):
@@ -551,23 +693,71 @@ def _aufraeumen(jetzt:float):
def _budgetSperre(jetzt:float) -> float: def _budgetSperre(jetzt:float) -> float:
"""Fruehester Zeitpunkt, zu dem wieder eine Anfrage frei ist. """Sekunden, bis wieder eine Anfrage frei ist - unabhaengig vom Schluessel.
Sind in der zurueckliegenden Stunde bereits _BUDGET Anfragen gelaufen, Sind in der zurueckliegenden Stunde bereits _BUDGET Anfragen gelaufen, wird
wird gewartet, bis die aelteste aus dem Fenster faellt. Das ist die gewartet, bis die aelteste aus dem Fenster faellt. Dazu die Sperre, die die
eigentliche Sicherung: die Intervalle unten sind zwar so bemessen, dass API selbst gesetzt hat: ein 429 mit Retry-After oder ein RateLimit-Header,
sie passen, aber Sonderfaelle wie der Anstoss beim Ladebeginn kommen der dichteres Fragen verbietet. Beides gilt fuer alle Schluessel, weil das
zusaetzlich - und ein 429 kostet zwar kein Kontingent, verraet aber, dass Kontingent fuer alle zusammen gilt.
die Rechnung nicht aufging.
Das ist die eigentliche Sicherung: die Intervalle oben sind zwar so
bemessen, dass sie passen, aber Sonderfaelle wie der Anstoss beim
Ladebeginn oder nach einem Befehl kommen zusaetzlich.
""" """
_aufraeumen(jetzt) _aufraeumen(jetzt)
budget = _BUDGET_ALLEIN if _st["eigenerCmdKey"] else _BUDGET_GETEILT warten = max(0.0, _st["sperre"] - jetzt)
if len(_verbrauch) < budget: if len(_verbrauch) >= _BUDGET:
return 0.0 warten = max(warten, _verbrauch[0] + _FENSTER + 5.0 - jetzt)
return _verbrauch[0] + _FENSTER + 5.0 - jetzt return warten
def _kontingent(headers): def _waehlen(jetzt:float):
"""Den naechsten benutzbaren Schluessel liefern, sonst None.
Reihum, obwohl das Kontingent fuer alle zusammen gilt und die Reihenfolge
dafuer gleichgueltig waere: so faellt auf, wenn einer abgelaufen ist. Wer
immer nur den ersten nimmt, merkt vom zweiten erst etwas, wenn der erste
ausfaellt - und dann ist womoeglich auch der zweite laengst abgelaufen,
ohne dass es je jemand gesehen haette.
Uebersprungen wird, wen die API einzeln abgewiesen hat (401, 403).
"""
for i in range(1, len(_schluessel)+1):
index = (_st["zuletzt"] + i) % len(_schluessel)
if _schluessel[index].sperre <= jetzt:
_st["zuletzt"] = index
return _schluessel[index]
return None
def _fruehesteFreigabe(jetzt:float) -> float:
"""Sekunden, bis wieder gefragt werden darf - Budget und Schluessel."""
if not _schluessel:
return 60.0
schluessel = min(max(0.0, s.sperre - jetzt) for s in _schluessel)
return max(schluessel, _budgetSperre(jetzt))
def _restGesamt() -> int:
"""Was die API zuletzt als Rest gemeldet hat.
Nicht die Summe ueber die Schluessel: sie teilen sich einen Zaehler, also
sagen sie alle dieselbe Zahl. Es zaehlt die juengste Auskunft, und die ist
die des zuletzt benutzten Schluessels.
"""
if not _schluessel or _st["zuletzt"] < 0:
return -1
return _schluessel[_st["zuletzt"]].rest
def _fruehesterAblauf():
"""Wann der erste Schluessel ablaeuft - der bestimmt den naechsten Tausch."""
werte = [s.ablauf for s in _schluessel if s.ablauf]
return min(werte) if werte else None
def _kontingent(headers, sch:_Schluessel) -> float:
"""Aus den RateLimit-Headern eine Untergrenze fuer den Abstand ableiten. """Aus den RateLimit-Headern eine Untergrenze fuer den Abstand ableiten.
Die Doku nennt derzeit 20 Anfragen je Stunde, ausdruecklich nicht Die Doku nennt derzeit 20 Anfragen je Stunde, ausdruecklich nicht
@@ -576,13 +766,17 @@ def _kontingent(headers):
sind t/n Sekunden Abstand gerade noch tragbar; der Zuschlag haelt Abstand sind t/n Sekunden Abstand gerade noch tragbar; der Zuschlag haelt Abstand
zur Grenze. Zieht Skoda das Kontingent enger, folgt das Modul von selbst, zur Grenze. Zieht Skoda das Kontingent enger, folgt das Modul von selbst,
ohne dass hier eine Zahl nachgetragen werden muesste. ohne dass hier eine Zahl nachgetragen werden muesste.
Die Header gelten fuer den Schluessel, mit dem gefragt wurde, also gilt
auch der Abstand nur fuer ihn: ist er ausgeschoepft, darf der naechste
trotzdem sofort.
""" """
try: try:
rest = int(headers.get("RateLimit-Remaining", -1)) rest = int(headers.get("RateLimit-Remaining", -1))
reset = int(headers.get("RateLimit-Reset", -1)) reset = int(headers.get("RateLimit-Reset", -1))
except (TypeError, ValueError): except (TypeError, ValueError):
return 0.0 return 0.0
ret.rlRemaining = rest sch.rest = rest
if rest < 0 or reset < 0: if rest < 0 or reset < 0:
return 0.0 return 0.0
if rest == 0: if rest == 0:
@@ -590,16 +784,16 @@ def _kontingent(headers):
return (float(reset)/rest)*1.2 return (float(reset)/rest)*1.2
def _keyPruefen(headers): def _keyPruefen(headers, sch:_Schluessel):
"""Vor dem Ablauf des Schluessels warnen, solange noch Zeit zum Tausch ist.""" """Vor dem Ablauf des Schluessels warnen, solange noch Zeit zum Tausch ist."""
ts = _zeit(headers.get("X-API-Key-Expires-At")) ts = _zeit(headers.get("X-API-Key-Expires-At"))
if not ts: if not ts:
return return
ret.keyExpires = ts sch.ablauf = ts
tage = (ts - datetime.datetime.now()).total_seconds()/86400.0 tage = (ts - datetime.datetime.now()).total_seconds()/86400.0
if tage < _WARN_KEY and not _st["keyGewarnt"]: if tage < _WARN_KEY and not sch.gewarnt:
_st["keyGewarnt"] = True sch.gewarnt = True
_LOGGER.warning("Skoda-API-Schluessel laeuft am " _LOGGER.warning("Skoda-API-Schluessel "+sch.name+" laeuft am "
+ts.strftime("%d.%m.%Y")+" ab ("+str(int(tage)) +ts.strftime("%d.%m.%Y")+" ab ("+str(int(tage))
+" Tage) - in der MySkoda-App erneuern und skoda.conf ueberschreiben.") +" Tage) - in der MySkoda-App erneuern und skoda.conf ueberschreiben.")
@@ -656,6 +850,75 @@ def _werte() -> tuple:
ret.httpStatus, _leer(ret.apiErrors), ret.rlRemaining, ret.keyExpires) ret.httpStatus, _leer(ret.apiErrors), ret.rlRemaining, ret.keyExpires)
# ---------------------------------------------------------------------------
# Wallbox-Verlauf waehrend einer Ladung
# ---------------------------------------------------------------------------
#
# Das Fahrzeug meldet beim Laden an der Wallbox mitunter nur einmal in der
# Stunde. Fuer die Summe einer Ladung reicht das, fuer ihren Verlauf nicht -
# den kennen nur die Wallboxen, und die reicht der Manager ohnehin alle drei
# Sekunden herein. EnergyFlow haelt sie zwar auch fest, wird aber nach zwoelf
# Monaten ausgeduennt. Deshalb landen sie waehrend einer Ladung zusaetzlich in
# skoda_ladepunkte (Aufbau und Begruendung: solarLog_skoda_ladepunkte.sql im
# Web-Repository). Das kostet keine einzige Anfrage an die API.
_LADEPUNKT_S = 60 # Abstand zweier Punkte, solange Strom fliesst
_LADEPUNKT_KW = 0.5 # darunter gilt eine Wallbox als ruhend - dieselbe
# Schwelle wie beim Anstoss zu Ladebeginn und -ende;
# mit 0,1 kW zeichnete das Leerlaufrauschen der go-e
# im Obergeschoss Ladungen von 0,02 kWh
# Je Wallbox: wann zuletzt geschrieben, und ob sie da gerade Strom lieferte.
_ladepunkt = {"carport": {"zuletzt": 0.0, "aktiv": False},
"og": {"zuletzt": 0.0, "aktiv": False}}
def _ladepunkteSammeln(jetzt:float) -> list:
"""Die Punkte, die jetzt faellig sind - ohne sie zu schreiben.
Ein Punkt je _LADEPUNKT_S, solange eine Wallbox Strom liefert, dazu sofort
einer beim Beginn und einer mit 0 kW, sobald sie aufhoert: der traegt den
Zaehlerstand am Ende, und ohne ihn liefe die Kurve bis zur naechsten Ladung
in der Luft weiter.
Getrennt vom Schreiben, damit sich die Regel ohne Datenbank pruefen laesst.
"""
zeit = datetime.datetime.fromtimestamp(jetzt).replace(microsecond=0)
punkte = []
for name, kw, wh in (("carport", _haus["wbKw"], _haus["wbWh"]),
("og", _haus["wbogKw"], _haus["wbogWh"])):
st = _ladepunkt[name]
kw = float(kw or 0.0)
liefert = kw > _LADEPUNKT_KW
if liefert and (not st["aktiv"] or jetzt - st["zuletzt"] >= _LADEPUNKT_S):
faellig = round(kw, 3)
elif not liefert and st["aktiv"]:
faellig = 0.0
else:
faellig = None
if faellig is not None:
# Ein Zaehlerstand 0 ist ein ausgefallener Abruf der Wallbox, kein
# Stand - NULL, damit ihn niemand als Anfang einer Differenz nimmt.
punkte.append((zeit, name, faellig, wh if wh else None,
round(_haus["pvKw"], 3), round(_haus["gridKw"], 3)))
st["zuletzt"] = jetzt
st["aktiv"] = liefert
return punkte
def _ladepunkteSchreiben(punkte:list):
"""Blockierend - wird nur ueber run_in_executor aufgerufen."""
try:
with mc.connect(**_db) as verbindung:
with verbindung.cursor() as cursor:
cursor.executemany(
"INSERT INTO skoda_ladepunkte (datetime, wallbox, kw, wh_total, pv_kw, grid_kw) "
"VALUES (%s,%s,%s,%s,%s,%s);", punkte)
verbindung.commit()
except Exception as e:
_LOGGER.error("Wallbox-Verlauf nicht geschrieben: "+str(e))
def _schreiben(rohtext:str): def _schreiben(rohtext:str):
"""Eine Zeile in skoda und die unveraenderte Antwort in skoda_raw. """Eine Zeile in skoda und die unveraenderte Antwort in skoda_raw.
@@ -689,40 +952,60 @@ def _schreiben(rohtext:str):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _abrufen(): async def _abrufen():
"""Ein Durchgang: anfragen, auswerten, bei Aenderung protokollieren.""" """Ein Durchgang: anfragen, auswerten, bei Aenderung protokollieren.
kopf = {"X-API-Key": _st["apiKey"], "Accept": "application/json"}
Welcher Schluessel an der Reihe ist, entscheidet sich erst hier und nicht
beim Planen des Abrufs: bis dahin koennen Minuten vergehen, in denen ein
gesperrter Schluessel wieder frei wird oder ein freier verbraucht.
"""
jetzt = time.time()
sch = _waehlen(jetzt) if _budgetSperre(jetzt) <= 0.0 else None
if sch is None:
# Kontingent erschoepft oder kein Schluessel brauchbar. Das ist kein
# Fehler, sondern die Bremse bei der Arbeit - einfach nachsehen, sobald
# wieder etwas frei ist.
_st["naechster"] = jetzt + max(5.0, _fruehesteFreigabe(jetzt))
return
kopf = {"X-API-Key": sch.wert, "Accept": "application/json"}
url = _URL + _st["vin"] url = _URL + _st["vin"]
rohtext = "" rohtext = ""
abstand = 0.0 abstand = 0.0
# Zuruecksetzen, damit ein Abbruch ohne Antwort nicht als der Status der
# vorigen Runde gezaehlt wird - unten haengt daran die Stundenbilanz.
ret.httpStatus = 0
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, headers=kopf, timeout=_TIMEOUT) as response: async with session.get(url, headers=kopf, timeout=_TIMEOUT) as response:
ret.httpStatus = response.status ret.httpStatus = response.status
rohtext = await response.text() rohtext = await response.text()
abstand = _kontingent(response.headers) abstand = _kontingent(response.headers, sch)
_keyPruefen(response.headers) _keyPruefen(response.headers, sch)
if response.status == 200: if response.status == 200:
uebernehmen(json.loads(rohtext)) uebernehmen(json.loads(rohtext))
ret.error = 0 ret.error = 0
ret.lastOk = time.time() ret.lastOk = time.time()
elif response.status == 429: elif response.status == 429:
# Kontingent erschoepft. Retry-After ist verbindlich. # Kontingent erschoepft. Retry-After ist verbindlich und gilt
ret.error += 1 # fuer alle Schluessel - mit einem anderen weiterzufragen,
# braeuchte niemand zu versuchen. Keine Fehlerzaehlung: das ist
# Buchhaltung, keine Stoerung.
try: try:
abstand = max(abstand, float(response.headers.get("Retry-After", 60))) abstand = max(abstand, float(response.headers.get("Retry-After", 60)))
except (TypeError, ValueError): except (TypeError, ValueError):
abstand = max(abstand, 60.0) abstand = max(abstand, 60.0)
_LOGGER.warning("Skoda-API: Kontingent erschoepft, warte " _LOGGER.warning("Skoda-API: Kontingent erschoepft, pausiert "
+str(int(abstand))+" s.") +str(int(abstand))+" s.")
rohtext = "" rohtext = ""
elif response.status in (401, 403): elif response.status in (401, 403):
# Schluessel abgelaufen, widerrufen oder nicht fuer diese VIN # Schluessel abgelaufen, widerrufen oder nicht fuer diese VIN
# freigegeben. Das behebt sich nicht von selbst, also selten # freigegeben. Das behebt sich nicht von selbst, also selten
# nachfassen statt im Minutentakt gegen die Wand zu laufen. # nachfassen statt im Minutentakt gegen die Wand zu laufen -
ret.error += 1 # und nur mit diesem einen aussetzen. Sind die anderen in
# Ordnung, merkt der Rest des Hauses davon nichts.
abstand = max(abstand, float(_I_RUHE)) abstand = max(abstand, float(_I_RUHE))
_LOGGER.error("Skoda-API weist den Schluessel ab (HTTP " _LOGGER.error("Skoda-API weist "+sch.name+" ab (HTTP "
+str(response.status)+") - in der MySkoda-App " +str(response.status)+") - in der MySkoda-App "
"erneuern und skoda.conf ueberschreiben.") "erneuern und skoda.conf ueberschreiben.")
rohtext = "" rohtext = ""
@@ -742,8 +1025,17 @@ async def _abrufen():
# schon - auch ein 500 aus einer Stoerung bei Skoda. # schon - auch ein 500 aus einer Stoerung bei Skoda.
if ret.httpStatus not in (401, 403): if ret.httpStatus not in (401, 403):
_gezaehlt(jetzt) _gezaehlt(jetzt)
_st["naechster"] = jetzt + max(_I_MIN, _intervall(), abstand, if abstand > 0.0:
_budgetSperre(jetzt)) # Aus einem 429 oder den RateLimit-Headern - beides gilt fuer alle
# Schluessel. Nur ein einzeln abgewiesener Schluessel sperrt sich selbst.
if ret.httpStatus in (401, 403):
sch.sperre = max(sch.sperre, jetzt + abstand)
else:
_st["sperre"] = max(_st["sperre"], jetzt + abstand)
ret.rlRemaining = _restGesamt()
ret.keyExpires = _fruehesterAblauf()
_st["naechster"] = jetzt + max(float(_I_MIN), _intervall(),
_fruehesteFreigabe(jetzt))
if ret.httpStatus != 200: if ret.httpStatus != 200:
return return
@@ -802,6 +1094,19 @@ async def gatherData(wbKw:float=0.0, wbPlug:bool=False,
_st["ladenVorher"] = laedt _st["ladenVorher"] = laedt
_st["naechster"] = min(_st["naechster"], time.time()) _st["naechster"] = min(_st["naechster"], time.time())
# Und ein Befehl aus der Weboberflaeche, der gerade abgesetzt wurde.
_anstossPruefen()
# Der Verlauf der Wallboxen. Geschrieben wird im Hintergrund - eine
# langsame Datenbank soll den 3-Sekunden-Takt nicht ausbremsen -, und ein
# Fehler darf den Abruf des Fahrzeugs nicht mitreissen.
try:
punkte = _ladepunkteSammeln(time.time())
if punkte:
asyncio.get_event_loop().run_in_executor(None, _ladepunkteSchreiben, punkte)
except Exception as e:
_LOGGER.error("Wallbox-Verlauf nicht gesammelt: "+str(e))
ret.alter = round(time.time() - ret.lastOk, 1) if ret.lastOk else 0.0 ret.alter = round(time.time() - ret.lastOk, 1) if ret.lastOk else 0.0
if _st["laeuft"] or time.time() < _st["naechster"]: if _st["laeuft"] or time.time() < _st["naechster"]:
+2 -2
View File
@@ -22,7 +22,6 @@
/volume1/homes/wagner/SolarManager/rainOutput.log /volume1/homes/wagner/SolarManager/rainOutput.log
/volume1/homes/wagner/SolarManager/wattpilotshell.log /volume1/homes/wagner/SolarManager/wattpilotshell.log
/volume1/homes/wagner/SolarManager/wsMQTTbridge.log /volume1/homes/wagner/SolarManager/wsMQTTbridge.log
/volume1/homes/wagner/SolarManager/wecker.log
{ {
monthly monthly
# Zwei Wochen zurueck. Weiter zurueck hat noch nie jemand gesucht, und # Zwei Wochen zurueck. Weiter zurueck hat noch nie jemand gesucht, und
@@ -34,7 +33,8 @@
maxsize 10M maxsize 10M
compress compress
# Eine leere Datei zu rotieren bringt nichts ausser vierzehn leeren # 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 notifempty
# Fehlt eine Datei, ist das kein Fehler: nicht jeder Prozess laeuft # Fehlt eine Datei, ist das kein Fehler: nicht jeder Prozess laeuft
# auf jedem System. # auf jedem System.
+56
View File
@@ -0,0 +1,56 @@
# Zugangsdaten fuer die MySkoda-API. Das hier ist die Vorlage; die echte Datei
# heisst skoda.conf, liegt daneben und steht nicht im Repository.
#
# Gelesen wird sie von gatherSkodaData.py (hier) und von ajax/skodaCmd.php im
# Web-Repository - beide bei jeder Aenderung neu. Zum Tausch eines Schluessels
# genuegt deshalb das Ueberschreiben, ein Neustart ist nicht noetig.
#
# Format: eine Zuweisung je Zeile, NAME=wert. Zeilen mit # sind Kommentar,
# Anfuehrungszeichen um den Wert werden abgestreift.
# Die Fahrgestellnummer. Steht in der MySkoda-App unter den Fahrzeugdaten.
VIN=TMBJB9NY5RF000000
# Der API-Schluessel. Erzeugt unter https://go.skoda.eu/api-keys, gebunden an
# die dort ausgewaehlten Fahrzeuge - und mit Ablaufdatum: jede Antwort traegt
# X-API-Key-Expires-At mit, gatherSkodaData.py warnt 14 Tage vorher im Log.
API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Bis zu zwei weitere Schluessel, in derselben App angelegt.
#
# Sie bringen KEINEN dichteren Abruf. Die Doku nennt 20 Anfragen je Stunde
# "je Schluessel", gemessen gilt der Zaehler aber fuer alle Schluessel des
# Kontos zusammen: drei Anfragen hintereinander mit drei verschiedenen
# Schluesseln zaehlten RateLimit-Remaining von 18 ueber 17 auf 16 herunter,
# bei identischem Reset-Zeitpunkt.
#
# Wofuer sie dann gut sind: Ausfallsicherheit. Laeuft ein Schluessel ab oder
# wird er in der App widerrufen, sperrt gatherSkodaData.py genau diesen und
# fragt mit dem naechsten weiter - statt dass das Fahrzeug aus der Anzeige
# faellt, bis es jemand im Log bemerkt. Benutzt werden sie reihum, damit ein
# abgelaufener auffaellt und nicht jahrelang unbemerkt danebenliegt.
#
# Weglassen ist in Ordnung - dann bleibt es beim ersten.
#API_KEY2=
#API_KEY3=
# Eigener Schluessel fuer die Weboberflaeche (ajax/skodaCmd.php startet und
# stoppt darueber Laden, Klimatisierung und Lueftung). Ohne ihn schickt sie
# ihre Befehle mit API_KEY.
#
# Am Kontingent aendert er nichts - siehe oben, es gilt fuers ganze Konto.
# Weil Abruf und Weboberflaeche in getrennten Prozessen laufen und keinen
# gemeinsamen Zaehler haben, ist es fest aufgeteilt: 14 Anfragen je Stunde
# fuer den Abruf, 4 fuer Befehle, 2 Reserve. Was ein eigener Schluessel
# bringt, ist die Moeglichkeit, ihn einzeln zu widerrufen, ohne den Abruf
# mitzunehmen.
#CMD_API_KEY=
# Passwort der Datenbank solarLog. Wahlweise - laeuft das Modul im
# solarManager, reicht der es aus seiner config.ini herein (setDbPasswort).
#DB_PASSWORD=
+131
View File
@@ -0,0 +1,131 @@
"""Wallbox-Verlauf vergangener Ladungen aus EnergyFlow nach skoda_ladepunkte.
Den laufenden Verlauf schreibt gatherSkodaData.py selbst, im Minutentakt.
Fuer alles davor - und fuer Zeiten, in denen der Manager nicht lief - gibt
es nur EnergyFlow, und das wird nach zwoelf Monaten ausgeduennt. Dieses
Skript holt die Fuenf-Minuten-Werte heraus, solange sie noch da sind.
python3 skoda_ladepunkte_nachtragen.py 2026-09-11 2026-09-14
python3 skoda_ladepunkte_nachtragen.py 2026-09-11 2026-09-14 --probe
Ohne --probe wird geschrieben. Es darf beliebig oft laufen: nachgetragene
Zeilen im Zeitraum werden vorher entfernt, und wo der Manager selbst schon
Punkte geschrieben hat, traegt es nichts nach - die sind genauer.
EnergyFlow kennt keinen Zaehlerstand, nur Leistungen. wh_total wird deshalb
gerechnet: die Leistung ueber die Zeit aufsummiert und dann an die beiden
Zaehlerstaende angeglichen, die die Tabelle skoda vor und nach der Ladung
kennt. Fehlt einer davon, bleibt wh_total leer. Jede so entstandene Zeile
traegt quelle = 'energyflow'.
"""
import sys
import datetime
import mysql.connector as mc
import konfig
LUECKE = datetime.timedelta(hours=3) # wie LADUNG_LUECKE_S in ajax/skoda.php
RUHE_KW = 0.5 # wie _LADEPUNKT_KW in gatherSkodaData.py
WALLBOXEN = {
# Name: (Leistung in EnergyFlow, Zaehlerspalte in skoda)
"carport": ("PL1_EV + PL2_EV + PL3_EV", "wb_wh_total"),
"og": ("PL1_EVog + PL2_EVog + PL3_EVog", "wbog_wh_total"),
}
def bloecke(zeilen):
"""Zusammenhaengende Strecken mit Leistung, jeweils mit der Zeile danach."""
block = []
for i, (zeit, kw) in enumerate(zeilen):
if kw > RUHE_KW:
block.append((zeit, kw))
elif block:
block.append((zeit, 0.0)) # der Endpunkt, wie im Manager
yield block
block = []
if block:
yield block # laeuft noch - ohne Endpunkt
def zaehlerstand(cursor, spalte, zeit, richtung):
"""Zaehlerstand aus skoda direkt vor bzw. nach einem Zeitpunkt."""
if richtung < 0:
cursor.execute("SELECT datetime, " + spalte + " FROM skoda WHERE datetime <= %s "
"AND datetime >= %s AND " + spalte + " > 0 "
"ORDER BY datetime DESC LIMIT 1", (zeit, zeit - LUECKE))
else:
cursor.execute("SELECT datetime, " + spalte + " FROM skoda WHERE datetime >= %s "
"AND datetime <= %s AND " + spalte + " > 0 "
"ORDER BY datetime LIMIT 1", (zeit, zeit + LUECKE))
z = cursor.fetchone()
return int(z[1]) if z else None
def main():
if len(sys.argv) < 3:
print(__doc__)
sys.exit(1)
von = datetime.datetime.fromisoformat(sys.argv[1])
bis = datetime.datetime.fromisoformat(sys.argv[2])
probe = "--probe" in sys.argv
zugang = konfig.datenbank()
with mc.connect(host=zugang["host"], port=zugang["port"], user=zugang["user"],
password=zugang["password"], database=zugang["database"]) as v:
with v.cursor() as c:
neu = []
for name, (leistung, spalte) in WALLBOXEN.items():
c.execute("SELECT datetime, " + leistung + " FROM EnergyFlow "
"WHERE datetime BETWEEN %s AND %s ORDER BY datetime", (von, bis))
zeilen = [(z[0], float(z[1] or 0.0)) for z in c.fetchall()]
for block in bloecke(zeilen):
beginn, ende = block[0][0], block[-1][0]
c.execute("SELECT COUNT(*) FROM skoda_ladepunkte WHERE wallbox = %s "
"AND quelle = 'manager' AND datetime BETWEEN %s AND %s",
(name, beginn - datetime.timedelta(minutes=5), ende))
if c.fetchone()[0]:
print(name, beginn, "bis", ende, "- vom Manager schon erfasst, uebersprungen")
continue
# Leistung ueber die Zeit. Ein EnergyFlow-Wert ist das Mittel
# der fuenf Minuten, die er abschliesst.
summe, vorher, verlauf = 0.0, None, []
for zeit, kw in block:
dt = (zeit - vorher).total_seconds() if vorher else 300.0
summe += kw * dt / 3.6 if kw > 0 else 0.0
verlauf.append((zeit, kw, summe))
vorher = zeit
anfang = zaehlerstand(c, spalte, beginn - datetime.timedelta(minutes=5), -1)
schluss = zaehlerstand(c, spalte, ende, +1)
if anfang is not None and schluss is not None and schluss > anfang and summe > 0:
faktor = (schluss - anfang) / summe
else:
faktor = None
for zeit, kw, bis_hier in verlauf:
wh = round(anfang + bis_hier * faktor) if faktor else None
neu.append((zeit, name, round(kw, 3), wh))
print(name, beginn, "bis", ende, "-", len(verlauf), "Punkte,",
round(summe / 1000, 2), "kWh aufsummiert",
("gegen " + str(round((schluss - anfang) / 1000, 2)) + " kWh Zaehler"
if faktor else "(ohne Zaehlerstaende, wh_total bleibt leer)"))
if probe:
print(len(neu), "Zeilen - Probe, nichts geschrieben.")
return
c.execute("DELETE FROM skoda_ladepunkte WHERE quelle = 'energyflow' "
"AND datetime BETWEEN %s AND %s", (von, bis))
entfernt = c.rowcount
c.executemany("INSERT INTO skoda_ladepunkte (datetime, wallbox, kw, wh_total, quelle) "
"VALUES (%s,%s,%s,%s,'energyflow')", neu)
v.commit()
print(len(neu), "Zeilen nachgetragen,", entfernt, "fruehere Nachtraege ersetzt.")
if __name__ == "__main__":
main()
+275 -43
View File
@@ -5,6 +5,11 @@ sicherstellen, dass eine Antwort richtig zerlegt wird und dass die Faelle
nicht abstuerzen, die im Betrieb sicher vorkommen: fehlende Teilbereiche, nicht abstuerzen, die im Betrieb sicher vorkommen: fehlende Teilbereiche,
UNKNOWN statt eines Werts, Fahrzeug in Bewegung, leere Antwort. UNKNOWN statt eines Werts, Fahrzeug in Bewegung, leere Antwort.
Dazu die Buchfuehrung ueber das Kontingent, die sich sonst nur im Betrieb
zeigen wuerde - und dort erst als 429: dass es eingehalten wird, dass die
Schluessel reihum drankommen und dass ein abgewiesener die anderen nicht
mitnimmt. Abschnitt 13 spielt dafuer einen ganzen Tag Dauerladen durch.
python3 skoda_test.py python3 skoda_test.py
""" """
@@ -12,6 +17,11 @@ import datetime
import time import time
import gatherSkodaData as g import gatherSkodaData as g
# Nie in die echte Datenbank: gatherData() stoesst beim Laden das Schreiben
# des Wallbox-Verlaufs an, und diese Pruefung laeuft auch auf der NAS.
geschrieben = []
g._ladepunkteSchreiben = lambda punkte: geschrieben.extend(punkte)
fehler = [] fehler = []
@@ -23,6 +33,25 @@ def frisch():
g.ret = g.SkodaData() g.ret = g.SkodaData()
return g.ret return g.ret
_NAMEN = ("API_KEY", "API_KEY2", "API_KEY3")
def schluessel(anzahl, eigenerCmdKey=False):
"""So viele Schluessel setzen, als staenden sie in skoda.conf.
Ohne Datei und ohne Netz. Auch die gemeinsame Stundenbilanz wird geleert,
damit keine Probe die der vorigen erbt.
"""
g._schluessel[:] = []
werte = dict((_NAMEN[i], "schluessel"+str(i)) for i in range(anzahl))
if eigenerCmdKey:
werte["CMD_API_KEY"] = "steuerung"
g._schluesselUebernehmen(werte)
g._st["eigenerCmdKey"] = eigenerCmdKey
g._st["zuletzt"] = -1
g._st["sperre"] = 0.0
g._verbrauch.clear()
return g._schluessel
# --- 1. Vollstaendige Antwort --------------------------------------------- # --- 1. Vollstaendige Antwort ---------------------------------------------
r = frisch() r = frisch()
@@ -112,33 +141,55 @@ pruefe("gesteckt behaelt den Wert", r.chgKw, 10.8)
# --- 9. Abrufintervall folgt der Lage ------------------------------------ # --- 9. Abrufintervall folgt der Lage ------------------------------------
r = frisch() r = frisch()
schluessel(1)
g._st["letzteAend"] = 0.0 g._st["letzteAend"] = 0.0
g._haus["wbPlug"] = False; g._haus["wbKw"] = 0.0 g._haus["wbPlug"] = False; g._haus["wbKw"] = 0.0
r.chgState = "CHARGING" r.chgState = "CHARGING"
pruefe("laedt", g._intervall(), g._I_LADEN) pruefe("laedt", g._intervall(), float(g._I_LADEN))
r.chgState = "" r.chgState = ""
g._haus["wbPlug"] = True; g._haus["wbKw"] = 7.4 g._haus["wbPlug"] = True; g._haus["wbKw"] = 7.4
pruefe("Wallbox liefert", g._intervall(), g._I_LADEN) pruefe("Wallbox liefert", g._intervall(), float(g._I_LADEN))
g._haus["wbKw"] = 0.0 g._haus["wbKw"] = 0.0
pruefe("gesteckt, laedt nicht", g._intervall(), g._I_GESTECKT) pruefe("gesteckt, laedt nicht", g._intervall(), g._I_GESTECKT)
g._haus["wbPlug"] = False g._haus["wbPlug"] = False
r.parkState = "IN_MOTION" r.parkState = "IN_MOTION"
pruefe("unterwegs", g._intervall(), g._I_FAHRT) pruefe("unterwegs", g._intervall(), float(g._I_FAHRT))
r.parkState = "PARKED" r.parkState = "PARKED"
pruefe("lange nichts", g._intervall(), g._I_RUHE) pruefe("lange nichts", g._intervall(), g._I_RUHE)
r.error = 3 r.error = 3
pruefe("nach Fehlern zurueckhaltend", g._intervall(), 240.0) pruefe("nach Fehlern zurueckhaltend", g._intervall(), 240.0)
r.error = 0 r.error = 0
# Mehr Schluessel duerfen den Takt NICHT beschleunigen: das Kontingent gilt
# fuer alle zusammen (gemessen, siehe _LIMIT). Wer das hier wieder staffelt,
# baut 429-Antworten ein.
for lage, feld, wert in (("beim Laden", "chgState", "CHARGING"),
("unterwegs", "parkState", "IN_MOTION"),
("beim Parken", "parkState", "PARKED")):
r.chgState = ""
r.parkState = ""
setattr(r, feld, wert)
werte = []
for anzahl in (1, 2, 3):
schluessel(anzahl)
werte.append(g._intervall())
pruefe("Takt "+lage+" haengt nicht an der Zahl der Schluessel",
len(set(werte)), 1)
r.chgState = "CHARGING"
r.parkState = ""
schluessel(3)
pruefe("und beim Laden sind es 260 s", g._intervall(), 260.0)
# --- 10. Kontingent weitet den Abstand ------------------------------------ # --- 10. Kontingent weitet den Abstand ------------------------------------
r = frisch() r = frisch()
sch = schluessel(1)[0]
pruefe("100 Anfragen in 60 s", round(g._kontingent( pruefe("100 Anfragen in 60 s", round(g._kontingent(
{"RateLimit-Remaining":"100","RateLimit-Reset":"60"}),3), 0.72) {"RateLimit-Remaining":"100","RateLimit-Reset":"60"}, sch),3), 0.72)
pruefe("2 Anfragen in 600 s", round(g._kontingent( pruefe("2 Anfragen in 600 s", round(g._kontingent(
{"RateLimit-Remaining":"2","RateLimit-Reset":"600"}),1), 360.0) {"RateLimit-Remaining":"2","RateLimit-Reset":"600"}, sch),1), 360.0)
pruefe("nichts mehr frei", g._kontingent( pruefe("nichts mehr frei", g._kontingent(
{"RateLimit-Remaining":"0","RateLimit-Reset":"120"}), 125.0) {"RateLimit-Remaining":"0","RateLimit-Reset":"120"}, sch), 125.0)
pruefe("ohne Header kein Einfluss", g._kontingent({}), 0.0) pruefe("ohne Header kein Einfluss", g._kontingent({}, sch), 0.0)
# --- 11. Nur echte Aenderungen erzeugen eine Zeile ------------------------ # --- 11. Nur echte Aenderungen erzeugen eine Zeile ------------------------
r = frisch() r = frisch()
@@ -185,51 +236,134 @@ g.uebernehmen({"vehicle":{"charging":{"status":{"battery":
pruefe("Stromer ohne Tank", (r.carType, r.fuelPct), ("", 0)) pruefe("Stromer ohne Tank", (r.carType, r.fuelPct), ("", 0))
# --- 13. Das Kontingent wird unter Dauerlast eingehalten ------------------ # --- 13. Das Kontingent wird unter Dauerlast eingehalten ------------------
# Die Doku nennt 20 Anfragen je Stunde und Schluessel. Beim Laden greift das # Die Doku nennt 20 Anfragen je Stunde *und Schluessel*. Beim Laden greift das
# kuerzeste Intervall - genau dort muss die Sperre halten. Simuliert wird ein # kuerzeste Intervall - genau dort muss die Sperre halten. Simuliert wird ein
# ganzer Tag Dauerladen, ohne echte Zeit und ohne Netz. # ganzer Tag Dauerladen, ohne echte Zeit und ohne Netz.
g._verbrauch.clear()
zeitpunkte = [] def dauerlast(anzahl, eigenerCmdKey=False):
t = 0.0 """Einen Tag Dauerladen durchspielen. Liefert Zeitpunkte je Schluessel."""
while t < 24*3600: schluessel(anzahl, eigenerCmdKey)
zeitpunkte.append(t) takt = float(max(g._I_MIN, g._I_LADEN))
g._gezaehlt(t) punkte = dict((s.name, []) for s in g._schluessel)
t += max(g._I_MIN, g._I_LADEN, g._budgetSperre(t)) t, gebremst = 0.0, 0
schlimmste = 0 while t < 24*3600:
for i, t0 in enumerate(zeitpunkte): sch = g._waehlen(t) if g._budgetSperre(t) <= 0.0 else None
in_fenster = sum(1 for t1 in zeitpunkte[i:] if t1 - t0 < 3600) if sch is None:
schlimmste = max(schlimmste, in_fenster) # Nichts frei - genau das tut _abrufen() dann auch.
gebremst += 1
t += max(5.0, g._fruehesteFreigabe(t))
continue
g._gezaehlt(t)
punkte[sch.name].append(t)
t += takt
return punkte, gebremst
def schlimmstesFenster(zeiten):
"""Die meisten Anfragen, die in irgendeine Stunde fallen."""
return max([len([x for x in zeiten if t0 <= x < t0+3600])
for t0 in zeiten] or [0])
def alleZeiten(punkte):
zeiten = []
for z in punkte.values():
zeiten.extend(z)
return sorted(zeiten)
punkte, gebremst = dauerlast(1)
schlimmste = schlimmstesFenster(alleZeiten(punkte))
pruefe("nie mehr als das Limit je Stunde", schlimmste <= g._LIMIT, True) pruefe("nie mehr als das Limit je Stunde", schlimmste <= g._LIMIT, True)
pruefe("das eigene Budget wird eingehalten", schlimmste <= g._BUDGET_GETEILT, True) pruefe("das eigene Budget wird eingehalten", schlimmste <= g._BUDGET, True)
pruefe("und auch ausgenutzt", schlimmste >= g._BUDGET_GETEILT - 3, True) pruefe("und auch ausgenutzt", schlimmste >= g._BUDGET - 1, True)
pruefe("der 260-s-Takt passt ohne Bremse", gebremst, 0)
# Mit eigenem Schluessel fuer die Steuerung darf mehr genutzt werden. # Der entscheidende Punkt: drei Schluessel duerfen NICHT mehr Anfragen
g._st["eigenerCmdKey"] = True # ergeben. Sie teilen sich einen Zaehler - wer hier mehr misst, hat die
g._verbrauch.clear() # Staffel wieder eingebaut und wird im Betrieb mit 429 bezahlt.
for i in range(g._BUDGET_GETEILT): punkte3, gebremst3 = dauerlast(3)
g._gezaehlt(2000.0 + i) pruefe("drei Schluessel halten dasselbe Limit",
pruefe("geteiltes Budget reicht dem Modul allein nicht", schlimmstesFenster(alleZeiten(punkte3)) <= g._BUDGET, True)
g._budgetSperre(2000.0 + g._BUDGET_GETEILT), 0.0) pruefe("und bringen genauso viele Abrufe wie einer",
g._verbrauch.clear() len(alleZeiten(punkte3)), len(alleZeiten(punkte)))
for i in range(g._BUDGET_ALLEIN): for name, zeiten in punkte3.items():
g._gezaehlt(2000.0 + i) pruefe(name+" kommt reihum dran", len(zeiten) > 0, True)
pruefe("eigenes Budget greift bei 18", g._budgetSperre(2000.0 + g._BUDGET_ALLEIN) > 3500, True)
g._st["eigenerCmdKey"] = False
g._verbrauch.clear()
# Ein einzelner Anstoss ausser der Reihe darf das Budget nicht sprengen. # Ein eigener Schluessel fuer die Steuerung aendert am Kontingent nichts
g._verbrauch.clear() # mehr - er trennt nur, wer womit fragt.
for i in range(g._BUDGET_GETEILT): punkte, _ = dauerlast(1, eigenerCmdKey=True)
g._gezaehlt(1000.0 + i) pruefe("eigener Befehlsschluessel gibt dem Abruf nicht mehr",
pruefe("Budget voll: Sperre greift", g._budgetSperre(1000.0+g._BUDGET_GETEILT) > 3500, True) schlimmstesFenster(alleZeiten(punkte)) <= g._BUDGET, True)
g._verbrauch.clear()
pruefe("Budget leer: keine Sperre", g._budgetSperre(0.0), 0.0) # Reihum: jeder ist einmal dran, bevor einer ein zweites Mal drankommt. Nicht
# wegen des Kontingents - das ist gemeinsam -, sondern damit ein abgelaufener
# Schluessel auffaellt, statt jahrelang unbenutzt danebenzuliegen.
schluessel(3)
folge = []
for i in range(9):
sch = g._waehlen(i*300.0)
g._gezaehlt(i*300.0)
folge.append(sch.name)
pruefe("die Schluessel wechseln sich ab", folge, list(_NAMEN)*3)
# Ein abgewiesener Schluessel (401, 403) sperrt sich selbst - die uebrigen
# muessen weiterlaufen, sonst nimmt ein abgelaufener das ganze Fahrzeug mit.
schluessel(3)
g._schluessel[0].sperre = 1e9
pruefe("gesperrter wird uebersprungen",
[g._waehlen(float(i)).name for i in range(4)],
["API_KEY2", "API_KEY3"]*2)
for s in g._schluessel:
s.sperre = 500.0
pruefe("alle gesperrt: kein Abruf", g._waehlen(0.0), None)
pruefe("dafuer ein sauberer Wiedervorlagetermin",
g._fruehesteFreigabe(0.0), 500.0)
# Ein 429 dagegen gilt fuer alle - ein anderer Schluessel hilft da nicht.
schluessel(3)
g._st["sperre"] = 400.0
pruefe("429 haelt alle zurueck", g._budgetSperre(0.0), 400.0)
pruefe("und der Schluessel selbst bleibt unbescholten",
g._schluessel[0].sperre, 0.0)
# Wird ein Schluessel getauscht, behalten die anderen, was ueber sie bekannt
# ist - eine Sperre aus einem 401 und das Ablaufdatum. Sonst begaenne nach
# jedem Anfassen der Datei das Ausprobieren von vorn.
schluessel(3)
g._schluessel[0].sperre = 4711.0
g._schluessel[0].ablauf = datetime.datetime(2027, 5, 5)
g._schluesselUebernehmen({"API_KEY":"schluessel0", "API_KEY2":"schluessel1",
"API_KEY3":"frisch geholt"})
pruefe("Sperre ueberlebt den Tausch", g._schluessel[0].sperre, 4711.0)
pruefe("Ablaufdatum auch", g._schluessel[0].ablauf, datetime.datetime(2027, 5, 5))
pruefe("der neue faengt bei null an", g._schluessel[2].sperre, 0.0)
g._schluessel[:] = []
g._schluesselUebernehmen({"API_KEY":"x", "API_KEY2":"x"})
pruefe("derselbe Schluessel zweimal zaehlt einfach", len(g._schluessel), 1)
# --- 14. Header mit den echten Zahlen ------------------------------------- # --- 14. Header mit den echten Zahlen -------------------------------------
g.ret = g.SkodaData() g.ret = g.SkodaData()
sch = schluessel(1)[0]
pruefe("19 Anfragen in 3400 s", round(g._kontingent( pruefe("19 Anfragen in 3400 s", round(g._kontingent(
{"RateLimit-Remaining":"19","RateLimit-Reset":"3400"}),0), 215.0) {"RateLimit-Remaining":"19","RateLimit-Reset":"3400"}, sch),0), 215.0)
pruefe("Restkontingent gemerkt", g.ret.rlRemaining, 19) pruefe("Restkontingent gemerkt", sch.rest, 19)
# In die Historie geht die juengste Auskunft, nicht die Summe: die Schluessel
# teilen sich einen Zaehler und melden daher alle dieselbe Zahl.
drei = schluessel(3)
pruefe("ohne Auskunft bleibt es unbekannt", g._restGesamt(), -1)
drei[0].rest, drei[1].rest = 5, 7
g._st["zuletzt"] = 1
pruefe("der zuletzt benutzte Schluessel zaehlt", g._restGesamt(), 7)
drei[0].ablauf = datetime.datetime(2027, 1, 1)
drei[1].ablauf = datetime.datetime(2026, 10, 1)
pruefe("es zaehlt der Schluessel, der zuerst ablaeuft",
g._fruehesterAblauf(), datetime.datetime(2026, 10, 1))
# Abschnitt 15 soll wieder ohne Konfiguration laufen: keine Schluessel, keine
# Datei - sonst wuerde gatherData() auf der NAS die echte skoda.conf finden
# und einen Abruf anstossen, den diese Pruefung nicht will.
g._schluessel[:] = []
g._KONFIG = "/gibt/es/nicht/skoda.conf"
g._st["konfMtime"] = 0.0
# --- 15. Ladebeginn und Ladeende stossen einen Abruf an ------------------- # --- 15. Ladebeginn und Ladeende stossen einen Abruf an -------------------
# Bei 20 Anfragen je Stunde sind das die beiden wertvollsten Augenblicke: # Bei 20 Anfragen je Stunde sind das die beiden wertvollsten Augenblicke:
@@ -248,6 +382,104 @@ g._st["naechster"] = time.time() + 9999
asyncio.get_event_loop().run_until_complete(g.gatherData(wbKw=0.0, wbPlug=True)) asyncio.get_event_loop().run_until_complete(g.gatherData(wbKw=0.0, wbPlug=True))
pruefe("ohne Flanke kein Anstoss", g._st["naechster"] > time.time() + 1000, True) pruefe("ohne Flanke kein Anstoss", g._st["naechster"] > time.time() + 1000, True)
# --- 16. Ein Befehl aus der Weboberflaeche zieht den Abruf vor ------------
# ajax/skodaCmd.php beruehrt nach einem angenommenen Befehl eine Datei, deren
# Aenderungszeit der Zeitpunkt des Befehls ist. Hier wird sie untergeschoben,
# statt den Webserver zu bemuehen.
import os
import tempfile
anstoss = os.path.join(tempfile.gettempdir(), "skoda_abruf_pruefung")
g._ANSTOSS_ORTE = (anstoss,)
if os.path.exists(anstoss):
os.remove(anstoss)
g._st["anstoss"] = None
g._st["naechster"] = time.time() + 9999
g._anstossPruefen()
pruefe("ohne Datei kein Anstoss", g._st["naechster"] > time.time()+1000, True)
# Beim ersten Blick nach dem Start wird nur gemerkt. Der letzte Befehl kann
# Tage her sein - dafuer einen Abruf auszugeben, waere Verschwendung.
open(anstoss, "w").close()
frueher = time.time() - 86400
os.utime(anstoss, (frueher, frueher))
g._anstossPruefen()
pruefe("alter Befehl wird nicht nachgeholt", g._st["naechster"] > time.time()+1000, True)
# Jetzt der Befehl.
befehl = time.time()
os.utime(anstoss, (befehl, befehl))
g._anstossPruefen()
pruefe("Abruf wird vorgezogen",
abs(g._st["naechster"] - (befehl + g._NACH_BEFEHL)) < 1.0, True)
pruefe("und zwar nicht sofort", g._st["naechster"] > befehl + 20, True)
# Ohne neuen Befehl bleibt es beim Plan - sonst haenge der Abruf fest.
g._st["naechster"] = befehl + 5000
g._anstossPruefen()
pruefe("ohne neuen Befehl kein zweiter Anstoss", g._st["naechster"], befehl + 5000)
# Ein frueher geplanter Abruf wird nicht nach hinten geschoben.
g._st["naechster"] = befehl + 1
spaeter = befehl + 10
os.utime(anstoss, (spaeter, spaeter))
g._anstossPruefen()
pruefe("nur vorziehen, nie verzoegern", g._st["naechster"], befehl + 1)
os.remove(anstoss)
# --- 17. Wallbox-Verlauf waehrend einer Ladung ----------------------------
# Punkte im Minutentakt, solange Strom fliesst, einer sofort am Anfang und
# einer mit 0 kW am Ende. Die Zeit wird von Hand gestellt.
for st in g._ladepunkt.values():
st["zuletzt"] = 0.0
st["aktiv"] = False
def haus(kw, wh, ogkw=0.0, ogwh=829520):
g._haus.update({"wbKw": kw, "wbWh": wh, "wbogKw": ogkw, "wbogWh": ogwh,
"pvKw": 8.0, "gridKw": -1.5})
t0 = 1757750400.0 # 13.09.2026, 10:00 Ortszeit ungefaehr
haus(0.0, 7751734)
pruefe("ruhende Wallbox schreibt nichts", g._ladepunkteSammeln(t0), [])
haus(4.94, 7751740)
p = g._ladepunkteSammeln(t0 + 3)
pruefe("Beginn sofort festgehalten", len(p), 1)
pruefe("mit Wallbox, Leistung und Zaehler", (p[0][1], p[0][2], p[0][3]),
("carport", 4.94, 7751740))
pruefe("drei Sekunden spaeter nichts", g._ladepunkteSammeln(t0 + 6), [])
pruefe("59 Sekunden nach dem Beginn noch nichts", g._ladepunkteSammeln(t0 + 62), [])
pruefe("nach 60 Sekunden der naechste Punkt", len(g._ladepunkteSammeln(t0 + 63)), 1)
haus(0.0, 7776854)
p = g._ladepunkteSammeln(t0 + 70)
pruefe("Ende mit 0 kW", [(x[1], x[2], x[3]) for x in p], [("carport", 0.0, 7776854)])
pruefe("danach wieder Ruhe", g._ladepunkteSammeln(t0 + 200), [])
# Ein Zaehlerstand 0 ist ein ausgefallener Abruf, kein Stand.
haus(11.0, 0)
p = g._ladepunkteSammeln(t0 + 300)
pruefe("Zaehler 0 wird NULL", p[0][3], None)
# Beide Wallboxen unabhaengig voneinander.
haus(0.0, 7776900, ogkw=7.2, ogwh=829600)
p = g._ladepunkteSammeln(t0 + 400)
pruefe("Carport endet, Obergeschoss beginnt",
sorted((x[1], x[2]) for x in p), [("carport", 0.0), ("og", 7.2)])
# Und gatherData() stoesst das Schreiben tatsaechlich an.
for st in g._ladepunkt.values():
st["zuletzt"] = 0.0
st["aktiv"] = False
del geschrieben[:]
g._st["naechster"] = time.time() + 9999
schleife = asyncio.get_event_loop()
schleife.run_until_complete(g.gatherData(wbKw=10.8, wbPlug=True, wbWh=7760000))
schleife.run_until_complete(asyncio.sleep(0.2)) # dem Hintergrund Zeit geben
pruefe("gatherData schreibt den Punkt", [(x[1], x[2]) for x in geschrieben],
[("carport", 10.8)])
print() print()
if fehler: if fehler:
+8 -1
View File
@@ -438,7 +438,14 @@ async def repeat(interval):
rtData["p_l2ev"] = wp.power2 rtData["p_l2ev"] = wp.power2
rtData["p_l3ev"] = wp.power3 rtData["p_l3ev"] = wp.power3
rtData["evPower"] = wp.power rtData["evPower"] = wp.power
rtData["evPlug"] = wp.carConnected # carConnected ist ein Text ("no car", "charging", "ready",
# "complete"), kein Wahrheitswert - und bool("no car") ist True.
# So stand der Stecker der Wallbox im Carport dauerhaft auf
# gesteckt: in der Tabelle skoda, im Abruftakt von
# gatherSkodaData.py und als Steckersymbol im Realtime-SVG.
# "ready" heisst: Auto dran, wartet noch - also gesteckt.
# Als bool passt es zu evPlugOG und kommt per MQTT als 1/0 an.
rtData["evPlug"] = wp.carConnected not in (None, "no car")
# Gesamtzaehler beider Wallboxen in Wh. Die Differenz zweier # Gesamtzaehler beider Wallboxen in Wh. Die Differenz zweier
# Staende trifft eine Ladung auf die Wattstunde genau - genauer # Staende trifft eine Ladung auf die Wattstunde genau - genauer
# als jede Summe ueber die Fuenf-Minuten-Leistungswerte, deren # als jede Summe ueber die Fuenf-Minuten-Leistungswerte, deren
-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";