4395ff6ac6
Xcode ist vorhanden, die Huelle liess sich also wirklich bauen statt nur vorzubereiten: Capacitor 8 fuer iOS und Android, Build fuer den Simulator erfolgreich, App laeuft und zeigt echte Daten vom Server. CapacitorHttp eingeschaltet. Das ist keine Feinheit: die native Huelle liefert die Oberflaeche unter eigenem Ursprung aus, jede Anfrage an Home Assistant ist damit ursprungsuebergreifend, und die WebView lehnt sie ohne CORS-Freigabe ab. Nativ gestellte Anfragen kennen keine CORS-Pruefung - die App braucht dadurch keine CORS-Einstellung am Server. Drei Fehler, die erst die Bildschirmfotos zeigten: 1. Der Fahrzeug-Block auf der Uebersicht war unsichtbar. Ursache war der Flexbox-Fallstrick: "overflow: hidden" setzt die automatische Mindesthoehe auf 0, und sobald die Seite laenger ist als der Bildschirm, quetscht flex-shrink den Block auf Hoehe 0. Modellname, Typenschild und Kennzeichen waren damit schlicht weg. 2. Neben dem Typenschild stand der Modellname noch einmal komplett, obwohl das Schild ihn bereits zeigt. Das alte Panel loest das laengst richtig - nur der Zusatz gehoert daneben. Nachgezogen, samt Entdopplung, wenn die Ausfuehrung schon im Namen steckt. 3. In der Fotogalerie liefen die Dateinamen ueber den Rand der Miniaturen. Dazu: Datumszeile in Listen bricht um statt abzuschneiden, Statistik nutzt auf der Flaeche mehrere Spalten. Im Backend war das Kilometerstand-Screening kaputt: es rief urlopen direkt auf, was Home Assistant seit 2026.8 als blockierenden Aufruf abbricht. Jede Fahrt blieb dadurch ohne Strecke - sichtbar nur als Warnung im Protokoll. Laeuft jetzt ueber task.executor und rechnet an der Testinstanz wieder echte Strecken aus dem Verlauf. Die CORS-Freigabe fuer die Web-Fassung ist in der Testkonfiguration hinterlegt. Sie greift in Home Assistant 2026.8 allerdings nicht - deshalb wird die App aus Home Assistant selbst ausgeliefert (gleicher Ursprung, kein CORS), was ohnehin der geplante Weg ist. Die erzeugten Ordner ios/ und android/ bleiben ungetrackt; sie entstehen jederzeit neu aus dem Webbuendel.
197 lines
8.2 KiB
Python
197 lines
8.2 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):
|
|
"""Fragt die Verlaufsdaten des Kilometerstand-Sensors über die REST-API ab.
|
|
|
|
Beide blockierenden Schritte laufen über task.executor. Ein früherer Stand
|
|
rief urlopen() direkt auf - das lief jahrelang durch, bis Home Assistant
|
|
anfing, blockierende Aufrufe im Ereignisschleifen-Thread zu erkennen und
|
|
abzubrechen (real beobachtet ab 2026.8: "Caught blocking call to
|
|
putrequest ... inside the event loop"). Seitdem schlug jedes Screening
|
|
fehl, und manuell angelegte Fahrten blieben ohne Strecke - sichtbar nur
|
|
als Warnung im Protokoll, weil der Aufrufer den Fehler bewusst abfängt.
|
|
|
|
task.executor braucht echte externe Funktionen; urllib.request.urlopen und
|
|
die read-Methode der Antwort sind genau das (dieselbe Einschränkung wie bei
|
|
io.open in profil.py)."""
|
|
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 = task.executor(urllib.request.urlopen, req, timeout=10)
|
|
rohdaten = task.executor(resp.read)
|
|
resp.close()
|
|
daten = json.loads(rohdaten.decode("utf-8"))
|
|
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)
|