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:
2026-09-15 09:29:57 +02:00
co-authored by Claude Opus 5
parent 153658df81
commit 8c62455b2c
5 changed files with 858 additions and 127 deletions
+388 -83
View File
@@ -18,13 +18,24 @@ Parkposition und Klima. Spec: https://public.api.connect.skoda-auto.cz/docs
Zwei Dinge unterscheiden das Modul von den uebrigen Sammlern:
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"]: