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
+275 -43
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,
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
"""
@@ -12,6 +17,11 @@ import datetime
import time
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 = []
@@ -23,6 +33,25 @@ def frisch():
g.ret = g.SkodaData()
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 ---------------------------------------------
r = frisch()
@@ -112,33 +141,55 @@ pruefe("gesteckt behaelt den Wert", r.chgKw, 10.8)
# --- 9. Abrufintervall folgt der Lage ------------------------------------
r = frisch()
schluessel(1)
g._st["letzteAend"] = 0.0
g._haus["wbPlug"] = False; g._haus["wbKw"] = 0.0
r.chgState = "CHARGING"
pruefe("laedt", g._intervall(), g._I_LADEN)
pruefe("laedt", g._intervall(), float(g._I_LADEN))
r.chgState = ""
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
pruefe("gesteckt, laedt nicht", g._intervall(), g._I_GESTECKT)
g._haus["wbPlug"] = False
r.parkState = "IN_MOTION"
pruefe("unterwegs", g._intervall(), g._I_FAHRT)
pruefe("unterwegs", g._intervall(), float(g._I_FAHRT))
r.parkState = "PARKED"
pruefe("lange nichts", g._intervall(), g._I_RUHE)
r.error = 3
pruefe("nach Fehlern zurueckhaltend", g._intervall(), 240.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 ------------------------------------
r = frisch()
sch = schluessel(1)[0]
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(
{"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(
{"RateLimit-Remaining":"0","RateLimit-Reset":"120"}), 125.0)
pruefe("ohne Header kein Einfluss", g._kontingent({}), 0.0)
{"RateLimit-Remaining":"0","RateLimit-Reset":"120"}, sch), 125.0)
pruefe("ohne Header kein Einfluss", g._kontingent({}, sch), 0.0)
# --- 11. Nur echte Aenderungen erzeugen eine Zeile ------------------------
r = frisch()
@@ -185,51 +236,134 @@ g.uebernehmen({"vehicle":{"charging":{"status":{"battery":
pruefe("Stromer ohne Tank", (r.carType, r.fuelPct), ("", 0))
# --- 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
# ganzer Tag Dauerladen, ohne echte Zeit und ohne Netz.
g._verbrauch.clear()
zeitpunkte = []
t = 0.0
while t < 24*3600:
zeitpunkte.append(t)
g._gezaehlt(t)
t += max(g._I_MIN, g._I_LADEN, g._budgetSperre(t))
schlimmste = 0
for i, t0 in enumerate(zeitpunkte):
in_fenster = sum(1 for t1 in zeitpunkte[i:] if t1 - t0 < 3600)
schlimmste = max(schlimmste, in_fenster)
def dauerlast(anzahl, eigenerCmdKey=False):
"""Einen Tag Dauerladen durchspielen. Liefert Zeitpunkte je Schluessel."""
schluessel(anzahl, eigenerCmdKey)
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)
punkte[sch.name].append(t)
t += takt
return punkte, gebremst
def schlimmstesFenster(zeiten):
"""Die meisten Anfragen, die in irgendeine Stunde fallen."""
return max([len([x for x in zeiten if t0 <= x < t0+3600])
for t0 in zeiten] or [0])
def alleZeiten(punkte):
zeiten = []
for z in punkte.values():
zeiten.extend(z)
return sorted(zeiten)
punkte, gebremst = dauerlast(1)
schlimmste = schlimmstesFenster(alleZeiten(punkte))
pruefe("nie mehr als das Limit je Stunde", schlimmste <= g._LIMIT, True)
pruefe("das eigene Budget wird eingehalten", schlimmste <= g._BUDGET_GETEILT, True)
pruefe("und auch ausgenutzt", schlimmste >= g._BUDGET_GETEILT - 3, True)
pruefe("das eigene Budget wird eingehalten", schlimmste <= g._BUDGET, 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.
g._st["eigenerCmdKey"] = True
g._verbrauch.clear()
for i in range(g._BUDGET_GETEILT):
g._gezaehlt(2000.0 + i)
pruefe("geteiltes Budget reicht dem Modul allein nicht",
g._budgetSperre(2000.0 + g._BUDGET_GETEILT), 0.0)
g._verbrauch.clear()
for i in range(g._BUDGET_ALLEIN):
g._gezaehlt(2000.0 + i)
pruefe("eigenes Budget greift bei 18", g._budgetSperre(2000.0 + g._BUDGET_ALLEIN) > 3500, True)
g._st["eigenerCmdKey"] = False
g._verbrauch.clear()
# Der entscheidende Punkt: drei Schluessel duerfen NICHT mehr Anfragen
# ergeben. Sie teilen sich einen Zaehler - wer hier mehr misst, hat die
# Staffel wieder eingebaut und wird im Betrieb mit 429 bezahlt.
punkte3, gebremst3 = dauerlast(3)
pruefe("drei Schluessel halten dasselbe Limit",
schlimmstesFenster(alleZeiten(punkte3)) <= g._BUDGET, True)
pruefe("und bringen genauso viele Abrufe wie einer",
len(alleZeiten(punkte3)), len(alleZeiten(punkte)))
for name, zeiten in punkte3.items():
pruefe(name+" kommt reihum dran", len(zeiten) > 0, True)
# Ein einzelner Anstoss ausser der Reihe darf das Budget nicht sprengen.
g._verbrauch.clear()
for i in range(g._BUDGET_GETEILT):
g._gezaehlt(1000.0 + i)
pruefe("Budget voll: Sperre greift", g._budgetSperre(1000.0+g._BUDGET_GETEILT) > 3500, True)
g._verbrauch.clear()
pruefe("Budget leer: keine Sperre", g._budgetSperre(0.0), 0.0)
# Ein eigener Schluessel fuer die Steuerung aendert am Kontingent nichts
# mehr - er trennt nur, wer womit fragt.
punkte, _ = dauerlast(1, eigenerCmdKey=True)
pruefe("eigener Befehlsschluessel gibt dem Abruf nicht mehr",
schlimmstesFenster(alleZeiten(punkte)) <= g._BUDGET, True)
# 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 -------------------------------------
g.ret = g.SkodaData()
sch = schluessel(1)[0]
pruefe("19 Anfragen in 3400 s", round(g._kontingent(
{"RateLimit-Remaining":"19","RateLimit-Reset":"3400"}),0), 215.0)
pruefe("Restkontingent gemerkt", g.ret.rlRemaining, 19)
{"RateLimit-Remaining":"19","RateLimit-Reset":"3400"}, sch),0), 215.0)
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 -------------------
# 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))
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()
if fehler: