Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8340e8356e | ||
|
|
8c62455b2c | ||
|
|
153658df81 | ||
|
|
32e571099b | ||
|
|
09ce5c56c7 | ||
|
|
1f1700391d |
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- ===========================================================================
|
||||||
|
-- Automatiken als Ausloeser fuer andere Automatiken
|
||||||
|
--
|
||||||
|
-- Anlass: "zehn Minuten nach dem Wecker den Rollladen hoch, aber nur wenn es
|
||||||
|
-- dann schon hell ist". Die Zusatzbedingung gilt erst zum spaeteren
|
||||||
|
-- Zeitpunkt - als blosse Verzoegerung an einer Aktion waere das nicht
|
||||||
|
-- formulierbar, dafuer braeuchte die Aktion ein eigenes Bedingungssystem.
|
||||||
|
-- Der zweite Schritt ist also eine eigene Automatik, und was ihr fehlte, war
|
||||||
|
-- nur ein Weg, sich auf die erste zu beziehen.
|
||||||
|
--
|
||||||
|
-- Dieses Skript legt dafuer zwei Dinge an:
|
||||||
|
--
|
||||||
|
-- 1. den Datentyp `elapsed` - "so lange ist es her". Er verhaelt sich zu
|
||||||
|
-- einem Zeitpunkt wie `deltatime` zum Sonnenaufgang: der Messwert ist
|
||||||
|
-- der Bezugspunkt, die Schwelle der Versatz, das Vorzeichen steckt im
|
||||||
|
-- Operator. Gerechnet wird aber mit dem echten Abstand und nicht mit
|
||||||
|
-- der Uhrzeit innerhalb des Tages - sonst machte ein Lauf von
|
||||||
|
-- vorgestern die Bedingung heute wahr.
|
||||||
|
--
|
||||||
|
-- 2. das gerechnete Geraet "Automatiken", Gegenstueck zum schon
|
||||||
|
-- vorhandenen "Zeitpunkt". Seine Messwerte legt der Runner selbst an
|
||||||
|
-- und fuehrt sie nach: je Automatik einen, benannt wie sie, mit
|
||||||
|
-- "auto:<id>" als URL. Deshalb steht hier nur der Aktor.
|
||||||
|
--
|
||||||
|
-- Der Editor braucht dafuer keine Zeile Aenderung, um das anzubieten: er
|
||||||
|
-- listet Geraete und deren Messwerte, und "Automatiken" ist dann ein Geraet
|
||||||
|
-- wie jedes andere.
|
||||||
|
--
|
||||||
|
-- Einspielen:
|
||||||
|
-- mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < automatik_ausloeser.sql
|
||||||
|
--
|
||||||
|
-- Danach den Runner neu starten (startSolarServer.sh) - die Regelwerks-
|
||||||
|
-- Nachladung holt nur Daten, keinen neuen Code.
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
-- Die IDs in state_types sind feste Kennungen, keine Auto-Increment-Werte
|
||||||
|
-- (0 = bool, 5 = time, 7 = deltatime ...). 10 ist die naechste freie.
|
||||||
|
INSERT INTO state_types (id, type)
|
||||||
|
SELECT 10, 'elapsed'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM state_types WHERE type = 'elapsed');
|
||||||
|
|
||||||
|
-- Kein Raum und keine Etage: das Geraet steht nirgends im Haus, genau wie
|
||||||
|
-- "Zeitpunkt". Der Geraetewaehler im Editor sortiert es dadurch zu den
|
||||||
|
-- uebrigen ortlosen.
|
||||||
|
INSERT INTO actors (type, name, url, room)
|
||||||
|
SELECT 'LOGIC', 'Automatiken', 'Automatik', NULL
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM actors WHERE url = 'Automatik');
|
||||||
|
|
||||||
|
SELECT a.id AS aktor, a.name, a.url,
|
||||||
|
(SELECT id FROM state_types WHERE type = 'elapsed') AS typ_elapsed,
|
||||||
|
(SELECT COUNT(*) FROM actor_states WHERE actor_id = a.id) AS messwerte
|
||||||
|
FROM actors a WHERE a.url = 'Automatik';
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- ===========================================================================
|
||||||
|
-- Rahmenbedingungen erweitern: Ferien und Feiertage dreiwertig,
|
||||||
|
-- dazu die Tagessperre.
|
||||||
|
--
|
||||||
|
-- Einspielen:
|
||||||
|
-- mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < rahmen_erweitern.sql
|
||||||
|
--
|
||||||
|
-- Danach den Runner neu starten (startSolarServer.sh) - Daten laedt er
|
||||||
|
-- selbst nach, neuen Code nicht.
|
||||||
|
--
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 1. on_vacation / on_holiday sind jetzt dreiwertig
|
||||||
|
--
|
||||||
|
-- 0 = nie an solchen Tagen laeuft die Automatik nicht
|
||||||
|
-- 1 = egal der Tag aendert nichts (Vorgabe, wie bisher)
|
||||||
|
-- 2 = zusaetzlich zaehlt wie ein passender Wochentag
|
||||||
|
--
|
||||||
|
-- Die Spalten bleiben, wie sie sind - TINYINT traegt die 2 ohne Weiteres,
|
||||||
|
-- und die vorhandenen Zeilen stehen bereits auf 0 oder 1 und behalten damit
|
||||||
|
-- genau ihre bisherige Bedeutung. Es gibt nichts umzurechnen.
|
||||||
|
--
|
||||||
|
-- Gebraucht wird die 2 fuer den Fall, den man vorher gar nicht schreiben
|
||||||
|
-- konnte: "an Wochenenden und Feiertagen". Die Wochentagsmaske kennt nur
|
||||||
|
-- Samstag und Sonntag, ein Feiertag am Dienstag ist eben ein Dienstag. Mit
|
||||||
|
-- der 2 genuegt Sa+So angehakt und "Feiertage: zusaetzlich".
|
||||||
|
--
|
||||||
|
-- 2. once_per_day - nach dem Auslösen bis Mitternacht Ruhe
|
||||||
|
--
|
||||||
|
-- Fuer alles, was man hinterher von Hand wieder anders stellt. Ein Rollladen,
|
||||||
|
-- den man um acht nochmal zugezogen hat, soll nicht um neun von selbst wieder
|
||||||
|
-- auffahren, nur weil eine Wolke weiterzieht und die Helligkeitsschwelle ein
|
||||||
|
-- zweites Mal steigt.
|
||||||
|
--
|
||||||
|
-- Die Sperrzeit (lockout_secs) taugt dafuer nicht: sie zaehlt Sekunden und
|
||||||
|
-- muesste auf 86400 stehen, womit sie am naechsten Morgen den Termin knapp
|
||||||
|
-- verfehlen wuerde - eine Automatik, die heute um 07:00:05 lief, waere
|
||||||
|
-- morgen um 07:00:00 noch gesperrt und fiele einen ganzen Tag aus.
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
ALTER TABLE automations
|
||||||
|
MODIFY COLUMN on_vacation TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
COMMENT '0=nie, 1=egal, 2=zusaetzlich (zaehlt wie passender Wochentag)',
|
||||||
|
MODIFY COLUMN on_holiday TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
COMMENT '0=nie, 1=egal, 2=zusaetzlich (zaehlt wie passender Wochentag)';
|
||||||
|
|
||||||
|
-- Idempotent: beim zweiten Lauf steht die Spalte schon da.
|
||||||
|
SET @vorhanden := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'automations'
|
||||||
|
AND COLUMN_NAME = 'once_per_day');
|
||||||
|
|
||||||
|
SET @sql := IF(@vorhanden = 0,
|
||||||
|
'ALTER TABLE automations ADD COLUMN once_per_day TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
COMMENT ''nach dem Auslösen bis Mitternacht gesperrt''
|
||||||
|
AFTER force_once',
|
||||||
|
'DO 0');
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SELECT COLUMN_NAME, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'automations'
|
||||||
|
AND COLUMN_NAME IN ('on_vacation', 'on_holiday', 'once_per_day');
|
||||||
@@ -13,6 +13,9 @@ URL des Aktors in der Tabelle `actors`:
|
|||||||
Funkart des Geraets, deshalb wird dort nicht danach
|
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")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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()
|
||||||
+274
-42
@@ -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))
|
||||||
|
punkte = dict((s.name, []) for s in g._schluessel)
|
||||||
|
t, gebremst = 0.0, 0
|
||||||
|
while t < 24*3600:
|
||||||
|
sch = g._waehlen(t) if g._budgetSperre(t) <= 0.0 else None
|
||||||
|
if sch is None:
|
||||||
|
# Nichts frei - genau das tut _abrufen() dann auch.
|
||||||
|
gebremst += 1
|
||||||
|
t += max(5.0, g._fruehesteFreigabe(t))
|
||||||
|
continue
|
||||||
g._gezaehlt(t)
|
g._gezaehlt(t)
|
||||||
t += max(g._I_MIN, g._I_LADEN, g._budgetSperre(t))
|
punkte[sch.name].append(t)
|
||||||
schlimmste = 0
|
t += takt
|
||||||
for i, t0 in enumerate(zeitpunkte):
|
return punkte, gebremst
|
||||||
in_fenster = sum(1 for t1 in zeitpunkte[i:] if t1 - t0 < 3600)
|
|
||||||
schlimmste = max(schlimmste, in_fenster)
|
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
@@ -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
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
echo "Starting..."
|
|
||||||
# Change the name of the script here
|
|
||||||
SCRIPT_NAME="wecker.py"
|
|
||||||
LOG_FILE="wecker.log"
|
|
||||||
|
|
||||||
# Find the process ID of any running instance of the script
|
|
||||||
PID=$(ps aux | grep "$SCRIPT_NAME" | grep -v grep | awk '{print $2}')
|
|
||||||
|
|
||||||
# If a running instance was found, kill it
|
|
||||||
if [[ -n "$PID" ]]; then
|
|
||||||
echo "Killing process $PID"
|
|
||||||
kill "$PID"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# Start the script
|
|
||||||
cd "/volume1/homes/wagner/SolarManager/"
|
|
||||||
# Angehaengt statt ueberschrieben. Dieses Skript laeuft jede Nacht um drei,
|
|
||||||
# und mit "&>" war die Datei danach jedes Mal leer - was der Wecker am
|
|
||||||
# Vortag gemeldet hat, war morgens nicht mehr nachzulesen. Ausserdem braucht
|
|
||||||
# logrotate den Anhaengemodus: es leert die Datei mit copytruncate, waehrend
|
|
||||||
# der Prozess sie offen haelt.
|
|
||||||
/usr/bin/python3 "$SCRIPT_NAME" >> "$LOG_FILE" 2>&1 &
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import sys
|
|
||||||
import mysql.connector as pymysql
|
|
||||||
from mysql.connector import connect, Error
|
|
||||||
import requests
|
|
||||||
import datetime
|
|
||||||
import time
|
|
||||||
import socket
|
|
||||||
import traceback
|
|
||||||
import konfig
|
|
||||||
|
|
||||||
# MySQL-Datenbankverbindung. Zugangsdaten stehen in config.ini, siehe konfig.py.
|
|
||||||
DB_CONFIG = konfig.datenbank("alarm")
|
|
||||||
|
|
||||||
wled_url = "http://192.168.179.139/json" # URL zur WLED JSON API
|
|
||||||
LOG_SERVER = ("localhost", 13377) # UDP Logserver
|
|
||||||
|
|
||||||
# UDP Logging Funktion
|
|
||||||
def send_log(message):
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
log_message = f"{datetime.datetime.now().strftime('%b %d %H:%M:%S')} alarm_scheduler: {message}"
|
|
||||||
sock.sendto(log_message.encode(), LOG_SERVER)
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
def is_holiday():
|
|
||||||
today_str = datetime.datetime.today().strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
conn = pymysql.connect(**DB_CONFIG)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
query = "SELECT COUNT(*) FROM feiertage WHERE datetime = %s"
|
|
||||||
cursor.execute(query, (today_str,))
|
|
||||||
result = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return result[0] > 0
|
|
||||||
|
|
||||||
def is_school_holiday():
|
|
||||||
|
|
||||||
today_str = datetime.datetime.today().strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
conn = pymysql.connect(**DB_CONFIG)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
query = "SELECT COUNT(*) FROM ferien WHERE date_from <= %s AND date_to >= %s"
|
|
||||||
cursor.execute(query, (today_str, today_str))
|
|
||||||
result = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return result[0] > 0
|
|
||||||
|
|
||||||
def get_active_alarms():
|
|
||||||
today = datetime.datetime.today()
|
|
||||||
today_str = today.strftime("%Y-%m-%d")
|
|
||||||
weekday_map = {0: "mo", 1: "di", 2: "mi", 3: "do", 4: "fr", 5: "sa", 6: "so"}
|
|
||||||
weekday_col = weekday_map[today.weekday()]
|
|
||||||
holiday = is_holiday()
|
|
||||||
school_holiday = is_school_holiday()
|
|
||||||
|
|
||||||
conn = pymysql.connect(**DB_CONFIG)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
query = f"""
|
|
||||||
SELECT id, TIME_FORMAT(time_1, '%H:%i:%s'), mode FROM alarmtime
|
|
||||||
WHERE onoff != -1 AND {weekday_col} != -1 AND (wecked IS NULL OR wecked != '{today_str}')
|
|
||||||
"""
|
|
||||||
|
|
||||||
if holiday:
|
|
||||||
query += " AND mode NOT IN ('arbeit', 'schule')"
|
|
||||||
elif school_holiday:
|
|
||||||
query += " AND mode != 'schule'"
|
|
||||||
|
|
||||||
cursor.execute(query)
|
|
||||||
alarms = cursor.fetchall()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return [(alarm[0], alarm[1]) for alarm in alarms]
|
|
||||||
|
|
||||||
def trigger_wled_playlist():
|
|
||||||
data = {"ps": 5} # Beispiel: Playlist 1 aktivieren
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
for attempt in range(5):
|
|
||||||
try:
|
|
||||||
response = requests.post(wled_url, json=data, headers=headers)
|
|
||||||
send_log(f"WLED Response (Attempt {attempt + 1}): {response.text}")
|
|
||||||
try:
|
|
||||||
if response.json().get("success"):
|
|
||||||
return
|
|
||||||
except ValueError:
|
|
||||||
send_log(f"Value error: {response.text}") # Falls die Antwort kein gültiges JSON ist, weitermachen
|
|
||||||
except:
|
|
||||||
send_log(f"No response (Attempt {attempt + 1}): {traceback.print_exc()}")
|
|
||||||
time.sleep(4) # Kurze Wartezeit zwischen Versuchen
|
|
||||||
|
|
||||||
def mark_alarm_as_executed(alarm_id):
|
|
||||||
today_str = datetime.datetime.today().strftime("%Y-%m-%d")
|
|
||||||
conn = pymysql.connect(**DB_CONFIG)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
query = "UPDATE alarmtime SET wecked = %s WHERE id = %s"
|
|
||||||
cursor.execute(query, (today_str, alarm_id))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def main():
|
|
||||||
last_db_check = 0
|
|
||||||
active_alarms = []
|
|
||||||
|
|
||||||
while True:
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
# Nur alle 5 Minuten die Datenbankabfrage ausführen
|
|
||||||
if now - last_db_check >= 300:
|
|
||||||
active_alarms = get_active_alarms()
|
|
||||||
last_db_check = now
|
|
||||||
|
|
||||||
current_time = datetime.datetime.now().strftime("%H:%M:%S")
|
|
||||||
|
|
||||||
for alarm_id, alarm_time in active_alarms:
|
|
||||||
if current_time >= alarm_time:
|
|
||||||
send_log(f"Triggering WLED Playlist at {alarm_time}")
|
|
||||||
trigger_wled_playlist()
|
|
||||||
mark_alarm_as_executed(alarm_id)
|
|
||||||
active_alarms = get_active_alarms()
|
|
||||||
|
|
||||||
time.sleep(20) # Überprüfung alle 20 sec
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
-- ===========================================================================
|
||||||
|
-- Der Wecker als Automatik
|
||||||
|
--
|
||||||
|
-- Ersetzt wecker.py. Das Skript hat dasselbe getan, was der AutoAction-Runner
|
||||||
|
-- ohnehin kann, nur mit eigener Datenbank (`alarm`), eigenem Feiertags- und
|
||||||
|
-- Ferienkalender, eigenem UDP-Log und einer eigenen Endlosschleife:
|
||||||
|
--
|
||||||
|
-- wecker.py Automatik
|
||||||
|
-- --------------------------------- ----------------------------------
|
||||||
|
-- alarmtime.time_1 = 05:50:00 Bedingung "Uhrzeit = 05:50"
|
||||||
|
-- alarmtime.mo..fr != -1 weekdays = 31 (Bit 0..4, Mo bis Fr)
|
||||||
|
-- mode = schule, is_holiday() on_holiday = 0
|
||||||
|
-- mode = schule, is_school_holiday() on_vacation = 0
|
||||||
|
-- alarmtime.wecked = heute cond_met, die steigende Flanke
|
||||||
|
-- POST {"ps": 5} an 192.168.179.139 Kommando "Preset" des Aktors
|
||||||
|
-- MenasHimmel, Parameter preset = 5
|
||||||
|
--
|
||||||
|
-- Preset 5 heisst am Geraet "Wakeup".
|
||||||
|
--
|
||||||
|
-- Von den vier Zeilen in `alarmtime` war nur eine scharf (id 3, 05:50, Modus
|
||||||
|
-- schule); die drei anderen standen auf onoff = -1. Nur diese eine zieht hier
|
||||||
|
-- also um.
|
||||||
|
--
|
||||||
|
-- Einspielen:
|
||||||
|
-- mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < wecker_zu_automatik.sql
|
||||||
|
--
|
||||||
|
-- Dasselbe geht auch ohne SQL: im Dashboard unter "Automatismen", Etage EG,
|
||||||
|
-- neue Automatik anlegen - Bedingung "Uhrzeit um 05:50", Aktion
|
||||||
|
-- "MenasHimmel / Preset" mit Wakeup, Wochentage Mo-Fr, die beiden Haken
|
||||||
|
-- "auch in den Ferien" und "auch an Feiertagen" heraus. Der Runner liest das
|
||||||
|
-- Regelwerk alle 30 Sekunden neu, ein Neustart ist so oder so nicht noetig.
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
INSERT INTO automations
|
||||||
|
(name, floor, enabled, window_from, window_to, weekdays,
|
||||||
|
on_vacation, on_holiday, force_once, lockout_secs)
|
||||||
|
SELECT Wecker Magdalena, EG, 1, 00:00:00, 23:59:00, 31, 0, 0, 0, 900
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM automations WHERE name = Wecker Magdalena);
|
||||||
|
|
||||||
|
SET @auto = (SELECT id FROM automations WHERE name = Wecker Magdalena);
|
||||||
|
|
||||||
|
-- Bedingung: state_id 1 ist der gerechnete Messwert "Uhrzeit" des
|
||||||
|
-- Logic-Aktors. Operator = heisst "um", also ab dieser Minute noch
|
||||||
|
-- catchup_minutes lang wahr - ausgeloest wird trotzdem nur einmal, dafuer
|
||||||
|
-- sorgt die Flanke.
|
||||||
|
INSERT INTO automation_conditions
|
||||||
|
(automation_id, group_no, position, state_id, operator, value)
|
||||||
|
SELECT @auto, 0, 0, 1, =, 05:50
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM automation_conditions
|
||||||
|
WHERE automation_id = @auto AND state_id = 1);
|
||||||
|
|
||||||
|
-- Aktion: command_id 20 ist "Preset" des WLED-Aktors MenasHimmel
|
||||||
|
-- (wled://192.168.179.139), Vorlage {"ps":%preset%}.
|
||||||
|
INSERT INTO automation_actions (automation_id, position, command_id)
|
||||||
|
SELECT @auto, 0, 20
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM automation_actions
|
||||||
|
WHERE automation_id = @auto AND command_id = 20);
|
||||||
|
|
||||||
|
SET @aktion = (SELECT id FROM automation_actions
|
||||||
|
WHERE automation_id = @auto AND command_id = 20);
|
||||||
|
|
||||||
|
-- parameter_id 16 ist "preset" zu diesem Kommando.
|
||||||
|
INSERT INTO automation_action_params (action_id, parameter_id, value)
|
||||||
|
VALUES (@aktion, 16, "5")
|
||||||
|
ON DUPLICATE KEY UPDATE value = VALUES(value);
|
||||||
|
|
||||||
|
SELECT a.id, a.name, a.weekdays, a.on_vacation, a.on_holiday,
|
||||||
|
c.operator, c.value AS uhrzeit, p.value AS preset
|
||||||
|
FROM automations a
|
||||||
|
JOIN automation_conditions c ON c.automation_id = a.id
|
||||||
|
JOIN automation_actions ak ON ak.automation_id = a.id
|
||||||
|
JOIN automation_action_params p ON p.action_id = ak.id
|
||||||
|
WHERE a.name = "Wecker Magdalena";
|
||||||
Reference in New Issue
Block a user