"""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: 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. 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. 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, field 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") # 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. # # Fehlerantworten ab 500 zaehlen laut Doku mit, 401 und 403 nicht. _LIMIT = 20 # bekanntes Kontingent je Stunde # 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: # # 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 # # Umgestellt wird von selbst, sobald der zweite Schluessel auftaucht - von # Hand ist hier nichts nachzuziehen. _BUDGET_ALLEIN = 18 _BUDGET_GETEILT = 14 _FENSTER = 3600 # Bezugszeitraum des Kontingents # Abrufintervalle in Sekunden. _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 _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 rlRemaining:int = -1 # Restkontingent laut RateLimit-Header keyExpires:Optional[datetime.datetime] = None 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 "keyGewarnt": False, "konfMtime": 0.0, "apiKey": "", "vin": "", "ladenVorher": False, # Hausseite lieferte beim letzten Aufruf Strom "eigenerCmdKey": False, # Steuerung hat einen eigenen Schluessel } # Zeitpunkte der Anfragen der letzten Stunde. 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() # 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=... Der Schluessel laeuft ab. Weil die Datei bei jeder Aenderung neu gelesen wird, genuegt zum Tausch das Ueberschreiben - der Manager laeuft weiter. """ try: mtime = os.path.getmtime(_KONFIG) except OSError: if _st["apiKey"]: _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"]) 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(_st["apiKey"] 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"]: _LOGGER.error("skoda.conf braucht API_KEY und VIN.") return False ret.vin = _st["vin"] _LOGGER.info("skoda.conf gelesen, VIN endet auf "+_st["vin"][-4:] +(", eigener Schluessel fuer die Steuerung" if _st["eigenerCmdKey"] else ", ein Schluessel fuer alles")) return True 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 _I_LADEN if ret.parkState == "IN_MOTION": return _I_FAHRT if _haus["wbPlug"] or ret.chgState in _GESTECKT: return _I_GESTECKT if time.time() - _st["letzteAend"] > _RUHE_AB: return _I_RUHE return _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: """Fruehester Zeitpunkt, zu dem wieder eine Anfrage frei ist. 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. """ _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 def _kontingent(headers): """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. """ try: rest = int(headers.get("RateLimit-Remaining", -1)) reset = int(headers.get("RateLimit-Reset", -1)) except (TypeError, ValueError): return 0.0 ret.rlRemaining = 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): """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 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 " +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) 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.""" kopf = {"X-API-Key": _st["apiKey"], "Accept": "application/json"} url = _URL + _st["vin"] rohtext = "" abstand = 0.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) 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 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 " +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 abstand = max(abstand, float(_I_RUHE)) _LOGGER.error("Skoda-API weist den Schluessel 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) _st["naechster"] = jetzt + max(_I_MIN, _intervall(), abstand, _budgetSperre(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()) 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)