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>
This commit is contained in:
+258
@@ -0,0 +1,258 @@
|
||||
"""Pruefung der Zuordnung in gatherSkodaData.py ohne Fahrzeug und ohne Netz.
|
||||
|
||||
Das Auto kommt erst naechste Woche. Bis dahin laesst sich wenigstens
|
||||
sicherstellen, dass eine Antwort richtig zerlegt wird und dass die Faelle
|
||||
nicht abstuerzen, die im Betrieb sicher vorkommen: fehlende Teilbereiche,
|
||||
UNKNOWN statt eines Werts, Fahrzeug in Bewegung, leere Antwort.
|
||||
|
||||
python3 skoda_test.py
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import time
|
||||
import gatherSkodaData as g
|
||||
|
||||
|
||||
fehler = []
|
||||
|
||||
def pruefe(was, ist, soll):
|
||||
if ist != soll:
|
||||
fehler.append(was+": "+repr(ist)+" statt "+repr(soll))
|
||||
|
||||
def frisch():
|
||||
g.ret = g.SkodaData()
|
||||
return g.ret
|
||||
|
||||
|
||||
# --- 1. Vollstaendige Antwort ---------------------------------------------
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{
|
||||
"vin":"TMBJB9NY5RF999999","name":"Elroy",
|
||||
"charging":{"isVehicleInSavedLocation":True,
|
||||
"carCapturedTimestamp":"2026-09-02T05:00:00Z",
|
||||
"status":{"state":"CHARGING","chargeType":"AC","chargePowerInKw":10.8,
|
||||
"battery":{"stateOfChargeInPercent":71,
|
||||
"remainingCruisingRangeInMeters":249000}},
|
||||
"settings":{"targetStateOfChargeInPercent":80,"chargingCareMode":"ACTIVATED"}}}})
|
||||
pruefe("SoC", r.soc, 71)
|
||||
pruefe("Reichweite km", r.range_km, 249.0)
|
||||
pruefe("Rohwert bleibt roh", r.range_m, 249000)
|
||||
pruefe("UTC nach Ortszeit", r.capChg, datetime.datetime(2026,9,2,7,0,0))
|
||||
pruefe("Batterieschonung", r.careMode, True)
|
||||
|
||||
# --- 2. Reichweite, die schon in Kilometern kommt --------------------------
|
||||
# Das Feld heisst Meter, das Beispiel der Spec sieht nach Kilometern aus.
|
||||
# Beide Lesarten muessen zur selben Anzeige fuehren.
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"charging":{"status":{"battery":
|
||||
{"stateOfChargeInPercent":71,"remainingCruisingRangeInMeters":249}}}}})
|
||||
pruefe("249 gilt als Kilometer", r.range_km, 249.0)
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"charging":{"status":{"battery":
|
||||
{"stateOfChargeInPercent":3,"remainingCruisingRangeInMeters":900}}}}})
|
||||
pruefe("900 gilt als Kilometer", r.range_km, 900.0)
|
||||
|
||||
# --- 3. UNKNOWN ist kein Nein ---------------------------------------------
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"status":{
|
||||
"overall":{"locked":"UNKNOWN","doorsLocked":"UNKNOWN","doors":"UNKNOWN",
|
||||
"windows":"UNSUPPORTED","lights":"OFF"},
|
||||
"detail":{"sunroof":"UNSUPPORTED","trunk":"CLOSED","bonnet":"CLOSED"}}}})
|
||||
pruefe("UNKNOWN wird None", r.locked, None)
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"status":{
|
||||
"overall":{"locked":"NO","doorsLocked":"NO","doors":"OPEN",
|
||||
"windows":"CLOSED","lights":"OFF"},
|
||||
"detail":{"sunroof":"CLOSED","trunk":"CLOSED","bonnet":"CLOSED"}}}})
|
||||
pruefe("NO wird False", r.locked, False)
|
||||
|
||||
# --- 4. Fehlende Teilbereiche loeschen den letzten Stand nicht -------------
|
||||
# Die API laesst einen Teil weg, wenn sie ihn gerade nicht bekommt, und legt
|
||||
# stattdessen einen Eintrag in errors ab. Der alte Wert ist dann die bessere
|
||||
# Auskunft als eine Null.
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"odometer":{"mileageInKm":12753},
|
||||
"charging":{"status":{"battery":{"stateOfChargeInPercent":71}}}}})
|
||||
g.uebernehmen({"vehicle":{"odometer":{"mileageInKm":12753}},
|
||||
"errors":[{"type":"CHARGING_UNAVAILABLE"}]})
|
||||
pruefe("SoC bleibt stehen", r.soc, 71)
|
||||
pruefe("Fehler vermerkt", r.apiErrors, "CHARGING_UNAVAILABLE")
|
||||
|
||||
# --- 5. Fahrzeug in Bewegung: keine Koordinaten ---------------------------
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"parkingPosition":{"state":"PARKED",
|
||||
"gpsCoordinates":{"latitude":47.5779944,"longitude":10.2623373}}}})
|
||||
g.uebernehmen({"vehicle":{"parkingPosition":{"state":"IN_MOTION"}}})
|
||||
pruefe("Zustand folgt", r.parkState, "IN_MOTION")
|
||||
pruefe("letzte Position bleibt", r.lat, 47.577994)
|
||||
|
||||
# --- 6. Fahrenheit wird umgerechnet ---------------------------------------
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"airConditioning":{"state":"COOLING",
|
||||
"targetTemperature":{"value":72.5,"unit":"FAHRENHEIT"}}}})
|
||||
pruefe("Fahrenheit nach Celsius", r.acTargetC, 22.5)
|
||||
|
||||
# --- 7. Leere und kaputte Antworten stuerzen nicht ab ---------------------
|
||||
r = frisch()
|
||||
g.uebernehmen({})
|
||||
g.uebernehmen({"vehicle":{}})
|
||||
g.uebernehmen({"vehicle":{"charging":{},"status":{},"odometer":{},
|
||||
"parkingPosition":{},"airConditioning":{}}})
|
||||
pruefe("leer bleibt leer", r.soc, 0)
|
||||
|
||||
# --- 8. Ladeleistung faellt auf 0, wenn das Kabel raus ist ----------------
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"charging":{"status":{"state":"CHARGING","chargePowerInKw":10.8}}}})
|
||||
g.uebernehmen({"vehicle":{"charging":{"status":{"state":"CONNECT_CABLE"}}}})
|
||||
pruefe("abgesteckt heisst 0 kW", r.chgKw, 0.0)
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"charging":{"status":{"state":"CHARGING","chargePowerInKw":10.8}}}})
|
||||
g.uebernehmen({"vehicle":{"charging":{"status":{"state":"CONSERVING"}}}})
|
||||
pruefe("gesteckt behaelt den Wert", r.chgKw, 10.8)
|
||||
|
||||
# --- 9. Abrufintervall folgt der Lage ------------------------------------
|
||||
r = frisch()
|
||||
g._st["letzteAend"] = 0.0
|
||||
g._haus["wbPlug"] = False; g._haus["wbKw"] = 0.0
|
||||
r.chgState = "CHARGING"
|
||||
pruefe("laedt", g._intervall(), g._I_LADEN)
|
||||
r.chgState = ""
|
||||
g._haus["wbPlug"] = True; g._haus["wbKw"] = 7.4
|
||||
pruefe("Wallbox liefert", g._intervall(), g._I_LADEN)
|
||||
g._haus["wbKw"] = 0.0
|
||||
pruefe("gesteckt, laedt nicht", g._intervall(), g._I_GESTECKT)
|
||||
g._haus["wbPlug"] = False
|
||||
r.parkState = "IN_MOTION"
|
||||
pruefe("unterwegs", g._intervall(), g._I_FAHRT)
|
||||
r.parkState = "PARKED"
|
||||
pruefe("lange nichts", g._intervall(), g._I_RUHE)
|
||||
r.error = 3
|
||||
pruefe("nach Fehlern zurueckhaltend", g._intervall(), 240.0)
|
||||
r.error = 0
|
||||
|
||||
# --- 10. Kontingent weitet den Abstand ------------------------------------
|
||||
r = frisch()
|
||||
pruefe("100 Anfragen in 60 s", round(g._kontingent(
|
||||
{"RateLimit-Remaining":"100","RateLimit-Reset":"60"}),3), 0.72)
|
||||
pruefe("2 Anfragen in 600 s", round(g._kontingent(
|
||||
{"RateLimit-Remaining":"2","RateLimit-Reset":"600"}),1), 360.0)
|
||||
pruefe("nichts mehr frei", g._kontingent(
|
||||
{"RateLimit-Remaining":"0","RateLimit-Reset":"120"}), 125.0)
|
||||
pruefe("ohne Header kein Einfluss", g._kontingent({}), 0.0)
|
||||
|
||||
# --- 11. Nur echte Aenderungen erzeugen eine Zeile ------------------------
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"charging":{"status":{"battery":{"stateOfChargeInPercent":71}}}}})
|
||||
a = g._signatur()
|
||||
g._haus["pvKw"] = 8.2 # Hausseite aendert sich staendig
|
||||
b = g._signatur()
|
||||
pruefe("Hausseite loest nichts aus", a, b)
|
||||
g.uebernehmen({"vehicle":{"charging":{"status":{"battery":{"stateOfChargeInPercent":72}}}}})
|
||||
pruefe("SoC-Sprung loest aus", g._signatur() != a, True)
|
||||
|
||||
|
||||
# --- 12. Plug-in-Hybrid: beide Antriebe werden getrennt gefuehrt ----------
|
||||
# Welcher Skoda kommt, steht noch nicht fest. Ohne include-Parameter liefert
|
||||
# die API von sich aus fuelStatus, wenn ein Verbrenner mitfaehrt - die
|
||||
# Spalten dafuer muessen von Anfang an da sein, nachtragen laesst sich das
|
||||
# nicht.
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"fuelStatus":{
|
||||
"carType":"HYBRID","totalRangeInKm":620,
|
||||
"carCapturedTimestamp":"2026-09-02T05:00:00Z",
|
||||
"primaryEngineRange":{"engineType":"ELECTRIC","currentSoCInPercent":71,
|
||||
"remainingRangeInKm":110},
|
||||
"secondaryEngineRange":{"engineType":"GASOLINE","currentFuelLevelInPercent":87,
|
||||
"remainingRangeInKm":510}}}})
|
||||
pruefe("Fahrzeugart", r.carType, "HYBRID")
|
||||
pruefe("Gesamtreichweite", r.totalRangeKm, 620.0)
|
||||
pruefe("Antrieb 1 elektrisch", (r.eng1Type, r.eng1Soc), ("ELECTRIC", 71))
|
||||
pruefe("Antrieb 2 Benzin", (r.eng2Type, r.eng2FuelPct), ("GASOLINE", 87))
|
||||
pruefe("Tankanzeige fuer die Oberflaeche", r.fuelPct, 87)
|
||||
pruefe("Zeitstempel Verbrenner", r.capFuel, datetime.datetime(2026,9,2,7,0,0))
|
||||
|
||||
# Umgekehrte Reihenfolge - die Spec legt nicht fest, welcher Motor wo steht.
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"fuelStatus":{"carType":"HYBRID",
|
||||
"primaryEngineRange":{"engineType":"DIESEL","currentFuelLevelInPercent":42},
|
||||
"secondaryEngineRange":{"engineType":"ELECTRIC","currentSoCInPercent":66}}}})
|
||||
pruefe("Tank auch an erster Stelle", r.fuelPct, 42)
|
||||
|
||||
# Reiner Stromer: kein fuelStatus, alles bleibt leer.
|
||||
r = frisch()
|
||||
g.uebernehmen({"vehicle":{"charging":{"status":{"battery":
|
||||
{"stateOfChargeInPercent":71}}}},"errors":[{"type":"FUEL_STATUS_UNSUPPORTED"}]})
|
||||
pruefe("Stromer ohne Tank", (r.carType, r.fuelPct), ("", 0))
|
||||
|
||||
# --- 13. Das Kontingent wird unter Dauerlast eingehalten ------------------
|
||||
# Die Doku nennt 20 Anfragen je Stunde und Schluessel. Beim Laden greift das
|
||||
# kuerzeste Intervall - genau dort muss die Sperre halten. Simuliert wird ein
|
||||
# ganzer Tag Dauerladen, ohne echte Zeit und ohne Netz.
|
||||
g._verbrauch.clear()
|
||||
zeitpunkte = []
|
||||
t = 0.0
|
||||
while t < 24*3600:
|
||||
zeitpunkte.append(t)
|
||||
g._gezaehlt(t)
|
||||
t += max(g._I_MIN, g._I_LADEN, g._budgetSperre(t))
|
||||
schlimmste = 0
|
||||
for i, t0 in enumerate(zeitpunkte):
|
||||
in_fenster = sum(1 for t1 in zeitpunkte[i:] if t1 - t0 < 3600)
|
||||
schlimmste = max(schlimmste, in_fenster)
|
||||
pruefe("nie mehr als das Limit je Stunde", schlimmste <= g._LIMIT, True)
|
||||
pruefe("das eigene Budget wird eingehalten", schlimmste <= g._BUDGET_GETEILT, True)
|
||||
pruefe("und auch ausgenutzt", schlimmste >= g._BUDGET_GETEILT - 3, True)
|
||||
|
||||
# Mit eigenem Schluessel fuer die Steuerung darf mehr genutzt werden.
|
||||
g._st["eigenerCmdKey"] = True
|
||||
g._verbrauch.clear()
|
||||
for i in range(g._BUDGET_GETEILT):
|
||||
g._gezaehlt(2000.0 + i)
|
||||
pruefe("geteiltes Budget reicht dem Modul allein nicht",
|
||||
g._budgetSperre(2000.0 + g._BUDGET_GETEILT), 0.0)
|
||||
g._verbrauch.clear()
|
||||
for i in range(g._BUDGET_ALLEIN):
|
||||
g._gezaehlt(2000.0 + i)
|
||||
pruefe("eigenes Budget greift bei 18", g._budgetSperre(2000.0 + g._BUDGET_ALLEIN) > 3500, True)
|
||||
g._st["eigenerCmdKey"] = False
|
||||
g._verbrauch.clear()
|
||||
|
||||
# Ein einzelner Anstoss ausser der Reihe darf das Budget nicht sprengen.
|
||||
g._verbrauch.clear()
|
||||
for i in range(g._BUDGET_GETEILT):
|
||||
g._gezaehlt(1000.0 + i)
|
||||
pruefe("Budget voll: Sperre greift", g._budgetSperre(1000.0+g._BUDGET_GETEILT) > 3500, True)
|
||||
g._verbrauch.clear()
|
||||
pruefe("Budget leer: keine Sperre", g._budgetSperre(0.0), 0.0)
|
||||
|
||||
# --- 14. Header mit den echten Zahlen -------------------------------------
|
||||
g.ret = g.SkodaData()
|
||||
pruefe("19 Anfragen in 3400 s", round(g._kontingent(
|
||||
{"RateLimit-Remaining":"19","RateLimit-Reset":"3400"}),0), 215.0)
|
||||
pruefe("Restkontingent gemerkt", g.ret.rlRemaining, 19)
|
||||
|
||||
# --- 15. Ladebeginn und Ladeende stossen einen Abruf an -------------------
|
||||
# Bei 20 Anfragen je Stunde sind das die beiden wertvollsten Augenblicke:
|
||||
# der Ladestand davor und danach traegt die Kapazitaetsrechnung.
|
||||
import asyncio
|
||||
g._st["ladenVorher"] = False
|
||||
g._st["naechster"] = time.time() + 9999
|
||||
asyncio.get_event_loop().run_until_complete(g.gatherData(wbKw=10.8, wbPlug=True))
|
||||
pruefe("Beginn wird bemerkt", g._st["ladenVorher"], True)
|
||||
pruefe("Abruf vorgezogen", g._st["naechster"] < time.time() + 120, True)
|
||||
g._st["naechster"] = time.time() + 9999
|
||||
asyncio.get_event_loop().run_until_complete(g.gatherData(wbKw=0.0, wbPlug=True))
|
||||
pruefe("Ende wird bemerkt", g._st["ladenVorher"], False)
|
||||
pruefe("Abruf wieder vorgezogen", g._st["naechster"] < time.time() + 120, True)
|
||||
g._st["naechster"] = time.time() + 9999
|
||||
asyncio.get_event_loop().run_until_complete(g.gatherData(wbKw=0.0, wbPlug=True))
|
||||
pruefe("ohne Flanke kein Anstoss", g._st["naechster"] > time.time() + 1000, True)
|
||||
|
||||
|
||||
print()
|
||||
if fehler:
|
||||
for f in fehler:
|
||||
print("FEHLER "+f)
|
||||
print("\n"+str(len(fehler))+" von "+str(len(fehler))+" Pruefungen fehlgeschlagen.")
|
||||
else:
|
||||
print("Alle Pruefungen bestanden.")
|
||||
Reference in New Issue
Block a user