c11c492d86
homeassistant/install.sh richtet das Panel samt Backend auf einer Home-Assistant-Instanz ein und verdrahtet dabei die Selbstaktualisierung, so dass danach alles Weitere ueber die Weboberflaeche laeuft: Sensoren zuordnen, nach Updates suchen, einspielen. Gedacht fuer das Add-on "Terminal & SSH" direkt auf der Instanz: bash <(curl -fsSL <ROH-URL>/homeassistant/install.sh) Alternativ von einem Rechner aus mit --ziel gegen ein eingebundenes config-Verzeichnis, oder mit --von gegen eine lokale Arbeitskopie. Was es tut: pyscript installieren (neueste Version von GitHub, falls nicht vorhanden), pyscript/ und www/ einspielen, den Belegleser mitliefern (er liegt in data/, ist aber Code), fehlende Datenbestaende aus der Vorlage anlegen, den Abschnitt in die configuration.yaml eintragen und UPDATE_REPO_URL setzen. Drei Eigenschaften, auf die es dabei ankommt: - Mehrfach ausfuehrbar. Der Abschnitt in der configuration.yaml steht zwischen Markern und wird beim zweiten Lauf ersetzt statt angehaengt. Stehen pyscript oder panel_custom bereits ausserhalb dieses Abschnitts, weist das Skript darauf hin, statt einen doppelten Schluessel zu erzeugen. - Vorhandene Daten bleiben unangetastet. Fahrzeugprofil, Fahrten, Tankvorgaenge und ein bereits hinterlegtes Token werden nie ueberschrieben, nur fehlende Dateien angelegt. - Die configuration.yaml wird vor jeder Aenderung mit Zeitstempel gesichert. Geprueft gegen eine frische Home-Assistant-Instanz im Container: HA startet ohne Konfigurationsfehler, alle neun pyscript-Entitaeten werden veroeffentlicht, das Panel ist als "Mein Audi" mit mdi:car-sports unter /audi-dashboard-panel registriert und rendert im Browser mit fuenf Tabs. Ein zweiter Durchlauf laesst Profil und Token unveraendert und den Konfigurationsabschnitt genau einmal stehen. Ausserdem den blockierenden Verlaufsabruf in fahrtabschluss_logik.py uebernommen: urlopen lief direkt statt ueber task.executor, was Home Assistant seit 2026.8 abbricht - jede Fahrt waere ohne Strecke geblieben. Der Fix lag bisher nur im Branch umsetzung-datametric360; eine frische Installation haette den Fehler sonst mitgebracht. INSTALL.md nennt den schnellen Weg jetzt vor der ausfuehrlichen Anleitung.
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)
|