Files
SolarManager/gatherSkodaData.py
T
adminandClaude Opus 5 79843aa2ae SolarManager unter Versionsverwaltung
Erster Stand der Hintergrundprozesse, die auf der Synology unter
/volume1/homes/wagner/SolarManager laufen: der Manager selbst, die Sammler
je Geraet, die MQTT-Bruecke, der Wecker und - neu hinzugezogen - der
AutoAction-Runner, der als Hintergrundprozess hierher gehoert und nicht ins
Web-Verzeichnis.

Zugangsdaten stehen nicht mehr im Quelltext, sondern in config.ini, die
nicht mit eingecheckt wird. Vorlage ist config.ini.example, gelesen wird sie
von konfig.py. Betroffen waren solarManager.py (Datenbank und Wattpilot),
zeit.py, gatherWaterData.py, wecker.py und skoda_testdaten.py, das sich das
Passwort bisher aus dem Quelltext eines anderen Moduls herausgesucht hat.

Die Kia-Anbindung ist mit dem Fahrzeug entfallen: kiaTest.py,
gatherCarData.py und hyundai_kia_connect_api sind nicht mehr dabei, ebenso
gatherInverterData.py, auf das nur noch eine auskommentierte Zeile zeigte.

Die mitgelieferten Bibliotheken bleiben im Repository - die NAS hat kein
pip, sie muessen neben den Skripten liegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:46:59 +02:00

840 lines
35 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: 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)