"""Anbindung der oeffentlichen MySkoda-API. Loest den Weg ueber den Kia ab, dessen Dateien mit dem Fahrzeug weg sind. Der lief ueber einen Cron-Job, der die Tabelle car fuellte, waehrend der Manager daraus nur die jeweils letzte Zeile wieder herauslas - Daten also durch die Datenbank hindurch von einem Prozess zum anderen. Hier laeuft alles im Manager selbst: der Abruf fuellt rtData direkt und schreibt seine eigene, ausfuehrliche Historie. Die API ist gegenueber dem frueheren Weg deutlich schlichter. Ein Header X-API-Key genuegt, kein Login, kein Token-Refresh. Der Schluessel wird in der MySkoda-App unter https://go.skoda.eu/api-keys erzeugt, ist an die dort ausgewaehlten Fahrzeuge gebunden und laeuft ab - jede erfolgreiche Antwort traegt X-API-Key-Expires-At mit, das Modul warnt rechtzeitig vorher. Ein einziger GET liefert Ladezustand, Verriegelung, Kilometerstand, 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: 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 bei so wenigen Anfragen sind genau diese beiden Augenblicke die 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 3-Sekunden-Takt des Managers damit nicht ausbremsen. """ import json import asyncio import aiohttp import logging import os import re import collections import time import datetime from typing import List, Optional from dataclasses import dataclass import mysql.connector as mc _LOGGER = logging.getLogger(__name__) _URL = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/" # Der include-Parameter bleibt bewusst weg. Ohne ihn liefert die API alles, # was das Fahrzeug unterstuetzt, und meldet UNSUPPORTED nur fuer Teile, die # ausdruecklich angefordert wurden - eine Liste haette also bei jedem Abruf # Fehler fuer alles erzeugt, was dieses Modell nicht kann. So passt sich der # Abruf von selbst an, ob ein reiner Stromer oder ein Plug-in-Hybrid ankommt, # und die Ladeprofile kommen ohne Zutun in skoda_raw mit. # Schluessel und VIN stehen bewusst nicht im Quelltext: der Schluessel laeuft # ab und muss dann getauscht werden. Die Datei wird bei jeder Aenderung neu # gelesen, ein Neustart des Managers ist dafuer nicht noetig. _KONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skoda.conf") # 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 # gemessenes Kontingent je Stunde, alle Schluessel _MAX_KEYS = 3 # mehr werden aus skoda.conf nicht gelesen # 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: # # dieses Modul 14 / Stunde # Befehle 4 / Stunde (ajax/skodaCmd.php) # Reserve 2 / Stunde # # 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. 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 = 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 _WARN_KEY = 14 # Tage vor Ablauf des Schluessels warnen _TIMEOUT = aiohttp.ClientTimeout(total=20) # Ladezustaende, in denen das Kabel steckt. _GESTECKT = ("CHARGING", "CONSERVING", "READY_FOR_CHARGING", "CHARGING_INTERRUPTED", "DISCHARGING") @dataclass class SkodaData: # --- Identitaet ------------------------------------------------------- vin:str = "" name:str = "" plate:str = "" # --- Batterie und Laden ---------------------------------------------- soc:int = 0 # Prozent range_m:int = 0 # Restreichweite, Einheit wie geliefert range_km:float = 0.0 # daraus abgeleitet, immer Kilometer chgState:str = "" # CHARGING, CONSERVING, CONNECT_CABLE, ... chgType:str = "" # AC, DC, OFF chgKw:float = 0.0 # Ladeleistung laut Fahrzeug chgKmh:float = 0.0 # Ladegeschwindigkeit in km/h chgRemMin:int = 0 # Restladezeit in Minuten chgFullAt:Optional[datetime.datetime] = None savedLoc:bool = False # steht an einem gespeicherten Ladeort # --- Ladeeinstellungen ------------------------------------------------ targetSoc:int = 0 careTargetSoc:int = 0 careMode:bool = False # Batterieschonung aktiv chgMode:str = "" # MANUAL, TIMER, ... maxAc:str = "" # REDUCED, MAXIMUM maxAcA:int = 0 # Ampere-Grenze autoUnlock:bool = False # --- Zustand ---------------------------------------------------------- locked:Optional[bool] = None # None, wenn das Fahrzeug UNKNOWN meldet doorsLocked:str = "" doors:str = "" windows:str = "" lights:str = "" sunroof:str = "" trunk:str = "" bonnet:str = "" odoKm:int = 0 # --- Verbrenner, nur bei Hybrid oder Verbrenner besetzt --------------- carType:str = "" # HYBRID, GASOLINE, DIESEL, CNG, LPG totalRangeKm:float = 0.0 # Gesamtreichweite ueber alle Antriebe adBlueKm:float = 0.0 eng1Type:str = "" # ELECTRIC, GASOLINE, DIESEL, ... eng1Soc:int = 0 eng1FuelPct:int = 0 eng1RangeKm:float = 0.0 eng2Type:str = "" eng2Soc:int = 0 eng2FuelPct:int = 0 eng2RangeKm:float = 0.0 fuelPct:int = 0 # Tankfuellung, unabhaengig davon, an # welcher der beiden Motorstellen der # Verbrenner gemeldet wird # --- Position --------------------------------------------------------- parkState:str = "" # PARKED, IN_MOTION lat:Optional[float] = None lon:Optional[float] = None address:str = "" # --- Klima ------------------------------------------------------------ acState:str = "" acTargetC:Optional[float] = None acWinFront:Optional[bool] = None acWinRear:Optional[bool] = None auxState:str = "" ventState:str = "" # --- Zeitstempel des Fahrzeugs --------------------------------------- # Die Antwort setzt sich aus mehreren Quellen zusammen, jede mit eigenem # Stand. Alle vier mitzufuehren zeigt spaeter, wie alt ein Wert war. capChg:Optional[datetime.datetime] = None capStatus:Optional[datetime.datetime] = None capOdo:Optional[datetime.datetime] = None capFuel:Optional[datetime.datetime] = None capAc:Optional[datetime.datetime] = None # --- Betrieb des Moduls ---------------------------------------------- error:int = 0 # aufeinanderfolgende Fehlversuche httpStatus:int = 0 apiErrors:str = "" # Fehlerliste der Antwort, kommagetrennt 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 ret = SkodaData() # Modul-Singleton, ueberlebt zwischen Aufrufen # Alles, was nur den Ablauf steuert und nicht nach aussen gehoert. _st = { "naechster": 0.0, # fruehester naechster Abruf "laeuft": False, # ein Abruf ist unterwegs "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 "konfMtime": 0.0, "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 - 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, "pvKw":0.0, "gridKw":0.0, "wbWh":0, "wbogWh":0} _db = {"host":"localhost", "port":3310, "user":"solarLog", "passwd":"", "database":"solarLog"} # --------------------------------------------------------------------------- # Kleinkram # --------------------------------------------------------------------------- _ISO = re.compile(r"(\d{4})-(\d\d)-(\d\d)[T ](\d\d):(\d\d):(\d\d)" r"(?:\.\d+)?(Z|[+-]\d\d:?\d\d)?") def _zeit(s) -> Optional[datetime.datetime]: """ISO-8601 der API in lokale, naive Zeit fuer MySQL DATETIME. Die API liefert UTC mit Z. Der Rest der Datenbank steht in Ortszeit, also wird hier umgerechnet - sonst laegen Ladevorgaenge im Sommer zwei Stunden neben den Zaehlerwerten in EnergyFlow, mit denen sie verglichen werden sollen. datetime.fromisoformat kann in Python 3.8 weder Z noch beliebige Bruchteile, daher der eigene Ausdruck. """ if not s: return None m = _ISO.match(str(s).strip()) if not m: return None y, mo, d, h, mi, se, off = m.groups() dt = datetime.datetime(int(y), int(mo), int(d), int(h), int(mi), int(se)) if not off: return dt # ohne Zone: schon lokal if off != "Z": vz = 1 if off[0] == "+" else -1 off = off[1:].replace(":", "") dt -= vz*datetime.timedelta(hours=int(off[:2]), minutes=int(off[2:])) return dt.replace(tzinfo=datetime.timezone.utc).astimezone().replace(tzinfo=None) def _janein(v) -> Optional[bool]: """YES/NO/ON/OFF/ACTIVATED der API in bool, UNKNOWN in None. Ein fehlender Wert und ein ausdrueckliches UNKNOWN sind nicht dasselbe wie ein Nein. Beim Kia wurde beides zu 0 und war hinterher nicht mehr zu unterscheiden; deshalb hier None. """ if v is None: return None v = str(v).upper() if v in ("YES", "ON", "TRUE", "ACTIVATED", "LOCKED", "PERMANENT"): return True if v in ("NO", "OFF", "FALSE", "DEACTIVATED", "UNLOCKED"): return False return None 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=... 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 _schluessel: _LOGGER.warning("skoda.conf nicht mehr lesbar, benutze den letzten Stand.") return True return False if mtime == _st["konfMtime"]: return bool(_schluessel and _st["vin"]) werte = {} try: with open(_KONFIG, "r") as f: for zeile in f: zeile = zeile.strip() if not zeile or zeile.startswith("#") or "=" not in zeile: continue k, v = zeile.split("=", 1) werte[k.strip().upper()] = v.strip().strip('"').strip("'") except OSError as e: _LOGGER.error("skoda.conf nicht lesbar: "+str(e)) return bool(_schluessel and _st["vin"]) _st["konfMtime"] = mtime _st["vin"] = werte.get("VIN", "") _st["eigenerCmdKey"] = bool(werte.get("CMD_API_KEY")) _db["passwd"] = werte.get("DB_PASSWORD", _db["passwd"]) _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:] +", "+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. Alternativ steht DB_PASSWORD in skoda.conf. So oder so taucht es hier nicht im Quelltext auf. """ _db["passwd"] = pw # --------------------------------------------------------------------------- # Antwort auswerten # --------------------------------------------------------------------------- def uebernehmen(antwort:dict): """Eine Antwort des Fahrzeugs in ret uebertragen. Bewusst eine eigene Funktion ohne Netz und ohne Datenbank: so laesst sich die Zuordnung mit einer gespeicherten Antwort pruefen, bevor das Auto da ist (siehe __main__ am Dateiende). Fehlende Teile werden uebersprungen statt genullt. Die API laesst einen Teil weg, wenn sie ihn gerade nicht bekommt, und legt dann einen Eintrag in errors ab - der alte Wert ist dann die bessere Auskunft als eine Null. """ v = antwort.get("vehicle") or {} fehler = [] for e in antwort.get("errors") or []: if e.get("type"): fehler.append(e["type"]) ret.apiErrors = ",".join(fehler)[:255] if v.get("vin"): ret.vin = v["vin"] if v.get("name"): ret.name = v["name"] if v.get("licensePlate"): ret.plate = v["licensePlate"] # --- Laden ------------------------------------------------------------ chg = v.get("charging") if chg: ret.savedLoc = bool(chg.get("isVehicleInSavedLocation", False)) ret.capChg = _zeit(chg.get("carCapturedTimestamp")) or ret.capChg s = chg.get("status") or {} ret.chgState = s.get("state", ret.chgState) or "" ret.chgType = s.get("chargeType", ret.chgType) or "" if s.get("chargePowerInKw") is not None: ret.chgKw = round(float(s["chargePowerInKw"]), 3) elif ret.chgState not in _GESTECKT: ret.chgKw = 0.0 if s.get("chargingRateInKilometersPerHour") is not None: ret.chgKmh = round(float(s["chargingRateInKilometersPerHour"]), 2) if s.get("remainingTimeToFullyChargedInMinutes") is not None: ret.chgRemMin = int(s["remainingTimeToFullyChargedInMinutes"]) ret.chgFullAt = _zeit(s.get("fullyChargedAt")) or ret.chgFullAt b = s.get("battery") or {} if b.get("stateOfChargeInPercent") is not None: ret.soc = int(b["stateOfChargeInPercent"]) if b.get("remainingCruisingRangeInMeters") is not None: ret.range_m = int(b["remainingCruisingRangeInMeters"]) # Das Feld heisst Meter, das Beispiel der Spec (249) sieht nach # Kilometern aus. Der Rohwert wird unveraendert mitgeschrieben, # abgeleitet wird nur diese Anzeige: ueber 1500 kann nur Meter # gemeint sein, ein Akku traegt keine 1500 Kilometer. ret.range_km = round(ret.range_m/1000.0, 1) if ret.range_m > 1500 else float(ret.range_m) cs = chg.get("settings") or {} if cs.get("targetStateOfChargeInPercent") is not None: ret.targetSoc = int(cs["targetStateOfChargeInPercent"]) if cs.get("batteryCareModeTargetValueInPercent") is not None: ret.careTargetSoc = int(cs["batteryCareModeTargetValueInPercent"]) cm = _janein(cs.get("chargingCareMode")) if cm is not None: ret.careMode = cm ret.chgMode = cs.get("preferredChargeMode", ret.chgMode) or "" ret.maxAc = cs.get("maxChargeCurrentAc", ret.maxAc) or "" if cs.get("maxChargeCurrentAcAmpere") is not None: ret.maxAcA = int(cs["maxChargeCurrentAcAmpere"]) au = _janein(cs.get("autoUnlockPlugWhenCharged")) if au is not None: ret.autoUnlock = au # --- Tueren, Fenster, Licht ------------------------------------------- st = v.get("status") if st: ret.capStatus = _zeit(st.get("carCapturedTimestamp")) or ret.capStatus o = st.get("overall") or {} ret.locked = _janein(o.get("locked")) ret.doorsLocked = o.get("doorsLocked", "") or "" ret.doors = o.get("doors", "") or "" ret.windows = o.get("windows", "") or "" ret.lights = o.get("lights", "") or "" d = st.get("detail") or {} ret.sunroof = d.get("sunroof", "") or "" ret.trunk = d.get("trunk", "") or "" ret.bonnet = d.get("bonnet", "") or "" # --- Kilometerstand --------------------------------------------------- od = v.get("odometer") if od and od.get("mileageInKm") is not None: ret.odoKm = int(od["mileageInKm"]) ret.capOdo = _zeit(od.get("carCapturedTimestamp")) or ret.capOdo # --- Verbrenner ------------------------------------------------------- # Ein reiner Stromer laesst diesen Block weg, dann bleiben die Spalten # NULL. Ein Hybrid liesse sich sonst nachtraeglich nicht auswerten - was # hier nicht mitgeschrieben wird, ist fuer immer fort. fs = v.get("fuelStatus") if fs: ret.carType = fs.get("carType", ret.carType) or "" ret.capFuel = _zeit(fs.get("carCapturedTimestamp")) or ret.capFuel if fs.get("totalRangeInKm") is not None: ret.totalRangeKm = round(float(fs["totalRangeInKm"]), 1) if fs.get("adBlueRange") is not None: ret.adBlueKm = round(float(fs["adBlueRange"]), 1) for nr, schluessel in ((1, "primaryEngineRange"), (2, "secondaryEngineRange")): er = fs.get(schluessel) or {} if not er: continue setattr(ret, "eng"+str(nr)+"Type", er.get("engineType", "") or "") if er.get("currentSoCInPercent") is not None: setattr(ret, "eng"+str(nr)+"Soc", int(er["currentSoCInPercent"])) if er.get("currentFuelLevelInPercent") is not None: setattr(ret, "eng"+str(nr)+"FuelPct", int(er["currentFuelLevelInPercent"])) if er.get("remainingRangeInKm") is not None: setattr(ret, "eng"+str(nr)+"RangeKm", round(float(er["remainingRangeInKm"]), 1)) if (er.get("engineType") not in (None, "ELECTRIC") and er.get("currentFuelLevelInPercent") is not None): ret.fuelPct = int(er["currentFuelLevelInPercent"]) # --- Position --------------------------------------------------------- pp = v.get("parkingPosition") if pp: ret.parkState = pp.get("state", ret.parkState) or "" g = pp.get("gpsCoordinates") or {} if g.get("latitude") is not None: ret.lat = round(float(g["latitude"]), 6) ret.lon = round(float(g["longitude"]), 6) ret.address = (pp.get("formattedAddress") or ret.address)[:160] # --- Klima ------------------------------------------------------------ ac = v.get("airConditioning") if ac: ret.acState = ac.get("state", ret.acState) or "" ret.capAc = _zeit(ac.get("carCapturedTimestamp")) or ret.capAc tt = ac.get("targetTemperature") or {} if tt.get("value") is not None: t = float(tt["value"]) if str(tt.get("unit", "CELSIUS")).upper() == "FAHRENHEIT": t = (t-32.0)*5.0/9.0 ret.acTargetC = round(t, 1) wh = ac.get("windowHeating") or {} ret.acWinFront = _janein(wh.get("front")) ret.acWinRear = _janein(wh.get("rear")) aux = v.get("auxiliaryHeating") if aux: ret.auxState = aux.get("state", ret.auxState) or "" vt = v.get("activeVentilation") if vt: ret.ventState = vt.get("state", ret.ventState) or "" def _signatur() -> tuple: """Fingerabdruck der Werte, deren Aenderung eine neue Zeile rechtfertigt. Das Fahrzeug meldet sich nur, wenn es etwas zu melden hat; zwischendurch liefert die Cloud denselben Stand erneut. Ohne diesen Vergleich stuenden in der Tabelle vor allem Wiederholungen. Die hausseitigen Werte gehen absichtlich nicht ein - die stehen ohnehin alle 300 s in EnergyFlow. chgKw gehoert dagegen hinein, obwohl es waehrend des Ladens fast jeden Abruf veraendert: genau diese Punkte sind die Ladekurve. Ohne den Wert entstuende eine Zeile erst, wenn der Ladestand um einen Prozentpunkt weiterspringt - der Knick, an dem das Fahrzeug abregelt, faende sich hinterher nicht wieder. """ return (ret.capChg, ret.capStatus, ret.capOdo, ret.capFuel, ret.capAc, ret.soc, ret.range_m, ret.chgState, ret.chgType, ret.chgRemMin, ret.chgKw, ret.locked, ret.doorsLocked, ret.doors, ret.windows, ret.lights, ret.sunroof, ret.trunk, ret.bonnet, ret.odoKm, ret.carType, ret.eng1FuelPct, ret.eng1RangeKm, ret.eng2FuelPct, ret.eng2RangeKm, ret.totalRangeKm, ret.parkState, ret.lat, ret.lon, ret.acState, ret.auxState, ret.ventState, ret.targetSoc, ret.chgMode, ret.maxAcA, ret.careMode) def _intervall() -> float: """Wie lange bis zum naechsten Abruf. Waehrend des Ladens dicht, damit die Ladekurve genug Stuetzstellen hat - eine DC-Ladung ist nach einer halben Stunde vorbei. Beim Parken weit, weil sich dann ohnehin nichts aendert und das Kontingent begrenzt ist. """ if ret.error: # Nach einem Fehler zurueckhaltend erneut versuchen. Ist die Cloud weg, # 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 float(_I_LADEN) if ret.parkState == "IN_MOTION": return float(_I_FAHRT) if _haus["wbPlug"] or ret.chgState in _GESTECKT: return float(_I_GESTECKT) if time.time() - _st["letzteAend"] > _RUHE_AB: return float(_I_RUHE) return float(_I_AKTIV) def _gezaehlt(jetzt:float): """Eine Anfrage in die Stundenbilanz aufnehmen.""" _verbrauch.append(jetzt) _aufraeumen(jetzt) def _aufraeumen(jetzt:float): while _verbrauch and jetzt - _verbrauch[0] >= _FENSTER: _verbrauch.popleft() def _budgetSperre(jetzt:float) -> float: """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. 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) warten = max(0.0, _st["sperre"] - jetzt) if len(_verbrauch) >= _BUDGET: warten = max(warten, _verbrauch[0] + _FENSTER + 5.0 - jetzt) return warten 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 endgueltig, und erklaert die Header zur massgeblichen Quelle. Bleiben im laufenden Fenster noch n Anfragen und laeuft es in t Sekunden ab, dann 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 sch.rest = rest if rest < 0 or reset < 0: return 0.0 if rest == 0: return float(reset) + 5.0 return (float(reset)/rest)*1.2 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 sch.ablauf = ts tage = (ts - datetime.datetime.now()).total_seconds()/86400.0 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.") # --------------------------------------------------------------------------- # Historie # --------------------------------------------------------------------------- _SPALTEN = ("datetime, cap_chg, cap_status, cap_odo, cap_fuel, cap_ac, " "soc, range_m, chg_state, chg_type, chg_kw, chg_kmh, chg_rem_min, " "chg_full_at, saved_loc, target_soc, care_target_soc, care_mode, " "chg_mode, max_ac, max_ac_a, auto_unlock, " "locked, doors_locked, doors, windows, lights, sunroof, trunk, bonnet, " "odo_km, car_type, total_range_km, adblue_km, " "eng1_type, eng1_soc, eng1_fuel_pct, eng1_range_km, " "eng2_type, eng2_soc, eng2_fuel_pct, eng2_range_km, " "park_state, lat, lon, address, " "ac_state, ac_target_c, ac_win_front, ac_win_rear, aux_state, vent_state, " "wb_kw, wb_plug, wb_wh_total, wbog_kw, wbog_plug, wbog_wh_total, " "pv_kw, grid_kw, " "http_status, api_errors, rl_remaining, key_expires") def _leer(v): """Leere Zeichenkette als NULL schreiben. Kein Wert und ein leerer Wert sind in der Auswertung nicht dasselbe. Mit NULL genuegt spaeter IS NULL, sonst muesste jede Abfrage zusaetzlich an den Leerstring denken - und wer das einmal vergisst, zaehlt Fahrzeuge ohne Verbrenner als Fahrzeuge mit leerem Tank. """ return v if v else None def _werte() -> tuple: return (datetime.datetime.now().replace(microsecond=0), ret.capChg, ret.capStatus, ret.capOdo, ret.capFuel, ret.capAc, ret.soc, ret.range_m, _leer(ret.chgState), _leer(ret.chgType), ret.chgKw, ret.chgKmh, ret.chgRemMin, ret.chgFullAt, ret.savedLoc, ret.targetSoc, ret.careTargetSoc, ret.careMode, _leer(ret.chgMode), _leer(ret.maxAc), ret.maxAcA, ret.autoUnlock, ret.locked, _leer(ret.doorsLocked), _leer(ret.doors), _leer(ret.windows), _leer(ret.lights), _leer(ret.sunroof), _leer(ret.trunk), _leer(ret.bonnet), ret.odoKm, _leer(ret.carType), ret.totalRangeKm, ret.adBlueKm, _leer(ret.eng1Type), ret.eng1Soc, ret.eng1FuelPct, ret.eng1RangeKm, _leer(ret.eng2Type), ret.eng2Soc, ret.eng2FuelPct, ret.eng2RangeKm, _leer(ret.parkState), ret.lat, ret.lon, _leer(ret.address), _leer(ret.acState), ret.acTargetC, ret.acWinFront, ret.acWinRear, _leer(ret.auxState), _leer(ret.ventState), round(_haus["wbKw"], 3), _haus["wbPlug"], _haus["wbWh"], round(_haus["wbogKw"], 3), _haus["wbogPlug"], _haus["wbogWh"], round(_haus["pvKw"], 3), round(_haus["gridKw"], 3), 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. Blockierend - wird nur ueber run_in_executor aufgerufen, damit der 3-Sekunden-Takt des Managers nicht daran haengt. Die Rohantwort mitzuschreiben kostet wenig und rettet spaeter viel: taucht ein Feld auf, das hier noch nicht zugeordnet ist, laesst es sich aus der Historie nachtragen, statt erst ab dem Tag der Erkenntnis zu existieren. """ werte = _werte() platz = ",".join(["%s"]*len(werte)) try: with mc.connect(**_db) as verbindung: with verbindung.cursor() as cursor: cursor.execute("INSERT INTO skoda ("+_SPALTEN+") VALUES ("+platz+");", werte) zeile = cursor.lastrowid if rohtext: cursor.execute("INSERT INTO skoda_raw (datetime, sample_id, payload) " "VALUES (%s,%s,%s);", (datetime.datetime.now().replace(microsecond=0), zeile, rohtext)) verbindung.commit() except Exception as e: _LOGGER.error("Skoda-Historie nicht geschrieben: "+str(e)) # --------------------------------------------------------------------------- # Abruf # --------------------------------------------------------------------------- async def _abrufen(): """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, 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 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, 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 - # 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 "+sch.name+" ab (HTTP " +str(response.status)+") - in der MySkoda-App " "erneuern und skoda.conf ueberschreiben.") rohtext = "" else: ret.error += 1 _LOGGER.warning("Skoda-API antwortet mit HTTP "+str(response.status)) rohtext = "" except asyncio.TimeoutError: ret.error += 1 _LOGGER.warning("Skoda-API antwortet nicht rechtzeitig.") except Exception as e: ret.error += 1 _LOGGER.warning("Skoda-API nicht erreichbar: "+str(e)) jetzt = time.time() # 401 und 403 zaehlen laut Doku nicht gegen das Kontingent, alles andere # schon - auch ein 500 aus einer Stoerung bei Skoda. if ret.httpStatus not in (401, 403): _gezaehlt(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 sig = _signatur() geaendert = sig != _st["signatur"] if geaendert: _st["letzteAend"] = jetzt # Ohne Aenderung trotzdem gelegentlich eine Zeile: sonst ist hinterher # nicht zu unterscheiden, ob das Auto stillstand oder das Modul stand. if geaendert or (jetzt - _st["letzteZeile"]) >= _HEARTBEAT: _st["signatur"] = sig _st["letzteZeile"] = jetzt try: await asyncio.get_event_loop().run_in_executor(None, _schreiben, rohtext) except Exception as e: _LOGGER.error("Skoda-Historie nicht geschrieben: "+str(e)) async def gatherData(wbKw:float=0.0, wbPlug:bool=False, wbogKw:float=0.0, wbogPlug:bool=False, pvKw:float=0.0, gridKw:float=0.0, wbWh:int=0, wbogWh:int=0) -> SkodaData: """Letzten bekannten Fahrzeugstand liefern, bei Bedarf einen Abruf anstossen. Kehrt sofort zurueck. Der eigentliche Abruf laeuft im Hintergrund, damit eine langsame Cloud-Antwort den 3-Sekunden-Takt des Managers nicht verzoegert; das Ergebnis steht dann beim naechsten Aufruf bereit. Die hausseitigen Werte kommen vom Manager mit und werden neben dem Fahrzeugstand protokolliert. Erst dadurch wird die Batterie messbar: die Wallbox zaehlt die eingespeiste Energie, das Fahrzeug meldet den Ladestand, und aus kWh je SoC-Prozent ergibt sich die nutzbare Kapazitaet und ihr Verlauf ueber die Jahre. """ _haus["wbKw"] = wbKw _haus["wbPlug"] = bool(wbPlug) _haus["wbogKw"] = wbogKw _haus["wbogPlug"] = bool(wbogPlug) _haus["pvKw"] = pvKw _haus["gridKw"] = gridKw # Gesamtzaehlerstaende der Wallboxen in Wh. Die Energie einer Ladung ist # dann die Differenz zweier Staende statt einer Summe ueber gemittelte # Leistungswerte - der Fehler an den Raendern des Ladevorgangs entfaellt. _haus["wbWh"] = int(wbWh or 0) _haus["wbogWh"] = int(wbogWh or 0) # Die Wallbox merkt Anfang und Ende einer Ladung sofort, das Fahrzeug # erst beim naechsten Abruf. Bei 20 Anfragen je Stunde sind genau diese # beiden Augenblicke die wertvollsten: der Ladestand davor und danach # bestimmt die Kapazitaetsrechnung, waehrend ein Punkt mitten in der # Kurve wenig beitraegt. Beide Flanken stossen deshalb einen Abruf an - # die Budgetsperre kann ihn trotzdem noch verzoegern. laedt = bool(wbPlug) and wbKw > 0.5 if laedt != _st["ladenVorher"]: _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"]: return ret if not _konfig(): # Noch kein Schluessel hinterlegt. In Ruhe erneut nachsehen, statt die # Datei alle drei Sekunden zu suchen. _st["naechster"] = time.time() + 60 return ret _st["laeuft"] = True if True: async def lauf(): try: await _abrufen() finally: _st["laeuft"] = False asyncio.ensure_future(lauf()) return ret if __name__ == "__main__": # Pruefung ohne Fahrzeug: gespeicherte Antwort einlesen und zeigen, was # daraus in der Tabelle landen wuerde. # python3 gatherSkodaData.py antwort.json import sys import pprint logging.basicConfig(level=logging.DEBUG) with open(sys.argv[1], "r") as f: uebernehmen(json.load(f)) pprint.pprint(ret) print() for name, wert in zip(_SPALTEN.replace(" ", "").split(","), _werte()): print(name.ljust(16), wert)