- 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>
1145 lines
49 KiB
Python
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)
|