Compare commits
12
Commits
32e571099b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6da199df1 | ||
|
|
813af4e8a2 | ||
|
|
b4e3531800 | ||
|
|
b40feeea23 | ||
|
|
e43ef83d47 | ||
|
|
e1e678f6c0 | ||
|
|
35382a6ae1 | ||
|
|
0a37d8d763 | ||
|
|
46ef245f7c | ||
|
|
8340e8356e | ||
|
|
8c62455b2c | ||
|
|
153658df81 |
@@ -1,6 +1,10 @@
|
|||||||
# Zugangsdaten - nichts davon gehoert ins Repository
|
# Zugangsdaten - nichts davon gehoert ins Repository
|
||||||
config.ini
|
config.ini
|
||||||
skoda.conf
|
skoda.conf
|
||||||
|
# Das VAPID-Schluesselpaar fuer Web Push. Der private Teil unterschreibt jede
|
||||||
|
# Meldung an die Handys - er gehoert nicht ins Repository. Neu erzeugen:
|
||||||
|
# python3 vapid_erzeugen.py (danach muessen sich alle Geraete neu anmelden).
|
||||||
|
push_vapid.json
|
||||||
*.conf
|
*.conf
|
||||||
secrets.conf
|
secrets.conf
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ flowchart LR
|
|||||||
WR["Wechselrichter<br/><small>GoodWe · OpenDTU · DTU-BI</small>"]
|
WR["Wechselrichter<br/><small>GoodWe · OpenDTU · DTU-BI</small>"]
|
||||||
EM3["Stromzähler<br/><small>2× Shelly EM3</small>"]
|
EM3["Stromzähler<br/><small>2× Shelly EM3</small>"]
|
||||||
HEIZ["Heizung"]
|
HEIZ["Heizung"]
|
||||||
|
BYD["BYD-Speicher<br/><small>BMU</small>"]
|
||||||
GOE["go-eCharger"]
|
GOE["go-eCharger"]
|
||||||
WPILOT["Wattpilot"]
|
WPILOT["Wattpilot"]
|
||||||
SKODA["MySkoda-API"]
|
SKODA["MySkoda-API"]
|
||||||
@@ -25,9 +26,10 @@ flowchart LR
|
|||||||
VENTILE["Ventilsteuerungen<br/><small>2× ESP32</small>"]
|
VENTILE["Ventilsteuerungen<br/><small>2× ESP32</small>"]
|
||||||
HAUSGER["Thermostate · Shellys · Schalter<br/><small>melden sich per<br/>Home-Assistant-Discovery</small>"]
|
HAUSGER["Thermostate · Shellys · Schalter<br/><small>melden sich per<br/>Home-Assistant-Discovery</small>"]
|
||||||
|
|
||||||
MGR["<b>solarManager.py</b><br/><small>gatherModbusData · gatherOpenDTUData<br/>gatherDTUBIData · gatherShellyEM3Data EG/UG<br/>gatherHeaterData · gatherSkodaData<br/>gatherWaterData · charger_goE</small>"]
|
MGR["<b>solarManager.py</b><br/><small>gatherModbusData · gatherOpenDTUData<br/>gatherDTUBIData · gatherShellyEM3Data EG/UG<br/>gatherHeaterData · gatherSkodaData<br/>gatherBYDData · gatherWaterData<br/>charger_goE</small>"]
|
||||||
WPB["<b>wattpilot_bruecke.py</b>"]
|
WPB["<b>wattpilot_bruecke.py</b>"]
|
||||||
WSB["<b>wsMQTTbridge.py</b>"]
|
WSB["<b>wsMQTTbridge.py</b>"]
|
||||||
|
FCS["<b>gatherForecastData.py</b><br/><small>Open-Meteo</small>"]
|
||||||
RAIN["<b>gatherRainData.py</b>"]
|
RAIN["<b>gatherRainData.py</b>"]
|
||||||
|
|
||||||
BROKER{{"<b>MQTT-Broker</b>"}}
|
BROKER{{"<b>MQTT-Broker</b>"}}
|
||||||
@@ -36,15 +38,18 @@ flowchart LR
|
|||||||
WR -->|Modbus · HTTP| MGR
|
WR -->|Modbus · HTTP| MGR
|
||||||
EM3 -->|HTTP| MGR
|
EM3 -->|HTTP| MGR
|
||||||
HEIZ -->|HTTP| MGR
|
HEIZ -->|HTTP| MGR
|
||||||
|
BYD -->|Modbus-RTU in TCP| MGR
|
||||||
GOE -->|HTTP| MGR
|
GOE -->|HTTP| MGR
|
||||||
WPILOT -->|WebSocket| MGR
|
WPILOT -->|WebSocket| MGR
|
||||||
SKODA -->|HTTPS| MGR
|
SKODA -->|HTTPS| MGR
|
||||||
WPILOT -->|WebSocket| WPB
|
WPILOT -->|WebSocket| WPB
|
||||||
STATION -->|WebSocket| WSB
|
STATION -->|WebSocket| WSB
|
||||||
METEO -->|HTTPS| RAIN
|
METEO -->|HTTPS| RAIN
|
||||||
|
METEO -->|HTTPS| FCS
|
||||||
|
|
||||||
SOLARLOG -->|zisterne| MGR
|
SOLARLOG -->|zisterne| MGR
|
||||||
MGR -->|"EnergyFlow · skoda"| SOLARLOG
|
MGR -->|"EnergyFlow · skoda<br/>skoda_ladepunkte · byd<br/>byd_zellen"| SOLARLOG
|
||||||
|
FCS -->|"weatherHours · weatherDays<br/>weatherTilted · weatherForecastLog"| SOLARLOG
|
||||||
|
|
||||||
MGR -->|"solarManager/#"| BROKER
|
MGR -->|"solarManager/#"| BROKER
|
||||||
WPB -->|"wattpilot/#"| BROKER
|
WPB -->|"wattpilot/#"| BROKER
|
||||||
@@ -57,22 +62,45 @@ flowchart LR
|
|||||||
classDef quelle fill:#eef4fb,stroke:#7f9dc0
|
classDef quelle fill:#eef4fb,stroke:#7f9dc0
|
||||||
classDef skript fill:#fff6e5,stroke:#d0a548
|
classDef skript fill:#fff6e5,stroke:#d0a548
|
||||||
classDef speicher fill:#eaf5ee,stroke:#6fa981
|
classDef speicher fill:#eaf5ee,stroke:#6fa981
|
||||||
class WR,EM3,HEIZ,GOE,WPILOT,SKODA,STATION,METEO,VENTILE,HAUSGER quelle
|
class WR,EM3,HEIZ,BYD,GOE,WPILOT,SKODA,STATION,METEO,VENTILE,HAUSGER quelle
|
||||||
class MGR,WPB,WSB,RAIN skript
|
class MGR,WPB,WSB,RAIN,FCS skript
|
||||||
class BROKER,SOLARLOG speicher
|
class BROKER,SOLARLOG speicher
|
||||||
```
|
```
|
||||||
|
|
||||||
Vier Prozesse holen aktiv etwas ab. Alles andere meldet sich von selbst: die
|
Fünf Prozesse holen aktiv etwas ab. Alles andere meldet sich von selbst: die
|
||||||
Ventilsteuerungen, die Wallbox und jedes Gerät, das
|
Ventilsteuerungen, die Wallbox und jedes Gerät, das
|
||||||
Home-Assistant-Discovery spricht, schreiben ohne Umweg auf den Broker. Für
|
Home-Assistant-Discovery spricht, schreiben ohne Umweg auf den Broker. Für
|
||||||
deren Messwerte ist also **kein Skript** zuständig — wer sie sucht, sucht am
|
deren Messwerte ist also **kein Skript** zuständig — wer sie sucht, sucht am
|
||||||
Gerät, nicht im Quelltext.
|
Gerät, nicht im Quelltext.
|
||||||
|
|
||||||
`solarManager.py` ist der Sonderfall: ein Prozess, aber acht
|
`solarManager.py` ist der Sonderfall: ein Prozess, aber neun
|
||||||
Sammler-Module, jedes für eine Anlage. Sie laufen nicht einzeln, sondern
|
Sammler-Module, jedes für eine Anlage. Sie laufen nicht einzeln, sondern
|
||||||
werden importiert; ihr gemeinsames Ergebnis geht als ein Baum nach
|
werden importiert; ihr gemeinsames Ergebnis geht als ein Baum nach
|
||||||
`solarManager/#`.
|
`solarManager/#`.
|
||||||
|
|
||||||
|
`gatherBYDData` liest die BMU der Batterie direkt (192.168.16.254:8080) und
|
||||||
|
legt alles unter `solarManager/byd/…` ab: Ladestand und Gesundheit laut BMU,
|
||||||
|
Zellspannungen (`zellen`, 128 Werte in mV, 16 je Modul), Temperaturen
|
||||||
|
(`temperaturen`, 64 Werte, 8 je Modul), Spreizung, Ausgleich, Fehlerbits und
|
||||||
|
die Gesamtzähler. `ok` fällt auf 0, wenn zehn Minuten nichts kam. Das
|
||||||
|
Netzwerkmodul der BMU startet etwa alle 102 Sekunden neu und antwortet nur
|
||||||
|
kurz danach — der Sammler klopft deshalb jede Sekunde an und bekommt so rund
|
||||||
|
alle 100 Sekunden einen vollständigen Satz. Einzelheiten im Kopf des Moduls
|
||||||
|
und, ausführlich, im Web-Repository unter `doku/byd.md`.
|
||||||
|
|
||||||
|
`gatherForecastData` holt alle zwanzig Minuten die Wettervorhersage von
|
||||||
|
Open-Meteo und schreibt sie nach `solarLog` (`weatherHours`, `weatherDays`,
|
||||||
|
`weatherForecastLog`, `weatherTilted`) — Grundlage des Meteogramms der
|
||||||
|
Weboberfläche. Es ist der einzige Sammler, der nichts nach MQTT
|
||||||
|
veröffentlicht: ein Meteogramm liest niemand im Sekundentakt.
|
||||||
|
|
||||||
|
**Es löscht nichts.** Die Tabellen sind zugleich ein Archiv — aus der
|
||||||
|
Einstrahlung und `EnergyFlow_hourly.pv_kwh` soll später eine eigene
|
||||||
|
Ertragsprognose gerechnet werden. `wetterarchiv_nachtragen.py` hat sie
|
||||||
|
einmalig aus der ERA5-Schnittstelle von Open-Meteo bis zum 02.04.2022
|
||||||
|
zurückgefüllt. Einzelheiten stehen im Web-Repository unter
|
||||||
|
`doku/meteogramm.md`.
|
||||||
|
|
||||||
Die Zisterne fällt aus der Reihe — ihr Stand steht in `solarLog`, und
|
Die Zisterne fällt aus der Reihe — ihr Stand steht in `solarLog`, und
|
||||||
`gatherWaterData` liest ihn von dort. Die Daten laufen also durch die
|
`gatherWaterData` liest ihn von dort. Die Daten laufen also durch die
|
||||||
Datenbank hindurch von einem Prozess zum nächsten.
|
Datenbank hindurch von einem Prozess zum nächsten.
|
||||||
@@ -147,7 +175,10 @@ von Hand, wenn sich am Bestand etwas geändert hat.
|
|||||||
| `Raumtemp/#`, `Power_*/#`, `wasser/#` | die Geräte selbst | Browser, Runner |
|
| `Raumtemp/#`, `Power_*/#`, `wasser/#` | die Geräte selbst | Browser, Runner |
|
||||||
| `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` |
|
||||||
|
| `byd`, `byd_zellen` (in `solarLog`) | `gatherBYDData.py`, alle 5 bzw. 15 Minuten | Web-Repo (`solarLog_byd.sql`) |
|
||||||
|
| `weatherHours`, `weatherDays`, `weatherTilted`, `weatherForecastLog` (in `solarLog`) | `gatherForecastData.py`, alle 20 Minuten; nichts wird geloescht | Web-Repo (`solarLog_weather.sql`) |
|
||||||
| **`homeMesh`** | `device_discovery.py`, Runner, Web-Editor | Runner, `ajax/*.php` |
|
| **`homeMesh`** | `device_discovery.py`, Runner, Web-Editor | Runner, `ajax/*.php` |
|
||||||
|
| `skoda.conf` | Einstellungen → Fahrzeug (`restricted/skodaKeys.php`) oder von Hand | `gatherSkodaData.py`, `ajax/skodaCmd.php` |
|
||||||
|
|
||||||
## Was wann startet
|
## Was wann startet
|
||||||
|
|
||||||
@@ -156,6 +187,7 @@ von Hand, wenn sich am Bestand etwas geändert hat.
|
|||||||
| `solarManager.py` | `startSolarServer.sh` (Aufgabenplaner, beim Hochfahren) |
|
| `solarManager.py` | `startSolarServer.sh` (Aufgabenplaner, beim Hochfahren) |
|
||||||
| `autoActions/autoaction_runner.py` | dito |
|
| `autoActions/autoaction_runner.py` | dito |
|
||||||
| `gatherRainData.py` | dito |
|
| `gatherRainData.py` | dito |
|
||||||
|
| `gatherForecastData.py` | dito |
|
||||||
| `wsMQTTbridge.py` | `startMQTTbridge.sh` |
|
| `wsMQTTbridge.py` | `startMQTTbridge.sh` |
|
||||||
| `wattpilot_bruecke.py` | `startWattpilotMQTT.sh` |
|
| `wattpilot_bruecke.py` | `startWattpilotMQTT.sh` |
|
||||||
| `autoActions/fetch_calendar.py` | Cronjob, einmal im Jahr |
|
| `autoActions/fetch_calendar.py` | Cronjob, einmal im Jahr |
|
||||||
@@ -197,16 +229,27 @@ nicht in den Ferien, nicht an Feiertagen, WLED-Preset 5 („Wakeup") auf
|
|||||||
im Kopf, wie dasselbe im Dashboard von Hand geht.
|
im Kopf, wie dasselbe im Dashboard von Hand geht.
|
||||||
|
|
||||||
Damit ist die Weckzeit dort einstellbar, wo alles andere auch eingestellt
|
Damit ist die Weckzeit dort einstellbar, wo alles andere auch eingestellt
|
||||||
wird — unter „Automatismen", Etage EG. Die Datenbank `alarm` liest niemand
|
wird — unter „Automatismen", Etage EG. Die Datenbank `alarm` ist gelöscht,
|
||||||
mehr; sie steht noch da, mitsamt dem Abschnitt `[alarm]` in der `config.ini`,
|
und `config.ini.example` hat keinen Abschnitt `[alarm]` mehr. Ein
|
||||||
und kann weg, sobald der erste Morgen ohne `wecker.py` durch ist.
|
übriggebliebener `[alarm]` in einer echten `config.ini` stört nicht, liest
|
||||||
|
aber auch niemand.
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
Zugangsdaten und Standort stehen in `config.ini` (Vorlage:
|
Zugangsdaten und Standort stehen in `config.ini` (Vorlage:
|
||||||
`config.ini.example`), gelesen über `konfig.py`; der Runner hat seine eigene
|
`config.ini.example`), gelesen über `konfig.py`; der Runner hat seine eigene
|
||||||
unter `autoActions/`. Beide sind per `.gitignore` ausgenommen — nichts davon
|
unter `autoActions/`. Die Zugangsschlüssel der MyŠkoda-API stehen getrennt in
|
||||||
gehört in den Quelltext.
|
`skoda.conf` (Vorlage: `skoda.conf.example`), weil auch die Weboberfläche sie
|
||||||
|
liest und im Reiter „Fahrzeug" neue einträgt. Alle drei sind per
|
||||||
|
`.gitignore` ausgenommen — nichts davon gehört in den Quelltext.
|
||||||
|
|
||||||
|
## Werkzeuge
|
||||||
|
|
||||||
|
| Skript | wofür |
|
||||||
|
|---|---|
|
||||||
|
| `skoda_test.py` | prüft Zerlegung und Kontingent-Buchführung von `gatherSkodaData.py` ohne Fahrzeug und ohne Netz |
|
||||||
|
| `skoda_ladepunkte_nachtragen.py` | holt den Wallbox-Verlauf vergangener Ladungen aus `EnergyFlow` nach `skoda_ladepunkte`, solange er dort noch nicht ausgedünnt ist (`--probe` schreibt nichts) |
|
||||||
|
| `wecker_zu_automatik.sql` | hat den alten Wecker als Automatik angelegt; nur noch zum Nachlesen |
|
||||||
|
|
||||||
Mehr zum Runner selbst, zum Aufbau einer Automatik und zu den Transporten
|
Mehr zum Runner selbst, zum Aufbau einer Automatik und zu den Transporten
|
||||||
steht in [autoActions/README.md](autoActions/README.md).
|
steht in [autoActions/README.md](autoActions/README.md).
|
||||||
|
|||||||
+33
-12
@@ -93,8 +93,7 @@ kostete jeder Aussetzer die Automatik für den ganzen Tag — und Aussetzer gab
|
|||||||
es reichlich, weil die Uhr am Geräte-Poll hing und jede dritte Minute
|
es reichlich, weil die Uhr am Geräte-Poll hing und jede dritte Minute
|
||||||
übersprang. Jetzt gilt die Bedingung fünf Minuten lang (einstellbar), die
|
übersprang. Jetzt gilt die Bedingung fünf Minuten lang (einstellbar), die
|
||||||
Flanke sorgt weiterhin für genau einen Lauf, und ein Neustart mitten im
|
Flanke sorgt weiterhin für genau einen Lauf, und ein Neustart mitten im
|
||||||
Fenster holt den Lauf nach. Die breiten Zeitfenster in `auto_watering.py`
|
Fenster holt den Lauf nach.
|
||||||
folgen derselben Überlegung.
|
|
||||||
|
|
||||||
Beim Sonnenauf- und -untergang ist der Wert ein Versatz, und der kann davor
|
Beim Sonnenauf- und -untergang ist der Wert ein Versatz, und der kann davor
|
||||||
oder danach liegen — deshalb dieselben drei Fälle mal zwei: `+ 00:30` eine
|
oder danach liegen — deshalb dieselben drei Fälle mal zwei: `+ 00:30` eine
|
||||||
@@ -116,7 +115,8 @@ das Fenster zugeht und in diesem Fenster noch nichts passiert ist.
|
|||||||
|
|
||||||
## Der Rahmen
|
## Der Rahmen
|
||||||
|
|
||||||
Vor jeder Auswertung fragt `tag_passt()`, ob der heutige Tag überhaupt zählt.
|
Vor jeder Auswertung fragt `tag_passt()`, ob der Tag überhaupt zählt — heute,
|
||||||
|
oder bei einer Vorabend-Regel morgen (siehe [Am Vorabend](#am-vorabend)).
|
||||||
Drei Dinge entscheiden das: die Wochentagsmaske (`weekdays`, ein Bit je Tag,
|
Drei Dinge entscheiden das: die Wochentagsmaske (`weekdays`, ein Bit je Tag,
|
||||||
Montag ist Bit 0), und Ferien und Feiertage aus `calendar_days`.
|
Montag ist Bit 0), und Ferien und Feiertage aus `calendar_days`.
|
||||||
|
|
||||||
@@ -147,6 +147,19 @@ vorhandenen Zeilen stehen auf `0` oder `1` und behalten damit genau ihre
|
|||||||
bisherige Bedeutung. Umzurechnen gibt es nichts, die Migration
|
bisherige Bedeutung. Umzurechnen gibt es nichts, die Migration
|
||||||
(`rahmen_erweitern.sql`) setzt nur Kommentare und legt `once_per_day` an.
|
(`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
|
## Nur einmal am Tag
|
||||||
|
|
||||||
`once_per_day` sperrt eine Automatik nach dem Auslösen bis Mitternacht.
|
`once_per_day` sperrt eine Automatik nach dem Auslösen bis Mitternacht.
|
||||||
@@ -209,9 +222,10 @@ vorhandenen „Zeitpunkt". Jede Automatik ist dort ein Messwert, ihr Wert ist
|
|||||||
der Zeitpunkt der letzten Auslösung:
|
der Zeitpunkt der letzten Auslösung:
|
||||||
|
|
||||||
```
|
```
|
||||||
Wecker Magdalena um 05:50 → Licht auf Wakeup
|
Wecker Magdalena um 05:50 → Licht auf Wakeup
|
||||||
Rollladen Magdalena Wecker Magdalena + 00:10
|
Wecker Magdalena Rollos Wecker Magdalena ab + 00:10
|
||||||
UND Sonnenaufgang ab + 00:00 → Rollladen auf
|
UND Sonne Ost > 200 Lux → Rollläden auf
|
||||||
|
Schlafzimmer morgens Wecker Magdalena Rollos ab + 00:00 → Rollladen auf
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Editor braucht dafür keine Zeile Änderung. Er listet Geräte und deren
|
Der Editor braucht dafür keine Zeile Änderung. Er listet Geräte und deren
|
||||||
@@ -374,6 +388,7 @@ Parameter dort keine eigene URL — ihr Name *ist* der Platzhalter.
|
|||||||
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 < automatik_ausloeser.sql
|
||||||
mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < rahmen_erweitern.sql
|
mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < rahmen_erweitern.sql
|
||||||
|
mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < vorabend.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
|
||||||
```
|
```
|
||||||
@@ -385,8 +400,9 @@ Messwerte darunter legt der Runner selbst an. Ohne das Skript läuft alles
|
|||||||
wählen.
|
wählen.
|
||||||
|
|
||||||
`rahmen_erweitern.sql` gehört zum Rahmen: es beschriftet `on_vacation` und
|
`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
|
`on_holiday` mit ihren drei Bedeutungen und legt `once_per_day` an.
|
||||||
Skripte sind idempotent — ein zweiter Lauf schadet nicht.
|
`vorabend.sql` legt `next_day` an. Alle drei 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.
|
||||||
@@ -399,13 +415,15 @@ nicht im Web-Verzeichnis:
|
|||||||
```
|
```
|
||||||
/volume1/homes/wagner/SolarManager/
|
/volume1/homes/wagner/SolarManager/
|
||||||
├── solarManager.py
|
├── solarManager.py
|
||||||
├── startSolarServer.sh startet beide, siehe unten
|
├── gatherRainData.py
|
||||||
|
├── startSolarServer.sh startet alle drei, siehe unten
|
||||||
└── autoActions/
|
└── autoActions/
|
||||||
├── autoaction_runner.py
|
├── autoaction_runner.py
|
||||||
├── transports.py
|
├── transports.py
|
||||||
├── fetch_calendar.py
|
├── fetch_calendar.py
|
||||||
├── automatik_ausloeser.sql einmalig, siehe Einrichten
|
├── automatik_ausloeser.sql einmalig, siehe Einrichten
|
||||||
├── rahmen_erweitern.sql einmalig, siehe Einrichten
|
├── rahmen_erweitern.sql einmalig, siehe Einrichten
|
||||||
|
├── vorabend.sql einmalig, siehe Einrichten
|
||||||
└── config.ini Zugangsdaten, nicht im Git
|
└── config.ini Zugangsdaten, nicht im Git
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -414,10 +432,13 @@ Das Web-UI kennt diesen Pfad nicht — Browser und Runner reden ausschließlich
|
|||||||
sobald im Browser etwas gespeichert wurde; ein Neustart nach jeder Änderung ist
|
sobald im Browser etwas gespeichert wurde; ein Neustart nach jeder Änderung ist
|
||||||
nicht nötig.
|
nicht nötig.
|
||||||
|
|
||||||
`startSolarServer.sh` startet `solarManager.py` und den Runner gemeinsam und
|
`startSolarServer.sh` startet `solarManager.py`, den Runner und
|
||||||
beendet vorher, was schon läuft. Aufgerufen wird es beim Booten (auf der
|
`gatherRainData.py` gemeinsam und beendet vorher, was schon läuft. Aufgerufen wird es beim Booten (auf der
|
||||||
Synology über den Aufgabenplaner, Ereignis „Hochfahren", als root); dasselbe
|
Synology über den Aufgabenplaner, Ereignis „Hochfahren", als root); dasselbe
|
||||||
Skript von Hand aufzurufen ist der normale Weg, den Runner neu zu starten.
|
Skript von Hand aufzurufen ist der normale Weg, den Runner neu zu starten —
|
||||||
|
als `wagner` genügt, ohne sudo. Über SSH abgekoppelt, damit die Prozesse das
|
||||||
|
Abmelden überleben:
|
||||||
|
`setsid nohup bash startSolarServer.sh > /tmp/restart_solar.out 2>&1 < /dev/null &`.
|
||||||
Zwei Instanzen dürfen nie gleichzeitig laufen — sie würden jedes Kommando
|
Zwei Instanzen dürfen nie gleichzeitig laufen — sie würden jedes Kommando
|
||||||
doppelt schicken und sich gegenseitig vom MQTT-Broker werfen, weil beide
|
doppelt schicken und sich gegenseitig vom MQTT-Broker werfen, weil beide
|
||||||
dieselbe Client-Kennung benutzen. Genau davor schützt das Beenden am Anfang.
|
dieselbe Client-Kennung benutzen. Genau davor schützt das Beenden am Anfang.
|
||||||
|
|||||||
@@ -35,14 +35,23 @@ Nur die steigende Flanke loest aus: `automations.cond_met` haelt fest, ob die
|
|||||||
Bedingung beim letzten Durchlauf schon erfuellt war. Ohne das wuerde
|
Bedingung beim letzten Durchlauf schon erfuellt war. Ohne das wuerde
|
||||||
"Temperatur ueber 22 Grad" bei jedem Takt erneut feuern.
|
"Temperatur ueber 22 Grad" bei jedem Takt erneut feuern.
|
||||||
|
|
||||||
|
Die Haltezeit (`automations.hold_secs`) schiebt diese Flanke nach hinten: Erst
|
||||||
|
wenn die Bedingung so viele Sekunden am Stueck erfuellt war, gilt sie als
|
||||||
|
erfuellt. Gedacht fuer Dauerzustaende, die sich nicht in einem einzelnen
|
||||||
|
Messwert zeigen - "der Wasserzaehler laeuft" ist jedes Haendewaschen, "laeuft
|
||||||
|
seit einer halben Stunde ohne Pause" ist ein offener Hahn. Gezaehlt wird hier
|
||||||
|
im Speicher (`erfuellt_seit`) und nicht in der Datenbank: es ist ein
|
||||||
|
Laufzustand wie `war_aktiv`, und ein Neustart soll ihn bewusst verwerfen -
|
||||||
|
nach einem Neustart weiss niemand, ob die Bedingung in der Zwischenzeit
|
||||||
|
durchgehend anlag.
|
||||||
|
|
||||||
Zeit-Ausloeser gibt es in drei Formen: "um 16:30" gilt ab dieser Minute noch
|
Zeit-Ausloeser gibt es in drei Formen: "um 16:30" gilt ab dieser Minute noch
|
||||||
catchup_minutes lang, "ab 16:30" von da an bis Mitternacht, "vor 16:30" bis
|
catchup_minutes lang, "ab 16:30" von da an bis Mitternacht, "vor 16:30" bis
|
||||||
dahin. Ausgeloest wird in allen drei Faellen nur einmal, eben wegen der
|
dahin. Ausgeloest wird in allen drei Faellen nur einmal, eben wegen der
|
||||||
Flanke. Das Nachholfenster bei "um" ist der Ersatz fuer die frueher
|
Flanke. Das Nachholfenster bei "um" ist der Ersatz fuer die frueher
|
||||||
verlangte Punktgenauigkeit: ein Neustart, ein haengendes Geraet oder ein
|
verlangte Punktgenauigkeit: ein Neustart, ein haengendes Geraet oder ein
|
||||||
langsamer Durchlauf kosten die Automatik nicht mehr den ganzen Tag, und weil
|
langsamer Durchlauf kosten die Automatik nicht mehr den ganzen Tag, und weil
|
||||||
nur die Flanke zaehlt, laeuft sie trotzdem hoechstens einmal. Dieselbe
|
nur die Flanke zaehlt, laeuft sie trotzdem hoechstens einmal.
|
||||||
Ueberlegung steht hinter den breiten Zeitfenstern in auto_watering.py.
|
|
||||||
|
|
||||||
Beim Sonnenauf- und -untergang traegt der Operator zusaetzlich das
|
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"
|
||||||
@@ -76,7 +85,8 @@ import pymysql
|
|||||||
import requests
|
import requests
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
from transports import (AUTOMATIK_URL, AutomatikTransport, HTTPTransport,
|
from transports import (AUTOMATIK_URL, BENACHRICHTIGUNG_TOPIC,
|
||||||
|
BenachrichtigungTransport, AutomatikTransport, HTTPTransport,
|
||||||
LogicTransport, MQTTTransport, TahomaTransport,
|
LogicTransport, MQTTTransport, TahomaTransport,
|
||||||
WLEDTransport, ausloeser_kennung, ausloeser_url,
|
WLEDTransport, ausloeser_kennung, ausloeser_url,
|
||||||
ist_topic)
|
ist_topic)
|
||||||
@@ -114,6 +124,18 @@ class Config:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return vorgabe
|
return vorgabe
|
||||||
|
|
||||||
|
def abschnitt(self, sektion):
|
||||||
|
"""
|
||||||
|
Einen ganzen Abschnitt als Dict.
|
||||||
|
|
||||||
|
Fuer den Mailzugang: der besteht aus einem halben Dutzend Feldern und
|
||||||
|
wird als Ganzes an den Transport weitergereicht, statt sechsmal
|
||||||
|
einzeln abgefragt zu werden.
|
||||||
|
"""
|
||||||
|
if not self.cfg.has_section(sektion):
|
||||||
|
return {}
|
||||||
|
return {k: v.strip() for k, v in self.cfg.items(sektion)}
|
||||||
|
|
||||||
def ja(self, sektion, schluessel, vorgabe=False):
|
def ja(self, sektion, schluessel, vorgabe=False):
|
||||||
return self.text(sektion, schluessel, str(vorgabe)).lower() in ("true", "1", "yes", "on")
|
return self.text(sektion, schluessel, str(vorgabe)).lower() in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
@@ -358,10 +380,17 @@ def uhrzeit_erfuellt(typ, op, soll, jetzt, fenster):
|
|||||||
soll_d = als_datum(soll)
|
soll_d = als_datum(soll)
|
||||||
if soll_d is None:
|
if soll_d is None:
|
||||||
return False
|
return False
|
||||||
ist_d = jetzt.date()
|
# Nur Monat und Tag zaehlen, das Jahr der Eingabe wird ignoriert -
|
||||||
if op == "=": return ist_d == soll_d
|
# eine Datumsbedingung soll sich jaehrlich wiederholen, ohne dass sie
|
||||||
if op == "<": return ist_d < soll_d
|
# jedes Jahr von Hand nachgezogen werden muss. Der Vergleich zweier
|
||||||
return ist_d >= soll_d
|
# (Monat, Tag)-Paare ordnet sich wie das Kalenderjahr selbst; nur ein
|
||||||
|
# Fenster, das über den Jahreswechsel reicht, laesst sich damit nicht
|
||||||
|
# in einer einzigen Bedingung ausdruecken (siehe doku/automatiken.md).
|
||||||
|
ist_mt = (jetzt.month, jetzt.day)
|
||||||
|
soll_mt = (soll_d.month, soll_d.day)
|
||||||
|
if op == "=": return ist_mt == soll_mt
|
||||||
|
if op == "<": return ist_mt < soll_mt
|
||||||
|
return ist_mt >= soll_mt
|
||||||
|
|
||||||
jetzt_m, soll_m = jetzt.hour * 60 + jetzt.minute, minuten(soll)
|
jetzt_m, soll_m = jetzt.hour * 60 + jetzt.minute, minuten(soll)
|
||||||
if op == "=": return im_nachholfenster(jetzt_m, soll_m, fenster)
|
if op == "=": return im_nachholfenster(jetzt_m, soll_m, fenster)
|
||||||
@@ -515,9 +544,30 @@ def im_zeitfenster(jetzt, von, bis):
|
|||||||
NIE, EGAL, ZUSAETZLICH = 0, 1, 2
|
NIE, EGAL, ZUSAETZLICH = 0, 1, 2
|
||||||
|
|
||||||
|
|
||||||
def tag_passt(automatik, jetzt, kalender):
|
def gemeinter_tag(automatik, jetzt):
|
||||||
"""
|
"""
|
||||||
Faellt der heutige Tag in den Rahmen der Automatik?
|
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
|
Die Wochentage sind eine Maske, Ferien und Feiertage haben je drei
|
||||||
Zustaende. Zwei davon gab es immer: EGAL (der Tag aendert nichts, die
|
Zustaende. Zwei davon gab es immer: EGAL (der Tag aendert nichts, die
|
||||||
@@ -536,7 +586,7 @@ def tag_passt(automatik, jetzt, kalender):
|
|||||||
den Ferien nicht - ein Verbot schlaegt eine Erweiterung. Anders herum
|
den Ferien nicht - ein Verbot schlaegt eine Erweiterung. Anders herum
|
||||||
liesse sich "nie" nicht mehr verlassen.
|
liesse sich "nie" nicht mehr verlassen.
|
||||||
"""
|
"""
|
||||||
passt = bool(automatik["weekdays"] & (1 << jetzt.weekday()))
|
passt = bool(automatik["weekdays"] & (1 << tag.weekday()))
|
||||||
if kalender["feiertag"] and automatik["on_holiday"] == ZUSAETZLICH:
|
if kalender["feiertag"] and automatik["on_holiday"] == ZUSAETZLICH:
|
||||||
passt = True
|
passt = True
|
||||||
if kalender["ferien"] and automatik["on_vacation"] == ZUSAETZLICH:
|
if kalender["ferien"] and automatik["on_vacation"] == ZUSAETZLICH:
|
||||||
@@ -717,8 +767,9 @@ class Runner:
|
|||||||
self.geschrieben_um = {} # state_id -> wann das war
|
self.geschrieben_um = {} # state_id -> wann das war
|
||||||
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.erfuellt_seit = {} # automation_id -> seit wann die Bedingung anliegt
|
||||||
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.reihenfolge = [] # automation_id, Ausloeser vor Nachfolger
|
||||||
self.ausloeser_states = {} # automation_id -> state_id des Ausloesers
|
self.ausloeser_states = {} # automation_id -> state_id des Ausloesers
|
||||||
@@ -735,7 +786,25 @@ class Runner:
|
|||||||
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),
|
AutomatikTransport(self.ausloesezeiten),
|
||||||
|
BenachrichtigungTransport(self.push_abos, self.push_abo_weg,
|
||||||
|
self.push_abo_erfolg,
|
||||||
|
config.abschnitt("mail"),
|
||||||
|
# Ein leerer Eintrag in der config.ini ist
|
||||||
|
# vorhanden, aber leer - die Vorgabe von
|
||||||
|
# text() greift dann nicht.
|
||||||
|
(config.text("push", "schluessel")
|
||||||
|
or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"..", "push_vapid.json")),
|
||||||
|
self.dry_run),
|
||||||
]
|
]
|
||||||
|
# Meldungen, die nicht aus einer Automatik kommen: die Probe aus den
|
||||||
|
# Einstellungen, spaeter vielleicht ein Skript. Derselbe Weg, dieselbe
|
||||||
|
# Zustellung - nur ohne Umweg ueber eine Regel.
|
||||||
|
self.meldungen = queue.Queue()
|
||||||
|
try:
|
||||||
|
self.mqtt.subscribe(BENACHRICHTIGUNG_TOPIC)
|
||||||
|
except Exception as fehler:
|
||||||
|
logger.warning("Meldungs-Topic nicht abonnierbar: %r", fehler)
|
||||||
self.regelwerk = None
|
self.regelwerk = None
|
||||||
|
|
||||||
# --- Aufbau ----------------------------------------------------------
|
# --- Aufbau ----------------------------------------------------------
|
||||||
@@ -754,10 +823,83 @@ class Runner:
|
|||||||
|
|
||||||
def _mqtt_nachricht(self, client, userdata, nachricht):
|
def _mqtt_nachricht(self, client, userdata, nachricht):
|
||||||
# Der Client laeuft schon, waehrend __init__ noch die Transporte baut.
|
# Der Client laeuft schon, waehrend __init__ noch die Transporte baut.
|
||||||
|
if nachricht.topic.startswith("benachrichtigung/"):
|
||||||
|
# Nicht hier zustellen: Dieser Rueckruf laeuft im MQTT-Faden, und
|
||||||
|
# eine Datenbankverbindung gehoert einem Faden. Der naechste Takt
|
||||||
|
# holt es ab.
|
||||||
|
schlange = getattr(self, "meldungen", None)
|
||||||
|
if schlange is not None:
|
||||||
|
schlange.put((nachricht.topic.rsplit("/", 1)[-1], nachricht.payload))
|
||||||
|
return
|
||||||
for transport in getattr(self, "transporte", []):
|
for transport in getattr(self, "transporte", []):
|
||||||
if isinstance(transport, MQTTTransport):
|
if isinstance(transport, MQTTTransport):
|
||||||
transport.nachricht(nachricht.topic, nachricht.payload)
|
transport.nachricht(nachricht.topic, nachricht.payload)
|
||||||
|
|
||||||
|
# --- Benachrichtigungen ----------------------------------------------
|
||||||
|
|
||||||
|
def benachrichtigung(self):
|
||||||
|
"""Der Transport fuer die Meldungen, oder None."""
|
||||||
|
for transport in self.transporte:
|
||||||
|
if isinstance(transport, BenachrichtigungTransport):
|
||||||
|
return transport
|
||||||
|
return None
|
||||||
|
|
||||||
|
def meldungen_abarbeiten(self):
|
||||||
|
"""
|
||||||
|
Was ueber benachrichtigung/# hereinkam, zustellen.
|
||||||
|
|
||||||
|
Nutzlast ist JSON ({"titel": ..., "text": ...}); ein blosser Text
|
||||||
|
geht auch durch und wird zum Textkoerper.
|
||||||
|
"""
|
||||||
|
transport = self.benachrichtigung()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
kanal, rohtext = self.meldungen.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
return
|
||||||
|
if transport is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = rohtext.decode("utf-8", "replace") if isinstance(rohtext, bytes) else str(rohtext)
|
||||||
|
try:
|
||||||
|
daten = json.loads(text)
|
||||||
|
if not isinstance(daten, dict):
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
daten = {"text": text}
|
||||||
|
if kanal == "mail":
|
||||||
|
transport.mail_senden(daten.get("betreff") or daten.get("titel") or "Smarthome",
|
||||||
|
daten.get("text") or "")
|
||||||
|
else:
|
||||||
|
transport.push(daten.get("titel") or "Smarthome", daten.get("text") or "")
|
||||||
|
except Exception as fehler:
|
||||||
|
logger.warning("Meldung (%s) nicht zustellbar: %r", kanal, fehler)
|
||||||
|
|
||||||
|
def push_abos(self):
|
||||||
|
"""Die angemeldeten Geraete. Fehlt die Tabelle, gibt es eben keine."""
|
||||||
|
try:
|
||||||
|
with self.db.cursor() as c:
|
||||||
|
c.execute("SELECT id, endpoint, p256dh, auth, name FROM push_abos")
|
||||||
|
return list(c.fetchall())
|
||||||
|
except Exception as fehler:
|
||||||
|
logger.debug("push_abos nicht lesbar: %r", fehler)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def push_abo_weg(self, abo_id, grund):
|
||||||
|
try:
|
||||||
|
with self.db.cursor() as c:
|
||||||
|
c.execute("DELETE FROM push_abos WHERE id = %s", (abo_id,))
|
||||||
|
except Exception as fehler:
|
||||||
|
logger.warning("Push-Abo %s nicht loeschbar: %r", abo_id, fehler)
|
||||||
|
|
||||||
|
def push_abo_erfolg(self, abo_id):
|
||||||
|
try:
|
||||||
|
with self.db.cursor() as c:
|
||||||
|
c.execute("UPDATE push_abos SET zuletzt = NOW(), fehler = '' WHERE id = %s",
|
||||||
|
(abo_id,))
|
||||||
|
except Exception as fehler:
|
||||||
|
logger.debug("Push-Abo %s nicht fortschreibbar: %r", abo_id, fehler)
|
||||||
|
|
||||||
def transport_fuer(self, actor_url):
|
def transport_fuer(self, actor_url):
|
||||||
for transport in self.transporte:
|
for transport in self.transporte:
|
||||||
if transport.passt(actor_url):
|
if transport.passt(actor_url):
|
||||||
@@ -991,21 +1133,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 -----------------------------------------------------------
|
||||||
@@ -1098,6 +1249,9 @@ class Runner:
|
|||||||
continue
|
continue
|
||||||
auftrag = {
|
auftrag = {
|
||||||
"actor_url": kommando["actor_url"],
|
"actor_url": kommando["actor_url"],
|
||||||
|
# Nur fuer Protokollmeldungen des Transports: "io://1215-.../332898"
|
||||||
|
# sagt niemandem, welcher Rollladen gemeint ist.
|
||||||
|
"actor_name": kommando["actor_name"],
|
||||||
"command_url": kommando["command_url"],
|
"command_url": kommando["command_url"],
|
||||||
"params": [{"url": p["url"], "name": p["parameter_name"],
|
"params": [{"url": p["url"], "name": p["parameter_name"],
|
||||||
"wert": aktion["werte"].get(p["id"], "")}
|
"wert": aktion["werte"].get(p["id"], "")}
|
||||||
@@ -1163,13 +1317,13 @@ class Runner:
|
|||||||
|
|
||||||
def durchlauf(self, fenster=NACHHOLFENSTER):
|
def durchlauf(self, fenster=NACHHOLFENSTER):
|
||||||
jetzt = datetime.now()
|
jetzt = datetime.now()
|
||||||
kalender = self.kalender()
|
|
||||||
|
|
||||||
for automation_id in self.reihenfolge:
|
for automation_id in self.reihenfolge:
|
||||||
automatik = self.regelwerk.automatiken.get(automation_id)
|
automatik = self.regelwerk.automatiken.get(automation_id)
|
||||||
if automatik is None:
|
if automatik is None:
|
||||||
continue
|
continue
|
||||||
aktiv = (tag_passt(automatik, jetzt, kalender)
|
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)
|
||||||
|
|
||||||
@@ -1178,7 +1332,12 @@ class Runner:
|
|||||||
self.lief_im_fenster[automatik["id"]] = False
|
self.lief_im_fenster[automatik["id"]] = False
|
||||||
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"]:
|
# Mit Haltezeit zaehlt nicht, ob die Bedingung erfuellt ist,
|
||||||
|
# sondern ob sie es lange genug am Stueck ist. Weil das Ergebnis
|
||||||
|
# an dieselbe Stelle tritt, bleibt alles danach - Flanke,
|
||||||
|
# Tagessperre, Sperrzeit, Protokoll - unveraendert.
|
||||||
|
reif = self.haltezeit_reif(automatik, erfuellt, jetzt)
|
||||||
|
if reif and not automatik["cond_met"]:
|
||||||
if automatik.get("once_per_day") and self.lief_heute(automatik, jetzt):
|
if automatik.get("once_per_day") and self.lief_heute(automatik, jetzt):
|
||||||
logger.debug("%s: lief heute schon, bis Mitternacht gesperrt",
|
logger.debug("%s: lief heute schon, bis Mitternacht gesperrt",
|
||||||
automatik["name"])
|
automatik["name"])
|
||||||
@@ -1187,7 +1346,7 @@ class Runner:
|
|||||||
automatik["name"])
|
automatik["name"])
|
||||||
else:
|
else:
|
||||||
self.ausloesen(automatik, "fired")
|
self.ausloesen(automatik, "fired")
|
||||||
self.flanke_merken(automatik, erfuellt)
|
self.flanke_merken(automatik, reif)
|
||||||
else:
|
else:
|
||||||
# Das Fenster ist gerade zugegangen. Wer "auf jeden Fall"
|
# Das Fenster ist gerade zugegangen. Wer "auf jeden Fall"
|
||||||
# angehakt hat, bekommt jetzt seinen Lauf - aber nur, wenn in
|
# angehakt hat, bekommt jetzt seinen Lauf - aber nur, wenn in
|
||||||
@@ -1200,12 +1359,57 @@ class Runner:
|
|||||||
automatik["name"])
|
automatik["name"])
|
||||||
self.ausloesen(automatik, "forced")
|
self.ausloesen(automatik, "forced")
|
||||||
# Ausserhalb des Fensters die Flanke zuruecksetzen, sonst
|
# Ausserhalb des Fensters die Flanke zuruecksetzen, sonst
|
||||||
# koennte sie im naechsten Fenster nicht mehr steigen.
|
# koennte sie im naechsten Fenster nicht mehr steigen. Eine
|
||||||
|
# angefangene Haltezeit verfaellt mit: sie soll innerhalb des
|
||||||
|
# Fensters voll gelaufen sein, nicht ueber dessen Rand hinweg.
|
||||||
self.flanke_merken(automatik, False)
|
self.flanke_merken(automatik, False)
|
||||||
|
self.erfuellt_seit.pop(automatik["id"], None)
|
||||||
self.lief_im_fenster[automatik["id"]] = False
|
self.lief_im_fenster[automatik["id"]] = False
|
||||||
|
|
||||||
self.war_aktiv[automatik["id"]] = aktiv
|
self.war_aktiv[automatik["id"]] = aktiv
|
||||||
|
|
||||||
|
def haltezeit_reif(self, automatik, erfuellt, jetzt):
|
||||||
|
"""
|
||||||
|
Liegt die Bedingung lange genug am Stueck an?
|
||||||
|
|
||||||
|
Ohne Haltezeit (hold_secs = 0, die Vorgabe) ist die Antwort schlicht
|
||||||
|
die Bedingung selbst - dann verhaelt sich alles wie vorher.
|
||||||
|
|
||||||
|
Sonst wird der Zeitpunkt gemerkt, an dem die Bedingung wahr wurde, und
|
||||||
|
erst nach Ablauf der Haltezeit "ja" gemeldet. Faellt sie zwischendurch
|
||||||
|
auch nur einen Takt aus, wird der Zeitpunkt verworfen und faengt beim
|
||||||
|
naechsten Mal von vorn an; genau das ist mit "ununterbrochen" gemeint.
|
||||||
|
|
||||||
|
Wichtig ist, dass cond_met bis dahin auf 0 bleibt - die Flanke wird
|
||||||
|
also nicht verbraucht, sondern aufgeschoben. Deshalb wird sie hier
|
||||||
|
nicht selbst gesetzt, sondern das Ergebnis nach oben gereicht.
|
||||||
|
|
||||||
|
Zwei Dinge, die man wissen sollte:
|
||||||
|
|
||||||
|
* Ein punktgenauer Zeit-Ausloeser ("um 16:30") ist nur das
|
||||||
|
Nachholfenster lang wahr. Eine Haltezeit darueber hinaus wuerde nie
|
||||||
|
reif - was kein Fehler, aber auch keine sinnvolle Kombination ist.
|
||||||
|
* Eine Bedingung gilt so lange weiter, wie ihr letzter Messwert gilt.
|
||||||
|
Bei einem Zaehler, der alle fuenf Minuten meldet, ist die Haltezeit
|
||||||
|
also auf fuenf Minuten genau - sinnvolle Stufen beginnen deutlich
|
||||||
|
darueber.
|
||||||
|
"""
|
||||||
|
halten = int(automatik.get("hold_secs") or 0)
|
||||||
|
if not halten:
|
||||||
|
return erfuellt
|
||||||
|
if not erfuellt:
|
||||||
|
if self.erfuellt_seit.pop(automatik["id"], None) is not None:
|
||||||
|
logger.debug("%s: Haltezeit abgebrochen, faengt von vorn an",
|
||||||
|
automatik["name"])
|
||||||
|
return False
|
||||||
|
seit = self.erfuellt_seit.setdefault(automatik["id"], jetzt)
|
||||||
|
offen = halten - (jetzt - seit).total_seconds()
|
||||||
|
if offen > 0:
|
||||||
|
logger.debug("%s: Bedingung erfuellt, noch %d s Haltezeit",
|
||||||
|
automatik["name"], offen)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def gesperrt(automatik, jetzt):
|
def gesperrt(automatik, jetzt):
|
||||||
"""
|
"""
|
||||||
@@ -1282,6 +1486,7 @@ class Runner:
|
|||||||
while True:
|
while True:
|
||||||
jetzt = time.monotonic()
|
jetzt = time.monotonic()
|
||||||
self.werte_einsammeln(auch_geraete=nur_einmal)
|
self.werte_einsammeln(auch_geraete=nur_einmal)
|
||||||
|
self.meldungen_abarbeiten()
|
||||||
self.durchlauf(fenster)
|
self.durchlauf(fenster)
|
||||||
self.ergebnisse_verbuchen()
|
self.ergebnisse_verbuchen()
|
||||||
self.protokoll_saeubern()
|
self.protokoll_saeubern()
|
||||||
|
|||||||
@@ -85,3 +85,32 @@ dry_run = false
|
|||||||
|
|
||||||
# DEBUG, INFO, WARNING, ERROR
|
# DEBUG, INFO, WARNING, ERROR
|
||||||
log_level = INFO
|
log_level = INFO
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# BENACHRICHTIGUNGEN - E-Mail
|
||||||
|
# ============================================================================
|
||||||
|
# Fuer das Kommando "E-Mail" des Geraets "Benachrichtigungen". Ohne Server
|
||||||
|
# und Empfaenger meldet die Automatik einen Fehler ins Protokoll, statt still
|
||||||
|
# nichts zu tun.
|
||||||
|
#
|
||||||
|
# port 587 beginnt im Klartext und wechselt mit STARTTLS (der Normalfall)
|
||||||
|
# port 465 spricht von Anfang an verschluesselt
|
||||||
|
#
|
||||||
|
# Mehrere Empfaenger mit Komma trennen. "von" ist der Absender; viele Anbieter
|
||||||
|
# verlangen, dass er gleich dem Anmeldenamen ist.
|
||||||
|
[mail]
|
||||||
|
server =
|
||||||
|
port = 587
|
||||||
|
benutzer =
|
||||||
|
passwort =
|
||||||
|
von =
|
||||||
|
an =
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# BENACHRICHTIGUNGEN - Web Push
|
||||||
|
# ============================================================================
|
||||||
|
# Das Schluesselpaar erzeugt einmalig SolarManager/vapid_erzeugen.py. Der
|
||||||
|
# Pfad muss nur gesetzt werden, wenn die Datei woanders liegt als
|
||||||
|
# SolarManager/push_vapid.json.
|
||||||
|
[push]
|
||||||
|
schluessel =
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- ===========================================================================
|
||||||
|
-- Haltezeit: die Bedingung muss eine Weile durchgehend erfuellt sein
|
||||||
|
--
|
||||||
|
-- Einspielen:
|
||||||
|
-- mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < haltezeit.sql
|
||||||
|
--
|
||||||
|
-- Danach den Runner neu starten (startSolarServer.sh) - Daten laedt er
|
||||||
|
-- selbst nach, neuen Code nicht.
|
||||||
|
--
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Wozu
|
||||||
|
--
|
||||||
|
-- Bisher loeste eine Automatik in dem Moment aus, in dem die Bedingung wahr
|
||||||
|
-- wurde. Fuer "Temperatur ueber 22 Grad" ist das richtig. Fuer "der
|
||||||
|
-- Wasserzaehler laeuft und hoert nicht auf" ist es unbrauchbar: Jeder
|
||||||
|
-- Spuelgang macht die Bedingung wahr, und gemeint war "seit einer halben
|
||||||
|
-- Stunde ohne Pause".
|
||||||
|
--
|
||||||
|
-- Die Sperrzeit (lockout_secs) taugt dafuer nicht - sie begrenzt, wie oft
|
||||||
|
-- ausgeloest wird, nicht wie lange etwas anliegen muss. Und die Verkettung
|
||||||
|
-- ueber eine zweite Automatik traefe es nur ungefaehr.
|
||||||
|
--
|
||||||
|
-- hold_secs = 0 ist die Vorgabe und heisst: sofort, also genau wie bisher.
|
||||||
|
-- Jede vorhandene Automatik behaelt damit ihr Verhalten.
|
||||||
|
--
|
||||||
|
-- Gezaehlt wird im Runner, nicht in der Datenbank: Er weiss ohnehin bei
|
||||||
|
-- jedem Takt, ob die Bedingung gerade erfuellt ist. Faellt sie zwischendurch
|
||||||
|
-- aus, faengt die Zeit von vorn an - genau das ist "ohne Pause".
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE `automations`
|
||||||
|
ADD COLUMN IF NOT EXISTS `hold_secs` int(11) NOT NULL DEFAULT 0
|
||||||
|
COMMENT 'Haltezeit: so lange muss die Bedingung ununterbrochen erfuellt sein, bevor ausgeloest wird. 0 = sofort'
|
||||||
|
AFTER `lockout_secs`;
|
||||||
+282
-17
@@ -40,7 +40,9 @@ id, `aktion` ein Dict mit actor_url, command_url und params (Liste aus
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib3
|
import urllib3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -48,6 +50,14 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
logger = logging.getLogger("autoaction.transport")
|
logger = logging.getLogger("autoaction.transport")
|
||||||
|
|
||||||
|
# Die Pakete fuer Web Push (pywebpush, py_vapid, http_ece) liegen wie alle
|
||||||
|
# anderen Fremdpakete im SolarManager-Ordner, nicht in site-packages. Der
|
||||||
|
# Runner startet aber aus autoActions/ heraus - ohne diese Zeile faende er
|
||||||
|
# sie nicht.
|
||||||
|
_SOLARMANAGER = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _SOLARMANAGER not in sys.path:
|
||||||
|
sys.path.append(_SOLARMANAGER)
|
||||||
|
|
||||||
# Die Tahoma-Box hat ein selbst ausgestelltes Zertifikat auf einen Namen, den
|
# Die Tahoma-Box hat ein selbst ausgestelltes Zertifikat auf einen Namen, den
|
||||||
# nur das Heimnetz kennt. Die Pruefung ist dort bewusst aus (wie in
|
# nur das Heimnetz kennt. Die Pruefung ist dort bewusst aus (wie in
|
||||||
# ajax/tahoma.php); ohne diese Zeile warnt urllib3 bei jeder einzelnen
|
# ajax/tahoma.php); ohne diese Zeile warnt urllib3 bei jeder einzelnen
|
||||||
@@ -445,6 +455,14 @@ class TahomaTransport(Transport):
|
|||||||
# nach einem Kommando steht dort noch der alte Wert.
|
# nach einem Kommando steht dort noch der alte Wert.
|
||||||
JALOUSIE_VORLAUF_SEKUNDEN = 8
|
JALOUSIE_VORLAUF_SEKUNDEN = 8
|
||||||
|
|
||||||
|
# So oft wird ein Kommando hoechstens geschickt, wenn der Behang danach
|
||||||
|
# nicht auf seinem Ziel steht. Zwei Versuche, weil ein dritter bei einem
|
||||||
|
# wirklich stummen Motor nur Zeit kostet - siehe _fahren().
|
||||||
|
JALOUSIE_VERSUCHE = 2
|
||||||
|
|
||||||
|
# Wie weit der gemeldete Stand vom Ziel abweichen darf, in Prozentpunkten.
|
||||||
|
JALOUSIE_TOLERANZ = 2
|
||||||
|
|
||||||
def __init__(self, requests_modul, pin, token, timeout=10, dry_run=False):
|
def __init__(self, requests_modul, pin, token, timeout=10, dry_run=False):
|
||||||
self.requests = requests_modul
|
self.requests = requests_modul
|
||||||
self.pin = pin
|
self.pin = pin
|
||||||
@@ -598,12 +616,78 @@ class TahomaTransport(Transport):
|
|||||||
" (zuerst %s, dann warten)" % vorstufe if umweg else "")
|
" (zuerst %s, dann warten)" % vorstufe if umweg else "")
|
||||||
return
|
return
|
||||||
|
|
||||||
if umweg:
|
position_ziel = self._positionsZiel(befehl, parameter, position_index)
|
||||||
self._apply(aktion["actor_url"], befehl, vorstufe)
|
neigung_ziel = (int(parameter[neigung_index])
|
||||||
self._warteAufJalousie(aktion["actor_url"], 0,
|
if neigung_index is not None else None)
|
||||||
None if position_index is None else int(parameter[position_index]))
|
|
||||||
self._apply(aktion["actor_url"], befehl, parameter)
|
|
||||||
|
|
||||||
|
if umweg:
|
||||||
|
# Dieselbe Position, aber Neigung 0 - das ist der ganze Sinn der
|
||||||
|
# Vorstufe.
|
||||||
|
self._fahren(aktion, befehl, vorstufe, 0, position_ziel, "Vorstufe")
|
||||||
|
self._fahren(aktion, befehl, parameter, neigung_ziel, position_ziel, "Endstellung")
|
||||||
|
|
||||||
|
def _positionsZiel(self, befehl, parameter, position_index):
|
||||||
|
"""
|
||||||
|
Wo der Behang danach stehen soll - oder None, wenn das niemand weiss.
|
||||||
|
|
||||||
|
Bei setClosure und setClosureAndOrientation steht es im Parameter.
|
||||||
|
"up" und "down" tragen keinen, meinen aber null bzw. hundert Prozent
|
||||||
|
Schliessung; ohne diese Zuordnung haette ausgerechnet der haeufigste
|
||||||
|
Befehl kein pruefbares Ziel, und der Wiederholversuch liefe fuer ihn
|
||||||
|
leer.
|
||||||
|
|
||||||
|
Bei "my", "stop" und "wink" gibt es wirklich keines. Dort wird nichts
|
||||||
|
geprueft und nichts wiederholt - siehe _fahren().
|
||||||
|
"""
|
||||||
|
if position_index is not None:
|
||||||
|
return int(parameter[position_index])
|
||||||
|
return {"up": 0, "down": 100}.get(befehl)
|
||||||
|
|
||||||
|
def _fahren(self, aktion, befehl, parameter, neigung_ziel, position_ziel, schritt):
|
||||||
|
"""
|
||||||
|
Ein Kommando schicken und wiederholen, bis der Behang sein Ziel zeigt.
|
||||||
|
|
||||||
|
Der Funk von der Box zum Motor ist unbestaetigt: die Box nimmt den
|
||||||
|
Befehl an und meldet Erfolg, auch wenn er den Motor nie erreicht -
|
||||||
|
dann faehrt der Behang gar nicht. Erkennbar ist das allein daran,
|
||||||
|
dass er hinterher nicht dort steht, wo er stehen soll. Genau das ist
|
||||||
|
die Bedingung fuer den zweiten Versuch.
|
||||||
|
|
||||||
|
Wiederholt wird in jedem Fall, in dem das Ziel nicht erreicht ist.
|
||||||
|
Ob der Behang unterwegs war, liesse sich an core:MovingState ablesen
|
||||||
|
und koennte einen Abbruch begruenden (Hindernis, Wandschalter) - das
|
||||||
|
ist hier bewusst nicht gewollt: ein Behang, der nicht dort steht, wo
|
||||||
|
er stehen soll, soll es noch einmal versuchen.
|
||||||
|
|
||||||
|
Ohne pruefbares Ziel wird einmal geschickt und nicht gewartet. Bei
|
||||||
|
"stop" auf das Ende einer Fahrt zu warten waere ein Widerspruch in
|
||||||
|
sich, und zu pruefen gaebe es nichts.
|
||||||
|
"""
|
||||||
|
name = aktion.get("actor_name") or aktion["actor_url"]
|
||||||
|
if neigung_ziel is None and position_ziel is None:
|
||||||
|
self._apply(aktion["actor_url"], befehl, parameter)
|
||||||
|
return True
|
||||||
|
|
||||||
|
for versuch in range(1, self.JALOUSIE_VERSUCHE + 1):
|
||||||
|
self._apply(aktion["actor_url"], befehl, parameter)
|
||||||
|
if self._warteAufJalousie(aktion["actor_url"],
|
||||||
|
"%s, %s, Versuch %d" % (name, schritt, versuch),
|
||||||
|
neigung_ziel, position_ziel):
|
||||||
|
return True
|
||||||
|
if versuch >= self.JALOUSIE_VERSUCHE:
|
||||||
|
break
|
||||||
|
# Vor der zweiten Fahrt noch einmal hinsehen. Der Stand wird alle
|
||||||
|
# zwei Sekunden gelesen und von der Box traege gemeldet - er kann
|
||||||
|
# in der letzten Sekunde des Fensters angekommen sein, und dann
|
||||||
|
# waere die Wiederholung eine Fahrt zuviel.
|
||||||
|
if self._zieleErreicht(self._zustaende(aktion["actor_url"]),
|
||||||
|
neigung_ziel, position_ziel):
|
||||||
|
return True
|
||||||
|
logger.info("%s (%s): nicht auf dem Ziel, %d. Versuch",
|
||||||
|
name, schritt, versuch + 1)
|
||||||
|
logger.warning("%s (%s): Ziel nach %d Versuchen nicht erreicht",
|
||||||
|
name, schritt, self.JALOUSIE_VERSUCHE)
|
||||||
|
return False
|
||||||
def _kannKombi(self, actor_url):
|
def _kannKombi(self, actor_url):
|
||||||
"""
|
"""
|
||||||
Hat das Geraet ein Kommando fuer Position und Neigung zusammen?
|
Hat das Geraet ein Kommando fuer Position und Neigung zusammen?
|
||||||
@@ -622,7 +706,30 @@ class TahomaTransport(Transport):
|
|||||||
raise RuntimeError("Tahoma antwortete mit %d: %s"
|
raise RuntimeError("Tahoma antwortete mit %d: %s"
|
||||||
% (antwort.status_code, antwort.text[:120]))
|
% (antwort.status_code, antwort.text[:120]))
|
||||||
|
|
||||||
def _warteAufJalousie(self, actor_url, neigung_ziel, schliessung_ziel=None):
|
def _zieleErreicht(self, zustaende, neigung_ziel, schliessung_ziel):
|
||||||
|
"""
|
||||||
|
Zeigt das Geraet die gewuenschten Werte? None heisst "ist egal".
|
||||||
|
|
||||||
|
Verglichen wird mit Spielraum: io-Motoren melden fuer befohlene 100 %
|
||||||
|
gern 99 oder 101. Ohne Toleranz gaelte eine geglueckte Fahrt als
|
||||||
|
verloren, und der Behang fuehre ein zweites Mal - genau das, was der
|
||||||
|
Wiederholversuch verhindern soll.
|
||||||
|
|
||||||
|
Ein fehlendes Feld gilt nie als erreicht. Als 0 durchgehen zu lassen
|
||||||
|
waere ausgerechnet beim Ziel 0 ein falsches Erfolgssignal.
|
||||||
|
"""
|
||||||
|
if zustaende is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def passt(wert, ziel):
|
||||||
|
if ziel is None:
|
||||||
|
return True
|
||||||
|
return wert is not None and abs(int(wert) - ziel) <= self.JALOUSIE_TOLERANZ
|
||||||
|
|
||||||
|
return (passt(zustaende.get("core:SlateOrientationState"), neigung_ziel)
|
||||||
|
and passt(zustaende.get("core:ClosureState"), schliessung_ziel))
|
||||||
|
|
||||||
|
def _warteAufJalousie(self, actor_url, actor_name, neigung_ziel, schliessung_ziel=None):
|
||||||
"""
|
"""
|
||||||
Wartet, bis die Jalousie ihre Fahrt beendet hat und die Ziele zeigt.
|
Wartet, bis die Jalousie ihre Fahrt beendet hat und die Ziele zeigt.
|
||||||
|
|
||||||
@@ -644,20 +751,13 @@ class TahomaTransport(Transport):
|
|||||||
if z.get("core:MovingState") is True:
|
if z.get("core:MovingState") is True:
|
||||||
gestartet = True
|
gestartet = True
|
||||||
continue
|
continue
|
||||||
neigung = z.get("core:SlateOrientationState")
|
if self._zieleErreicht(z, neigung_ziel, schliessung_ziel) and (
|
||||||
schliessung = z.get("core:ClosureState")
|
|
||||||
# Ein fehlendes Feld darf nicht als 0 durchgehen - das waere
|
|
||||||
# ausgerechnet beim Ziel 0 ein falsches Erfolgssignal.
|
|
||||||
neigung_ok = neigung is not None and int(neigung) == neigung_ziel
|
|
||||||
schliessung_ok = (schliessung_ziel is None
|
|
||||||
or (schliessung is not None and int(schliessung) == schliessung_ziel))
|
|
||||||
if neigung_ok and schliessung_ok and (
|
|
||||||
gestartet or time.time() - start >= self.JALOUSIE_VORLAUF_SEKUNDEN):
|
gestartet or time.time() - start >= self.JALOUSIE_VORLAUF_SEKUNDEN):
|
||||||
return True
|
return True
|
||||||
logger.warning("%s hat Neigung %s%% nicht innerhalb von %d s erreicht",
|
logger.warning("%s (%s) steht nach %d s nicht auf Neigung %s / Position %s",
|
||||||
actor_url, neigung_ziel, self.JALOUSIE_WARTE_SEKUNDEN)
|
actor_url, actor_name, self.JALOUSIE_WARTE_SEKUNDEN,
|
||||||
|
neigung_ziel, schliessung_ziel)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _zahl(wert):
|
def _zahl(wert):
|
||||||
"""Tahoma erwartet Zahlen als Zahlen, Text als Text."""
|
"""Tahoma erwartet Zahlen als Zahlen, Text als Text."""
|
||||||
@@ -795,3 +895,168 @@ class AutomatikTransport(Transport):
|
|||||||
|
|
||||||
def senden(self, aktion):
|
def senden(self, aktion):
|
||||||
raise RuntimeError("Das Geraet \"Automatiken\" kann nichts schalten")
|
raise RuntimeError("Das Geraet \"Automatiken\" kann nichts schalten")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Benachrichtigungen
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
BENACHRICHTIGUNG_URL = "Benachrichtigung"
|
||||||
|
|
||||||
|
#: Themen, auf denen auch andere Stellen eine Meldung anstossen koennen - die
|
||||||
|
#: Probe aus den Einstellungen nimmt diesen Weg (restricted/push.php).
|
||||||
|
BENACHRICHTIGUNG_TOPIC = "benachrichtigung/#"
|
||||||
|
|
||||||
|
|
||||||
|
class BenachrichtigungTransport(Transport):
|
||||||
|
"""
|
||||||
|
Meldungen aufs Handy (Web Push) und per E-Mail.
|
||||||
|
|
||||||
|
Ein gerechnetes Geraet wie LogicTransport: Es steht hinter der Aktor-URL
|
||||||
|
"Benachrichtigung" und hat keine Messwerte, nur Kommandos - eines je
|
||||||
|
Kanal. Fuer den Editor ist das ein Geraet wie jedes andere, deshalb
|
||||||
|
braucht die Weboberflaeche dafuer keine Zeile.
|
||||||
|
|
||||||
|
Web Push braucht drei Dinge, die alle schon da sind: ein Abo je Geraet
|
||||||
|
(homeMesh.push_abos, angelegt vom Browser), ein VAPID-Schluesselpaar
|
||||||
|
(push_vapid.json) und die Pakete im SolarManager-Ordner. Verschluesselt
|
||||||
|
wird gegen die Schluessel des Geraets - der Push-Dienst des Herstellers
|
||||||
|
sieht nur ein Paket, das er nicht lesen kann.
|
||||||
|
|
||||||
|
Ein Abo, das der Dienst mit 404 oder 410 ablehnt, gibt es nicht mehr
|
||||||
|
(Browserdaten geloescht, Symbol entfernt). Es wird dann geloescht statt
|
||||||
|
ewig weiter angeschrieben: sonst haengt an jeder Meldung ein Fehler, den
|
||||||
|
niemand beheben kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
schema = "benachrichtigung"
|
||||||
|
|
||||||
|
def __init__(self, abos_lesen, abo_weg, abo_erfolg, mail_konfig, vapid_datei,
|
||||||
|
dry_run=False):
|
||||||
|
self.abos_lesen = abos_lesen # () -> [{id, endpoint, p256dh, auth}]
|
||||||
|
self.abo_weg = abo_weg # (id, grund) -> None
|
||||||
|
self.abo_erfolg = abo_erfolg # (id) -> None
|
||||||
|
self.mail = mail_konfig or {}
|
||||||
|
self.vapid_datei = vapid_datei
|
||||||
|
self.dry_run = dry_run
|
||||||
|
self._vapid = None
|
||||||
|
|
||||||
|
def passt(self, actor_url):
|
||||||
|
return str(actor_url or "") == BENACHRICHTIGUNG_URL
|
||||||
|
|
||||||
|
def zustaende_anmelden(self, states):
|
||||||
|
pass # nichts zu lesen - das Geraet meldet nichts
|
||||||
|
|
||||||
|
def zustaende_lesen(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# --- Kanaele ---------------------------------------------------------
|
||||||
|
|
||||||
|
def senden(self, aktion):
|
||||||
|
kanal = str(aktion.get("command_url") or "")
|
||||||
|
werte = {p.get("url") or p.get("name"): str(p.get("wert") or "")
|
||||||
|
for p in aktion.get("params") or []}
|
||||||
|
if kanal == "push":
|
||||||
|
self.push(werte.get("titel") or "Smarthome", werte.get("text") or "")
|
||||||
|
elif kanal == "mail":
|
||||||
|
self.mail_senden(werte.get("betreff") or "Smarthome", werte.get("text") or "")
|
||||||
|
else:
|
||||||
|
raise ValueError("Unbekannter Kanal: %s" % kanal)
|
||||||
|
|
||||||
|
def vapid(self):
|
||||||
|
"""
|
||||||
|
Das Schluesselpaar, einmal gelesen und in ein Vapid-Objekt gepackt.
|
||||||
|
|
||||||
|
pywebpush nimmt als Schluessel entweder einen Dateipfad, eine
|
||||||
|
base64-Zeichenkette oder ein fertiges Vapid-Objekt - aber nicht den
|
||||||
|
PEM-Text selbst. Der steht in push_vapid.json, also wird er hier
|
||||||
|
einmal eingelesen.
|
||||||
|
"""
|
||||||
|
if self._vapid is None:
|
||||||
|
from py_vapid import Vapid01
|
||||||
|
with open(self.vapid_datei) as f:
|
||||||
|
daten = json.load(f)
|
||||||
|
daten["vapid"] = Vapid01.from_pem(daten["private_key_pem"].encode("utf-8"))
|
||||||
|
self._vapid = daten
|
||||||
|
return self._vapid
|
||||||
|
|
||||||
|
def push(self, titel, text):
|
||||||
|
"""An alle angemeldeten Geraete."""
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
|
||||||
|
abos = self.abos_lesen()
|
||||||
|
if not abos:
|
||||||
|
logger.info("Push \"%s\": kein Geraet angemeldet", titel)
|
||||||
|
return
|
||||||
|
if self.dry_run:
|
||||||
|
logger.info("[dry-run] Push an %d Geraet(e): %s / %s", len(abos), titel, text)
|
||||||
|
return
|
||||||
|
|
||||||
|
schluessel = self.vapid()
|
||||||
|
nutzlast = json.dumps({"titel": titel, "text": text, "url": "index.php"},
|
||||||
|
ensure_ascii=False)
|
||||||
|
fehler = []
|
||||||
|
for abo in abos:
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info={
|
||||||
|
"endpoint": abo["endpoint"],
|
||||||
|
"keys": {"p256dh": abo["p256dh"], "auth": abo["auth"]},
|
||||||
|
},
|
||||||
|
data=nutzlast,
|
||||||
|
vapid_private_key=schluessel["vapid"],
|
||||||
|
vapid_claims={"sub": schluessel.get("subject", "mailto:admin@example.org")},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
self.abo_erfolg(abo["id"])
|
||||||
|
except WebPushException as f:
|
||||||
|
code = getattr(getattr(f, "response", None), "status_code", 0)
|
||||||
|
if code in (404, 410):
|
||||||
|
self.abo_weg(abo["id"], "vom Push-Dienst abgemeldet (%d)" % code)
|
||||||
|
logger.info("Push-Abo %s ist weg (%d), geloescht", abo["id"], code)
|
||||||
|
else:
|
||||||
|
fehler.append("%s: %s" % (abo.get("name") or abo["id"], f))
|
||||||
|
except Exception as f: # Netz, Zeitlimit, Schluessel
|
||||||
|
fehler.append("%s: %r" % (abo.get("name") or abo["id"], f))
|
||||||
|
if fehler:
|
||||||
|
raise RuntimeError("; ".join(fehler)[:250])
|
||||||
|
|
||||||
|
def mail_senden(self, betreff, text):
|
||||||
|
"""Eine Mail ueber den SMTP-Zugang aus der config.ini."""
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
server = self.mail.get("server") or ""
|
||||||
|
an = [a.strip() for a in (self.mail.get("an") or "").split(",") if a.strip()]
|
||||||
|
if not server or not an:
|
||||||
|
raise RuntimeError("Kein Mailzugang eingetragen (Abschnitt [mail] in config.ini)")
|
||||||
|
if self.dry_run:
|
||||||
|
logger.info("[dry-run] Mail an %s: %s / %s", ", ".join(an), betreff, text)
|
||||||
|
return
|
||||||
|
|
||||||
|
nachricht = EmailMessage()
|
||||||
|
nachricht["From"] = self.mail.get("von") or an[0]
|
||||||
|
nachricht["To"] = ", ".join(an)
|
||||||
|
nachricht["Subject"] = betreff
|
||||||
|
nachricht.set_content(text)
|
||||||
|
|
||||||
|
port = int(self.mail.get("port") or 587)
|
||||||
|
# Zwei Bauarten: 465 spricht von Anfang an verschluesselt, 587 beginnt
|
||||||
|
# im Klartext und wechselt mit STARTTLS. Alles andere waere heute
|
||||||
|
# unverschluesselter Versand - den gibt es hier nicht.
|
||||||
|
if port == 465:
|
||||||
|
verbindung = smtplib.SMTP_SSL(server, port, timeout=15,
|
||||||
|
context=ssl.create_default_context())
|
||||||
|
else:
|
||||||
|
verbindung = smtplib.SMTP(server, port, timeout=15)
|
||||||
|
verbindung.starttls(context=ssl.create_default_context())
|
||||||
|
try:
|
||||||
|
if self.mail.get("benutzer"):
|
||||||
|
verbindung.login(self.mail["benutzer"], self.mail.get("passwort") or "")
|
||||||
|
verbindung.send_message(nachricht)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
verbindung.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -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';
|
||||||
+34
-11
@@ -11,7 +11,7 @@
|
|||||||
; Messwerte (solarLog)
|
; Messwerte (solarLog)
|
||||||
; ===========================================================================
|
; ===========================================================================
|
||||||
; Benutzt von solarManager.py, gatherWaterData.py, gatherSkodaData.py,
|
; Benutzt von solarManager.py, gatherWaterData.py, gatherSkodaData.py,
|
||||||
; skoda_testdaten.py und zeit.py.
|
; skoda_testdaten.py und skoda_ladepunkte_nachtragen.py.
|
||||||
[database]
|
[database]
|
||||||
host = localhost
|
host = localhost
|
||||||
port = 3310
|
port = 3310
|
||||||
@@ -19,16 +19,6 @@ user = solarLog
|
|||||||
password =
|
password =
|
||||||
database = solarLog
|
database = solarLog
|
||||||
|
|
||||||
; ===========================================================================
|
|
||||||
; Wecker (wecker.py)
|
|
||||||
; ===========================================================================
|
|
||||||
[alarm]
|
|
||||||
host = localhost
|
|
||||||
port = 3310
|
|
||||||
user = alarm
|
|
||||||
password =
|
|
||||||
database = alarm
|
|
||||||
|
|
||||||
; ===========================================================================
|
; ===========================================================================
|
||||||
; Wallbox
|
; Wallbox
|
||||||
; ===========================================================================
|
; ===========================================================================
|
||||||
@@ -57,3 +47,36 @@ intervall_minuten = 30
|
|||||||
; Messwerte geleert, und es wird nicht mehr bewaessert - eine retained "0,0 mm"
|
; Messwerte geleert, und es wird nicht mehr bewaessert - eine retained "0,0 mm"
|
||||||
; von gestern wuerde den Runner sonst giessen lassen.
|
; von gestern wuerde den Runner sonst giessen lassen.
|
||||||
max_alter_stunden = 3
|
max_alter_stunden = 3
|
||||||
|
|
||||||
|
; ===========================================================================
|
||||||
|
; BYD-Speicher (gatherBYDData.py)
|
||||||
|
; ===========================================================================
|
||||||
|
; Adresse der BMU. Ab Werk 192.168.16.254; der Abschnitt darf fehlen.
|
||||||
|
[byd]
|
||||||
|
host = 192.168.16.254
|
||||||
|
|
||||||
|
; ===========================================================================
|
||||||
|
; Wettervorhersage (gatherForecastData.py)
|
||||||
|
; ===========================================================================
|
||||||
|
; Grundlage des Meteogramms. Geschrieben wird in die Datenbank, nicht nach
|
||||||
|
; MQTT - die Zugangsdaten stehen in [database].
|
||||||
|
[vorhersage]
|
||||||
|
lat = 47.5780
|
||||||
|
lon = 10.2630
|
||||||
|
timezone = Europe/Berlin
|
||||||
|
; Was Open-Meteo hergibt. past_days traegt Bewoelkung, Strahlung und
|
||||||
|
; Niederschlag links vom Jetzt-Strich - beides misst die eigene Station nicht.
|
||||||
|
forecast_days = 7
|
||||||
|
past_days = 2
|
||||||
|
; Open-Meteo rechnet stuendlich neu. Zwanzig Minuten heisst: die laufende
|
||||||
|
; Stunde ist nie aelter als zwanzig Minuten, und 72 Abrufe am Tag liegen weit
|
||||||
|
; unter dem freien Kontingent.
|
||||||
|
intervall_minuten = 20
|
||||||
|
; Einstrahlung in Modulebene, je Flaeche "kuerzel:neigung:azimut".
|
||||||
|
; Neigung in Grad gegen die Waagerechte, Azimut in Grad mit 0 = Sued,
|
||||||
|
; negativ = Ost, positiv = West (so verlangt es Open-Meteo).
|
||||||
|
; Leer lassen, solange die Winkel nicht ausgemessen sind - dann wird die
|
||||||
|
; geneigte Einstrahlung uebersprungen und das Meteogramm merkt nichts davon.
|
||||||
|
; Nachtragen geht jederzeit, die Archiv-API liefert sie auch rueckwirkend.
|
||||||
|
; flaechen = dach:30:10, veranda:90:10, carport:15:10
|
||||||
|
flaechen =
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""BYD Battery-Box HVM direkt an der BMU auslesen.
|
||||||
|
|
||||||
|
Der Gen24 liefert ueber Modbus nur, was er selbst von der Batterie wissen
|
||||||
|
muss: Ladestand, Spannung, Strom. Alles darunter - 128 Zellspannungen, 64
|
||||||
|
Temperaturfuehler, Gesundheit (SOH), Gesamtzaehler, Ausgleich - kennt nur die
|
||||||
|
BMU. Sie spricht auf Port 8080 dasselbe Protokoll wie die BE-Connect-App:
|
||||||
|
Modbus-RTU in TCP verpackt. Ablauf und Byte-Lagen stammen aus dem
|
||||||
|
ioBroker-Adapter bydhvs (MIT, github.com/christianh17/ioBroker.bydhvs).
|
||||||
|
|
||||||
|
Drei Eigenheiten bestimmen den Aufbau:
|
||||||
|
|
||||||
|
Alles in EINER Verbindung, Anfrage 0 zuerst. Eine neue Verbindung, die
|
||||||
|
gleich mit Anfrage 1 beginnt, schliesst die BMU wortlos.
|
||||||
|
|
||||||
|
Das Netzwerkmodul (Hi-Link HLK-RM08K) startet etwa alle 102 Sekunden neu,
|
||||||
|
unabhaengig davon, ob jemand fragt. Waehrend des Neustarts laufen
|
||||||
|
Verbindungen rund 15 Sekunden ins Leere; danach bedient es nur die ersten
|
||||||
|
ein, zwei Verbindungen, alle spaeteren schliesst es sofort mit 0 Bytes.
|
||||||
|
Das Fenster ist also nur wenige Sekunden breit (beobachtet am 15.09.2026).
|
||||||
|
Der Sammler klopft deshalb in einer eigenen Schleife jede _KLOPFEN
|
||||||
|
Sekunde mit kurzem Verbindungs-Timeout an - der 3-Sekunden-Takt des
|
||||||
|
Managers waere dafuer zu grob. Geht eine Verbindung durch, laeuft der
|
||||||
|
ganze Zyklus in ihr; die haelt das Modul offen, auch ueber die Messpause.
|
||||||
|
Nach einem Erfolg ruht die Schleife _RUHE Sekunden bis kurz vor dem
|
||||||
|
naechsten Neustart.
|
||||||
|
|
||||||
|
Die Lade-/Entladezaehler stehen bei dieser HVM wortvertauscht (low word
|
||||||
|
zuerst). Mit der Lesart des Adapters kaemen 31 Millionen kWh heraus.
|
||||||
|
|
||||||
|
Wie die anderen langsamen Sammler blockiert gatherData() nie: die Schleife
|
||||||
|
laeuft als Hintergrund-Task, zurueck kommt immer der letzte Stand.
|
||||||
|
|
||||||
|
Historie: alle 5 Minuten eine Zeile in byd, alle 15 Minuten die Zellen
|
||||||
|
in byd_zellen (siehe solarLog_byd.sql im Web-Repository).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import mysql.connector as mc
|
||||||
|
|
||||||
|
import konfig
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_HOST = konfig.wert("byd", "host", "192.168.16.254")
|
||||||
|
_PORT = 8080
|
||||||
|
_KLOPFEN = 1 # s zwischen zwei Versuchen, solange das Fenster zu ist
|
||||||
|
_RUHE = 80 # s nach einem Erfolg - das naechste Fenster kommt erst nach ~102 s
|
||||||
|
_VERBINDEN = 1.5 # s Timeout fuers Verbinden; im Neustart antwortet niemand
|
||||||
|
_ZEITLIMIT = 6 # s je Antwort
|
||||||
|
_MESSPAUSE = 8 # s, die die BMU nach "Zellmessung starten" braucht
|
||||||
|
_VERALTET = 600 # s ohne Erfolg, danach gilt der Stand als nicht mehr aktuell
|
||||||
|
_DB_ZEILE = 300 # s zwischen zwei Zeilen in byd
|
||||||
|
_DB_ZELLEN = 900 # s zwischen zwei Zeilen in byd_zellen
|
||||||
|
|
||||||
|
_ANFRAGE = {
|
||||||
|
"stamm": bytes.fromhex("010300000066c5e0"),
|
||||||
|
"echt": bytes.fromhex("01030500001984cc"),
|
||||||
|
"typ": bytes.fromhex("010300100003040e"),
|
||||||
|
"messen": bytes.fromhex("0110055000020400018100f853"),
|
||||||
|
"status": bytes.fromhex("010305510001d517"),
|
||||||
|
"zellen": bytes.fromhex("01030558004104e5"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bit j in Paket 1, Byte 29/30 - Wortlaut aus dem Adapter, uebersetzt.
|
||||||
|
_FEHLER = [
|
||||||
|
"Zu warm beim Laden", "Zu kalt beim Laden", "Ueberstrom beim Entladen",
|
||||||
|
"Ueberstrom beim Laden", "Hauptstromkreis gestoert", "Kurzschlussalarm",
|
||||||
|
"Zellen ungleich", "Stromsensor gestoert", "Batteriespannung zu hoch",
|
||||||
|
"Batteriespannung zu niedrig", "Zellspannung zu hoch",
|
||||||
|
"Zellspannung zu niedrig", "Spannungssensor gestoert",
|
||||||
|
"Temperatursensor gestoert", "Zu warm beim Entladen",
|
||||||
|
"Zu kalt beim Entladen",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BYDData:
|
||||||
|
ok: int = 0 # 1 = Stand juenger als _VERALTET
|
||||||
|
stand: int = 0 # Unix-Zeit des letzten vollstaendigen Zyklus
|
||||||
|
seriennummer: str = ""
|
||||||
|
firmware: str = ""
|
||||||
|
module: int = 0
|
||||||
|
soc: float = 0.0 # % laut BMU
|
||||||
|
soh: float = 0.0 # %
|
||||||
|
spannung: float = 0.0 # V am Batterieausgang
|
||||||
|
strom: float = 0.0 # A, positiv = laden
|
||||||
|
leistung: float = 0.0 # W, positiv = laden
|
||||||
|
tMin: float = 0.0 # degC ueber alle Fuehler
|
||||||
|
tMax: float = 0.0
|
||||||
|
zelleMin: int = 0 # mV
|
||||||
|
zelleMax: int = 0
|
||||||
|
zelleMinNr: int = 0 # 1..128
|
||||||
|
zelleMaxNr: int = 0
|
||||||
|
spreizung: int = 0 # mV
|
||||||
|
ausgleich: int = 0 # Zahl der Zellen, die gerade ausgeglichen werden
|
||||||
|
ausgleichRoh: str = "" # "0x" + 32 Hex-Zeichen, Bitlage noch nicht bestaetigt;
|
||||||
|
# das Praefix haelt mqttClient davon ab, es als Zahl zu lesen
|
||||||
|
fehler: int = 0
|
||||||
|
fehlertext: str = ""
|
||||||
|
geladen: float = 0.0 # kWh seit Inbetriebnahme
|
||||||
|
entladen: float = 0.0
|
||||||
|
zellen: List[int] = field(default_factory=list) # mV, 16 je Modul
|
||||||
|
temperaturen: List[int] = field(default_factory=list) # degC, 8 je Modul
|
||||||
|
|
||||||
|
|
||||||
|
ret = BYDData()
|
||||||
|
_task = None
|
||||||
|
_letzteZeile = 0.0
|
||||||
|
_letzteZellen = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _crc_ok(p):
|
||||||
|
crc = 0xFFFF
|
||||||
|
for b in p:
|
||||||
|
crc ^= b
|
||||||
|
for _ in range(8):
|
||||||
|
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||||||
|
return crc == 0
|
||||||
|
|
||||||
|
|
||||||
|
class _Abgewiesen(Exception):
|
||||||
|
"""Die BMU hat die Verbindung ohne Antwort geschlossen - Fenster zu."""
|
||||||
|
|
||||||
|
|
||||||
|
async def _frage(reader, writer, name, pause=0.2):
|
||||||
|
await asyncio.sleep(pause)
|
||||||
|
writer.write(_ANFRAGE[name])
|
||||||
|
await writer.drain()
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
teil = await asyncio.wait_for(reader.read(1024), _ZEITLIMIT)
|
||||||
|
if not teil:
|
||||||
|
raise _Abgewiesen("%s: geschlossen nach %d Bytes" % (name, len(buf)))
|
||||||
|
buf += teil
|
||||||
|
if len(buf) < 3:
|
||||||
|
continue
|
||||||
|
if buf[1] & 0x80:
|
||||||
|
n = 5
|
||||||
|
elif buf[1] == 0x10:
|
||||||
|
n = 8
|
||||||
|
else:
|
||||||
|
n = buf[2] + 5
|
||||||
|
if len(buf) >= n:
|
||||||
|
p = buf[:n]
|
||||||
|
if not _crc_ok(p):
|
||||||
|
raise IOError("%s: Pruefsumme falsch" % name)
|
||||||
|
if p[1] & 0x80:
|
||||||
|
raise IOError("%s: BMU meldet Ausnahme %d" % (name, p[2]))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _i16(b, o):
|
||||||
|
return struct.unpack_from(">h", b, o)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _u16(b, o):
|
||||||
|
return struct.unpack_from(">H", b, o)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _u32_vertauscht(b, o):
|
||||||
|
"""Zwei Worte, das niedrige zuerst."""
|
||||||
|
return (_u16(b, o + 2) << 16) | _u16(b, o)
|
||||||
|
|
||||||
|
|
||||||
|
async def _zyklus():
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(_HOST, _PORT), _VERBINDEN)
|
||||||
|
try:
|
||||||
|
p0 = await _frage(reader, writer, "stamm", 0)
|
||||||
|
p1 = await _frage(reader, writer, "echt")
|
||||||
|
p2 = await _frage(reader, writer, "typ")
|
||||||
|
module = p0[36] % 16
|
||||||
|
hvm = p2[5] == 1
|
||||||
|
nZellen = module * (16 if hvm else 32)
|
||||||
|
nTemps = module * (8 if hvm else 12)
|
||||||
|
|
||||||
|
await _frage(reader, writer, "messen")
|
||||||
|
await asyncio.sleep(_MESSPAUSE)
|
||||||
|
await _frage(reader, writer, "status", 0)
|
||||||
|
p5 = await _frage(reader, writer, "zellen")
|
||||||
|
p6 = await _frage(reader, writer, "zellen")
|
||||||
|
p7 = await _frage(reader, writer, "zellen")
|
||||||
|
p8 = await _frage(reader, writer, "zellen")
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
zellen = [_i16(p5, 101 + 2 * i) for i in range(16)]
|
||||||
|
zellen += [_i16(p6, 5 + 2 * i) for i in range(min(64, nZellen - 16))]
|
||||||
|
zellen += [_i16(p7, 5 + 2 * i) for i in range(min(48, nZellen - 80))]
|
||||||
|
temps = [p7[103 + i] for i in range(min(30, nTemps))]
|
||||||
|
temps += [p8[5 + i] for i in range(min(34, nTemps - 30))]
|
||||||
|
|
||||||
|
d = BYDData()
|
||||||
|
d.seriennummer = p0[3:22].decode("ascii", "replace")
|
||||||
|
d.firmware = "BMS V%d.%d" % (p0[31], p0[32])
|
||||||
|
d.module = module
|
||||||
|
d.soc = _i16(p1, 3)
|
||||||
|
d.soh = _i16(p1, 9)
|
||||||
|
d.strom = _i16(p1, 11) / 10
|
||||||
|
d.spannung = _u16(p1, 35) / 100
|
||||||
|
d.leistung = round(d.strom * d.spannung)
|
||||||
|
d.fehler = _u16(p1, 29)
|
||||||
|
d.fehlertext = "; ".join(t for j, t in enumerate(_FEHLER) if d.fehler & (1 << j))
|
||||||
|
d.geladen = _u32_vertauscht(p1, 37) / 10
|
||||||
|
d.entladen = _u32_vertauscht(p1, 41) / 10
|
||||||
|
d.zellen = zellen
|
||||||
|
d.temperaturen = temps
|
||||||
|
gueltig = [z for z in zellen if 2000 < z < 4000]
|
||||||
|
if gueltig:
|
||||||
|
d.zelleMin, d.zelleMax = min(gueltig), max(gueltig)
|
||||||
|
d.zelleMinNr = zellen.index(d.zelleMin) + 1
|
||||||
|
d.zelleMaxNr = zellen.index(d.zelleMax) + 1
|
||||||
|
d.spreizung = d.zelleMax - d.zelleMin
|
||||||
|
if temps:
|
||||||
|
d.tMin, d.tMax = min(temps), max(temps)
|
||||||
|
else:
|
||||||
|
d.tMin, d.tMax = _i16(p1, 17), _i16(p1, 15)
|
||||||
|
d.ausgleichRoh = "0x" + p5[17:33].hex()
|
||||||
|
d.ausgleich = bin(int(d.ausgleichRoh, 16)).count("1")
|
||||||
|
d.stand = int(time.time())
|
||||||
|
d.ok = 1
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _speichern(d):
|
||||||
|
"""Historie schreiben, gedrosselt auf _DB_ZEILE / _DB_ZELLEN."""
|
||||||
|
global _letzteZeile, _letzteZellen
|
||||||
|
jetzt = time.time()
|
||||||
|
zeile = jetzt - _letzteZeile >= _DB_ZEILE
|
||||||
|
zellen = jetzt - _letzteZellen >= _DB_ZELLEN
|
||||||
|
if not (zeile or zellen):
|
||||||
|
return
|
||||||
|
zeit = datetime.datetime.now().replace(microsecond=0)
|
||||||
|
try:
|
||||||
|
with mc.connect(**konfig.datenbank()) as verbindung:
|
||||||
|
with verbindung.cursor() as cursor:
|
||||||
|
if zeile:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO byd (datetime, soc, soh, spannung, strom, leistung, "
|
||||||
|
"t_min, t_max, zelle_min, zelle_max, zelle_min_nr, zelle_max_nr, "
|
||||||
|
"spreizung, ausgleich, fehler, geladen_kwh, entladen_kwh) "
|
||||||
|
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s);",
|
||||||
|
(zeit, d.soc, d.soh, d.spannung, d.strom, d.leistung,
|
||||||
|
d.tMin, d.tMax, d.zelleMin, d.zelleMax, d.zelleMinNr,
|
||||||
|
d.zelleMaxNr, d.spreizung, d.ausgleich, d.fehler,
|
||||||
|
d.geladen, d.entladen))
|
||||||
|
if zellen:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO byd_zellen (datetime, zellen, temperaturen, ausgleich) "
|
||||||
|
"VALUES (%s,%s,%s,%s);",
|
||||||
|
(zeit, json.dumps(d.zellen), json.dumps(d.temperaturen),
|
||||||
|
d.ausgleichRoh[2:]))
|
||||||
|
verbindung.commit()
|
||||||
|
if zeile:
|
||||||
|
_letzteZeile = jetzt
|
||||||
|
if zellen:
|
||||||
|
_letzteZellen = jetzt
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error("BYD: Historie nicht geschrieben: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _schleife():
|
||||||
|
global ret
|
||||||
|
gewarnt = False
|
||||||
|
letzterErfolg = time.time()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
d = await _zyklus()
|
||||||
|
except (_Abgewiesen, ConnectionError, asyncio.TimeoutError, OSError) as e:
|
||||||
|
# Ein zugesperrtes Fenster ist der Normalfall und kein Log wert.
|
||||||
|
# Erst wenn gar nichts mehr durchkommt, soll es jemand sehen.
|
||||||
|
_LOGGER.debug("BYD: %s: %s", type(e).__name__, e)
|
||||||
|
if not gewarnt and time.time() - letzterErfolg > _VERALTET:
|
||||||
|
_LOGGER.warning("BYD: seit %d Minuten keine Daten von %s (%s)",
|
||||||
|
_VERALTET // 60, _HOST, e)
|
||||||
|
gewarnt = True
|
||||||
|
if ret.ok and time.time() - ret.stand > _VERALTET:
|
||||||
|
ret.ok = 0
|
||||||
|
await asyncio.sleep(_KLOPFEN)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("BYD: Antwort nicht verstanden")
|
||||||
|
await asyncio.sleep(_RUHE)
|
||||||
|
else:
|
||||||
|
ret = d
|
||||||
|
letzterErfolg = time.time()
|
||||||
|
if gewarnt:
|
||||||
|
_LOGGER.warning("BYD: Daten kommen wieder")
|
||||||
|
gewarnt = False
|
||||||
|
await asyncio.get_running_loop().run_in_executor(None, _speichern, d)
|
||||||
|
await asyncio.sleep(_RUHE)
|
||||||
|
|
||||||
|
|
||||||
|
async def gatherData() -> BYDData:
|
||||||
|
"""Startet beim ersten Aufruf die Schleife und gibt den letzten Stand zurueck."""
|
||||||
|
global _task
|
||||||
|
if _task is None or _task.done():
|
||||||
|
if _task is not None and not _task.cancelled() and _task.exception():
|
||||||
|
_LOGGER.error("BYD: Schleife beendet: %r - starte neu", _task.exception())
|
||||||
|
_task = asyncio.ensure_future(_schleife())
|
||||||
|
return ret
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Wettervorhersage von Open-Meteo in die Datenbank.
|
||||||
|
|
||||||
|
Grundlage des Meteogramms auf der Solar-Seite (js/solar/meteogramm.js im
|
||||||
|
Web-Repository, bedient von ajax/meteogramm.php). Geschrieben werden die
|
||||||
|
Tabellen weatherHours, weatherDays, weatherTilted und weatherForecastLog in
|
||||||
|
solarLog - angelegt von solarLog_weather.sql im Web-Repository, dort steht
|
||||||
|
auch, warum sie so aussehen, wie sie aussehen.
|
||||||
|
|
||||||
|
Warum ein eigenes Skript und keine Erweiterung von gatherRainData.py, das
|
||||||
|
dieselbe Schnittstelle benutzt: dessen Fehlerregel ist sicherheitsrelevant.
|
||||||
|
Es leert bei einer Stoerung bewusst seine Werte, damit der AutoAction-Runner
|
||||||
|
nicht auf eine alte "0,0 mm" hin giesst. Hier gilt das Gegenteil - eine
|
||||||
|
Vorhersage von vor zwei Stunden ist immer noch eine brauchbare Vorhersage,
|
||||||
|
sie wird nur als alt beschriftet. Zwei gegenlaeufige Fehlerregeln in einer
|
||||||
|
Schleife waeren eine Falle fuer den naechsten, der etwas aendert.
|
||||||
|
|
||||||
|
Es wird nichts geloescht. Die Tabellen sind zugleich ein Archiv: aus der
|
||||||
|
Einstrahlung der Vergangenheit und EnergyFlow_hourly.pv_kwh soll spaeter eine
|
||||||
|
eigene Ertragsprognose gerechnet werden. 8.760 Zeilen im Jahr kosten rund
|
||||||
|
1,5 MB - ein einziger Tag EnergyFlow ist groesser.
|
||||||
|
|
||||||
|
Drei Dinge, die den Aufbau erklaeren:
|
||||||
|
|
||||||
|
Eine Stunde wird mehrfach geschrieben und dabei genauer. Erst ist sie
|
||||||
|
Vorhersage fuer uebermorgen, zuletzt Analyse des vergangenen Tages.
|
||||||
|
Deshalb REPLACE INTO auf dem Zeitstempel und kein Anhaengen. Damit man der
|
||||||
|
Zeile ansieht, woher sie stammt, wird "ist_vorhersage" mitgeschrieben: 1,
|
||||||
|
solange die Stunde beim Schreiben noch in der Zukunft lag.
|
||||||
|
|
||||||
|
Was dabei verlorenginge, faengt weatherForecastLog auf - dort landet je
|
||||||
|
Stunde die *erste* Aussage und wird nie wieder angefasst (INSERT IGNORE).
|
||||||
|
Nur so laesst sich spaeter beantworten, wie gut eine Vorhersage von vor drei
|
||||||
|
Tagen war.
|
||||||
|
|
||||||
|
Die Einstrahlung in Modulebene haengt an Neigung und Azimut und braucht
|
||||||
|
einen eigenen Abruf je Flaeche. Sie landet in weatherTilted und ist
|
||||||
|
freiwillig: ohne Eintrag in der config.ini wird sie uebersprungen, und das
|
||||||
|
Meteogramm merkt nichts davon.
|
||||||
|
|
||||||
|
Gestartet von startSolarServer.sh, Standort und Takt stehen in config.ini
|
||||||
|
(siehe config.ini.example, Abschnitt [vorhersage]).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import mysql.connector as mc
|
||||||
|
import requests
|
||||||
|
|
||||||
|
import konfig
|
||||||
|
|
||||||
|
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||||||
|
|
||||||
|
# Was je Stunde geholt wird. Die Reihenfolge ist gleichgueltig, die Namen
|
||||||
|
# nicht - sie sind zugleich die Schluessel in der Antwort.
|
||||||
|
STUNDENFELDER = [
|
||||||
|
"temperature_2m", "apparent_temperature", "relativehumidity_2m",
|
||||||
|
"dewpoint_2m", "pressure_msl", "precipitation", "precipitation_probability",
|
||||||
|
"snowfall", "weathercode", "cloudcover_low", "cloudcover_mid",
|
||||||
|
"cloudcover_high", "windspeed_10m", "windgusts_10m", "winddirection_10m",
|
||||||
|
"shortwave_radiation", "direct_radiation", "diffuse_radiation", "is_day",
|
||||||
|
]
|
||||||
|
|
||||||
|
TAGESFELDER = [
|
||||||
|
"weathercode", "temperature_2m_max", "temperature_2m_min", "sunrise",
|
||||||
|
"sunset", "precipitation_sum", "precipitation_probability_max",
|
||||||
|
"windspeed_10m_max", "windgusts_10m_max", "shortwave_radiation_sum",
|
||||||
|
]
|
||||||
|
|
||||||
|
# pressure_msl und nicht surface_pressure: auf 729 m Hoehe meldet der
|
||||||
|
# Stationsdruck rund 940 hPa, die eigene Wetterstation meldet qff (auf
|
||||||
|
# Meereshoehe gerechnet) rund 1035. Nebeneinander waere das kein Vergleich,
|
||||||
|
# sondern eine Verwechslung.
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
# mysql.connector meldet bei jedem Verbindungsaufbau drei Zeilen ueber sein
|
||||||
|
# Authentifizierungsmodul. Bei einem Abruf alle zwanzig Minuten waeren das
|
||||||
|
# neunzig Zeilen am Tag, die niemandem etwas sagen.
|
||||||
|
logging.getLogger("mysql.connector").setLevel(logging.WARNING)
|
||||||
|
logger = logging.getLogger("gatherForecastData")
|
||||||
|
|
||||||
|
|
||||||
|
def flaechen_lesen():
|
||||||
|
"""
|
||||||
|
Die Flaechen fuer die Einstrahlung in Modulebene.
|
||||||
|
|
||||||
|
Format in der config.ini, eine je Zeile oder durch Komma getrennt:
|
||||||
|
|
||||||
|
flaechen = dach:30:10, carport:15:10
|
||||||
|
|
||||||
|
also Kuerzel, Neigung in Grad, Azimut in Grad (0 = Sued, negativ = Ost,
|
||||||
|
positiv = West - so verlangt es Open-Meteo). Ein leerer Eintrag ist
|
||||||
|
erlaubt und heisst: keine geneigte Einstrahlung holen.
|
||||||
|
"""
|
||||||
|
roh = konfig.wert("vorhersage", "flaechen", "")
|
||||||
|
aus = []
|
||||||
|
for teil in roh.replace("\n", ",").split(","):
|
||||||
|
teil = teil.strip()
|
||||||
|
if not teil:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
name, neigung, azimut = teil.split(":")
|
||||||
|
aus.append((name.strip(), float(neigung), float(azimut)))
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("[vorhersage] flaechen: '%s' verstehe ich nicht, "
|
||||||
|
"erwartet wird kuerzel:neigung:azimut", teil)
|
||||||
|
return aus
|
||||||
|
|
||||||
|
|
||||||
|
def abrufen(zusatz=None, felder=None):
|
||||||
|
"""Eine Antwort von Open-Meteo holen. Wirft bei Fehlern."""
|
||||||
|
p = {
|
||||||
|
"latitude": konfig.wert("vorhersage", "lat"),
|
||||||
|
"longitude": konfig.wert("vorhersage", "lon"),
|
||||||
|
"timezone": konfig.wert("vorhersage", "timezone", "Europe/Berlin"),
|
||||||
|
"forecast_days": konfig.wert("vorhersage", "forecast_days", "7"),
|
||||||
|
"past_days": konfig.wert("vorhersage", "past_days", "2"),
|
||||||
|
"hourly": ",".join(felder if felder else STUNDENFELDER),
|
||||||
|
}
|
||||||
|
if felder is None:
|
||||||
|
p["daily"] = ",".join(TAGESFELDER)
|
||||||
|
if zusatz:
|
||||||
|
p.update(zusatz)
|
||||||
|
antwort = requests.get(OPEN_METEO_URL, params=p, timeout=20)
|
||||||
|
antwort.raise_for_status()
|
||||||
|
return antwort.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _zeit(text):
|
||||||
|
"""'2026-09-21T14:00' -> datetime. Open-Meteo liefert immer Minuten mit."""
|
||||||
|
return datetime.strptime(text, "%Y-%m-%dT%H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _zahl(wert):
|
||||||
|
"""None bleibt None - eine fehlende Zahl ist nicht dasselbe wie eine 0."""
|
||||||
|
return None if wert is None else wert
|
||||||
|
|
||||||
|
|
||||||
|
def stundenzeilen(daten, jetzt):
|
||||||
|
"""Die Antwort in Zeilen fuer weatherHours uebersetzen."""
|
||||||
|
h = daten["hourly"]
|
||||||
|
zeilen = []
|
||||||
|
for i, t in enumerate(h["time"]):
|
||||||
|
zeit = _zeit(t)
|
||||||
|
h_ = lambda name: _zahl(h[name][i])
|
||||||
|
zeilen.append((
|
||||||
|
zeit,
|
||||||
|
h_("temperature_2m"), h_("apparent_temperature"),
|
||||||
|
h_("relativehumidity_2m"), h_("dewpoint_2m"),
|
||||||
|
None if h_("pressure_msl") is None else round(h_("pressure_msl")),
|
||||||
|
h_("precipitation"), h_("precipitation_probability"), h_("snowfall"),
|
||||||
|
h_("weathercode"),
|
||||||
|
h_("cloudcover_low"), h_("cloudcover_mid"), h_("cloudcover_high"),
|
||||||
|
h_("windspeed_10m"), h_("windgusts_10m"), h_("winddirection_10m"),
|
||||||
|
h_("shortwave_radiation"), h_("direct_radiation"), h_("diffuse_radiation"),
|
||||||
|
1 if h_("is_day") else 0,
|
||||||
|
1 if zeit > jetzt else 0, # ist_vorhersage
|
||||||
|
jetzt, # abgerufen
|
||||||
|
))
|
||||||
|
return zeilen
|
||||||
|
|
||||||
|
|
||||||
|
def tageszeilen(daten, jetzt):
|
||||||
|
"""Die Antwort in Zeilen fuer weatherDays uebersetzen."""
|
||||||
|
d = daten["daily"]
|
||||||
|
zeilen = []
|
||||||
|
for i, t in enumerate(d["time"]):
|
||||||
|
tag = datetime.strptime(t, "%Y-%m-%d").date()
|
||||||
|
d_ = lambda name: _zahl(d[name][i])
|
||||||
|
# sunrise/sunset kommen als voller Zeitstempel, gespeichert wird die
|
||||||
|
# Uhrzeit - das Datum steht schon im Schluessel.
|
||||||
|
auf = _zeit(d["sunrise"][i]).time() if d["sunrise"][i] else None
|
||||||
|
unter = _zeit(d["sunset"][i]).time() if d["sunset"][i] else None
|
||||||
|
zeilen.append((
|
||||||
|
tag, d_("weathercode"), d_("temperature_2m_min"), d_("temperature_2m_max"),
|
||||||
|
auf, unter,
|
||||||
|
d_("precipitation_sum"), d_("precipitation_probability_max"),
|
||||||
|
d_("windspeed_10m_max"), d_("windgusts_10m_max"),
|
||||||
|
d_("shortwave_radiation_sum"),
|
||||||
|
1 if tag > jetzt.date() else 0,
|
||||||
|
jetzt,
|
||||||
|
))
|
||||||
|
return zeilen
|
||||||
|
|
||||||
|
|
||||||
|
def schreiben(zug, stunden, tage, geneigt):
|
||||||
|
"""Alles in einer Transaktion. Entweder der ganze Stand oder keiner."""
|
||||||
|
with mc.connect(**zug) as verbindung:
|
||||||
|
with verbindung.cursor() as cursor:
|
||||||
|
cursor.executemany(
|
||||||
|
"REPLACE INTO weatherHours (datetime, temp, gefuehlt, feuchte, "
|
||||||
|
"taupunkt, druck, regen, regen_wkt, schnee, wettercode, "
|
||||||
|
"wolken_tief, wolken_mittel, wolken_hoch, wind, boe, richtung, "
|
||||||
|
"strahlung, direkt, diffus, tag, ist_vorhersage, abgerufen) "
|
||||||
|
"VALUES (" + ",".join(["%s"] * 22) + ");", stunden)
|
||||||
|
cursor.executemany(
|
||||||
|
"REPLACE INTO weatherDays (date, wettercode, temp_min, temp_max, "
|
||||||
|
"sonnenauf, sonnenunter, regen_summe, regen_wkt, wind_max, "
|
||||||
|
"boe_max, strahlung_summe, ist_vorhersage, abgerufen) "
|
||||||
|
"VALUES (" + ",".join(["%s"] * 13) + ");", tage)
|
||||||
|
# Nur die erste Aussage je Stunde, danach nie wieder angefasst.
|
||||||
|
cursor.executemany(
|
||||||
|
"INSERT IGNORE INTO weatherForecastLog "
|
||||||
|
"(datetime, abgerufen, temp, strahlung, regen) "
|
||||||
|
"VALUES (%s,%s,%s,%s,%s);",
|
||||||
|
[(z[0], z[21], z[1], z[16], z[6]) for z in stunden if z[20]])
|
||||||
|
if geneigt:
|
||||||
|
cursor.executemany(
|
||||||
|
"REPLACE INTO weatherTilted (datetime, flaeche, strahlung) "
|
||||||
|
"VALUES (%s,%s,%s);", geneigt)
|
||||||
|
verbindung.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def geneigte_einstrahlung(flaechen):
|
||||||
|
"""
|
||||||
|
Je Flaeche ein zusaetzlicher Abruf, weil tilt/azimut global gelten.
|
||||||
|
|
||||||
|
Faellt einer aus, fehlt nur diese Flaeche - die uebrigen Werte sind
|
||||||
|
deswegen nicht schlechter.
|
||||||
|
"""
|
||||||
|
aus = []
|
||||||
|
for name, neigung, azimut in flaechen:
|
||||||
|
try:
|
||||||
|
daten = abrufen({"tilt": neigung, "azimuth": azimut},
|
||||||
|
felder=["global_tilted_irradiance"])
|
||||||
|
h = daten["hourly"]
|
||||||
|
for i, t in enumerate(h["time"]):
|
||||||
|
aus.append((_zeit(t), name, _zahl(h["global_tilted_irradiance"][i])))
|
||||||
|
except Exception as fehler:
|
||||||
|
logger.warning("Flaeche %s: %r", name, fehler)
|
||||||
|
return aus
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
takt = int(konfig.wert("vorhersage", "intervall_minuten", "20")) * 60
|
||||||
|
flaechen = flaechen_lesen()
|
||||||
|
if flaechen:
|
||||||
|
logger.info("Einstrahlung in Modulebene fuer: %s",
|
||||||
|
", ".join(f[0] for f in flaechen))
|
||||||
|
else:
|
||||||
|
logger.info("Keine Flaechen eingetragen - ohne geneigte Einstrahlung")
|
||||||
|
|
||||||
|
letzter_erfolg = time.time()
|
||||||
|
gewarnt = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
jetzt = datetime.now().replace(microsecond=0)
|
||||||
|
daten = abrufen()
|
||||||
|
stunden = stundenzeilen(daten, jetzt)
|
||||||
|
tage = tageszeilen(daten, jetzt)
|
||||||
|
geneigt = geneigte_einstrahlung(flaechen)
|
||||||
|
schreiben(konfig.datenbank(), stunden, tage, geneigt)
|
||||||
|
logger.info("%d Stunden, %d Tage%s geschrieben", len(stunden), len(tage),
|
||||||
|
", %d Flaechenwerte" % len(geneigt) if geneigt else "")
|
||||||
|
letzter_erfolg = time.time()
|
||||||
|
gewarnt = False
|
||||||
|
except Exception as fehler:
|
||||||
|
# Die Tabelle bleibt stehen. Eine alte Vorhersage ist brauchbar,
|
||||||
|
# sie wird in der Anzeige nur als alt beschriftet - dafuer reist
|
||||||
|
# "abgerufen" mit. Erst nach drei Stunden ohne jeden Erfolg lohnt
|
||||||
|
# eine Meldung, vorher ist es ein Schluckauf der Leitung.
|
||||||
|
logger.debug("Abruf fehlgeschlagen: %r", fehler)
|
||||||
|
if not gewarnt and time.time() - letzter_erfolg > 3 * 3600:
|
||||||
|
logger.error("Seit drei Stunden keine Vorhersage: %r", fehler)
|
||||||
|
gewarnt = True
|
||||||
|
time.sleep(takt)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+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"]:
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import functools
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
|
||||||
|
MAX_RECORD_SIZE = pow(2, 31) - 1
|
||||||
|
MIN_RECORD_SIZE = 3
|
||||||
|
KEY_LENGTH = 16
|
||||||
|
NONCE_LENGTH = 12
|
||||||
|
TAG_LENGTH = 16
|
||||||
|
|
||||||
|
# Valid content types (ordered from newest, to most obsolete)
|
||||||
|
versions = {
|
||||||
|
"aes128gcm": {"pad": 1},
|
||||||
|
"aesgcm": {"pad": 2},
|
||||||
|
"aesgcm128": {"pad": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ECEException(Exception):
|
||||||
|
"""Exception for ECE encryption functions"""
|
||||||
|
|
||||||
|
def __init__(self, message):
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
def derive_key(
|
||||||
|
mode, version, salt, key, private_key, dh, auth_secret, keyid, keylabel="P-256"
|
||||||
|
):
|
||||||
|
"""Derive the encryption key
|
||||||
|
|
||||||
|
:param mode: operational mode (encrypt or decrypt)
|
||||||
|
:type mode: enumerate('encrypt', 'decrypt)
|
||||||
|
:param salt: encryption salt value
|
||||||
|
:type salt: str
|
||||||
|
:param key: raw key
|
||||||
|
:type key: str
|
||||||
|
:param private_key: DH private key
|
||||||
|
:type key: object
|
||||||
|
:param dh: Diffie Helman public key value
|
||||||
|
:type dh: str
|
||||||
|
:param keyid: key identifier label
|
||||||
|
:type keyid: str
|
||||||
|
:param keylabel: label for aesgcm/aesgcm128
|
||||||
|
:type keylabel: str
|
||||||
|
:param auth_secret: authorization secret
|
||||||
|
:type auth_secret: str
|
||||||
|
:param version: Content Type identifier
|
||||||
|
:type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128')
|
||||||
|
|
||||||
|
"""
|
||||||
|
context = b""
|
||||||
|
keyinfo = ""
|
||||||
|
nonceinfo = ""
|
||||||
|
|
||||||
|
def build_info(base, info_context):
|
||||||
|
return b"Content-Encoding: " + base + b"\0" + info_context
|
||||||
|
|
||||||
|
def derive_dh(mode, version, private_key, dh, keylabel):
|
||||||
|
def length_prefix(key):
|
||||||
|
return struct.pack("!H", len(key)) + key
|
||||||
|
|
||||||
|
if isinstance(dh, ec.EllipticCurvePublicKey):
|
||||||
|
pubkey = dh
|
||||||
|
dh = dh.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
|
||||||
|
else:
|
||||||
|
pubkey = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), dh)
|
||||||
|
|
||||||
|
encoded = private_key.public_key().public_bytes(
|
||||||
|
Encoding.X962, PublicFormat.UncompressedPoint
|
||||||
|
)
|
||||||
|
if mode == "encrypt":
|
||||||
|
sender_pub_key = encoded
|
||||||
|
receiver_pub_key = dh
|
||||||
|
else:
|
||||||
|
sender_pub_key = dh
|
||||||
|
receiver_pub_key = encoded
|
||||||
|
|
||||||
|
if version == "aes128gcm":
|
||||||
|
context = b"WebPush: info\x00" + receiver_pub_key + sender_pub_key
|
||||||
|
else:
|
||||||
|
context = (
|
||||||
|
keylabel.encode("utf-8")
|
||||||
|
+ b"\0"
|
||||||
|
+ length_prefix(receiver_pub_key)
|
||||||
|
+ length_prefix(sender_pub_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
return private_key.exchange(ec.ECDH(), pubkey), context
|
||||||
|
|
||||||
|
if version not in versions:
|
||||||
|
raise ECEException("Invalid version")
|
||||||
|
if mode not in ["encrypt", "decrypt"]:
|
||||||
|
raise ECEException("unknown 'mode' specified: " + mode)
|
||||||
|
if salt is None or len(salt) != KEY_LENGTH:
|
||||||
|
raise ECEException("'salt' must be a 16 octet value")
|
||||||
|
if dh is not None:
|
||||||
|
if private_key is None:
|
||||||
|
raise ECEException("DH requires a private_key")
|
||||||
|
(secret, context) = derive_dh(
|
||||||
|
mode=mode,
|
||||||
|
version=version,
|
||||||
|
private_key=private_key,
|
||||||
|
dh=dh,
|
||||||
|
keylabel=keylabel,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
secret = key
|
||||||
|
|
||||||
|
if secret is None:
|
||||||
|
raise ECEException("unable to determine the secret")
|
||||||
|
|
||||||
|
if version == "aesgcm":
|
||||||
|
keyinfo = build_info(b"aesgcm", context)
|
||||||
|
nonceinfo = build_info(b"nonce", context)
|
||||||
|
elif version == "aesgcm128":
|
||||||
|
keyinfo = b"Content-Encoding: aesgcm128"
|
||||||
|
nonceinfo = b"Content-Encoding: nonce"
|
||||||
|
elif version == "aes128gcm":
|
||||||
|
keyinfo = b"Content-Encoding: aes128gcm\x00"
|
||||||
|
nonceinfo = b"Content-Encoding: nonce\x00"
|
||||||
|
if dh is None:
|
||||||
|
# Only mix the authentication secret when using DH for aes128gcm
|
||||||
|
auth_secret = None
|
||||||
|
|
||||||
|
if auth_secret is not None:
|
||||||
|
if version == "aes128gcm":
|
||||||
|
info = context
|
||||||
|
else:
|
||||||
|
info = build_info(b"auth", b"")
|
||||||
|
hkdf_auth = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=auth_secret,
|
||||||
|
info=info,
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
secret = hkdf_auth.derive(secret)
|
||||||
|
|
||||||
|
hkdf_key = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=KEY_LENGTH,
|
||||||
|
salt=salt,
|
||||||
|
info=keyinfo,
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
hkdf_nonce = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=NONCE_LENGTH,
|
||||||
|
salt=salt,
|
||||||
|
info=nonceinfo,
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
return hkdf_key.derive(secret), hkdf_nonce.derive(secret)
|
||||||
|
|
||||||
|
|
||||||
|
def iv(base, counter):
|
||||||
|
"""Generate an initialization vector."""
|
||||||
|
if (counter >> 64) != 0:
|
||||||
|
raise ECEException("Counter too big")
|
||||||
|
(mask,) = struct.unpack("!Q", base[4:])
|
||||||
|
return base[:4] + struct.pack("!Q", counter ^ mask)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(
|
||||||
|
content,
|
||||||
|
salt=None,
|
||||||
|
key=None,
|
||||||
|
private_key=None,
|
||||||
|
dh=None,
|
||||||
|
auth_secret=None,
|
||||||
|
keyid=None,
|
||||||
|
keylabel="P-256",
|
||||||
|
rs=4096,
|
||||||
|
version="aes128gcm",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Decrypt a data block
|
||||||
|
|
||||||
|
:param content: Data to be decrypted
|
||||||
|
:type content: str
|
||||||
|
:param salt: Encryption salt
|
||||||
|
:type salt: str
|
||||||
|
:param key: local public key
|
||||||
|
:type key: str
|
||||||
|
:param private_key: DH private key
|
||||||
|
:type key: object
|
||||||
|
:param keyid: Internal key identifier for private key info
|
||||||
|
:type keyid: str
|
||||||
|
:param dh: Remote Diffie Hellman sequence (omit for aes128gcm)
|
||||||
|
:type dh: str
|
||||||
|
:param rs: Record size
|
||||||
|
:type rs: int
|
||||||
|
:param auth_secret: Authorization secret
|
||||||
|
:type auth_secret: str
|
||||||
|
:param version: ECE Method version
|
||||||
|
:type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128')
|
||||||
|
:return: Decrypted message content
|
||||||
|
:rtype str
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def parse_content_header(content):
|
||||||
|
"""Parse an aes128gcm content body and extract the header values.
|
||||||
|
|
||||||
|
:param content: The encrypted body of the message
|
||||||
|
:type content: str
|
||||||
|
|
||||||
|
"""
|
||||||
|
id_len = struct.unpack("!B", content[20:21])[0]
|
||||||
|
return {
|
||||||
|
"salt": content[:16],
|
||||||
|
"rs": struct.unpack("!L", content[16:20])[0],
|
||||||
|
"keyid": content[21 : 21 + id_len],
|
||||||
|
"content": content[21 + id_len :],
|
||||||
|
}
|
||||||
|
|
||||||
|
def decrypt_record(key, nonce, counter, content):
|
||||||
|
decryptor = Cipher(
|
||||||
|
algorithms.AES(key),
|
||||||
|
modes.GCM(iv(nonce, counter), tag=content[-TAG_LENGTH:]),
|
||||||
|
backend=default_backend(),
|
||||||
|
).decryptor()
|
||||||
|
return decryptor.update(content[:-TAG_LENGTH]) + decryptor.finalize()
|
||||||
|
|
||||||
|
def unpad_legacy(data):
|
||||||
|
pad_size = versions[version]["pad"]
|
||||||
|
pad = functools.reduce(
|
||||||
|
lambda x, y: x << 8 | y,
|
||||||
|
struct.unpack("!" + ("B" * pad_size), data[0:pad_size]),
|
||||||
|
)
|
||||||
|
if pad_size + pad > len(data) or data[pad_size : pad_size + pad] != (
|
||||||
|
b"\x00" * pad
|
||||||
|
):
|
||||||
|
raise ECEException("Bad padding")
|
||||||
|
return data[pad_size + pad :]
|
||||||
|
|
||||||
|
def unpad(data, last):
|
||||||
|
i = len(data) - 1
|
||||||
|
for i in range(len(data) - 1, -1, -1):
|
||||||
|
v = struct.unpack("B", data[i : i + 1])[0]
|
||||||
|
if v != 0:
|
||||||
|
if not last and v != 1:
|
||||||
|
raise ECEException("record delimiter != 1")
|
||||||
|
if last and v != 2:
|
||||||
|
raise ECEException("last record delimiter != 2")
|
||||||
|
return data[0:i]
|
||||||
|
raise ECEException("all zero record plaintext")
|
||||||
|
|
||||||
|
if version not in versions:
|
||||||
|
raise ECEException("Invalid version")
|
||||||
|
|
||||||
|
overhead = versions[version]["pad"]
|
||||||
|
if version == "aes128gcm":
|
||||||
|
try:
|
||||||
|
content_header = parse_content_header(content)
|
||||||
|
except Exception:
|
||||||
|
raise ECEException("Could not parse the content header")
|
||||||
|
salt = content_header["salt"]
|
||||||
|
rs = content_header["rs"]
|
||||||
|
keyid = content_header["keyid"]
|
||||||
|
if private_key is not None and not dh:
|
||||||
|
dh = keyid
|
||||||
|
else:
|
||||||
|
keyid = keyid.decode("utf-8")
|
||||||
|
content = content_header["content"]
|
||||||
|
overhead += 16
|
||||||
|
|
||||||
|
(key_, nonce_) = derive_key(
|
||||||
|
"decrypt",
|
||||||
|
version=version,
|
||||||
|
salt=salt,
|
||||||
|
key=key,
|
||||||
|
private_key=private_key,
|
||||||
|
dh=dh,
|
||||||
|
auth_secret=auth_secret,
|
||||||
|
keyid=keyid,
|
||||||
|
keylabel=keylabel,
|
||||||
|
)
|
||||||
|
if rs <= overhead:
|
||||||
|
raise ECEException("Record size too small")
|
||||||
|
chunk = rs
|
||||||
|
if version != "aes128gcm":
|
||||||
|
chunk += 16 # account for tags in old versions
|
||||||
|
if len(content) % chunk == 0:
|
||||||
|
raise ECEException("Message truncated")
|
||||||
|
|
||||||
|
result = b""
|
||||||
|
counter = 0
|
||||||
|
try:
|
||||||
|
for i in list(range(0, len(content), chunk)):
|
||||||
|
data = decrypt_record(key_, nonce_, counter, content[i : i + chunk])
|
||||||
|
if version == "aes128gcm":
|
||||||
|
last = (i + chunk) >= len(content)
|
||||||
|
result += unpad(data, last)
|
||||||
|
else:
|
||||||
|
result += unpad_legacy(data)
|
||||||
|
counter += 1
|
||||||
|
except InvalidTag as ex:
|
||||||
|
raise ECEException("Decryption error: {}".format(repr(ex)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(
|
||||||
|
content,
|
||||||
|
salt=None,
|
||||||
|
key=None,
|
||||||
|
private_key=None,
|
||||||
|
dh=None,
|
||||||
|
auth_secret=None,
|
||||||
|
keyid=None,
|
||||||
|
keylabel="P-256",
|
||||||
|
rs=4096,
|
||||||
|
version="aes128gcm",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Encrypt a data block
|
||||||
|
|
||||||
|
:param content: block of data to encrypt
|
||||||
|
:type content: str
|
||||||
|
:param salt: Encryption salt
|
||||||
|
:type salt: str
|
||||||
|
:param key: Encryption key data
|
||||||
|
:type key: str
|
||||||
|
:param private_key: DH private key
|
||||||
|
:type key: object
|
||||||
|
:param keyid: Internal key identifier for private key info
|
||||||
|
:type keyid: str
|
||||||
|
:param dh: Remote Diffie Hellman sequence
|
||||||
|
:type dh: str
|
||||||
|
:param rs: Record size
|
||||||
|
:type rs: int
|
||||||
|
:param auth_secret: Authorization secret
|
||||||
|
:type auth_secret: str
|
||||||
|
:param version: ECE Method version
|
||||||
|
:type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128')
|
||||||
|
:return: Encrypted message content
|
||||||
|
:rtype str
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def encrypt_record(key, nonce, counter, buf, last):
|
||||||
|
encryptor = Cipher(
|
||||||
|
algorithms.AES(key),
|
||||||
|
modes.GCM(iv(nonce, counter)),
|
||||||
|
backend=default_backend(),
|
||||||
|
).encryptor()
|
||||||
|
|
||||||
|
if version == "aes128gcm":
|
||||||
|
data = encryptor.update(buf + (b"\x02" if last else b"\x01"))
|
||||||
|
else:
|
||||||
|
data = encryptor.update((b"\x00" * versions[version]["pad"]) + buf)
|
||||||
|
data += encryptor.finalize()
|
||||||
|
data += encryptor.tag
|
||||||
|
return data
|
||||||
|
|
||||||
|
def compose_aes128gcm(salt, content, rs, keyid):
|
||||||
|
"""Compose the header and content of an aes128gcm encrypted
|
||||||
|
message body
|
||||||
|
|
||||||
|
:param salt: The sender's salt value
|
||||||
|
:type salt: str
|
||||||
|
:param content: The encrypted body of the message
|
||||||
|
:type content: str
|
||||||
|
:param rs: Override for the content length
|
||||||
|
:type rs: int
|
||||||
|
:param keyid: The keyid to use for this message
|
||||||
|
:type keyid: str
|
||||||
|
|
||||||
|
"""
|
||||||
|
if len(keyid) > 255:
|
||||||
|
raise ECEException("keyid is too long")
|
||||||
|
header = salt
|
||||||
|
if rs > MAX_RECORD_SIZE:
|
||||||
|
raise ECEException("Too much content")
|
||||||
|
header += struct.pack("!L", rs)
|
||||||
|
header += struct.pack("!B", len(keyid))
|
||||||
|
header += keyid
|
||||||
|
return header + content
|
||||||
|
|
||||||
|
if version not in versions:
|
||||||
|
raise ECEException("Invalid version")
|
||||||
|
|
||||||
|
if salt is None:
|
||||||
|
salt = os.urandom(16)
|
||||||
|
|
||||||
|
(key_, nonce_) = derive_key(
|
||||||
|
"encrypt",
|
||||||
|
version=version,
|
||||||
|
salt=salt,
|
||||||
|
key=key,
|
||||||
|
private_key=private_key,
|
||||||
|
dh=dh,
|
||||||
|
auth_secret=auth_secret,
|
||||||
|
keyid=keyid,
|
||||||
|
keylabel=keylabel,
|
||||||
|
)
|
||||||
|
|
||||||
|
overhead = versions[version]["pad"]
|
||||||
|
if version == "aes128gcm":
|
||||||
|
overhead += 16
|
||||||
|
end = len(content)
|
||||||
|
else:
|
||||||
|
end = len(content) + 1
|
||||||
|
if rs <= overhead:
|
||||||
|
raise ECEException("Record size too small")
|
||||||
|
chunk_size = rs - overhead
|
||||||
|
|
||||||
|
result = b""
|
||||||
|
counter = 0
|
||||||
|
|
||||||
|
# the extra one on the loop ensures that we produce a padding only
|
||||||
|
# record if the data length is an exact multiple of the chunk size
|
||||||
|
for i in list(range(0, end, chunk_size)):
|
||||||
|
result += encrypt_record(
|
||||||
|
key_, nonce_, counter, content[i : i + chunk_size], (i + chunk_size) >= end
|
||||||
|
)
|
||||||
|
counter += 1
|
||||||
|
if version == "aes128gcm":
|
||||||
|
if keyid is None and private_key is not None:
|
||||||
|
kid = private_key.public_key().public_bytes(
|
||||||
|
Encoding.X962, PublicFormat.UncompressedPoint
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kid = (keyid or "").encode("utf-8")
|
||||||
|
return compose_aes128gcm(salt, result, rs, keyid=kid)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import unittest
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
|
|
||||||
|
from pytest import raises
|
||||||
|
|
||||||
|
import http_ece as ece
|
||||||
|
from http_ece import ECEException
|
||||||
|
|
||||||
|
|
||||||
|
TEST_VECTORS = os.path.join(os.sep, "..", "encrypt_data.json")[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def logmsg(arg):
|
||||||
|
"""
|
||||||
|
print(arg)
|
||||||
|
"""
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def logbuf(msg, buf):
|
||||||
|
"""used for debugging test code."""
|
||||||
|
if buf is None:
|
||||||
|
buf = b""
|
||||||
|
logmsg(msg + ": [" + str(len(buf)) + "]")
|
||||||
|
for i in list(range(0, len(buf), 48)):
|
||||||
|
logmsg(" " + repr(buf[i : i + 48]))
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def b64e(arg):
|
||||||
|
if arg is None:
|
||||||
|
return None
|
||||||
|
return base64.urlsafe_b64encode(arg).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def b64d(arg):
|
||||||
|
if arg is None:
|
||||||
|
return None
|
||||||
|
return base64.urlsafe_b64decode(str(arg) + "===="[: len(arg) % 4 :])
|
||||||
|
|
||||||
|
|
||||||
|
def make_key():
|
||||||
|
return ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||||
|
|
||||||
|
|
||||||
|
class TestEce(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.private_key = make_key()
|
||||||
|
self.dh = self.private_key.public_key().public_bytes(
|
||||||
|
Encoding.X962, PublicFormat.UncompressedPoint
|
||||||
|
)
|
||||||
|
self.m_key = os.urandom(16)
|
||||||
|
self.m_salt = os.urandom(16)
|
||||||
|
|
||||||
|
def test_derive_key_invalid_mode(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.derive_key(
|
||||||
|
"invalid",
|
||||||
|
version="aes128gcm",
|
||||||
|
salt=self.m_salt,
|
||||||
|
key=self.m_key,
|
||||||
|
private_key=self.private_key,
|
||||||
|
dh=None,
|
||||||
|
auth_secret=None,
|
||||||
|
keyid="valid",
|
||||||
|
)
|
||||||
|
assert ex.value.message == "unknown 'mode' specified: invalid"
|
||||||
|
|
||||||
|
def test_derive_key_invalid_salt(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.derive_key(
|
||||||
|
"encrypt",
|
||||||
|
version="aes128gcm",
|
||||||
|
salt=None,
|
||||||
|
key=self.m_key,
|
||||||
|
private_key=self.private_key,
|
||||||
|
dh=None,
|
||||||
|
auth_secret=None,
|
||||||
|
keyid="valid",
|
||||||
|
)
|
||||||
|
assert ex.value.message == "'salt' must be a 16 octet value"
|
||||||
|
|
||||||
|
def test_derive_key_invalid_version(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.derive_key(
|
||||||
|
"encrypt",
|
||||||
|
version="invalid",
|
||||||
|
salt=self.m_salt,
|
||||||
|
key=None,
|
||||||
|
private_key=self.private_key,
|
||||||
|
dh=None,
|
||||||
|
auth_secret=None,
|
||||||
|
keyid="valid",
|
||||||
|
)
|
||||||
|
assert ex.value.message == "Invalid version"
|
||||||
|
|
||||||
|
def test_derive_key_no_private_key(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.derive_key(
|
||||||
|
"encrypt",
|
||||||
|
version="aes128gcm",
|
||||||
|
salt=self.m_salt,
|
||||||
|
key=None,
|
||||||
|
private_key=None,
|
||||||
|
dh=self.dh,
|
||||||
|
auth_secret=None,
|
||||||
|
keyid="valid",
|
||||||
|
)
|
||||||
|
assert ex.value.message == "DH requires a private_key"
|
||||||
|
|
||||||
|
def test_derive_key_no_secret(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.derive_key(
|
||||||
|
"encrypt",
|
||||||
|
version="aes128gcm",
|
||||||
|
salt=self.m_salt,
|
||||||
|
key=None,
|
||||||
|
private_key=None,
|
||||||
|
dh=None,
|
||||||
|
auth_secret=None,
|
||||||
|
keyid="valid",
|
||||||
|
)
|
||||||
|
assert ex.value.message == "unable to determine the secret"
|
||||||
|
|
||||||
|
def test_iv_bad_counter(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.iv(os.urandom(8), pow(2, 64) + 1)
|
||||||
|
assert ex.value.message == "Counter too big"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEceChecking(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.m_key = os.urandom(16)
|
||||||
|
self.m_input = os.urandom(5)
|
||||||
|
# This header is specific to the padding tests, but can be used
|
||||||
|
# elsewhere
|
||||||
|
self.m_header = b"\xaa\xd2\x05}3S\xb7\xff7\xbd\xe4*\xe1\xd5\x0f\xda"
|
||||||
|
self.m_header += struct.pack("!L", 32) + b"\0"
|
||||||
|
|
||||||
|
def test_encrypt_small_rs(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.encrypt(
|
||||||
|
self.m_input,
|
||||||
|
version="aes128gcm",
|
||||||
|
key=self.m_key,
|
||||||
|
rs=1,
|
||||||
|
)
|
||||||
|
assert ex.value.message == "Record size too small"
|
||||||
|
|
||||||
|
def test_decrypt_small_rs(self):
|
||||||
|
header = os.urandom(16) + struct.pack("!L", 2) + b"\0"
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.decrypt(
|
||||||
|
header + self.m_input,
|
||||||
|
version="aes128gcm",
|
||||||
|
key=self.m_key,
|
||||||
|
rs=1,
|
||||||
|
)
|
||||||
|
assert ex.value.message == "Record size too small"
|
||||||
|
|
||||||
|
def test_encrypt_bad_version(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.encrypt(
|
||||||
|
self.m_input,
|
||||||
|
version="bogus",
|
||||||
|
key=self.m_key,
|
||||||
|
)
|
||||||
|
assert ex.value.message == "Invalid version"
|
||||||
|
|
||||||
|
def test_decrypt_bad_version(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.decrypt(
|
||||||
|
self.m_input,
|
||||||
|
version="bogus",
|
||||||
|
key=self.m_key,
|
||||||
|
)
|
||||||
|
assert ex.value.message == "Invalid version"
|
||||||
|
|
||||||
|
def test_decrypt_bad_header(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.decrypt(
|
||||||
|
os.urandom(4),
|
||||||
|
version="aes128gcm",
|
||||||
|
key=self.m_key,
|
||||||
|
)
|
||||||
|
assert ex.value.message == "Could not parse the content header"
|
||||||
|
|
||||||
|
def test_encrypt_long_keyid(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.encrypt(
|
||||||
|
self.m_input,
|
||||||
|
version="aes128gcm",
|
||||||
|
key=self.m_key,
|
||||||
|
keyid=b64e(os.urandom(192)), # 256 bytes
|
||||||
|
)
|
||||||
|
assert ex.value.message == "keyid is too long"
|
||||||
|
|
||||||
|
def test_overlong_padding(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.decrypt(
|
||||||
|
self.m_header + b"\xbb\xc7\xb9ev\x0b\xf0f+\x93\xf4"
|
||||||
|
b"\xe5\xd6\x94\xb7e\xf0\xcd\x15\x9b(\x01\xa5",
|
||||||
|
version="aes128gcm",
|
||||||
|
key=b"d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r",
|
||||||
|
keyid=b64e(os.urandom(192)), # 256 bytes
|
||||||
|
)
|
||||||
|
assert ex.value.message == "all zero record plaintext"
|
||||||
|
|
||||||
|
def test_bad_early_delimiter(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.decrypt(
|
||||||
|
self.m_header + b"\xb9\xc7\xb9ev\x0b\xf0\x9eB\xb1\x08C8u"
|
||||||
|
b"\xa3\x06\xc9x\x06\n\xfc|}\xe9R\x85\x91"
|
||||||
|
b"\x8bX\x02`\xf3"
|
||||||
|
+ b"E8z(\xe5%f/H\xc1\xc32\x04\xb1\x95\xb5N\x9ep\xd4\x0e<\xf3"
|
||||||
|
b"\xef\x0cg\x1b\xe0\x14I~\xdc",
|
||||||
|
version="aes128gcm",
|
||||||
|
key=b"d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r",
|
||||||
|
keyid=b64e(os.urandom(192)), # 256 bytes
|
||||||
|
)
|
||||||
|
assert ex.value.message == "record delimiter != 1"
|
||||||
|
|
||||||
|
def test_bad_final_delimiter(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.decrypt(
|
||||||
|
self.m_header + b"\xba\xc7\xb9ev\x0b\xf0\x9eB\xb1\x08Ji"
|
||||||
|
b"\xe4P\x1b\x8dI\xdb\xc6y#MG\xc2W\x16",
|
||||||
|
version="aes128gcm",
|
||||||
|
key=b"d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r",
|
||||||
|
keyid=b64e(os.urandom(192)), # 256 bytes
|
||||||
|
)
|
||||||
|
assert ex.value.message == "last record delimiter != 2"
|
||||||
|
|
||||||
|
def test_damage(self):
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.decrypt(
|
||||||
|
self.m_header + b"\xbb\xc6\xb1\x1dF:~\x0f\x07+\xbe\xaaD"
|
||||||
|
b"\xe0\xd6.K\xe5\xf9]%\xe3\x86q\xe0}",
|
||||||
|
version="aes128gcm",
|
||||||
|
key=b"d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r",
|
||||||
|
keyid=b64e(os.urandom(192)), # 256 bytes
|
||||||
|
)
|
||||||
|
assert ex.value.message == "Decryption error: InvalidTag()"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEceIntegration(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
ece.keys = {}
|
||||||
|
ece.labels = {}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
ece.keys = {}
|
||||||
|
ece.labels = {}
|
||||||
|
|
||||||
|
def _rsoverhead(self, version):
|
||||||
|
if version == "aesgcm128":
|
||||||
|
return 1
|
||||||
|
if version == "aesgcm":
|
||||||
|
return 2
|
||||||
|
return 18
|
||||||
|
|
||||||
|
def _generate_input(self, minLen=0):
|
||||||
|
length = struct.unpack("!B", os.urandom(1))[0] + minLen
|
||||||
|
return os.urandom(length)
|
||||||
|
|
||||||
|
def encrypt_decrypt(self, input, encrypt_params, decrypt_params=None, version=None):
|
||||||
|
"""Run and encrypt/decrypt cycle on some test data
|
||||||
|
|
||||||
|
:param input: data for input
|
||||||
|
:type length: bytearray
|
||||||
|
:param encrypt_params: Dictionary of encryption parameters
|
||||||
|
:type encrypt_params: dict
|
||||||
|
:param decrypt_params: Optional dictionary of decryption parameters
|
||||||
|
:type decrypt_params: dict
|
||||||
|
:param version: Content-Type of the body, formulating encryption
|
||||||
|
:type enumerate("aes128gcm", "aesgcm", "aesgcm128"):
|
||||||
|
"""
|
||||||
|
if decrypt_params is None:
|
||||||
|
decrypt_params = encrypt_params
|
||||||
|
logbuf("Input", input)
|
||||||
|
if "key" in encrypt_params:
|
||||||
|
logbuf("Key", encrypt_params["key"])
|
||||||
|
if version != "aes128gcm":
|
||||||
|
salt = os.urandom(16)
|
||||||
|
decrypt_rs_default = 4096
|
||||||
|
else:
|
||||||
|
salt = None
|
||||||
|
decrypt_rs_default = None
|
||||||
|
logbuf("Salt", salt)
|
||||||
|
if "auth_secret" in encrypt_params:
|
||||||
|
logbuf("Auth Secret", encrypt_params["auth_secret"])
|
||||||
|
encrypted = ece.encrypt(
|
||||||
|
input,
|
||||||
|
salt=salt,
|
||||||
|
key=encrypt_params.get("key"),
|
||||||
|
keyid=encrypt_params.get("keyid"),
|
||||||
|
dh=encrypt_params.get("dh"),
|
||||||
|
private_key=encrypt_params.get("private_key"),
|
||||||
|
auth_secret=encrypt_params.get("auth_secret"),
|
||||||
|
rs=encrypt_params.get("rs", 4096),
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
logbuf("Encrypted", encrypted)
|
||||||
|
decrypted = ece.decrypt(
|
||||||
|
encrypted,
|
||||||
|
salt=salt,
|
||||||
|
key=decrypt_params.get("key"),
|
||||||
|
keyid=decrypt_params.get("keyid"),
|
||||||
|
dh=decrypt_params.get("dh"),
|
||||||
|
private_key=decrypt_params.get("private_key"),
|
||||||
|
auth_secret=decrypt_params.get("auth_secret"),
|
||||||
|
rs=decrypt_params.get("rs", decrypt_rs_default),
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
logbuf("Decrypted", decrypted)
|
||||||
|
assert input == decrypted
|
||||||
|
|
||||||
|
def use_explicit_key(self, version=None):
|
||||||
|
params = {
|
||||||
|
"key": os.urandom(16),
|
||||||
|
}
|
||||||
|
self.encrypt_decrypt(self._generate_input(), params, version=version)
|
||||||
|
|
||||||
|
def auth_secret(self, version):
|
||||||
|
params = {"key": os.urandom(16), "auth_secret": os.urandom(16)}
|
||||||
|
self.encrypt_decrypt(self._generate_input(), params, version=version)
|
||||||
|
|
||||||
|
def exactly_one_record(self, version=None):
|
||||||
|
input = self._generate_input(1)
|
||||||
|
params = {"key": os.urandom(16), "rs": len(input) + self._rsoverhead(version)}
|
||||||
|
self.encrypt_decrypt(input, params, version=version)
|
||||||
|
|
||||||
|
def detect_truncation(self, version):
|
||||||
|
if version == "aes128gcm":
|
||||||
|
return
|
||||||
|
|
||||||
|
input = self._generate_input(2)
|
||||||
|
key = os.urandom(16)
|
||||||
|
salt = os.urandom(16)
|
||||||
|
|
||||||
|
rs = len(input) + self._rsoverhead(version) - 1
|
||||||
|
encrypted = ece.encrypt(input, salt=salt, key=key, rs=rs, version=version)
|
||||||
|
if version == "aes128gcm":
|
||||||
|
chunk = encrypted[0 : 21 + rs]
|
||||||
|
else:
|
||||||
|
chunk = encrypted[0 : rs + 16]
|
||||||
|
with raises(ECEException) as ex:
|
||||||
|
ece.decrypt(chunk, salt=salt, key=key, rs=rs, version=version)
|
||||||
|
assert ex.value.message == "Message truncated"
|
||||||
|
|
||||||
|
def use_dh(self, version):
|
||||||
|
def pubbytes(k):
|
||||||
|
return k.public_key().public_bytes(
|
||||||
|
Encoding.X962, PublicFormat.UncompressedPoint
|
||||||
|
)
|
||||||
|
|
||||||
|
def privbytes(k):
|
||||||
|
d = k.private_numbers().private_value
|
||||||
|
b = b""
|
||||||
|
for i in range(0, k.private_numbers().public_numbers.curve.key_size, 32):
|
||||||
|
b = struct.pack("!L", (d >> i) & 0xFFFFFFFF) + b
|
||||||
|
return b
|
||||||
|
|
||||||
|
def logec(s, k):
|
||||||
|
logbuf(s + " private", privbytes(k))
|
||||||
|
logbuf(s + " public", pubbytes(k))
|
||||||
|
|
||||||
|
def is_uncompressed(k):
|
||||||
|
b1 = pubbytes(k)[0:1]
|
||||||
|
assert struct.unpack("B", b1)[0] == 4, "is an uncompressed point"
|
||||||
|
|
||||||
|
# the static key is used by the receiver
|
||||||
|
static_key = make_key()
|
||||||
|
is_uncompressed(static_key)
|
||||||
|
|
||||||
|
logec("receiver", static_key)
|
||||||
|
|
||||||
|
# the ephemeral key is used by the sender
|
||||||
|
ephemeral_key = make_key()
|
||||||
|
is_uncompressed(ephemeral_key)
|
||||||
|
|
||||||
|
logec("sender", ephemeral_key)
|
||||||
|
|
||||||
|
auth_secret = os.urandom(16)
|
||||||
|
|
||||||
|
if version != "aes128gcm":
|
||||||
|
decrypt_dh = pubbytes(ephemeral_key)
|
||||||
|
else:
|
||||||
|
decrypt_dh = None
|
||||||
|
|
||||||
|
encrypt_params = {
|
||||||
|
"private_key": ephemeral_key,
|
||||||
|
"dh": static_key.public_key(),
|
||||||
|
"auth_secret": auth_secret,
|
||||||
|
}
|
||||||
|
decrypt_params = {
|
||||||
|
"private_key": static_key,
|
||||||
|
"dh": decrypt_dh,
|
||||||
|
"auth_secret": auth_secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.encrypt_decrypt(
|
||||||
|
self._generate_input(), encrypt_params, decrypt_params, version
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_types(self):
|
||||||
|
for ver in ["aes128gcm", "aesgcm", "aesgcm128"]:
|
||||||
|
for f in (
|
||||||
|
self.use_dh,
|
||||||
|
self.use_explicit_key,
|
||||||
|
self.auth_secret,
|
||||||
|
self.exactly_one_record,
|
||||||
|
self.detect_truncation,
|
||||||
|
):
|
||||||
|
ece.keys = {}
|
||||||
|
ece.labels = {}
|
||||||
|
f(version=ver)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNode(unittest.TestCase):
|
||||||
|
"""Testing using data from the node.js version."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
if not os.path.exists(TEST_VECTORS):
|
||||||
|
self.skipTest("No %s file found" % TEST_VECTORS)
|
||||||
|
f = open(TEST_VECTORS, "r")
|
||||||
|
self.legacy_data = json.loads(f.read())
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
def _run(self, mode):
|
||||||
|
if mode == "encrypt":
|
||||||
|
func = ece.encrypt
|
||||||
|
local = "sender"
|
||||||
|
inp = "input"
|
||||||
|
outp = "encrypted"
|
||||||
|
else:
|
||||||
|
func = ece.decrypt
|
||||||
|
local = "receiver"
|
||||||
|
inp = "encrypted"
|
||||||
|
outp = "input"
|
||||||
|
|
||||||
|
for data in self.legacy_data:
|
||||||
|
logmsg("%s: %s" % (mode, data["test"]))
|
||||||
|
p = data["params"][mode]
|
||||||
|
|
||||||
|
if "pad" in p and mode == "encrypt":
|
||||||
|
# This library doesn't pad in exactly the same way.
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "keys" in data:
|
||||||
|
key = None
|
||||||
|
decode_pub = ec.EllipticCurvePublicNumbers.from_encoded_point
|
||||||
|
pubnum = decode_pub(ec.SECP256R1(), b64d(data["keys"][local]["public"]))
|
||||||
|
d = 0
|
||||||
|
dbin = b64d(data["keys"][local]["private"])
|
||||||
|
for i in range(0, len(dbin), 4):
|
||||||
|
d = (d << 32) + struct.unpack("!L", dbin[i : i + 4])[0]
|
||||||
|
privnum = ec.EllipticCurvePrivateNumbers(d, pubnum)
|
||||||
|
private_key = privnum.private_key(default_backend())
|
||||||
|
else:
|
||||||
|
key = b64d(p["key"])
|
||||||
|
private_key = None
|
||||||
|
|
||||||
|
if "authSecret" in p:
|
||||||
|
auth_secret = b64d(p["authSecret"])
|
||||||
|
else:
|
||||||
|
auth_secret = None
|
||||||
|
if "dh" in p:
|
||||||
|
dh = b64d(p["dh"])
|
||||||
|
else:
|
||||||
|
dh = None
|
||||||
|
|
||||||
|
result = func(
|
||||||
|
b64d(data[inp]),
|
||||||
|
salt=b64d(p["salt"]),
|
||||||
|
key=key,
|
||||||
|
dh=dh,
|
||||||
|
auth_secret=auth_secret,
|
||||||
|
keyid=p.get("keyid"),
|
||||||
|
private_key=private_key,
|
||||||
|
rs=p.get("rs", 4096),
|
||||||
|
version=p["version"],
|
||||||
|
)
|
||||||
|
assert b64d(data[outp]) == result
|
||||||
|
|
||||||
|
def test_decrypt(self):
|
||||||
|
self._run("decrypt")
|
||||||
|
|
||||||
|
def test_encrypt(self):
|
||||||
|
self._run("encrypt")
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
/volume1/homes/wagner/SolarManager/solarOutput.log
|
/volume1/homes/wagner/SolarManager/solarOutput.log
|
||||||
/volume1/homes/wagner/SolarManager/autoActions.log
|
/volume1/homes/wagner/SolarManager/autoActions.log
|
||||||
/volume1/homes/wagner/SolarManager/rainOutput.log
|
/volume1/homes/wagner/SolarManager/rainOutput.log
|
||||||
|
/volume1/homes/wagner/SolarManager/forecastOutput.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
|
||||||
{
|
{
|
||||||
|
|||||||
+5
-1
@@ -54,7 +54,11 @@ def publish(dict,topic="solarManager"):
|
|||||||
mqttClient.publish(topic+"/"+key,value)
|
mqttClient.publish(topic+"/"+key,value)
|
||||||
else:
|
else:
|
||||||
for key, value in dict.items():
|
for key, value in dict.items():
|
||||||
if hasattr(value, '__len__') and (not isinstance(value, str)):
|
# Ein einzelnes Dataclass-Objekt (z.B. rtData["byd"]) hat keine
|
||||||
|
# Laenge und fiel frueher in den Zahlenzweig - "value is no number".
|
||||||
|
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
||||||
|
publish(value,topic+"/"+key)
|
||||||
|
elif hasattr(value, '__len__') and (not isinstance(value, str)):
|
||||||
if dataclasses.is_dataclass(value): #make subtopics of dataclasses
|
if dataclasses.is_dataclass(value): #make subtopics of dataclasses
|
||||||
publish(value,topic+"/"+key)
|
publish(value,topic+"/"+key)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: py-vapid
|
||||||
|
Version: 1.9.4
|
||||||
|
Summary: Simple VAPID header generation library
|
||||||
|
Project-URL: Homepage, https://github.com/mozilla-services/vapid
|
||||||
|
Author-email: JR Conlin <src+vapid@jrconlin.com>
|
||||||
|
License: MPL-2.0
|
||||||
|
License-File: LICENSE
|
||||||
|
Keywords: push,vapid,webpush
|
||||||
|
Classifier: Programming Language :: Python
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Topic :: Internet :: WWW/HTTP
|
||||||
|
Requires-Dist: cryptography>=46
|
||||||
|
Provides-Extra: test
|
||||||
|
Requires-Dist: coverage; extra == 'test'
|
||||||
|
Requires-Dist: flake8; extra == 'test'
|
||||||
|
Requires-Dist: mock>=1.0; extra == 'test'
|
||||||
|
Requires-Dist: pytest; extra == 'test'
|
||||||
|
Description-Content-Type: text/x-rst
|
||||||
|
|
||||||
|
|PyPI version py_vapid|
|
||||||
|
|
||||||
|
Easy VAPID generation
|
||||||
|
=====================
|
||||||
|
|
||||||
|
This minimal library contains the minimal set of functions you need to
|
||||||
|
generate a VAPID key set and get the headers you’ll need to sign a
|
||||||
|
WebPush subscription update.
|
||||||
|
|
||||||
|
VAPID is a voluntary standard for WebPush subscription providers (sites
|
||||||
|
that send WebPush updates to remote customers) to self-identify to Push
|
||||||
|
Servers (the servers that convey the push notifications).
|
||||||
|
|
||||||
|
The VAPID “claims” are a set of JSON keys and values. There are two
|
||||||
|
required fields, one semi-optional and several optional additional
|
||||||
|
fields.
|
||||||
|
|
||||||
|
At a minimum a VAPID claim set should look like:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
{"sub":"mailto:YourEmail@YourSite.com","aud":"https://PushServer","exp":"ExpirationTimestamp"}
|
||||||
|
|
||||||
|
A few notes:
|
||||||
|
|
||||||
|
**sub** is the email address you wish to have on record for this
|
||||||
|
request, prefixed with “``mailto:``”. If things go wrong, this is the
|
||||||
|
email that will be used to contact you (for instance). This can be a
|
||||||
|
general delivery address like “``mailto:push_operations@example.com``”
|
||||||
|
or a specific address like “``mailto:bob@example.com``”.
|
||||||
|
|
||||||
|
**aud** is the audience for the VAPID. This is the scheme and host you
|
||||||
|
use to send subscription endpoints and generally coincides with the
|
||||||
|
``endpoint`` specified in the Subscription Info block.
|
||||||
|
|
||||||
|
As example, if a WebPush subscription info contains:
|
||||||
|
``{"endpoint": "https://push.example.com:8012/v1/push/...", ...}``
|
||||||
|
|
||||||
|
then the ``aud`` would be “``https://push.example.com:8012``”
|
||||||
|
|
||||||
|
While some Push Services consider this an optional field, others may be
|
||||||
|
stricter.
|
||||||
|
|
||||||
|
**exp** This is the UTC timestamp for when this VAPID request will
|
||||||
|
expire. The maximum period is 24 hours. Setting a shorter period can
|
||||||
|
prevent “replay” attacks. Setting a longer period allows you to reuse
|
||||||
|
headers for multiple sends (e.g. if you’re sending hundreds of updates
|
||||||
|
within an hour or so.) If no ``exp`` is included, one that will expire
|
||||||
|
in 24 hours will be auto-generated for you.
|
||||||
|
|
||||||
|
Claims should be stored in a JSON compatible file. In the examples
|
||||||
|
below, we’ve stored the claims into a file named ``claims.json``.
|
||||||
|
|
||||||
|
py_vapid can either be installed as a library or used as a stand along
|
||||||
|
app, ``bin/vapid``.
|
||||||
|
|
||||||
|
App Installation
|
||||||
|
----------------
|
||||||
|
|
||||||
|
You’ll need ``python virtualenv`` Run that in the current directory.
|
||||||
|
|
||||||
|
Then run
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
bin/pip install -r requirements.txt
|
||||||
|
|
||||||
|
bin/python -m pip install -e .
|
||||||
|
|
||||||
|
App Usage
|
||||||
|
---------
|
||||||
|
|
||||||
|
Run by itself, ``bin/vapid`` will check and optionally create the
|
||||||
|
public_key.pem and private_key.pem files.
|
||||||
|
|
||||||
|
``bin/vapid --gen`` can be used to generate a new set of public and
|
||||||
|
private key PEM files. These will overwrite the contents of
|
||||||
|
``private_key.pem`` and ``public_key.pem``.
|
||||||
|
|
||||||
|
``bin/vapid --sign claims.json`` will generate a set of HTTP headers
|
||||||
|
from a JSON formatted claims file. A sample ``claims.json`` is included
|
||||||
|
with this distribution.
|
||||||
|
|
||||||
|
``bin/vapid --sign claims.json --json`` will output the headers in JSON
|
||||||
|
format, which may be useful for other programs.
|
||||||
|
|
||||||
|
``bin/vapid --applicationServerKey`` will return the
|
||||||
|
``applicationServerKey`` value you can use to make a restricted
|
||||||
|
endpoint. See
|
||||||
|
https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe
|
||||||
|
for more details. Be aware that this value is tied to the generated
|
||||||
|
public/private key. If you remove or generate a new key, any restricted
|
||||||
|
URL you’ve previously generated will need to be reallocated. Please note
|
||||||
|
that some User Agents may require you `to decode this string into a
|
||||||
|
Uint8Array <https://github.com/GoogleChrome/push-notifications/blob/master/app/scripts/main.js>`__.
|
||||||
|
|
||||||
|
See ``bin/vapid -h`` for all options and commands.
|
||||||
|
|
||||||
|
CHANGELOG
|
||||||
|
---------
|
||||||
|
|
||||||
|
I’m terrible about updating the Changelog. Please see the
|
||||||
|
```git log`` <https://github.com/web-push-libs/vapid/pulls?q=is%3Apr+is%3Aclosed>`__
|
||||||
|
history for details.
|
||||||
|
|
||||||
|
.. |PyPI version py_vapid| image:: https://badge.fury.io/py/py-vapid.svg
|
||||||
|
:target: https://pypi.org/project/py-vapid/
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
py_vapid/__init__.py,sha256=mw3C-f44oNgyrdtBjiQfVQWajblAn9PzdNXkgHLmq_c,12572
|
||||||
|
py_vapid/__main__.py,sha256=SUF8l6XUGRXf5f3t3SE6gIifpa2Wml4z9XstEaUowGU,4657
|
||||||
|
py_vapid/jwt.py,sha256=GIbrJc2Sb2frpZQMbCY04Cw9v-iHolBccy5qKCrDYE8,2547
|
||||||
|
py_vapid/main.py,sha256=44Lyn5lDapqQ8OCMxzcwlMZsm9jWh_zEkQcfzeteCno,4548
|
||||||
|
py_vapid/utils.py,sha256=1OGZIKOcQRnjJaTzy81SVopIyvC-AGME-eu5nBcnPLw,921
|
||||||
|
py_vapid/tests/.test_vapid.py.swp,sha256=F47Jt7zk85_Pv46wQgerqo7DuJHrHe99dd2B6TxcCS0,16384
|
||||||
|
py_vapid/tests/test_vapid.py,sha256=Rg1bBdhUUhF9sgRuwcDcJd0wGLTff7HmyOBifGtBOJc,10403
|
||||||
|
py_vapid-1.9.4.dist-info/METADATA,sha256=_iiqfj5sVbSZQKt7EeQ-miKfjOAfzBvCkjkf6wPXqkU,4596
|
||||||
|
py_vapid-1.9.4.dist-info/WHEEL,sha256=aha0VrrYvgDJ3Xxl3db_g_MDIW-ZexDdrc_m-Hk8YY4,105
|
||||||
|
py_vapid-1.9.4.dist-info/entry_points.txt,sha256=8VfF1HHZcNIS15s9Y9JNV3Ue2nX2VIbSbOdkxClZJRc,45
|
||||||
|
py_vapid-1.9.4.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
|
||||||
|
py_vapid-1.9.4.dist-info/RECORD,,
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: hatchling 1.28.0
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py2-none-any
|
||||||
|
Tag: py3-none-any
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[console_scripts]
|
||||||
|
vapid = py_vapid.main:main
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
Mozilla Public License Version 2.0
|
||||||
|
==================================
|
||||||
|
|
||||||
|
1. Definitions
|
||||||
|
--------------
|
||||||
|
|
||||||
|
1.1. "Contributor"
|
||||||
|
means each individual or legal entity that creates, contributes to
|
||||||
|
the creation of, or owns Covered Software.
|
||||||
|
|
||||||
|
1.2. "Contributor Version"
|
||||||
|
means the combination of the Contributions of others (if any) used
|
||||||
|
by a Contributor and that particular Contributor's Contribution.
|
||||||
|
|
||||||
|
1.3. "Contribution"
|
||||||
|
means Covered Software of a particular Contributor.
|
||||||
|
|
||||||
|
1.4. "Covered Software"
|
||||||
|
means Source Code Form to which the initial Contributor has attached
|
||||||
|
the notice in Exhibit A, the Executable Form of such Source Code
|
||||||
|
Form, and Modifications of such Source Code Form, in each case
|
||||||
|
including portions thereof.
|
||||||
|
|
||||||
|
1.5. "Incompatible With Secondary Licenses"
|
||||||
|
means
|
||||||
|
|
||||||
|
(a) that the initial Contributor has attached the notice described
|
||||||
|
in Exhibit B to the Covered Software; or
|
||||||
|
|
||||||
|
(b) that the Covered Software was made available under the terms of
|
||||||
|
version 1.1 or earlier of the License, but not also under the
|
||||||
|
terms of a Secondary License.
|
||||||
|
|
||||||
|
1.6. "Executable Form"
|
||||||
|
means any form of the work other than Source Code Form.
|
||||||
|
|
||||||
|
1.7. "Larger Work"
|
||||||
|
means a work that combines Covered Software with other material, in
|
||||||
|
a separate file or files, that is not Covered Software.
|
||||||
|
|
||||||
|
1.8. "License"
|
||||||
|
means this document.
|
||||||
|
|
||||||
|
1.9. "Licensable"
|
||||||
|
means having the right to grant, to the maximum extent possible,
|
||||||
|
whether at the time of the initial grant or subsequently, any and
|
||||||
|
all of the rights conveyed by this License.
|
||||||
|
|
||||||
|
1.10. "Modifications"
|
||||||
|
means any of the following:
|
||||||
|
|
||||||
|
(a) any file in Source Code Form that results from an addition to,
|
||||||
|
deletion from, or modification of the contents of Covered
|
||||||
|
Software; or
|
||||||
|
|
||||||
|
(b) any new file in Source Code Form that contains any Covered
|
||||||
|
Software.
|
||||||
|
|
||||||
|
1.11. "Patent Claims" of a Contributor
|
||||||
|
means any patent claim(s), including without limitation, method,
|
||||||
|
process, and apparatus claims, in any patent Licensable by such
|
||||||
|
Contributor that would be infringed, but for the grant of the
|
||||||
|
License, by the making, using, selling, offering for sale, having
|
||||||
|
made, import, or transfer of either its Contributions or its
|
||||||
|
Contributor Version.
|
||||||
|
|
||||||
|
1.12. "Secondary License"
|
||||||
|
means either the GNU General Public License, Version 2.0, the GNU
|
||||||
|
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||||
|
Public License, Version 3.0, or any later versions of those
|
||||||
|
licenses.
|
||||||
|
|
||||||
|
1.13. "Source Code Form"
|
||||||
|
means the form of the work preferred for making modifications.
|
||||||
|
|
||||||
|
1.14. "You" (or "Your")
|
||||||
|
means an individual or a legal entity exercising rights under this
|
||||||
|
License. For legal entities, "You" includes any entity that
|
||||||
|
controls, is controlled by, or is under common control with You. For
|
||||||
|
purposes of this definition, "control" means (a) the power, direct
|
||||||
|
or indirect, to cause the direction or management of such entity,
|
||||||
|
whether by contract or otherwise, or (b) ownership of more than
|
||||||
|
fifty percent (50%) of the outstanding shares or beneficial
|
||||||
|
ownership of such entity.
|
||||||
|
|
||||||
|
2. License Grants and Conditions
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
2.1. Grants
|
||||||
|
|
||||||
|
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||||
|
non-exclusive license:
|
||||||
|
|
||||||
|
(a) under intellectual property rights (other than patent or trademark)
|
||||||
|
Licensable by such Contributor to use, reproduce, make available,
|
||||||
|
modify, display, perform, distribute, and otherwise exploit its
|
||||||
|
Contributions, either on an unmodified basis, with Modifications, or
|
||||||
|
as part of a Larger Work; and
|
||||||
|
|
||||||
|
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||||
|
for sale, have made, import, and otherwise transfer either its
|
||||||
|
Contributions or its Contributor Version.
|
||||||
|
|
||||||
|
2.2. Effective Date
|
||||||
|
|
||||||
|
The licenses granted in Section 2.1 with respect to any Contribution
|
||||||
|
become effective for each Contribution on the date the Contributor first
|
||||||
|
distributes such Contribution.
|
||||||
|
|
||||||
|
2.3. Limitations on Grant Scope
|
||||||
|
|
||||||
|
The licenses granted in this Section 2 are the only rights granted under
|
||||||
|
this License. No additional rights or licenses will be implied from the
|
||||||
|
distribution or licensing of Covered Software under this License.
|
||||||
|
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||||
|
Contributor:
|
||||||
|
|
||||||
|
(a) for any code that a Contributor has removed from Covered Software;
|
||||||
|
or
|
||||||
|
|
||||||
|
(b) for infringements caused by: (i) Your and any other third party's
|
||||||
|
modifications of Covered Software, or (ii) the combination of its
|
||||||
|
Contributions with other software (except as part of its Contributor
|
||||||
|
Version); or
|
||||||
|
|
||||||
|
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||||
|
its Contributions.
|
||||||
|
|
||||||
|
This License does not grant any rights in the trademarks, service marks,
|
||||||
|
or logos of any Contributor (except as may be necessary to comply with
|
||||||
|
the notice requirements in Section 3.4).
|
||||||
|
|
||||||
|
2.4. Subsequent Licenses
|
||||||
|
|
||||||
|
No Contributor makes additional grants as a result of Your choice to
|
||||||
|
distribute the Covered Software under a subsequent version of this
|
||||||
|
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||||
|
permitted under the terms of Section 3.3).
|
||||||
|
|
||||||
|
2.5. Representation
|
||||||
|
|
||||||
|
Each Contributor represents that the Contributor believes its
|
||||||
|
Contributions are its original creation(s) or it has sufficient rights
|
||||||
|
to grant the rights to its Contributions conveyed by this License.
|
||||||
|
|
||||||
|
2.6. Fair Use
|
||||||
|
|
||||||
|
This License is not intended to limit any rights You have under
|
||||||
|
applicable copyright doctrines of fair use, fair dealing, or other
|
||||||
|
equivalents.
|
||||||
|
|
||||||
|
2.7. Conditions
|
||||||
|
|
||||||
|
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||||
|
in Section 2.1.
|
||||||
|
|
||||||
|
3. Responsibilities
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
3.1. Distribution of Source Form
|
||||||
|
|
||||||
|
All distribution of Covered Software in Source Code Form, including any
|
||||||
|
Modifications that You create or to which You contribute, must be under
|
||||||
|
the terms of this License. You must inform recipients that the Source
|
||||||
|
Code Form of the Covered Software is governed by the terms of this
|
||||||
|
License, and how they can obtain a copy of this License. You may not
|
||||||
|
attempt to alter or restrict the recipients' rights in the Source Code
|
||||||
|
Form.
|
||||||
|
|
||||||
|
3.2. Distribution of Executable Form
|
||||||
|
|
||||||
|
If You distribute Covered Software in Executable Form then:
|
||||||
|
|
||||||
|
(a) such Covered Software must also be made available in Source Code
|
||||||
|
Form, as described in Section 3.1, and You must inform recipients of
|
||||||
|
the Executable Form how they can obtain a copy of such Source Code
|
||||||
|
Form by reasonable means in a timely manner, at a charge no more
|
||||||
|
than the cost of distribution to the recipient; and
|
||||||
|
|
||||||
|
(b) You may distribute such Executable Form under the terms of this
|
||||||
|
License, or sublicense it under different terms, provided that the
|
||||||
|
license for the Executable Form does not attempt to limit or alter
|
||||||
|
the recipients' rights in the Source Code Form under this License.
|
||||||
|
|
||||||
|
3.3. Distribution of a Larger Work
|
||||||
|
|
||||||
|
You may create and distribute a Larger Work under terms of Your choice,
|
||||||
|
provided that You also comply with the requirements of this License for
|
||||||
|
the Covered Software. If the Larger Work is a combination of Covered
|
||||||
|
Software with a work governed by one or more Secondary Licenses, and the
|
||||||
|
Covered Software is not Incompatible With Secondary Licenses, this
|
||||||
|
License permits You to additionally distribute such Covered Software
|
||||||
|
under the terms of such Secondary License(s), so that the recipient of
|
||||||
|
the Larger Work may, at their option, further distribute the Covered
|
||||||
|
Software under the terms of either this License or such Secondary
|
||||||
|
License(s).
|
||||||
|
|
||||||
|
3.4. Notices
|
||||||
|
|
||||||
|
You may not remove or alter the substance of any license notices
|
||||||
|
(including copyright notices, patent notices, disclaimers of warranty,
|
||||||
|
or limitations of liability) contained within the Source Code Form of
|
||||||
|
the Covered Software, except that You may alter any license notices to
|
||||||
|
the extent required to remedy known factual inaccuracies.
|
||||||
|
|
||||||
|
3.5. Application of Additional Terms
|
||||||
|
|
||||||
|
You may choose to offer, and to charge a fee for, warranty, support,
|
||||||
|
indemnity or liability obligations to one or more recipients of Covered
|
||||||
|
Software. However, You may do so only on Your own behalf, and not on
|
||||||
|
behalf of any Contributor. You must make it absolutely clear that any
|
||||||
|
such warranty, support, indemnity, or liability obligation is offered by
|
||||||
|
You alone, and You hereby agree to indemnify every Contributor for any
|
||||||
|
liability incurred by such Contributor as a result of warranty, support,
|
||||||
|
indemnity or liability terms You offer. You may include additional
|
||||||
|
disclaimers of warranty and limitations of liability specific to any
|
||||||
|
jurisdiction.
|
||||||
|
|
||||||
|
4. Inability to Comply Due to Statute or Regulation
|
||||||
|
---------------------------------------------------
|
||||||
|
|
||||||
|
If it is impossible for You to comply with any of the terms of this
|
||||||
|
License with respect to some or all of the Covered Software due to
|
||||||
|
statute, judicial order, or regulation then You must: (a) comply with
|
||||||
|
the terms of this License to the maximum extent possible; and (b)
|
||||||
|
describe the limitations and the code they affect. Such description must
|
||||||
|
be placed in a text file included with all distributions of the Covered
|
||||||
|
Software under this License. Except to the extent prohibited by statute
|
||||||
|
or regulation, such description must be sufficiently detailed for a
|
||||||
|
recipient of ordinary skill to be able to understand it.
|
||||||
|
|
||||||
|
5. Termination
|
||||||
|
--------------
|
||||||
|
|
||||||
|
5.1. The rights granted under this License will terminate automatically
|
||||||
|
if You fail to comply with any of its terms. However, if You become
|
||||||
|
compliant, then the rights granted under this License from a particular
|
||||||
|
Contributor are reinstated (a) provisionally, unless and until such
|
||||||
|
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||||
|
ongoing basis, if such Contributor fails to notify You of the
|
||||||
|
non-compliance by some reasonable means prior to 60 days after You have
|
||||||
|
come back into compliance. Moreover, Your grants from a particular
|
||||||
|
Contributor are reinstated on an ongoing basis if such Contributor
|
||||||
|
notifies You of the non-compliance by some reasonable means, this is the
|
||||||
|
first time You have received notice of non-compliance with this License
|
||||||
|
from such Contributor, and You become compliant prior to 30 days after
|
||||||
|
Your receipt of the notice.
|
||||||
|
|
||||||
|
5.2. If You initiate litigation against any entity by asserting a patent
|
||||||
|
infringement claim (excluding declaratory judgment actions,
|
||||||
|
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||||
|
directly or indirectly infringes any patent, then the rights granted to
|
||||||
|
You by any and all Contributors for the Covered Software under Section
|
||||||
|
2.1 of this License shall terminate.
|
||||||
|
|
||||||
|
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||||
|
end user license agreements (excluding distributors and resellers) which
|
||||||
|
have been validly granted by You or Your distributors under this License
|
||||||
|
prior to termination shall survive termination.
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 6. Disclaimer of Warranty *
|
||||||
|
* ------------------------- *
|
||||||
|
* *
|
||||||
|
* Covered Software is provided under this License on an "as is" *
|
||||||
|
* basis, without warranty of any kind, either expressed, implied, or *
|
||||||
|
* statutory, including, without limitation, warranties that the *
|
||||||
|
* Covered Software is free of defects, merchantable, fit for a *
|
||||||
|
* particular purpose or non-infringing. The entire risk as to the *
|
||||||
|
* quality and performance of the Covered Software is with You. *
|
||||||
|
* Should any Covered Software prove defective in any respect, You *
|
||||||
|
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||||
|
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||||
|
* essential part of this License. No use of any Covered Software is *
|
||||||
|
* authorized under this License except under this disclaimer. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 7. Limitation of Liability *
|
||||||
|
* -------------------------- *
|
||||||
|
* *
|
||||||
|
* Under no circumstances and under no legal theory, whether tort *
|
||||||
|
* (including negligence), contract, or otherwise, shall any *
|
||||||
|
* Contributor, or anyone who distributes Covered Software as *
|
||||||
|
* permitted above, be liable to You for any direct, indirect, *
|
||||||
|
* special, incidental, or consequential damages of any character *
|
||||||
|
* including, without limitation, damages for lost profits, loss of *
|
||||||
|
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||||
|
* and all other commercial damages or losses, even if such party *
|
||||||
|
* shall have been informed of the possibility of such damages. This *
|
||||||
|
* limitation of liability shall not apply to liability for death or *
|
||||||
|
* personal injury resulting from such party's negligence to the *
|
||||||
|
* extent applicable law prohibits such limitation. Some *
|
||||||
|
* jurisdictions do not allow the exclusion or limitation of *
|
||||||
|
* incidental or consequential damages, so this exclusion and *
|
||||||
|
* limitation may not apply to You. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
8. Litigation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Any litigation relating to this License may be brought only in the
|
||||||
|
courts of a jurisdiction where the defendant maintains its principal
|
||||||
|
place of business and such litigation shall be governed by laws of that
|
||||||
|
jurisdiction, without reference to its conflict-of-law provisions.
|
||||||
|
Nothing in this Section shall prevent a party's ability to bring
|
||||||
|
cross-claims or counter-claims.
|
||||||
|
|
||||||
|
9. Miscellaneous
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This License represents the complete agreement concerning the subject
|
||||||
|
matter hereof. If any provision of this License is held to be
|
||||||
|
unenforceable, such provision shall be reformed only to the extent
|
||||||
|
necessary to make it enforceable. Any law or regulation which provides
|
||||||
|
that the language of a contract shall be construed against the drafter
|
||||||
|
shall not be used to construe this License against a Contributor.
|
||||||
|
|
||||||
|
10. Versions of the License
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
10.1. New Versions
|
||||||
|
|
||||||
|
Mozilla Foundation is the license steward. Except as provided in Section
|
||||||
|
10.3, no one other than the license steward has the right to modify or
|
||||||
|
publish new versions of this License. Each version will be given a
|
||||||
|
distinguishing version number.
|
||||||
|
|
||||||
|
10.2. Effect of New Versions
|
||||||
|
|
||||||
|
You may distribute the Covered Software under the terms of the version
|
||||||
|
of the License under which You originally received the Covered Software,
|
||||||
|
or under the terms of any subsequent version published by the license
|
||||||
|
steward.
|
||||||
|
|
||||||
|
10.3. Modified Versions
|
||||||
|
|
||||||
|
If you create software not governed by this License, and you want to
|
||||||
|
create a new license for such software, you may create and use a
|
||||||
|
modified version of this License if you rename the license and remove
|
||||||
|
any references to the name of the license steward (except to note that
|
||||||
|
such modified license differs from this License).
|
||||||
|
|
||||||
|
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||||
|
Licenses
|
||||||
|
|
||||||
|
If You choose to distribute Source Code Form that is Incompatible With
|
||||||
|
Secondary Licenses under the terms of this version of the License, the
|
||||||
|
notice described in Exhibit B of this License must be attached.
|
||||||
|
|
||||||
|
Exhibit A - Source Code Form License Notice
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
If it is not possible or desirable to put the notice in a particular
|
||||||
|
file, then You may include the notice in a location (such as a LICENSE
|
||||||
|
file in a relevant directory) where a recipient would be likely to look
|
||||||
|
for such a notice.
|
||||||
|
|
||||||
|
You may add additional accurate notices of copyright ownership.
|
||||||
|
|
||||||
|
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||||
|
---------------------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||||
|
defined by the Mozilla Public License, v. 2.0.
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import binascii
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import copy
|
||||||
|
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec, utils as ecutils
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
|
||||||
|
from py_vapid.utils import b64urldecode, b64urlencode
|
||||||
|
from py_vapid.jwt import sign
|
||||||
|
|
||||||
|
# Show compliance version. For earlier versions see previously tagged releases.
|
||||||
|
VERSION = "VAPID-RFC/ECE-RFC"
|
||||||
|
|
||||||
|
|
||||||
|
class VapidException(Exception):
|
||||||
|
"""An exception wrapper for Vapid."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Vapid01(object):
|
||||||
|
"""Minimal VAPID Draft 01 signature generation library.
|
||||||
|
|
||||||
|
https://tools.ietf.org/html/draft-ietf-webpush-vapid-01
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
_private_key = None
|
||||||
|
_public_key = None
|
||||||
|
_schema = "WebPush"
|
||||||
|
|
||||||
|
def __init__(self, private_key=None, conf=None):
|
||||||
|
"""Initialize VAPID with an optional private key.
|
||||||
|
|
||||||
|
:param private_key: A private key object
|
||||||
|
:type private_key: ec.EllipticCurvePrivateKey
|
||||||
|
|
||||||
|
"""
|
||||||
|
if conf is None:
|
||||||
|
conf = {}
|
||||||
|
self.conf = conf
|
||||||
|
self.private_key = private_key
|
||||||
|
if private_key:
|
||||||
|
self._public_key = self.private_key.public_key()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_raw(cls, private_raw):
|
||||||
|
"""Initialize VAPID using a private key point in "raw" or
|
||||||
|
"uncompressed" form. Raw keys consist of a single, 32 octet
|
||||||
|
encoded integer.
|
||||||
|
|
||||||
|
:param private_raw: A private key point in uncompressed form.
|
||||||
|
:type private_raw: bytes
|
||||||
|
|
||||||
|
"""
|
||||||
|
key = ec.derive_private_key(
|
||||||
|
int(binascii.hexlify(b64urldecode(private_raw)), 16),
|
||||||
|
curve=ec.SECP256R1(),
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
return cls(key)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_raw_public(cls, public_raw):
|
||||||
|
key = ec.EllipticCurvePublicKey.from_encoded_point(
|
||||||
|
curve=ec.SECP256R1(), data=b64urldecode(public_raw)
|
||||||
|
)
|
||||||
|
ss = cls()
|
||||||
|
ss._public_key = key
|
||||||
|
return ss
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_pem(cls, private_key):
|
||||||
|
"""Initialize VAPID using a private key in PEM format.
|
||||||
|
|
||||||
|
:param private_key: A private key in PEM format.
|
||||||
|
:type private_key: bytes
|
||||||
|
|
||||||
|
"""
|
||||||
|
# not sure why, but load_pem_private_key fails to deserialize
|
||||||
|
return cls.from_der(b"".join(private_key.splitlines()[1:-1]))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_der(cls, private_key):
|
||||||
|
"""Initialize VAPID using a private key in DER format.
|
||||||
|
|
||||||
|
:param private_key: A private key in DER format and Base64-encoded.
|
||||||
|
:type private_key: bytes
|
||||||
|
|
||||||
|
"""
|
||||||
|
key = serialization.load_der_private_key(
|
||||||
|
b64urldecode(private_key), password=None, backend=default_backend()
|
||||||
|
)
|
||||||
|
return cls(key)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, private_key_file=None):
|
||||||
|
"""Initialize VAPID using a file containing a private key in PEM or
|
||||||
|
DER format.
|
||||||
|
|
||||||
|
:param private_key_file: Name of the file containing the private key
|
||||||
|
:type private_key_file: str
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not os.path.isfile(private_key_file):
|
||||||
|
logging.info("Private key not found, generating key...")
|
||||||
|
vapid = cls()
|
||||||
|
vapid.generate_keys()
|
||||||
|
vapid.save_key(private_key_file)
|
||||||
|
return vapid
|
||||||
|
with open(private_key_file, "r") as file:
|
||||||
|
private_key = file.read()
|
||||||
|
try:
|
||||||
|
if "-----BEGIN" in private_key:
|
||||||
|
vapid = cls.from_pem(private_key.encode("utf8"))
|
||||||
|
else:
|
||||||
|
vapid = cls.from_der(private_key.encode("utf8"))
|
||||||
|
return vapid
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("Could not open private key file: %s", repr(exc))
|
||||||
|
raise VapidException(exc)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_string(cls, private_key):
|
||||||
|
"""Initialize VAPID using a string containing the private key. This
|
||||||
|
will try to determine if the key is in RAW or DER format.
|
||||||
|
|
||||||
|
:param private_key: String containing the key info
|
||||||
|
:type private_key: str
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
pkey = private_key.encode().replace(b"\n", b"")
|
||||||
|
key = b64urldecode(pkey)
|
||||||
|
if len(key) == 32:
|
||||||
|
return cls.from_raw(pkey)
|
||||||
|
return cls.from_der(pkey)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def verify(cls, key, auth):
|
||||||
|
"""Verify a VAPID authorization token.
|
||||||
|
|
||||||
|
:param key: base64 serialized public key
|
||||||
|
:type key: str
|
||||||
|
:param auth: authorization token
|
||||||
|
type key: str
|
||||||
|
|
||||||
|
"""
|
||||||
|
tokens = auth.rsplit(" ", 1)[1].rsplit(".", 1)
|
||||||
|
kp = cls().from_raw_public(key.encode())
|
||||||
|
return kp.verify_token(
|
||||||
|
validation_token=tokens[0].encode(), verification_token=tokens[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def private_key(self):
|
||||||
|
"""The VAPID private ECDSA key"""
|
||||||
|
if not self._private_key:
|
||||||
|
raise VapidException("No private key. Call generate_keys()")
|
||||||
|
return self._private_key
|
||||||
|
|
||||||
|
@private_key.setter
|
||||||
|
def private_key(self, value):
|
||||||
|
"""Set the VAPID private ECDSA key
|
||||||
|
|
||||||
|
:param value: the byte array containing the private ECDSA key data
|
||||||
|
:type value: ec.EllipticCurvePrivateKey
|
||||||
|
|
||||||
|
"""
|
||||||
|
self._private_key = value
|
||||||
|
if value:
|
||||||
|
self._public_key = self.private_key.public_key()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_key(self):
|
||||||
|
"""The VAPID public ECDSA key
|
||||||
|
|
||||||
|
The public key is currently read only. Set it via the `.private_key`
|
||||||
|
method. This will autogenerate a public and private key if no value
|
||||||
|
has been set.
|
||||||
|
|
||||||
|
:returns ec.EllipticCurvePublicKey
|
||||||
|
|
||||||
|
"""
|
||||||
|
return self._public_key
|
||||||
|
|
||||||
|
def generate_keys(self):
|
||||||
|
"""Generate a valid ECDSA Key Pair."""
|
||||||
|
self.private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||||
|
|
||||||
|
def private_pem(self):
|
||||||
|
return self.private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def public_pem(self):
|
||||||
|
return self.public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_key(self, key_file):
|
||||||
|
"""Save the private key to a PEM file.
|
||||||
|
|
||||||
|
:param key_file: The file path to save the private key data
|
||||||
|
:type key_file: str
|
||||||
|
|
||||||
|
"""
|
||||||
|
with open(key_file, "wb") as file:
|
||||||
|
file.write(self.private_pem())
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
def save_public_key(self, key_file):
|
||||||
|
"""Save the public key to a PEM file.
|
||||||
|
:param key_file: The name of the file to save the public key
|
||||||
|
:type key_file: str
|
||||||
|
|
||||||
|
"""
|
||||||
|
with open(key_file, "wb") as file:
|
||||||
|
file.write(self.public_pem())
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
def verify_token(self, validation_token, verification_token):
|
||||||
|
"""Internally used to verify the verification token is correct.
|
||||||
|
|
||||||
|
:param validation_token: Provided validation token string
|
||||||
|
:type validation_token: str
|
||||||
|
:param verification_token: Generated verification token
|
||||||
|
:type verification_token: str
|
||||||
|
:returns: Boolean indicating if verifictation token is valid.
|
||||||
|
:rtype: boolean
|
||||||
|
|
||||||
|
"""
|
||||||
|
hsig = b64urldecode(verification_token.encode("utf8"))
|
||||||
|
r = int(binascii.hexlify(hsig[:32]), 16)
|
||||||
|
s = int(binascii.hexlify(hsig[32:]), 16)
|
||||||
|
try:
|
||||||
|
self.public_key.verify(
|
||||||
|
ecutils.encode_dss_signature(r, s),
|
||||||
|
validation_token,
|
||||||
|
signature_algorithm=ec.ECDSA(hashes.SHA256()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except InvalidSignature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _base_sign(self, claims):
|
||||||
|
cclaims = copy.deepcopy(claims)
|
||||||
|
if not cclaims.get("exp"):
|
||||||
|
cclaims["exp"] = int(time.time()) + 86400
|
||||||
|
if not self.conf.get("no-strict", False):
|
||||||
|
valid = _check_sub(cclaims.get("sub", ""))
|
||||||
|
else:
|
||||||
|
valid = cclaims.get("sub") is not None
|
||||||
|
if not valid:
|
||||||
|
raise VapidException(
|
||||||
|
"Missing 'sub' from claims. "
|
||||||
|
"'sub' is your admin email as a mailto: link."
|
||||||
|
)
|
||||||
|
if not re.match(
|
||||||
|
r"^https?://[^/:]+(:\d+)?$", cclaims.get("aud", ""), re.IGNORECASE
|
||||||
|
):
|
||||||
|
raise VapidException(
|
||||||
|
"Missing 'aud' from claims. "
|
||||||
|
"'aud' is the scheme, host and optional port for this "
|
||||||
|
"transaction e.g. https://example.com:8080"
|
||||||
|
)
|
||||||
|
return cclaims
|
||||||
|
|
||||||
|
def sign(self, claims, crypto_key=None):
|
||||||
|
"""Sign a set of claims.
|
||||||
|
:param claims: JSON object containing the JWT claims to use.
|
||||||
|
:type claims: dict
|
||||||
|
:param crypto_key: Optional existing crypto_key header content. The
|
||||||
|
vapid public key will be appended to this data.
|
||||||
|
:type crypto_key: str
|
||||||
|
:returns: a hash containing the header fields to use in
|
||||||
|
the subscription update.
|
||||||
|
:rtype: dict
|
||||||
|
|
||||||
|
"""
|
||||||
|
sig = sign(self._base_sign(claims), self.private_key)
|
||||||
|
pkey = "p256ecdsa="
|
||||||
|
pkey += b64urlencode(
|
||||||
|
self.public_key.public_bytes(
|
||||||
|
serialization.Encoding.X962,
|
||||||
|
serialization.PublicFormat.UncompressedPoint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if crypto_key:
|
||||||
|
crypto_key = crypto_key + ";" + pkey
|
||||||
|
else:
|
||||||
|
crypto_key = pkey
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Authorization": "{} {}".format(self._schema, sig.strip("=")),
|
||||||
|
"Crypto-Key": crypto_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Vapid02(Vapid01):
|
||||||
|
"""Minimal Vapid RFC8292 signature generation library
|
||||||
|
|
||||||
|
https://tools.ietf.org/html/rfc8292
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
_schema = "vapid"
|
||||||
|
|
||||||
|
def sign(self, claims, crypto_key=None):
|
||||||
|
"""Generate an authorization token
|
||||||
|
|
||||||
|
:param claims: JSON object containing the JWT claims to use.
|
||||||
|
:type claims: dict
|
||||||
|
:param crypto_key: Optional existing crypto_key header content. The
|
||||||
|
vapid public key will be appended to this data.
|
||||||
|
:type crypto_key: str
|
||||||
|
:returns: a hash containing the header fields to use in
|
||||||
|
the subscription update.
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
sig = sign(self._base_sign(claims), self.private_key)
|
||||||
|
pkey = self.public_key.public_bytes(
|
||||||
|
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"Authorization": "{schema} t={t},k={k}".format(
|
||||||
|
schema=self._schema, t=sig, k=b64urlencode(pkey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def verify(cls, auth):
|
||||||
|
"""Ensure that the token is correctly formatted and valid
|
||||||
|
|
||||||
|
:param auth: An Authorization header
|
||||||
|
:type auth: str
|
||||||
|
:rtype: bool
|
||||||
|
|
||||||
|
"""
|
||||||
|
pref_tok = auth.rsplit(" ", 1)
|
||||||
|
assert pref_tok[0].lower() == cls._schema, "Incorrect schema specified"
|
||||||
|
parts = {}
|
||||||
|
for tok in pref_tok[1].split(","):
|
||||||
|
kv = tok.split("=", 1)
|
||||||
|
parts[kv[0]] = kv[1]
|
||||||
|
assert "k" in parts.keys(), "Auth missing public key 'k' value"
|
||||||
|
assert "t" in parts.keys(), "Auth missing token set 't' value"
|
||||||
|
kp = cls().from_raw_public(parts["k"].encode())
|
||||||
|
tokens = parts["t"].rsplit(".", 1)
|
||||||
|
return kp.verify_token(
|
||||||
|
validation_token=tokens[0].encode(), verification_token=tokens[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_sub(sub):
|
||||||
|
"""Check to see if the `sub` is a properly formatted `mailto:`
|
||||||
|
|
||||||
|
a `mailto:` should be a SMTP mail address. Mind you, since I run
|
||||||
|
YouFailAtEmail.com, you have every right to yell about how terrible
|
||||||
|
this check is. I really should be doing a proper component parse
|
||||||
|
and valiate each component individually per RFC5341, instead I do
|
||||||
|
the unholy regex you see below.
|
||||||
|
|
||||||
|
:param sub: Candidate JWT `sub`
|
||||||
|
:type sub: str
|
||||||
|
:rtype: bool
|
||||||
|
|
||||||
|
"""
|
||||||
|
pattern = r"^(mailto:.+@((localhost|[%\w-]+(\.[%\w-]+)+|([0-9a-f]{1,4}):+([0-9a-f]{1,4})?)))|https:\/\/(localhost|[\w-]+\.[\w\.-]+|([0-9a-f]{1,4}:+)+([0-9a-f]{1,4})?)$" # noqa
|
||||||
|
return re.match(pattern, sub, re.IGNORECASE) is not None
|
||||||
|
|
||||||
|
|
||||||
|
Vapid = Vapid02
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
from typing import cast
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from py_vapid import Vapid01, Vapid02, b64urlencode
|
||||||
|
|
||||||
|
|
||||||
|
def prompt(prompt: str) -> str:
|
||||||
|
# Not sure why, but python3 throws and exception if you try to
|
||||||
|
# monkeypatch for this. It's ugly, but this seems to play nicer.
|
||||||
|
try:
|
||||||
|
return input(prompt)
|
||||||
|
except NameError:
|
||||||
|
return raw_input(prompt) # noqa: F821
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="VAPID tool")
|
||||||
|
parser.add_argument("--sign", "-s", help="claims file to sign")
|
||||||
|
parser.add_argument(
|
||||||
|
"--gen", "-g", help="generate new key pairs", default=False, action="store_true"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--version2",
|
||||||
|
"-2",
|
||||||
|
help="use RFC8292 VAPID spec",
|
||||||
|
default=True,
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--version1",
|
||||||
|
"-1",
|
||||||
|
help="use VAPID spec Draft-01",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json", help="dump as json", default=False, action="store_true"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-strict",
|
||||||
|
help='Do not be strict about "sub"',
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--applicationServerKey",
|
||||||
|
help="show applicationServerKey value",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--private-key", "-k", help="private key pem file", default="private_key.pem"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Added to solve 2.7 => 3.* incompatibility
|
||||||
|
# This library advocates for Vapid02
|
||||||
|
Vapid = Vapid02
|
||||||
|
if args.version1:
|
||||||
|
Vapid = Vapid01
|
||||||
|
if args.gen or not os.path.exists(args.private_key):
|
||||||
|
if not args.gen:
|
||||||
|
print("No private key file found.")
|
||||||
|
answer = None
|
||||||
|
while answer not in ["y", "n"]:
|
||||||
|
answer = prompt("Do you want me to create one for you? (Y/n)")
|
||||||
|
if not answer:
|
||||||
|
answer = "y"
|
||||||
|
answer = answer.lower()[0]
|
||||||
|
if answer == "n":
|
||||||
|
print("Sorry, can't do much for you then.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
vapid = Vapid(conf=vars(args))
|
||||||
|
vapid.generate_keys()
|
||||||
|
print("Generating private_key.pem")
|
||||||
|
vapid.save_key("private_key.pem")
|
||||||
|
print("Generating public_key.pem")
|
||||||
|
vapid.save_public_key("public_key.pem")
|
||||||
|
vapid = Vapid.from_file(args.private_key)
|
||||||
|
claim_file = args.sign
|
||||||
|
result = dict()
|
||||||
|
if args.applicationServerKey:
|
||||||
|
raw_pub = vapid.public_key.public_bytes(
|
||||||
|
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||||
|
)
|
||||||
|
print("Application Server Key = {}\n\n".format(b64urlencode(raw_pub)))
|
||||||
|
if claim_file:
|
||||||
|
if not os.path.exists(claim_file):
|
||||||
|
print("No {} file found.".format(claim_file))
|
||||||
|
print(
|
||||||
|
"""
|
||||||
|
The claims file should be a JSON formatted file that holds the
|
||||||
|
information that describes you. There are three elements in the claims
|
||||||
|
file you'll need:
|
||||||
|
|
||||||
|
"sub" This is your site's admin email address
|
||||||
|
(e.g. "mailto:admin@example.com")
|
||||||
|
"exp" This is the expiration time for the claim in seconds. If you don't
|
||||||
|
have one, I'll add one that expires in 24 hours.
|
||||||
|
|
||||||
|
You're also welcome to add additional fields to the claims which could be
|
||||||
|
helpful for the Push Service operations team to pass along to your operations
|
||||||
|
team (e.g. "ami-id": "e-123456", "cust-id": "a3sfa10987"). Remember to keep
|
||||||
|
these values short to prevent some servers from rejecting the transaction due
|
||||||
|
to overly large headers. See https://jwt.io/introduction/ for details.
|
||||||
|
|
||||||
|
For example, a claims.json file could contain:
|
||||||
|
|
||||||
|
{"sub": "mailto:admin@example.com"}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
exit(1)
|
||||||
|
try:
|
||||||
|
claims = json.loads(open(claim_file).read())
|
||||||
|
result.update(vapid.sign(claims))
|
||||||
|
except Exception as barrier:
|
||||||
|
print("Crap, something went wrong: {}".format(repr(barrier)))
|
||||||
|
raise barrier
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result))
|
||||||
|
return
|
||||||
|
print("Include the following headers in your request:\n")
|
||||||
|
for key, value in result.items():
|
||||||
|
print("{}: {}\n".format(key, value))
|
||||||
|
print("\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
|
||||||
|
from py_vapid.utils import b64urldecode, b64urlencode, num_to_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def extract_signature(auth):
|
||||||
|
"""Extracts the payload and signature from a JWT, converting from RFC7518
|
||||||
|
to RFC 3279
|
||||||
|
|
||||||
|
:param auth: A JWT Authorization Token.
|
||||||
|
:type auth: str
|
||||||
|
|
||||||
|
:return tuple containing the signature material and signature
|
||||||
|
|
||||||
|
"""
|
||||||
|
payload, asig = auth.encode('utf8').rsplit(b'.', 1)
|
||||||
|
sig = b64urldecode(asig)
|
||||||
|
if len(sig) != 64:
|
||||||
|
raise InvalidSignature()
|
||||||
|
|
||||||
|
encoded = utils.encode_dss_signature(
|
||||||
|
s=int(binascii.hexlify(sig[32:]), 16),
|
||||||
|
r=int(binascii.hexlify(sig[:32]), 16)
|
||||||
|
)
|
||||||
|
return payload, encoded
|
||||||
|
|
||||||
|
|
||||||
|
def decode(token, key):
|
||||||
|
"""Decode a web token into an assertion dictionary
|
||||||
|
|
||||||
|
:param token: VAPID auth token
|
||||||
|
:type token: str
|
||||||
|
:param key: bitarray containing the public key
|
||||||
|
:type key: str
|
||||||
|
|
||||||
|
:return dict of the VAPID claims
|
||||||
|
|
||||||
|
:raise InvalidSignature
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sig_material, signature = extract_signature(token)
|
||||||
|
dkey = b64urldecode(key.encode('utf8'))
|
||||||
|
pkey = ec.EllipticCurvePublicKey.from_encoded_point(
|
||||||
|
ec.SECP256R1(),
|
||||||
|
dkey,
|
||||||
|
)
|
||||||
|
pkey.verify(
|
||||||
|
signature,
|
||||||
|
sig_material,
|
||||||
|
ec.ECDSA(hashes.SHA256())
|
||||||
|
)
|
||||||
|
return json.loads(
|
||||||
|
b64urldecode(sig_material.split(b'.')[1]).decode('utf8')
|
||||||
|
)
|
||||||
|
except InvalidSignature:
|
||||||
|
raise
|
||||||
|
except(ValueError, TypeError, binascii.Error):
|
||||||
|
raise InvalidSignature()
|
||||||
|
|
||||||
|
|
||||||
|
def sign(claims, key):
|
||||||
|
"""Sign the claims
|
||||||
|
|
||||||
|
:param claims: list of JWS claims
|
||||||
|
:type claims: dict
|
||||||
|
:param key: Private key for signing
|
||||||
|
:type key: ec.EllipticCurvePrivateKey
|
||||||
|
:param algorithm: JWT "alg" descriptor
|
||||||
|
:type algorithm: str
|
||||||
|
|
||||||
|
"""
|
||||||
|
header = b64urlencode(b"""{"typ":"JWT","alg":"ES256"}""")
|
||||||
|
# Unfortunately, chrome seems to require the claims to be sorted.
|
||||||
|
claims = b64urlencode(json.dumps(claims,
|
||||||
|
separators=(',', ':'),
|
||||||
|
sort_keys=True).encode('utf8'))
|
||||||
|
token = "{}.{}".format(header, claims)
|
||||||
|
rsig = key.sign(token.encode('utf8'), ec.ECDSA(hashes.SHA256()))
|
||||||
|
(r, s) = utils.decode_dss_signature(rsig)
|
||||||
|
sig = b64urlencode(num_to_bytes(r, 32) + num_to_bytes(s, 32))
|
||||||
|
return "{}.{}".format(token, sig)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
from py_vapid import Vapid01, Vapid02, b64urlencode
|
||||||
|
|
||||||
|
|
||||||
|
def prompt(prompt):
|
||||||
|
# Not sure why, but python3 throws and exception if you try to
|
||||||
|
# monkeypatch for this. It's ugly, but this seems to play nicer.
|
||||||
|
try:
|
||||||
|
return input(prompt)
|
||||||
|
except NameError:
|
||||||
|
return raw_input(prompt) # noqa: F821
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="VAPID tool")
|
||||||
|
parser.add_argument('--sign', '-s', help='claims file to sign')
|
||||||
|
parser.add_argument('--gen', '-g', help='generate new key pairs',
|
||||||
|
default=False, action="store_true")
|
||||||
|
parser.add_argument('--version2', '-2', help="use RFC8292 VAPID spec",
|
||||||
|
default=True, action="store_true")
|
||||||
|
parser.add_argument('--version1', '-1', help="use VAPID spec Draft-01",
|
||||||
|
default=False, action="store_true")
|
||||||
|
parser.add_argument('--json', help="dump as json",
|
||||||
|
default=False, action="store_true")
|
||||||
|
parser.add_argument('--no-strict', help='Do not be strict about "sub"',
|
||||||
|
default=False, action="store_true")
|
||||||
|
parser.add_argument('--applicationServerKey',
|
||||||
|
help="show applicationServerKey value",
|
||||||
|
default=False, action="store_true")
|
||||||
|
parser.add_argument('--private-key', '-k', help='private key pem file',
|
||||||
|
default="private_key.pem")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Added to solve 2.7 => 3.* incompatibility
|
||||||
|
Vapid = Vapid02
|
||||||
|
if args.version1:
|
||||||
|
Vapid = Vapid01
|
||||||
|
if args.gen or not os.path.exists(args.private_key):
|
||||||
|
if not args.gen:
|
||||||
|
print("No private key file found.")
|
||||||
|
answer = None
|
||||||
|
while answer not in ['y', 'n']:
|
||||||
|
answer = prompt("Do you want me to create one for you? (Y/n)")
|
||||||
|
if not answer:
|
||||||
|
answer = 'y'
|
||||||
|
answer = answer.lower()[0]
|
||||||
|
if answer == 'n':
|
||||||
|
print("Sorry, can't do much for you then.")
|
||||||
|
exit(1)
|
||||||
|
vapid = Vapid(conf=args)
|
||||||
|
vapid.generate_keys()
|
||||||
|
print("Generating private_key.pem")
|
||||||
|
vapid.save_key('private_key.pem')
|
||||||
|
print("Generating public_key.pem")
|
||||||
|
vapid.save_public_key('public_key.pem')
|
||||||
|
vapid = Vapid.from_file(args.private_key)
|
||||||
|
claim_file = args.sign
|
||||||
|
result = dict()
|
||||||
|
if args.applicationServerKey:
|
||||||
|
raw_pub = vapid.public_key.public_bytes(
|
||||||
|
serialization.Encoding.X962,
|
||||||
|
serialization.PublicFormat.UncompressedPoint
|
||||||
|
)
|
||||||
|
print("Application Server Key = {}\n\n".format(
|
||||||
|
b64urlencode(raw_pub)))
|
||||||
|
if claim_file:
|
||||||
|
if not os.path.exists(claim_file):
|
||||||
|
print("No {} file found.".format(claim_file))
|
||||||
|
print("""
|
||||||
|
The claims file should be a JSON formatted file that holds the
|
||||||
|
information that describes you. There are three elements in the claims
|
||||||
|
file you'll need:
|
||||||
|
|
||||||
|
"sub" This is your site's admin email address
|
||||||
|
(e.g. "mailto:admin@example.com")
|
||||||
|
"exp" This is the expiration time for the claim in seconds. If you don't
|
||||||
|
have one, I'll add one that expires in 24 hours.
|
||||||
|
|
||||||
|
You're also welcome to add additional fields to the claims which could be
|
||||||
|
helpful for the Push Service operations team to pass along to your operations
|
||||||
|
team (e.g. "ami-id": "e-123456", "cust-id": "a3sfa10987"). Remember to keep
|
||||||
|
these values short to prevent some servers from rejecting the transaction due
|
||||||
|
to overly large headers. See https://jwt.io/introduction/ for details.
|
||||||
|
|
||||||
|
For example, a claims.json file could contain:
|
||||||
|
|
||||||
|
{"sub": "mailto:admin@example.com"}
|
||||||
|
""")
|
||||||
|
exit(1)
|
||||||
|
try:
|
||||||
|
claims = json.loads(open(claim_file).read())
|
||||||
|
result.update(vapid.sign(claims))
|
||||||
|
except Exception as exc:
|
||||||
|
print("Crap, something went wrong: {}".format(repr(exc)))
|
||||||
|
raise exc
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result))
|
||||||
|
return
|
||||||
|
print("Include the following headers in your request:\n")
|
||||||
|
for key, value in result.items():
|
||||||
|
print("{}: {}\n".format(key, value))
|
||||||
|
print("\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import binascii
|
||||||
|
import base64
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from mock import patch, Mock
|
||||||
|
|
||||||
|
from py_vapid import Vapid01, Vapid02, VapidException, _check_sub
|
||||||
|
from py_vapid.jwt import decode
|
||||||
|
|
||||||
|
TEST_KEY_PRIVATE_DER = """
|
||||||
|
MHcCAQEEIPeN1iAipHbt8+/KZ2NIF8NeN24jqAmnMLFZEMocY8RboAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAEEJwJZq/GN8jJbo1GGpyU70hmP2hbWAUpQFKDByKB81yldJ9GTklB
|
||||||
|
M5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ==
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = dict(
|
||||||
|
d=111971876876285331364078054667935803036831194031221090723024134705696601261147, # noqa
|
||||||
|
x=7512698603580564493364310058109115206932767156853859985379597995200661812060, # noqa
|
||||||
|
y=74837673548863147047276043384733294240255217876718360423043754089982135570501 # noqa
|
||||||
|
)
|
||||||
|
|
||||||
|
# This is the same private key, in PEM form.
|
||||||
|
TEST_KEY_PRIVATE_PEM = (
|
||||||
|
"-----BEGIN PRIVATE KEY-----{}"
|
||||||
|
"-----END PRIVATE KEY-----\n").format(TEST_KEY_PRIVATE_DER)
|
||||||
|
|
||||||
|
# This is the same private key, as a point in uncompressed form. This should
|
||||||
|
# be Base64url-encoded without padding.
|
||||||
|
TEST_KEY_PRIVATE_RAW = """
|
||||||
|
943WICKkdu3z78pnY0gXw143biOoCacwsVkQyhxjxFs
|
||||||
|
""".strip().encode('utf8')
|
||||||
|
|
||||||
|
# This is a public key in PEM form.
|
||||||
|
TEST_KEY_PUBLIC_PEM = """-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEJwJZq/GN8jJbo1GGpyU70hmP2hb
|
||||||
|
WAUpQFKDByKB81yldJ9GTklBM5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this is a public key in uncompressed form ('\x04' + 2 * 32 octets)
|
||||||
|
# Remember, this should have any padding stripped.
|
||||||
|
TEST_KEY_PUBLIC_RAW = (
|
||||||
|
"BBCcCWavxjfIyW6NRhqclO9IZj9oW1gFKUBSgwcigfNc"
|
||||||
|
"pXSfRk5JQTOcahMLjzO1bkHMoiw4b6L7YTyF8foLEEU"
|
||||||
|
).strip('=').encode('utf8')
|
||||||
|
|
||||||
|
|
||||||
|
def setup_module(self):
|
||||||
|
with open('/tmp/private', 'w') as ff:
|
||||||
|
ff.write(TEST_KEY_PRIVATE_PEM)
|
||||||
|
with open('/tmp/public', 'w') as ff:
|
||||||
|
ff.write(TEST_KEY_PUBLIC_PEM)
|
||||||
|
with open('/tmp/private.der', 'w') as ff:
|
||||||
|
ff.write(TEST_KEY_PRIVATE_DER)
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_module(self):
|
||||||
|
os.unlink('/tmp/private')
|
||||||
|
os.unlink('/tmp/public')
|
||||||
|
|
||||||
|
|
||||||
|
class VapidTestCase(unittest.TestCase):
|
||||||
|
def check_keys(self, v):
|
||||||
|
assert v.private_key.private_numbers().private_value == key.get('d')
|
||||||
|
assert v.public_key.public_numbers().x == key.get('x')
|
||||||
|
assert v.public_key.public_numbers().y == key.get('y')
|
||||||
|
|
||||||
|
def test_init(self):
|
||||||
|
v1 = Vapid01.from_file("/tmp/private")
|
||||||
|
self.check_keys(v1)
|
||||||
|
v2 = Vapid01.from_pem(TEST_KEY_PRIVATE_PEM.encode())
|
||||||
|
self.check_keys(v2)
|
||||||
|
v3 = Vapid01.from_der(TEST_KEY_PRIVATE_DER.encode())
|
||||||
|
self.check_keys(v3)
|
||||||
|
v4 = Vapid01.from_file("/tmp/private.der")
|
||||||
|
self.check_keys(v4)
|
||||||
|
no_exist = '/tmp/not_exist'
|
||||||
|
Vapid01.from_file(no_exist)
|
||||||
|
assert os.path.isfile(no_exist)
|
||||||
|
os.unlink(no_exist)
|
||||||
|
|
||||||
|
def repad(self, data):
|
||||||
|
return data + "===="[len(data) % 4:]
|
||||||
|
|
||||||
|
@patch("py_vapid.Vapid01.from_pem", side_effect=Exception)
|
||||||
|
def test_init_bad_read(self, mm):
|
||||||
|
self.assertRaises(Exception,
|
||||||
|
Vapid01.from_file,
|
||||||
|
private_key_file="/tmp/private")
|
||||||
|
|
||||||
|
def test_gen_key(self):
|
||||||
|
v = Vapid01()
|
||||||
|
v.generate_keys()
|
||||||
|
assert v.public_key
|
||||||
|
assert v.private_key
|
||||||
|
|
||||||
|
def test_private_key(self):
|
||||||
|
v = Vapid01()
|
||||||
|
self.assertRaises(VapidException,
|
||||||
|
lambda: v.private_key)
|
||||||
|
|
||||||
|
def test_public_key(self):
|
||||||
|
v = Vapid01()
|
||||||
|
assert v._private_key is None
|
||||||
|
assert v._public_key is None
|
||||||
|
|
||||||
|
def test_save_key(self):
|
||||||
|
v = Vapid01()
|
||||||
|
v.generate_keys()
|
||||||
|
v.save_key("/tmp/p2")
|
||||||
|
os.unlink("/tmp/p2")
|
||||||
|
|
||||||
|
def test_same_public_key(self):
|
||||||
|
v = Vapid01()
|
||||||
|
v.generate_keys()
|
||||||
|
v.save_public_key("/tmp/p2")
|
||||||
|
os.unlink("/tmp/p2")
|
||||||
|
|
||||||
|
def test_from_raw(self):
|
||||||
|
v = Vapid01.from_raw(TEST_KEY_PRIVATE_RAW)
|
||||||
|
self.check_keys(v)
|
||||||
|
|
||||||
|
def test_from_string(self):
|
||||||
|
v1 = Vapid01.from_string(TEST_KEY_PRIVATE_DER)
|
||||||
|
v2 = Vapid01.from_string(TEST_KEY_PRIVATE_RAW.decode())
|
||||||
|
self.check_keys(v1)
|
||||||
|
self.check_keys(v2)
|
||||||
|
|
||||||
|
def test_sign_01(self):
|
||||||
|
v = Vapid01.from_string(TEST_KEY_PRIVATE_DER)
|
||||||
|
claims = {"aud": "https://example.com",
|
||||||
|
"sub": "mailto:admin@example.com"}
|
||||||
|
result = v.sign(claims, "id=previous")
|
||||||
|
assert result['Crypto-Key'] == (
|
||||||
|
'id=previous;p256ecdsa=' + TEST_KEY_PUBLIC_RAW.decode('utf8'))
|
||||||
|
pkey = binascii.b2a_base64(
|
||||||
|
v.public_key.public_bytes(
|
||||||
|
serialization.Encoding.X962,
|
||||||
|
serialization.PublicFormat.UncompressedPoint
|
||||||
|
)
|
||||||
|
).decode('utf8').replace('+', '-').replace('/', '_').strip()
|
||||||
|
items = decode(result['Authorization'].split(' ')[1], pkey)
|
||||||
|
for k in claims:
|
||||||
|
assert items[k] == claims[k]
|
||||||
|
result = v.sign(claims)
|
||||||
|
assert result['Crypto-Key'] == (
|
||||||
|
'p256ecdsa=' + TEST_KEY_PUBLIC_RAW.decode('utf8'))
|
||||||
|
# Verify using the same function as Integration
|
||||||
|
# this should ensure that the r,s sign values are correctly formed
|
||||||
|
assert Vapid01.verify(
|
||||||
|
key=result['Crypto-Key'].split('=')[1],
|
||||||
|
auth=result['Authorization']
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sign_02(self):
|
||||||
|
v = Vapid02.from_file("/tmp/private")
|
||||||
|
claims = {"aud": "https://example.com",
|
||||||
|
"sub": "mailto:admin@example.com",
|
||||||
|
"foo": "extra value"}
|
||||||
|
claim_check = copy.deepcopy(claims)
|
||||||
|
result = v.sign(claims, "id=previous")
|
||||||
|
auth = result['Authorization']
|
||||||
|
assert auth[:6] == 'vapid '
|
||||||
|
assert ' t=' in auth
|
||||||
|
assert ',k=' in auth
|
||||||
|
parts = auth[6:].split(',')
|
||||||
|
assert len(parts) == 2
|
||||||
|
t_val = json.loads(base64.urlsafe_b64decode(
|
||||||
|
self.repad(parts[0][2:].split('.')[1])
|
||||||
|
).decode('utf8'))
|
||||||
|
k_val = binascii.a2b_base64(self.repad(parts[1][2:]))
|
||||||
|
assert binascii.hexlify(k_val)[:2] == b'04'
|
||||||
|
assert len(k_val) == 65
|
||||||
|
assert claims == claim_check
|
||||||
|
for k in claims:
|
||||||
|
assert t_val[k] == claims[k]
|
||||||
|
|
||||||
|
def test_sign_02_localhost(self):
|
||||||
|
v = Vapid02.from_file("/tmp/private")
|
||||||
|
claims = {"aud": "http://localhost:8000",
|
||||||
|
"sub": "mailto:admin@example.com",
|
||||||
|
"foo": "extra value"}
|
||||||
|
result = v.sign(claims, "id=previous")
|
||||||
|
auth = result['Authorization']
|
||||||
|
assert auth[:6] == 'vapid '
|
||||||
|
assert ' t=' in auth
|
||||||
|
assert ',k=' in auth
|
||||||
|
|
||||||
|
def test_integration(self):
|
||||||
|
# These values were taken from a test page. DO NOT ALTER!
|
||||||
|
key = ("BDd3_hVL9fZi9Ybo2UUzA284WG5FZR30_95YeZJsiApwXKpNcF1rRPF3foI"
|
||||||
|
"iBHXRdJI2Qhumhf6_LFTeZaNndIo")
|
||||||
|
auth = ("eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJod"
|
||||||
|
"HRwczovL3VwZGF0ZXMucHVzaC5zZXJ2aWNlcy5tb3ppbGxhLmNvbSIsImV"
|
||||||
|
"4cCI6MTQ5NDY3MTQ3MCwic3ViIjoibWFpbHRvOnNpbXBsZS1wdXNoLWRlb"
|
||||||
|
"W9AZ2F1bnRmYWNlLmNvLnVrIn0.LqPi86T-HJ71TXHAYFptZEHD7Wlfjcc"
|
||||||
|
"4u5jYZ17WpqOlqDcW-5Wtx3x1OgYX19alhJ9oLumlS2VzEvNioZolQA")
|
||||||
|
assert Vapid01.verify(key=key, auth="webpush {}".format(auth))
|
||||||
|
assert Vapid02.verify(auth="vapid t={},k={}".format(auth, key))
|
||||||
|
|
||||||
|
def test_bad_integration(self):
|
||||||
|
# These values were taken from a test page. DO NOT ALTER!
|
||||||
|
key = ("BDd3_hVL9fZi9Ybo2UUzA284WG5FZR30_95YeZJsiApwXKpNcF1rRPF3foI"
|
||||||
|
"iBHXRdJI2Qhumhf6_LFTeZaNndIo")
|
||||||
|
auth = ("WebPush eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJod"
|
||||||
|
"HRwczovL3VwZGF0ZXMucHVzaC5zZXJ2aWNlcy5tb3ppbGxhLmNvbSIsImV"
|
||||||
|
"4cCI6MTQ5NDY3MTQ3MCwic3ViIjoibWFpbHRvOnNpbXBsZS1wdXNoLWRlb"
|
||||||
|
"W9AZ2F1bnRmYWNlLmNvLnVrIn0.LqPi86T-HJ71TXHAYFptZEHD7Wlfjcc"
|
||||||
|
"4u5jYZ17WpqOlqDcW-5Wtx3x1OgYX19alhJ9oLumlS2VzEvNioZ_BAD")
|
||||||
|
assert not Vapid01.verify(key=key, auth=auth)
|
||||||
|
|
||||||
|
def test_bad_sign(self):
|
||||||
|
v = Vapid01.from_file("/tmp/private")
|
||||||
|
self.assertRaises(VapidException,
|
||||||
|
v.sign,
|
||||||
|
{})
|
||||||
|
self.assertRaises(VapidException,
|
||||||
|
v.sign,
|
||||||
|
{'sub': 'foo',
|
||||||
|
'aud': "p.example.com"})
|
||||||
|
self.assertRaises(VapidException,
|
||||||
|
v.sign,
|
||||||
|
{'sub': 'mailto:foo@bar.com',
|
||||||
|
'aud': "p.example.com"})
|
||||||
|
self.assertRaises(VapidException,
|
||||||
|
v.sign,
|
||||||
|
{'sub': 'mailto:foo@bar.com',
|
||||||
|
'aud': "https://p.example.com:8080/"})
|
||||||
|
|
||||||
|
def test_ignore_sub(self):
|
||||||
|
v = Vapid02.from_file("/tmp/private")
|
||||||
|
v.conf['no-strict'] = True
|
||||||
|
assert v.sign({"sub": "foo", "aud": "http://localhost:8000"})
|
||||||
|
|
||||||
|
@patch('cryptography.hazmat.primitives.asymmetric'
|
||||||
|
'.ec.EllipticCurvePublicNumbers')
|
||||||
|
def test_invalid_sig(self, mm):
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
ve = Mock()
|
||||||
|
ve.verify.side_effect = InvalidSignature
|
||||||
|
pk = Mock()
|
||||||
|
pk.public_key.return_value = ve
|
||||||
|
mm.from_encoded_point.return_value = pk
|
||||||
|
self.assertRaises(InvalidSignature,
|
||||||
|
decode,
|
||||||
|
'foo.bar.blat',
|
||||||
|
'aaaa')
|
||||||
|
self.assertRaises(InvalidSignature,
|
||||||
|
decode,
|
||||||
|
'foo.bar.a',
|
||||||
|
'aaaa')
|
||||||
|
|
||||||
|
def test_sub(self):
|
||||||
|
valid = [
|
||||||
|
'mailto:me@localhost',
|
||||||
|
'mailto:me@1.2.3.4',
|
||||||
|
'mailto:me@1234::',
|
||||||
|
'mailto:me@1234::5678',
|
||||||
|
'mailto:admin@example.org',
|
||||||
|
'mailto:admin-test-case@example-test-case.test.org',
|
||||||
|
'https://localhost',
|
||||||
|
'https://exmample-test-case.test.org',
|
||||||
|
'https://8001::',
|
||||||
|
'https://8001:1000:0001',
|
||||||
|
'https://1.2.3.4'
|
||||||
|
]
|
||||||
|
invalid = [
|
||||||
|
'mailto:@foobar.com',
|
||||||
|
'mailto:example.org',
|
||||||
|
'mailto:0123:',
|
||||||
|
'mailto:::1234',
|
||||||
|
'https://somehost',
|
||||||
|
'https://xyz:123',
|
||||||
|
]
|
||||||
|
|
||||||
|
for val in valid:
|
||||||
|
assert _check_sub(val) is True
|
||||||
|
for val in invalid:
|
||||||
|
assert _check_sub(val) is False
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
|
||||||
|
|
||||||
|
def b64urldecode(data):
|
||||||
|
"""Decodes an unpadded Base64url-encoded string.
|
||||||
|
|
||||||
|
:param data: data bytes to decode
|
||||||
|
:type data: bytes
|
||||||
|
|
||||||
|
:returns bytes
|
||||||
|
|
||||||
|
"""
|
||||||
|
return base64.urlsafe_b64decode(data + b"===="[len(data) % 4:])
|
||||||
|
|
||||||
|
|
||||||
|
def b64urlencode(data):
|
||||||
|
"""Encode a byte string into a Base64url-encoded string without padding
|
||||||
|
|
||||||
|
:param data: data bytes to encode
|
||||||
|
:type data: bytes
|
||||||
|
|
||||||
|
:returns str
|
||||||
|
|
||||||
|
"""
|
||||||
|
return base64.urlsafe_b64encode(data).replace(b'=', b'').decode('utf8')
|
||||||
|
|
||||||
|
|
||||||
|
def num_to_bytes(n, pad_to):
|
||||||
|
"""Returns the byte representation of an integer, in big-endian order.
|
||||||
|
:param n: The integer to encode.
|
||||||
|
:type n: int
|
||||||
|
:param pad_to: Expected length of result, zeropad if necessary.
|
||||||
|
:type pad_to: int
|
||||||
|
:returns bytes
|
||||||
|
"""
|
||||||
|
h = '%x' % n
|
||||||
|
r = binascii.unhexlify('0' * (len(h) % 2) + h)
|
||||||
|
return b'\x00' * (pad_to - len(r)) + r
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: pywebpush
|
||||||
|
Version: 2.1.2
|
||||||
|
Summary: WebPush publication library
|
||||||
|
Author-email: JR Conlin <src+webpusher@jrconlin.com>
|
||||||
|
License: MPL-2.0
|
||||||
|
Project-URL: Homepage, https://github.com/web-push-libs/pywebpush
|
||||||
|
Keywords: webpush,vapid,notification
|
||||||
|
Classifier: Topic :: Internet :: WWW/HTTP
|
||||||
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
||||||
|
Classifier: Programming Language :: Python
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
License-File: LICENSE
|
||||||
|
Requires-Dist: aiohttp
|
||||||
|
Requires-Dist: cryptography>=2.6.1
|
||||||
|
Requires-Dist: http-ece>=1.1.0
|
||||||
|
Requires-Dist: requests>=2.21.0
|
||||||
|
Requires-Dist: six>=1.15.0
|
||||||
|
Requires-Dist: py-vapid>=1.7.0
|
||||||
|
Provides-Extra: dev
|
||||||
|
Requires-Dist: black; extra == "dev"
|
||||||
|
Requires-Dist: mock; extra == "dev"
|
||||||
|
Requires-Dist: pytest; extra == "dev"
|
||||||
|
Dynamic: license-file
|
||||||
|
|
||||||
|
# Webpush Data encryption library for Python
|
||||||
|
|
||||||
|
[](https://travis-ci.org/web-push-libs/pywebpush)
|
||||||
|
[](https://requires.io/github/web-push-libs/pywebpush/requirements/?branch=main)
|
||||||
|
|
||||||
|
This library is available on [pypi as pywebpush](https://pypi.python.org/pypi/pywebpush).
|
||||||
|
Source is available on [github](https://github.com/mozilla-services/pywebpush).
|
||||||
|
Please note: This library was designated as a `Critical Project` by PyPi, it is currently
|
||||||
|
maintained by [a single person](https://xkcd.com/2347/). I still accept PRs and Issues, but
|
||||||
|
make of that what you will.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To work with this repo locally, you'll need to run `python -m venv venv`.
|
||||||
|
Then `venv/bin/pip install --editable .`
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
In the browser, the promise handler for
|
||||||
|
[registration.pushManager.subscribe()](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
|
||||||
|
returns a
|
||||||
|
[PushSubscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription)
|
||||||
|
object. This object has a .toJSON() method that will return a JSON object that contains all the info we need to encrypt
|
||||||
|
and push data.
|
||||||
|
|
||||||
|
As illustration, a `subscription_info` object may look like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"endpoint": "https://updates.push.services.mozilla.com/push/v1/gAA...",
|
||||||
|
"keys": { "auth": "k8J...", "p256dh": "BOr..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
How you send the PushSubscription data to your backend, store it
|
||||||
|
referenced to the user who requested it, and recall it when there's
|
||||||
|
a new push subscription update is left as an exercise for the
|
||||||
|
reader.
|
||||||
|
|
||||||
|
### Sending Data using `webpush()` One Call
|
||||||
|
|
||||||
|
In many cases, your code will be sending a single message to many
|
||||||
|
recipients. There's a "One Call" function which will make things
|
||||||
|
easier.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pywebpush import webpush
|
||||||
|
|
||||||
|
webpush(subscription_info,
|
||||||
|
data,
|
||||||
|
vapid_private_key="Private Key or File Path[1]",
|
||||||
|
vapid_claims={"sub": "mailto:YourEmailAddress"})
|
||||||
|
```
|
||||||
|
|
||||||
|
This will encode `data`, add the appropriate VAPID auth headers if required and send it to the push server identified
|
||||||
|
in the `subscription_info` block.
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
|
||||||
|
_subscription_info_ - The `dict` of the subscription info (described above).
|
||||||
|
|
||||||
|
_data_ - can be any serial content (string, bit array, serialized JSON, etc), but be sure that your receiving
|
||||||
|
application is able to parse and understand it. (e.g. `data = "Mary had a little lamb."`)
|
||||||
|
|
||||||
|
_content_type_ - specifies the form of Encryption to use, either `'aes128gcm'` or the deprecated `'aesgcm'`. NOTE that
|
||||||
|
not all User Agents can decrypt `'aesgcm'`, so the library defaults to the RFC 8188 standard form.
|
||||||
|
|
||||||
|
_vapid_claims_ - a `dict` containing the VAPID claims required for authorization (See
|
||||||
|
[py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details). If `aud` is not specified,
|
||||||
|
pywebpush will attempt to auto-fill from the `endpoint`. If `exp` is not specified or set in the past, it will be set
|
||||||
|
to 12 hours from now. In both cases, the passed `dict` **will be mutated** after the call.
|
||||||
|
|
||||||
|
_vapid_private_key_ - Either a path to a VAPID EC2 private key PEM file, or a string containing the DER representation.
|
||||||
|
(See [py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details.) The `private_key` may be
|
||||||
|
a base64 encoded DER formatted private key, or the path to an OpenSSL exported private key file.
|
||||||
|
|
||||||
|
e.g. the output of:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info={
|
||||||
|
"endpoint": "https://push.example.com/v1/12345",
|
||||||
|
"keys": {
|
||||||
|
"p256dh": "0123abcde...",
|
||||||
|
"auth": "abc123..."
|
||||||
|
}},
|
||||||
|
data="Mary had a little lamb, with a nice mint jelly",
|
||||||
|
vapid_private_key="path/to/vapid_private.pem",
|
||||||
|
vapid_claims={
|
||||||
|
"sub": "mailto:YourNameHere@example.org",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except WebPushException as ex:
|
||||||
|
print("I'm sorry, Dave, but I can't do that: {}", repr(ex))
|
||||||
|
# Mozilla returns additional information in the body of the response.
|
||||||
|
if ex.response is not None and ex.response.json():
|
||||||
|
extra = ex.response.json()
|
||||||
|
print("Remote service replied with a {}:{}, {}",
|
||||||
|
extra.code,
|
||||||
|
extra.errno,
|
||||||
|
extra.message
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
If you expect to resend to the same recipient, or have more needs than just sending data quickly, you
|
||||||
|
can pass just `wp = WebPusher(subscription_info)`. This will return a `WebPusher` object.
|
||||||
|
|
||||||
|
The following methods are available:
|
||||||
|
|
||||||
|
#### `.send(data, headers={}, ttl=0, gcm_key="", reg_id="", content_encoding="aes128gcm", curl=False, timeout=None)`
|
||||||
|
|
||||||
|
Send the data using additional parameters. On error, returns a `WebPushException`
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
|
||||||
|
_data_ Binary string of data to send
|
||||||
|
|
||||||
|
_headers_ A `dict` containing any additional headers to send
|
||||||
|
|
||||||
|
_ttl_ Message Time To Live on Push Server waiting for the client to reconnect (in seconds)
|
||||||
|
|
||||||
|
_gcm_key_ Google Cloud Messaging key (if using the older GCM push system) This is the API key obtained from the Google
|
||||||
|
Developer Console.
|
||||||
|
|
||||||
|
_reg_id_ Google Cloud Messaging registration ID (will be extracted from endpoint if not specified)
|
||||||
|
|
||||||
|
_content_encoding_ ECE content encoding type (defaults to "aes128gcm")
|
||||||
|
|
||||||
|
_curl_ Do not execute the POST, but return as a `curl` command. This will write the encrypted content to a local file
|
||||||
|
named `encrpypted.data`. This command is meant to be used for debugging purposes.
|
||||||
|
|
||||||
|
_timeout_ timeout for requests POST query.
|
||||||
|
See [requests documentation](http://docs.python-requests.org/en/master/user/quickstart/#timeouts).
|
||||||
|
|
||||||
|
##### Example
|
||||||
|
|
||||||
|
to send from Chrome using the old GCM mode:
|
||||||
|
|
||||||
|
```python
|
||||||
|
WebPusher(subscription_info).send(data, headers, ttl, gcm_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.encode(data, content_encoding="aes128gcm")`
|
||||||
|
|
||||||
|
Encode the `data` for future use. On error, returns a `WebPushException`
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
|
||||||
|
_data_ Binary string of data to send
|
||||||
|
|
||||||
|
_content_encoding_ ECE content encoding type (defaults to "aes128gcm")
|
||||||
|
|
||||||
|
*Note* This will return a `NoData` exception if the data is not present or empty. It is completely
|
||||||
|
valid to send a WebPush notification with no data, but encoding is a no-op in that case. Best not
|
||||||
|
to call it if you don't have data.
|
||||||
|
|
||||||
|
##### Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
encoded_data = WebPush(subscription_info).encode(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stand Alone Webpush
|
||||||
|
|
||||||
|
If you're not really into coding your own solution, there's also a "stand-alone" `pywebpush` command in the
|
||||||
|
./bin directory.
|
||||||
|
|
||||||
|
This uses two files:
|
||||||
|
|
||||||
|
- the _data_ file, which contains the message to send, in whatever form you like.
|
||||||
|
- the _subscription info_ file, which contains the subscription information as JSON encoded data. This is usually returned by the Push `subscribe` method and looks something like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"endpoint": "https://push...",
|
||||||
|
"keys": {
|
||||||
|
"auth": "ab01...",
|
||||||
|
"p256dh": "aa02..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're interested in just testing your applications WebPush interface, you could use the Command Line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/pywebpush --data stuff_to_send.data --info subscription.info
|
||||||
|
```
|
||||||
|
|
||||||
|
which will encrypt and send the contents of `stuff_to_send.data`.
|
||||||
|
|
||||||
|
See `./bin/pywebpush --help` for available commands and options.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pywebpush/__init__.py,sha256=vCF-y1o1D04pEsJhkjsl_9IKkKy3Y5FeQrmF-TX5Fx4,26945
|
||||||
|
pywebpush/__main__.py,sha256=GjEZps3NHHD_3_g64Sb_LEQzT6uY1xWHuN3LKob010o,2869
|
||||||
|
pywebpush/foo.py,sha256=ejmplddiH71DFZ7A2L2TW_jTBuC3H4FGRRaARWch6Po,1349
|
||||||
|
pywebpush/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||||
|
pywebpush/tests/test_webpush.py,sha256=xwLpJiHnytiwIpIZ91mJ6w0ad4nJp6ISNJ7J_RQNOfo,25481
|
||||||
|
pywebpush-2.1.2.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
|
||||||
|
pywebpush-2.1.2.dist-info/METADATA,sha256=v49HlJ8MkEdZ18ZBO7D_dRR_xk8rqwCqj-TDZofu-6c,8263
|
||||||
|
pywebpush-2.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
||||||
|
pywebpush-2.1.2.dist-info/entry_points.txt,sha256=6J8vnkUNHIR8-eKLXPVhB07sVdxFWih0_PzGXmtH2Jc,54
|
||||||
|
pywebpush-2.1.2.dist-info/top_level.txt,sha256=bBvdlXyyUMP2V1KRUcbbLWC_ogM0IzDb2GVxIWDSjEc,10
|
||||||
|
pywebpush-2.1.2.dist-info/RECORD,,
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: setuptools (80.9.0)
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py3-none-any
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[console_scripts]
|
||||||
|
pywebpush = pywebpush.__main__:main
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
Mozilla Public License Version 2.0
|
||||||
|
==================================
|
||||||
|
|
||||||
|
1. Definitions
|
||||||
|
--------------
|
||||||
|
|
||||||
|
1.1. "Contributor"
|
||||||
|
means each individual or legal entity that creates, contributes to
|
||||||
|
the creation of, or owns Covered Software.
|
||||||
|
|
||||||
|
1.2. "Contributor Version"
|
||||||
|
means the combination of the Contributions of others (if any) used
|
||||||
|
by a Contributor and that particular Contributor's Contribution.
|
||||||
|
|
||||||
|
1.3. "Contribution"
|
||||||
|
means Covered Software of a particular Contributor.
|
||||||
|
|
||||||
|
1.4. "Covered Software"
|
||||||
|
means Source Code Form to which the initial Contributor has attached
|
||||||
|
the notice in Exhibit A, the Executable Form of such Source Code
|
||||||
|
Form, and Modifications of such Source Code Form, in each case
|
||||||
|
including portions thereof.
|
||||||
|
|
||||||
|
1.5. "Incompatible With Secondary Licenses"
|
||||||
|
means
|
||||||
|
|
||||||
|
(a) that the initial Contributor has attached the notice described
|
||||||
|
in Exhibit B to the Covered Software; or
|
||||||
|
|
||||||
|
(b) that the Covered Software was made available under the terms of
|
||||||
|
version 1.1 or earlier of the License, but not also under the
|
||||||
|
terms of a Secondary License.
|
||||||
|
|
||||||
|
1.6. "Executable Form"
|
||||||
|
means any form of the work other than Source Code Form.
|
||||||
|
|
||||||
|
1.7. "Larger Work"
|
||||||
|
means a work that combines Covered Software with other material, in
|
||||||
|
a separate file or files, that is not Covered Software.
|
||||||
|
|
||||||
|
1.8. "License"
|
||||||
|
means this document.
|
||||||
|
|
||||||
|
1.9. "Licensable"
|
||||||
|
means having the right to grant, to the maximum extent possible,
|
||||||
|
whether at the time of the initial grant or subsequently, any and
|
||||||
|
all of the rights conveyed by this License.
|
||||||
|
|
||||||
|
1.10. "Modifications"
|
||||||
|
means any of the following:
|
||||||
|
|
||||||
|
(a) any file in Source Code Form that results from an addition to,
|
||||||
|
deletion from, or modification of the contents of Covered
|
||||||
|
Software; or
|
||||||
|
|
||||||
|
(b) any new file in Source Code Form that contains any Covered
|
||||||
|
Software.
|
||||||
|
|
||||||
|
1.11. "Patent Claims" of a Contributor
|
||||||
|
means any patent claim(s), including without limitation, method,
|
||||||
|
process, and apparatus claims, in any patent Licensable by such
|
||||||
|
Contributor that would be infringed, but for the grant of the
|
||||||
|
License, by the making, using, selling, offering for sale, having
|
||||||
|
made, import, or transfer of either its Contributions or its
|
||||||
|
Contributor Version.
|
||||||
|
|
||||||
|
1.12. "Secondary License"
|
||||||
|
means either the GNU General Public License, Version 2.0, the GNU
|
||||||
|
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||||
|
Public License, Version 3.0, or any later versions of those
|
||||||
|
licenses.
|
||||||
|
|
||||||
|
1.13. "Source Code Form"
|
||||||
|
means the form of the work preferred for making modifications.
|
||||||
|
|
||||||
|
1.14. "You" (or "Your")
|
||||||
|
means an individual or a legal entity exercising rights under this
|
||||||
|
License. For legal entities, "You" includes any entity that
|
||||||
|
controls, is controlled by, or is under common control with You. For
|
||||||
|
purposes of this definition, "control" means (a) the power, direct
|
||||||
|
or indirect, to cause the direction or management of such entity,
|
||||||
|
whether by contract or otherwise, or (b) ownership of more than
|
||||||
|
fifty percent (50%) of the outstanding shares or beneficial
|
||||||
|
ownership of such entity.
|
||||||
|
|
||||||
|
2. License Grants and Conditions
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
2.1. Grants
|
||||||
|
|
||||||
|
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||||
|
non-exclusive license:
|
||||||
|
|
||||||
|
(a) under intellectual property rights (other than patent or trademark)
|
||||||
|
Licensable by such Contributor to use, reproduce, make available,
|
||||||
|
modify, display, perform, distribute, and otherwise exploit its
|
||||||
|
Contributions, either on an unmodified basis, with Modifications, or
|
||||||
|
as part of a Larger Work; and
|
||||||
|
|
||||||
|
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||||
|
for sale, have made, import, and otherwise transfer either its
|
||||||
|
Contributions or its Contributor Version.
|
||||||
|
|
||||||
|
2.2. Effective Date
|
||||||
|
|
||||||
|
The licenses granted in Section 2.1 with respect to any Contribution
|
||||||
|
become effective for each Contribution on the date the Contributor first
|
||||||
|
distributes such Contribution.
|
||||||
|
|
||||||
|
2.3. Limitations on Grant Scope
|
||||||
|
|
||||||
|
The licenses granted in this Section 2 are the only rights granted under
|
||||||
|
this License. No additional rights or licenses will be implied from the
|
||||||
|
distribution or licensing of Covered Software under this License.
|
||||||
|
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||||
|
Contributor:
|
||||||
|
|
||||||
|
(a) for any code that a Contributor has removed from Covered Software;
|
||||||
|
or
|
||||||
|
|
||||||
|
(b) for infringements caused by: (i) Your and any other third party's
|
||||||
|
modifications of Covered Software, or (ii) the combination of its
|
||||||
|
Contributions with other software (except as part of its Contributor
|
||||||
|
Version); or
|
||||||
|
|
||||||
|
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||||
|
its Contributions.
|
||||||
|
|
||||||
|
This License does not grant any rights in the trademarks, service marks,
|
||||||
|
or logos of any Contributor (except as may be necessary to comply with
|
||||||
|
the notice requirements in Section 3.4).
|
||||||
|
|
||||||
|
2.4. Subsequent Licenses
|
||||||
|
|
||||||
|
No Contributor makes additional grants as a result of Your choice to
|
||||||
|
distribute the Covered Software under a subsequent version of this
|
||||||
|
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||||
|
permitted under the terms of Section 3.3).
|
||||||
|
|
||||||
|
2.5. Representation
|
||||||
|
|
||||||
|
Each Contributor represents that the Contributor believes its
|
||||||
|
Contributions are its original creation(s) or it has sufficient rights
|
||||||
|
to grant the rights to its Contributions conveyed by this License.
|
||||||
|
|
||||||
|
2.6. Fair Use
|
||||||
|
|
||||||
|
This License is not intended to limit any rights You have under
|
||||||
|
applicable copyright doctrines of fair use, fair dealing, or other
|
||||||
|
equivalents.
|
||||||
|
|
||||||
|
2.7. Conditions
|
||||||
|
|
||||||
|
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||||
|
in Section 2.1.
|
||||||
|
|
||||||
|
3. Responsibilities
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
3.1. Distribution of Source Form
|
||||||
|
|
||||||
|
All distribution of Covered Software in Source Code Form, including any
|
||||||
|
Modifications that You create or to which You contribute, must be under
|
||||||
|
the terms of this License. You must inform recipients that the Source
|
||||||
|
Code Form of the Covered Software is governed by the terms of this
|
||||||
|
License, and how they can obtain a copy of this License. You may not
|
||||||
|
attempt to alter or restrict the recipients' rights in the Source Code
|
||||||
|
Form.
|
||||||
|
|
||||||
|
3.2. Distribution of Executable Form
|
||||||
|
|
||||||
|
If You distribute Covered Software in Executable Form then:
|
||||||
|
|
||||||
|
(a) such Covered Software must also be made available in Source Code
|
||||||
|
Form, as described in Section 3.1, and You must inform recipients of
|
||||||
|
the Executable Form how they can obtain a copy of such Source Code
|
||||||
|
Form by reasonable means in a timely manner, at a charge no more
|
||||||
|
than the cost of distribution to the recipient; and
|
||||||
|
|
||||||
|
(b) You may distribute such Executable Form under the terms of this
|
||||||
|
License, or sublicense it under different terms, provided that the
|
||||||
|
license for the Executable Form does not attempt to limit or alter
|
||||||
|
the recipients' rights in the Source Code Form under this License.
|
||||||
|
|
||||||
|
3.3. Distribution of a Larger Work
|
||||||
|
|
||||||
|
You may create and distribute a Larger Work under terms of Your choice,
|
||||||
|
provided that You also comply with the requirements of this License for
|
||||||
|
the Covered Software. If the Larger Work is a combination of Covered
|
||||||
|
Software with a work governed by one or more Secondary Licenses, and the
|
||||||
|
Covered Software is not Incompatible With Secondary Licenses, this
|
||||||
|
License permits You to additionally distribute such Covered Software
|
||||||
|
under the terms of such Secondary License(s), so that the recipient of
|
||||||
|
the Larger Work may, at their option, further distribute the Covered
|
||||||
|
Software under the terms of either this License or such Secondary
|
||||||
|
License(s).
|
||||||
|
|
||||||
|
3.4. Notices
|
||||||
|
|
||||||
|
You may not remove or alter the substance of any license notices
|
||||||
|
(including copyright notices, patent notices, disclaimers of warranty,
|
||||||
|
or limitations of liability) contained within the Source Code Form of
|
||||||
|
the Covered Software, except that You may alter any license notices to
|
||||||
|
the extent required to remedy known factual inaccuracies.
|
||||||
|
|
||||||
|
3.5. Application of Additional Terms
|
||||||
|
|
||||||
|
You may choose to offer, and to charge a fee for, warranty, support,
|
||||||
|
indemnity or liability obligations to one or more recipients of Covered
|
||||||
|
Software. However, You may do so only on Your own behalf, and not on
|
||||||
|
behalf of any Contributor. You must make it absolutely clear that any
|
||||||
|
such warranty, support, indemnity, or liability obligation is offered by
|
||||||
|
You alone, and You hereby agree to indemnify every Contributor for any
|
||||||
|
liability incurred by such Contributor as a result of warranty, support,
|
||||||
|
indemnity or liability terms You offer. You may include additional
|
||||||
|
disclaimers of warranty and limitations of liability specific to any
|
||||||
|
jurisdiction.
|
||||||
|
|
||||||
|
4. Inability to Comply Due to Statute or Regulation
|
||||||
|
---------------------------------------------------
|
||||||
|
|
||||||
|
If it is impossible for You to comply with any of the terms of this
|
||||||
|
License with respect to some or all of the Covered Software due to
|
||||||
|
statute, judicial order, or regulation then You must: (a) comply with
|
||||||
|
the terms of this License to the maximum extent possible; and (b)
|
||||||
|
describe the limitations and the code they affect. Such description must
|
||||||
|
be placed in a text file included with all distributions of the Covered
|
||||||
|
Software under this License. Except to the extent prohibited by statute
|
||||||
|
or regulation, such description must be sufficiently detailed for a
|
||||||
|
recipient of ordinary skill to be able to understand it.
|
||||||
|
|
||||||
|
5. Termination
|
||||||
|
--------------
|
||||||
|
|
||||||
|
5.1. The rights granted under this License will terminate automatically
|
||||||
|
if You fail to comply with any of its terms. However, if You become
|
||||||
|
compliant, then the rights granted under this License from a particular
|
||||||
|
Contributor are reinstated (a) provisionally, unless and until such
|
||||||
|
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||||
|
ongoing basis, if such Contributor fails to notify You of the
|
||||||
|
non-compliance by some reasonable means prior to 60 days after You have
|
||||||
|
come back into compliance. Moreover, Your grants from a particular
|
||||||
|
Contributor are reinstated on an ongoing basis if such Contributor
|
||||||
|
notifies You of the non-compliance by some reasonable means, this is the
|
||||||
|
first time You have received notice of non-compliance with this License
|
||||||
|
from such Contributor, and You become compliant prior to 30 days after
|
||||||
|
Your receipt of the notice.
|
||||||
|
|
||||||
|
5.2. If You initiate litigation against any entity by asserting a patent
|
||||||
|
infringement claim (excluding declaratory judgment actions,
|
||||||
|
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||||
|
directly or indirectly infringes any patent, then the rights granted to
|
||||||
|
You by any and all Contributors for the Covered Software under Section
|
||||||
|
2.1 of this License shall terminate.
|
||||||
|
|
||||||
|
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||||
|
end user license agreements (excluding distributors and resellers) which
|
||||||
|
have been validly granted by You or Your distributors under this License
|
||||||
|
prior to termination shall survive termination.
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 6. Disclaimer of Warranty *
|
||||||
|
* ------------------------- *
|
||||||
|
* *
|
||||||
|
* Covered Software is provided under this License on an "as is" *
|
||||||
|
* basis, without warranty of any kind, either expressed, implied, or *
|
||||||
|
* statutory, including, without limitation, warranties that the *
|
||||||
|
* Covered Software is free of defects, merchantable, fit for a *
|
||||||
|
* particular purpose or non-infringing. The entire risk as to the *
|
||||||
|
* quality and performance of the Covered Software is with You. *
|
||||||
|
* Should any Covered Software prove defective in any respect, You *
|
||||||
|
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||||
|
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||||
|
* essential part of this License. No use of any Covered Software is *
|
||||||
|
* authorized under this License except under this disclaimer. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 7. Limitation of Liability *
|
||||||
|
* -------------------------- *
|
||||||
|
* *
|
||||||
|
* Under no circumstances and under no legal theory, whether tort *
|
||||||
|
* (including negligence), contract, or otherwise, shall any *
|
||||||
|
* Contributor, or anyone who distributes Covered Software as *
|
||||||
|
* permitted above, be liable to You for any direct, indirect, *
|
||||||
|
* special, incidental, or consequential damages of any character *
|
||||||
|
* including, without limitation, damages for lost profits, loss of *
|
||||||
|
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||||
|
* and all other commercial damages or losses, even if such party *
|
||||||
|
* shall have been informed of the possibility of such damages. This *
|
||||||
|
* limitation of liability shall not apply to liability for death or *
|
||||||
|
* personal injury resulting from such party's negligence to the *
|
||||||
|
* extent applicable law prohibits such limitation. Some *
|
||||||
|
* jurisdictions do not allow the exclusion or limitation of *
|
||||||
|
* incidental or consequential damages, so this exclusion and *
|
||||||
|
* limitation may not apply to You. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
8. Litigation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Any litigation relating to this License may be brought only in the
|
||||||
|
courts of a jurisdiction where the defendant maintains its principal
|
||||||
|
place of business and such litigation shall be governed by laws of that
|
||||||
|
jurisdiction, without reference to its conflict-of-law provisions.
|
||||||
|
Nothing in this Section shall prevent a party's ability to bring
|
||||||
|
cross-claims or counter-claims.
|
||||||
|
|
||||||
|
9. Miscellaneous
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This License represents the complete agreement concerning the subject
|
||||||
|
matter hereof. If any provision of this License is held to be
|
||||||
|
unenforceable, such provision shall be reformed only to the extent
|
||||||
|
necessary to make it enforceable. Any law or regulation which provides
|
||||||
|
that the language of a contract shall be construed against the drafter
|
||||||
|
shall not be used to construe this License against a Contributor.
|
||||||
|
|
||||||
|
10. Versions of the License
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
10.1. New Versions
|
||||||
|
|
||||||
|
Mozilla Foundation is the license steward. Except as provided in Section
|
||||||
|
10.3, no one other than the license steward has the right to modify or
|
||||||
|
publish new versions of this License. Each version will be given a
|
||||||
|
distinguishing version number.
|
||||||
|
|
||||||
|
10.2. Effect of New Versions
|
||||||
|
|
||||||
|
You may distribute the Covered Software under the terms of the version
|
||||||
|
of the License under which You originally received the Covered Software,
|
||||||
|
or under the terms of any subsequent version published by the license
|
||||||
|
steward.
|
||||||
|
|
||||||
|
10.3. Modified Versions
|
||||||
|
|
||||||
|
If you create software not governed by this License, and you want to
|
||||||
|
create a new license for such software, you may create and use a
|
||||||
|
modified version of this License if you rename the license and remove
|
||||||
|
any references to the name of the license steward (except to note that
|
||||||
|
such modified license differs from this License).
|
||||||
|
|
||||||
|
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||||
|
Licenses
|
||||||
|
|
||||||
|
If You choose to distribute Source Code Form that is Incompatible With
|
||||||
|
Secondary Licenses under the terms of this version of the License, the
|
||||||
|
notice described in Exhibit B of this License must be attached.
|
||||||
|
|
||||||
|
Exhibit A - Source Code Form License Notice
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
If it is not possible or desirable to put the notice in a particular
|
||||||
|
file, then You may include the notice in a location (such as a LICENSE
|
||||||
|
file in a relevant directory) where a recipient would be likely to look
|
||||||
|
for such a notice.
|
||||||
|
|
||||||
|
You may add additional accurate notices of copyright ownership.
|
||||||
|
|
||||||
|
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||||
|
---------------------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||||
|
defined by the Mozilla Public License, v. 2.0.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pywebpush
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import cast, Union, Dict
|
||||||
|
|
||||||
|
try:
|
||||||
|
from urlparse import urlparse
|
||||||
|
except ImportError: # pragma nocover
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import http_ece
|
||||||
|
import requests
|
||||||
|
import six
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from functools import partial
|
||||||
|
from py_vapid import Vapid, Vapid01
|
||||||
|
from requests import Response
|
||||||
|
|
||||||
|
|
||||||
|
class WebPushException(Exception):
|
||||||
|
"""Web Push failure.
|
||||||
|
|
||||||
|
This may contain the requests.Response
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message, response=None):
|
||||||
|
self.message = message
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
extra = ""
|
||||||
|
if self.response is not None:
|
||||||
|
try:
|
||||||
|
extra = ", Response {}".format(
|
||||||
|
self.response.text,
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
extra = ", Response {}".format(self.response)
|
||||||
|
return "WebPushException: {}{}".format(self.message, extra)
|
||||||
|
|
||||||
|
|
||||||
|
class NoData(Exception):
|
||||||
|
"""Message contained No Data, no encoding required."""
|
||||||
|
|
||||||
|
|
||||||
|
class CaseInsensitiveDict(dict):
|
||||||
|
"""A dictionary that has case-insensitive keys"""
|
||||||
|
|
||||||
|
def __init__(self, data={}, **kwargs):
|
||||||
|
for key in data:
|
||||||
|
dict.__setitem__(self, key.lower(), data[key])
|
||||||
|
self.update(kwargs)
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return dict.__contains__(self, key.lower())
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
dict.__setitem__(self, key.lower(), value)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return dict.__getitem__(self, key.lower())
|
||||||
|
|
||||||
|
def __delitem__(self, key):
|
||||||
|
dict.__delitem__(self, key.lower())
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
try:
|
||||||
|
return self.__getitem__(key)
|
||||||
|
except KeyError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
def update(self, data):
|
||||||
|
for key in data:
|
||||||
|
self.__setitem__(key, data[key])
|
||||||
|
|
||||||
|
|
||||||
|
class WebPusher:
|
||||||
|
"""WebPusher encrypts a data block using HTTP Encrypted Content Encoding
|
||||||
|
for WebPush.
|
||||||
|
|
||||||
|
See https://tools.ietf.org/html/draft-ietf-webpush-protocol-04
|
||||||
|
for the current specification, and
|
||||||
|
https://developer.mozilla.org/en-US/docs/Web/API/Push_API for an
|
||||||
|
overview of Web Push.
|
||||||
|
|
||||||
|
Example of use:
|
||||||
|
|
||||||
|
The javascript promise handler for PushManager.subscribe()
|
||||||
|
receives a subscription_info object. subscription_info.getJSON()
|
||||||
|
will return a JSON representation.
|
||||||
|
(e.g.
|
||||||
|
.. code-block:: javascript
|
||||||
|
subscription_info.getJSON() ==
|
||||||
|
{"endpoint": "https://push.server.com/...",
|
||||||
|
"keys":{"auth": "...", "p256dh": "..."}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
This subscription_info block can be stored.
|
||||||
|
|
||||||
|
To send a subscription update:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
# Optional
|
||||||
|
# headers = py_vapid.sign({"aud": "https://push.server.com/",
|
||||||
|
"sub": "mailto:your_admin@your.site.com"})
|
||||||
|
data = "Mary had a little lamb, with a nice mint jelly"
|
||||||
|
WebPusher(subscription_info).send(data, headers)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
subscription_info = {}
|
||||||
|
valid_encodings = [
|
||||||
|
# "aesgcm128", # this is draft-0, but DO NOT USE.
|
||||||
|
"aesgcm", # draft-httpbis-encryption-encoding-01
|
||||||
|
"aes128gcm", # RFC8188 Standard encoding
|
||||||
|
]
|
||||||
|
verbose = False
|
||||||
|
|
||||||
|
# Note: the type declarations are not valid under python 3.8,
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
subscription_info: Dict[
|
||||||
|
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
||||||
|
],
|
||||||
|
requests_session: Union[None, requests.Session] = None,
|
||||||
|
aiohttp_session: Union[None, aiohttp.client.ClientSession] = None,
|
||||||
|
verbose: bool = False,
|
||||||
|
):
|
||||||
|
"""Initialize using the info provided by the client PushSubscription
|
||||||
|
object (See
|
||||||
|
https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
|
||||||
|
|
||||||
|
:param subscription_info: a dict containing the subscription_info from
|
||||||
|
the client.
|
||||||
|
:type subscription_info: dict
|
||||||
|
|
||||||
|
:param requests_session: a requests.Session object to optimize requests
|
||||||
|
to the same client.
|
||||||
|
:type requests_session: requests.Session
|
||||||
|
|
||||||
|
:param verbose: provide verbose feedback
|
||||||
|
:type verbose: bool
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.verbose = verbose
|
||||||
|
if requests_session is None:
|
||||||
|
self.requests_method = requests
|
||||||
|
else:
|
||||||
|
self.requests_method = requests_session
|
||||||
|
|
||||||
|
self.aiohttp_session = aiohttp_session
|
||||||
|
|
||||||
|
if "endpoint" not in subscription_info:
|
||||||
|
raise WebPushException("subscription_info missing endpoint URL")
|
||||||
|
self.subscription_info = deepcopy(subscription_info)
|
||||||
|
self.auth_key = self.receiver_key = None
|
||||||
|
if "keys" in subscription_info:
|
||||||
|
keys: Dict[str, Union[str, bytes]] = cast(
|
||||||
|
Dict[str, Union[str, bytes]], self.subscription_info["keys"]
|
||||||
|
)
|
||||||
|
for k in ["p256dh", "auth"]:
|
||||||
|
if keys.get(k) is None:
|
||||||
|
raise WebPushException("Missing keys value: {}".format(k))
|
||||||
|
if isinstance(keys[k], six.text_type):
|
||||||
|
keys[k] = bytes(cast(str, keys[k]).encode("utf8"))
|
||||||
|
receiver_raw = base64.urlsafe_b64decode(
|
||||||
|
self._repad(cast(bytes, keys["p256dh"]))
|
||||||
|
)
|
||||||
|
if len(receiver_raw) != 65 and receiver_raw[0] != "\x04":
|
||||||
|
raise WebPushException("Invalid p256dh key specified")
|
||||||
|
self.receiver_key = receiver_raw
|
||||||
|
self.auth_key = base64.urlsafe_b64decode(
|
||||||
|
self._repad(cast(bytes, keys["auth"]))
|
||||||
|
)
|
||||||
|
|
||||||
|
def verb(self, msg: str, *args, **kwargs):
|
||||||
|
if self.verbose:
|
||||||
|
logging.info(msg.format(*args, **kwargs))
|
||||||
|
|
||||||
|
def _repad(self, data: bytes):
|
||||||
|
"""Add base64 padding to the end of a string, if required"""
|
||||||
|
return data + b"===="[: len(data) % 4]
|
||||||
|
|
||||||
|
def encode(
|
||||||
|
self, data: bytes, content_encoding: str = "aes128gcm"
|
||||||
|
) -> CaseInsensitiveDict:
|
||||||
|
"""Encrypt the data.
|
||||||
|
|
||||||
|
:param data: A serialized block of byte data (String, JSON, bit array,
|
||||||
|
etc.) Make sure that whatever you send, your client knows how
|
||||||
|
to understand it.
|
||||||
|
:type data: str
|
||||||
|
:param content_encoding: The content_encoding type to use to encrypt
|
||||||
|
the data. Defaults to RFC8188 "aes128gcm". The previous draft-01 is
|
||||||
|
"aesgcm", however this format is now deprecated.
|
||||||
|
:type content_encoding: enum("aesgcm", "aes128gcm")
|
||||||
|
|
||||||
|
"""
|
||||||
|
reply = CaseInsensitiveDict()
|
||||||
|
# Salt is a random 16 byte array.
|
||||||
|
if not data:
|
||||||
|
self.verb("No data found...")
|
||||||
|
raise NoData()
|
||||||
|
if not self.auth_key or not self.receiver_key:
|
||||||
|
raise WebPushException("No keys specified in subscription info")
|
||||||
|
self.verb("Encoding data...")
|
||||||
|
salt = None
|
||||||
|
if content_encoding not in self.valid_encodings:
|
||||||
|
raise WebPushException(
|
||||||
|
"Invalid content encoding specified. "
|
||||||
|
"Select from " + json.dumps(self.valid_encodings)
|
||||||
|
)
|
||||||
|
if content_encoding == "aesgcm":
|
||||||
|
self.verb("Generating salt for aesgcm...")
|
||||||
|
salt = os.urandom(16)
|
||||||
|
logging.debug("Salt: {}".format(salt))
|
||||||
|
# The server key is an ephemeral ECDH key used only for this
|
||||||
|
# transaction
|
||||||
|
server_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||||
|
crypto_key = server_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.X962,
|
||||||
|
format=serialization.PublicFormat.UncompressedPoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(data, six.text_type):
|
||||||
|
data = bytes(data.encode("utf8"))
|
||||||
|
if content_encoding == "aes128gcm":
|
||||||
|
self.verb("Encrypting to aes128gcm...")
|
||||||
|
encrypted = http_ece.encrypt(
|
||||||
|
data,
|
||||||
|
salt=salt,
|
||||||
|
private_key=server_key,
|
||||||
|
dh=self.receiver_key,
|
||||||
|
auth_secret=self.auth_key,
|
||||||
|
version=content_encoding,
|
||||||
|
)
|
||||||
|
reply["body"] = encrypted
|
||||||
|
else:
|
||||||
|
self.verb("Encrypting to aesgcm...")
|
||||||
|
crypto_key = base64.urlsafe_b64encode(crypto_key).strip(b"=")
|
||||||
|
encrypted = http_ece.encrypt(
|
||||||
|
data,
|
||||||
|
salt=salt,
|
||||||
|
private_key=server_key,
|
||||||
|
keyid=crypto_key.decode(),
|
||||||
|
dh=self.receiver_key,
|
||||||
|
auth_secret=self.auth_key,
|
||||||
|
version=content_encoding,
|
||||||
|
)
|
||||||
|
reply["crypto_key"] = crypto_key
|
||||||
|
reply["body"] = encrypted
|
||||||
|
if salt:
|
||||||
|
reply["salt"] = base64.urlsafe_b64encode(salt).strip(b"=")
|
||||||
|
return reply
|
||||||
|
|
||||||
|
def as_curl(self, endpoint: str, encoded_data: bytes, headers: Dict[str, str]):
|
||||||
|
"""Return the send as a curl command.
|
||||||
|
|
||||||
|
Useful for debugging. This will write out the encoded data to a local
|
||||||
|
file named `encrypted.data`
|
||||||
|
|
||||||
|
:param endpoint: Push service endpoint URL
|
||||||
|
:type endpoint: basestring
|
||||||
|
:param encoded_data: byte array of encoded data
|
||||||
|
:type encoded_data: bytearray
|
||||||
|
:param headers: Additional headers for the send
|
||||||
|
:type headers: dict
|
||||||
|
:returns string
|
||||||
|
|
||||||
|
"""
|
||||||
|
header_list = [
|
||||||
|
'-H "{}: {}" \\ \n'.format(key.lower(), val) for key, val in headers.items()
|
||||||
|
]
|
||||||
|
data = ""
|
||||||
|
if encoded_data:
|
||||||
|
with open("encrypted.data", "wb") as f:
|
||||||
|
f.write(encoded_data)
|
||||||
|
data = "--data-binary @encrypted.data"
|
||||||
|
if "content-length" not in headers:
|
||||||
|
self.verb("Generating content-length header...")
|
||||||
|
header_list.append(
|
||||||
|
'-H "content-length: {}" \\ \n'.format(len(encoded_data))
|
||||||
|
)
|
||||||
|
return """curl -vX POST {url} \\\n{headers}{data}""".format(
|
||||||
|
url=endpoint, headers="".join(header_list), data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
def _prepare_send_data(
|
||||||
|
self,
|
||||||
|
data: Union[None, bytes] = None,
|
||||||
|
headers: Union[None, Dict[str, str]] = None,
|
||||||
|
ttl: int = 0,
|
||||||
|
gcm_key: Union[None, str] = None,
|
||||||
|
reg_id: Union[None, str] = None,
|
||||||
|
content_encoding: str = "aes128gcm",
|
||||||
|
curl: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Encode and send the data to the Push Service.
|
||||||
|
|
||||||
|
:param data: A serialized block of data (see encode() ).
|
||||||
|
:type data: str
|
||||||
|
:param headers: A dictionary containing any additional HTTP headers.
|
||||||
|
:type headers: dict
|
||||||
|
:param ttl: The Time To Live in seconds for this message if the
|
||||||
|
recipient is not online. (Defaults to "0", which discards the
|
||||||
|
message immediately if the recipient is unavailable.)
|
||||||
|
:type ttl: int
|
||||||
|
:param gcm_key: API key obtained from the Google Developer Console.
|
||||||
|
Needed if endpoint is https://android.googleapis.com/gcm/send
|
||||||
|
:type gcm_key: string
|
||||||
|
:param reg_id: registration id of the recipient. If not provided,
|
||||||
|
it will be extracted from the endpoint.
|
||||||
|
:type reg_id: str
|
||||||
|
:param content_encoding: ECE content encoding (defaults to "aes128gcm")
|
||||||
|
:type content_encoding: str
|
||||||
|
:param curl: Display output as `curl` command instead of sending
|
||||||
|
:type curl: bool
|
||||||
|
"""
|
||||||
|
# Encode the data.
|
||||||
|
if headers is None:
|
||||||
|
headers = dict()
|
||||||
|
encoded = CaseInsensitiveDict()
|
||||||
|
headers = CaseInsensitiveDict(headers)
|
||||||
|
if data:
|
||||||
|
encoded = self.encode(data, content_encoding)
|
||||||
|
if "crypto_key" in encoded:
|
||||||
|
# Append the p256dh to the end of any existing crypto-key
|
||||||
|
crypto_key = headers.get("crypto-key", "")
|
||||||
|
if crypto_key:
|
||||||
|
# due to some confusion by a push service provider, we
|
||||||
|
# should use ';' instead of ',' to append the headers.
|
||||||
|
# see
|
||||||
|
# https://github.com/webpush-wg/webpush-encryption/issues/6
|
||||||
|
crypto_key += ";"
|
||||||
|
crypto_key += "dh=" + encoded["crypto_key"].decode("utf8")
|
||||||
|
headers.update({"crypto-key": crypto_key})
|
||||||
|
if "salt" in encoded:
|
||||||
|
headers.update({"encryption": "salt=" + encoded["salt"].decode("utf8")})
|
||||||
|
headers.update(
|
||||||
|
{
|
||||||
|
"content-encoding": content_encoding,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if gcm_key:
|
||||||
|
# guess if it is a legacy GCM project key or actual FCM key
|
||||||
|
# gcm keys are all about 40 chars (use 100 for confidence),
|
||||||
|
# fcm keys are 153-175 chars
|
||||||
|
if len(gcm_key) < 100:
|
||||||
|
self.verb("Guessing this is legacy GCM...")
|
||||||
|
endpoint = "https://android.googleapis.com/gcm/send"
|
||||||
|
else:
|
||||||
|
self.verb("Guessing this is FCM...")
|
||||||
|
endpoint = "https://fcm.googleapis.com/fcm/send"
|
||||||
|
reg_ids = []
|
||||||
|
if not reg_id:
|
||||||
|
reg_id = cast(str, self.subscription_info["endpoint"]).rsplit("/", 1)[
|
||||||
|
-1
|
||||||
|
]
|
||||||
|
self.verb("Fetching out registration id: {}", reg_id)
|
||||||
|
reg_ids.append(reg_id)
|
||||||
|
gcm_data = dict()
|
||||||
|
gcm_data["registration_ids"] = reg_ids
|
||||||
|
if data:
|
||||||
|
buffer = encoded.get("body")
|
||||||
|
if buffer:
|
||||||
|
gcm_data["raw_data"] = base64.b64encode(buffer).decode("utf8")
|
||||||
|
gcm_data["time_to_live"] = int(headers["ttl"] if "ttl" in headers else ttl)
|
||||||
|
encoded_data = json.dumps(gcm_data)
|
||||||
|
headers.update(
|
||||||
|
{
|
||||||
|
"Authorization": "key=" + gcm_key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
encoded_data = encoded.get("body")
|
||||||
|
endpoint = self.subscription_info["endpoint"]
|
||||||
|
|
||||||
|
if "ttl" not in headers or ttl:
|
||||||
|
self.verb("Generating TTL of 0...")
|
||||||
|
headers["ttl"] = str(ttl or 0)
|
||||||
|
# Additionally useful headers:
|
||||||
|
# Authorization / Crypto-Key (VAPID headers)
|
||||||
|
|
||||||
|
self.verb(
|
||||||
|
"\nSending request to" "\n\thost: {}\n\theaders: {}\n\tdata: {}",
|
||||||
|
endpoint,
|
||||||
|
headers,
|
||||||
|
encoded_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"endpoint": endpoint, "data": encoded_data, "headers": headers}
|
||||||
|
|
||||||
|
def send(self, *args, **kwargs) -> Union[Response, str]:
|
||||||
|
"""Encode and send the data to the Push Service"""
|
||||||
|
timeout = kwargs.pop("timeout", 10000)
|
||||||
|
curl = kwargs.pop("curl", False)
|
||||||
|
|
||||||
|
params = self._prepare_send_data(*args, **kwargs)
|
||||||
|
endpoint = params.pop("endpoint")
|
||||||
|
|
||||||
|
if curl:
|
||||||
|
encoded_data = params["data"]
|
||||||
|
headers = params["headers"]
|
||||||
|
return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
|
||||||
|
|
||||||
|
resp = self.requests_method.post(
|
||||||
|
endpoint,
|
||||||
|
timeout=timeout,
|
||||||
|
**params,
|
||||||
|
)
|
||||||
|
self.verb(
|
||||||
|
"\nResponse:\n\tcode: {}\n\tbody: {}\n",
|
||||||
|
resp.status_code,
|
||||||
|
resp.text or "Empty",
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
async def send_async(self, *args, **kwargs) -> Union[aiohttp.ClientResponse, str]:
|
||||||
|
timeout = kwargs.pop("timeout", 10000)
|
||||||
|
curl = kwargs.pop("curl", False)
|
||||||
|
|
||||||
|
params = self._prepare_send_data(*args, **kwargs)
|
||||||
|
endpoint = params.pop("endpoint")
|
||||||
|
|
||||||
|
if curl:
|
||||||
|
encoded_data = params["data"]
|
||||||
|
headers = params["headers"]
|
||||||
|
return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
|
||||||
|
if self.aiohttp_session:
|
||||||
|
resp = await self.aiohttp_session.post(endpoint, timeout=timeout, **params)
|
||||||
|
resp_text = await resp.text()
|
||||||
|
else:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
resp = await session.post(endpoint, timeout=timeout, **params)
|
||||||
|
resp_text = await resp.text()
|
||||||
|
self.verb(
|
||||||
|
"\nResponse:\n\tcode: {}\n\tbody: {}\n",
|
||||||
|
resp.status,
|
||||||
|
resp_text or "Empty",
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def webpush(
|
||||||
|
subscription_info: Dict[
|
||||||
|
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
||||||
|
],
|
||||||
|
data: Union[None, str] = None,
|
||||||
|
vapid_private_key: Union[None, Vapid, str] = None,
|
||||||
|
vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
|
||||||
|
content_encoding: str = "aes128gcm",
|
||||||
|
curl: bool = False,
|
||||||
|
timeout: Union[None, float] = None,
|
||||||
|
ttl: int = 0,
|
||||||
|
verbose: bool = False,
|
||||||
|
headers: Union[None, Dict[str, Union[str, int, float]]] = None,
|
||||||
|
requests_session: Union[None, requests.Session] = None,
|
||||||
|
) -> Union[str, requests.Response]:
|
||||||
|
"""
|
||||||
|
One call solution to endcode and send `data` to the endpoint
|
||||||
|
contained in `subscription_info` using optional VAPID auth headers.
|
||||||
|
|
||||||
|
in example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from pywebpush import python
|
||||||
|
|
||||||
|
webpush(
|
||||||
|
subscription_info={
|
||||||
|
"endpoint": "https://push.example.com/v1/abcd",
|
||||||
|
"keys": {"p256dh": "0123abcd...",
|
||||||
|
"auth": "001122..."}
|
||||||
|
},
|
||||||
|
data="Mary had a little lamb, with a nice mint jelly",
|
||||||
|
vapid_private_key="path/to/key.pem",
|
||||||
|
vapid_claims={"sub": "YourNameHere@example.com"}
|
||||||
|
)
|
||||||
|
|
||||||
|
No additional method call is required. Any non-success will throw a
|
||||||
|
`WebPushException`.
|
||||||
|
|
||||||
|
:param subscription_info: Provided by the client call
|
||||||
|
:type subscription_info: dict
|
||||||
|
:param data: Serialized data to send
|
||||||
|
:type data: str
|
||||||
|
:param vapid_private_key: Vapid instance or path to vapid private key PEM \
|
||||||
|
or encoded str
|
||||||
|
:type vapid_private_key: Union[Vapid, str]
|
||||||
|
:param vapid_claims: Dictionary of claims ('sub' required)
|
||||||
|
:type vapid_claims: dict
|
||||||
|
:param content_encoding: Optional content type string
|
||||||
|
:type content_encoding: str
|
||||||
|
:param curl: Return as "curl" string instead of sending
|
||||||
|
:type curl: bool
|
||||||
|
:param timeout: POST requests timeout
|
||||||
|
:type timeout: float
|
||||||
|
:param ttl: Time To Live
|
||||||
|
:type ttl: int
|
||||||
|
:param verbose: Provide verbose feedback
|
||||||
|
:type verbose: bool
|
||||||
|
:return requests.Response or string
|
||||||
|
:param headers: Dictionary of extra HTTP headers to include
|
||||||
|
:type headers: dict
|
||||||
|
|
||||||
|
"""
|
||||||
|
if headers is None:
|
||||||
|
headers = dict()
|
||||||
|
else:
|
||||||
|
# Ensure we don't leak VAPID headers by mutating the passed in dict.
|
||||||
|
headers = headers.copy()
|
||||||
|
|
||||||
|
vapid_headers = None
|
||||||
|
if vapid_claims:
|
||||||
|
if verbose:
|
||||||
|
logging.info("Generating VAPID headers...")
|
||||||
|
if not vapid_claims.get("aud"):
|
||||||
|
url = urlparse(cast(str, subscription_info.get("endpoint")))
|
||||||
|
aud = "{}://{}".format(url.scheme, url.netloc)
|
||||||
|
vapid_claims["aud"] = aud
|
||||||
|
# Remember, passed structures are mutable in python.
|
||||||
|
# It's possible that a previously set `exp` field is no longer valid.
|
||||||
|
if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int(
|
||||||
|
time.time()
|
||||||
|
):
|
||||||
|
# encryption lives for 12 hours
|
||||||
|
vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
|
||||||
|
if verbose:
|
||||||
|
logging.info("Setting VAPID expry to {}...".format(vapid_claims["exp"]))
|
||||||
|
if not vapid_private_key:
|
||||||
|
raise WebPushException("VAPID dict missing 'private_key'")
|
||||||
|
if isinstance(vapid_private_key, Vapid01):
|
||||||
|
if verbose:
|
||||||
|
logging.info("Looks like we already have a valid VAPID key")
|
||||||
|
vv = vapid_private_key
|
||||||
|
elif os.path.isfile(vapid_private_key):
|
||||||
|
# Presume that key from file is handled correctly by
|
||||||
|
# py_vapid.
|
||||||
|
if verbose:
|
||||||
|
logging.info("Reading VAPID key from file {}".format(vapid_private_key))
|
||||||
|
vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover
|
||||||
|
else:
|
||||||
|
if verbose:
|
||||||
|
logging.info("Reading VAPID key from arguments")
|
||||||
|
vv = Vapid.from_string(private_key=vapid_private_key)
|
||||||
|
if verbose:
|
||||||
|
logging.info("\t claims: {}".format(vapid_claims))
|
||||||
|
vapid_headers = vv.sign(vapid_claims)
|
||||||
|
if verbose:
|
||||||
|
logging.info("\t headers: {}".format(vapid_headers))
|
||||||
|
headers.update(vapid_headers)
|
||||||
|
|
||||||
|
response = WebPusher(
|
||||||
|
subscription_info, requests_session=requests_session, verbose=verbose
|
||||||
|
).send(
|
||||||
|
data,
|
||||||
|
headers,
|
||||||
|
ttl=ttl,
|
||||||
|
content_encoding=content_encoding,
|
||||||
|
curl=curl,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if not curl and cast(Response, response).status_code > 202:
|
||||||
|
response = cast(Response, response)
|
||||||
|
raise WebPushException(
|
||||||
|
"Push failed: {} {}\nResponse body:{}".format(
|
||||||
|
response.status_code, response.reason, response.text
|
||||||
|
),
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def webpush_async(
|
||||||
|
subscription_info: Dict[
|
||||||
|
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
||||||
|
],
|
||||||
|
data: Union[None, str] = None,
|
||||||
|
vapid_private_key: Union[None, Vapid, str] = None,
|
||||||
|
vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
|
||||||
|
content_encoding: str = "aes128gcm",
|
||||||
|
curl: bool = False,
|
||||||
|
timeout: Union[None, float] = None,
|
||||||
|
ttl: int = 0,
|
||||||
|
verbose: bool = False,
|
||||||
|
headers: Union[None, Dict[str, Union[str, int, float]]] = None,
|
||||||
|
aiohttp_session: Union[None, aiohttp.ClientSession] = None,
|
||||||
|
) -> Union[str, aiohttp.ClientResponse]:
|
||||||
|
"""
|
||||||
|
Async version of webpush function. One call solution to encode and send
|
||||||
|
`data` to the endpoint contained in `subscription_info` using optional
|
||||||
|
VAPID auth headers.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from pywebpush import webpush_async
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def send_notification():
|
||||||
|
response = await webpush_async(
|
||||||
|
subscription_info={
|
||||||
|
"endpoint": "https://push.example.com/v1/abcd",
|
||||||
|
"keys": {"p256dh": "0123abcd...",
|
||||||
|
"auth": "001122..."}
|
||||||
|
},
|
||||||
|
data="Mary had a little lamb, with a nice mint jelly",
|
||||||
|
vapid_private_key="path/to/key.pem",
|
||||||
|
vapid_claims={"sub": "YourNameHere@example.com"}
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(send_notification())
|
||||||
|
|
||||||
|
No additional method call is required. Any non-success will throw a
|
||||||
|
`WebPushException`.
|
||||||
|
|
||||||
|
:param subscription_info: Provided by the client call
|
||||||
|
:type subscription_info: dict
|
||||||
|
:param data: Serialized data to send
|
||||||
|
:type data: str
|
||||||
|
:param vapid_private_key: Vapid instance or path to vapid private key PEM \
|
||||||
|
or encoded str
|
||||||
|
:type vapid_private_key: Union[Vapid, str]
|
||||||
|
:param vapid_claims: Dictionary of claims ('sub' required)
|
||||||
|
:type vapid_claims: dict
|
||||||
|
:param content_encoding: Optional content type string
|
||||||
|
:type content_encoding: str
|
||||||
|
:param curl: Return as "curl" string instead of sending
|
||||||
|
:type curl: bool
|
||||||
|
:param timeout: POST requests timeout
|
||||||
|
:type timeout: float
|
||||||
|
:param ttl: Time To Live
|
||||||
|
:type ttl: int
|
||||||
|
:param verbose: Provide verbose feedback
|
||||||
|
:type verbose: bool
|
||||||
|
:param headers: Dictionary of extra HTTP headers to include
|
||||||
|
:type headers: dict
|
||||||
|
:param aiohttp_session: Optional aiohttp ClientSession for connection reuse
|
||||||
|
:type aiohttp_session: aiohttp.ClientSession
|
||||||
|
:return aiohttp.ClientResponse or string
|
||||||
|
|
||||||
|
"""
|
||||||
|
if headers is None:
|
||||||
|
headers = dict()
|
||||||
|
else:
|
||||||
|
# Ensure we don't leak VAPID headers by mutating the passed in dict.
|
||||||
|
headers = headers.copy()
|
||||||
|
|
||||||
|
vapid_headers = None
|
||||||
|
if vapid_claims:
|
||||||
|
if verbose:
|
||||||
|
logging.info("Generating VAPID headers...")
|
||||||
|
if not vapid_claims.get("aud"):
|
||||||
|
url = urlparse(cast(str, subscription_info.get("endpoint")))
|
||||||
|
aud = "{}://{}".format(url.scheme, url.netloc)
|
||||||
|
vapid_claims["aud"] = aud
|
||||||
|
# Remember, passed structures are mutable in python.
|
||||||
|
# It's possible that a previously set `exp` field is no longer valid.
|
||||||
|
if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int(
|
||||||
|
time.time()
|
||||||
|
):
|
||||||
|
# encryption lives for 12 hours
|
||||||
|
vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
|
||||||
|
if verbose:
|
||||||
|
logging.info(
|
||||||
|
"Setting VAPID expiry to {}...".format(vapid_claims["exp"])
|
||||||
|
)
|
||||||
|
if not vapid_private_key:
|
||||||
|
raise WebPushException("VAPID dict missing 'private_key'")
|
||||||
|
if isinstance(vapid_private_key, Vapid01):
|
||||||
|
if verbose:
|
||||||
|
logging.info("Looks like we already have a valid VAPID key")
|
||||||
|
vv = vapid_private_key
|
||||||
|
elif os.path.isfile(vapid_private_key):
|
||||||
|
# Presume that key from file is handled correctly by
|
||||||
|
# py_vapid.
|
||||||
|
if verbose:
|
||||||
|
logging.info("Reading VAPID key from file {}".format(vapid_private_key))
|
||||||
|
vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover
|
||||||
|
else:
|
||||||
|
if verbose:
|
||||||
|
logging.info("Reading VAPID key from arguments")
|
||||||
|
vv = Vapid.from_string(private_key=vapid_private_key)
|
||||||
|
if verbose:
|
||||||
|
logging.info("\t claims: {}".format(vapid_claims))
|
||||||
|
vapid_headers = vv.sign(vapid_claims)
|
||||||
|
if verbose:
|
||||||
|
logging.info("\t headers: {}".format(vapid_headers))
|
||||||
|
headers.update(vapid_headers)
|
||||||
|
|
||||||
|
response = await WebPusher(
|
||||||
|
subscription_info, aiohttp_session=aiohttp_session, verbose=verbose
|
||||||
|
).send_async(
|
||||||
|
data,
|
||||||
|
headers,
|
||||||
|
ttl=ttl,
|
||||||
|
content_encoding=content_encoding,
|
||||||
|
curl=curl,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if not curl and cast(aiohttp.ClientResponse, response).status > 202:
|
||||||
|
response = cast(aiohttp.ClientResponse, response)
|
||||||
|
response_text = await response.text()
|
||||||
|
raise WebPushException(
|
||||||
|
"Push failed: {} {}\nResponse body:{}".format(
|
||||||
|
response.status, response.reason, response_text
|
||||||
|
),
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
return response
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from requests import JSONDecodeError
|
||||||
|
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
|
||||||
|
|
||||||
|
def get_config():
|
||||||
|
parser = argparse.ArgumentParser(description="WebPush tool")
|
||||||
|
parser.add_argument("--data", "-d", help="Data file")
|
||||||
|
parser.add_argument("--info", "-i", help="Subscription Info JSON file")
|
||||||
|
parser.add_argument("--head", help="Header Info JSON file")
|
||||||
|
parser.add_argument("--claims", help="Vapid claim file")
|
||||||
|
parser.add_argument("--key", help="Vapid private key file path")
|
||||||
|
parser.add_argument(
|
||||||
|
"--curl",
|
||||||
|
help="Don't send, display as curl command",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
parser.add_argument("--encoding", default="aes128gcm")
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
"-v",
|
||||||
|
help="Provide verbose feedback",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.info:
|
||||||
|
raise WebPushException("Subscription Info argument missing.")
|
||||||
|
if not os.path.exists(args.info):
|
||||||
|
raise WebPushException("Subscription Info file missing.")
|
||||||
|
try:
|
||||||
|
with open(args.info) as r:
|
||||||
|
try:
|
||||||
|
args.sub_info = json.loads(r.read())
|
||||||
|
except JSONDecodeError as e:
|
||||||
|
raise WebPushException(
|
||||||
|
"Could not read the subscription info file: {}", e
|
||||||
|
)
|
||||||
|
if args.data:
|
||||||
|
with open(args.data) as r:
|
||||||
|
args.data = r.read()
|
||||||
|
if args.head:
|
||||||
|
with open(args.head) as r:
|
||||||
|
try:
|
||||||
|
args.head = json.loads(r.read())
|
||||||
|
except JSONDecodeError as e:
|
||||||
|
raise WebPushException("Could not read the header arguments: {}", e)
|
||||||
|
if args.claims:
|
||||||
|
if not args.key:
|
||||||
|
raise WebPushException("No private --key specified for claims")
|
||||||
|
with open(args.claims) as r:
|
||||||
|
try:
|
||||||
|
args.claims = json.loads(r.read())
|
||||||
|
except JSONDecodeError as e:
|
||||||
|
raise WebPushException(
|
||||||
|
"Could not read the VAPID claims file {}".format(e)
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
logging.error("Couldn't read input {}.".format(ex))
|
||||||
|
raise ex
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Send data"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
args = get_config()
|
||||||
|
result = webpush(
|
||||||
|
args.sub_info,
|
||||||
|
data=args.data,
|
||||||
|
vapid_private_key=args.key,
|
||||||
|
vapid_claims=args.claims,
|
||||||
|
curl=args.curl,
|
||||||
|
content_encoding=args.encoding,
|
||||||
|
verbose=args.verbose,
|
||||||
|
headers=args.head,
|
||||||
|
)
|
||||||
|
print(result)
|
||||||
|
except Exception as ex:
|
||||||
|
logging.error("{}".format(ex))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from pywebpush import webpush
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def send_push_notification(subscription, payload):
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
# subscriptionData = json.loads(subscription)
|
||||||
|
|
||||||
|
# logger.error(subscriptionData)
|
||||||
|
|
||||||
|
webpush(
|
||||||
|
subscription_info={
|
||||||
|
"endpoint": subscription["endpoint"],
|
||||||
|
"keys": subscription["keys"],
|
||||||
|
},
|
||||||
|
data=json.dumps(payload),
|
||||||
|
vapid_claims={
|
||||||
|
"aud": "https://eshopper.africa",
|
||||||
|
"exp": int((datetime.datetime.now().timestamp())) + 86400,
|
||||||
|
"sub": "mailto:events@eshopper.africa",
|
||||||
|
},
|
||||||
|
vapid_private_key="UCUKEHn7Jd33QZx5lJFKBY4plOxGsJ6xJSOzE14jQlo",
|
||||||
|
)
|
||||||
|
|
||||||
|
# subscription_info = { 'endpoint': subscription['endpoint'], 'keys': subscription['keys'] },
|
||||||
|
|
||||||
|
# data = json.loads(payload),
|
||||||
|
|
||||||
|
# headers = {}
|
||||||
|
|
||||||
|
# ttl = 0
|
||||||
|
|
||||||
|
# gcm_key = ''
|
||||||
|
|
||||||
|
# content_encoding="aes128gcm"
|
||||||
|
|
||||||
|
# reg_id=""
|
||||||
|
|
||||||
|
# WebPusher = webpush(subscription_info)
|
||||||
|
|
||||||
|
# WebPusher(subscription_info).send(data, headers, ttl, gcm_key, reg_id, content_encoding, timeout=None)
|
||||||
|
|
||||||
|
except Exception as inst:
|
||||||
|
print(f" webpush Notification Error : {inst}")
|
||||||
|
|
||||||
|
|
||||||
|
send_push_notification({"endpoint": "https://example.com", "keys": {}}, "laaaa")
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
import time
|
||||||
|
from typing import cast, Union, Dict
|
||||||
|
from unittest.mock import patch, Mock, AsyncMock
|
||||||
|
|
||||||
|
import http_ece
|
||||||
|
import py_vapid
|
||||||
|
import requests
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
|
from pywebpush import (
|
||||||
|
WebPusher,
|
||||||
|
NoData,
|
||||||
|
WebPushException,
|
||||||
|
CaseInsensitiveDict,
|
||||||
|
webpush,
|
||||||
|
webpush_async,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WebpushTestUtils(unittest.TestCase):
|
||||||
|
# This is a exported DER formatted string of an ECDH public key
|
||||||
|
# This was lifted from the py_vapid tests.
|
||||||
|
vapid_key = (
|
||||||
|
"MHcCAQEEIPeN1iAipHbt8+/KZ2NIF8NeN24jqAmnMLFZEMocY8RboAoGCCqGSM49"
|
||||||
|
"AwEHoUQDQgAEEJwJZq/GN8jJbo1GGpyU70hmP2hbWAUpQFKDByKB81yldJ9GTklB"
|
||||||
|
"M5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ=="
|
||||||
|
)
|
||||||
|
|
||||||
|
def _gen_subscription_info(self, recv_key=None, endpoint="https://example.com/"):
|
||||||
|
if not recv_key:
|
||||||
|
recv_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||||
|
return {
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"keys": {
|
||||||
|
"auth": base64.urlsafe_b64encode(os.urandom(16)).strip(b"="),
|
||||||
|
"p256dh": self._get_pubkey_str(recv_key),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_pubkey_str(self, priv_key):
|
||||||
|
return base64.urlsafe_b64encode(
|
||||||
|
priv_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.X962,
|
||||||
|
format=serialization.PublicFormat.UncompressedPoint,
|
||||||
|
)
|
||||||
|
).strip(b"=")
|
||||||
|
|
||||||
|
def test_init(self):
|
||||||
|
# use static values so we know what to look for in the reply
|
||||||
|
subscription_info = {
|
||||||
|
"endpoint": "https://example.com/",
|
||||||
|
"keys": {
|
||||||
|
"p256dh": (
|
||||||
|
"BOrnIslXrUow2VAzKCUAE4sIbK00daEZCswOcf8m3T"
|
||||||
|
"F8V82B-OpOg5JbmYLg44kRcvQC1E2gMJshsUYA-_zMPR8"
|
||||||
|
),
|
||||||
|
"auth": "k8JV6sjdbhAi1n3_LDBLvA",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rk_decode = (
|
||||||
|
b'\x04\xea\xe7"\xc9W\xadJ0\xd9P3(%\x00\x13\x8b'
|
||||||
|
b"\x08l\xad4u\xa1\x19\n\xcc\x0eq\xff&\xdd1"
|
||||||
|
b"|W\xcd\x81\xf8\xeaN\x83\x92[\x99\x82\xe0\xe3"
|
||||||
|
b"\x89\x11r\xf4\x02\xd4M\xa00\x9b!\xb1F\x00"
|
||||||
|
b"\xfb\xfc\xcc=\x1f"
|
||||||
|
)
|
||||||
|
self.assertRaises(
|
||||||
|
WebPushException, WebPusher, {"keys": {"p256dh": "AAA=", "auth": "AAA="}}
|
||||||
|
)
|
||||||
|
self.assertRaises(
|
||||||
|
WebPushException,
|
||||||
|
WebPusher,
|
||||||
|
{"endpoint": "https://example.com", "keys": {"p256dh": "AAA="}},
|
||||||
|
)
|
||||||
|
self.assertRaises(
|
||||||
|
WebPushException,
|
||||||
|
WebPusher,
|
||||||
|
{"endpoint": "https://example.com", "keys": {"auth": "AAA="}},
|
||||||
|
)
|
||||||
|
self.assertRaises(
|
||||||
|
WebPushException,
|
||||||
|
WebPusher,
|
||||||
|
{
|
||||||
|
"endpoint": "https://example.com",
|
||||||
|
"keys": {"p256dh": "AAA=", "auth": "AAA="},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
push = WebPusher(subscription_info)
|
||||||
|
assert push.subscription_info != subscription_info
|
||||||
|
assert push.subscription_info["keys"] != subscription_info["keys"]
|
||||||
|
assert push.subscription_info["endpoint"] == subscription_info["endpoint"]
|
||||||
|
assert push.receiver_key == rk_decode
|
||||||
|
assert push.auth_key == b'\x93\xc2U\xea\xc8\xddn\x10"\xd6}\xff,0K\xbc'
|
||||||
|
|
||||||
|
def test_encode(self):
|
||||||
|
for content_encoding in ["aesgcm", "aes128gcm"]:
|
||||||
|
recv_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||||
|
subscription_info = self._gen_subscription_info(recv_key)
|
||||||
|
data = "Mary had a little lamb, with some nice mint jelly"
|
||||||
|
push = WebPusher(subscription_info)
|
||||||
|
encoded = push.encode(data.encode(), content_encoding=content_encoding)
|
||||||
|
"""
|
||||||
|
crypto_key = base64.urlsafe_b64encode(
|
||||||
|
self._get_pubkey_str(recv_key)
|
||||||
|
).strip(b'=')
|
||||||
|
"""
|
||||||
|
# Convert these b64 strings into their raw, binary form.
|
||||||
|
raw_salt = None
|
||||||
|
if "salt" in encoded:
|
||||||
|
raw_salt = base64.urlsafe_b64decode(push._repad(encoded["salt"]))
|
||||||
|
raw_dh = None
|
||||||
|
if content_encoding != "aes128gcm":
|
||||||
|
raw_dh = base64.urlsafe_b64decode(push._repad(encoded["crypto_key"]))
|
||||||
|
raw_auth = base64.urlsafe_b64decode(
|
||||||
|
push._repad(subscription_info["keys"]["auth"])
|
||||||
|
)
|
||||||
|
decoded = http_ece.decrypt(
|
||||||
|
encoded["body"],
|
||||||
|
salt=raw_salt,
|
||||||
|
dh=raw_dh,
|
||||||
|
private_key=recv_key,
|
||||||
|
auth_secret=raw_auth,
|
||||||
|
version=content_encoding,
|
||||||
|
)
|
||||||
|
assert decoded.decode("utf8") == data
|
||||||
|
|
||||||
|
def test_bad_content_encoding(self):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb, with some nice mint jelly"
|
||||||
|
push = WebPusher(subscription_info)
|
||||||
|
self.assertRaises(
|
||||||
|
WebPushException, push.encode, data, content_encoding="aesgcm128"
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_send(self, mock_post):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
WebPusher(subscription_info).send(data, headers)
|
||||||
|
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||||
|
pheaders = mock_post.call_args[1].get("headers")
|
||||||
|
assert pheaders.get("ttl") == "0"
|
||||||
|
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||||
|
ckey = pheaders.get("crypto-key")
|
||||||
|
assert "pre-existing" in ckey
|
||||||
|
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_send_vapid(self, mock_post):
|
||||||
|
mock_post.return_value.status_code = 200
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_private_key=self.vapid_key,
|
||||||
|
vapid_claims={"sub": "mailto:ops@example.com"},
|
||||||
|
content_encoding="aesgcm",
|
||||||
|
headers={"Test-Header": "test-value"},
|
||||||
|
)
|
||||||
|
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||||
|
pheaders = mock_post.call_args[1].get("headers")
|
||||||
|
assert pheaders.get("ttl") == "0"
|
||||||
|
|
||||||
|
def repad(str):
|
||||||
|
return str + "===="[: len(str) % 4]
|
||||||
|
|
||||||
|
auth = json.loads(
|
||||||
|
base64.urlsafe_b64decode(
|
||||||
|
repad(pheaders["authorization"].split(".")[1])
|
||||||
|
).decode("utf8")
|
||||||
|
)
|
||||||
|
assert subscription_info.get("endpoint", "").startswith(auth["aud"])
|
||||||
|
assert "vapid" in pheaders.get("authorization")
|
||||||
|
ckey = pheaders.get("crypto-key")
|
||||||
|
assert "dh=" in ckey
|
||||||
|
assert pheaders.get("content-encoding") == "aesgcm"
|
||||||
|
assert pheaders.get("test-header") == "test-value"
|
||||||
|
|
||||||
|
@patch.object(WebPusher, "send")
|
||||||
|
@patch.object(py_vapid.Vapid, "sign")
|
||||||
|
def test_webpush_vapid_instance(self, vapid_sign, pusher_send):
|
||||||
|
pusher_send.return_value.status_code = 200
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||||
|
claims: Dict[str, Union[str, int]] = dict(
|
||||||
|
sub="mailto:ops@example.com", aud="https://example.com"
|
||||||
|
)
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_private_key=vapid_key,
|
||||||
|
vapid_claims=claims,
|
||||||
|
)
|
||||||
|
vapid_sign.assert_called_once_with(claims)
|
||||||
|
pusher_send.assert_called_once()
|
||||||
|
|
||||||
|
@patch.object(WebPusher, "send")
|
||||||
|
@patch.object(py_vapid.Vapid, "sign")
|
||||||
|
def test_webpush_vapid_exp(self, vapid_sign, pusher_send):
|
||||||
|
pusher_send.return_value.status_code = 200
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||||
|
claims = dict(
|
||||||
|
sub="mailto:ops@example.com",
|
||||||
|
aud="https://example.com",
|
||||||
|
exp=int(time.time() - 48600),
|
||||||
|
)
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_private_key=vapid_key,
|
||||||
|
vapid_claims=claims,
|
||||||
|
)
|
||||||
|
vapid_sign.assert_called_once_with(claims)
|
||||||
|
pusher_send.assert_called_once()
|
||||||
|
assert int(claims["exp"]) > int(time.time())
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_send_bad_vapid_no_key(self, mock_post):
|
||||||
|
mock_post.return_value.status_code = 200
|
||||||
|
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
self.assertRaises(
|
||||||
|
WebPushException,
|
||||||
|
webpush,
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_claims={
|
||||||
|
"aud": "https://example.com",
|
||||||
|
"sub": "mailto:ops@example.com",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_send_bad_vapid_bad_return(self, mock_post):
|
||||||
|
mock_post.return_value.status_code = 410
|
||||||
|
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
self.assertRaises(
|
||||||
|
WebPushException,
|
||||||
|
webpush,
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_claims={
|
||||||
|
"aud": "https://example.com",
|
||||||
|
"sub": "mailto:ops@example.com",
|
||||||
|
},
|
||||||
|
vapid_private_key=self.vapid_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_send_empty(self, mock_post):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||||
|
WebPusher(subscription_info).send("", headers)
|
||||||
|
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||||
|
pheaders = mock_post.call_args[1].get("headers")
|
||||||
|
assert pheaders.get("ttl") == "0"
|
||||||
|
assert "encryption" not in pheaders
|
||||||
|
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||||
|
ckey = pheaders.get("crypto-key")
|
||||||
|
assert "pre-existing" in ckey
|
||||||
|
|
||||||
|
def test_encode_empty(self):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||||
|
pusher = WebPusher(subscription_info)
|
||||||
|
self.assertRaises(NoData, pusher.encode, "", headers)
|
||||||
|
|
||||||
|
def test_encode_no_crypto(self):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
del subscription_info["keys"]
|
||||||
|
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||||
|
data = "Something"
|
||||||
|
pusher = WebPusher(subscription_info)
|
||||||
|
self.assertRaises(WebPushException, pusher.encode, data, headers)
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_send_no_headers(self, mock_post):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
WebPusher(subscription_info).send(data)
|
||||||
|
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||||
|
pheaders = mock_post.call_args[1].get("headers")
|
||||||
|
assert pheaders.get("ttl") == "0"
|
||||||
|
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||||
|
|
||||||
|
@patch("pywebpush.open")
|
||||||
|
def test_as_curl(self, opener):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
result = webpush(
|
||||||
|
subscription_info,
|
||||||
|
data="Mary had a little lamb",
|
||||||
|
vapid_claims={
|
||||||
|
"aud": "https://example.com",
|
||||||
|
"sub": "mailto:ops@example.com",
|
||||||
|
},
|
||||||
|
vapid_private_key=self.vapid_key,
|
||||||
|
curl=True,
|
||||||
|
)
|
||||||
|
result = cast(str, result)
|
||||||
|
for s in [
|
||||||
|
"curl -vX POST https://example.com",
|
||||||
|
'-H "content-encoding: aes128gcm"',
|
||||||
|
'-H "authorization: vapid ',
|
||||||
|
'-H "ttl: 0"',
|
||||||
|
'-H "content-length:',
|
||||||
|
]:
|
||||||
|
assert s in result, "missing: {}".format(s)
|
||||||
|
|
||||||
|
def test_ci_dict(self):
|
||||||
|
ci = CaseInsensitiveDict({"Foo": "apple", "bar": "banana"})
|
||||||
|
assert "apple" == ci["foo"]
|
||||||
|
assert "apple" == ci.get("FOO")
|
||||||
|
assert "apple" == ci.get("Foo")
|
||||||
|
del ci["FOO"]
|
||||||
|
assert ci.get("Foo") is None
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_gcm(self, mock_post):
|
||||||
|
subscription_info = self._gen_subscription_info(
|
||||||
|
None, endpoint="https://android.googleapis.com/gcm/send/regid123"
|
||||||
|
)
|
||||||
|
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
wp = WebPusher(subscription_info)
|
||||||
|
wp.send(data, headers, gcm_key="gcm_key_value")
|
||||||
|
pdata = json.loads(mock_post.call_args[1].get("data"))
|
||||||
|
pheaders = mock_post.call_args[1].get("headers")
|
||||||
|
assert pdata["registration_ids"][0] == "regid123"
|
||||||
|
assert pheaders.get("authorization") == "key=gcm_key_value"
|
||||||
|
assert pheaders.get("content-type") == "application/json"
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_timeout(self, mock_post):
|
||||||
|
mock_post.return_value.status_code = 200
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
WebPusher(subscription_info).send(timeout=5.2)
|
||||||
|
assert mock_post.call_args[1].get("timeout") == 5.2
|
||||||
|
webpush(subscription_info, timeout=10.001)
|
||||||
|
assert mock_post.call_args[1].get("timeout") == 10.001
|
||||||
|
|
||||||
|
@patch("requests.Session")
|
||||||
|
def test_send_using_requests_session(self, mock_session):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
WebPusher(subscription_info, requests_session=mock_session).send(data, headers)
|
||||||
|
assert subscription_info.get("endpoint") == mock_session.post.call_args[0][0]
|
||||||
|
pheaders = mock_session.post.call_args[1].get("headers")
|
||||||
|
assert pheaders.get("ttl") == "0"
|
||||||
|
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||||
|
ckey = pheaders.get("crypto-key")
|
||||||
|
assert "pre-existing" in ckey
|
||||||
|
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||||
|
|
||||||
|
|
||||||
|
class WebPusherAsyncTestCase(WebpushTestUtils, unittest.IsolatedAsyncioTestCase):
|
||||||
|
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||||
|
async def test_send(self, mock_post):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
await WebPusher(subscription_info).send_async(data, headers)
|
||||||
|
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||||
|
pheaders = mock_post.call_args[1].get("headers")
|
||||||
|
assert pheaders.get("ttl") == "0"
|
||||||
|
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||||
|
ckey = pheaders.get("crypto-key")
|
||||||
|
assert "pre-existing" in ckey
|
||||||
|
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||||
|
|
||||||
|
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||||
|
async def test_send_empty(self, mock_post):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||||
|
await WebPusher(subscription_info).send_async("", headers)
|
||||||
|
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||||
|
pheaders = mock_post.call_args[1].get("headers")
|
||||||
|
assert pheaders.get("ttl") == "0"
|
||||||
|
assert "encryption" not in pheaders
|
||||||
|
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||||
|
ckey = pheaders.get("crypto-key")
|
||||||
|
assert "pre-existing" in ckey
|
||||||
|
|
||||||
|
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||||
|
async def test_send_no_headers(self, mock_post):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
await WebPusher(subscription_info).send_async(data)
|
||||||
|
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||||
|
pheaders = mock_post.call_args[1].get("headers")
|
||||||
|
assert pheaders.get("ttl") == "0"
|
||||||
|
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||||
|
|
||||||
|
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||||
|
async def test_fcm(self, mock_post):
|
||||||
|
subscription_info = self._gen_subscription_info(
|
||||||
|
None, endpoint="https://android.googleapis.com/fcm/send/regid123"
|
||||||
|
)
|
||||||
|
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
wp = WebPusher(subscription_info)
|
||||||
|
await wp.send_async(data, headers, gcm_key="gcm_key_value")
|
||||||
|
pdata = json.loads(mock_post.call_args[1].get("data"))
|
||||||
|
pheaders = mock_post.call_args[1].get("headers")
|
||||||
|
assert pdata["registration_ids"][0] == "regid123"
|
||||||
|
assert pheaders.get("authorization") == "key=gcm_key_value"
|
||||||
|
assert pheaders.get("content-type") == "application/json"
|
||||||
|
|
||||||
|
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||||
|
async def test_timeout(self, mock_post):
|
||||||
|
mock_post.return_value.status_code = 200
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
await WebPusher(subscription_info).send_async(timeout=5.2)
|
||||||
|
assert mock_post.call_args[1].get("timeout") == 5.2
|
||||||
|
|
||||||
|
@patch("aiohttp.ClientSession", new_callable=AsyncMock)
|
||||||
|
async def test_send_using_requests_session(self, mock_session):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
await WebPusher(subscription_info, aiohttp_session=mock_session).send_async(
|
||||||
|
data, headers
|
||||||
|
)
|
||||||
|
assert subscription_info.get("endpoint") == mock_session.post.call_args[0][0]
|
||||||
|
pheaders = mock_session.post.call_args[1].get("headers")
|
||||||
|
assert pheaders.get("ttl") == "0"
|
||||||
|
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||||
|
ckey = pheaders.get("crypto-key")
|
||||||
|
assert "pre-existing" in ckey
|
||||||
|
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||||
|
|
||||||
|
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||||
|
async def test_webpush_async_vapid(self, mock_post):
|
||||||
|
mock_post.return_value.status = 200
|
||||||
|
mock_post.return_value.text = AsyncMock(return_value="")
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
await webpush_async(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_private_key=self.vapid_key,
|
||||||
|
vapid_claims={"sub": "mailto:ops@example.com"},
|
||||||
|
content_encoding="aesgcm",
|
||||||
|
headers={"Test-Header": "test-value"},
|
||||||
|
)
|
||||||
|
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||||
|
pheaders = mock_post.call_args[1].get("headers")
|
||||||
|
assert pheaders.get("ttl") == "0"
|
||||||
|
|
||||||
|
def repad(str):
|
||||||
|
return str + "===="[: len(str) % 4]
|
||||||
|
|
||||||
|
auth = json.loads(
|
||||||
|
base64.urlsafe_b64decode(
|
||||||
|
repad(pheaders["authorization"].split(".")[1])
|
||||||
|
).decode("utf8")
|
||||||
|
)
|
||||||
|
assert subscription_info.get("endpoint", "").startswith(auth["aud"])
|
||||||
|
assert "vapid" in pheaders.get("authorization")
|
||||||
|
ckey = pheaders.get("crypto-key")
|
||||||
|
assert "dh=" in ckey
|
||||||
|
assert pheaders.get("content-encoding") == "aesgcm"
|
||||||
|
assert pheaders.get("test-header") == "test-value"
|
||||||
|
|
||||||
|
@patch.object(WebPusher, "send_async")
|
||||||
|
@patch.object(py_vapid.Vapid, "sign")
|
||||||
|
async def test_webpush_async_vapid_instance(self, vapid_sign, pusher_send):
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status = 200
|
||||||
|
pusher_send.return_value = mock_response
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||||
|
claims: Dict[str, Union[str, int]] = dict(
|
||||||
|
sub="mailto:ops@example.com", aud="https://example.com"
|
||||||
|
)
|
||||||
|
await webpush_async(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_private_key=vapid_key,
|
||||||
|
vapid_claims=claims,
|
||||||
|
)
|
||||||
|
vapid_sign.assert_called_once_with(claims)
|
||||||
|
pusher_send.assert_called_once()
|
||||||
|
|
||||||
|
@patch.object(WebPusher, "send_async")
|
||||||
|
@patch.object(py_vapid.Vapid, "sign")
|
||||||
|
async def test_webpush_async_vapid_exp(self, vapid_sign, pusher_send):
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status = 200
|
||||||
|
pusher_send.return_value = mock_response
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||||
|
claims = dict(
|
||||||
|
sub="mailto:ops@example.com",
|
||||||
|
aud="https://example.com",
|
||||||
|
exp=int(time.time() - 48600),
|
||||||
|
)
|
||||||
|
await webpush_async(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_private_key=vapid_key,
|
||||||
|
vapid_claims=claims,
|
||||||
|
)
|
||||||
|
vapid_sign.assert_called_once_with(claims)
|
||||||
|
pusher_send.assert_called_once()
|
||||||
|
assert int(claims["exp"]) > int(time.time())
|
||||||
|
|
||||||
|
async def test_webpush_async_bad_vapid_no_key(self):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
with self.assertRaises(WebPushException):
|
||||||
|
await webpush_async(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_claims={
|
||||||
|
"aud": "https://example.com",
|
||||||
|
"sub": "mailto:ops@example.com",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||||
|
async def test_webpush_async_bad_vapid_bad_return(self, mock_post):
|
||||||
|
mock_post.return_value.status = 410
|
||||||
|
mock_post.return_value.reason = "Gone"
|
||||||
|
mock_post.return_value.text = AsyncMock(return_value="Subscription expired")
|
||||||
|
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
data = "Mary had a little lamb"
|
||||||
|
with self.assertRaises(WebPushException):
|
||||||
|
await webpush_async(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_claims={
|
||||||
|
"aud": "https://example.com",
|
||||||
|
"sub": "mailto:ops@example.com",
|
||||||
|
},
|
||||||
|
vapid_private_key=self.vapid_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||||
|
async def test_webpush_async_timeout(self, mock_post):
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.text = AsyncMock(return_value="")
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
await webpush_async(subscription_info, timeout=10.001)
|
||||||
|
assert mock_post.call_args[1].get("timeout") == 10.001
|
||||||
|
|
||||||
|
async def test_webpush_async_as_curl(self):
|
||||||
|
subscription_info = self._gen_subscription_info()
|
||||||
|
result = await webpush_async(
|
||||||
|
subscription_info,
|
||||||
|
data="Mary had a little lamb",
|
||||||
|
vapid_claims={
|
||||||
|
"aud": "https://example.com",
|
||||||
|
"sub": "mailto:ops@example.com",
|
||||||
|
},
|
||||||
|
vapid_private_key=self.vapid_key,
|
||||||
|
curl=True,
|
||||||
|
)
|
||||||
|
result = cast(str, result)
|
||||||
|
for s in [
|
||||||
|
"curl -vX POST https://example.com",
|
||||||
|
'-H "content-encoding: aes128gcm"',
|
||||||
|
'-H "authorization: vapid ',
|
||||||
|
'-H "ttl: 0"',
|
||||||
|
'-H "content-length:',
|
||||||
|
]:
|
||||||
|
assert s in result, "missing: {}".format(s)
|
||||||
|
|
||||||
|
|
||||||
|
class WebpushExceptionTestCase(unittest.TestCase):
|
||||||
|
def test_exception(self):
|
||||||
|
from requests import Response
|
||||||
|
|
||||||
|
exp = WebPushException("foo")
|
||||||
|
assert "{}".format(exp) == "WebPushException: foo"
|
||||||
|
# Really should try to load the response to verify, but this mock
|
||||||
|
# covers what we need.
|
||||||
|
response = Mock(spec=Response)
|
||||||
|
response.text = (
|
||||||
|
'{"code": 401, "errno": 109, "error": '
|
||||||
|
'"Unauthorized", "more_info": "http://'
|
||||||
|
"autopush.readthedocs.io/en/latest/htt"
|
||||||
|
'p.html#error-codes", "message": "Requ'
|
||||||
|
"est did not validate missing authoriz"
|
||||||
|
'ation header"}'
|
||||||
|
)
|
||||||
|
response.json.return_value = json.loads(response.text)
|
||||||
|
response.status_code = 401
|
||||||
|
response.reason = "Unauthorized"
|
||||||
|
exp = WebPushException("foo", response)
|
||||||
|
assert "{}".format(exp) == "WebPushException: foo, Response {}".format(
|
||||||
|
response.text
|
||||||
|
)
|
||||||
|
assert "{}".format(exp.response), "<Response [401]>"
|
||||||
|
assert cast(requests.Response, exp.response).json().get("errno") == 109
|
||||||
|
exp = WebPushException("foo", [1, 2, 3])
|
||||||
|
assert "{}".format(exp) == "WebPushException: foo, Response [1, 2, 3]"
|
||||||
@@ -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()
|
||||||
+275
-43
@@ -5,6 +5,11 @@ sicherstellen, dass eine Antwort richtig zerlegt wird und dass die Faelle
|
|||||||
nicht abstuerzen, die im Betrieb sicher vorkommen: fehlende Teilbereiche,
|
nicht abstuerzen, die im Betrieb sicher vorkommen: fehlende Teilbereiche,
|
||||||
UNKNOWN statt eines Werts, Fahrzeug in Bewegung, leere Antwort.
|
UNKNOWN statt eines Werts, Fahrzeug in Bewegung, leere Antwort.
|
||||||
|
|
||||||
|
Dazu die Buchfuehrung ueber das Kontingent, die sich sonst nur im Betrieb
|
||||||
|
zeigen wuerde - und dort erst als 429: dass es eingehalten wird, dass die
|
||||||
|
Schluessel reihum drankommen und dass ein abgewiesener die anderen nicht
|
||||||
|
mitnimmt. Abschnitt 13 spielt dafuer einen ganzen Tag Dauerladen durch.
|
||||||
|
|
||||||
python3 skoda_test.py
|
python3 skoda_test.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -12,6 +17,11 @@ import datetime
|
|||||||
import time
|
import time
|
||||||
import gatherSkodaData as g
|
import gatherSkodaData as g
|
||||||
|
|
||||||
|
# Nie in die echte Datenbank: gatherData() stoesst beim Laden das Schreiben
|
||||||
|
# des Wallbox-Verlaufs an, und diese Pruefung laeuft auch auf der NAS.
|
||||||
|
geschrieben = []
|
||||||
|
g._ladepunkteSchreiben = lambda punkte: geschrieben.extend(punkte)
|
||||||
|
|
||||||
|
|
||||||
fehler = []
|
fehler = []
|
||||||
|
|
||||||
@@ -23,6 +33,25 @@ def frisch():
|
|||||||
g.ret = g.SkodaData()
|
g.ret = g.SkodaData()
|
||||||
return g.ret
|
return g.ret
|
||||||
|
|
||||||
|
_NAMEN = ("API_KEY", "API_KEY2", "API_KEY3")
|
||||||
|
|
||||||
|
def schluessel(anzahl, eigenerCmdKey=False):
|
||||||
|
"""So viele Schluessel setzen, als staenden sie in skoda.conf.
|
||||||
|
|
||||||
|
Ohne Datei und ohne Netz. Auch die gemeinsame Stundenbilanz wird geleert,
|
||||||
|
damit keine Probe die der vorigen erbt.
|
||||||
|
"""
|
||||||
|
g._schluessel[:] = []
|
||||||
|
werte = dict((_NAMEN[i], "schluessel"+str(i)) for i in range(anzahl))
|
||||||
|
if eigenerCmdKey:
|
||||||
|
werte["CMD_API_KEY"] = "steuerung"
|
||||||
|
g._schluesselUebernehmen(werte)
|
||||||
|
g._st["eigenerCmdKey"] = eigenerCmdKey
|
||||||
|
g._st["zuletzt"] = -1
|
||||||
|
g._st["sperre"] = 0.0
|
||||||
|
g._verbrauch.clear()
|
||||||
|
return g._schluessel
|
||||||
|
|
||||||
|
|
||||||
# --- 1. Vollstaendige Antwort ---------------------------------------------
|
# --- 1. Vollstaendige Antwort ---------------------------------------------
|
||||||
r = frisch()
|
r = frisch()
|
||||||
@@ -112,33 +141,55 @@ pruefe("gesteckt behaelt den Wert", r.chgKw, 10.8)
|
|||||||
|
|
||||||
# --- 9. Abrufintervall folgt der Lage ------------------------------------
|
# --- 9. Abrufintervall folgt der Lage ------------------------------------
|
||||||
r = frisch()
|
r = frisch()
|
||||||
|
schluessel(1)
|
||||||
g._st["letzteAend"] = 0.0
|
g._st["letzteAend"] = 0.0
|
||||||
g._haus["wbPlug"] = False; g._haus["wbKw"] = 0.0
|
g._haus["wbPlug"] = False; g._haus["wbKw"] = 0.0
|
||||||
r.chgState = "CHARGING"
|
r.chgState = "CHARGING"
|
||||||
pruefe("laedt", g._intervall(), g._I_LADEN)
|
pruefe("laedt", g._intervall(), float(g._I_LADEN))
|
||||||
r.chgState = ""
|
r.chgState = ""
|
||||||
g._haus["wbPlug"] = True; g._haus["wbKw"] = 7.4
|
g._haus["wbPlug"] = True; g._haus["wbKw"] = 7.4
|
||||||
pruefe("Wallbox liefert", g._intervall(), g._I_LADEN)
|
pruefe("Wallbox liefert", g._intervall(), float(g._I_LADEN))
|
||||||
g._haus["wbKw"] = 0.0
|
g._haus["wbKw"] = 0.0
|
||||||
pruefe("gesteckt, laedt nicht", g._intervall(), g._I_GESTECKT)
|
pruefe("gesteckt, laedt nicht", g._intervall(), g._I_GESTECKT)
|
||||||
g._haus["wbPlug"] = False
|
g._haus["wbPlug"] = False
|
||||||
r.parkState = "IN_MOTION"
|
r.parkState = "IN_MOTION"
|
||||||
pruefe("unterwegs", g._intervall(), g._I_FAHRT)
|
pruefe("unterwegs", g._intervall(), float(g._I_FAHRT))
|
||||||
r.parkState = "PARKED"
|
r.parkState = "PARKED"
|
||||||
pruefe("lange nichts", g._intervall(), g._I_RUHE)
|
pruefe("lange nichts", g._intervall(), g._I_RUHE)
|
||||||
r.error = 3
|
r.error = 3
|
||||||
pruefe("nach Fehlern zurueckhaltend", g._intervall(), 240.0)
|
pruefe("nach Fehlern zurueckhaltend", g._intervall(), 240.0)
|
||||||
r.error = 0
|
r.error = 0
|
||||||
|
|
||||||
|
# Mehr Schluessel duerfen den Takt NICHT beschleunigen: das Kontingent gilt
|
||||||
|
# fuer alle zusammen (gemessen, siehe _LIMIT). Wer das hier wieder staffelt,
|
||||||
|
# baut 429-Antworten ein.
|
||||||
|
for lage, feld, wert in (("beim Laden", "chgState", "CHARGING"),
|
||||||
|
("unterwegs", "parkState", "IN_MOTION"),
|
||||||
|
("beim Parken", "parkState", "PARKED")):
|
||||||
|
r.chgState = ""
|
||||||
|
r.parkState = ""
|
||||||
|
setattr(r, feld, wert)
|
||||||
|
werte = []
|
||||||
|
for anzahl in (1, 2, 3):
|
||||||
|
schluessel(anzahl)
|
||||||
|
werte.append(g._intervall())
|
||||||
|
pruefe("Takt "+lage+" haengt nicht an der Zahl der Schluessel",
|
||||||
|
len(set(werte)), 1)
|
||||||
|
r.chgState = "CHARGING"
|
||||||
|
r.parkState = ""
|
||||||
|
schluessel(3)
|
||||||
|
pruefe("und beim Laden sind es 260 s", g._intervall(), 260.0)
|
||||||
|
|
||||||
# --- 10. Kontingent weitet den Abstand ------------------------------------
|
# --- 10. Kontingent weitet den Abstand ------------------------------------
|
||||||
r = frisch()
|
r = frisch()
|
||||||
|
sch = schluessel(1)[0]
|
||||||
pruefe("100 Anfragen in 60 s", round(g._kontingent(
|
pruefe("100 Anfragen in 60 s", round(g._kontingent(
|
||||||
{"RateLimit-Remaining":"100","RateLimit-Reset":"60"}),3), 0.72)
|
{"RateLimit-Remaining":"100","RateLimit-Reset":"60"}, sch),3), 0.72)
|
||||||
pruefe("2 Anfragen in 600 s", round(g._kontingent(
|
pruefe("2 Anfragen in 600 s", round(g._kontingent(
|
||||||
{"RateLimit-Remaining":"2","RateLimit-Reset":"600"}),1), 360.0)
|
{"RateLimit-Remaining":"2","RateLimit-Reset":"600"}, sch),1), 360.0)
|
||||||
pruefe("nichts mehr frei", g._kontingent(
|
pruefe("nichts mehr frei", g._kontingent(
|
||||||
{"RateLimit-Remaining":"0","RateLimit-Reset":"120"}), 125.0)
|
{"RateLimit-Remaining":"0","RateLimit-Reset":"120"}, sch), 125.0)
|
||||||
pruefe("ohne Header kein Einfluss", g._kontingent({}), 0.0)
|
pruefe("ohne Header kein Einfluss", g._kontingent({}, sch), 0.0)
|
||||||
|
|
||||||
# --- 11. Nur echte Aenderungen erzeugen eine Zeile ------------------------
|
# --- 11. Nur echte Aenderungen erzeugen eine Zeile ------------------------
|
||||||
r = frisch()
|
r = frisch()
|
||||||
@@ -185,51 +236,134 @@ g.uebernehmen({"vehicle":{"charging":{"status":{"battery":
|
|||||||
pruefe("Stromer ohne Tank", (r.carType, r.fuelPct), ("", 0))
|
pruefe("Stromer ohne Tank", (r.carType, r.fuelPct), ("", 0))
|
||||||
|
|
||||||
# --- 13. Das Kontingent wird unter Dauerlast eingehalten ------------------
|
# --- 13. Das Kontingent wird unter Dauerlast eingehalten ------------------
|
||||||
# Die Doku nennt 20 Anfragen je Stunde und Schluessel. Beim Laden greift das
|
# Die Doku nennt 20 Anfragen je Stunde *und Schluessel*. Beim Laden greift das
|
||||||
# kuerzeste Intervall - genau dort muss die Sperre halten. Simuliert wird ein
|
# kuerzeste Intervall - genau dort muss die Sperre halten. Simuliert wird ein
|
||||||
# ganzer Tag Dauerladen, ohne echte Zeit und ohne Netz.
|
# ganzer Tag Dauerladen, ohne echte Zeit und ohne Netz.
|
||||||
g._verbrauch.clear()
|
|
||||||
zeitpunkte = []
|
def dauerlast(anzahl, eigenerCmdKey=False):
|
||||||
t = 0.0
|
"""Einen Tag Dauerladen durchspielen. Liefert Zeitpunkte je Schluessel."""
|
||||||
while t < 24*3600:
|
schluessel(anzahl, eigenerCmdKey)
|
||||||
zeitpunkte.append(t)
|
takt = float(max(g._I_MIN, g._I_LADEN))
|
||||||
g._gezaehlt(t)
|
punkte = dict((s.name, []) for s in g._schluessel)
|
||||||
t += max(g._I_MIN, g._I_LADEN, g._budgetSperre(t))
|
t, gebremst = 0.0, 0
|
||||||
schlimmste = 0
|
while t < 24*3600:
|
||||||
for i, t0 in enumerate(zeitpunkte):
|
sch = g._waehlen(t) if g._budgetSperre(t) <= 0.0 else None
|
||||||
in_fenster = sum(1 for t1 in zeitpunkte[i:] if t1 - t0 < 3600)
|
if sch is None:
|
||||||
schlimmste = max(schlimmste, in_fenster)
|
# Nichts frei - genau das tut _abrufen() dann auch.
|
||||||
|
gebremst += 1
|
||||||
|
t += max(5.0, g._fruehesteFreigabe(t))
|
||||||
|
continue
|
||||||
|
g._gezaehlt(t)
|
||||||
|
punkte[sch.name].append(t)
|
||||||
|
t += takt
|
||||||
|
return punkte, gebremst
|
||||||
|
|
||||||
|
def schlimmstesFenster(zeiten):
|
||||||
|
"""Die meisten Anfragen, die in irgendeine Stunde fallen."""
|
||||||
|
return max([len([x for x in zeiten if t0 <= x < t0+3600])
|
||||||
|
for t0 in zeiten] or [0])
|
||||||
|
|
||||||
|
def alleZeiten(punkte):
|
||||||
|
zeiten = []
|
||||||
|
for z in punkte.values():
|
||||||
|
zeiten.extend(z)
|
||||||
|
return sorted(zeiten)
|
||||||
|
|
||||||
|
punkte, gebremst = dauerlast(1)
|
||||||
|
schlimmste = schlimmstesFenster(alleZeiten(punkte))
|
||||||
pruefe("nie mehr als das Limit je Stunde", schlimmste <= g._LIMIT, True)
|
pruefe("nie mehr als das Limit je Stunde", schlimmste <= g._LIMIT, True)
|
||||||
pruefe("das eigene Budget wird eingehalten", schlimmste <= g._BUDGET_GETEILT, True)
|
pruefe("das eigene Budget wird eingehalten", schlimmste <= g._BUDGET, True)
|
||||||
pruefe("und auch ausgenutzt", schlimmste >= g._BUDGET_GETEILT - 3, True)
|
pruefe("und auch ausgenutzt", schlimmste >= g._BUDGET - 1, True)
|
||||||
|
pruefe("der 260-s-Takt passt ohne Bremse", gebremst, 0)
|
||||||
|
|
||||||
# Mit eigenem Schluessel fuer die Steuerung darf mehr genutzt werden.
|
# Der entscheidende Punkt: drei Schluessel duerfen NICHT mehr Anfragen
|
||||||
g._st["eigenerCmdKey"] = True
|
# ergeben. Sie teilen sich einen Zaehler - wer hier mehr misst, hat die
|
||||||
g._verbrauch.clear()
|
# Staffel wieder eingebaut und wird im Betrieb mit 429 bezahlt.
|
||||||
for i in range(g._BUDGET_GETEILT):
|
punkte3, gebremst3 = dauerlast(3)
|
||||||
g._gezaehlt(2000.0 + i)
|
pruefe("drei Schluessel halten dasselbe Limit",
|
||||||
pruefe("geteiltes Budget reicht dem Modul allein nicht",
|
schlimmstesFenster(alleZeiten(punkte3)) <= g._BUDGET, True)
|
||||||
g._budgetSperre(2000.0 + g._BUDGET_GETEILT), 0.0)
|
pruefe("und bringen genauso viele Abrufe wie einer",
|
||||||
g._verbrauch.clear()
|
len(alleZeiten(punkte3)), len(alleZeiten(punkte)))
|
||||||
for i in range(g._BUDGET_ALLEIN):
|
for name, zeiten in punkte3.items():
|
||||||
g._gezaehlt(2000.0 + i)
|
pruefe(name+" kommt reihum dran", len(zeiten) > 0, True)
|
||||||
pruefe("eigenes Budget greift bei 18", g._budgetSperre(2000.0 + g._BUDGET_ALLEIN) > 3500, True)
|
|
||||||
g._st["eigenerCmdKey"] = False
|
|
||||||
g._verbrauch.clear()
|
|
||||||
|
|
||||||
# Ein einzelner Anstoss ausser der Reihe darf das Budget nicht sprengen.
|
# Ein eigener Schluessel fuer die Steuerung aendert am Kontingent nichts
|
||||||
g._verbrauch.clear()
|
# mehr - er trennt nur, wer womit fragt.
|
||||||
for i in range(g._BUDGET_GETEILT):
|
punkte, _ = dauerlast(1, eigenerCmdKey=True)
|
||||||
g._gezaehlt(1000.0 + i)
|
pruefe("eigener Befehlsschluessel gibt dem Abruf nicht mehr",
|
||||||
pruefe("Budget voll: Sperre greift", g._budgetSperre(1000.0+g._BUDGET_GETEILT) > 3500, True)
|
schlimmstesFenster(alleZeiten(punkte)) <= g._BUDGET, True)
|
||||||
g._verbrauch.clear()
|
|
||||||
pruefe("Budget leer: keine Sperre", g._budgetSperre(0.0), 0.0)
|
# Reihum: jeder ist einmal dran, bevor einer ein zweites Mal drankommt. Nicht
|
||||||
|
# wegen des Kontingents - das ist gemeinsam -, sondern damit ein abgelaufener
|
||||||
|
# Schluessel auffaellt, statt jahrelang unbenutzt danebenzuliegen.
|
||||||
|
schluessel(3)
|
||||||
|
folge = []
|
||||||
|
for i in range(9):
|
||||||
|
sch = g._waehlen(i*300.0)
|
||||||
|
g._gezaehlt(i*300.0)
|
||||||
|
folge.append(sch.name)
|
||||||
|
pruefe("die Schluessel wechseln sich ab", folge, list(_NAMEN)*3)
|
||||||
|
|
||||||
|
# Ein abgewiesener Schluessel (401, 403) sperrt sich selbst - die uebrigen
|
||||||
|
# muessen weiterlaufen, sonst nimmt ein abgelaufener das ganze Fahrzeug mit.
|
||||||
|
schluessel(3)
|
||||||
|
g._schluessel[0].sperre = 1e9
|
||||||
|
pruefe("gesperrter wird uebersprungen",
|
||||||
|
[g._waehlen(float(i)).name for i in range(4)],
|
||||||
|
["API_KEY2", "API_KEY3"]*2)
|
||||||
|
for s in g._schluessel:
|
||||||
|
s.sperre = 500.0
|
||||||
|
pruefe("alle gesperrt: kein Abruf", g._waehlen(0.0), None)
|
||||||
|
pruefe("dafuer ein sauberer Wiedervorlagetermin",
|
||||||
|
g._fruehesteFreigabe(0.0), 500.0)
|
||||||
|
|
||||||
|
# Ein 429 dagegen gilt fuer alle - ein anderer Schluessel hilft da nicht.
|
||||||
|
schluessel(3)
|
||||||
|
g._st["sperre"] = 400.0
|
||||||
|
pruefe("429 haelt alle zurueck", g._budgetSperre(0.0), 400.0)
|
||||||
|
pruefe("und der Schluessel selbst bleibt unbescholten",
|
||||||
|
g._schluessel[0].sperre, 0.0)
|
||||||
|
|
||||||
|
# Wird ein Schluessel getauscht, behalten die anderen, was ueber sie bekannt
|
||||||
|
# ist - eine Sperre aus einem 401 und das Ablaufdatum. Sonst begaenne nach
|
||||||
|
# jedem Anfassen der Datei das Ausprobieren von vorn.
|
||||||
|
schluessel(3)
|
||||||
|
g._schluessel[0].sperre = 4711.0
|
||||||
|
g._schluessel[0].ablauf = datetime.datetime(2027, 5, 5)
|
||||||
|
g._schluesselUebernehmen({"API_KEY":"schluessel0", "API_KEY2":"schluessel1",
|
||||||
|
"API_KEY3":"frisch geholt"})
|
||||||
|
pruefe("Sperre ueberlebt den Tausch", g._schluessel[0].sperre, 4711.0)
|
||||||
|
pruefe("Ablaufdatum auch", g._schluessel[0].ablauf, datetime.datetime(2027, 5, 5))
|
||||||
|
pruefe("der neue faengt bei null an", g._schluessel[2].sperre, 0.0)
|
||||||
|
g._schluessel[:] = []
|
||||||
|
g._schluesselUebernehmen({"API_KEY":"x", "API_KEY2":"x"})
|
||||||
|
pruefe("derselbe Schluessel zweimal zaehlt einfach", len(g._schluessel), 1)
|
||||||
|
|
||||||
# --- 14. Header mit den echten Zahlen -------------------------------------
|
# --- 14. Header mit den echten Zahlen -------------------------------------
|
||||||
g.ret = g.SkodaData()
|
g.ret = g.SkodaData()
|
||||||
|
sch = schluessel(1)[0]
|
||||||
pruefe("19 Anfragen in 3400 s", round(g._kontingent(
|
pruefe("19 Anfragen in 3400 s", round(g._kontingent(
|
||||||
{"RateLimit-Remaining":"19","RateLimit-Reset":"3400"}),0), 215.0)
|
{"RateLimit-Remaining":"19","RateLimit-Reset":"3400"}, sch),0), 215.0)
|
||||||
pruefe("Restkontingent gemerkt", g.ret.rlRemaining, 19)
|
pruefe("Restkontingent gemerkt", sch.rest, 19)
|
||||||
|
|
||||||
|
# In die Historie geht die juengste Auskunft, nicht die Summe: die Schluessel
|
||||||
|
# teilen sich einen Zaehler und melden daher alle dieselbe Zahl.
|
||||||
|
drei = schluessel(3)
|
||||||
|
pruefe("ohne Auskunft bleibt es unbekannt", g._restGesamt(), -1)
|
||||||
|
drei[0].rest, drei[1].rest = 5, 7
|
||||||
|
g._st["zuletzt"] = 1
|
||||||
|
pruefe("der zuletzt benutzte Schluessel zaehlt", g._restGesamt(), 7)
|
||||||
|
drei[0].ablauf = datetime.datetime(2027, 1, 1)
|
||||||
|
drei[1].ablauf = datetime.datetime(2026, 10, 1)
|
||||||
|
pruefe("es zaehlt der Schluessel, der zuerst ablaeuft",
|
||||||
|
g._fruehesterAblauf(), datetime.datetime(2026, 10, 1))
|
||||||
|
|
||||||
|
# Abschnitt 15 soll wieder ohne Konfiguration laufen: keine Schluessel, keine
|
||||||
|
# Datei - sonst wuerde gatherData() auf der NAS die echte skoda.conf finden
|
||||||
|
# und einen Abruf anstossen, den diese Pruefung nicht will.
|
||||||
|
g._schluessel[:] = []
|
||||||
|
g._KONFIG = "/gibt/es/nicht/skoda.conf"
|
||||||
|
g._st["konfMtime"] = 0.0
|
||||||
|
|
||||||
# --- 15. Ladebeginn und Ladeende stossen einen Abruf an -------------------
|
# --- 15. Ladebeginn und Ladeende stossen einen Abruf an -------------------
|
||||||
# Bei 20 Anfragen je Stunde sind das die beiden wertvollsten Augenblicke:
|
# Bei 20 Anfragen je Stunde sind das die beiden wertvollsten Augenblicke:
|
||||||
@@ -248,6 +382,104 @@ g._st["naechster"] = time.time() + 9999
|
|||||||
asyncio.get_event_loop().run_until_complete(g.gatherData(wbKw=0.0, wbPlug=True))
|
asyncio.get_event_loop().run_until_complete(g.gatherData(wbKw=0.0, wbPlug=True))
|
||||||
pruefe("ohne Flanke kein Anstoss", g._st["naechster"] > time.time() + 1000, True)
|
pruefe("ohne Flanke kein Anstoss", g._st["naechster"] > time.time() + 1000, True)
|
||||||
|
|
||||||
|
# --- 16. Ein Befehl aus der Weboberflaeche zieht den Abruf vor ------------
|
||||||
|
# ajax/skodaCmd.php beruehrt nach einem angenommenen Befehl eine Datei, deren
|
||||||
|
# Aenderungszeit der Zeitpunkt des Befehls ist. Hier wird sie untergeschoben,
|
||||||
|
# statt den Webserver zu bemuehen.
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
anstoss = os.path.join(tempfile.gettempdir(), "skoda_abruf_pruefung")
|
||||||
|
g._ANSTOSS_ORTE = (anstoss,)
|
||||||
|
if os.path.exists(anstoss):
|
||||||
|
os.remove(anstoss)
|
||||||
|
|
||||||
|
g._st["anstoss"] = None
|
||||||
|
g._st["naechster"] = time.time() + 9999
|
||||||
|
g._anstossPruefen()
|
||||||
|
pruefe("ohne Datei kein Anstoss", g._st["naechster"] > time.time()+1000, True)
|
||||||
|
|
||||||
|
# Beim ersten Blick nach dem Start wird nur gemerkt. Der letzte Befehl kann
|
||||||
|
# Tage her sein - dafuer einen Abruf auszugeben, waere Verschwendung.
|
||||||
|
open(anstoss, "w").close()
|
||||||
|
frueher = time.time() - 86400
|
||||||
|
os.utime(anstoss, (frueher, frueher))
|
||||||
|
g._anstossPruefen()
|
||||||
|
pruefe("alter Befehl wird nicht nachgeholt", g._st["naechster"] > time.time()+1000, True)
|
||||||
|
|
||||||
|
# Jetzt der Befehl.
|
||||||
|
befehl = time.time()
|
||||||
|
os.utime(anstoss, (befehl, befehl))
|
||||||
|
g._anstossPruefen()
|
||||||
|
pruefe("Abruf wird vorgezogen",
|
||||||
|
abs(g._st["naechster"] - (befehl + g._NACH_BEFEHL)) < 1.0, True)
|
||||||
|
pruefe("und zwar nicht sofort", g._st["naechster"] > befehl + 20, True)
|
||||||
|
|
||||||
|
# Ohne neuen Befehl bleibt es beim Plan - sonst haenge der Abruf fest.
|
||||||
|
g._st["naechster"] = befehl + 5000
|
||||||
|
g._anstossPruefen()
|
||||||
|
pruefe("ohne neuen Befehl kein zweiter Anstoss", g._st["naechster"], befehl + 5000)
|
||||||
|
|
||||||
|
# Ein frueher geplanter Abruf wird nicht nach hinten geschoben.
|
||||||
|
g._st["naechster"] = befehl + 1
|
||||||
|
spaeter = befehl + 10
|
||||||
|
os.utime(anstoss, (spaeter, spaeter))
|
||||||
|
g._anstossPruefen()
|
||||||
|
pruefe("nur vorziehen, nie verzoegern", g._st["naechster"], befehl + 1)
|
||||||
|
os.remove(anstoss)
|
||||||
|
|
||||||
|
# --- 17. Wallbox-Verlauf waehrend einer Ladung ----------------------------
|
||||||
|
# Punkte im Minutentakt, solange Strom fliesst, einer sofort am Anfang und
|
||||||
|
# einer mit 0 kW am Ende. Die Zeit wird von Hand gestellt.
|
||||||
|
for st in g._ladepunkt.values():
|
||||||
|
st["zuletzt"] = 0.0
|
||||||
|
st["aktiv"] = False
|
||||||
|
|
||||||
|
def haus(kw, wh, ogkw=0.0, ogwh=829520):
|
||||||
|
g._haus.update({"wbKw": kw, "wbWh": wh, "wbogKw": ogkw, "wbogWh": ogwh,
|
||||||
|
"pvKw": 8.0, "gridKw": -1.5})
|
||||||
|
|
||||||
|
t0 = 1757750400.0 # 13.09.2026, 10:00 Ortszeit ungefaehr
|
||||||
|
haus(0.0, 7751734)
|
||||||
|
pruefe("ruhende Wallbox schreibt nichts", g._ladepunkteSammeln(t0), [])
|
||||||
|
|
||||||
|
haus(4.94, 7751740)
|
||||||
|
p = g._ladepunkteSammeln(t0 + 3)
|
||||||
|
pruefe("Beginn sofort festgehalten", len(p), 1)
|
||||||
|
pruefe("mit Wallbox, Leistung und Zaehler", (p[0][1], p[0][2], p[0][3]),
|
||||||
|
("carport", 4.94, 7751740))
|
||||||
|
|
||||||
|
pruefe("drei Sekunden spaeter nichts", g._ladepunkteSammeln(t0 + 6), [])
|
||||||
|
pruefe("59 Sekunden nach dem Beginn noch nichts", g._ladepunkteSammeln(t0 + 62), [])
|
||||||
|
pruefe("nach 60 Sekunden der naechste Punkt", len(g._ladepunkteSammeln(t0 + 63)), 1)
|
||||||
|
|
||||||
|
haus(0.0, 7776854)
|
||||||
|
p = g._ladepunkteSammeln(t0 + 70)
|
||||||
|
pruefe("Ende mit 0 kW", [(x[1], x[2], x[3]) for x in p], [("carport", 0.0, 7776854)])
|
||||||
|
pruefe("danach wieder Ruhe", g._ladepunkteSammeln(t0 + 200), [])
|
||||||
|
|
||||||
|
# Ein Zaehlerstand 0 ist ein ausgefallener Abruf, kein Stand.
|
||||||
|
haus(11.0, 0)
|
||||||
|
p = g._ladepunkteSammeln(t0 + 300)
|
||||||
|
pruefe("Zaehler 0 wird NULL", p[0][3], None)
|
||||||
|
|
||||||
|
# Beide Wallboxen unabhaengig voneinander.
|
||||||
|
haus(0.0, 7776900, ogkw=7.2, ogwh=829600)
|
||||||
|
p = g._ladepunkteSammeln(t0 + 400)
|
||||||
|
pruefe("Carport endet, Obergeschoss beginnt",
|
||||||
|
sorted((x[1], x[2]) for x in p), [("carport", 0.0), ("og", 7.2)])
|
||||||
|
|
||||||
|
# Und gatherData() stoesst das Schreiben tatsaechlich an.
|
||||||
|
for st in g._ladepunkt.values():
|
||||||
|
st["zuletzt"] = 0.0
|
||||||
|
st["aktiv"] = False
|
||||||
|
del geschrieben[:]
|
||||||
|
g._st["naechster"] = time.time() + 9999
|
||||||
|
schleife = asyncio.get_event_loop()
|
||||||
|
schleife.run_until_complete(g.gatherData(wbKw=10.8, wbPlug=True, wbWh=7760000))
|
||||||
|
schleife.run_until_complete(asyncio.sleep(0.2)) # dem Hintergrund Zeit geben
|
||||||
|
pruefe("gatherData schreibt den Punkt", [(x[1], x[2]) for x in geschrieben],
|
||||||
|
[("carport", 10.8)])
|
||||||
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fehler:
|
if fehler:
|
||||||
|
|||||||
+16
-3
@@ -16,6 +16,7 @@ import gatherWaterData
|
|||||||
import gatherHeaterData
|
import gatherHeaterData
|
||||||
import gatherShellyEM3DataEG
|
import gatherShellyEM3DataEG
|
||||||
import gatherShellyEM3DataUG
|
import gatherShellyEM3DataUG
|
||||||
|
import gatherBYDData
|
||||||
import datetime
|
import datetime
|
||||||
from dateutil import tz
|
from dateutil import tz
|
||||||
from suntime import Sun, SunTimeException
|
from suntime import Sun, SunTimeException
|
||||||
@@ -156,6 +157,7 @@ rtData = {"P_Load":0.0,
|
|||||||
"waterHeight":0,
|
"waterHeight":0,
|
||||||
"waterTemp": 0.0,
|
"waterTemp": 0.0,
|
||||||
"inverters":[InvData(),InvData(),InvData(),InvData(),InvData(),InvData(),InvData(),InvData()],
|
"inverters":[InvData(),InvData(),InvData(),InvData(),InvData(),InvData(),InvData(),InvData()],
|
||||||
|
"byd":gatherBYDData.BYDData(), # direkt aus der BMU, siehe gatherBYDData.py
|
||||||
"P_AC": 0.0
|
"P_AC": 0.0
|
||||||
}
|
}
|
||||||
avgData = dict(rtData)
|
avgData = dict(rtData)
|
||||||
@@ -268,7 +270,7 @@ async def repeat(interval):
|
|||||||
rtData["inverters"][0].p_PV.append(0)
|
rtData["inverters"][0].p_PV.append(0)
|
||||||
while True:
|
while True:
|
||||||
|
|
||||||
inv,dtubi,dtu,heat,chrg,pEG,pUG,skoda,slp = await asyncio.gather(
|
inv,dtubi,dtu,heat,chrg,pEG,pUG,skoda,byd,slp = await asyncio.gather(
|
||||||
gatherModbusData.get_runtime_data(estProduction, sunset),
|
gatherModbusData.get_runtime_data(estProduction, sunset),
|
||||||
gatherDTUBIData.gatherData(),
|
gatherDTUBIData.gatherData(),
|
||||||
gatherOpenDTUData.gatherData(),
|
gatherOpenDTUData.gatherData(),
|
||||||
@@ -280,8 +282,10 @@ async def repeat(interval):
|
|||||||
rtData["evPowerOG"], rtData["evPlugOG"],
|
rtData["evPowerOG"], rtData["evPlugOG"],
|
||||||
rtData["P_PV"]/1000.0, rtData["P_Grid"]/1000.0,
|
rtData["P_PV"]/1000.0, rtData["P_Grid"]/1000.0,
|
||||||
rtData["wbWh"], rtData["wbogWh"]),
|
rtData["wbWh"], rtData["wbogWh"]),
|
||||||
|
gatherBYDData.gatherData(),
|
||||||
asyncio.sleep(interval),
|
asyncio.sleep(interval),
|
||||||
)
|
)
|
||||||
|
rtData["byd"] = byd
|
||||||
rtData["evSOC"] = skoda.soc
|
rtData["evSOC"] = skoda.soc
|
||||||
rtData["evRange"] = skoda.range_km
|
rtData["evRange"] = skoda.range_km
|
||||||
rtData["evRemChrgTime"] = skoda.chgRemMin
|
rtData["evRemChrgTime"] = skoda.chgRemMin
|
||||||
@@ -400,7 +404,9 @@ async def repeat(interval):
|
|||||||
rtData["P_WR"] = inv.p_wr
|
rtData["P_WR"] = inv.p_wr
|
||||||
rtData["ibatt"] = inv.ibatt
|
rtData["ibatt"] = inv.ibatt
|
||||||
rtData["ubatt"] = inv.ubatt
|
rtData["ubatt"] = inv.ubatt
|
||||||
rtData["tbatt"] = 0
|
# Der Gen24 kennt keine Batterietemperatur; die BMU schon. Die
|
||||||
|
# Spalte battTemp in EnergyFlow stand bisher immer auf 0.
|
||||||
|
rtData["tbatt"] = byd.tMax if byd.ok else 0
|
||||||
rtData["crgMaxPct"] = inv.crgMaxPct
|
rtData["crgMaxPct"] = inv.crgMaxPct
|
||||||
#rtData["pwrMaxPct"] = inv.pwrMaxPct
|
#rtData["pwrMaxPct"] = inv.pwrMaxPct
|
||||||
if((rtData["P_PV"]+rtData["P_Akku"]) != 0):
|
if((rtData["P_PV"]+rtData["P_Akku"]) != 0):
|
||||||
@@ -438,7 +444,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
|
||||||
|
|||||||
@@ -62,3 +62,4 @@ starte() {
|
|||||||
starte "solarManager.py" "solarOutput.log"
|
starte "solarManager.py" "solarOutput.log"
|
||||||
starte "autoActions/autoaction_runner.py" "autoActions.log"
|
starte "autoActions/autoaction_runner.py" "autoActions.log"
|
||||||
starte "gatherRainData.py" "rainOutput.log"
|
starte "gatherRainData.py" "rainOutput.log"
|
||||||
|
starte "gatherForecastData.py" "forecastOutput.log"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Einmalig: das VAPID-Schluesselpaar fuer Web Push erzeugen.
|
||||||
|
|
||||||
|
Der private Schluessel bleibt auf der NAS (der Runner unterschreibt damit
|
||||||
|
jede Nachricht), der oeffentliche geht in den Browser - er steckt im Abo und
|
||||||
|
laesst sich nicht missbrauchen. Beide landen in push_vapid.json neben
|
||||||
|
skoda.conf: ausserhalb des Web-Roots, aber fuer die Weboberflaeche lesbar,
|
||||||
|
die den oeffentlichen Teil braucht.
|
||||||
|
|
||||||
|
Vorhandene Schluessel werden NICHT ueberschrieben - alle bestehenden Abos
|
||||||
|
haengen daran und waeren sonst wertlos.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ORDNER = "/volume1/homes/wagner/SolarManager"
|
||||||
|
ZIEL = os.path.join(ORDNER, "push_vapid.json")
|
||||||
|
sys.path.insert(0, ORDNER)
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
|
||||||
|
|
||||||
|
def b64(roh):
|
||||||
|
return base64.urlsafe_b64encode(roh).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
if os.path.exists(ZIEL):
|
||||||
|
with open(ZIEL) as f:
|
||||||
|
vorhanden = json.load(f)
|
||||||
|
print("existiert schon, unveraendert:", vorhanden.get("public_key", "")[:16] + "…")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
schluessel = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
privat = schluessel.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption()).decode("ascii")
|
||||||
|
oeffentlich = schluessel.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.X962,
|
||||||
|
format=serialization.PublicFormat.UncompressedPoint)
|
||||||
|
|
||||||
|
with open(ZIEL, "w") as f:
|
||||||
|
json.dump({
|
||||||
|
"public_key": b64(oeffentlich),
|
||||||
|
"private_key_pem": privat,
|
||||||
|
# Wen die Push-Dienste bei Problemen erreichen. Muss eine mailto- oder
|
||||||
|
# https-Adresse sein, sonst lehnen manche Dienste ab.
|
||||||
|
"subject": "mailto:m0w1337@gmail.com",
|
||||||
|
}, f, indent=2)
|
||||||
|
os.chmod(ZIEL, 0o644)
|
||||||
|
print("neu erzeugt:", b64(oeffentlich))
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Das Wetterarchiv rueckwirkend fuellen - einmalig, kein Dauerlaeufer.
|
||||||
|
|
||||||
|
python3 wetterarchiv_nachtragen.py [--von 2022-04-02] [--bis 2026-09-20]
|
||||||
|
|
||||||
|
gatherForecastData.py sammelt ab heute. Warten muss man deswegen nicht:
|
||||||
|
Open-Meteo hat neben der Vorhersage eine Archiv-Schnittstelle
|
||||||
|
(archive-api.open-meteo.com, aus den ERA5-Reanalysen), die dieselben
|
||||||
|
Stundenwerte rueckwirkend liefert - Temperatur, Wind, Niederschlag,
|
||||||
|
Bewoelkung und vor allem die Einstrahlung.
|
||||||
|
|
||||||
|
Wozu: EnergyFlow_hourly.pv_kwh haelt den gemessenen Ertrag je Stunde seit dem
|
||||||
|
02.04.2022. Das ist die eine Haelfte eines Datenpaares; die andere ist die
|
||||||
|
Einstrahlung derselben Stunde. Ist sie nachgetragen, laesst sich die
|
||||||
|
Korrelation sofort rechnen statt erst in einem Jahr - ueber alle
|
||||||
|
Jahreszeiten, mit Schnee, Dunst und Hochsommer.
|
||||||
|
|
||||||
|
Geschrieben wird in dieselbe Tabelle wie im laufenden Betrieb, mit
|
||||||
|
ist_vorhersage = 0: eine Analyse ist keine Vorhersage. Bereits vorhandene
|
||||||
|
Stunden werden ueberschrieben - die Analyse ist genauer als die Vorhersage,
|
||||||
|
die dort vielleicht noch steht. weatherForecastLog bleibt unberuehrt: dort
|
||||||
|
gehoert hinein, was *vorher* gesagt wurde, und das laesst sich nicht
|
||||||
|
nachtraegen.
|
||||||
|
|
||||||
|
Die Archiv-Schnittstelle hinkt dem heutigen Tag rund fuenf Tage hinterher.
|
||||||
|
Ohne --bis endet der Lauf deshalb eine Woche vor heute; was danach kommt,
|
||||||
|
hat der Sammler ohnehin schon.
|
||||||
|
|
||||||
|
Abgerufen wird jahresweise. Ein einzelner Aufruf ueber vier Jahre waere
|
||||||
|
mehrere Megabyte in einer Antwort, und faellt er in der Mitte aus, faengt
|
||||||
|
alles von vorn an.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
import mysql.connector as mc
|
||||||
|
import requests
|
||||||
|
|
||||||
|
import konfig
|
||||||
|
import gatherForecastData as vorhersage
|
||||||
|
|
||||||
|
ARCHIV_URL = "https://archive-api.open-meteo.com/v1/archive"
|
||||||
|
|
||||||
|
# Dieselben Felder wie im laufenden Betrieb, abzueglich derer, die das Archiv
|
||||||
|
# nicht kennt: precipitation_probability ist eine Eigenschaft der Vorhersage -
|
||||||
|
# im Nachhinein hat es geregnet oder nicht.
|
||||||
|
ARCHIVFELDER = [f for f in vorhersage.STUNDENFELDER
|
||||||
|
if f != "precipitation_probability"]
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
logging.getLogger("mysql.connector").setLevel(logging.WARNING)
|
||||||
|
logger = logging.getLogger("wetterarchiv")
|
||||||
|
|
||||||
|
|
||||||
|
def archiv_holen(von, bis, felder=None, zusatz=None):
|
||||||
|
p = {
|
||||||
|
"latitude": konfig.wert("vorhersage", "lat"),
|
||||||
|
"longitude": konfig.wert("vorhersage", "lon"),
|
||||||
|
"timezone": konfig.wert("vorhersage", "timezone", "Europe/Berlin"),
|
||||||
|
"start_date": von.isoformat(),
|
||||||
|
"end_date": bis.isoformat(),
|
||||||
|
"hourly": ",".join(felder if felder else ARCHIVFELDER),
|
||||||
|
}
|
||||||
|
if felder is None:
|
||||||
|
p["daily"] = ",".join(vorhersage.TAGESFELDER)
|
||||||
|
if zusatz:
|
||||||
|
p.update(zusatz)
|
||||||
|
antwort = requests.get(ARCHIV_URL, params=p, timeout=120)
|
||||||
|
antwort.raise_for_status()
|
||||||
|
return antwort.json()
|
||||||
|
|
||||||
|
|
||||||
|
def zeilen(daten, gelaufen):
|
||||||
|
"""
|
||||||
|
Wie vorhersage.stundenzeilen(), nur ohne Regenwahrscheinlichkeit und
|
||||||
|
immer als Analyse.
|
||||||
|
|
||||||
|
Absichtlich eine eigene Funktion und kein Schalter in der anderen: der
|
||||||
|
Sammler soll nicht bei jedem Takt eine Bedingung auswerten, die nur
|
||||||
|
dieses Skript braucht.
|
||||||
|
"""
|
||||||
|
h = daten["hourly"]
|
||||||
|
aus = []
|
||||||
|
for i, t in enumerate(h["time"]):
|
||||||
|
w = lambda name: h[name][i] if name in h else None
|
||||||
|
druck = w("pressure_msl")
|
||||||
|
aus.append((
|
||||||
|
vorhersage._zeit(t),
|
||||||
|
w("temperature_2m"), w("apparent_temperature"),
|
||||||
|
w("relativehumidity_2m"), w("dewpoint_2m"),
|
||||||
|
None if druck is None else round(druck),
|
||||||
|
w("precipitation"), None, w("snowfall"),
|
||||||
|
w("weathercode"),
|
||||||
|
w("cloudcover_low"), w("cloudcover_mid"), w("cloudcover_high"),
|
||||||
|
w("windspeed_10m"), w("windgusts_10m"), w("winddirection_10m"),
|
||||||
|
w("shortwave_radiation"), w("direct_radiation"), w("diffuse_radiation"),
|
||||||
|
1 if w("is_day") else 0,
|
||||||
|
0, # ist_vorhersage: eine Analyse ist keine
|
||||||
|
gelaufen,
|
||||||
|
))
|
||||||
|
return aus
|
||||||
|
|
||||||
|
|
||||||
|
def schreiben(zug, stunden, tage, geneigt):
|
||||||
|
"""Wie im Sammler, aber ohne weatherForecastLog."""
|
||||||
|
with mc.connect(**zug) as verbindung:
|
||||||
|
with verbindung.cursor() as cursor:
|
||||||
|
cursor.executemany(
|
||||||
|
"REPLACE INTO weatherHours (datetime, temp, gefuehlt, feuchte, "
|
||||||
|
"taupunkt, druck, regen, regen_wkt, schnee, wettercode, "
|
||||||
|
"wolken_tief, wolken_mittel, wolken_hoch, wind, boe, richtung, "
|
||||||
|
"strahlung, direkt, diffus, tag, ist_vorhersage, abgerufen) "
|
||||||
|
"VALUES (" + ",".join(["%s"] * 22) + ");", stunden)
|
||||||
|
if tage:
|
||||||
|
cursor.executemany(
|
||||||
|
"REPLACE INTO weatherDays (date, wettercode, temp_min, "
|
||||||
|
"temp_max, sonnenauf, sonnenunter, regen_summe, regen_wkt, "
|
||||||
|
"wind_max, boe_max, strahlung_summe, ist_vorhersage, abgerufen) "
|
||||||
|
"VALUES (" + ",".join(["%s"] * 13) + ");", tage)
|
||||||
|
if geneigt:
|
||||||
|
cursor.executemany(
|
||||||
|
"REPLACE INTO weatherTilted (datetime, flaeche, strahlung) "
|
||||||
|
"VALUES (%s,%s,%s);", geneigt)
|
||||||
|
verbindung.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
|
p.add_argument("--von", default="2022-04-02",
|
||||||
|
help="erster Tag (Vorgabe: Beginn von EnergyFlow_hourly)")
|
||||||
|
p.add_argument("--bis", default=None,
|
||||||
|
help="letzter Tag (Vorgabe: heute minus sieben Tage)")
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
von = date.fromisoformat(a.von)
|
||||||
|
bis = date.fromisoformat(a.bis) if a.bis else date.today() - timedelta(days=7)
|
||||||
|
if von > bis:
|
||||||
|
raise SystemExit("--von liegt hinter --bis")
|
||||||
|
|
||||||
|
flaechen = vorhersage.flaechen_lesen()
|
||||||
|
gelaufen = datetime.now().replace(microsecond=0)
|
||||||
|
zug = konfig.datenbank()
|
||||||
|
gesamt = 0
|
||||||
|
|
||||||
|
jahr_von = von
|
||||||
|
while jahr_von <= bis:
|
||||||
|
jahr_bis = min(date(jahr_von.year, 12, 31), bis)
|
||||||
|
logger.info("hole %s bis %s", jahr_von, jahr_bis)
|
||||||
|
daten = archiv_holen(jahr_von, jahr_bis)
|
||||||
|
stunden = zeilen(daten, gelaufen)
|
||||||
|
|
||||||
|
tage = vorhersage.tageszeilen(daten, gelaufen) if "daily" in daten else []
|
||||||
|
# Zwei Korrekturen gegenueber dem laufenden Betrieb:
|
||||||
|
#
|
||||||
|
# ist_vorhersage ist hier immer 0 - tageszeilen() entscheidet das
|
||||||
|
# anhand von "heute", und im Archiv ist jeder Tag Vergangenheit.
|
||||||
|
#
|
||||||
|
# Sonnenauf- und -untergang werden verworfen. Die Archiv-Schnittstelle
|
||||||
|
# rechnet alle Zeiten mit dem *heute* gueltigen Zeitzonenversatz um -
|
||||||
|
# die Antwort sagt selbst "utc_offset_seconds: 7200", auch fuer einen
|
||||||
|
# Dezembertag. Sonnenaufgang am 21.12.2025 kaeme so als 09:04 statt
|
||||||
|
# 08:04. Bei den Stundenwerten faellt das nicht ins Gewicht (sie sind
|
||||||
|
# ohnehin an ihrem Zeitstempel aufgehaengt und werden nur als Reihe
|
||||||
|
# gelesen), bei einer Uhrzeit schon. Gebraucht werden die Sonnenzeiten
|
||||||
|
# nur fuer die sichtbaren Tage, und die schreibt der Sammler richtig.
|
||||||
|
tage = [t[:4] + (None, None) + t[6:-2] + (0, gelaufen) for t in tage]
|
||||||
|
|
||||||
|
geneigt = []
|
||||||
|
for name, neigung, azimut in flaechen:
|
||||||
|
g = archiv_holen(jahr_von, jahr_bis,
|
||||||
|
felder=["global_tilted_irradiance"],
|
||||||
|
zusatz={"tilt": neigung, "azimuth": azimut})
|
||||||
|
h = g["hourly"]
|
||||||
|
geneigt += [(vorhersage._zeit(t), name,
|
||||||
|
h["global_tilted_irradiance"][i])
|
||||||
|
for i, t in enumerate(h["time"])]
|
||||||
|
|
||||||
|
schreiben(zug, stunden, tage, geneigt)
|
||||||
|
gesamt += len(stunden)
|
||||||
|
logger.info(" %d Stunden, %d Tage, %d Flaechenwerte geschrieben",
|
||||||
|
len(stunden), len(tage), len(geneigt))
|
||||||
|
jahr_von = date(jahr_von.year + 1, 1, 1)
|
||||||
|
time.sleep(2) # nicht in einem Zug durch die fremde Schnittstelle
|
||||||
|
|
||||||
|
logger.info("fertig: %d Stunden nachgetragen", gesamt)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user