Initialer Import: HA-Panel, Design-System, Companion-App
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>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""Technische Konfiguration — die einzige Stelle, die vor der Installation
|
||||
angepasst werden muss (siehe INSTALL.md Schritt 4).
|
||||
|
||||
Alles andere (WLAN-Name des Fahrzeugs, Pausenzeit, Reifendaten, ...) ist Teil
|
||||
des Fahrzeugprofils (data/fahrzeugprofil.json) und über die Oberfläche
|
||||
änderbar. Diese Entity-IDs sind es bewusst nicht: @state_trigger und die
|
||||
Fahrzeugstatus-Abfrage brauchen sie als festen Wert, bevor überhaupt ein
|
||||
Profil gelesen werden kann.
|
||||
|
||||
In Home Assistant unter Entwicklerwerkzeuge -> Zustände nachschlagen.
|
||||
|
||||
Die drei ersten (WLAN, Kilometerstand, Tankfüllstand) werden vom Backend
|
||||
zwingend gebraucht (§7.1, §7.2, §7.4, §7.5). Alles danach speist nur die
|
||||
Übersicht im Frontend (§5.1, §5.2, §9) — bleibt ein Wert leer ("") oder passt
|
||||
eine Entity-ID nicht zur tatsächlichen Integration, liefert
|
||||
zustand_oder_none() in frontend_veroeffentlichung.py für das jeweilige Feld
|
||||
None statt abzustürzen; die Oberfläche zeigt dann „unbekannt" statt eines
|
||||
falschen Werts.
|
||||
|
||||
Alle Entity-IDs unten sind an der echten Integration TommiG1/HA_VAG-EU-Data-Act
|
||||
(Version 0.6.35, per HACS installiert) real geprüft, nicht nur vermutet: das
|
||||
Gerät heißt dort "Audi RS 4 Avant" (mit Leerzeichen vor der "4") - deshalb
|
||||
"rs_4" mit Unterstrich im Slug, nicht "rs4". Eine ältere Integrationsversion
|
||||
hatte hier noch deutsche Feldnamen (z. B. "..._tur_vorne_links"); die aktuelle
|
||||
Version benennt englisch (z. B. "..._front_left_door").
|
||||
"""
|
||||
|
||||
# Companion-App-Sensor für das aktuell verbundene WLAN (iOS-App:
|
||||
# Einstellungen -> Companion App -> Sensoren -> "Verbundenes WLAN")
|
||||
WLAN_SENSOR = "sensor.iphone_wifi_connection"
|
||||
|
||||
# Kilometerstand aus der HACS-Integration TommiG1/HA_VAG-EU-Data-Act
|
||||
KM_SENSOR = "sensor.audi_rs_4_avant_mileage"
|
||||
|
||||
# Tankfüllstand aus derselben Integration (Prozent)
|
||||
TANK_SENSOR = "sensor.audi_rs_4_avant_fuel_level"
|
||||
|
||||
# Reichweite (§5.1 Übersicht)
|
||||
RANGE_SENSOR = "sensor.audi_rs_4_avant_range_primary"
|
||||
|
||||
# 12V-Batteriespannung (Mein Audi -> Zustand). Die aktuell genutzte
|
||||
# HACS-Integration (TommiG1/HA_VAG-EU-Data-Act 0.6.35) liefert dafür KEINEN
|
||||
# Sensor (real geprüft: kein "battery"/"voltage"-Entity unter diesem Gerät) -
|
||||
# deshalb hier bewusst leer gelassen. Liefert später eine andere Quelle (z. B.
|
||||
# ein OBD-Adapter) eine Spannung, einfach die Entity-ID eintragen; bis dahin
|
||||
# zeigt die Oberfläche "unbekannt" statt eines falschen Werts (siehe
|
||||
# zustand_oder_none() in frontend_veroeffentlichung.py).
|
||||
BATTERIE_SENSOR = ""
|
||||
|
||||
# Der von der Integration selbst bereitgestellte Knopf für eine sofortige
|
||||
# Neuabfrage beim Fahrzeug (Entity-Domäne "button", real geprüft) - genau der
|
||||
# Weg, den "Ziehen zum Aktualisieren" im Frontend auslöst (siehe
|
||||
# audi_dashboard_jetzt_aktualisieren() in frontend_api.py), statt eines
|
||||
# generischen homeassistant.update_entity auf einzelne Sensoren.
|
||||
REFRESH_BUTTON = "button.audi_rs_4_avant_refresh_now"
|
||||
|
||||
# Türen (§4.1) - die Integration liefert vier Einzelsensoren statt eines
|
||||
# Summensensors. binary_sensor, device_class "door": "off" = zu (real
|
||||
# geprüft).
|
||||
TUER_SENSOREN = [
|
||||
"binary_sensor.audi_rs_4_avant_front_left_door",
|
||||
"binary_sensor.audi_rs_4_avant_front_right_door",
|
||||
"binary_sensor.audi_rs_4_avant_rear_left_door",
|
||||
"binary_sensor.audi_rs_4_avant_rear_right_door",
|
||||
]
|
||||
|
||||
# Fenster (§4.1) - device_class "window": "off" = zu (real geprüft)
|
||||
FENSTER_SENSOREN = [
|
||||
"binary_sensor.audi_rs_4_avant_front_left_window",
|
||||
"binary_sensor.audi_rs_4_avant_front_right_window",
|
||||
"binary_sensor.audi_rs_4_avant_rear_left_window",
|
||||
"binary_sensor.audi_rs_4_avant_rear_right_window",
|
||||
]
|
||||
|
||||
# Verriegelung - kein lock.*-Entity vorhanden, sondern vier
|
||||
# Türschloss-Einzelsensoren. binary_sensor mit device_class "lock" (real
|
||||
# geprüft): "on" = entriegelt, "off" = verriegelt (Home-Assistant-Konvention
|
||||
# für diese device_class).
|
||||
TUERSCHLOSS_SENSOREN = [
|
||||
"binary_sensor.audi_rs_4_avant_front_left_door_lock",
|
||||
"binary_sensor.audi_rs_4_avant_front_right_door_lock",
|
||||
"binary_sensor.audi_rs_4_avant_rear_left_door_lock",
|
||||
"binary_sensor.audi_rs_4_avant_rear_right_door_lock",
|
||||
]
|
||||
|
||||
# Heckklappe und Motorhaube - von der Integration separat von den vier Türen
|
||||
# geführt, für "Sicher abgestellt" (§9) aber ebenso relevant wie diese
|
||||
HECKKLAPPE_SENSOR = "binary_sensor.audi_rs_4_avant_tailgate"
|
||||
HECKKLAPPENSCHLOSS_SENSOR = "binary_sensor.audi_rs_4_avant_tailgate_lock"
|
||||
HAUBE_SENSOR = "binary_sensor.audi_rs_4_avant_hood"
|
||||
HAUBENSCHLOSS_SENSOR = "binary_sensor.audi_rs_4_avant_hood_lock"
|
||||
|
||||
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
|
||||
# aus dem Servicebuch berechnete Prognose - siehe fahrzeugMeldung() im
|
||||
# Frontend). Je ein Datum (ISO mit Zeitzone) und eine Streckenangabe in km;
|
||||
# die Streckensensoren liefern negative Werte (Restkilometer als Countdown),
|
||||
# im Frontend wird davon nur der Betrag angezeigt.
|
||||
NAECHSTER_OELWECHSEL_SENSOR = "sensor.audi_rs_4_avant_oil_change_due"
|
||||
OELWECHSEL_STRECKE_SENSOR = "sensor.audi_rs_4_avant_oil_change_distance"
|
||||
NAECHSTE_INSPEKTION_SENSOR = "sensor.audi_rs_4_avant_inspection_due"
|
||||
INSPEKTION_STRECKE_SENSOR = "sensor.audi_rs_4_avant_inspection_distance"
|
||||
|
||||
# Update-Funktion (Einstellungen -> "Update suchen", siehe
|
||||
# updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird -
|
||||
# z. B. ein privates GitHub-Repo, genau wie für die HACS-Integration bereits
|
||||
# verwendet. Leer lassen, solange es keins gibt - "Update suchen" meldet dann
|
||||
# nur "keine Update-Quelle eingerichtet", ohne etwas zu tun.
|
||||
UPDATE_REPO_URL = ""
|
||||
UPDATE_BRANCH = "main"
|
||||
@@ -0,0 +1,187 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Veröffentlicht Profil, Fahrten, Tankvorgänge und den Live-Fahrzeugstatus
|
||||
als pyscript-Zustandsentitäten, damit das Frontend sie über das ganz normale
|
||||
hass.states lesen kann - kein Service-Aufruf mit Rückgabewert nötig, dessen
|
||||
genaues Verhalten in der Home-Assistant-Weboberfläche nicht durchgängig
|
||||
dokumentiert ist (siehe Kopfkommentar in frontend_api.py).
|
||||
|
||||
Importierbar mit `import frontend_veroeffentlichung`, aufgerufen sowohl
|
||||
periodisch (frontend_api.py) als auch direkt nach Schreibaktionen aus
|
||||
fahrterkennung.py, belegverarbeitung.py und reifenzaehler.py, damit sich die
|
||||
Oberfläche unmittelbar aktualisiert statt bis zum nächsten Intervall zu
|
||||
warten.
|
||||
|
||||
BEKANNTE GRENZE: Zustands-Attribute in Home Assistant sind nicht für beliebig
|
||||
große Datenmengen gedacht (~16 KB Richtwert für die Aufzeichnung in der
|
||||
Recorder-Datenbank). Für das Profil und den aktuellen Stand ist das
|
||||
unkritisch; die Fahrten- und Tankvorgänge-Archive könnten das nach einigen
|
||||
Jahren überschreiten. Wird das relevant, müssten fahrten_veroeffentlichen()/
|
||||
tankvorgaenge_veroeffentlichen() auf einen Zeitraum eingeschränkt werden
|
||||
(z. B. nur das laufende Jahr), statt hier vorschnell zu vereinfachen.
|
||||
"""
|
||||
|
||||
import einstellungen
|
||||
import profil
|
||||
|
||||
|
||||
def profil_veroeffentlichen():
|
||||
state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": profil.profil_lesen()})
|
||||
|
||||
|
||||
def fahrten_veroeffentlichen():
|
||||
state.set("pyscript.audi_dashboard_fahrten", "aktuell", new_attributes={"daten": profil.fahrten_lesen()})
|
||||
|
||||
|
||||
def tankvorgaenge_veroeffentlichen():
|
||||
state.set("pyscript.audi_dashboard_tankvorgaenge", "aktuell", new_attributes={"daten": profil.tankvorgaenge_lesen()})
|
||||
|
||||
|
||||
def batterieverlauf_veroeffentlichen():
|
||||
state.set("pyscript.audi_dashboard_batterieverlauf", "aktuell", new_attributes={"daten": profil.batterieverlauf_lesen()})
|
||||
|
||||
|
||||
def beleg_ergebnis_veroeffentlichen(daten):
|
||||
"""Ergebnis eines Beleg-Uploads (geparste Werte fürs Ausfüllen eines
|
||||
Entwurfs, oder ein Fehler wie 'Falsches Belegdatum') fürs Frontend, nach
|
||||
demselben Zustands-statt-Rückgabewert-Muster wie die übrigen
|
||||
veroeffentlichen()-Funktionen hier (siehe Kopfkommentar)."""
|
||||
state.set("pyscript.audi_dashboard_beleg_ergebnis", "aktuell", new_attributes={"daten": daten})
|
||||
|
||||
|
||||
def zustand_oder_none(entity_id):
|
||||
"""Sicherer Zustandszugriff: pyscripts state.get() wirft NameError, wenn
|
||||
die Entity (noch) nicht existiert - real an einer Testinstanz beobachtet,
|
||||
nicht nur vermutet. Genau das ist hier der Normalfall, solange manche der
|
||||
in einstellungen.py hinterlegten Entity-IDs noch Platzhalter sind, und
|
||||
bleibt auch danach relevant: fällt eine Datenquelle aus, soll die
|
||||
Oberfläche das zeigen (§9), nicht an einem Fehler hängen bleiben."""
|
||||
if not entity_id:
|
||||
return None
|
||||
try:
|
||||
wert = state.get(entity_id)
|
||||
except NameError:
|
||||
return None
|
||||
if wert in (None, "unknown", "unavailable"):
|
||||
return None
|
||||
return wert
|
||||
|
||||
|
||||
def _zu_zahl(wert):
|
||||
try:
|
||||
return float(wert)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _abs_zahl(wert):
|
||||
zahl = _zu_zahl(wert)
|
||||
return None if zahl is None else abs(zahl)
|
||||
|
||||
|
||||
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
|
||||
|
||||
|
||||
def _sicherheitscheck():
|
||||
"""Liste der einzeln geprüften Punkte hinter "Sicher abgestellt" (§9),
|
||||
fürs Frontend (Klick auf den Status öffnet diese Liste mit einem grünen/
|
||||
roten/grauen Punkt je Zeile). "ok" ist None, wenn der Sensor fehlt oder
|
||||
nicht verfügbar ist - genau daraus leitet sich auch die zusammengefasste
|
||||
gesichert-Kennzahl unten ab, damit beide nie auseinanderlaufen können."""
|
||||
eintraege = []
|
||||
for pos, sensor in zip(POSITIONEN, einstellungen.TUER_SENSOREN):
|
||||
w = zustand_oder_none(sensor)
|
||||
eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off"})
|
||||
for pos, sensor in zip(POSITIONEN, einstellungen.FENSTER_SENSOREN):
|
||||
w = zustand_oder_none(sensor)
|
||||
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"})
|
||||
for pos, sensor in zip(POSITIONEN, einstellungen.TUERSCHLOSS_SENSOREN):
|
||||
w = zustand_oder_none(sensor)
|
||||
eintraege.append({"label": f"Türschloss {pos}", "ok": None if w is None else w == "off"})
|
||||
for label, sensor in [
|
||||
("Heckklappe", einstellungen.HECKKLAPPE_SENSOR),
|
||||
("Heckklappenschloss", einstellungen.HECKKLAPPENSCHLOSS_SENSOR),
|
||||
("Motorhaube", einstellungen.HAUBE_SENSOR),
|
||||
("Haubenschloss", einstellungen.HAUBENSCHLOSS_SENSOR),
|
||||
]:
|
||||
w = zustand_oder_none(sensor)
|
||||
eintraege.append({"label": label, "ok": None if w is None else w == "off"})
|
||||
return eintraege
|
||||
|
||||
|
||||
def fahrzeugstatus_veroeffentlichen():
|
||||
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
|
||||
Frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss.
|
||||
"""
|
||||
km = zustand_oder_none(einstellungen.KM_SENSOR)
|
||||
tankprozent = zustand_oder_none(einstellungen.TANK_SENSOR)
|
||||
reichweite = zustand_oder_none(einstellungen.RANGE_SENSOR)
|
||||
batteriespannung = zustand_oder_none(einstellungen.BATTERIE_SENSOR)
|
||||
|
||||
# "Sicher abgestellt" (§9) - erst wenn WIRKLICH jeder einzeln geprüfte
|
||||
# Punkt zu/verriegelt ist, gilt das Fahrzeug als gesichert; fehlt auch
|
||||
# nur eine Meldung, ist der Status unbekannt statt geraten.
|
||||
sicherheitscheck = _sicherheitscheck()
|
||||
if any([e["ok"] is None for e in sicherheitscheck]):
|
||||
gesichert = None
|
||||
else:
|
||||
gesichert = all([e["ok"] for e in sicherheitscheck])
|
||||
|
||||
daten = {
|
||||
"km": _zu_zahl(km),
|
||||
"tankprozent": _zu_zahl(tankprozent),
|
||||
"reichweite_km": _zu_zahl(reichweite),
|
||||
"batteriespannung": _zu_zahl(batteriespannung),
|
||||
"gesichert": gesichert,
|
||||
"sicherheitscheck": sicherheitscheck,
|
||||
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die
|
||||
# App-eigene Servicebuch-Berechnung, siehe fahrzeugMeldung() im
|
||||
# Frontend) - die Streckensensoren liefern negative Restkilometer-
|
||||
# Werte, hier deshalb der Betrag.
|
||||
"oelwechsel_faellig_ts": zustand_oder_none(einstellungen.NAECHSTER_OELWECHSEL_SENSOR),
|
||||
"oelwechsel_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.OELWECHSEL_STRECKE_SENSOR)),
|
||||
"inspektion_faellig_ts": zustand_oder_none(einstellungen.NAECHSTE_INSPEKTION_SENSOR),
|
||||
"inspektion_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.INSPEKTION_STRECKE_SENSOR)),
|
||||
}
|
||||
state.set("pyscript.audi_dashboard_fahrzeugstatus", "aktuell", new_attributes={"daten": daten})
|
||||
|
||||
|
||||
def alles_veroeffentlichen():
|
||||
profil_veroeffentlichen()
|
||||
fahrten_veroeffentlichen()
|
||||
tankvorgaenge_veroeffentlichen()
|
||||
fahrzeugstatus_veroeffentlichen()
|
||||
batterieverlauf_veroeffentlichen()
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Datenzugriff für Fahrzeugprofil, Fahrten und Tankvorgänge (Bauauftrag §6).
|
||||
|
||||
Importierbar aus anderen pyscript-Dateien mit `import profil`.
|
||||
|
||||
Drei getrennte Bestände, wie in §6.1 festgelegt:
|
||||
- Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische
|
||||
- Fahrten: JSON Lines, eine Zeile je Fahrt
|
||||
- Tankvorgänge: JSON Lines
|
||||
|
||||
Drei Eigenheiten von pyscript, alle an einer echten Testinstanz beobachtet,
|
||||
nicht nur aus der Dokumentation übernommen:
|
||||
|
||||
1. Das eingebaute `open()` existiert in pyscript nicht (NameError) - aus
|
||||
Sicherheitsgründen bewusst nicht freigegeben. Der funktionierende Weg ist
|
||||
`task.executor(io.open, pfad, modus)`: `io.open` ist eine echte externe
|
||||
Funktion aus der Standardbibliothek, kein im pyscript-Ordner selbst
|
||||
definierter Code.
|
||||
2. task.executor() akzeptiert generell nur solche echten externen Funktionen
|
||||
- eigene, im pyscript-Ordner definierte Funktionen weist es mit "pyscript
|
||||
functions can't be called from task.executor" zurück. Deshalb hier nur
|
||||
`io.open` selbst über den Executor, Lesen/Schreiben/Schließen auf dem
|
||||
zurückgegebenen Datei-Objekt direkt (das ist kein bare-name-Aufruf mehr,
|
||||
sondern ein Methodenaufruf auf einem bereits vorhandenen Objekt).
|
||||
3. Variablen, die innerhalb eines `with ... as f:`-Blocks zugewiesen werden,
|
||||
waren danach außerhalb nicht mehr auffindbar (NameError), deshalb kein
|
||||
`with` - offen/lesen/schließen nacheinander.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
BASIS = "/config/audi_dashboard"
|
||||
PROFIL_PFAD = f"{BASIS}/fahrzeugprofil.json"
|
||||
FAHRTEN_PFAD = f"{BASIS}/fahrten.jsonl"
|
||||
TANKVORGAENGE_PFAD = f"{BASIS}/tankvorgaenge.jsonl"
|
||||
BATTERIEVERLAUF_PFAD = f"{BASIS}/batteriespannung.jsonl"
|
||||
BELEGE_ORDNER = f"{BASIS}/belege"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Ordner ---
|
||||
|
||||
def ordner_sicherstellen():
|
||||
os.makedirs(BASIS, exist_ok=True)
|
||||
os.makedirs(BELEGE_ORDNER, exist_ok=True)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- Fahrzeugprofil
|
||||
|
||||
def profil_lesen():
|
||||
f = task.executor(io.open, PROFIL_PFAD, "r")
|
||||
inhalt = f.read()
|
||||
f.close()
|
||||
return json.loads(inhalt)
|
||||
|
||||
|
||||
def profil_schreiben(profil):
|
||||
tmp = PROFIL_PFAD + ".tmp"
|
||||
text = json.dumps(profil, ensure_ascii=False, indent=2)
|
||||
f = task.executor(io.open, tmp, "w")
|
||||
f.write(text)
|
||||
f.close()
|
||||
os.replace(tmp, PROFIL_PFAD)
|
||||
|
||||
|
||||
# --------------------------------------------------------- JSON-Lines-Basis
|
||||
|
||||
def _zeilen_lesen(pfad):
|
||||
if not os.path.exists(pfad):
|
||||
return []
|
||||
f = task.executor(io.open, pfad, "r")
|
||||
inhalt = f.read()
|
||||
f.close()
|
||||
datensaetze = []
|
||||
for zeile in inhalt.splitlines():
|
||||
zeile = zeile.strip()
|
||||
if zeile:
|
||||
datensaetze.append(json.loads(zeile))
|
||||
return datensaetze
|
||||
|
||||
|
||||
def _zeilen_schreiben(pfad, datensaetze):
|
||||
tmp = pfad + ".tmp"
|
||||
zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze]
|
||||
text = "\n".join(zeilen)
|
||||
if zeilen:
|
||||
text += "\n"
|
||||
f = task.executor(io.open, tmp, "w")
|
||||
f.write(text)
|
||||
f.close()
|
||||
os.replace(tmp, pfad)
|
||||
|
||||
|
||||
def _zeile_anhaengen(pfad, datensatz):
|
||||
f = task.executor(io.open, pfad, "a")
|
||||
f.write(json.dumps(datensatz, ensure_ascii=False) + "\n")
|
||||
f.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Fahrten
|
||||
|
||||
def fahrten_lesen():
|
||||
return _zeilen_lesen(FAHRTEN_PFAD)
|
||||
|
||||
|
||||
def fahrten_schreiben(fahrten):
|
||||
_zeilen_schreiben(FAHRTEN_PFAD, fahrten)
|
||||
|
||||
|
||||
def fahrt_anhaengen(fahrt):
|
||||
_zeile_anhaengen(FAHRTEN_PFAD, fahrt)
|
||||
|
||||
|
||||
def offene_fahrten():
|
||||
"""Alle Fahrten mit status == 'offen', ältere zuerst."""
|
||||
fahrten = fahrten_lesen()
|
||||
offen = [f for f in fahrten if f.get("status") == "offen"]
|
||||
return sorted(offen, key=lambda f: f.get("ts_start", ""))
|
||||
|
||||
|
||||
def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben):
|
||||
"""Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID und
|
||||
schreibt den gesamten Bestand neu. Manuell geänderte Felder
|
||||
(edited_fields, analog zu §7.7 Regel 3) werden dabei nie überschrieben.
|
||||
Gemeinsame Grundlage für Fahrten und Tankvorgänge - beide Archive
|
||||
funktionieren nach demselben Muster."""
|
||||
geaendert = False
|
||||
for d in zeilen:
|
||||
if d.get(id_feld) == id_wert:
|
||||
geschuetzt = set(d.get("edited_fields", []))
|
||||
for feld, wert in aenderungen.items():
|
||||
if feld not in geschuetzt:
|
||||
d[feld] = wert
|
||||
geaendert = True
|
||||
break
|
||||
if geaendert:
|
||||
schreiben(zeilen)
|
||||
return geaendert
|
||||
|
||||
|
||||
def fahrt_aktualisieren(trip_id, aenderungen):
|
||||
return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben)
|
||||
|
||||
|
||||
def fahrt_loeschen(trip_id):
|
||||
fahrten = fahrten_lesen()
|
||||
uebrig = [f for f in fahrten if f.get("trip_id") != trip_id]
|
||||
if len(uebrig) == len(fahrten):
|
||||
return False
|
||||
fahrten_schreiben(uebrig)
|
||||
return True
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Tankvorgänge
|
||||
|
||||
def tankvorgaenge_lesen():
|
||||
return _zeilen_lesen(TANKVORGAENGE_PFAD)
|
||||
|
||||
|
||||
def tankvorgaenge_schreiben(tankvorgaenge):
|
||||
_zeilen_schreiben(TANKVORGAENGE_PFAD, tankvorgaenge)
|
||||
|
||||
|
||||
def tankvorgang_anhaengen(tankvorgang):
|
||||
_zeile_anhaengen(TANKVORGAENGE_PFAD, tankvorgang)
|
||||
|
||||
|
||||
def tankvorgang_aktualisieren(tank_id, aenderungen):
|
||||
return _datensatz_aktualisieren(tankvorgaenge_lesen(), "tank_id", tank_id, aenderungen, tankvorgaenge_schreiben)
|
||||
|
||||
|
||||
def tankvorgang_loeschen(tank_id):
|
||||
tankvorgaenge = tankvorgaenge_lesen()
|
||||
uebrig = [t for t in tankvorgaenge if t.get("tank_id") != tank_id]
|
||||
if len(uebrig) == len(tankvorgaenge):
|
||||
return False
|
||||
tankvorgaenge_schreiben(uebrig)
|
||||
return True
|
||||
|
||||
|
||||
def letzter_tankvorgang():
|
||||
"""Der zeitlich jüngste bereits erfasste Tankvorgang (nach ts), oder None,
|
||||
falls noch keiner existiert. Grundlage für die Gefahrene-Distanz-Berechnung
|
||||
beim Anlegen eines neuen Tankvorgangs (§5.5)."""
|
||||
tankvorgaenge = tankvorgaenge_lesen()
|
||||
if not tankvorgaenge:
|
||||
return None
|
||||
return max(tankvorgaenge, key=lambda t: t.get("ts") or "")
|
||||
|
||||
|
||||
def distanz_seit_letzter_tankung(aktueller_km):
|
||||
"""Gefahrene Distanz seit dem vorherigen Tankvorgang, als Vorschlag für
|
||||
das gleichnamige Formularfeld (§5.5) - frei überschreibbar, genau wie
|
||||
odometer_km selbst. None, wenn kein Kilometerstand oder kein vorheriger
|
||||
Tankvorgang vorliegt (erster Eintrag überhaupt). Gemeinsame Grundlage für
|
||||
Beleg-Erfassung (belegverarbeitung.py) und automatische Tankerkennung
|
||||
(tankerkennung.py)."""
|
||||
if aktueller_km is None:
|
||||
return None
|
||||
letzter = letzter_tankvorgang()
|
||||
if not letzter or letzter.get("odometer_km") is None:
|
||||
return None
|
||||
return round(aktueller_km - letzter["odometer_km"], 1)
|
||||
|
||||
|
||||
def tankvorgang_nach_id(tank_id):
|
||||
for t in tankvorgaenge_lesen():
|
||||
if t.get("tank_id") == tank_id:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def tankvorgang_nach_receipt_key(receipt_key):
|
||||
"""Für §7.7 Regel: derselbe Beleg (receipt_key, minutengenau) erzeugt
|
||||
keinen zweiten Datensatz."""
|
||||
for t in tankvorgaenge_lesen():
|
||||
if t.get("receipt_key") == receipt_key:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------- Batteriespannung
|
||||
|
||||
def batterieverlauf_lesen():
|
||||
"""Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}), älteste
|
||||
zuerst - siehe batterieverlauf.py für die Aufzeichnungslogik. min_ts/
|
||||
max_ts sind die Zeitstempel (ISO, UTC) der jeweiligen Einzelmessung, für
|
||||
die Datum/Uhrzeit-Anzeige beim Antippen des Diagrammpunkts im Frontend -
|
||||
der Punkt selbst zeigt nur den Minimalwert (siehe dortiger Kommentar,
|
||||
warum der aussagekräftig für die Entladung ist)."""
|
||||
return _zeilen_lesen(BATTERIEVERLAUF_PFAD)
|
||||
|
||||
|
||||
def batterieverlauf_tageswert_aktualisieren(datum, ts, spannung):
|
||||
"""Trägt eine neue Messung in den Tageseintrag für `datum` ein: legt ihn
|
||||
beim ersten Wert des Tages an, erweitert sonst nur min/max samt dem
|
||||
Zeitstempel der jeweils neuen Extremmessung. Das Fahrzeug meldet die
|
||||
Spannung künftig mehrfach pro Stunde (aktive Fahrt) statt nur einmal
|
||||
täglich - der komplette Bestand wird deshalb bei jeder Messung neu
|
||||
geschrieben (wie bei den übrigen JSON-Lines-Beständen hier), was bei
|
||||
einem Eintrag pro Tag über Jahre hinweg unproblematisch bleibt."""
|
||||
verlauf = batterieverlauf_lesen()
|
||||
for eintrag in verlauf:
|
||||
if eintrag.get("datum") == datum:
|
||||
if spannung < eintrag["min"]:
|
||||
eintrag["min"] = spannung
|
||||
eintrag["min_ts"] = ts
|
||||
if spannung > eintrag["max"]:
|
||||
eintrag["max"] = spannung
|
||||
eintrag["max_ts"] = ts
|
||||
break
|
||||
else:
|
||||
verlauf.append({"datum": datum, "min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts})
|
||||
_zeilen_schreiben(BATTERIEVERLAUF_PFAD, verlauf)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- IDs
|
||||
|
||||
def neue_id(praefix):
|
||||
return f"{praefix}-{uuid.uuid4().hex[:12]}"
|
||||
Reference in New Issue
Block a user