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: 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"]:
+56
View File
@@ -0,0 +1,56 @@
# Zugangsdaten fuer die MySkoda-API. Das hier ist die Vorlage; die echte Datei
# heisst skoda.conf, liegt daneben und steht nicht im Repository.
#
# Gelesen wird sie von gatherSkodaData.py (hier) und von ajax/skodaCmd.php im
# Web-Repository - beide bei jeder Aenderung neu. Zum Tausch eines Schluessels
# genuegt deshalb das Ueberschreiben, ein Neustart ist nicht noetig.
#
# Format: eine Zuweisung je Zeile, NAME=wert. Zeilen mit # sind Kommentar,
# Anfuehrungszeichen um den Wert werden abgestreift.
# Die Fahrgestellnummer. Steht in der MySkoda-App unter den Fahrzeugdaten.
VIN=TMBJB9NY5RF000000
# Der API-Schluessel. Erzeugt unter https://go.skoda.eu/api-keys, gebunden an
# die dort ausgewaehlten Fahrzeuge - und mit Ablaufdatum: jede Antwort traegt
# X-API-Key-Expires-At mit, gatherSkodaData.py warnt 14 Tage vorher im Log.
API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Bis zu zwei weitere Schluessel, in derselben App angelegt.
#
# Sie bringen KEINEN dichteren Abruf. Die Doku nennt 20 Anfragen je Stunde
# "je Schluessel", gemessen gilt der Zaehler aber fuer alle Schluessel des
# Kontos zusammen: drei Anfragen hintereinander mit drei verschiedenen
# Schluesseln zaehlten RateLimit-Remaining von 18 ueber 17 auf 16 herunter,
# bei identischem Reset-Zeitpunkt.
#
# Wofuer sie dann gut sind: Ausfallsicherheit. Laeuft ein Schluessel ab oder
# wird er in der App widerrufen, sperrt gatherSkodaData.py genau diesen und
# fragt mit dem naechsten weiter - statt dass das Fahrzeug aus der Anzeige
# faellt, bis es jemand im Log bemerkt. Benutzt werden sie reihum, damit ein
# abgelaufener auffaellt und nicht jahrelang unbemerkt danebenliegt.
#
# Weglassen ist in Ordnung - dann bleibt es beim ersten.
#API_KEY2=
#API_KEY3=
# Eigener Schluessel fuer die Weboberflaeche (ajax/skodaCmd.php startet und
# stoppt darueber Laden, Klimatisierung und Lueftung). Ohne ihn schickt sie
# ihre Befehle mit API_KEY.
#
# Am Kontingent aendert er nichts - siehe oben, es gilt fuers ganze Konto.
# Weil Abruf und Weboberflaeche in getrennten Prozessen laufen und keinen
# gemeinsamen Zaehler haben, ist es fest aufgeteilt: 14 Anfragen je Stunde
# fuer den Abruf, 4 fuer Befehle, 2 Reserve. Was ein eigener Schluessel
# bringt, ist die Moeglichkeit, ihn einzeln zu widerrufen, ohne den Abruf
# mitzunehmen.
#CMD_API_KEY=
# Passwort der Datenbank solarLog. Wahlweise - laeuft das Modul im
# solarManager, reicht der es aus seiner config.ini herein (setDbPasswort).
#DB_PASSWORD=
+131
View File
@@ -0,0 +1,131 @@
"""Wallbox-Verlauf vergangener Ladungen aus EnergyFlow nach skoda_ladepunkte.
Den laufenden Verlauf schreibt gatherSkodaData.py selbst, im Minutentakt.
Fuer alles davor - und fuer Zeiten, in denen der Manager nicht lief - gibt
es nur EnergyFlow, und das wird nach zwoelf Monaten ausgeduennt. Dieses
Skript holt die Fuenf-Minuten-Werte heraus, solange sie noch da sind.
python3 skoda_ladepunkte_nachtragen.py 2026-09-11 2026-09-14
python3 skoda_ladepunkte_nachtragen.py 2026-09-11 2026-09-14 --probe
Ohne --probe wird geschrieben. Es darf beliebig oft laufen: nachgetragene
Zeilen im Zeitraum werden vorher entfernt, und wo der Manager selbst schon
Punkte geschrieben hat, traegt es nichts nach - die sind genauer.
EnergyFlow kennt keinen Zaehlerstand, nur Leistungen. wh_total wird deshalb
gerechnet: die Leistung ueber die Zeit aufsummiert und dann an die beiden
Zaehlerstaende angeglichen, die die Tabelle skoda vor und nach der Ladung
kennt. Fehlt einer davon, bleibt wh_total leer. Jede so entstandene Zeile
traegt quelle = 'energyflow'.
"""
import sys
import datetime
import mysql.connector as mc
import konfig
LUECKE = datetime.timedelta(hours=3) # wie LADUNG_LUECKE_S in ajax/skoda.php
RUHE_KW = 0.5 # wie _LADEPUNKT_KW in gatherSkodaData.py
WALLBOXEN = {
# Name: (Leistung in EnergyFlow, Zaehlerspalte in skoda)
"carport": ("PL1_EV + PL2_EV + PL3_EV", "wb_wh_total"),
"og": ("PL1_EVog + PL2_EVog + PL3_EVog", "wbog_wh_total"),
}
def bloecke(zeilen):
"""Zusammenhaengende Strecken mit Leistung, jeweils mit der Zeile danach."""
block = []
for i, (zeit, kw) in enumerate(zeilen):
if kw > RUHE_KW:
block.append((zeit, kw))
elif block:
block.append((zeit, 0.0)) # der Endpunkt, wie im Manager
yield block
block = []
if block:
yield block # laeuft noch - ohne Endpunkt
def zaehlerstand(cursor, spalte, zeit, richtung):
"""Zaehlerstand aus skoda direkt vor bzw. nach einem Zeitpunkt."""
if richtung < 0:
cursor.execute("SELECT datetime, " + spalte + " FROM skoda WHERE datetime <= %s "
"AND datetime >= %s AND " + spalte + " > 0 "
"ORDER BY datetime DESC LIMIT 1", (zeit, zeit - LUECKE))
else:
cursor.execute("SELECT datetime, " + spalte + " FROM skoda WHERE datetime >= %s "
"AND datetime <= %s AND " + spalte + " > 0 "
"ORDER BY datetime LIMIT 1", (zeit, zeit + LUECKE))
z = cursor.fetchone()
return int(z[1]) if z else None
def main():
if len(sys.argv) < 3:
print(__doc__)
sys.exit(1)
von = datetime.datetime.fromisoformat(sys.argv[1])
bis = datetime.datetime.fromisoformat(sys.argv[2])
probe = "--probe" in sys.argv
zugang = konfig.datenbank()
with mc.connect(host=zugang["host"], port=zugang["port"], user=zugang["user"],
password=zugang["password"], database=zugang["database"]) as v:
with v.cursor() as c:
neu = []
for name, (leistung, spalte) in WALLBOXEN.items():
c.execute("SELECT datetime, " + leistung + " FROM EnergyFlow "
"WHERE datetime BETWEEN %s AND %s ORDER BY datetime", (von, bis))
zeilen = [(z[0], float(z[1] or 0.0)) for z in c.fetchall()]
for block in bloecke(zeilen):
beginn, ende = block[0][0], block[-1][0]
c.execute("SELECT COUNT(*) FROM skoda_ladepunkte WHERE wallbox = %s "
"AND quelle = 'manager' AND datetime BETWEEN %s AND %s",
(name, beginn - datetime.timedelta(minutes=5), ende))
if c.fetchone()[0]:
print(name, beginn, "bis", ende, "- vom Manager schon erfasst, uebersprungen")
continue
# Leistung ueber die Zeit. Ein EnergyFlow-Wert ist das Mittel
# der fuenf Minuten, die er abschliesst.
summe, vorher, verlauf = 0.0, None, []
for zeit, kw in block:
dt = (zeit - vorher).total_seconds() if vorher else 300.0
summe += kw * dt / 3.6 if kw > 0 else 0.0
verlauf.append((zeit, kw, summe))
vorher = zeit
anfang = zaehlerstand(c, spalte, beginn - datetime.timedelta(minutes=5), -1)
schluss = zaehlerstand(c, spalte, ende, +1)
if anfang is not None and schluss is not None and schluss > anfang and summe > 0:
faktor = (schluss - anfang) / summe
else:
faktor = None
for zeit, kw, bis_hier in verlauf:
wh = round(anfang + bis_hier * faktor) if faktor else None
neu.append((zeit, name, round(kw, 3), wh))
print(name, beginn, "bis", ende, "-", len(verlauf), "Punkte,",
round(summe / 1000, 2), "kWh aufsummiert",
("gegen " + str(round((schluss - anfang) / 1000, 2)) + " kWh Zaehler"
if faktor else "(ohne Zaehlerstaende, wh_total bleibt leer)"))
if probe:
print(len(neu), "Zeilen - Probe, nichts geschrieben.")
return
c.execute("DELETE FROM skoda_ladepunkte WHERE quelle = 'energyflow' "
"AND datetime BETWEEN %s AND %s", (von, bis))
entfernt = c.rowcount
c.executemany("INSERT INTO skoda_ladepunkte (datetime, wallbox, kw, wh_total, quelle) "
"VALUES (%s,%s,%s,%s,'energyflow')", neu)
v.commit()
print(len(neu), "Zeilen nachgetragen,", entfernt, "fruehere Nachtraege ersetzt.")
if __name__ == "__main__":
main()
+274 -42
View File
@@ -5,6 +5,11 @@ sicherstellen, dass eine Antwort richtig zerlegt wird und dass die Faelle
nicht abstuerzen, die im Betrieb sicher vorkommen: fehlende Teilbereiche, nicht abstuerzen, die im Betrieb sicher vorkommen: fehlende Teilbereiche,
UNKNOWN statt eines Werts, Fahrzeug in Bewegung, leere Antwort. UNKNOWN statt eines Werts, Fahrzeug in Bewegung, leere Antwort.
Dazu die Buchfuehrung ueber das Kontingent, die sich sonst nur im Betrieb
zeigen wuerde - und dort erst als 429: dass es eingehalten wird, dass die
Schluessel reihum drankommen und dass ein abgewiesener die anderen nicht
mitnimmt. Abschnitt 13 spielt dafuer einen ganzen Tag Dauerladen durch.
python3 skoda_test.py python3 skoda_test.py
""" """
@@ -12,6 +17,11 @@ import datetime
import time import time
import gatherSkodaData as g import gatherSkodaData as g
# Nie in die echte Datenbank: gatherData() stoesst beim Laden das Schreiben
# des Wallbox-Verlaufs an, und diese Pruefung laeuft auch auf der NAS.
geschrieben = []
g._ladepunkteSchreiben = lambda punkte: geschrieben.extend(punkte)
fehler = [] fehler = []
@@ -23,6 +33,25 @@ def frisch():
g.ret = g.SkodaData() g.ret = g.SkodaData()
return g.ret return g.ret
_NAMEN = ("API_KEY", "API_KEY2", "API_KEY3")
def schluessel(anzahl, eigenerCmdKey=False):
"""So viele Schluessel setzen, als staenden sie in skoda.conf.
Ohne Datei und ohne Netz. Auch die gemeinsame Stundenbilanz wird geleert,
damit keine Probe die der vorigen erbt.
"""
g._schluessel[:] = []
werte = dict((_NAMEN[i], "schluessel"+str(i)) for i in range(anzahl))
if eigenerCmdKey:
werte["CMD_API_KEY"] = "steuerung"
g._schluesselUebernehmen(werte)
g._st["eigenerCmdKey"] = eigenerCmdKey
g._st["zuletzt"] = -1
g._st["sperre"] = 0.0
g._verbrauch.clear()
return g._schluessel
# --- 1. Vollstaendige Antwort --------------------------------------------- # --- 1. Vollstaendige Antwort ---------------------------------------------
r = frisch() r = frisch()
@@ -112,33 +141,55 @@ pruefe("gesteckt behaelt den Wert", r.chgKw, 10.8)
# --- 9. Abrufintervall folgt der Lage ------------------------------------ # --- 9. Abrufintervall folgt der Lage ------------------------------------
r = frisch() r = frisch()
schluessel(1)
g._st["letzteAend"] = 0.0 g._st["letzteAend"] = 0.0
g._haus["wbPlug"] = False; g._haus["wbKw"] = 0.0 g._haus["wbPlug"] = False; g._haus["wbKw"] = 0.0
r.chgState = "CHARGING" r.chgState = "CHARGING"
pruefe("laedt", g._intervall(), g._I_LADEN) pruefe("laedt", g._intervall(), float(g._I_LADEN))
r.chgState = "" r.chgState = ""
g._haus["wbPlug"] = True; g._haus["wbKw"] = 7.4 g._haus["wbPlug"] = True; g._haus["wbKw"] = 7.4
pruefe("Wallbox liefert", g._intervall(), g._I_LADEN) pruefe("Wallbox liefert", g._intervall(), float(g._I_LADEN))
g._haus["wbKw"] = 0.0 g._haus["wbKw"] = 0.0
pruefe("gesteckt, laedt nicht", g._intervall(), g._I_GESTECKT) pruefe("gesteckt, laedt nicht", g._intervall(), g._I_GESTECKT)
g._haus["wbPlug"] = False g._haus["wbPlug"] = False
r.parkState = "IN_MOTION" r.parkState = "IN_MOTION"
pruefe("unterwegs", g._intervall(), g._I_FAHRT) pruefe("unterwegs", g._intervall(), float(g._I_FAHRT))
r.parkState = "PARKED" r.parkState = "PARKED"
pruefe("lange nichts", g._intervall(), g._I_RUHE) pruefe("lange nichts", g._intervall(), g._I_RUHE)
r.error = 3 r.error = 3
pruefe("nach Fehlern zurueckhaltend", g._intervall(), 240.0) pruefe("nach Fehlern zurueckhaltend", g._intervall(), 240.0)
r.error = 0 r.error = 0
# Mehr Schluessel duerfen den Takt NICHT beschleunigen: das Kontingent gilt
# fuer alle zusammen (gemessen, siehe _LIMIT). Wer das hier wieder staffelt,
# baut 429-Antworten ein.
for lage, feld, wert in (("beim Laden", "chgState", "CHARGING"),
("unterwegs", "parkState", "IN_MOTION"),
("beim Parken", "parkState", "PARKED")):
r.chgState = ""
r.parkState = ""
setattr(r, feld, wert)
werte = []
for anzahl in (1, 2, 3):
schluessel(anzahl)
werte.append(g._intervall())
pruefe("Takt "+lage+" haengt nicht an der Zahl der Schluessel",
len(set(werte)), 1)
r.chgState = "CHARGING"
r.parkState = ""
schluessel(3)
pruefe("und beim Laden sind es 260 s", g._intervall(), 260.0)
# --- 10. Kontingent weitet den Abstand ------------------------------------ # --- 10. Kontingent weitet den Abstand ------------------------------------
r = frisch() r = frisch()
sch = schluessel(1)[0]
pruefe("100 Anfragen in 60 s", round(g._kontingent( pruefe("100 Anfragen in 60 s", round(g._kontingent(
{"RateLimit-Remaining":"100","RateLimit-Reset":"60"}),3), 0.72) {"RateLimit-Remaining":"100","RateLimit-Reset":"60"}, sch),3), 0.72)
pruefe("2 Anfragen in 600 s", round(g._kontingent( pruefe("2 Anfragen in 600 s", round(g._kontingent(
{"RateLimit-Remaining":"2","RateLimit-Reset":"600"}),1), 360.0) {"RateLimit-Remaining":"2","RateLimit-Reset":"600"}, sch),1), 360.0)
pruefe("nichts mehr frei", g._kontingent( pruefe("nichts mehr frei", g._kontingent(
{"RateLimit-Remaining":"0","RateLimit-Reset":"120"}), 125.0) {"RateLimit-Remaining":"0","RateLimit-Reset":"120"}, sch), 125.0)
pruefe("ohne Header kein Einfluss", g._kontingent({}), 0.0) pruefe("ohne Header kein Einfluss", g._kontingent({}, sch), 0.0)
# --- 11. Nur echte Aenderungen erzeugen eine Zeile ------------------------ # --- 11. Nur echte Aenderungen erzeugen eine Zeile ------------------------
r = frisch() r = frisch()
@@ -185,51 +236,134 @@ g.uebernehmen({"vehicle":{"charging":{"status":{"battery":
pruefe("Stromer ohne Tank", (r.carType, r.fuelPct), ("", 0)) pruefe("Stromer ohne Tank", (r.carType, r.fuelPct), ("", 0))
# --- 13. Das Kontingent wird unter Dauerlast eingehalten ------------------ # --- 13. Das Kontingent wird unter Dauerlast eingehalten ------------------
# Die Doku nennt 20 Anfragen je Stunde und Schluessel. Beim Laden greift das # Die Doku nennt 20 Anfragen je Stunde *und Schluessel*. Beim Laden greift das
# kuerzeste Intervall - genau dort muss die Sperre halten. Simuliert wird ein # kuerzeste Intervall - genau dort muss die Sperre halten. Simuliert wird ein
# ganzer Tag Dauerladen, ohne echte Zeit und ohne Netz. # ganzer Tag Dauerladen, ohne echte Zeit und ohne Netz.
g._verbrauch.clear()
zeitpunkte = [] def dauerlast(anzahl, eigenerCmdKey=False):
t = 0.0 """Einen Tag Dauerladen durchspielen. Liefert Zeitpunkte je Schluessel."""
while t < 24*3600: schluessel(anzahl, eigenerCmdKey)
zeitpunkte.append(t) takt = float(max(g._I_MIN, g._I_LADEN))
punkte = dict((s.name, []) for s in g._schluessel)
t, gebremst = 0.0, 0
while t < 24*3600:
sch = g._waehlen(t) if g._budgetSperre(t) <= 0.0 else None
if sch is None:
# Nichts frei - genau das tut _abrufen() dann auch.
gebremst += 1
t += max(5.0, g._fruehesteFreigabe(t))
continue
g._gezaehlt(t) g._gezaehlt(t)
t += max(g._I_MIN, g._I_LADEN, g._budgetSperre(t)) punkte[sch.name].append(t)
schlimmste = 0 t += takt
for i, t0 in enumerate(zeitpunkte): return punkte, gebremst
in_fenster = sum(1 for t1 in zeitpunkte[i:] if t1 - t0 < 3600)
schlimmste = max(schlimmste, in_fenster) def schlimmstesFenster(zeiten):
"""Die meisten Anfragen, die in irgendeine Stunde fallen."""
return max([len([x for x in zeiten if t0 <= x < t0+3600])
for t0 in zeiten] or [0])
def alleZeiten(punkte):
zeiten = []
for z in punkte.values():
zeiten.extend(z)
return sorted(zeiten)
punkte, gebremst = dauerlast(1)
schlimmste = schlimmstesFenster(alleZeiten(punkte))
pruefe("nie mehr als das Limit je Stunde", schlimmste <= g._LIMIT, True) pruefe("nie mehr als das Limit je Stunde", schlimmste <= g._LIMIT, True)
pruefe("das eigene Budget wird eingehalten", schlimmste <= g._BUDGET_GETEILT, True) pruefe("das eigene Budget wird eingehalten", schlimmste <= g._BUDGET, True)
pruefe("und auch ausgenutzt", schlimmste >= g._BUDGET_GETEILT - 3, True) pruefe("und auch ausgenutzt", schlimmste >= g._BUDGET - 1, True)
pruefe("der 260-s-Takt passt ohne Bremse", gebremst, 0)
# Mit eigenem Schluessel fuer die Steuerung darf mehr genutzt werden. # Der entscheidende Punkt: drei Schluessel duerfen NICHT mehr Anfragen
g._st["eigenerCmdKey"] = True # ergeben. Sie teilen sich einen Zaehler - wer hier mehr misst, hat die
g._verbrauch.clear() # Staffel wieder eingebaut und wird im Betrieb mit 429 bezahlt.
for i in range(g._BUDGET_GETEILT): punkte3, gebremst3 = dauerlast(3)
g._gezaehlt(2000.0 + i) pruefe("drei Schluessel halten dasselbe Limit",
pruefe("geteiltes Budget reicht dem Modul allein nicht", schlimmstesFenster(alleZeiten(punkte3)) <= g._BUDGET, True)
g._budgetSperre(2000.0 + g._BUDGET_GETEILT), 0.0) pruefe("und bringen genauso viele Abrufe wie einer",
g._verbrauch.clear() len(alleZeiten(punkte3)), len(alleZeiten(punkte)))
for i in range(g._BUDGET_ALLEIN): for name, zeiten in punkte3.items():
g._gezaehlt(2000.0 + i) pruefe(name+" kommt reihum dran", len(zeiten) > 0, True)
pruefe("eigenes Budget greift bei 18", g._budgetSperre(2000.0 + g._BUDGET_ALLEIN) > 3500, True)
g._st["eigenerCmdKey"] = False
g._verbrauch.clear()
# Ein einzelner Anstoss ausser der Reihe darf das Budget nicht sprengen. # Ein eigener Schluessel fuer die Steuerung aendert am Kontingent nichts
g._verbrauch.clear() # mehr - er trennt nur, wer womit fragt.
for i in range(g._BUDGET_GETEILT): punkte, _ = dauerlast(1, eigenerCmdKey=True)
g._gezaehlt(1000.0 + i) pruefe("eigener Befehlsschluessel gibt dem Abruf nicht mehr",
pruefe("Budget voll: Sperre greift", g._budgetSperre(1000.0+g._BUDGET_GETEILT) > 3500, True) schlimmstesFenster(alleZeiten(punkte)) <= g._BUDGET, True)
g._verbrauch.clear()
pruefe("Budget leer: keine Sperre", g._budgetSperre(0.0), 0.0) # Reihum: jeder ist einmal dran, bevor einer ein zweites Mal drankommt. Nicht
# wegen des Kontingents - das ist gemeinsam -, sondern damit ein abgelaufener
# Schluessel auffaellt, statt jahrelang unbenutzt danebenzuliegen.
schluessel(3)
folge = []
for i in range(9):
sch = g._waehlen(i*300.0)
g._gezaehlt(i*300.0)
folge.append(sch.name)
pruefe("die Schluessel wechseln sich ab", folge, list(_NAMEN)*3)
# Ein abgewiesener Schluessel (401, 403) sperrt sich selbst - die uebrigen
# muessen weiterlaufen, sonst nimmt ein abgelaufener das ganze Fahrzeug mit.
schluessel(3)
g._schluessel[0].sperre = 1e9
pruefe("gesperrter wird uebersprungen",
[g._waehlen(float(i)).name for i in range(4)],
["API_KEY2", "API_KEY3"]*2)
for s in g._schluessel:
s.sperre = 500.0
pruefe("alle gesperrt: kein Abruf", g._waehlen(0.0), None)
pruefe("dafuer ein sauberer Wiedervorlagetermin",
g._fruehesteFreigabe(0.0), 500.0)
# Ein 429 dagegen gilt fuer alle - ein anderer Schluessel hilft da nicht.
schluessel(3)
g._st["sperre"] = 400.0
pruefe("429 haelt alle zurueck", g._budgetSperre(0.0), 400.0)
pruefe("und der Schluessel selbst bleibt unbescholten",
g._schluessel[0].sperre, 0.0)
# Wird ein Schluessel getauscht, behalten die anderen, was ueber sie bekannt
# ist - eine Sperre aus einem 401 und das Ablaufdatum. Sonst begaenne nach
# jedem Anfassen der Datei das Ausprobieren von vorn.
schluessel(3)
g._schluessel[0].sperre = 4711.0
g._schluessel[0].ablauf = datetime.datetime(2027, 5, 5)
g._schluesselUebernehmen({"API_KEY":"schluessel0", "API_KEY2":"schluessel1",
"API_KEY3":"frisch geholt"})
pruefe("Sperre ueberlebt den Tausch", g._schluessel[0].sperre, 4711.0)
pruefe("Ablaufdatum auch", g._schluessel[0].ablauf, datetime.datetime(2027, 5, 5))
pruefe("der neue faengt bei null an", g._schluessel[2].sperre, 0.0)
g._schluessel[:] = []
g._schluesselUebernehmen({"API_KEY":"x", "API_KEY2":"x"})
pruefe("derselbe Schluessel zweimal zaehlt einfach", len(g._schluessel), 1)
# --- 14. Header mit den echten Zahlen ------------------------------------- # --- 14. Header mit den echten Zahlen -------------------------------------
g.ret = g.SkodaData() g.ret = g.SkodaData()
sch = schluessel(1)[0]
pruefe("19 Anfragen in 3400 s", round(g._kontingent( pruefe("19 Anfragen in 3400 s", round(g._kontingent(
{"RateLimit-Remaining":"19","RateLimit-Reset":"3400"}),0), 215.0) {"RateLimit-Remaining":"19","RateLimit-Reset":"3400"}, sch),0), 215.0)
pruefe("Restkontingent gemerkt", g.ret.rlRemaining, 19) pruefe("Restkontingent gemerkt", sch.rest, 19)
# In die Historie geht die juengste Auskunft, nicht die Summe: die Schluessel
# teilen sich einen Zaehler und melden daher alle dieselbe Zahl.
drei = schluessel(3)
pruefe("ohne Auskunft bleibt es unbekannt", g._restGesamt(), -1)
drei[0].rest, drei[1].rest = 5, 7
g._st["zuletzt"] = 1
pruefe("der zuletzt benutzte Schluessel zaehlt", g._restGesamt(), 7)
drei[0].ablauf = datetime.datetime(2027, 1, 1)
drei[1].ablauf = datetime.datetime(2026, 10, 1)
pruefe("es zaehlt der Schluessel, der zuerst ablaeuft",
g._fruehesterAblauf(), datetime.datetime(2026, 10, 1))
# Abschnitt 15 soll wieder ohne Konfiguration laufen: keine Schluessel, keine
# Datei - sonst wuerde gatherData() auf der NAS die echte skoda.conf finden
# und einen Abruf anstossen, den diese Pruefung nicht will.
g._schluessel[:] = []
g._KONFIG = "/gibt/es/nicht/skoda.conf"
g._st["konfMtime"] = 0.0
# --- 15. Ladebeginn und Ladeende stossen einen Abruf an ------------------- # --- 15. Ladebeginn und Ladeende stossen einen Abruf an -------------------
# Bei 20 Anfragen je Stunde sind das die beiden wertvollsten Augenblicke: # Bei 20 Anfragen je Stunde sind das die beiden wertvollsten Augenblicke:
@@ -248,6 +382,104 @@ g._st["naechster"] = time.time() + 9999
asyncio.get_event_loop().run_until_complete(g.gatherData(wbKw=0.0, wbPlug=True)) asyncio.get_event_loop().run_until_complete(g.gatherData(wbKw=0.0, wbPlug=True))
pruefe("ohne Flanke kein Anstoss", g._st["naechster"] > time.time() + 1000, True) pruefe("ohne Flanke kein Anstoss", g._st["naechster"] > time.time() + 1000, True)
# --- 16. Ein Befehl aus der Weboberflaeche zieht den Abruf vor ------------
# ajax/skodaCmd.php beruehrt nach einem angenommenen Befehl eine Datei, deren
# Aenderungszeit der Zeitpunkt des Befehls ist. Hier wird sie untergeschoben,
# statt den Webserver zu bemuehen.
import os
import tempfile
anstoss = os.path.join(tempfile.gettempdir(), "skoda_abruf_pruefung")
g._ANSTOSS_ORTE = (anstoss,)
if os.path.exists(anstoss):
os.remove(anstoss)
g._st["anstoss"] = None
g._st["naechster"] = time.time() + 9999
g._anstossPruefen()
pruefe("ohne Datei kein Anstoss", g._st["naechster"] > time.time()+1000, True)
# Beim ersten Blick nach dem Start wird nur gemerkt. Der letzte Befehl kann
# Tage her sein - dafuer einen Abruf auszugeben, waere Verschwendung.
open(anstoss, "w").close()
frueher = time.time() - 86400
os.utime(anstoss, (frueher, frueher))
g._anstossPruefen()
pruefe("alter Befehl wird nicht nachgeholt", g._st["naechster"] > time.time()+1000, True)
# Jetzt der Befehl.
befehl = time.time()
os.utime(anstoss, (befehl, befehl))
g._anstossPruefen()
pruefe("Abruf wird vorgezogen",
abs(g._st["naechster"] - (befehl + g._NACH_BEFEHL)) < 1.0, True)
pruefe("und zwar nicht sofort", g._st["naechster"] > befehl + 20, True)
# Ohne neuen Befehl bleibt es beim Plan - sonst haenge der Abruf fest.
g._st["naechster"] = befehl + 5000
g._anstossPruefen()
pruefe("ohne neuen Befehl kein zweiter Anstoss", g._st["naechster"], befehl + 5000)
# Ein frueher geplanter Abruf wird nicht nach hinten geschoben.
g._st["naechster"] = befehl + 1
spaeter = befehl + 10
os.utime(anstoss, (spaeter, spaeter))
g._anstossPruefen()
pruefe("nur vorziehen, nie verzoegern", g._st["naechster"], befehl + 1)
os.remove(anstoss)
# --- 17. Wallbox-Verlauf waehrend einer Ladung ----------------------------
# Punkte im Minutentakt, solange Strom fliesst, einer sofort am Anfang und
# einer mit 0 kW am Ende. Die Zeit wird von Hand gestellt.
for st in g._ladepunkt.values():
st["zuletzt"] = 0.0
st["aktiv"] = False
def haus(kw, wh, ogkw=0.0, ogwh=829520):
g._haus.update({"wbKw": kw, "wbWh": wh, "wbogKw": ogkw, "wbogWh": ogwh,
"pvKw": 8.0, "gridKw": -1.5})
t0 = 1757750400.0 # 13.09.2026, 10:00 Ortszeit ungefaehr
haus(0.0, 7751734)
pruefe("ruhende Wallbox schreibt nichts", g._ladepunkteSammeln(t0), [])
haus(4.94, 7751740)
p = g._ladepunkteSammeln(t0 + 3)
pruefe("Beginn sofort festgehalten", len(p), 1)
pruefe("mit Wallbox, Leistung und Zaehler", (p[0][1], p[0][2], p[0][3]),
("carport", 4.94, 7751740))
pruefe("drei Sekunden spaeter nichts", g._ladepunkteSammeln(t0 + 6), [])
pruefe("59 Sekunden nach dem Beginn noch nichts", g._ladepunkteSammeln(t0 + 62), [])
pruefe("nach 60 Sekunden der naechste Punkt", len(g._ladepunkteSammeln(t0 + 63)), 1)
haus(0.0, 7776854)
p = g._ladepunkteSammeln(t0 + 70)
pruefe("Ende mit 0 kW", [(x[1], x[2], x[3]) for x in p], [("carport", 0.0, 7776854)])
pruefe("danach wieder Ruhe", g._ladepunkteSammeln(t0 + 200), [])
# Ein Zaehlerstand 0 ist ein ausgefallener Abruf, kein Stand.
haus(11.0, 0)
p = g._ladepunkteSammeln(t0 + 300)
pruefe("Zaehler 0 wird NULL", p[0][3], None)
# Beide Wallboxen unabhaengig voneinander.
haus(0.0, 7776900, ogkw=7.2, ogwh=829600)
p = g._ladepunkteSammeln(t0 + 400)
pruefe("Carport endet, Obergeschoss beginnt",
sorted((x[1], x[2]) for x in p), [("carport", 0.0), ("og", 7.2)])
# Und gatherData() stoesst das Schreiben tatsaechlich an.
for st in g._ladepunkt.values():
st["zuletzt"] = 0.0
st["aktiv"] = False
del geschrieben[:]
g._st["naechster"] = time.time() + 9999
schleife = asyncio.get_event_loop()
schleife.run_until_complete(g.gatherData(wbKw=10.8, wbPlug=True, wbWh=7760000))
schleife.run_until_complete(asyncio.sleep(0.2)) # dem Hintergrund Zeit geben
pruefe("gatherData schreibt den Punkt", [(x[1], x[2]) for x in geschrieben],
[("carport", 10.8)])
print() print()
if fehler: if fehler:
+8 -1
View File
@@ -438,7 +438,14 @@ async def repeat(interval):
rtData["p_l2ev"] = wp.power2 rtData["p_l2ev"] = wp.power2
rtData["p_l3ev"] = wp.power3 rtData["p_l3ev"] = wp.power3
rtData["evPower"] = wp.power rtData["evPower"] = wp.power
rtData["evPlug"] = wp.carConnected # carConnected ist ein Text ("no car", "charging", "ready",
# "complete"), kein Wahrheitswert - und bool("no car") ist True.
# So stand der Stecker der Wallbox im Carport dauerhaft auf
# gesteckt: in der Tabelle skoda, im Abruftakt von
# gatherSkodaData.py und als Steckersymbol im Realtime-SVG.
# "ready" heisst: Auto dran, wartet noch - also gesteckt.
# Als bool passt es zu evPlugOG und kommt per MQTT als 1/0 an.
rtData["evPlug"] = wp.carConnected not in (None, "no car")
# Gesamtzaehler beider Wallboxen in Wh. Die Differenz zweier # Gesamtzaehler beider Wallboxen in Wh. Die Differenz zweier
# Staende trifft eine Ladung auf die Wattstunde genau - genauer # Staende trifft eine Ladung auf die Wattstunde genau - genauer
# als jede Summe ueber die Fuenf-Minuten-Leistungswerte, deren # als jede Summe ueber die Fuenf-Minuten-Leistungswerte, deren