d8b12da36d
Das Backend liegt jetzt als custom_components/audi_dashboard/ vor - eine normale Home-Assistant-Integration mit Config-Flow, einer sensor-Plattform und 18 Diensten. Damit ist die App über HACS installierbar; bis das Repo auf GitHub gespiegelt ist (HACS spricht ausschließlich mit GitHub), installiert homeassistant/installationspaket/install.ps1 denselben Ordner ohne HACS. Fünf Installationsschritte entfallen ersatzlos: der pyscript:-Block, der panel_custom:-Block, das Kopieren der Oberfläche nach www/, das langlebige Zugriffstoken (der Verlauf wird direkt über die recorder-API gelesen) und "pip install pypdf" (steht in manifest.json). Das Fahrzeugprofil legt die Integration beim ersten Start aus ihrer Vorlage an. Drei alte Schwächen sind dabei mit erledigt: - Die Nutzlast landet nicht mehr in der Recorder-Datenbank (_unrecorded_attributes - das kann nur eine echte Entität). - Eine laufende Fahrt überlebt einen Neustart (Store statt Arbeitsspeicher); fiel sie während eines Ausfalls ins Ende, schließt nach_neustart_fortsetzen() sie beim letzten aufgezeichneten Zeitpunkt. - Sensor-Zuordnungen wirken sofort - die Zustandsbeobachter werden neu gebunden, der Neustart-Hinweis und der Neustart-Dienst sind weg. Namensvertrag geändert, beide Oberflächen mitgezogen: pyscript.audi_dashboard_x -> sensor.audi_dashboard_x, pyscript.audi_dashboard_y -> audi_dashboard.y. Eine Companion-App vom alten Stand findet nach dem Umstieg nichts mehr und muss neu gebaut werden; das Panel liegt in der Integration und kann nicht driften. Der selbstgebaute Updater entfällt - HACS ist die Update-Mechanik, die Home Assistant kennt. Die Versionierung schrumpft auf eine Quelle: manifest.json. Geprüft am laufenden Testcontainer (Container byteweise identisch mit dem Repo): alle 18 Dienste, Panel, Config-Entry neu laden, Historienimport, echter Shell-Beleg in-process, Neuinstallation im Wegwerf-Container blank mit automatisch nachinstalliertem pypdf. Companion-App: tsc sauber, 112/112 Tests, beide Rauchtests gegen das laufende Backend grün. Belegparser 8/8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
5.3 KiB
Python
136 lines
5.3 KiB
Python
"""Zweistufiger Fahrtabschluss: offene Fahrten nachträglich mit Start- und
|
|
Endkilometerstand vervollständigen (SPECIFICATION.md §7.2).
|
|
|
|
Der Kilometerstand kommt laut Datenquelle nicht sicher mit Fahrtende, sondern
|
|
teils erst mit Beginn oder während der nächsten Fahrt. Statt auf einen festen
|
|
Zeitpunkt zu warten, wird deshalb der aufgezeichnete Verlauf des
|
|
Kilometerstand-Sensors nach dem Wert durchsucht, dessen Zeitstempel am
|
|
nächsten am Fahrtbeginn bzw. -ende liegt.
|
|
|
|
Läuft nach jedem Fahrtende und zusätzlich bei jeder Änderung des
|
|
Kilometerstand-Sensors - unabhängig vom Fahrtende-Ereignis selbst, eben weil
|
|
der Wert auch erst mit der nächsten Fahrt eintreffen kann.
|
|
|
|
VORBEHALT: Der GPS-Fallback (km_quelle="gps") ist weiterhin nicht umgesetzt,
|
|
da die Adressauflösung ein eigenes Arbeitspaket ist. Fahrten ohne passenden
|
|
Kilometerstand bleiben "offen" statt auf GPS-Distanz auszuweichen.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from .verlauf import naechster_wert, verlauf_lesen
|
|
|
|
if TYPE_CHECKING:
|
|
from .koordinator import Koordinator
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Wie weit vor/zurück gesucht wird, ausgehend von Fahrtbeginn bzw. -ende.
|
|
# 48h deckt auch länger stehende Fahrzeuge ab, ohne bei jedem Screening die
|
|
# komplette Historie zu laden.
|
|
SUCHFENSTER = datetime.timedelta(hours=48)
|
|
|
|
|
|
def _als_zeit(wert: str | None) -> datetime.datetime | None:
|
|
if not wert:
|
|
return None
|
|
try:
|
|
return datetime.datetime.fromisoformat(wert)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _vollstaendig(aenderungen: dict, fahrt: dict) -> dict:
|
|
"""Ergänzt Strecke, Quelle und Status, sobald beide Kilometerstände da
|
|
sind. Eine Fahrt ist genau dann "vollständig", wenn ihre Strecke feststeht
|
|
- "offen" heißt nicht "unterwegs", sondern "Daten fehlen noch"."""
|
|
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
|
|
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
|
|
aenderungen["km_quelle"] = "odometer"
|
|
aenderungen["status"] = "vollständig"
|
|
return aenderungen
|
|
|
|
|
|
async def durchfuehren(k: Koordinator) -> None:
|
|
"""Versucht, alle offenen Fahrten zu vervollständigen."""
|
|
km_sensor = k.zuordnung.werte.KM_SENSOR
|
|
fahrten = await k.ablage.fahrten_lesen()
|
|
offene = [f for f in fahrten if f.get("status") == "offen"]
|
|
if not offene:
|
|
return
|
|
offene.sort(key=lambda f: f.get("ts_start", ""))
|
|
|
|
if km_sensor:
|
|
for fahrt in offene:
|
|
await _fahrt_screenen(k, km_sensor, fahrt)
|
|
|
|
await _lueckenlos_verketten(k)
|
|
await k.fahrten_veroeffentlichen()
|
|
|
|
|
|
async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None:
|
|
start = _als_zeit(fahrt.get("ts_start"))
|
|
ende = _als_zeit(fahrt.get("ts_end"))
|
|
if start is None or ende is None:
|
|
_LOGGER.warning(
|
|
"Fahrt %s hat keinen lesbaren Zeitraum - Screening übersprungen",
|
|
fahrt.get("trip_id"),
|
|
)
|
|
return
|
|
|
|
# Ein Abruf für beide Zeitpunkte statt zwei: die Fenster überlappen sich
|
|
# bei einer normalen Fahrt ohnehin fast vollständig.
|
|
punkte = await verlauf_lesen(k.hass, km_sensor, start - SUCHFENSTER, ende + SUCHFENSTER)
|
|
if not punkte:
|
|
return
|
|
|
|
geaendert = False
|
|
if fahrt.get("odo_end") is None:
|
|
wert = naechster_wert(ende, punkte)
|
|
if wert is not None:
|
|
fahrt["odo_end"] = wert
|
|
geaendert = True
|
|
if fahrt.get("odo_start") is None:
|
|
wert = naechster_wert(start, punkte)
|
|
if wert is not None:
|
|
fahrt["odo_start"] = wert
|
|
geaendert = True
|
|
|
|
if geaendert:
|
|
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
|
|
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
|
|
|
|
|
|
async def _lueckenlos_verketten(k: Koordinator) -> None:
|
|
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
|
|
wurde.
|
|
|
|
Zwischen zwei in der Liste direkt aufeinanderfolgenden Fahrten steht das
|
|
Fahrzeug nur - der Kilometerstand ändert sich dabei nicht, unabhängig
|
|
davon, wie lange die Standzeit dauert. Ein bestätigter Wert wird deshalb
|
|
an den direkten Nachbarn weitergereicht, auch wenn dessen eigenes
|
|
Screening noch keinen Treffer hatte.
|
|
|
|
Annahme: die Fahrten-Liste enthält keine Lücken durch verpasste
|
|
Ereignisse. Das ist der Normalfall, aber keine Garantie."""
|
|
fahrten = sorted(await k.ablage.fahrten_lesen(), key=lambda f: f.get("ts_start", ""))
|
|
geaendert: set[str] = set()
|
|
|
|
for aktuelle, naechste in zip(fahrten, fahrten[1:]):
|
|
if aktuelle.get("odo_end") is not None and naechste.get("odo_start") is None:
|
|
naechste["odo_start"] = aktuelle["odo_end"]
|
|
geaendert.add(naechste["trip_id"])
|
|
elif naechste.get("odo_start") is not None and aktuelle.get("odo_end") is None:
|
|
aktuelle["odo_end"] = naechste["odo_start"]
|
|
geaendert.add(aktuelle["trip_id"])
|
|
|
|
for fahrt in fahrten:
|
|
if fahrt["trip_id"] not in geaendert:
|
|
continue
|
|
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
|
|
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
|