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:
@@ -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}")
|
||||
Reference in New Issue
Block a user