Files
SolarManager/gatherSkodaData.py
adminandClaude Opus 5 8c62455b2c Skoda: mehrere Schluessel, Wallbox-Ladeverlauf, Stecker als 1/0
- gatherSkodaData.py liest bis zu drei API-Schluessel und nutzt sie reihum;
  das Kontingent gilt gemessen fuers ganze Konto, daher feste Aufteilung
  14 Abruf / 4 Befehle / 2 Reserve
- nach einem Befehl aus der Weboberflaeche wird einmal ausser der Reihe
  nachgesehen
- Wallbox-Verlauf waehrend einer Ladung nach skoda_ladepunkte, dazu
  skoda_ladepunkte_nachtragen.py fuer Ladungen aus EnergyFlow
- evPlug ist jetzt ein Wahrheitswert: bool("no car") war immer True
- skoda.conf.example beschreibt das gemeinsame Format

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:29:57 +02:00

1145 lines
49 KiB
Python

"""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)