Station name as "brand, street, city", grey box on all editable fields

The "grey box around the icons" report turned out not to be a defect: the box
on "10.000 km"/"1 Jahr"/"15" is the wanted pattern, and every value the user
can change should carry it. All .feld inputs and selects now get the grey iOS
fill; inputs with their own visual language (checkbox/radio/range/file) keep
the transparent base rule, so the switches are unaffected.

Fuel stations are now displayed as "Shell, Pascalstr. 8, Ingolstadt" instead of
the bare operator name. New _marke()/_ist_strasse()/_tankstelle() helpers are
shared by both parser paths so Shell and non-Shell receipts format the same.
The brand is only matched against the receipt header - searching the whole text
would let "Total" hit a totals line. Missing parts are dropped instead of
leaving empty comma slots, and without a known brand the operator name takes
its place. station_address still carries the full street and postcode.
test_bekannte_stationen updated to the new format; suite stays green.

installationspaket/ is versioned from now on (user request). It contains no
real vehicle data - only the example profile with empty FIN/plate placeholders.
Keep it in sync whenever pyscript/ or www/ changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 12:52:31 +02:00
parent 39da68cd87
commit fb38c297bd
49 changed files with 11131 additions and 27 deletions
@@ -0,0 +1,113 @@
"""Backup-Funktion für Fahrzeugprofil, Fahrten und Tankvorgänge.
Manuell über "Backup jetzt erstellen" in den Einstellungen oder automatisch
je nach einstellungen.backup_intervall im Fahrzeugprofil (aus/täglich/
wöchentlich/monatlich) - immer um 04:00 Uhr, wenn eingestellt. Ein einzelner
täglicher Trigger um 04:00 genügt für alle drei Intervalle: wöchentlich läuft
nur montags, monatlich nur am Monatsersten, täglich jeden Tag - so reicht eine
einzige Trigger-Registrierung statt drei.
Jedes Backup ist ein eigener Zeitstempel-Unterordner mit Kopien der drei
Bestände (siehe profil.py), keine ZIP-Datei - das würde den vorhandenen
task.executor(io.open, ...)-Weg (siehe dessen Kopfkommentar in profil.py)
unnötig verkomplizieren. shutil.copy() ist wie io.open eine echte externe
Funktion und läuft deshalb über denselben task.executor-Weg.
"Backup exportieren"/"importieren" in der Oberfläche laufen dagegen rein
über den Browser (aktuell geladenes Profil + Fahrten + Tankvorgänge als eine
JSON-Datei, siehe audi-dashboard-app.js) - audi_dashboard_backup_wiederherstellen()
hier ist nur das Gegenstück zum Import, das die drei Bestände wieder
komplett überschreibt.
"""
import datetime
import json
import os
import shutil
import entitaeten
import frontend_veroeffentlichung
import profil
BACKUP_ORDNER = f"{profil.BASIS}/backups"
# entitaeten.json gehört dazu: Ohne sie wäre nach einer Wiederherstellung die
# komplette Sensor-Zuordnung aus dem Setup-Menü verloren, und weil sie sonst
# nirgends abgelegt ist, gäbe es nichts zurückzuholen.
_DATEIEN = [
"fahrzeugprofil.json",
"fahrten.jsonl",
"tankvorgaenge.jsonl",
"entitaeten.json",
]
def _backup_jetzt():
ziel = f"{BACKUP_ORDNER}/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
os.makedirs(ziel, exist_ok=True)
for name in _DATEIEN:
quelle = f"{profil.BASIS}/{name}"
if os.path.exists(quelle):
task.executor(shutil.copy, quelle, f"{ziel}/{name}")
return ziel
def _letztes_backup_vermerken():
p = profil.profil_lesen()
if p is None:
return
p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat()
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
@service
def audi_dashboard_backup_jetzt():
"""Erstellt sofort ein Backup. Aufruf als pyscript.audi_dashboard_backup_jetzt."""
ziel = _backup_jetzt()
_letztes_backup_vermerken()
log.info(f"audi_dashboard: Backup erstellt unter {ziel}")
@time_trigger("cron(0 4 * * *)")
def _automatisches_backup():
p = profil.profil_lesen()
if p is None:
return
intervall = p.get("einstellungen", {}).get("backup_intervall", "aus")
heute = datetime.date.today()
faellig = (
intervall == "taeglich"
or (intervall == "woechentlich" and heute.weekday() == 0)
or (intervall == "monatlich" and heute.day == 1)
)
if not faellig:
return
ziel = _backup_jetzt()
_letztes_backup_vermerken()
log.info(f"audi_dashboard: automatisches Backup ({intervall}) erstellt unter {ziel}")
@service
def audi_dashboard_backup_wiederherstellen(profil_json=None, fahrten_json=None, tankvorgaenge_json=None, entitaeten_json=None):
"""Stellt ein über "Backup exportieren" heruntergeladenes Backup wieder
her - überschreibt Profil, Fahrten, Tankvorgänge und die Sensor-Zuordnung
komplett. Aufruf als pyscript.audi_dashboard_backup_wiederherstellen.
entitaeten_json ist optional: ältere Sicherungen, die vor dem Setup-Menü
entstanden sind, enthalten den Abschnitt nicht - dann bleibt die aktuelle
Zuordnung unangetastet, statt sie zu leeren."""
if profil_json:
p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json
profil.profil_schreiben(p)
if fahrten_json is not None:
fahrten = json.loads(fahrten_json) if isinstance(fahrten_json, str) else fahrten_json
profil.fahrten_schreiben(fahrten)
if tankvorgaenge_json is not None:
tankvorgaenge = json.loads(tankvorgaenge_json) if isinstance(tankvorgaenge_json, str) else tankvorgaenge_json
profil.tankvorgaenge_schreiben(tankvorgaenge)
if entitaeten_json:
zuordnung = json.loads(entitaeten_json) if isinstance(entitaeten_json, str) else entitaeten_json
entitaeten.overrides_schreiben(zuordnung)
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.alles_veroeffentlichen()
log.info("audi_dashboard: Backup wiederhergestellt")
@@ -0,0 +1,62 @@
"""Langfristige Aufzeichnung der 12V-Batteriespannung (Mein Audi -> Zustand
-> Batteriespannung antippen). Eine über die Jahre absinkende Spannung
deutet auf eine alternde Batterie hin - dafür braucht es einen Verlauf,
nicht nur den aktuellen Wert.
Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}) reicht für diesen
Zweck aus und hält batteriespannung.jsonl auch über viele Jahre klein - auch
wenn der Sensor selbst künftig mehrfach pro Stunde meldet (aktive Fahrt:
Motor an/aus, Ladezustand des Alternators wechselt laufend).
batterie_verlauf_pruefen() läuft deshalb häufig (alle 5 Minuten) und trägt
jede Messung in den Tageseintrag ein, statt wie zuvor nur einmal täglich
einen Einzelwert zu speichern - profil.batterieverlauf_tageswert_aktualisieren()
erweitert dabei nur min/max samt dem genauen Zeitstempel der jeweiligen
Extremmessung, robust gegen Neustarts und schwankende Erreichbarkeit.
Der Tagesminimalwert ist die für die Entladung aussagekräftige Größe: er
fällt typischerweise auf den Moment kurz nach dem Start, bevor der
Alternator die Ladespannung hochfährt - also am nächsten an die "echte"
Ruhespannung heran, ohne 2+ Stunden Standzeit abwarten zu müssen. Deshalb
verwendet auch die Ladezustands-/Gesundheitsauswertung im Frontend
(audi-dashboard-app.js, AGM_RUHE_MAX_V) den Tagesminimalwert als
repräsentativen Wert des Tages, nicht den Maximalwert.
Anders als in der ersten Fassung wird hier NICHT mehr gefiltert, welche
Messwerte gespeichert werden - min UND max jedes Tages werden roh
aufgezeichnet (der Maximalwert ist z. B. informativ beim Antippen eines
Punkts im Diagramm sichtbar). Die AGM-Ruhespannungs-Grenze wird stattdessen
erst beim Anzeigen angewendet (siehe AGM_RUHE_MAX_V im Frontend): Tage, an
denen selbst der Minimalwert über der Grenze liegt (Fahrzeug nie im
Ruhezustand beobachtet, z. B. bei einer sehr langen Fahrt), tauchen dort
nicht in der Ladezustands-Statistik auf.
Ohne eingetragenen einstellungen.BATTERIE_SENSOR (aktuell der Fall - die
genutzte Integration liefert dafür keinen Sensor, siehe dortiger Kommentar)
passiert hier bewusst nichts, statt Platzhalter-Nullwerte aufzuzeichnen.
"""
import datetime
import einstellungen
import frontend_veroeffentlichung
import profil
@time_trigger("startup")
@time_trigger("period(now, 5 minutes)")
def batterie_verlauf_pruefen():
if not einstellungen.BATTERIE_SENSOR:
return
wert = frontend_veroeffentlichung.zustand_oder_none(einstellungen.BATTERIE_SENSOR)
if wert is None:
return
try:
spannung = float(wert)
except (TypeError, ValueError):
return
heute = datetime.date.today().isoformat()
ts = datetime.datetime.now(datetime.timezone.utc).isoformat()
profil.batterieverlauf_tageswert_aktualisieren(heute, ts, spannung)
frontend_veroeffentlichung.batterieverlauf_veroeffentlichen()
log.info(f"audi_dashboard: Batteriespannung erfasst ({heute}: {spannung} V)")
@@ -0,0 +1,275 @@
"""Belegverarbeitung für Shell-Tankbelege (§7.7, §10 Punkt 7).
Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff.
shell_beleg_parser.py, im Lastenheft (§2) als mitgeliefert genannt, lag
beim Bau nicht im Projektordner vor (siehe Ersteinschätzung) und wurde
nachträglich unter audi_dashboard/shell_beleg_parser.py gegen einen echten
Shell-eReceipt neu geschrieben (siehe dessen Kopfkommentar). Aufruf über
eine Kommandozeilen-Schnittstelle: `python3 shell_beleg_parser.py
<pfad-zur-pdf>`, Ausgabe als JSON auf stdout mit den Feldern aus §6.4
(receipt_key, ts, station_name, liters, fuel_total_eur, discount, ...).
Muss unter /config/audi_dashboard/shell_beleg_parser.py liegen (siehe
INSTALL.md) - eigener Prozess mit dem System-python3 des Containers, nicht
die pyscript-Sandbox, deshalb dort einmalig `pip install pypdf` nötig.
ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem
ursprünglichen Bau nachträglich verlangt):
- Volltankung (§7.4) ist entfernt - weder Berechnung noch Anzeige. Damit
entfallen auch level_before_pct/level_after_pct, die nur dafür gebraucht
wurden.
- fuel_total_eur ist immer der reale, bereits rabattierte Betrag.
Die SmartDeal-Ersparnis steht separat in discount. price_per_l wird
grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie aus einem vom
Beleg übernommenen Wert übernommen - siehe Kopfkommentar in
audi-dashboard-app.js (tankFelder()) für dieselbe Regel im Frontend.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import base64
import datetime
import io
import json
import os
import subprocess
import einstellungen
import frontend_veroeffentlichung
import profil
PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py"
# --------------------------------------------------------------- Hilfsfunktionen
def _pdf_speichern(inhalt_bytes, dateiname):
# io.open über task.executor, kein with, kein bloßes open() - siehe
# Kopfkommentar in profil.py für alle drei Gründe.
os.makedirs(profil.BELEGE_ORDNER, exist_ok=True)
pfad = f"{profil.BELEGE_ORDNER}/{dateiname}"
f = task.executor(io.open, pfad, "wb")
f.write(inhalt_bytes)
f.close()
return pfad
def _parser_aufrufen(pdf_pfad):
ergebnis = subprocess.run(
["python3", PARSER_SKRIPT, pdf_pfad],
capture_output=True, text=True, timeout=30,
)
if ergebnis.returncode != 0:
raise RuntimeError(f"shell_beleg_parser.py fehlgeschlagen: {ergebnis.stderr}")
return json.loads(ergebnis.stdout)
def _preis_je_liter(fuel_total_eur, liters):
if not liters:
return None
return round(fuel_total_eur / liters, 3)
# -------------------------------------------------------------------- Service
@service
def audi_dashboard_beleg_hochladen(pdf_base64=None, dateiname=None, tank_id=None):
"""Nimmt einen per Base64 codierten PDF-Beleg entgegen (Upload aus der
Oberfläche über hass.callService).
Mit tank_id: trägt die Belegdaten direkt in einen bereits bestehenden
Tankvorgang nach - egal ob der ursprünglich manuell oder automatisch
angelegt wurde. Der Kilometerstand/die gefahrene Distanz dieses
Tankvorgangs bleiben dabei unangetastet (die kommen weiterhin vom
Fahrzeug, nie vom Beleg, §7.7 Regel 2), nur die Beleg-Felder (Liter,
Kosten, Ersparnis, Station, Zeitstempel, ...) werden ergänzt
beziehungsweise überschrieben. Passt das Belegdatum nicht zum bereits
erfassten Tankvorgang (unterschiedlicher Kalendertag), wird nichts
übernommen - stattdessen ein Fehler veröffentlicht, damit in der
Oberfläche nicht versehentlich ein falscher Beleg einem bestehenden
Eintrag zugeordnet wird.
Ohne tank_id: legt KEINEN eigenständigen Tankvorgang mehr an (frühere
Abweichung vom Lastenheft, auf Wunsch entfernt), sondern parst den Beleg
nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte
in den gerade offenen Entwurf (neuer oder bearbeiteter Tankvorgang) und
speichert erst auf einen expliziten Klick auf "Speichern".
Aufruf als pyscript.audi_dashboard_beleg_hochladen."""
if not pdf_base64:
log.warning("audi_dashboard: kein pdf_base64 übergeben")
return
# task.executor entfällt hier bewusst: _pdf_speichern/_parser_aufrufen sind
# in dieser pyscript-Datei definiert, task.executor akzeptiert aber nur
# echte externe Python-Funktionen (siehe Kopfkommentar in profil.py).
#
# Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte
# Exception hier (kaputtes Base64, volle Festplatte, ...) wäre für die
# Oberfläche unsichtbar geblieben - serviceRufen() im Frontend loggt einen
# rejizierten Service-Aufruf nur in die Browser-Konsole, zeigt aber nichts
# an (siehe audi-dashboard-app.js). Bisher war nur der Parser-Aufruf
# selbst so abgesichert; b64decode/_pdf_speichern fehlten.
try:
inhalt_bytes = base64.b64decode(pdf_base64)
dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname)
geparst = _parser_aufrufen(pdf_pfad)
except Exception as exc:
log.error(f"audi_dashboard: Beleg konnte nicht verarbeitet werden: {exc}")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id})
return
receipt_key = geparst.get("receipt_key")
if not receipt_key:
log.error("audi_dashboard: Parser lieferte keinen receipt_key")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id})
return
# §7.7 Regel 3: derselbe Beleg (receipt_key, minutengenau) erzeugt
# keinen zweiten Datensatz
vorhandener_beleg = profil.tankvorgang_nach_receipt_key(receipt_key)
if vorhandener_beleg is not None and vorhandener_beleg.get("tank_id") != tank_id:
log.info(f"audi_dashboard: Beleg {receipt_key} bereits vorhanden, übersprungen")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg bereits erfasst", "tank_id": tank_id})
return
liters = geparst.get("liters")
fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert
beleg_felder = {
"receipt_key": receipt_key,
"receipt_no": geparst.get("receipt_no"),
"tse_beleg_nr": geparst.get("tse_beleg_nr"),
"ts": geparst.get("ts"),
"ts_payment": geparst.get("ts_payment"),
"ts_tse": geparst.get("ts_tse"),
"station_id": geparst.get("station_id"),
"station_name": geparst.get("station_name"),
"station_address": geparst.get("station_address"),
"article_no": geparst.get("article_no"),
"product_name": geparst.get("product_name"),
"fuel_type": geparst.get("fuel_type"),
# Liter, Kosten und Ersparnis kommen vom Beleg (§7.7 Regel 2).
# price_per_l wird nie vom Beleg übernommen, sondern immer berechnet.
"liters": liters,
"fuel_total_eur": fuel_total_eur,
"price_per_l": _preis_je_liter(fuel_total_eur, liters),
"discount": geparst.get("discount"), # SmartDeal-Ersparnis
"discount_per_l": geparst.get("discount_per_l"),
"list_price_per_l": geparst.get("list_price_per_l"),
"receipt_total_eur": geparst.get("receipt_total_eur"),
"net_eur": geparst.get("net_eur"),
"vat_eur": geparst.get("vat_eur"),
"receipt_file": pdf_pfad,
}
if tank_id:
vorhanden = profil.tankvorgang_nach_id(tank_id)
if not vorhanden:
log.warning(f"audi_dashboard: Tankvorgang {tank_id} für Beleg-Nachtrag nicht gefunden")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id})
return
vorhandenes_datum = (vorhanden.get("ts") or "")[:10]
belegdatum = (geparst.get("ts") or "")[:10]
if vorhandenes_datum and belegdatum and vorhandenes_datum != belegdatum:
log.warning(f"audi_dashboard: Belegdatum {belegdatum} passt nicht zu Tankvorgang {tank_id} (Datum {vorhandenes_datum})")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Falsches Belegdatum", "tank_id": tank_id})
return
aenderungen = dict(beleg_felder)
aenderungen["status"] = "vollständig"
if profil.tankvorgang_aktualisieren(tank_id, aenderungen):
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id})
log.info(f"audi_dashboard: Beleg {receipt_key} in Tankvorgang {tank_id} nachgetragen")
else:
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id})
return
# Kein tank_id: nur parsen und dem gerade offenen Entwurf in der
# Oberfläche zum Ausfüllen bereitstellen - legt selbst KEINEN
# Tankvorgang an (siehe Kopfkommentar oben).
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None})
log.info(f"audi_dashboard: Beleg {receipt_key} geparst, wartet auf Speichern in der Oberfläche")
@service
def audi_dashboard_tankvorgang_manuell(ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None):
"""Erfassung eines Tankvorgangs aus dem Formular "Neuer Tankvorgang" -
Fallback aus §7.7 / §10 Punkt 7, unter „Tanken" vorgesehen (§5.5).
Dieselben Felder wie beim Bearbeiten eines bestehenden Vorgangs (siehe
audi_dashboard_tankvorgang_aktualisieren). receipt_key/receipt_file sind
optional: kommen sie mit (weil zuvor ein Beleg über
audi_dashboard_beleg_hochladen ohne tank_id in den Entwurf übernommen
wurde), zählt der Tankvorgang als per Beleg erfasst, sonst als manuell.
Aufruf als pyscript.audi_dashboard_tankvorgang_manuell."""
if liter is None or kosten is None:
log.warning("audi_dashboard: liter und kosten sind Pflichtfelder")
return
odometer_km = km if km is not None else frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR)
tankvorgang = {
"tank_id": profil.neue_id("f"),
"receipt_key": receipt_key,
"ts": ts or datetime.datetime.now(datetime.timezone.utc).isoformat(),
"liters": liter,
"fuel_total_eur": kosten,
"price_per_l": _preis_je_liter(kosten, liter),
"discount": ersparnis,
"station_name": station,
"odometer_km": odometer_km,
# Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie
# odometer_km selbst frei überschreibbar - deshalb nimmt ein manuell
# übergebener Wert immer Vorrang vor der Berechnung.
"distance_km": distanz if distanz is not None else profil.distanz_seit_letzter_tankung(odometer_km),
"fuel_type": kraftstoff,
"source": "beleg" if receipt_key else "manual",
"status": "vollständig",
"receipt_file": receipt_file,
"edited_fields": [],
}
profil.tankvorgang_anhaengen(tankvorgang)
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} angelegt")
@service
def audi_dashboard_tankvorgang_aktualisieren(tank_id=None, ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None):
"""Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob
ursprünglich per Beleg oder manuell angelegt). receipt_key/receipt_file
sind optional: kommen sie mit (ein im Bearbeiten-Formular hochgeladener
Beleg wurde in den Entwurf übernommen), wird der Tankvorgang zusätzlich
als per Beleg erfasst markiert. Aufruf als
pyscript.audi_dashboard_tankvorgang_aktualisieren."""
if not tank_id:
log.warning("audi_dashboard: tankvorgang_aktualisieren ohne tank_id aufgerufen")
return
aenderungen = {
"ts": ts, "liters": liter, "fuel_total_eur": kosten,
"price_per_l": _preis_je_liter(kosten, liter),
"odometer_km": km, "discount": ersparnis, "station_name": station,
"distance_km": distanz, "fuel_type": kraftstoff,
}
if receipt_key:
aenderungen["receipt_key"] = receipt_key
aenderungen["receipt_file"] = receipt_file
aenderungen["source"] = "beleg"
if profil.tankvorgang_aktualisieren(tank_id, aenderungen):
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(f"audi_dashboard: Tankvorgang {tank_id} aktualisiert")
else:
log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden")
@service
def audi_dashboard_tankvorgang_loeschen(tank_id=None):
"""Löscht einen Tankvorgang unwiderruflich (Löschfunktion in der
Oberfläche). Aufruf als pyscript.audi_dashboard_tankvorgang_loeschen."""
if not tank_id:
log.warning("audi_dashboard: tankvorgang_loeschen ohne tank_id aufgerufen")
return
if profil.tankvorgang_loeschen(tank_id):
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(f"audi_dashboard: Tankvorgang {tank_id} gelöscht")
else:
log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden")
@@ -0,0 +1,71 @@
"""Fahrzeugbilder direkt aus der Oberfläche hochladen/löschen (Einstellungen
-> Fahrzeugbilder, siehe BILDER_UPLOAD_SLOTS in audi-dashboard-app.js).
Nur die festen, im Frontend fest verdrahteten Dateinamen sind erlaubt - eine
Positivliste, damit ein manipulierter Dateiname aus dem Frontend niemals
außerhalb von www/bilder/ schreiben kann (kein Pfad-Traversal über "../" oder
absolute Pfade). Die Bytes werden unverändert unter dem erwarteten Namen
abgelegt, unabhängig vom tatsächlichen Bildformat des Originalfotos (z. B.
JPEG von einem iPhone unter einem ".webp"-Namen) - Browser stellen <img> nach
den tatsächlichen Bilddaten dar, nicht nach der Dateiendung, das funktioniert
also trotzdem. Eine Größenbeschränkung gibt es bewusst nicht (Vorgabe: jede
Fotogröße muss hochladbar sein).
"""
import base64
import io
import os
BILDER_ORDNER = "/config/www/bilder"
ERLAUBTE_DATEINAMEN = {
"seitenansicht.webp",
"seitenansicht-winter.webp",
"front-schraeg.webp",
"frontansicht.webp",
"heckansicht.webp",
"cockpit.webp",
"scheinwerfer.webp",
"sitze.webp",
"rad-sommer.webp",
"rad-winter.webp",
}
def _pfad(dateiname):
if dateiname not in ERLAUBTE_DATEINAMEN:
return None
return f"{BILDER_ORDNER}/{dateiname}"
@service
def audi_dashboard_bild_hochladen(dateiname=None, daten_base64=None):
"""Speichert ein aus der Oberfläche hochgeladenes Fahrzeugfoto unter
einem der festen erwarteten Namen. Aufruf als
pyscript.audi_dashboard_bild_hochladen."""
pfad = _pfad(dateiname)
if not pfad or not daten_base64:
log.warning(f"audi_dashboard: Bild-Upload abgelehnt (dateiname={dateiname})")
return
os.makedirs(BILDER_ORDNER, exist_ok=True)
rohdaten = base64.b64decode(daten_base64)
tmp = pfad + ".tmp"
f = task.executor(io.open, tmp, "wb")
f.write(rohdaten)
f.close()
os.replace(tmp, pfad)
log.info(f"audi_dashboard: Fahrzeugbild gespeichert ({dateiname}, {len(rohdaten)} Bytes)")
@service
def audi_dashboard_bild_loeschen(dateiname=None):
"""Entfernt ein zuvor hochgeladenes Fahrzeugfoto wieder - die Ansicht
fällt danach auf den Platzhalter zurück. Aufruf als
pyscript.audi_dashboard_bild_loeschen."""
pfad = _pfad(dateiname)
if not pfad:
log.warning(f"audi_dashboard: Bild-Löschen abgelehnt (dateiname={dateiname})")
return
if os.path.exists(pfad):
os.remove(pfad)
log.info(f"audi_dashboard: Fahrzeugbild gelöscht ({dateiname})")
@@ -0,0 +1,31 @@
"""Trigger-Registrierung für den zweistufigen Fahrtabschluss (§7.2).
Die eigentliche Logik liegt in pyscript/modules/fahrtabschluss_logik.py
(importierbares Modul, auch von fahrterkennung.py genutzt). Diese Datei
sorgt nur dafür, dass ein Screening auch dann läuft, wenn der Kilometerstand-
Sensor sich ändert - unabhängig vom Fahrtende-Ereignis selbst, weil der Wert
laut §10 Punkt 4 auch erst mit der nächsten Fahrt eintreffen kann.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import einstellungen
import fahrtabschluss_logik
# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.KM_SENSOR:
@state_trigger(f"{einstellungen.KM_SENSOR}")
def kilometerstand_geaendert(value=None, old_value=None):
if value is None or old_value is None:
return
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@service
def audi_dashboard_screening_jetzt():
"""Manueller Anstoß des Screenings, z. B. aus der Oberfläche heraus
(pyscript.audi_dashboard_screening_jetzt)."""
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@@ -0,0 +1,171 @@
"""Fahrterkennung über den Zündungs-/ACC-Status des FMM003 (§7.1).
Start: die Zündungs-Entität (einstellungen.ZUENDUNG_SENSOR) wechselt auf
"on". Ende: sie wechselt auf "off" und kommt binnen der Pausenzeit nicht
zurück.
2026-08-12: ersetzt die frühere WLAN-basierte Erkennung (Vergleich des vom
iPhone gemeldeten verbundenen WLANs gegen den im Fahrzeugprofil hinterlegten
Namen) - die Zündung ist ein direkteres, von jeder Companion-App-Eigenheit
unabhängiges Signal.
Die Pausenregel wird über task.unique() umgesetzt: jede neue Änderung der
Zündungs-Entität killt eine noch laufende, wartende Bestätigung des
vorherigen Ereignisses. Kommt die Zündung während der Wartezeit zurück, wird
dadurch die wartende Ende-Bestätigung automatisch abgebrochen, ohne dass die
Fahrt beendet wird - das entspricht der Pausenregel aus §7.1.
Die Entity-IDs stehen zentral in pyscript/modules/einstellungen.py - dort vor
der Installation einmalig anpassen (siehe INSTALL.md), oder über das
Setup-Menü in der Oberfläche zuordnen (siehe entitaeten.py; Änderungen dort
wirken für DIESEN Trigger allerdings erst nach einem Neustart, da
@state_trigger die Entity-ID einmalig beim Modul-Laden einliest).
EINSCHRÄNKUNG: der Zustand einer laufenden Fahrt (_fahrt_start_ts) lebt nur
im Arbeitsspeicher dieses Skripts. Ein HA-Neustart mitten in einer Fahrt oder
während der Wartezeit verliert diesen Zwischenstand - die Fahrt würde dann
nicht aufgezeichnet. Das ist ein bekannter Punkt für eine spätere Härtung
(z. B. Zwischenstand in einer input_datetime-Helper-Entität sichern), hier
bewusst nicht mitgebaut, um die Kernlogik nicht zu verkomplizieren, bevor sie
sich im echten Betrieb bewährt hat.
"""
import datetime
import einstellungen
import fahrtabschluss_logik
import frontend_veroeffentlichung
import profil
_fahrt_start_ts = None
def _pausenzeit_sekunden():
p = profil.profil_lesen()
if p is None:
return 15 * 60
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
# Ohne konfigurierte Zündungs-Entität keinen Trigger registrieren - eine
# leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes Verhalten
# (siehe zustand_oder_none()-Kommentar in frontend_veroeffentlichung.py zur
# Grundhaltung "kein geratener Wert"), hier stattdessen ganz weggelassen.
if einstellungen.ZUENDUNG_SENSOR:
@state_trigger(f"{einstellungen.ZUENDUNG_SENSOR}")
def zuendung_geaendert(value=None, old_value=None):
# Killt eine noch laufende Ende-Bestätigung aus einer vorherigen
# Änderung - das ist der Mechanismus hinter der Pausenregel.
task.unique("audi_dashboard_fahrt_ende_pruefung")
global _fahrt_start_ts
an_jetzt = (value == "on")
an_vorher = (old_value == "on")
if an_jetzt and not an_vorher:
if _fahrt_start_ts is None:
_fahrt_start_ts = datetime.datetime.now(datetime.timezone.utc)
log.info(f"audi_dashboard: Fahrt gestartet um {_fahrt_start_ts.isoformat()}")
return
if an_vorher and not an_jetzt and _fahrt_start_ts is not None:
start_ts = _fahrt_start_ts
abbruch_ts = datetime.datetime.now(datetime.timezone.utc)
wartezeit_s = _pausenzeit_sekunden()
# task.unique() oben markiert diese Ausführung als die aktuell
# gültige. Kommt die Zündung vor Ablauf der Wartezeit zurück,
# ruft der nächste Trigger erneut task.unique() auf und killt
# genau diesen task.sleep() - der Code danach läuft dann nie.
task.sleep(wartezeit_s)
_fahrt_beenden(start_ts, abbruch_ts)
def _fahrt_beenden(start_ts, ende_ts):
global _fahrt_start_ts
dauer_s = int((ende_ts - start_ts).total_seconds())
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": start_ts.isoformat(),
"ts_end": ende_ts.isoformat(),
"duration_s": dauer_s,
"distance_km": None,
"km_quelle": None,
"odo_start": None,
"odo_end": None,
"avg_speed_kmh": None,
"start_lat": None,
"start_lon": None,
"end_lat": None,
"end_lon": None,
"start_address": None,
"end_address": None,
"art": "privat",
"route": None,
"pausen": [],
"source": "ha",
"status": "offen",
"edited_fields": [],
}
profil.fahrt_anhaengen(fahrt)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
log.info(f"audi_dashboard: Fahrt {fahrt['trip_id']} beendet, Status 'offen', Screening angestoßen")
_fahrt_start_ts = None
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@service
def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat"):
"""Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der
automatischen Zündungs-Erkennung. Aufruf als pyscript.audi_dashboard_fahrt_manuell_anlegen."""
start_ts = datetime.datetime.fromisoformat(ts_start)
ende_ts = datetime.datetime.fromisoformat(ts_end)
dauer_s = int((ende_ts - start_ts).total_seconds())
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": start_ts.isoformat(),
"ts_end": ende_ts.isoformat(),
"duration_s": dauer_s,
"distance_km": None,
"km_quelle": None,
"odo_start": None,
"odo_end": None,
"avg_speed_kmh": None,
"start_lat": None,
"start_lon": None,
"end_lat": None,
"end_lon": None,
"start_address": None,
"end_address": None,
"art": art,
"route": None,
"pausen": [],
"source": "manual",
"status": "offen",
"edited_fields": [],
}
profil.fahrt_anhaengen(fahrt)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@service
def audi_dashboard_fahrt_loeschen(trip_id=None):
"""Löscht eine Fahrt unwiderruflich (Löschfunktion in der Oberfläche,
§Löschfunktion). Aufruf als pyscript.audi_dashboard_fahrt_loeschen."""
if not trip_id:
log.warning("audi_dashboard: fahrt_loeschen ohne trip_id aufgerufen")
return
if profil.fahrt_loeschen(trip_id):
frontend_veroeffentlichung.fahrten_veroeffentlichen()
log.info(f"audi_dashboard: Fahrt {trip_id} gelöscht")
else:
log.warning(f"audi_dashboard: Fahrt {trip_id} nicht gefunden")
@@ -0,0 +1,112 @@
"""Verbindung zwischen Frontend und Backend.
Lesen: das Frontend liest hass.states["pyscript.audi_dashboard_profil"] usw.
direkt - kein Service-Aufruf mit Rückgabewert. Die Frontend-Dokumentation von
Home Assistant zu response-fähigen Services beschreibt das Verhalten aus
Sicht des Browsers nicht abschließend (Community-Diskussionen deuten auf
Einschränkungen hin); Zustände lesen ist dagegen ein seit Jahren stabiler,
einfacher Weg. Veröffentlicht wird periodisch UND sofort nach jeder
Schreibaktion (siehe frontend_veroeffentlichung.py, dort auch der Hinweis zur
Größengrenze von Zustands-Attributen).
Schreiben: einzig audi_dashboard_profil_schreiben() als generischer Weg für
das gesamte Profil - das Frontend hält Profil, Fahrzeugdaten und
Einstellungen ohnehin als einen zusammenhängenden Stand im Speicher (wie
schon im Prototyp) und schickt ihn nach jeder Änderung komplett zurück,
statt für jedes einzelne Feld einen eigenen Service zu brauchen.
"""
import json
import einstellungen
import entitaeten
import frontend_veroeffentlichung
import profil
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
# Vor der ersten Veröffentlichung anwenden, damit sie die im Setup-Menü
# gespeicherten Zuordnungen (data/entitaeten.json) schon zeigt, siehe
# entitaeten.py-Kopfkommentar.
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.alles_veroeffentlichen()
@time_trigger("period(now, 20 seconds)")
def periodisch_aktualisieren():
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
@time_trigger("period(now, 60 seconds)")
def periodisch_daten_aktualisieren():
# seltener als der Fahrzeugstatus - Profil/Fahrten/Tankvorgänge ändern
# sich normalerweise nur durch Aktionen, die ohnehin sofort selbst
# veröffentlichen (siehe unten); das hier ist nur das Sicherheitsnetz
# für Änderungen direkt im Dateisystem (§6.2, dritter Bearbeitungsweg).
frontend_veroeffentlichung.profil_veroeffentlichen()
frontend_veroeffentlichung.fahrten_veroeffentlichen()
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
@service
def audi_dashboard_jetzt_aktualisieren():
"""Gegenstück zum "Ziehen zum Aktualisieren" auf der Übersicht: stößt den
von der Integration bereitgestellten Refresh-Knopf an (siehe
einstellungen.REFRESH_BUTTON) statt auf den nächsten periodischen Takt zu
warten, und veröffentlicht den Status danach sofort neu. Ohne
eingetragenen Knopf (leerer Platzhalter) nur die direkte Neu-
veröffentlichung - besser als gar nichts, auch wenn dann kein echter neuer
Abruf beim Fahrzeug passiert. Aufruf als
pyscript.audi_dashboard_jetzt_aktualisieren."""
if einstellungen.REFRESH_BUTTON:
button.press(entity_id=einstellungen.REFRESH_BUTTON)
task.sleep(3)
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
log.info("audi_dashboard: manuelle Aktualisierung (Ziehen zum Aktualisieren) ausgelöst")
@service
def audi_dashboard_profil_schreiben(profil_json=None):
"""Überschreibt das komplette Fahrzeugprofil. Aufruf als
pyscript.audi_dashboard_profil_schreiben."""
if not profil_json:
log.warning("audi_dashboard: profil_schreiben ohne profil_json aufgerufen")
return
p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
log.info("audi_dashboard: Profil aktualisiert")
@service
def audi_dashboard_entitaeten_schreiben(zuordnung_json=None):
"""Speichert die im Setup-Menü (Einstellungen -> Fahrzeug einrichten ->
Setup) gewählte Sensor-zu-Entität-Zuordnung, wendet sie sofort an und
veröffentlicht Katalog+Status neu, damit Übersicht/Mein Audi ohne
Neustart die neuen Werte zeigen (Ausnahme: die drei trigger-gebundenen
Felder, siehe entitaeten.py). Aufruf als
pyscript.audi_dashboard_entitaeten_schreiben."""
if not zuordnung_json:
log.warning("audi_dashboard: entitaeten_schreiben ohne zuordnung_json aufgerufen")
return
mapping = json.loads(zuordnung_json) if isinstance(zuordnung_json, str) else zuordnung_json
entitaeten.overrides_schreiben(mapping)
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.entitaeten_veroeffentlichen()
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
log.info("audi_dashboard: Entitäten-Zuordnung aktualisiert")
@service
def audi_dashboard_neustart():
"""Startet Home Assistant neu. Einziger Zweck: eine Änderung an einem der
drei trigger-gebundenen Setup-Felder (ZUENDUNG_SENSOR/KM_SENSOR/
TANK_SENSOR, siehe entitaeten.py) wird erst nach einem Neustart wirksam,
weil @state_trigger die Entity-ID beim Modul-Laden einmalig einliest.
Wird vom Setup-Popup als expliziter, vom Nutzer bestätigter Knopf
angeboten - kein automatischer Selbst-Reload direkt nach dem Speichern.
Aufruf als pyscript.audi_dashboard_neustart."""
log.info("audi_dashboard: Neustart über das Setup-Menü ausgelöst")
homeassistant.restart()
@@ -0,0 +1,93 @@
"""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. Werte
lassen sich außerdem über das Setup-Menü in der Oberfläche zuordnen (siehe
entitaeten.py) - Änderungen von dort werden zur Laufzeit auf diese Variablen
angewendet (überschreiben also die hier hinterlegten Standardwerte), ohne
diese Datei anzufassen.
2026-08-12: Die HACS-Integration TommiG1/HA_VAG-EU-Data-Act (bisherige
Quelle für Kilometerstand, Tankfüllstand, Türen/Fenster/Schlösser,
Ölwechsel-/Inspektionsdaten) wird nicht mehr verwendet - die zugehörigen
Entity-IDs sind deshalb unten bewusst leer. Neue Datenquelle ist der
Teltonika FMM003 (GPS-Tracker mit CAN-Anbindung, siehe AGENTS.md Abschnitt
B); er liefert Standort, Zündungsstatus und Batteriespannung, aber keinen
Tankfüllstand, keine Tür-/Fenster-/Schlossdaten und keine Ölwechsel-/
Inspektionstermine - die entsprechenden Kacheln zeigen deshalb bis auf
Weiteres "unbekannt" statt eines falschen Werts (siehe zustand_oder_none()
in frontend_veroeffentlichung.py). Bleibt ein Wert leer ("") oder passt eine
Entity-ID nicht zur tatsächlichen Integration, liefert zustand_oder_none()
für das jeweilige Feld None statt abzustürzen.
"""
# Fahrterkennung (§7.1): Start/Ende einer Fahrt wird über den Zündungs-/ACC-
# Status des FMM003 erkannt (on = Fahrt läuft), nicht mehr über die WLAN-
# Verbindung des iPhones zum Fahrzeug (siehe fahrterkennung.py).
ZUENDUNG_SENSOR = "binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"
# Kilometerstand - bisher aus der TommiG1-Integration, aktuell keine Quelle
# vorhanden. Der FMM003 liefert unter sensor.testzone_fmm003_total_calculated_mileage
# einen selbst berechneten Wert, der aber auf einer anderen Zählbasis beruht
# als der echte Fahrzeug-Kilometerstand (GPS-Streckenberechnung statt
# Tacho) - bewusst NICHT automatisch übernommen, um Reifenzähler,
# Ölwechsel-Prognose und Fahrtabschluss-Screening nicht mit einem
# inkonsistenten Basiswert zu verfälschen. Bei Bedarf über das Setup-Menü
# gezielt zuordnen.
KM_SENSOR = ""
# Tankfüllstand (Prozent) - keine Quelle mehr vorhanden (der FMM003 ist kein
# Tankgeber).
TANK_SENSOR = ""
# Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden.
RANGE_SENSOR = ""
# 12V-Batteriespannung (Mein Audi -> Zustand). Vom FMM003 geliefert -
# external_power_voltage ist die vom Gerät gemessene Bordnetzspannung des
# Fahrzeugs, NICHT battery_voltage (das ist die interne Pufferbatterie des
# Trackers selbst und hat mit der Fahrzeugbatterie nichts zu tun).
BATTERIE_SENSOR = "sensor.testzone_fmm003_external_power_voltage"
# Knopf für eine sofortige Neuabfrage beim Fahrzeug - kam aus der
# TommiG1-Integration, keine Entsprechung beim FMM003 vorhanden.
REFRESH_BUTTON = ""
# Türen (§4.1) - keine Quelle mehr vorhanden.
TUER_SENSOREN = []
# Fenster (§4.1) - keine Quelle mehr vorhanden.
FENSTER_SENSOREN = []
# Heckklappe und Motorhaube - keine Quelle mehr vorhanden.
HECKKLAPPE_SENSOR = ""
HECKKLAPPENSCHLOSS_SENSOR = ""
HAUBE_SENSOR = ""
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
# aus dem Servicebuch berechnete Prognose) - keine Quelle mehr vorhanden.
NAECHSTER_OELWECHSEL_SENSOR = ""
OELWECHSEL_STRECKE_SENSOR = ""
NAECHSTE_INSPEKTION_SENSOR = ""
INSPEKTION_STRECKE_SENSOR = ""
# Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Breiten-/
# Längengrad als zwei eigene sensor-Entities (flespi liefert Koordinaten so,
# nicht als Attribute einer device_tracker-Entity - siehe _standort() in
# frontend_veroeffentlichung.py).
STANDORT_LAT_SENSOR = ""
STANDORT_LON_SENSOR = ""
# 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,245 @@
"""Sensor-zu-Entität-Zuordnung fürs Setup-Menü (Einstellungen -> Fahrzeug
einrichten -> Setup).
Ersetzt NICHT das manuelle Eintragen in einstellungen.py (dessen Kommentare
sind die eigentliche Dokumentation der Integration und bleiben unangetastet),
sondern legt eine zusätzliche, zur Laufzeit angewendete Override-Ebene
darüber: data/entitaeten.json enthält nur die vom Nutzer über das Setup-Menü
geänderten Zuordnungen, keyed nach den einstellungen.py-Variablennamen.
Warum das funktioniert, ohne jede Verbraucher-Datei anzufassen: Alle
bestehenden Leser (frontend_veroeffentlichung.py, fahrterkennung.py, ...)
machen `import einstellungen` und lesen dann `einstellungen.KM_SENSOR` -
ein lebendiger Attributzugriff auf das Modul-Objekt, kein `from einstellungen
import KM_SENSOR` (das würde den Wert beim Import einfrieren). overrides_
anwenden() unten nutzt genau das: setattr(einstellungen, key, wert) ändert
das Attribut auf dem bereits importierten Modul-Objekt, jeder spätere
Zugriff `einstellungen.KM_SENSOR` liefert sofort den neuen Wert.
Eine Ausnahme (siehe TRIGGER_GEBUNDEN unten): mehrere @state_trigger(f"...")-
Dekoratoren backen ZUENDUNG_SENSOR/KM_SENSOR/TANK_SENSOR einmalig beim
Modul-Laden in die Trigger-Registrierung ein. Eine Änderung dieser drei
Felder wirkt für die Fahrterkennung selbst erst nach einem Neustart von
Home Assistant (bzw. pyscript.reload) - das Setup-Menü weist deshalb bei
diesen drei Feldern ausdrücklich darauf hin, statt automatisch und riskant
einen Selbst-Reload aus einer laufenden pyscript-Funktion heraus zu
versuchen.
"""
import io
import json
import os
import einstellungen
BASIS = "/config/audi_dashboard"
OVERRIDES_PFAD = f"{BASIS}/entitaeten.json"
# Vier gleich aufgebaute Positionen, wie schon in frontend_veroeffentlichung.py
# (dort POSITIONEN genannt) - hier dupliziert statt importiert, damit
# entitaeten.py nicht von frontend_veroeffentlichung.py abhängt (wäre die
# falsche Richtung: die Veröffentlichung importiert diese Datei, nicht
# umgekehrt).
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
# Katalog aller von der App genutzten Entity-Rollen, fürs Setup-Menü im
# Frontend. Reihenfolge = Anzeige-Reihenfolge innerhalb jeder Gruppe.
# domains/device_classes/units sind Erwartungen fürs Frontend-seitige
# Filtern+Vorschlagen (Schalter "Nur passende Sensoren anzeigen") - eine
# fehlende oder leere Liste bedeutet "keine Einschränkung" bzw. "diese Rolle
# hat üblicherweise keine Einheit" (binary_sensor/button/device_tracker).
FELDER = [
{"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung",
"hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende (ersetzt die frühere WLAN-Erkennung).",
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True,
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
{"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung",
"hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]},
{"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung",
"hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.",
"domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False,
"stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]},
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
"hinweis": "Für die Übersicht - bleibt leer, zeigt die Oberfläche \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["reichweite", "range"]},
{"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht",
"hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False,
"stichworte": ["batterie", "battery", "spannung", "volt", "12v"]},
{"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung (Knopf)", "gruppe": "uebersicht",
"hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".",
"domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]},
{"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.",
"domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["tür", "door"]},
{"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["fenster", "window"]},
{"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]},
{"key": "HECKKLAPPENSCHLOSS_SENSOR", "label": "Heckklappenschloss", "gruppe": "sicherheit",
"hinweis": "\"on\" = entriegelt, \"aus\"/off = verriegelt.",
"domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["heckklappe", "tailgate", "schloss", "lock"]},
{"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["haube", "hood", "bonnet", "motorhaube"]},
{"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel (Datum)", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["ölwechsel", "oil", "service", "change"]},
{"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel (Restkilometer)", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["ölwechsel", "oil", "distance", "strecke"]},
{"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion (Datum)", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["inspektion", "inspection", "service"]},
{"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion (Restkilometer)", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["inspektion", "inspection", "distance", "strecke"]},
{"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort",
"hinweis": "Breitengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]},
{"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort",
"hinweis": "Längengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]},
]
# Mehrere @state_trigger(f"...")-Dekoratoren (fahrterkennung.py,
# fahrtabschluss.py, tankerkennung.py, reifenzaehler.py) backen diese drei
# Felder beim Modul-Laden ein (siehe Kopfkommentar) - im Setup-Menü als
# "wirkt erst nach Neustart"-Hinweis markiert.
_TRIGGER_GEBUNDEN = {"ZUENDUNG_SENSOR", "KM_SENSOR", "TANK_SENSOR"}
for _feld in FELDER:
_feld["trigger_gebunden"] = _feld["key"] in _TRIGGER_GEBUNDEN
_SCHLUESSEL = {feld["key"] for feld in FELDER}
# Schnappschuss der eingebauten Standardwerte aus einstellungen.py, genommen
# BEVOR overrides_anwenden() jemals lief (Modul-Ebene, läuft beim `import
# entitaeten` in frontend_api.py - vor dem @time_trigger("startup"), der
# overrides_anwenden() erstmals aufruft). Notwendig fürs Setup-Menü
# "Zurücksetzen": setattr() in overrides_anwenden() überschreibt das Attribut
# auf dem laufenden einstellungen-Modul dauerhaft, ein einfaches Weglassen
# des Overrides beim nächsten Speichern stellt den eingebauten Wert NICHT
# wieder her (das Attribut bleibt ja gesetzt) - "Zurücksetzen" muss den
# Standardwert stattdessen aktiv zurückschreiben.
_STANDARDWERTE = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER}
# --------------------------------------------------------------- JSON-I/O ---
# Gleiches Muster wie profil.profil_lesen()/profil_schreiben(): io.open nur
# über task.executor (pyscript-Einschränkung, siehe Kopfkommentar dort),
# atomares Schreiben über eine .tmp-Datei + os.replace.
def overrides_lesen():
"""Liest die gespeicherten Zuordnungen, oder {} wenn die Datei fehlt bzw.
beschädigt ist.
Der try/except ist nicht Vorsicht um ihrer selbst willen: overrides_
anwenden() läuft in beim_start() VOR alles_veroeffentlichen(). Ohne die
Absicherung reißt eine einzige unlesbare Zeile in dieser Datei den
gesamten Startvorgang mit - das Panel bliebe komplett leer, ohne dass
irgendetwas auf die Ursache hindeutet."""
if not os.path.exists(OVERRIDES_PFAD):
return {}
f = task.executor(io.open, OVERRIDES_PFAD, "r")
inhalt = f.read()
f.close()
if not inhalt.strip():
return {}
try:
gelesen = json.loads(inhalt)
except ValueError as fehler:
log.error(
f"audi_dashboard: {OVERRIDES_PFAD} ist kein gültiges JSON ({fehler}). "
"Die eingebauten Standardwerte gelten weiter; die Zuordnung lässt sich "
"im Setup-Menü neu speichern."
)
return {}
if not isinstance(gelesen, dict):
log.error(f"audi_dashboard: {OVERRIDES_PFAD} enthält kein Objekt - wird ignoriert.")
return {}
return gelesen
def overrides_schreiben(mapping):
"""Schreibt nur bekannte Schlüssel (siehe FELDER); unbekannte werden
stillschweigend verworfen, damit ein späterer Katalog-Umbau keine
Datenleichen in entitaeten.json hinterlässt."""
bereinigt = {k: v for k, v in mapping.items() if k in _SCHLUESSEL}
os.makedirs(BASIS, exist_ok=True)
tmp = OVERRIDES_PFAD + ".tmp"
text = json.dumps(bereinigt, ensure_ascii=False, indent=2)
f = task.executor(io.open, tmp, "w")
f.write(text)
f.close()
os.replace(tmp, OVERRIDES_PFAD)
# ------------------------------------------------------- Anwenden/Lesen ---
def _ist_leer(wert):
"""Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge
enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...],
und das ist inhaltlich dasselbe wie gar keine Liste.
Die Schleife steht hier bewusst statt eines any(...)-Generatorausdrucks:
pyscript kennt Generatorausdrücke nicht und wirft zur Laufzeit
"not implemented ast ast_generatorexp". Mengen-, Listen- und
Dict-Comprehensions funktionieren dagegen (siehe _SCHLUESSEL oben)."""
if wert in (None, ""):
return True
if isinstance(wert, list):
for eintrag in wert:
if eintrag:
return False
return True
return False
def overrides_anwenden():
"""Setzt für JEDES bekannte Feld den wirksamen Wert auf dem laufenden
einstellungen-Modul - den Override, wenn einer hinterlegt ist, sonst den
eingebauten Standardwert.
Wichtig ist das "jedes": Eine frühere Fassung übersprang leere Werte und
setzte nur die belegten. Weil setattr() das Modul-Attribut aber dauerhaft
verändert, blieb ein einmal gesetzter Wert danach für immer stehen -
"Zurücksetzen" im Setup-Menü hatte bei allen Feldern mit leerem
Standardwert (15 von 17) schlicht keine Wirkung, und die Oberfläche zeigte
beim nächsten Zeichnen wieder den alten Wert, als sei das Speichern
fehlgeschlagen. Umgekehrt wurde eine Liste aus leeren Einträgen gesetzt
statt übersprungen, was den Sicherheitscheck mit zwölf "unbekannt"-Zeilen
füllte und "Sicher abgestellt" dauerhaft grau werden ließ.
Jedes Feld bei jedem Durchlauf zu schreiben ist damit nicht nur korrekt,
sondern auch idempotent: der Zustand hängt allein an entitaeten.json,
nicht an der Reihenfolge vorheriger Aufrufe."""
overrides = overrides_lesen()
for key in _SCHLUESSEL:
wert = overrides.get(key)
setattr(einstellungen, key, _STANDARDWERTE[key] if _ist_leer(wert) else wert)
def aktueller_stand():
"""Katalog + aktuell wirksamer Wert je Feld (aus dem einstellungen-Modul
selbst gelesen, nicht direkt aus der JSON-Datei) - zeigt damit sowohl
bereits angewendete Overrides als auch die eingebauten Standardwerte
einheitlich. Grundlage für pyscript.audi_dashboard_entitaeten."""
werte = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER}
return {"katalog": FELDER, "werte": werte, "standardwerte": _STANDARDWERTE}
@@ -0,0 +1,196 @@
"""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)
@@ -0,0 +1,198 @@
"""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 entitaeten
import profil
def profil_veroeffentlichen():
daten = profil.profil_lesen()
if daten is None:
return
state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": daten})
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 entitaeten_veroeffentlichen():
"""Katalog + aktuell wirksame Zuordnung fürs Setup-Menü (Einstellungen ->
Fahrzeug einrichten -> Setup) - siehe entitaeten.py."""
state.set("pyscript.audi_dashboard_entitaeten", "aktuell", new_attributes={"daten": entitaeten.aktueller_stand()})
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)
def _standort():
"""Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel) - liest
STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR, zwei eigene sensor-Entities für
Breiten-/Längengrad (flespi liefert Koordinaten so, nicht als Attribute
einer einzelnen device_tracker-Entity). Fehlt eine der beiden Entity-IDs
oder ist der Zustand (noch) nicht verfügbar, liefert diese Funktion
durchgehend None statt eines geratenen Werts - genau das
zustand_oder_none()-Muster von oben, hier als eigene Funktion, weil
zusätzlich zum Zustand noch der Aktualisierungszeitpunkt gebraucht wird:
state.get() liefert dafür ein StateVal (str-Unterklasse) mit
.last_updated als datetime-Attribut - kein eigenständiges
state.last_updated(), das gibt es in pyscript nicht."""
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
lat_id, lon_id = einstellungen.STANDORT_LAT_SENSOR, einstellungen.STANDORT_LON_SENSOR
if not lat_id or not lon_id:
return leer
try:
lat_wert = state.get(lat_id)
lon_wert = state.get(lon_id)
except NameError:
return leer
lat, lon = _zu_zahl(lat_wert), _zu_zahl(lon_wert)
if lat is None or lon is None:
return leer
zeit = None
try:
zeit = lat_wert.last_updated.isoformat()
except AttributeError:
pass
return {"lat": lat, "lon": lon, "genauigkeit_m": None, "zeit": zeit}
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 label, sensor in [
("Heckklappe", einstellungen.HECKKLAPPE_SENSOR),
("Heckklappenschloss", einstellungen.HECKKLAPPENSCHLOSS_SENSOR),
("Motorhaube", einstellungen.HAUBE_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])
standort = _standort()
daten = {
"km": _zu_zahl(km),
"tankprozent": _zu_zahl(tankprozent),
"reichweite_km": _zu_zahl(reichweite),
"batteriespannung": _zu_zahl(batteriespannung),
"gesichert": gesichert,
"sicherheitscheck": sicherheitscheck,
"standort_lat": standort["lat"],
"standort_lon": standort["lon"],
"standort_genauigkeit_m": standort["genauigkeit_m"],
"standort_zeit": standort["zeit"],
# 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()
entitaeten_veroeffentlichen()
@@ -0,0 +1,280 @@
"""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():
"""Liest das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist.
Ohne diese Prüfung reißt eine fehlende Datei (Installation unvollständig,
siehe INSTALL.md Schritt 2) jeden Trigger und jeden Service mit, der das
Profil braucht — bei laufenden Zeittriggern also im Minutentakt. Jeder
Aufrufer muss den None-Fall abfangen."""
if not os.path.exists(PROFIL_PFAD):
log.error(
f"audi_dashboard: {PROFIL_PFAD} fehlt. Siehe INSTALL.md Schritt 2 — "
"bis dahin bleiben alle Funktionen aus, die das Profil brauchen."
)
return None
f = task.executor(io.open, PROFIL_PFAD, "r")
inhalt = f.read()
f.close()
try:
return json.loads(inhalt)
except ValueError as fehler:
log.error(
f"audi_dashboard: {PROFIL_PFAD} ist kein gültiges JSON ({fehler}). "
"Letztes Backup aus audi_dashboard/backups/ zurückspielen."
)
return None
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]}"
@@ -0,0 +1,107 @@
"""Reifenzähler ohne utility_meter (§7.5, §10 Punkt 6).
ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem
ursprünglichen Bau nachträglich verlangt): früher war der Zähler = aktueller
Kilometerstand - fester Startwert je Satz (reifen.saetze.<satz>.
start_odo_km). Das war falsch, sobald einmal zwischen Sommer und Winter
umgeschaltet wurde: die Differenz zum Startwert zählt dann alle seither
gefahrenen Kilometer mit, auch die auf dem jeweils anderen Satz. Jetzt gilt
stattdessen: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum
Zeitpunkt der Fahrt aktiv war. Dafür führt reifen.saetze.<satz>.km selbst den
fortlaufenden Gesamtstand (statt eines Startwerts), und reifen.
referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest, gegen den
die nächste Änderung als Delta gemessen wird. start_odo_km/§13 entfällt
damit vollständig.
Ergebnis wird zusätzlich als eigene pyscript-Zustandsvariable bereitgestellt
(nur zur Kontrolle in Entwicklerwerkzeuge → Zustände, das Frontend liest die
Werte direkt aus dem veröffentlichten Profil):
pyscript.reifen_sommer_km, pyscript.reifen_winter_km,
pyscript.reifen_aktiver_satz
Kein Abhängigkeit von total_increasing oder utility_meter-internem Zustand -
die Fortschreibung braucht nur den zuletzt bekannten Absolutwert des
Kilometerstand-Sensors. Ein zeitweise `unavailable` Quellsensor (§4.1)
verliert dadurch nichts: referenz_odo_km bleibt einfach stehen, bis wieder
ein gültiger Wert kommt, und das nächste Delta wird dann gegen diesen alten
Stand berechnet - auch km, die während eines HA-Neustarts/Ausfalls gefahren
wurden, werden so noch dem damals aktiven Satz zugerechnet.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import einstellungen
import frontend_veroeffentlichung
import profil
def _aktueller_km():
try:
return float(frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR))
except (TypeError, ValueError):
return None
def _zustaende_veroeffentlichen(p):
reifen = p.get("reifen", {})
saetze = reifen.get("saetze", {})
for satz_name in ("sommer", "winter"):
km = saetze.get(satz_name, {}).get("km")
if km is None:
state.set(f"pyscript.reifen_{satz_name}_km", "unbekannt")
else:
state.set(f"pyscript.reifen_{satz_name}_km", km, new_attributes={"unit_of_measurement": "km"})
state.set("pyscript.reifen_aktiver_satz", reifen.get("aktiv", "unbekannt"))
def _fortschreiben_und_veroeffentlichen(neuer_km, neuer_aktiv=None):
"""Rechnet zuerst mit dem bisher aktiven Satz ab (Delta seit
referenz_odo_km), trägt danach optional einen Satzwechsel ein - in dieser
Reihenfolge, damit beim Wechseln selbst keine km dem neuen Satz
zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden."""
p = profil.profil_lesen()
if p is None:
return
reifen = p.setdefault("reifen", {})
saetze = reifen.setdefault("saetze", {})
aktiv = reifen.get("aktiv", "sommer")
referenz = reifen.get("referenz_odo_km")
if referenz is not None and neuer_km is not None:
delta = round(neuer_km - referenz, 1)
if delta > 0:
satz = saetze.setdefault(aktiv, {})
satz["km"] = round((satz.get("km") or 0) + delta, 1)
if neuer_km is not None:
reifen["referenz_odo_km"] = neuer_km
if neuer_aktiv is not None:
reifen["aktiv"] = neuer_aktiv
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
_zustaende_veroeffentlichen(p)
@time_trigger("startup")
def beim_start():
_fortschreiben_und_veroeffentlichen(_aktueller_km())
# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.KM_SENSOR:
@state_trigger(f"{einstellungen.KM_SENSOR}")
def kilometerstand_geaendert(value=None):
_fortschreiben_und_veroeffentlichen(_aktueller_km())
@service
def audi_dashboard_reifen_wechseln(satz=None):
"""Umschalten des aktiven Reifensatzes (§5.2). Aufruf als
pyscript.audi_dashboard_reifen_wechseln mit satz="sommer"|"winter"."""
if satz not in ("sommer", "winter"):
log.warning(f"audi_dashboard: ungültiger Reifensatz '{satz}'")
return
_fortschreiben_und_veroeffentlichen(_aktueller_km(), neuer_aktiv=satz)
log.info(f"audi_dashboard: aktiver Reifensatz auf '{satz}' umgeschaltet")
@@ -0,0 +1,123 @@
"""Automatische Tankerkennung über den Füllstandssensor (Erweiterung zu §7.7/§5.5,
nach dem ursprünglichen Bau nachträglich verlangt).
Beobachtung des Nutzers: der Füllstand (TANK_SENSOR, in Prozent) springt beim
Fahren nie nach oben, er sinkt nur - jeder Anstieg ist also ein Tankvorgang.
Schwelle nach Nutzervorgabe: ab +5 Liter ODER +9 Prozentpunkten Anstieg gilt
als nachgetankt. Die 5-Liter-Vorgabe wird über fahrzeug.tankvolumen_liter aus
dem Fahrzeugprofil in Prozentpunkte umgerechnet, damit beide Angaben auf
derselben Einheit (Prozent) verglichen werden können; es gilt jeweils die
empfindlichere (kleinere) der beiden Schwellen.
Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich: die
Integration liefert einen Tankvorgang oft in mehreren kleinen Schritten
(z. B. 40% -> 45% -> 60%), von denen keiner allein die Schwelle
überschreiten muss. Deshalb wird der zuletzt bekannte Tiefststand
gespeichert und der Anstieg dagegen gemessen, nicht gegen den unmittelbar
vorherigen Wert. Nach Anlage eines Tankvorgangs wird der Tiefststand auf den
aktuellen Wert zurückgesetzt, damit derselbe Vorgang nicht mehrfach neue
Datensätze erzeugt, wenn der Sensor währenddessen weiter ansteigt.
Der so angelegte Tankvorgang ist bewusst ein unvollständiger Platzhalter
(status "unvollständig", ohne Liter/Kosten/Station) - siehe §10 Punkt 5 der
letzten Rückmeldung: die Erwartung ist ein automatisch erkannter Tankvorgang
mit Zeitstempel, Kilometerstand und gefahrener Distanz, den der Nutzer später
per Beleg-Nachtrag (audi_dashboard_beleg_hochladen mit tank_id, siehe
belegverarbeitung.py) vervollständigt.
EINSCHRÄNKUNG: der Tiefststand (_tiefststand_pct) lebt nur im Arbeitsspeicher
dieses Skripts, analog zur bekannten Einschränkung in fahrterkennung.py
(_fahrt_start_ts). Ein HA-Neustart während oder kurz vor einem Tankvorgang
verliert den bisherigen Tiefststand - der nächste Anstieg wird dann gegen den
ersten nach dem Neustart gemeldeten Wert gemessen. Bewusst nicht gehärtet,
aus demselben Grund wie dort.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import datetime
import einstellungen
import frontend_veroeffentlichung
import profil
LITER_SCHWELLE = 5
PROZENT_SCHWELLE = 9
STANDARD_TANKVOLUMEN_LITER = 58 # Fallback, falls im Profil nicht hinterlegt
_tiefststand_pct = None
def _als_zahl(wert):
try:
return float(wert)
except (TypeError, ValueError):
return None
def _schwelle_prozent():
p = profil.profil_lesen()
if p is None:
tankvolumen = STANDARD_TANKVOLUMEN_LITER
else:
tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
liter_als_prozent = (LITER_SCHWELLE / tankvolumen) * 100
return min(liter_als_prozent, PROZENT_SCHWELLE)
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
# Ohne konfigurierten Tankfüllstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.TANK_SENSOR:
@state_trigger(f"{einstellungen.TANK_SENSOR}")
def tankfuellstand_geaendert(value=None, old_value=None):
global _tiefststand_pct
aktuell = _als_zahl(value)
if aktuell is None:
return
if _tiefststand_pct is None:
_tiefststand_pct = aktuell
return
if aktuell <= _tiefststand_pct:
_tiefststand_pct = aktuell
return
anstieg = aktuell - _tiefststand_pct
if anstieg >= _schwelle_prozent():
_tankvorgang_automatisch_anlegen(anstieg)
_tiefststand_pct = aktuell
def _tankvorgang_automatisch_anlegen(anstieg_pct):
odometer_km = frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR)
tankvorgang = {
"tank_id": profil.neue_id("f"),
"receipt_key": None,
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"liters": None,
"fuel_total_eur": None,
"price_per_l": None,
"discount": None,
"station_name": None,
"odometer_km": odometer_km,
"distance_km": profil.distanz_seit_letzter_tankung(odometer_km),
"fuel_type": None,
"source": "auto",
"status": "unvollständig",
"receipt_file": None,
"edited_fields": [],
}
profil.tankvorgang_anhaengen(tankvorgang)
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(
f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} automatisch erkannt "
f"(Füllstandsanstieg {round(anstieg_pct, 1)} Prozentpunkte)"
)
@@ -0,0 +1,184 @@
"""Update-Funktion für die Oberfläche ("Update suchen" in Einstellungen).
Voraussetzung: einstellungen.UPDATE_REPO_URL zeigt auf ein Git-Repository, in
das dieses Projekt (pyscript/ + www/, wie mit update.ps1 auf die eigene
Testinstanz gespielt) gepflegt/gepusht wird - z. B. ein privates GitHub-Repo,
genau wie es für die HACS-Integration bereits genutzt wird. Ohne eingetragene
URL meldet "Update suchen" nur "keine Update-Quelle eingerichtet" (§9-
Fallback), es passiert nichts von selbst.
Kopiert wie update.ps1 bewusst NUR Code:
- pyscript/ komplett (kein Nutzerdaten-Ordner dort)
- www/*.js, www/*.css, www/badges/*
Bewusst NICHT angefasst: www/bilder/ (vom Nutzer hochgeladene Fahrzeugfotos,
siehe bilderverwaltung.py) und alles unter audi_dashboard/ (Fahrzeugprofil,
Fahrten- und Tankvorgänge-Archive) - ein Update darf niemals echte
Fahrzeugdaten oder eigene Fotos überschreiben.
Vor jeder Installation wird der bisherige Code-Stand unter
audi_dashboard/code_backups/<Zeitstempel>/ gesichert (gleiches Muster wie
backup.py für die Nutzerdaten) - kein automatisches Rollback in der
Oberfläche, aber ein manueller Weg zurück, falls ein Update Probleme macht.
git ist im offiziellen Home-Assistant-Container vorhanden (an der
Testinstanz geprüft) und subprocess ist über allow_all_imports bereits
freigegeben (siehe configuration_snippet.yaml), deshalb hier statt eines
selbstgebauten ZIP-Downloads einfach ein flacher Git-Checkout in einen
eigenen Zwischenordner, getrennt von /config/audi_dashboard (Nutzerdaten)
und /config/pyscript, /config/www (Live-Code).
"""
import datetime
import io
import json
import os
import shutil
import subprocess
import einstellungen
import profil
QUELLE_ORDNER = "/config/.audi_dashboard_update_quelle"
BACKUP_ORDNER = f"{profil.BASIS}/code_backups"
LIVE_PYSCRIPT = "/config/pyscript"
LIVE_WWW = "/config/www"
CODE_DATEIEN_WWW = ["audi-dashboard-app.js", "audi-dashboard-panel.js", "audi-dashboard.css"]
def _status_veroeffentlichen(daten):
state.set("pyscript.audi_dashboard_update_status", "aktuell", new_attributes={"daten": daten})
def _git(*args, cwd=None):
# subprocess.run() direkt, ohne task.executor - genau wie der bestehende
# PDF-Parser-Aufruf in belegverarbeitung.py (_parser_aufrufen), an der
# Testinstanz bereits bestätigt funktionsfähig.
return subprocess.run(
["git", *args],
cwd=cwd, capture_output=True, text=True, timeout=60,
)
def _lokale_version():
pfad = f"{LIVE_WWW}/audi-dashboard-version.json"
if not os.path.exists(pfad):
return None
try:
f = task.executor(io.open, pfad, "r")
inhalt = f.read()
f.close()
return json.loads(inhalt).get("version")
except (ValueError, OSError):
return None
def _quelle_aktualisieren():
"""Klont bzw. holt den konfigurierten Branch flach in QUELLE_ORDNER.
Gibt (erfolgreich, fehlertext) zurück."""
zweig = einstellungen.UPDATE_BRANCH or "main"
if not os.path.exists(f"{QUELLE_ORDNER}/.git"):
os.makedirs(os.path.dirname(QUELLE_ORDNER), exist_ok=True)
if os.path.exists(QUELLE_ORDNER):
task.executor(shutil.rmtree, QUELLE_ORDNER)
r = _git("clone", "--depth", "1", "--branch", zweig, einstellungen.UPDATE_REPO_URL, QUELLE_ORDNER)
if r.returncode != 0:
return False, r.stderr.strip() or "git clone fehlgeschlagen"
return True, None
r = _git("fetch", "--depth", "1", "origin", zweig, cwd=QUELLE_ORDNER)
if r.returncode != 0:
return False, r.stderr.strip() or "git fetch fehlgeschlagen"
r = _git("reset", "--hard", f"origin/{zweig}", cwd=QUELLE_ORDNER)
if r.returncode != 0:
return False, r.stderr.strip() or "git reset fehlgeschlagen"
return True, None
@service
def audi_dashboard_update_pruefen():
"""Prüft, ob unter einstellungen.UPDATE_REPO_URL eine neuere Version
vorliegt, ohne etwas zu installieren. Aufruf als
pyscript.audi_dashboard_update_pruefen."""
jetzt = datetime.datetime.now().isoformat()
if not einstellungen.UPDATE_REPO_URL:
_status_veroeffentlichen({"stand": "keine_quelle", "geprueft_um": jetzt})
return
ok, fehler = _quelle_aktualisieren()
if not ok:
_status_veroeffentlichen({
"stand": "fehler", "fehler": fehler, "geprueft_um": jetzt,
"aktuelle_version": _lokale_version(),
})
log.warning(f"audi_dashboard: Update-Prüfung fehlgeschlagen: {fehler}")
return
quell_version_pfad = f"{QUELLE_ORDNER}/www/audi-dashboard-version.json"
neue_version = None
if os.path.exists(quell_version_pfad):
f = task.executor(io.open, quell_version_pfad, "r")
inhalt = f.read()
f.close()
try:
neue_version = json.loads(inhalt).get("version")
except ValueError:
neue_version = None
aktuelle_version = _lokale_version()
verfuegbar = neue_version is not None and aktuelle_version is not None and neue_version > aktuelle_version
_status_veroeffentlichen({
"stand": "geprueft", "verfuegbar": verfuegbar,
"aktuelle_version": aktuelle_version, "neue_version": neue_version,
"geprueft_um": jetzt,
})
log.info(f"audi_dashboard: Update-Prüfung ok - aktuell={aktuelle_version}, verfügbar={neue_version}")
@service
def audi_dashboard_update_installieren():
"""Installiert den zuletzt per audi_dashboard_update_pruefen geholten
Stand aus QUELLE_ORDNER - sichert vorher den bisherigen Code. Aufruf als
pyscript.audi_dashboard_update_installieren."""
if not os.path.exists(f"{QUELLE_ORDNER}/.git"):
log.warning("audi_dashboard: Update-Installation ohne vorherige Prüfung abgelehnt")
return
zeitstempel = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
ziel_backup = f"{BACKUP_ORDNER}/{zeitstempel}"
os.makedirs(ziel_backup, exist_ok=True)
if os.path.exists(LIVE_PYSCRIPT):
task.executor(shutil.copytree, LIVE_PYSCRIPT, f"{ziel_backup}/pyscript")
for name in CODE_DATEIEN_WWW:
quelle = f"{LIVE_WWW}/{name}"
if os.path.exists(quelle):
task.executor(shutil.copy, quelle, f"{ziel_backup}/{name}")
quelle_pyscript = f"{QUELLE_ORDNER}/pyscript"
if os.path.exists(quelle_pyscript):
if os.path.exists(LIVE_PYSCRIPT):
task.executor(shutil.rmtree, LIVE_PYSCRIPT)
task.executor(shutil.copytree, quelle_pyscript, LIVE_PYSCRIPT)
for name in CODE_DATEIEN_WWW:
quelle = f"{QUELLE_ORDNER}/www/{name}"
if os.path.exists(quelle):
task.executor(shutil.copy, quelle, f"{LIVE_WWW}/{name}")
quelle_badges = f"{QUELLE_ORDNER}/www/badges"
if os.path.exists(quelle_badges):
ziel_badges = f"{LIVE_WWW}/badges"
if os.path.exists(ziel_badges):
task.executor(shutil.rmtree, ziel_badges)
task.executor(shutil.copytree, quelle_badges, ziel_badges)
# Version neu schreiben (aktuelle Uhrzeit als Cache-Buster, wie
# update.ps1) statt den im Quell-Repo eingecheckten Wert blind zu
# übernehmen - erzwingt bei jedem Browser einen echten Neuabruf von
# audi-dashboard-app.js/.css (siehe audi-dashboard-panel.js).
version = int(datetime.datetime.now().timestamp())
f = task.executor(io.open, f"{LIVE_WWW}/audi-dashboard-version.json", "w")
f.write(json.dumps({"version": version}))
f.close()
_status_veroeffentlichen({
"stand": "installiert", "verfuegbar": False,
"aktuelle_version": version, "neue_version": None,
"geprueft_um": datetime.datetime.now().isoformat(),
})
log.info(f"audi_dashboard: Update installiert (Backup unter {ziel_backup}), neue Version {version}")