Skoda: mehrere Schluessel, Wallbox-Ladeverlauf, Stecker als 1/0
- gatherSkodaData.py liest bis zu drei API-Schluessel und nutzt sie reihum;
das Kontingent gilt gemessen fuers ganze Konto, daher feste Aufteilung
14 Abruf / 4 Befehle / 2 Reserve
- nach einem Befehl aus der Weboberflaeche wird einmal ausser der Reihe
nachgesehen
- Wallbox-Verlauf waehrend einer Ladung nach skoda_ladepunkte, dazu
skoda_ladepunkte_nachtragen.py fuer Ladungen aus EnergyFlow
- evPlug ist jetzt ein Wahrheitswert: bool("no car") war immer True
- skoda.conf.example beschreibt das gemeinsame Format
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+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:
|
||||
|
||||
Es ist eine Cloud-API mit knappem Kontingent, kein Geraet im Haus: laut
|
||||
Doku 20 Anfragen je Stunde und Schluessel, ausdruecklich vorlaeufig. Der
|
||||
Manager ruft gatherData() im 3-Sekunden-Takt auf, angefragt wird aber nur,
|
||||
wenn das eigene Intervall abgelaufen ist - alle vier Minuten waehrend des
|
||||
Ladens, stuendlich beim Parken. Darueber liegen eine eigene Stundenbilanz
|
||||
und die RateLimit-Header der Antwort, die als massgebliche Quelle gelten.
|
||||
Zieht Skoda das Kontingent enger, folgt das Modul von selbst.
|
||||
Es ist eine Cloud-API mit knappem Kontingent, kein Geraet im Haus:
|
||||
20 Anfragen je Stunde, ausdruecklich vorlaeufig. Die Doku schreibt "je
|
||||
Schluessel", gemessen gilt der Zaehler aber fuer alle Schluessel dieses
|
||||
Kontos zusammen - siehe die Rechnung bei _LIMIT. Ein Abruf alle drei
|
||||
Minuten ist damit das Aeusserste, und die Intervalle unten sind daran
|
||||
bemessen.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
Abruf laeuft als Hintergrund-Task, zurueckgegeben wird immer sofort der
|
||||
zuletzt bekannte Stand. Eine haengende Cloud-Verbindung kann den
|
||||
@@ -48,7 +64,7 @@ import collections
|
||||
import time
|
||||
import datetime
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
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.
|
||||
_KONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skoda.conf")
|
||||
|
||||
# Das Kontingent ist der enge Punkt dieser Schnittstelle: laut Doku
|
||||
# 20 Anfragen je Stunde und Schluessel, ausdruecklich nicht endgueltig. Ein
|
||||
# Abruf alle drei Minuten ist damit schon das Aeusserste. Alle Intervalle
|
||||
# unten sind daran bemessen; _BUDGET zieht zusaetzlich eine eigene Grenze
|
||||
# knapp darunter, damit fuer einen Fehlversuch noch Luft bleibt.
|
||||
# Nach einem Befehl aus der Weboberflaeche einmal ausser der Reihe nachsehen.
|
||||
# ajax/skodaCmd.php beruehrt dazu eine Datei, sobald die API den Befehl mit
|
||||
# 202 angenommen hat; deren Aenderungszeit ist der Zeitpunkt des Befehls. Mehr
|
||||
# braucht es nicht: die beiden Prozesse teilen sich sonst nichts, und ein stat
|
||||
# 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.
|
||||
_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,
|
||||
# schickt die Weboberflaeche ihre Befehle darueber und nimmt diesem Modul
|
||||
# nichts weg - dann darf es fast das ganze Kontingent nutzen. Ohne zweiten
|
||||
# Schluessel teilen sich beide eines, und weil sie in getrennten Prozessen
|
||||
# laufen und keinen gemeinsamen Zaehler haben, ist es fest aufgeteilt:
|
||||
# Was davon dieses Modul nehmen darf. Der Rest ist fuer die Weboberflaeche,
|
||||
# die ihre Befehle ueber dieselbe Grenze schickt (4 je Stunde, siehe
|
||||
# ajax/skodaCmd.php), und zwei Anfragen Reserve. Die beiden Prozesse haben
|
||||
# keinen gemeinsamen Zaehler, deshalb die feste Aufteilung:
|
||||
#
|
||||
# eigener CMD_API_KEY nur API_KEY
|
||||
# dieses Modul 18 / Stunde 14 / Stunde
|
||||
# Befehle 15 / Stunde 4 / Stunde (ajax/skodaCmd.php)
|
||||
# Reserve 2 / Stunde 2 / Stunde
|
||||
# dieses Modul 14 / Stunde
|
||||
# Befehle 4 / Stunde (ajax/skodaCmd.php)
|
||||
# Reserve 2 / Stunde
|
||||
#
|
||||
# Umgestellt wird von selbst, sobald der zweite Schluessel auftaucht - von
|
||||
# Hand ist hier nichts nachzuziehen.
|
||||
_BUDGET_ALLEIN = 18
|
||||
_BUDGET_GETEILT = 14
|
||||
# Ein eigener CMD_API_KEY aendert daran nichts mehr - er trennt, wer womit
|
||||
# fragt, nicht wie viel. Sinn hat er trotzdem: widerrufen laesst er sich
|
||||
# einzeln, ohne den Abruf mitzunehmen.
|
||||
_BUDGET = 14
|
||||
_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_LADEN = 240 # laedt gerade - 15 Abrufe je Stunde
|
||||
_I_FAHRT = 300 # unterwegs
|
||||
_I_GESTECKT = 900 # Kabel steckt, laedt aber nicht
|
||||
_I_AKTIV = 1800 # steht, hat sich zuletzt aber noch geruehrt
|
||||
_I_RUHE = 3600 # seit Stunden unveraendert
|
||||
_I_LADEN = 260 # laedt gerade - knapp 14 Abrufe je Stunde
|
||||
_I_FAHRT = 260 # unterwegs
|
||||
_I_GESTECKT = 300 # Kabel steckt, laedt aber nicht
|
||||
_I_AKTIV = 600 # steht, hat sich zuletzt aber noch geruehrt
|
||||
_I_RUHE = 1200 # seit Stunden unveraendert
|
||||
|
||||
_HEARTBEAT = 3600 # auch ohne Aenderung so oft eine Zeile schreiben
|
||||
_RUHE_AB = 7200 # ab so langer Unveraendertheit gilt _I_RUHE
|
||||
@@ -197,8 +247,10 @@ class SkodaData:
|
||||
error:int = 0 # aufeinanderfolgende Fehlversuche
|
||||
httpStatus:int = 0
|
||||
apiErrors:str = "" # Fehlerliste der Antwort, kommagetrennt
|
||||
rlRemaining:int = -1 # Restkontingent laut RateLimit-Header
|
||||
keyExpires:Optional[datetime.datetime] = None
|
||||
keys:int = 0 # nutzbare Schluessel, nur zur Anzeige
|
||||
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
|
||||
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
|
||||
"letzteZeile": 0.0, # wann zuletzt eine Zeile geschrieben wurde
|
||||
"signatur": None, # Fingerabdruck der zuletzt geschriebenen Zeile
|
||||
"keyGewarnt": False,
|
||||
"konfMtime": 0.0,
|
||||
"apiKey": "",
|
||||
"vin": "",
|
||||
"ladenVorher": False, # Hausseite lieferte beim letzten Aufruf Strom
|
||||
"anstoss": None, # Stand der Anstossdatei, None = noch nie gesehen
|
||||
"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
|
||||
# schrumpft ueber das Fenster, sodass sich gegen Ende ein Schwall erlauben
|
||||
# liesse, der zu Beginn des naechsten Fensters sofort auflaeuft. Die eigene
|
||||
# Liste haelt den Abstand ueber jede Fenstergrenze hinweg.
|
||||
_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
|
||||
# Aufruf herein; der Hintergrund-Task greift auf den letzten Stand zu.
|
||||
_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.
|
||||
|
||||
Format, eine Zuweisung je Zeile: API_KEY=... und VIN=...
|
||||
Der Schluessel laeuft ab. Weil die Datei bei jeder Aenderung neu gelesen
|
||||
wird, genuegt zum Tausch das Ueberschreiben - der Manager laeuft weiter.
|
||||
Dazu wahlweise API_KEY2 und API_KEY3 sowie CMD_API_KEY fuer die
|
||||
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:
|
||||
mtime = os.path.getmtime(_KONFIG)
|
||||
except OSError:
|
||||
if _st["apiKey"]:
|
||||
if _schluessel:
|
||||
_LOGGER.warning("skoda.conf nicht mehr lesbar, benutze den letzten Stand.")
|
||||
return True
|
||||
return False
|
||||
if mtime == _st["konfMtime"]:
|
||||
return bool(_st["apiKey"] and _st["vin"])
|
||||
return bool(_schluessel and _st["vin"])
|
||||
werte = {}
|
||||
try:
|
||||
with open(_KONFIG, "r") as f:
|
||||
@@ -312,23 +395,82 @@ def _konfig() -> bool:
|
||||
werte[k.strip().upper()] = v.strip().strip('"').strip("'")
|
||||
except OSError as 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["apiKey"] = werte.get("API_KEY", "")
|
||||
_st["vin"] = werte.get("VIN", "")
|
||||
_st["eigenerCmdKey"] = bool(werte.get("CMD_API_KEY"))
|
||||
_db["passwd"] = werte.get("DB_PASSWORD", _db["passwd"])
|
||||
_st["keyGewarnt"] = False
|
||||
if not _st["apiKey"] or not _st["vin"]:
|
||||
_schluesselUebernehmen(werte)
|
||||
if not _schluessel or not _st["vin"]:
|
||||
_LOGGER.error("skoda.conf braucht API_KEY und VIN.")
|
||||
return False
|
||||
ret.vin = _st["vin"]
|
||||
ret.keys = len(_schluessel)
|
||||
_LOGGER.info("skoda.conf gelesen, VIN endet auf "+_st["vin"][-4:]
|
||||
+(", eigener Schluessel fuer die Steuerung"
|
||||
if _st["eigenerCmdKey"] else ", ein Schluessel fuer alles"))
|
||||
+", "+str(len(_schluessel))+" Schluessel"
|
||||
+(" (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
|
||||
|
||||
|
||||
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):
|
||||
"""Datenbank-Passwort vom Manager uebernehmen.
|
||||
|
||||
@@ -529,14 +671,14 @@ def _intervall() -> float:
|
||||
# bringt haeufiges Klopfen nichts und kostet nur Kontingent.
|
||||
return min(_I_RUHE, 60.0*(2**min(ret.error-1, 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":
|
||||
return _I_FAHRT
|
||||
return float(_I_FAHRT)
|
||||
if _haus["wbPlug"] or ret.chgState in _GESTECKT:
|
||||
return _I_GESTECKT
|
||||
return float(_I_GESTECKT)
|
||||
if time.time() - _st["letzteAend"] > _RUHE_AB:
|
||||
return _I_RUHE
|
||||
return _I_AKTIV
|
||||
return float(_I_RUHE)
|
||||
return float(_I_AKTIV)
|
||||
|
||||
|
||||
def _gezaehlt(jetzt:float):
|
||||
@@ -551,23 +693,71 @@ def _aufraeumen(jetzt: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,
|
||||
wird gewartet, bis die aelteste aus dem Fenster faellt. Das ist die
|
||||
eigentliche Sicherung: die Intervalle unten sind zwar so bemessen, dass
|
||||
sie passen, aber Sonderfaelle wie der Anstoss beim Ladebeginn kommen
|
||||
zusaetzlich - und ein 429 kostet zwar kein Kontingent, verraet aber, dass
|
||||
die Rechnung nicht aufging.
|
||||
Sind in der zurueckliegenden Stunde bereits _BUDGET Anfragen gelaufen, wird
|
||||
gewartet, bis die aelteste aus dem Fenster faellt. Dazu die Sperre, die die
|
||||
API selbst gesetzt hat: ein 429 mit Retry-After oder ein RateLimit-Header,
|
||||
der dichteres Fragen verbietet. Beides gilt fuer alle Schluessel, weil das
|
||||
Kontingent fuer alle zusammen gilt.
|
||||
|
||||
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)
|
||||
budget = _BUDGET_ALLEIN if _st["eigenerCmdKey"] else _BUDGET_GETEILT
|
||||
if len(_verbrauch) < budget:
|
||||
return 0.0
|
||||
return _verbrauch[0] + _FENSTER + 5.0 - jetzt
|
||||
warten = max(0.0, _st["sperre"] - jetzt)
|
||||
if len(_verbrauch) >= _BUDGET:
|
||||
warten = max(warten, _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.
|
||||
|
||||
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
|
||||
zur Grenze. Zieht Skoda das Kontingent enger, folgt das Modul von selbst,
|
||||
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:
|
||||
rest = int(headers.get("RateLimit-Remaining", -1))
|
||||
reset = int(headers.get("RateLimit-Reset", -1))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
ret.rlRemaining = rest
|
||||
sch.rest = rest
|
||||
if rest < 0 or reset < 0:
|
||||
return 0.0
|
||||
if rest == 0:
|
||||
@@ -590,16 +784,16 @@ def _kontingent(headers):
|
||||
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."""
|
||||
ts = _zeit(headers.get("X-API-Key-Expires-At"))
|
||||
if not ts:
|
||||
return
|
||||
ret.keyExpires = ts
|
||||
sch.ablauf = ts
|
||||
tage = (ts - datetime.datetime.now()).total_seconds()/86400.0
|
||||
if tage < _WARN_KEY and not _st["keyGewarnt"]:
|
||||
_st["keyGewarnt"] = True
|
||||
_LOGGER.warning("Skoda-API-Schluessel laeuft am "
|
||||
if tage < _WARN_KEY and not sch.gewarnt:
|
||||
sch.gewarnt = True
|
||||
_LOGGER.warning("Skoda-API-Schluessel "+sch.name+" laeuft am "
|
||||
+ts.strftime("%d.%m.%Y")+" ab ("+str(int(tage))
|
||||
+" 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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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):
|
||||
"""Eine Zeile in skoda und die unveraenderte Antwort in skoda_raw.
|
||||
|
||||
@@ -689,40 +952,60 @@ def _schreiben(rohtext:str):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _abrufen():
|
||||
"""Ein Durchgang: anfragen, auswerten, bei Aenderung protokollieren."""
|
||||
kopf = {"X-API-Key": _st["apiKey"], "Accept": "application/json"}
|
||||
"""Ein Durchgang: anfragen, auswerten, bei Aenderung protokollieren.
|
||||
|
||||
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"]
|
||||
rohtext = ""
|
||||
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:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=kopf, timeout=_TIMEOUT) as response:
|
||||
ret.httpStatus = response.status
|
||||
rohtext = await response.text()
|
||||
abstand = _kontingent(response.headers)
|
||||
_keyPruefen(response.headers)
|
||||
abstand = _kontingent(response.headers, sch)
|
||||
_keyPruefen(response.headers, sch)
|
||||
|
||||
if response.status == 200:
|
||||
uebernehmen(json.loads(rohtext))
|
||||
ret.error = 0
|
||||
ret.lastOk = time.time()
|
||||
elif response.status == 429:
|
||||
# Kontingent erschoepft. Retry-After ist verbindlich.
|
||||
ret.error += 1
|
||||
# Kontingent erschoepft. Retry-After ist verbindlich und gilt
|
||||
# fuer alle Schluessel - mit einem anderen weiterzufragen,
|
||||
# braeuchte niemand zu versuchen. Keine Fehlerzaehlung: das ist
|
||||
# Buchhaltung, keine Stoerung.
|
||||
try:
|
||||
abstand = max(abstand, float(response.headers.get("Retry-After", 60)))
|
||||
except (TypeError, ValueError):
|
||||
abstand = max(abstand, 60.0)
|
||||
_LOGGER.warning("Skoda-API: Kontingent erschoepft, warte "
|
||||
_LOGGER.warning("Skoda-API: Kontingent erschoepft, pausiert "
|
||||
+str(int(abstand))+" s.")
|
||||
rohtext = ""
|
||||
elif response.status in (401, 403):
|
||||
# Schluessel abgelaufen, widerrufen oder nicht fuer diese VIN
|
||||
# freigegeben. Das behebt sich nicht von selbst, also selten
|
||||
# nachfassen statt im Minutentakt gegen die Wand zu laufen.
|
||||
ret.error += 1
|
||||
# nachfassen statt im Minutentakt gegen die Wand zu laufen -
|
||||
# und nur mit diesem einen aussetzen. Sind die anderen in
|
||||
# Ordnung, merkt der Rest des Hauses davon nichts.
|
||||
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 "
|
||||
"erneuern und skoda.conf ueberschreiben.")
|
||||
rohtext = ""
|
||||
@@ -742,8 +1025,17 @@ async def _abrufen():
|
||||
# schon - auch ein 500 aus einer Stoerung bei Skoda.
|
||||
if ret.httpStatus not in (401, 403):
|
||||
_gezaehlt(jetzt)
|
||||
_st["naechster"] = jetzt + max(_I_MIN, _intervall(), abstand,
|
||||
_budgetSperre(jetzt))
|
||||
if abstand > 0.0:
|
||||
# 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:
|
||||
return
|
||||
@@ -802,6 +1094,19 @@ async def gatherData(wbKw:float=0.0, wbPlug:bool=False,
|
||||
_st["ladenVorher"] = laedt
|
||||
_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
|
||||
|
||||
if _st["laeuft"] or time.time() < _st["naechster"]:
|
||||
|
||||
Reference in New Issue
Block a user