From 8c62455b2c07ebec113f838864d0af15a5f59bb0 Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Tue, 15 Sep 2026 09:29:57 +0200 Subject: [PATCH] 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 --- gatherSkodaData.py | 471 +++++++++++++++++++++++++++------ skoda.conf.example | 56 ++++ skoda_ladepunkte_nachtragen.py | 131 +++++++++ skoda_test.py | 318 +++++++++++++++++++--- solarManager.py | 9 +- 5 files changed, 858 insertions(+), 127 deletions(-) create mode 100644 skoda.conf.example create mode 100644 skoda_ladepunkte_nachtragen.py diff --git a/gatherSkodaData.py b/gatherSkodaData.py index 7d9e453..2509ab4 100644 --- a/gatherSkodaData.py +++ b/gatherSkodaData.py @@ -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"]: diff --git a/skoda.conf.example b/skoda.conf.example new file mode 100644 index 0000000..e1d1f2b --- /dev/null +++ b/skoda.conf.example @@ -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= diff --git a/skoda_ladepunkte_nachtragen.py b/skoda_ladepunkte_nachtragen.py new file mode 100644 index 0000000..ee835b0 --- /dev/null +++ b/skoda_ladepunkte_nachtragen.py @@ -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() diff --git a/skoda_test.py b/skoda_test.py index 85f2123..e420fd3 100644 --- a/skoda_test.py +++ b/skoda_test.py @@ -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: diff --git a/solarManager.py b/solarManager.py index 504396b..0db91b9 100644 --- a/solarManager.py +++ b/solarManager.py @@ -438,7 +438,14 @@ async def repeat(interval): rtData["p_l2ev"] = wp.power2 rtData["p_l3ev"] = wp.power3 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 # Staende trifft eine Ladung auf die Wattstunde genau - genauer # als jede Summe ueber die Fuenf-Minuten-Leistungswerte, deren