e1d570992e
Drei zusammengehörige Teile in einem Repository: - homeassistant/ Das fertige, im Einsatz befindliche Home-Assistant-Panel (panel_custom Custom Element + pyscript-Backend). Echte Fahrzeug- und Personendaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl, Tankbelege) bleiben per .gitignore außen vor; die anonymisierte Vorlage fahrzeugprofil.example.json ist mit dabei. - design-system/ Eigenständige React-Komponentenbibliothek (@audi-dash/ui), die die visuelle Sprache des Panels nachbildet - ohne Audi-Markenzeichen und ohne die lizenzierte Hausschrift. Dient als Grundlage für Claude Design. War bis hierher ein eigenes Repository und ist in dieses eingeschmolzen worden. - companion-app/ Datenschicht der neuen App DataMetric360 (iOS/Android via Capacitor, zusätzlich als Iframe im HA-Dashboard). Noch ohne Oberfläche: REST- und WebSocket-Zugriff auf Home Assistant plus Warteschlange für Änderungen ohne Netz. Ersetzt das eingespritzte hass-Objekt, das nur innerhalb des HA-Frontends existiert. Dazu die Projektdokumentation: SPECIFICATION.md (Ist-Stand des Panels), COMPANION_APP_ARCHITECTURE.md (Architekturentscheidungen der neuen App), AUDIT_2026-08-10.md, DESIGN_BRIEF_DATAMETRIC360.md und der ursprüngliche Bauauftrag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
7.6 KiB
Python
188 lines
7.6 KiB
Python
"""Screening-Logik für den zweistufigen Fahrtabschluss (Bauauftrag §7.2).
|
|
|
|
Importierbar aus anderen pyscript-Dateien mit `import fahrtabschluss_logik`.
|
|
|
|
Der Kilometerstand kommt laut Datenquelle nicht sicher mit Fahrtende, sondern
|
|
teils erst mit Beginn oder während der nächsten Fahrt (§10 Punkt 4). Statt auf
|
|
einen festen Zeitpunkt zu warten, wird deshalb die Recorder-Historie des
|
|
Kilometerstand-Sensors nach dem Wert durchsucht, dessen Zeitstempel am
|
|
nächsten am Verbindungsabbruch liegt.
|
|
|
|
Zugriff auf die Historie läuft über die HA-REST-API (/api/history/period),
|
|
nicht über einen internen pyscript-Mechanismus - dafür gibt es in der
|
|
pyscript-Referenz keinen dokumentierten Weg. Das erfordert ein Long-Lived
|
|
Access Token, siehe README.
|
|
|
|
WICHTIGER VORBEHALT: Der GPS-Fallback aus §7.2 (km_quelle="gps") ist hier noch
|
|
NICHT umgesetzt, da die Erfassung von Start-/Endposition (device_tracker,
|
|
Adressauflösung) ein eigenes Arbeitspaket ist. Fahrten ohne passenden
|
|
Kilometerstand bleiben aktuell dauerhaft "offen" statt auf GPS-Distanz
|
|
auszuweichen. Siehe die Restarbeiten im Abschlussbericht.
|
|
"""
|
|
|
|
import datetime
|
|
import io
|
|
import json
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
import frontend_veroeffentlichung
|
|
import profil
|
|
|
|
BASIS = "/config/audi_dashboard"
|
|
TOKEN_PFAD = f"{BASIS}/ha_token.txt"
|
|
HA_BASIS_URL = "http://localhost:8123"
|
|
|
|
# Wie weit vor/zurück in der Historie gesucht wird, ausgehend vom
|
|
# Verbindungsabbruch. 48h deckt auch länger stehende Fahrzeuge ab, ohne bei
|
|
# jedem Screening die komplette Historie zu laden.
|
|
SUCHFENSTER_STUNDEN = 48
|
|
|
|
|
|
# ---------------------------------------------------------- HA-REST-Zugriff
|
|
|
|
def _token_lesen():
|
|
# io.open über task.executor statt bloßem open() - siehe Kopfkommentar
|
|
# in profil.py.
|
|
f = task.executor(io.open, TOKEN_PFAD, "r")
|
|
inhalt = f.read()
|
|
f.close()
|
|
return inhalt.strip()
|
|
|
|
|
|
def historie_abfragen(entity_id, start_iso, end_iso):
|
|
"""urllib.request.urlopen läuft direkt, nicht über task.executor - anders
|
|
als ein bloßes open() ist das ein modulqualifizierter Aufruf einer echten
|
|
externen Funktion, der an der Testinstanz ohne Fehler durchlief. Blockiert
|
|
kurz, läuft nur beim Fahrtabschluss-Screening, nicht bei jedem Tick."""
|
|
token = _token_lesen()
|
|
start_enc = urllib.parse.quote(start_iso)
|
|
end_enc = urllib.parse.quote(end_iso)
|
|
url = (
|
|
f"{HA_BASIS_URL}/api/history/period/{start_enc}"
|
|
f"?filter_entity_id={entity_id}&end_time={end_enc}"
|
|
)
|
|
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
|
resp = urllib.request.urlopen(req, timeout=10)
|
|
rohtext = resp.read().decode("utf-8")
|
|
resp.close()
|
|
daten = json.loads(rohtext)
|
|
return daten[0] if daten else []
|
|
|
|
|
|
# ------------------------------------------------------------- Kernlogik
|
|
|
|
def naechster_wert(zielzeit, punkte):
|
|
"""Wert aus der Historie, dessen Zeitstempel am nächsten an zielzeit
|
|
liegt. Gibt (wert, zeitdifferenz_s) zurück, oder (None, None). Öffentlich,
|
|
weil auch belegverarbeitung.py sie für den Tankfüllstand nutzt."""
|
|
beste_wert = None
|
|
beste_diff = None
|
|
for p in punkte:
|
|
try:
|
|
wert = float(p["state"])
|
|
except (ValueError, TypeError, KeyError):
|
|
continue
|
|
try:
|
|
zeit = datetime.datetime.fromisoformat(
|
|
p["last_changed"].replace("Z", "+00:00")
|
|
)
|
|
except (ValueError, KeyError):
|
|
continue
|
|
diff = abs((zeit - zielzeit).total_seconds())
|
|
if beste_diff is None or diff < beste_diff:
|
|
beste_wert = wert
|
|
beste_diff = diff
|
|
return beste_wert, beste_diff
|
|
|
|
|
|
def _zeitpunkt_screenen(km_sensor, zielzeit):
|
|
start = (zielzeit - datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat()
|
|
ende = (zielzeit + datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat()
|
|
try:
|
|
punkte = historie_abfragen(km_sensor, start, ende)
|
|
except Exception as exc:
|
|
# Fehlt z. B. der Token (INSTALL.md Schritt 5) oder ist die API kurz
|
|
# nicht erreichbar, bleibt die Fahrt einfach offen statt den ganzen
|
|
# Screening-Lauf für alle anderen offenen Fahrten abzubrechen.
|
|
log.warning(f"audi_dashboard: Kilometerstand-Screening fehlgeschlagen: {exc}")
|
|
return None
|
|
wert, _ = naechster_wert(zielzeit, punkte)
|
|
return wert
|
|
|
|
|
|
def screening_durchfuehren(km_sensor):
|
|
"""Versucht, offene Fahrten mit Start-/Endkilometer zu vervollständigen.
|
|
Wird sowohl nach Ablauf der Pausenzeit (§7.1) als auch bei jeder Änderung
|
|
des Kilometerstand-Sensors aufgerufen."""
|
|
fahrten = profil.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", ""))
|
|
|
|
for fahrt in offene:
|
|
geaendert = False
|
|
|
|
if fahrt.get("odo_end") is None:
|
|
ts_end = datetime.datetime.fromisoformat(fahrt["ts_end"])
|
|
wert = _zeitpunkt_screenen(km_sensor, ts_end)
|
|
if wert is not None:
|
|
fahrt["odo_end"] = wert
|
|
geaendert = True
|
|
|
|
if fahrt.get("odo_start") is None:
|
|
ts_start = datetime.datetime.fromisoformat(fahrt["ts_start"])
|
|
wert = _zeitpunkt_screenen(km_sensor, ts_start)
|
|
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")}
|
|
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"
|
|
profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
|
|
|
|
_lueckenlos_verketten()
|
|
frontend_veroeffentlichung.fahrten_veroeffentlichen()
|
|
|
|
|
|
def _lueckenlos_verketten():
|
|
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
|
|
wurde (§7.2). 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
|
|
Verbindungsereignisse (z. B. durch einen HA-Neustart mitten in einer
|
|
Fahrt). Das ist der Normalfall, aber keine Garantie - siehe
|
|
Abschlussbericht."""
|
|
fahrten = sorted(profil.fahrten_lesen(), key=lambda f: f.get("ts_start", ""))
|
|
geaendert_ids = []
|
|
|
|
for i in range(len(fahrten) - 1):
|
|
aktuelle, naechste = fahrten[i], fahrten[i + 1]
|
|
|
|
if aktuelle.get("odo_end") is not None and naechste.get("odo_start") is None:
|
|
naechste["odo_start"] = aktuelle["odo_end"]
|
|
geaendert_ids.append(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_ids.append(aktuelle["trip_id"])
|
|
|
|
for fahrt in fahrten:
|
|
if fahrt["trip_id"] not in geaendert_ids:
|
|
continue
|
|
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
|
|
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"
|
|
profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
|