pyscript-Backend zur echten HA-Integration umgebaut (HACS-fähig)

Das Backend liegt jetzt als custom_components/audi_dashboard/ vor - eine
normale Home-Assistant-Integration mit Config-Flow, einer sensor-Plattform
und 18 Diensten. Damit ist die App über HACS installierbar; bis das Repo auf
GitHub gespiegelt ist (HACS spricht ausschließlich mit GitHub), installiert
homeassistant/installationspaket/install.ps1 denselben Ordner ohne HACS.

Fünf Installationsschritte entfallen ersatzlos: der pyscript:-Block, der
panel_custom:-Block, das Kopieren der Oberfläche nach www/, das langlebige
Zugriffstoken (der Verlauf wird direkt über die recorder-API gelesen) und
"pip install pypdf" (steht in manifest.json). Das Fahrzeugprofil legt die
Integration beim ersten Start aus ihrer Vorlage an.

Drei alte Schwächen sind dabei mit erledigt:
- Die Nutzlast landet nicht mehr in der Recorder-Datenbank
  (_unrecorded_attributes - das kann nur eine echte Entität).
- Eine laufende Fahrt überlebt einen Neustart (Store statt Arbeitsspeicher);
  fiel sie während eines Ausfalls ins Ende, schließt
  nach_neustart_fortsetzen() sie beim letzten aufgezeichneten Zeitpunkt.
- Sensor-Zuordnungen wirken sofort - die Zustandsbeobachter werden neu
  gebunden, der Neustart-Hinweis und der Neustart-Dienst sind weg.

Namensvertrag geändert, beide Oberflächen mitgezogen:
pyscript.audi_dashboard_x -> sensor.audi_dashboard_x,
pyscript.audi_dashboard_y -> audi_dashboard.y. Eine Companion-App vom alten
Stand findet nach dem Umstieg nichts mehr und muss neu gebaut werden; das
Panel liegt in der Integration und kann nicht driften.

Der selbstgebaute Updater entfällt - HACS ist die Update-Mechanik, die Home
Assistant kennt. Die Versionierung schrumpft auf eine Quelle: manifest.json.

Geprüft am laufenden Testcontainer (Container byteweise identisch mit dem
Repo): alle 18 Dienste, Panel, Config-Entry neu laden, Historienimport,
echter Shell-Beleg in-process, Neuinstallation im Wegwerf-Container blank mit
automatisch nachinstalliertem pypdf. Companion-App: tsc sauber, 112/112
Tests, beide Rauchtests gegen das laufende Backend grün. Belegparser 8/8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 23:53:56 +02:00
parent 99cef7c393
commit d8b12da36d
136 changed files with 5289 additions and 17641 deletions
@@ -0,0 +1,136 @@
"""Audi Dashboard - Fahrtenbuch, Tankstatistik und Fahrzeugzustand als eigene
Home-Assistant-Integration.
Diese Datei ist der Lebenszyklus: einrichten, entladen, neu laden. Die Fach-
logik liegt in den Modulen daneben, der laufende Zustand im Koordinator.
WAS DIE INTEGRATION MITBRINGT, WAS FRÜHER VON HAND EINGERICHTET WERDEN MUSSTE
-----------------------------------------------------------------------------
Die pyscript-Fassung brauchte fünf getrennte Handgriffe in der Installation:
einen `pyscript:`-Block mit allow_all_imports und hass_is_global, einen
`panel_custom:`-Block, das Kopieren der Oberflächen-Dateien nach /config/www/,
ein langlebiges Zugriffstoken in einer Textdatei und ein `pip install pypdf`
im Container. Jeder davon konnte vergessen werden, und mehrere scheiterten
still.
Alle fünf sind hier weg:
- Das Panel meldet die Integration selbst an (kein YAML, kein Neustart).
- Die Oberflächen-Dateien liegen in frontend/ und werden von hier unter
einem eigenen URL-Pfad ausgeliefert (kein Kopieren nach www/).
- Den Verlauf liest die Integration direkt über die recorder-API (kein
Token, siehe verlauf.py).
- pypdf steht über manifest.json bereit (kein pip-Aufruf, siehe belege.py).
- Eingerichtet wird über die Oberfläche (config_flow), nicht über YAML.
Was NICHT hierher gehört und deshalb weiterhin von Hand in die
configuration.yaml kommt: der `recorder:`-Block mit der verlängerten
Aufbewahrung (recorder_snippet.yaml). Der betrifft die ganze Instanz und ihre
Datenbankgröße - das ist eine Entscheidung des Betreibers, keine, die eine
einzelne Integration für ihn treffen darf.
"""
from __future__ import annotations
import logging
import os
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.components import frontend, panel_custom
from homeassistant.loader import async_get_integration
from . import dienste
from .const import (
DOMAIN,
PANEL_ICON,
PANEL_KOMPONENTE,
PANEL_TITEL,
PANEL_URL,
STATIK_ORDNER,
STATIK_URL,
)
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
PLATTFORMEN = [Platform.SENSOR]
type AudiConfigEntry = ConfigEntry[Koordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AudiConfigEntry) -> bool:
integration = await async_get_integration(hass, DOMAIN)
version = str(integration.version)
koordinator = Koordinator(hass, entry, version)
entry.runtime_data = koordinator
await _statik_anmelden(hass)
await koordinator.starten()
await hass.config_entries.async_forward_entry_setups(entry, PLATTFORMEN)
await _panel_anmelden(hass, version)
dienste.registrieren(hass, koordinator)
# Ändert sich die Einrichtung, wird die Integration neu geladen statt
# halb angepasst - das ist die einzige Variante, bei der nichts von der
# alten Einrichtung übrig bleiben kann.
entry.async_on_unload(entry.add_update_listener(_neu_laden))
_LOGGER.info("Audi Dashboard %s eingerichtet", version)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AudiConfigEntry) -> bool:
geladen = await hass.config_entries.async_unload_platforms(entry, PLATTFORMEN)
if not geladen:
return False
dienste.abmelden(hass)
frontend.async_remove_panel(hass, PANEL_URL)
await entry.runtime_data.beenden()
return True
async def _neu_laden(hass: HomeAssistant, entry: AudiConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)
async def _statik_anmelden(hass: HomeAssistant) -> None:
"""Liefert frontend/ unter STATIK_URL aus.
Nur einmal je Home-Assistant-Lauf: aiohttp-Routen lassen sich nicht wieder
entfernen, ein zweiter Versuch nach einem Neu-Laden der Integration würde
scheitern. Das ist unkritisch - der Pfad zeigt auf einen Ordner, dessen
Inhalt sich beim Neu-Laden nicht ändert.
cache_headers=False ist Absicht: die Dateien werden von HACS ersetzt, und
ein Browser, der eine alte Fassung 31 Tage lang aus dem Cache bedient
(die HA-Vorgabe für statische Pfade), wäre genau die stille Veraltung, die
das Versions-Verfahren verhindern soll."""
if hass.data.get(DOMAIN, {}).get("statik"):
return
ordner = os.path.join(os.path.dirname(__file__), STATIK_ORDNER)
await hass.http.async_register_static_paths(
[StaticPathConfig(STATIK_URL, ordner, False)]
)
hass.data.setdefault(DOMAIN, {})["statik"] = True
async def _panel_anmelden(hass: HomeAssistant, version: str) -> None:
"""Meldet den Sidebar-Eintrag an.
Die Version hängt als Query-Parameter an der Modul-URL: sie ändert sich
genau dann, wenn eine neue Fassung installiert wurde, und ist damit der
passende Cache-Brecher für den Lade-Stub."""
await panel_custom.async_register_panel(
hass,
frontend_url_path=PANEL_URL,
webcomponent_name=PANEL_KOMPONENTE,
sidebar_title=PANEL_TITEL,
sidebar_icon=PANEL_ICON,
module_url=f"{STATIK_URL}/audi-dashboard-panel.js?v={version}",
embed_iframe=False,
require_admin=False,
config={"version": version, "statik": STATIK_URL},
)
+414
View File
@@ -0,0 +1,414 @@
"""Datenzugriff für Fahrzeugprofil, Fahrten, Tankvorgänge und Batterieverlauf.
Drei getrennte Bestände, wie in SPECIFICATION.md §6.1 festgelegt:
- Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische
- Fahrten: JSON Lines, eine Zeile je Fahrt
- Tankvorgänge: JSON Lines
- Batterieverlauf: JSON Lines, ein Eintrag je Tag
Dateiformat und Ordnername sind identisch mit der pyscript-Fassung
(/config/audi_dashboard/). Eine bestehende Installation läuft damit nach dem
Umstieg einfach weiter - es gibt keine Datenmigration, und deshalb auch
keinen Weg, bei der Migration etwas zu verlieren.
ZWEI DINGE, DIE HIER ANDERS SIND ALS IN DER PYSCRIPT-FASSUNG
------------------------------------------------------------
1. Ganz normales `open()`. Die dortige Verrenkung `task.executor(io.open, ...)`
war eine reine pyscript-Eigenheit (das eingebaute open() existiert dort
nicht). Hier läuft jede Dateioperation stattdessen über
`hass.async_add_executor_job` - ein Datei-Zugriff hat im Event-Loop nichts
verloren, und diese Klasse ist die einzige Stelle, die das kapselt: alle
öffentlichen Methoden sind `async` und schalten selbst in den Executor.
2. Eine Sperre um die verändernden Zugriffe. Fast jede Änderung ist ein
Lesen-Ändern-Schreiben über den kompletten Bestand (eine Fahrt ändern
heißt: alle Fahrten lesen, eine anfassen, alle zurückschreiben). Liefen
zwei davon verschränkt, gewänne die zuletzt schreibende und die andere
Änderung wäre spurlos weg. In der pyscript-Fassung war das eine offene
Flanke; hier kostet die Absicherung eine `asyncio.Lock`.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from collections.abc import Callable
from typing import Any
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
class Ablage:
"""Alle Dateizugriffe der App an einer Stelle."""
def __init__(self, hass: HomeAssistant, basis: str) -> None:
self._hass = hass
self.basis = basis
self.profil_pfad = os.path.join(basis, "fahrzeugprofil.json")
self.fahrten_pfad = os.path.join(basis, "fahrten.jsonl")
self.tankvorgaenge_pfad = os.path.join(basis, "tankvorgaenge.jsonl")
self.batterieverlauf_pfad = os.path.join(basis, "batteriespannung.jsonl")
self.zuordnung_pfad = os.path.join(basis, "entitaeten.json")
self.belege_ordner = os.path.join(basis, "belege")
self.backup_ordner = os.path.join(basis, "backups")
self._sperre = asyncio.Lock()
async def _im_executor(self, funktion: Callable, *args: Any) -> Any:
return await self._hass.async_add_executor_job(funktion, *args)
# ------------------------------------------------------------- Ordner
def _ordner_anlegen(self) -> None:
os.makedirs(self.basis, exist_ok=True)
os.makedirs(self.belege_ordner, exist_ok=True)
async def ordner_sicherstellen(self) -> None:
await self._im_executor(self._ordner_anlegen)
def _vorlage_anlegen(self, vorlage_pfad: str) -> bool:
if os.path.exists(self.profil_pfad):
return False
with open(vorlage_pfad, encoding="utf-8") as datei:
inhalt = datei.read()
self._text_schreiben(self.profil_pfad, inhalt)
return True
async def vorlage_anlegen(self, vorlage_pfad: str) -> bool:
"""Legt bei der allerersten Einrichtung ein Fahrzeugprofil aus der
mitgelieferten Vorlage an. True, wenn dabei wirklich etwas entstanden
ist.
Vorher war das ein eigener Installationsschritt ("die Datei aus data/
nach /config/audi_dashboard/ kopieren"). Wurde er vergessen, startete
die App in einen Zustand, den man von einem Fehler nicht unterscheiden
konnte: das Panel blieb leer, im Protokoll stand nur eine Zeile über
eine fehlende Datei.
Ein bereits vorhandenes Profil wird nie angefasst - deshalb die
Existenzprüfung im selben Executor-Aufruf wie das Schreiben, nicht
davor: sonst läge zwischen Prüfen und Schreiben ein Zeitfenster."""
return await self._im_executor(self._vorlage_anlegen, vorlage_pfad)
# ------------------------------------------------------ Rohe Dateiarbeit
@staticmethod
def _text_lesen(pfad: str) -> str | None:
if not os.path.exists(pfad):
return None
with open(pfad, encoding="utf-8") as datei:
return datei.read()
@staticmethod
def _text_schreiben(pfad: str, text: str) -> None:
"""Atomar: erst in eine .tmp-Datei, dann umbenennen.
os.replace ist auf allen unterstützten Systemen atomar. Damit kann ein
Absturz mitten im Schreiben keine halb geschriebene Datei hinterlassen
- der alte Stand bleibt vollständig, bis der neue vollständig da ist."""
os.makedirs(os.path.dirname(pfad), exist_ok=True)
tmp = pfad + ".tmp"
with open(tmp, "w", encoding="utf-8") as datei:
datei.write(text)
os.replace(tmp, pfad)
@staticmethod
def _zeilen_aus_text(text: str | None, pfad: str) -> list[dict]:
if not text:
return []
datensaetze: list[dict] = []
for nummer, zeile in enumerate(text.splitlines(), start=1):
zeile = zeile.strip()
if not zeile:
continue
try:
datensaetze.append(json.loads(zeile))
except ValueError as fehler:
# Eine kaputte Zeile darf nicht den ganzen Bestand mitreißen:
# der Rest ist gültig und die App bleibt benutzbar.
_LOGGER.error(
"%s Zeile %s ist kein gültiges JSON (%s) - Zeile übersprungen",
pfad, nummer, fehler,
)
return datensaetze
@staticmethod
def _text_aus_zeilen(datensaetze: list[dict]) -> str:
zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze]
return "\n".join(zeilen) + ("\n" if zeilen else "")
# ------------------------------------------------------- Fahrzeugprofil
def _profil_lesen(self) -> dict | None:
text = self._text_lesen(self.profil_pfad)
if text is None:
_LOGGER.error(
"%s fehlt - bis dahin bleiben alle Funktionen aus, die das Profil brauchen",
self.profil_pfad,
)
return None
try:
return json.loads(text)
except ValueError as fehler:
_LOGGER.error(
"%s ist kein gültiges JSON (%s). Letztes Backup aus %s zurückspielen.",
self.profil_pfad, fehler, self.backup_ordner,
)
return None
async def profil_lesen(self) -> dict | None:
"""Das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist.
Jeder Aufrufer muss den None-Fall abfangen: ohne diese Prüfung reißt
eine fehlende Datei jeden Zeittakt und jeden Dienst mit, der das
Profil braucht - bei laufenden Zeittriggern also im Minutentakt."""
return await self._im_executor(self._profil_lesen)
async def profil_schreiben(self, profil: dict) -> None:
async with self._sperre:
await self._im_executor(
self._text_schreiben,
self.profil_pfad,
json.dumps(profil, ensure_ascii=False, indent=2),
)
# -------------------------------------------------------------- Fahrten
async def fahrten_lesen(self) -> list[dict]:
text = await self._im_executor(self._text_lesen, self.fahrten_pfad)
return self._zeilen_aus_text(text, self.fahrten_pfad)
async def fahrten_schreiben(self, fahrten: list[dict]) -> None:
await self._im_executor(
self._text_schreiben, self.fahrten_pfad, self._text_aus_zeilen(fahrten)
)
async def fahrt_anhaengen(self, fahrt: dict) -> None:
async with self._sperre:
fahrten = await self.fahrten_lesen()
fahrten.append(fahrt)
await self.fahrten_schreiben(fahrten)
async def fahrten_ergaenzen(self, neue: list[dict]) -> None:
"""Mehrere Fahrten in einem Rutsch, nach Startzeit sortiert.
Für den Historienimport: bei einem Jahr Verlauf wären das sonst
hunderte einzelne Schreibvorgänge."""
if not neue:
return
async with self._sperre:
alle = await self.fahrten_lesen()
alle.extend(neue)
alle.sort(key=lambda f: f.get("ts_start") or "")
await self.fahrten_schreiben(alle)
@staticmethod
def _datensatz_aendern(
zeilen: list[dict], id_feld: str, id_wert: str, aenderungen: dict, schutz: bool
) -> bool:
"""Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID.
Manuell geänderte Felder (edited_fields) werden dabei nie
überschrieben. schutz=False hebt genau diese Sperre auf - nötig für
Eingaben aus der Oberfläche: edited_fields schützt gegen die
automatische Ergänzung, nicht gegen den Menschen, der das Feld gerade
selbst korrigiert."""
for d in zeilen:
if d.get(id_feld) != id_wert:
continue
geschuetzt = set(d.get("edited_fields", [])) if schutz else set()
for feld, wert in aenderungen.items():
if feld not in geschuetzt:
d[feld] = wert
return True
return False
async def fahrt_aktualisieren(self, trip_id: str, aenderungen: dict) -> bool:
async with self._sperre:
fahrten = await self.fahrten_lesen()
if not self._datensatz_aendern(fahrten, "trip_id", trip_id, aenderungen, True):
return False
await self.fahrten_schreiben(fahrten)
return True
async def fahrt_bearbeiten(self, trip_id: str, aenderungen: dict) -> bool:
"""Wie fahrt_aktualisieren(), aber für Eingaben aus der Oberfläche:
eine von Hand gesetzte Angabe sticht auch dann, wenn dasselbe Feld
schon einmal von Hand gesetzt wurde."""
async with self._sperre:
fahrten = await self.fahrten_lesen()
if not self._datensatz_aendern(fahrten, "trip_id", trip_id, aenderungen, False):
return False
await self.fahrten_schreiben(fahrten)
return True
async def fahrt_loeschen(self, trip_id: str) -> bool:
async with self._sperre:
fahrten = await self.fahrten_lesen()
uebrig = [f for f in fahrten if f.get("trip_id") != trip_id]
if len(uebrig) == len(fahrten):
return False
await self.fahrten_schreiben(uebrig)
return True
# --------------------------------------------------------- Tankvorgänge
async def tankvorgaenge_lesen(self) -> list[dict]:
text = await self._im_executor(self._text_lesen, self.tankvorgaenge_pfad)
return self._zeilen_aus_text(text, self.tankvorgaenge_pfad)
async def tankvorgaenge_schreiben(self, tankvorgaenge: list[dict]) -> None:
await self._im_executor(
self._text_schreiben,
self.tankvorgaenge_pfad,
self._text_aus_zeilen(tankvorgaenge),
)
async def tankvorgang_anhaengen(self, tankvorgang: dict) -> None:
async with self._sperre:
alle = await self.tankvorgaenge_lesen()
alle.append(tankvorgang)
await self.tankvorgaenge_schreiben(alle)
async def tankvorgaenge_ergaenzen(self, neue: list[dict]) -> None:
if not neue:
return
async with self._sperre:
alle = await self.tankvorgaenge_lesen()
alle.extend(neue)
alle.sort(key=lambda t: t.get("ts") or "")
await self.tankvorgaenge_schreiben(alle)
async def tankvorgang_aktualisieren(self, tank_id: str, aenderungen: dict) -> bool:
async with self._sperre:
alle = await self.tankvorgaenge_lesen()
if not self._datensatz_aendern(alle, "tank_id", tank_id, aenderungen, True):
return False
await self.tankvorgaenge_schreiben(alle)
return True
async def tankvorgang_loeschen(self, tank_id: str) -> bool:
async with self._sperre:
alle = await self.tankvorgaenge_lesen()
uebrig = [t for t in alle if t.get("tank_id") != tank_id]
if len(uebrig) == len(alle):
return False
await self.tankvorgaenge_schreiben(uebrig)
return True
async def tankvorgang_nach_id(self, tank_id: str) -> dict | None:
for t in await self.tankvorgaenge_lesen():
if t.get("tank_id") == tank_id:
return t
return None
async def tankvorgang_nach_receipt_key(self, receipt_key: str) -> dict | None:
"""Derselbe Beleg (receipt_key, minutengenau) darf keinen zweiten
Datensatz erzeugen (§7.7 Regel 3)."""
for t in await self.tankvorgaenge_lesen():
if t.get("receipt_key") == receipt_key:
return t
return None
async def distanz_seit_letzter_tankung(self, aktueller_km: float | None) -> float | None:
"""Gefahrene Distanz seit dem vorherigen Tankvorgang, als Vorschlag
für das gleichnamige Formularfeld - frei überschreibbar, genau wie
odometer_km selbst. None, wenn kein Kilometerstand oder kein
vorheriger Tankvorgang vorliegt (erster Eintrag überhaupt)."""
if aktueller_km is None:
return None
alle = await self.tankvorgaenge_lesen()
if not alle:
return None
letzter = max(alle, key=lambda t: t.get("ts") or "")
if letzter.get("odometer_km") is None:
return None
return round(aktueller_km - letzter["odometer_km"], 1)
# ------------------------------------------------------ Batteriespannung
async def batterieverlauf_lesen(self) -> list[dict]:
"""Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}), älteste
zuerst. min_ts/max_ts sind die Zeitstempel (ISO, UTC) der jeweiligen
Einzelmessung, für die Datum/Uhrzeit-Anzeige beim Antippen des
Diagrammpunkts - der Punkt selbst zeigt nur den Minimalwert."""
text = await self._im_executor(self._text_lesen, self.batterieverlauf_pfad)
return self._zeilen_aus_text(text, self.batterieverlauf_pfad)
async def batterieverlauf_tageswert_aktualisieren(
self, datum: str, ts: str, spannung: float
) -> None:
"""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."""
async with self._sperre:
verlauf = await self.batterieverlauf_lesen()
for eintrag in verlauf:
if eintrag.get("datum") != datum:
continue
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}
)
# Nach Datum sortiert schreiben, nicht in Einfügereihenfolge.
# Solange nur die Live-Aufzeichnung schrieb, war beides dasselbe
# (sie trägt immer den heutigen Tag ein). Der nachträgliche Import
# trägt dagegen vergangene Tage ein - ohne diese Zeile stünden sie
# hinter den neueren, und das Diagramm im Frontend, das die Datei
# in Dateireihenfolge zeichnet, liefe zeitlich rückwärts.
verlauf.sort(key=lambda e: e.get("datum") or "")
await self._im_executor(
self._text_schreiben,
self.batterieverlauf_pfad,
self._text_aus_zeilen(verlauf),
)
# ---------------------------------------------------- Sensor-Zuordnung
async def zuordnung_lesen(self) -> dict:
"""Die im Setup-Menü gespeicherten Zuordnungen, oder {} wenn die
Datei fehlt bzw. beschädigt ist.
Der Fehlerfall ist nicht Vorsicht um ihrer selbst willen: das Anwenden
läuft beim Start VOR der ersten Veröffentlichung. Ohne die Absicherung
reißt eine einzige unlesbare Zeile den gesamten Startvorgang mit - das
Panel bliebe komplett leer, ohne dass irgendetwas auf die Ursache
hindeutet."""
text = await self._im_executor(self._text_lesen, self.zuordnung_pfad)
if not text or not text.strip():
return {}
try:
gelesen = json.loads(text)
except ValueError as fehler:
_LOGGER.error(
"%s ist kein gültiges JSON (%s). Die eingebauten Standardwerte gelten "
"weiter; die Zuordnung lässt sich im Setup-Menü neu speichern.",
self.zuordnung_pfad, fehler,
)
return {}
if not isinstance(gelesen, dict):
_LOGGER.error("%s enthält kein Objekt - wird ignoriert.", self.zuordnung_pfad)
return {}
return gelesen
async def zuordnung_schreiben(self, mapping: dict) -> None:
await self._im_executor(
self._text_schreiben,
self.zuordnung_pfad,
json.dumps(mapping, ensure_ascii=False, indent=2),
)
def neue_id(praefix: str) -> str:
return f"{praefix}-{uuid.uuid4().hex[:12]}"
@@ -0,0 +1,64 @@
"""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 dafür und hält
die Datei auch über viele Jahre klein, selbst wenn der Sensor mehrfach pro
Stunde meldet (aktive Fahrt: Motor an/aus, Ladezustand des Alternators
wechselt laufend). Die Prüfung läuft deshalb alle 5 Minuten und trägt jede
Messung in den Tageseintrag ein; die Ablage 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 (AGM_RUHE_MAX_V) den
Tagesminimalwert, nicht den Maximalwert.
Es wird bewusst NICHT gefiltert, welche Messwerte gespeichert werden - min UND
max jedes Tages werden roh aufgezeichnet. Die AGM-Ruhespannungs-Grenze wird
erst beim Anzeigen angewendet: 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 Statistik auf.
Ohne zugeordneten BATTERIE_SENSOR passiert hier bewusst nichts, statt
Platzhalter-Nullwerte aufzuzeichnen.
"""
from __future__ import annotations
import datetime
import logging
from typing import TYPE_CHECKING
from .veroeffentlichung import zustand_oder_none
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
async def pruefen(k: Koordinator) -> None:
sensor = k.zuordnung.werte.BATTERIE_SENSOR
if not sensor:
return
wert = zustand_oder_none(k.hass, sensor)
if wert is None:
return
try:
spannung = float(wert)
except (TypeError, ValueError):
return
jetzt = datetime.datetime.now(datetime.UTC)
heute = datetime.date.today().isoformat()
await k.ablage.batterieverlauf_tageswert_aktualisieren(
heute, jetzt.isoformat(), spannung
)
await k.batterieverlauf_veroeffentlichen()
_LOGGER.debug("Batteriespannung erfasst (%s: %s V)", heute, spannung)
+275
View File
@@ -0,0 +1,275 @@
"""Belegverarbeitung für Tankbelege (SPECIFICATION.md §7.7).
Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff.
Das eigentliche Lesen der PDF macht shell_beleg_parser.py daneben; hier steht,
was mit dem Ergebnis passiert.
ZWEI REGELN, DIE DEN AUFBAU ERKLÄREN
------------------------------------
1. Liter, Kosten und Ersparnis kommen vom Beleg, Kilometerstand und gefahrene
Distanz kommen immer vom Fahrzeug (§7.7 Regel 2). Ein Beleg-Nachtrag rührt
odometer_km/distance_km deshalb nicht an.
2. price_per_l wird grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie
vom Beleg übernommen. fuel_total_eur ist dabei immer der reale, bereits
rabattierte Betrag; die SmartDeal-Ersparnis steht separat in discount.
Mit tank_id trägt der Upload die Belegdaten in einen bestehenden Tankvorgang
nach - egal ob der ursprünglich manuell oder automatisch angelegt wurde. Passt
das Belegdatum nicht zum erfassten Tankvorgang (anderer Kalendertag), wird
nichts übernommen, sondern ein Fehler veröffentlicht: sonst landet in der
Oberfläche versehentlich ein falscher Beleg an einem bestehenden Eintrag.
Ohne tank_id legt der Upload KEINEN eigenständigen Tankvorgang an, sondern
parst nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte
in den gerade offenen Entwurf und speichert erst auf einen expliziten Klick.
"""
from __future__ import annotations
import base64
import datetime
import logging
import os
from typing import TYPE_CHECKING
from . import shell_beleg_parser
from .tankerkennung import leerer_tankvorgang
from .veroeffentlichung import zustand_oder_none
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
def preis_je_liter(fuel_total_eur: float | None, liters: float | None) -> float | None:
if not liters or fuel_total_eur is None:
return None
return round(fuel_total_eur / liters, 3)
def _speichern_und_lesen(ordner: str, dateiname: str, rohdaten: bytes) -> tuple[str, dict]:
"""Läuft komplett im Executor: Datei schreiben und PDF lesen sind beides
blockierende Arbeit."""
os.makedirs(ordner, exist_ok=True)
pfad = os.path.join(ordner, dateiname)
with open(pfad, "wb") as datei:
datei.write(rohdaten)
return pfad, shell_beleg_parser.beleg_lesen(pfad)
async def hochladen(k: Koordinator, daten: dict) -> None:
pdf_base64 = daten.get("pdf_base64")
tank_id = daten.get("tank_id")
if not pdf_base64:
_LOGGER.warning("Beleg-Upload ohne pdf_base64 aufgerufen")
return
dateiname = daten.get("dateiname") or (
f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
)
# Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte
# Ausnahme hier (kaputtes Base64, volle Festplatte, unlesbares PDF) wäre
# für die Oberfläche unsichtbar - ein abgelehnter Dienstaufruf landet dort
# nur in der Browser-Konsole, angezeigt wird nichts.
try:
rohdaten = base64.b64decode(pdf_base64)
pdf_pfad, geparst = await k.hass.async_add_executor_job(
_speichern_und_lesen, k.ablage.belege_ordner, dateiname, rohdaten
)
except Exception as fehler: # noqa: BLE001 - jede Ursache endet hier gleich
_LOGGER.error("Beleg konnte nicht verarbeitet werden: %s", fehler)
await k.beleg_ergebnis_veroeffentlichen(
{"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}
)
return
receipt_key = geparst.get("receipt_key")
if not receipt_key:
_LOGGER.error("Parser lieferte keinen receipt_key")
await k.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 = await k.ablage.tankvorgang_nach_receipt_key(receipt_key)
if vorhandener is not None and vorhandener.get("tank_id") != tank_id:
_LOGGER.info("Beleg %s bereits vorhanden, übersprungen", receipt_key)
await k.beleg_ergebnis_veroeffentlichen(
{"fehler": "Beleg bereits erfasst", "tank_id": tank_id}
)
return
beleg_felder = _beleg_felder(geparst, pdf_pfad)
if not tank_id:
await k.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None})
_LOGGER.info("Beleg %s geparst, wartet auf Speichern in der Oberfläche", receipt_key)
return
await _nachtragen(k, tank_id, geparst, beleg_felder, receipt_key)
def _beleg_felder(geparst: dict, pdf_pfad: str) -> dict:
liters = geparst.get("liters")
fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert
return {
"receipt_key": geparst.get("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"),
"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,
}
async def _nachtragen(
k: Koordinator, tank_id: str, geparst: dict, beleg_felder: dict, receipt_key: str
) -> None:
vorhanden = await k.ablage.tankvorgang_nach_id(tank_id)
if not vorhanden:
_LOGGER.warning("Tankvorgang %s für Beleg-Nachtrag nicht gefunden", tank_id)
await k.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:
_LOGGER.warning(
"Belegdatum %s passt nicht zu Tankvorgang %s (Datum %s)",
belegdatum, tank_id, vorhandenes_datum,
)
await k.beleg_ergebnis_veroeffentlichen(
{"fehler": "Falsches Belegdatum", "tank_id": tank_id}
)
return
aenderungen = dict(beleg_felder)
aenderungen["status"] = "vollständig"
if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen):
await k.tankvorgaenge_veroeffentlichen()
await k.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id})
_LOGGER.info("Beleg %s in Tankvorgang %s nachgetragen", receipt_key, tank_id)
else:
await k.beleg_ergebnis_veroeffentlichen(
{"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id}
)
# ------------------------------------------------ Tankvorgänge aus dem Formular
async def tankvorgang_manuell(k: Koordinator, daten: dict) -> None:
"""Erfassung aus dem Formular "Neuer Tankvorgang".
receipt_key/receipt_file sind optional: kommen sie mit (weil zuvor ein
Beleg ohne tank_id in den Entwurf übernommen wurde), zählt der Tankvorgang
als per Beleg erfasst, sonst als manuell."""
liter = daten.get("liter")
kosten = daten.get("kosten")
if liter is None or kosten is None:
_LOGGER.warning("liter und kosten sind Pflichtfelder")
return
km = daten.get("km")
odometer_km = km if km is not None else _km_aus_sensor(k)
distanz = daten.get("distanz")
if distanz is None:
# Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie
# odometer_km selbst frei überschreibbar - ein übergebener Wert hat
# deshalb immer Vorrang vor der Berechnung.
distanz = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
receipt_key = daten.get("receipt_key")
tankvorgang = leerer_tankvorgang(
daten.get("ts") or datetime.datetime.now(datetime.UTC).isoformat(),
"beleg" if receipt_key else "manual",
)
tankvorgang.update({
"receipt_key": receipt_key,
"liters": liter,
"fuel_total_eur": kosten,
"price_per_l": preis_je_liter(kosten, liter),
"discount": daten.get("ersparnis"),
"station_name": daten.get("station"),
"odometer_km": odometer_km,
"distance_km": distanz,
"fuel_type": daten.get("kraftstoff"),
"status": "vollständig",
"receipt_file": daten.get("receipt_file"),
})
await k.ablage.tankvorgang_anhaengen(tankvorgang)
await k.tankvorgaenge_veroeffentlichen()
_LOGGER.info("Tankvorgang %s angelegt", tankvorgang["tank_id"])
def _km_aus_sensor(k: Koordinator) -> float | None:
wert = zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR)
try:
return float(wert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None:
"""Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob
ursprünglich per Beleg oder manuell angelegt)."""
tank_id = daten.get("tank_id")
if not tank_id:
_LOGGER.warning("tankvorgang_aktualisieren ohne tank_id aufgerufen")
return
liter = daten.get("liter")
kosten = daten.get("kosten")
aenderungen = {
"ts": daten.get("ts"),
"liters": liter,
"fuel_total_eur": kosten,
"price_per_l": preis_je_liter(kosten, liter),
"odometer_km": daten.get("km"),
"discount": daten.get("ersparnis"),
"station_name": daten.get("station"),
"distance_km": daten.get("distanz"),
"fuel_type": daten.get("kraftstoff"),
}
if daten.get("receipt_key"):
aenderungen["receipt_key"] = daten["receipt_key"]
aenderungen["receipt_file"] = daten.get("receipt_file")
aenderungen["source"] = "beleg"
if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen):
await k.tankvorgaenge_veroeffentlichen()
_LOGGER.info("Tankvorgang %s aktualisiert", tank_id)
else:
_LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id)
async def tankvorgang_loeschen(k: Koordinator, tank_id: str | None) -> None:
if not tank_id:
_LOGGER.warning("tankvorgang_loeschen ohne tank_id aufgerufen")
return
if await k.ablage.tankvorgang_loeschen(tank_id):
await k.tankvorgaenge_veroeffentlichen()
_LOGGER.info("Tankvorgang %s gelöscht", tank_id)
else:
_LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id)
@@ -0,0 +1,91 @@
"""Fahrzeugbilder direkt aus der Oberfläche hochladen und löschen
(Einstellungen -> Fahrzeugbilder).
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. Eine Größenbeschränkung gibt es
bewusst nicht: jede Fotogröße muss hochladbar sein.
WARUM DIESE DATEIEN NICHT IM INTEGRATIONSORDNER LIEGEN: sie sind Nutzerdaten,
kein Auslieferbestandteil. Lägen sie unter custom_components/audi_dashboard/,
würde das nächste HACS-Update sie mitlöschen. /config/www/bilder/ gehört
dagegen dem Nutzer und wird von keinem Update angefasst; ausgeliefert wird es
weiterhin unter /local/bilder/.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
ERLAUBTE_DATEINAMEN = frozenset({
"seitenansicht.webp",
"seitenansicht-winter.webp",
"front-schraeg.webp",
"frontansicht.webp",
"heckansicht.webp",
"cockpit.webp",
"scheinwerfer.webp",
"sitze.webp",
"rad-sommer.webp",
"rad-winter.webp",
"draufsicht.webp",
})
def _pfad(ordner: str, dateiname: str | None) -> str | None:
if not dateiname or dateiname not in ERLAUBTE_DATEINAMEN:
return None
return os.path.join(ordner, dateiname)
def _schreiben(pfad: str, rohdaten: bytes) -> None:
os.makedirs(os.path.dirname(pfad), exist_ok=True)
tmp = pfad + ".tmp"
with open(tmp, "wb") as datei:
datei.write(rohdaten)
os.replace(tmp, pfad)
def _loeschen(pfad: str) -> bool:
if not os.path.exists(pfad):
return False
os.remove(pfad)
return True
async def hochladen(
hass: HomeAssistant, ordner: str, dateiname: str | None, daten_base64: str | None
) -> None:
pfad = _pfad(ordner, dateiname)
if not pfad or not daten_base64:
_LOGGER.warning("Bild-Upload abgelehnt (dateiname=%s)", dateiname)
return
try:
rohdaten = base64.b64decode(daten_base64)
except (binascii.Error, ValueError) as fehler:
_LOGGER.error("Bild-Upload: Daten nicht lesbar (%s)", fehler)
return
await hass.async_add_executor_job(_schreiben, pfad, rohdaten)
_LOGGER.info("Fahrzeugbild gespeichert (%s, %s Bytes)", dateiname, len(rohdaten))
async def loeschen(hass: HomeAssistant, ordner: str, dateiname: str | None) -> None:
pfad = _pfad(ordner, dateiname)
if not pfad:
_LOGGER.warning("Bild-Löschen abgelehnt (dateiname=%s)", dateiname)
return
if await hass.async_add_executor_job(_loeschen, pfad):
_LOGGER.info("Fahrzeugbild gelöscht (%s)", dateiname)
@@ -0,0 +1,37 @@
"""Einrichtung über die Oberfläche (Einstellungen -> Geräte & Dienste ->
Integration hinzufügen -> Audi Dashboard).
Bewusst ohne Eingabefelder. Es gäbe genau eine Sache zu fragen - welche
Entität welche Rolle im Fahrzeug spielt -, und die gehört nicht hierher: die
Zuordnung wird im Setup-Menü der App selbst vorgenommen, wo neben jedem Feld
steht, wofür es gebraucht wird und was passiert, wenn es leer bleibt. Sie
hier abzufragen hieße, dieselbe Auswahl an zwei Stellen zu pflegen - und die
schlechtere von beiden zuerst zu zeigen.
Eine einzige Instanz, erzwungen über single_config_entry in manifest.json:
die App gehört zu genau einem Fahrzeug, und alle Bestände liegen unter einem
festen Pfad.
"""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
class AudiDashboardConfigFlow(ConfigFlow, domain=DOMAIN):
"""Ein Bestätigungsschritt, mehr braucht es nicht."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if user_input is None:
return self.async_show_form(step_id="user")
return self.async_create_entry(title="Audi Dashboard", data={})
+83
View File
@@ -0,0 +1,83 @@
"""Feste Namen und Pfade der Integration.
Die Entity-IDs und Dienstnamen hier sind ein VERTRAG mit beiden Oberflächen
(homeassistant/www/audi-dashboard-app.js und companion-app/): dort stehen
dieselben Namen in je einer zentralen Tabelle. Wer hier umbenennt, muss beide
mitziehen - sonst liest die App ins Leere, ohne dass irgendetwas abstürzt.
Warum die Namen sich gegenüber der pyscript-Fassung überhaupt geändert haben:
dort hießen sie `pyscript.audi_dashboard_*`, weil pyscript der Besitzer der
Domain war. Ohne pyscript wäre dieser Präfix eine Lüge - die Entitäten
gehören jetzt dieser Integration. Aus `pyscript.audi_dashboard_x` wird
deshalb `sensor.audi_dashboard_x`, aus dem Dienst `pyscript.audi_dashboard_y`
wird `audi_dashboard.y`.
"""
DOMAIN = "audi_dashboard"
# Unterordner im HA-Konfigurationsverzeichnis für alle Nutzerdaten
# (Fahrzeugprofil, Fahrten, Tankvorgänge, Belege, Sicherungen). Bewusst
# derselbe Name und dasselbe Dateiformat wie in der pyscript-Fassung: eine
# bestehende Installation wird dadurch einfach weiterbenutzt, es gibt keine
# Datenmigration und damit auch keinen Weg, dabei etwas zu verlieren.
BASIS_ORDNER = "audi_dashboard"
# Panel: Sidebar-Eintrag und URL. Früher aus configuration.yaml
# (panel_custom:), jetzt von der Integration selbst registriert - deshalb
# entfällt der YAML-Block und mit ihm der Neustart beim ersten Einbau.
PANEL_URL = "audi-dashboard"
PANEL_TITEL = "Mein Audi"
PANEL_ICON = "mdi:car-sports"
PANEL_KOMPONENTE = "audi-dashboard-panel"
# Statischer Pfad, unter dem die Oberflächen-Dateien aus frontend/
# ausgeliefert werden. Ersetzt /local/ (also /config/www/): die Dateien
# gehören jetzt zum Installationspaket der Integration und werden von HACS
# mitgeliefert, statt separat nach www/ kopiert zu werden.
STATIK_URL = "/audi_dashboard_static"
STATIK_ORDNER = "frontend"
# Vom Nutzer hochgeladene Fahrzeugfotos. Die bleiben in /config/www/bilder,
# also unter /local/bilder/: sie sind NUTZERDATEN, kein Auslieferbestandteil.
# Lägen sie im Integrationsordner, würde das nächste HACS-Update sie
# mitlöschen - genau das darf nicht passieren.
BILDER_ORDNER = "www/bilder"
# --------------------------------------------------------------- Entitäten
# Schlüssel -> Objekt-ID. Die vollständige Entity-ID ist
# sensor.audi_dashboard_<objekt-id>; sensor.py legt sie so an.
E_PROFIL = "profil"
E_FAHRTEN = "fahrten"
E_TANKVORGAENGE = "tankvorgaenge"
E_FAHRZEUGSTATUS = "fahrzeugstatus"
E_BATTERIEVERLAUF = "batterieverlauf"
E_ZUORDNUNG = "entitaeten"
E_BELEG_ERGEBNIS = "beleg_ergebnis"
E_IMPORT_STATUS = "import_status"
E_APP_VERSION = "app_version"
E_REIFEN_SOMMER = "reifen_sommer_km"
E_REIFEN_WINTER = "reifen_winter_km"
E_REIFEN_AKTIV = "reifen_aktiver_satz"
# Signal für den Weg Koordinator -> Entität (async_dispatcher_send).
SIGNAL_AKTUALISIERT = f"{DOMAIN}_aktualisiert"
# ----------------------------------------------------------------- Dienste
D_JETZT_AKTUALISIEREN = "jetzt_aktualisieren"
D_PROFIL_SCHREIBEN = "profil_schreiben"
D_ENTITAETEN_SCHREIBEN = "entitaeten_schreiben"
D_FAHRT_MANUELL_ANLEGEN = "fahrt_manuell_anlegen"
D_FAHRT_AKTUALISIEREN = "fahrt_aktualisieren"
D_FAHRT_LOESCHEN = "fahrt_loeschen"
D_SCREENING_JETZT = "screening_jetzt"
D_BELEG_HOCHLADEN = "beleg_hochladen"
D_TANKVORGANG_MANUELL = "tankvorgang_manuell"
D_TANKVORGANG_AKTUALISIEREN = "tankvorgang_aktualisieren"
D_TANKVORGANG_LOESCHEN = "tankvorgang_loeschen"
D_BILD_HOCHLADEN = "bild_hochladen"
D_BILD_LOESCHEN = "bild_loeschen"
D_REIFEN_WECHSELN = "reifen_wechseln"
D_REIFEN_KM_SETZEN = "reifen_km_setzen"
D_BACKUP_JETZT = "backup_jetzt"
D_BACKUP_WIEDERHERSTELLEN = "backup_wiederherstellen"
D_HISTORIE_IMPORTIEREN = "historie_importieren"
+256
View File
@@ -0,0 +1,256 @@
"""Die Dienste, die beide Oberflächen aufrufen.
Aus `pyscript.audi_dashboard_<name>` ist `audi_dashboard.<name>` geworden -
der doppelte Präfix war nur nötig, weil alle pyscript-Dienste sich eine Domain
teilten. Die Namen selbst und die Feldnamen sind unverändert; beide
Oberflächen führen dieselbe Liste in je einer zentralen Tabelle.
Die Schemata sind bewusst großzügig: die Oberfläche schickt Zahlenfelder je
nach Formularzustand mal als Zahl, mal gar nicht. Ein zu enges Schema würde
einen Dienstaufruf ablehnen, und ein abgelehnter Aufruf ist in der Oberfläche
unsichtbar - er landet nur in der Browser-Konsole. Was inhaltlich nicht passt,
weisen deshalb die Fachfunktionen mit einer verständlichen Protokollzeile ab,
nicht die Schema-Prüfung.
"""
from __future__ import annotations
import json
from collections.abc import Callable, Coroutine
from typing import Any
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from . import belege, bilder, fahrterkennung, historienimport, reifen, screening, sicherung
from .const import (
BILDER_ORDNER,
D_BACKUP_JETZT,
D_BACKUP_WIEDERHERSTELLEN,
D_BELEG_HOCHLADEN,
D_BILD_HOCHLADEN,
D_BILD_LOESCHEN,
D_ENTITAETEN_SCHREIBEN,
D_FAHRT_AKTUALISIEREN,
D_FAHRT_LOESCHEN,
D_FAHRT_MANUELL_ANLEGEN,
D_HISTORIE_IMPORTIEREN,
D_JETZT_AKTUALISIEREN,
D_PROFIL_SCHREIBEN,
D_REIFEN_KM_SETZEN,
D_REIFEN_WECHSELN,
D_SCREENING_JETZT,
D_TANKVORGANG_AKTUALISIEREN,
D_TANKVORGANG_LOESCHEN,
D_TANKVORGANG_MANUELL,
DOMAIN,
)
from .koordinator import Koordinator
# Ein JSON-Feld darf als Zeichenkette oder schon ausgepackt ankommen: das
# Panel schickt JSON.stringify(...), ein Aufruf aus einer Automation heraus
# reicht dagegen naheliegenderweise die Struktur selbst durch.
_JSON = vol.Any(cv.string, dict, list)
_ZAHL = vol.Any(vol.Coerce(float), None)
_TEXT = vol.Any(cv.string, None)
_FAHRT_FELDER = {
vol.Optional("art"): _TEXT,
vol.Optional("start_ort"): _TEXT,
vol.Optional("ziel_ort"): _TEXT,
vol.Optional("odo_start"): _ZAHL,
vol.Optional("odo_end"): _ZAHL,
vol.Optional("distanz"): _ZAHL,
}
_TANK_FELDER = {
vol.Optional("ts"): _TEXT,
vol.Optional("liter"): _ZAHL,
vol.Optional("kosten"): _ZAHL,
vol.Optional("km"): _ZAHL,
vol.Optional("ersparnis"): _ZAHL,
vol.Optional("station"): _TEXT,
vol.Optional("distanz"): _ZAHL,
vol.Optional("kraftstoff"): _TEXT,
vol.Optional("receipt_key"): _TEXT,
vol.Optional("receipt_file"): _TEXT,
}
SCHEMATA: dict[str, vol.Schema] = {
D_JETZT_AKTUALISIEREN: vol.Schema({}),
D_SCREENING_JETZT: vol.Schema({}),
D_BACKUP_JETZT: vol.Schema({}),
D_PROFIL_SCHREIBEN: vol.Schema({vol.Required("profil_json"): _JSON}),
D_ENTITAETEN_SCHREIBEN: vol.Schema({vol.Required("zuordnung_json"): _JSON}),
D_FAHRT_MANUELL_ANLEGEN: vol.Schema({
vol.Required("ts_start"): cv.string,
vol.Required("ts_end"): cv.string,
**_FAHRT_FELDER,
}),
D_FAHRT_AKTUALISIEREN: vol.Schema({
vol.Required("trip_id"): cv.string,
vol.Optional("ts_start"): _TEXT,
vol.Optional("ts_end"): _TEXT,
**_FAHRT_FELDER,
}),
D_FAHRT_LOESCHEN: vol.Schema({vol.Required("trip_id"): cv.string}),
D_BELEG_HOCHLADEN: vol.Schema({
vol.Required("pdf_base64"): cv.string,
vol.Optional("dateiname"): _TEXT,
vol.Optional("tank_id"): _TEXT,
}),
D_TANKVORGANG_MANUELL: vol.Schema(dict(_TANK_FELDER)),
D_TANKVORGANG_AKTUALISIEREN: vol.Schema({
vol.Required("tank_id"): cv.string, **_TANK_FELDER
}),
D_TANKVORGANG_LOESCHEN: vol.Schema({vol.Required("tank_id"): cv.string}),
D_BILD_HOCHLADEN: vol.Schema({
vol.Required("dateiname"): cv.string,
vol.Required("daten_base64"): cv.string,
}),
D_BILD_LOESCHEN: vol.Schema({vol.Required("dateiname"): cv.string}),
D_REIFEN_WECHSELN: vol.Schema({vol.Required("satz"): vol.In(reifen.SAETZE)}),
D_REIFEN_KM_SETZEN: vol.Schema({
vol.Required("satz"): vol.In(reifen.SAETZE),
vol.Required("km"): vol.Coerce(float),
}),
D_BACKUP_WIEDERHERSTELLEN: vol.Schema({
vol.Optional("profil_json"): _JSON,
vol.Optional("fahrten_json"): _JSON,
vol.Optional("tankvorgaenge_json"): _JSON,
vol.Optional("entitaeten_json"): _JSON,
}),
D_HISTORIE_IMPORTIEREN: vol.Schema({
vol.Required("start"): cv.string,
vol.Required("ende"): cv.string,
}),
}
Fabrik = Callable[[ServiceCall], Coroutine[Any, Any, None]]
def _als_dienst(fabrik: Fabrik) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]:
"""Macht aus einer Funktion, die eine Koroutine zurückgibt, eine echte
Koroutinen-Funktion.
Home Assistant prüft mit iscoroutinefunction(), ob es einen Dienst
erwarten muss. Ein Lambda, das eine Koroutine nur zurückgibt, besteht
diese Prüfung nicht - der Aufruf würde stillschweigend nichts tun."""
async def behandler(aufruf: ServiceCall) -> None:
await fabrik(aufruf)
return behandler
def _objekt(wert: object) -> Any:
return json.loads(wert) if isinstance(wert, str) else wert
def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
"""Meldet alle Dienste an. Gegenstück: abmelden()."""
async def jetzt_aktualisieren(_aufruf: ServiceCall) -> None:
"""Gegenstück zum "Ziehen zum Aktualisieren": stößt den von der
Datenquelle bereitgestellten Refresh-Knopf an, statt auf den nächsten
Takt zu warten. Ohne eingetragenen Knopf nur die direkte
Neuveröffentlichung - besser als gar nichts, auch wenn dann kein
echter neuer Abruf beim Fahrzeug passiert."""
knopf = k.zuordnung.werte.REFRESH_BUTTON
if knopf:
await hass.services.async_call(
"button", "press", {"entity_id": knopf}, blocking=True
)
await k.fahrzeugstatus_veroeffentlichen()
async def profil_schreiben(aufruf: ServiceCall) -> None:
"""Überschreibt das komplette Fahrzeugprofil.
Die Oberfläche hält Profil, Fahrzeugdaten und Einstellungen ohnehin
als einen zusammenhängenden Stand im Speicher und schickt ihn nach
jeder Änderung komplett zurück - deshalb ein einziger Dienst statt
eines eigenen je Feld."""
await k.ablage.profil_schreiben(_objekt(aufruf.data["profil_json"]))
await k.profil_veroeffentlichen()
async def entitaeten_schreiben(aufruf: ServiceCall) -> None:
"""Speichert die im Setup-Menü gewählte Sensor-Zuordnung, wendet sie
sofort an, bindet die Zustandsbeobachter neu und veröffentlicht
Katalog und Status neu.
Das Neubinden ist der Grund, warum der frühere Hinweis "wirkt erst
nach einem Neustart" und der Neustart-Knopf daneben entfallen konnten:
auch Zündung, Kilometerstand und Tankfüllstand wirken jetzt sofort."""
await k.zuordnung.speichern(_objekt(aufruf.data["zuordnung_json"]))
await k.trigger_neu_binden()
await k.zuordnung_veroeffentlichen()
await k.fahrzeugstatus_veroeffentlichen()
async def historie_importieren(aufruf: ServiceCall) -> None:
"""Startet den Import und kehrt sofort zurück.
Der Import kann bei einem Jahr Verlauf Minuten dauern. Die Oberfläche
wartet deshalb nicht auf den Dienstaufruf, sondern verfolgt den
Fortschritt über sensor.audi_dashboard_import_status - genau so, wie
sie es schon bei der pyscript-Fassung tat, deren Dienste ebenfalls
sofort zurückkehrten."""
k.entry.async_create_background_task(
hass,
historienimport.importieren(k, aufruf.data["start"], aufruf.data["ende"]),
"audi_dashboard_historienimport",
)
bilder_ordner = hass.config.path(BILDER_ORDNER)
behandler: dict[str, Callable[[ServiceCall], Coroutine[Any, Any, None]]] = {
D_JETZT_AKTUALISIEREN: jetzt_aktualisieren,
D_PROFIL_SCHREIBEN: profil_schreiben,
D_ENTITAETEN_SCHREIBEN: entitaeten_schreiben,
D_HISTORIE_IMPORTIEREN: historie_importieren,
D_SCREENING_JETZT: _als_dienst(lambda _a: screening.durchfuehren(k)),
D_FAHRT_MANUELL_ANLEGEN: _als_dienst(
lambda a: fahrterkennung.manuell_anlegen(k, dict(a.data))
),
D_FAHRT_AKTUALISIEREN: _als_dienst(
lambda a: fahrterkennung.aktualisieren(k, dict(a.data))
),
D_FAHRT_LOESCHEN: _als_dienst(
lambda a: fahrterkennung.loeschen(k, a.data["trip_id"])
),
D_BELEG_HOCHLADEN: _als_dienst(lambda a: belege.hochladen(k, dict(a.data))),
D_TANKVORGANG_MANUELL: _als_dienst(
lambda a: belege.tankvorgang_manuell(k, dict(a.data))
),
D_TANKVORGANG_AKTUALISIEREN: _als_dienst(
lambda a: belege.tankvorgang_aktualisieren(k, dict(a.data))
),
D_TANKVORGANG_LOESCHEN: _als_dienst(
lambda a: belege.tankvorgang_loeschen(k, a.data["tank_id"])
),
D_BILD_HOCHLADEN: _als_dienst(
lambda a: bilder.hochladen(
hass, bilder_ordner, a.data["dateiname"], a.data["daten_base64"]
)
),
D_BILD_LOESCHEN: _als_dienst(
lambda a: bilder.loeschen(hass, bilder_ordner, a.data["dateiname"])
),
D_REIFEN_WECHSELN: _als_dienst(lambda a: reifen.wechseln(k, a.data["satz"])),
D_REIFEN_KM_SETZEN: _als_dienst(
lambda a: reifen.km_setzen(k, a.data["satz"], a.data["km"])
),
D_BACKUP_JETZT: _als_dienst(lambda _a: sicherung.jetzt(k)),
D_BACKUP_WIEDERHERSTELLEN: _als_dienst(
lambda a: sicherung.wiederherstellen(k, dict(a.data))
),
}
for name, funktion in behandler.items():
hass.services.async_register(DOMAIN, name, funktion, schema=SCHEMATA[name])
def abmelden(hass: HomeAssistant) -> None:
for name in SCHEMATA:
hass.services.async_remove(DOMAIN, name)
@@ -0,0 +1,197 @@
"""Welche Home-Assistant-Entität welche Rolle im Fahrzeug spielt.
Das ist die einzige technische Konfiguration der App. Alles andere (Pausen-
zeit, Reifendaten, Versicherung, ...) gehört ins Fahrzeugprofil und ist über
die Oberfläche änderbar.
**Im Auslieferstand ist hier alles leer.** Das ist Absicht, kein unfertiger
Zustand: welche Entity-IDs richtig sind, hängt an der jeweiligen Instanz und
ihren Integrationen. Zugeordnet wird nach der Installation im Setup-Menü der
App (Einstellungen -> Fahrzeug einrichten -> Setup), das die Zuordnung nach
audi_dashboard/entitaeten.json schreibt (siehe zuordnung.py).
Ein leeres Feld ist der sichere Zustand: die betroffene Kachel zeigt
"unbekannt" statt eines falschen Werts (siehe zustand_oder_none() in
veroeffentlichung.py), und die trigger-gebundenen Felder registrieren gar
keinen Zustandsbeobachter, statt einen gegen eine nicht existierende Entität
zu registrieren. Eine gesetzte, aber falsche Entity-ID ist deshalb schlechter
als eine leere - genau das ist 2026-08-23 einmal passiert, als hier noch die
Entity-IDs einer längst abgeräumten Testinstanz standen.
Zwei typische Quellen auf dieser Instanz: der Teltonika FMM003 (GPS-Tracker
mit CAN-Anbindung, über flespi angebunden) und eine EU-Data-Act-Integration
des Herstellers. Welche davon welche Rolle bedient, entscheidet das
Setup-Menü - nicht diese Datei.
UNTERSCHIED ZUR PYSCRIPT-FASSUNG: dort waren das Modul-Variablen, die
entitaeten.py zur Laufzeit per setattr() auf dem importierten Modul-Objekt
überschrieb. Das funktionierte, war aber ein globaler, veränderlicher Zustand
quer durch alle Dateien. Hier ist es ein ganz normales Objekt, das der
Koordinator hält und weiterreicht.
"""
from __future__ import annotations
from dataclasses import dataclass, field, fields
# Vier gleich aufgebaute Positionen für Türen und Fenster.
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
@dataclass
class Sensorzuordnung:
"""Die wirksame Zuordnung. Feldnamen = Schlüssel in entitaeten.json."""
# Fahrterkennung: Start/Ende einer Fahrt über den Zündungs-/ACC-Status
# (on = Fahrt läuft). Dieselbe Entität speist auch die Anzeige
# "fährt/steht" - damit können Erfassung und Anzeige nicht auseinander-
# laufen.
ZUENDUNG_SENSOR: str = ""
# Kilometerstand - für Fahrtabschluss-Screening, Reifenzähler und
# Ölwechsel-Prognose.
#
# Beim FMM003 hier NICHT den selbst berechneten Gesamtkilometerstand
# (*_total_calculated_mileage) zuordnen: der beruht auf GPS-Strecken-
# rechnung statt auf dem Tacho und damit auf einer anderen Zählbasis als
# der echte Fahrzeug-Kilometerstand. Wer ihn einträgt, verfälscht alle
# drei genannten Auswertungen mit einem inkonsistenten Basiswert. Der vom
# CAN gelesene Wert (*_total_vehicle_mileage_read_from_can) bzw. der
# Kilometerstand der EU-Data-Act-Integration ist der richtige.
KM_SENSOR: str = ""
# Tankfüllstand in Prozent - für die automatische Tankerkennung.
TANK_SENSOR: str = ""
# Reichweite (Übersicht).
RANGE_SENSOR: str = ""
# 12V-Batteriespannung. Beim FMM003 ist das external_power_voltage - 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: str = ""
# Knopf für eine sofortige Neuabfrage beim Fahrzeug.
REFRESH_BUTTON: str = ""
# Türen/Fenster (je vier Positionen, siehe POSITIONEN).
TUER_SENSOREN: list[str] = field(default_factory=list)
FENSTER_SENSOREN: list[str] = field(default_factory=list)
HECKKLAPPE_SENSOR: str = ""
HAUBE_SENSOR: str = ""
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die
# App-eigene, aus dem Servicebuch berechnete Prognose).
NAECHSTER_OELWECHSEL_SENSOR: str = ""
OELWECHSEL_STRECKE_SENSOR: str = ""
NAECHSTE_INSPEKTION_SENSOR: str = ""
INSPEKTION_STRECKE_SENSOR: str = ""
# Live-GPS-Position (Übersicht -> Standort-Kachel). Breiten-/Längengrad
# als zwei eigene sensor-Entities: flespi liefert Koordinaten so, nicht
# als Attribute einer device_tracker-Entity.
STANDORT_LAT_SENSOR: str = ""
STANDORT_LON_SENSOR: str = ""
def _standardwerte() -> dict[str, object]:
"""Die eingebauten Werte, einmal eingefroren.
Gebraucht für "Zurücksetzen" im Setup-Menü: die Zuordnung wird bei jedem
Anwenden vollständig neu gesetzt, ein weggelassener Override fällt damit
auf genau diesen Wert zurück."""
leer = Sensorzuordnung()
werte: dict[str, object] = {}
for f in fields(leer):
wert = getattr(leer, f.name)
werte[f.name] = list(wert) if isinstance(wert, list) else wert
return werte
STANDARDWERTE: dict[str, object] = _standardwerte()
SCHLUESSEL: set[str] = set(STANDARDWERTE)
# Katalog aller Rollen fürs Setup-Menü im Frontend. Reihenfolge =
# Anzeige-Reihenfolge innerhalb jeder Gruppe. domains/device_classes/units
# sind Erwartungen fürs Frontend-seitige Filtern und Vorschlagen (Schalter
# "Nur passende Sensoren anzeigen") - eine fehlende oder leere Liste bedeutet
# "keine Einschränkung" bzw. "diese Rolle hat üblicherweise keine Einheit".
FELDER: list[dict] = [
{"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung",
"hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende.",
"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 sie 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": "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"]},
]
# In der pyscript-Fassung stand hier eine Liste TRIGGER_GEBUNDEN: die drei
# Felder ZUENDUNG/KM/TANK_SENSOR waren beim Modul-Laden einmalig in die
# @state_trigger-Registrierung eingebacken, eine Änderung wirkte deshalb erst
# nach einem HA-Neustart, und das Setup-Menü musste genau davor warnen.
# Diese Integration bindet ihre Zustandsbeobachter bei jeder Änderung neu
# (koordinator.trigger_neu_binden) - die Einschränkung, der Warnhinweis und
# der eigens dafür gebaute Neustart-Dienst sind damit ersatzlos weg.
for _feld in FELDER:
_feld["trigger_gebunden"] = False
@@ -0,0 +1,281 @@
"""Fahrterkennung über den Zündungs-/ACC-Status (SPECIFICATION.md §7.1).
Start: die Zündungs-Entität wechselt auf "on". Ende: sie wechselt auf "off"
und kommt binnen der Pausenzeit nicht zurück.
Die Pausenregel läuft über eine abbrechbare Aufgabe: jede neue Änderung der
Zündung bricht eine noch wartende Ende-Bestätigung ab. Kommt die Zündung
während der Wartezeit zurück, wird die Fahrt dadurch nicht beendet - genau
die Pausenregel aus §7.1. (In der pyscript-Fassung leisteten task.unique() und
task.sleep() dasselbe.)
WAS HIER GEGENÜBER DER PYSCRIPT-FASSUNG BESSER IST: dort lebte der Beginn
einer laufenden Fahrt nur im Arbeitsspeicher. Ein HA-Neustart mitten in einer
Fahrt oder während der Wartezeit verlor diesen Zwischenstand - die Fahrt wurde
dann gar nicht aufgezeichnet. Das war als bekannte Schwäche vermerkt und ist
hier behoben: der Zwischenstand liegt in einem Store (siehe koordinator.py),
überlebt einen Neustart und wird beim Hochfahren wieder aufgenommen.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from typing import TYPE_CHECKING
from . import screening
from .ablage import neue_id
from .veroeffentlichung import zustand_oder_none
from .verlauf import verlauf_lesen
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
STANDARD_PAUSENZEIT_MIN = 15
def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: str) -> dict:
"""Das vollständige Fahrt-Schema mit allem, was noch nicht bekannt ist,
auf None. Bewusst alle Felder ausgeschrieben: die Oberfläche darf sich auf
ihre Existenz verlassen, und ein fehlendes Feld wäre schwerer zu finden
als ein leeres."""
return {
"trip_id": neue_id("t"),
"ts_start": start_ts.isoformat(),
"ts_end": ende_ts.isoformat(),
"duration_s": int((ende_ts - start_ts).total_seconds()),
"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": quelle,
"status": "offen",
"edited_fields": [],
}
async def pausenzeit_sekunden(k: Koordinator) -> int:
profil = await k.ablage.profil_lesen()
if profil is None:
return STANDARD_PAUSENZEIT_MIN * 60
minuten = profil.get("einstellungen", {}).get(
"fahrten_pausenzeit_min", STANDARD_PAUSENZEIT_MIN
)
return int(minuten) * 60
async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) -> None:
"""Reagiert auf jede Zustandsänderung der Zündungs-Entität."""
# Eine noch wartende Ende-Bestätigung aus einer vorherigen Änderung
# abbrechen - das ist der Mechanismus hinter der Pausenregel.
k.warte_ende_ab_abbrechen()
an_jetzt = neu == "on"
an_vorher = alt == "on"
if an_jetzt and not an_vorher:
if k.fahrt_start_ts is None:
await k.fahrt_start_setzen(datetime.datetime.now(datetime.UTC))
_LOGGER.info("Fahrt gestartet um %s", k.fahrt_start_ts)
# Die Anzeige "fährt/steht" hängt an derselben Entität - sofort neu
# veröffentlichen, statt bis zum nächsten 20-Sekunden-Takt zu warten.
await k.fahrzeugstatus_veroeffentlichen()
return
if an_vorher and not an_jetzt and k.fahrt_start_ts is not None:
start_ts = k.fahrt_start_ts
abbruch_ts = datetime.datetime.now(datetime.UTC)
wartezeit = await pausenzeit_sekunden(k)
k.warte_ende_ab(_ende_bestaetigen(k, start_ts, abbruch_ts, wartezeit))
await k.fahrzeugstatus_veroeffentlichen()
async def _ende_bestaetigen(
k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime, wartezeit: int
) -> None:
"""Wartet die Pausenzeit ab und beendet die Fahrt dann.
Kommt die Zündung vorher zurück, bricht zuendung_geaendert() genau diese
Aufgabe ab und der Code hinter dem sleep läuft nie."""
try:
await asyncio.sleep(wartezeit)
except asyncio.CancelledError:
_LOGGER.debug("Fahrtende verworfen - Zündung kam innerhalb der Pausenzeit zurück")
raise
await fahrt_beenden(k, start_ts, ende_ts)
async def nach_neustart_fortsetzen(k: Koordinator) -> None:
"""Schließt eine Fahrt ab, die während eines HA-Ausfalls zu Ende ging.
Der Zwischenstand einer laufenden Fahrt überlebt jetzt einen Neustart
(Store im Koordinator). Damit entsteht aber ein neuer Fall, den es vorher
nicht gab: ging die Zündung aus, WÄHREND Home Assistant unten war, hat
niemand die Ende-Bestätigung gesehen - der Beobachter bekommt nach dem
Hochfahren keinen Wechsel mehr, weil der längst passiert ist. Ohne diese
Prüfung bliebe die Fahrt für immer offen, und die nächste echte Fahrt
würde an ihren Beginn angehängt.
Läuft bewusst erst, wenn Home Assistant vollständig gestartet ist: mitten
im Einrichten hat die Datenquelle ihren Zustand oft noch nicht gemeldet,
und ein "noch nicht da" ließe sich nicht von "aus" unterscheiden."""
if k.fahrt_start_ts is None:
return
sensor = k.zuordnung.werte.ZUENDUNG_SENSOR
if zustand_oder_none(k.hass, sensor) == "on":
return # fährt noch - der Beobachter übernimmt wie sonst auch
ende_ts = await _letztes_lebenszeichen(k, sensor, k.fahrt_start_ts)
_LOGGER.info(
"Fahrt seit %s wurde während eines Ausfalls beendet - wird jetzt mit "
"Ende %s abgeschlossen", k.fahrt_start_ts.isoformat(), ende_ts.isoformat(),
)
await fahrt_beenden(k, k.fahrt_start_ts, ende_ts)
async def _letztes_lebenszeichen(
k: Koordinator, sensor: str, seit: datetime.datetime
) -> datetime.datetime:
"""Der zuletzt aufgezeichnete Zeitpunkt der Zündungs-Entität seit `seit`.
Das ist der ehrlichste verfügbare Endzeitpunkt: bis dahin wissen wir, dass
das Fahrzeug beobachtet wurde. "Jetzt" zu nehmen wäre falsch, wenn Home
Assistant tagelang unten war - daraus würde eine Fahrt über Tage. Gibt es
gar keine Aufzeichnung (Sensor nicht zugeordnet, recorder zu kurz), bleibt
nur "jetzt"; das Screening trägt die Kilometerstände ohnehin nach."""
jetzt = datetime.datetime.now(datetime.UTC)
punkte = await verlauf_lesen(k.hass, sensor, seit, jetzt)
return punkte[-1][0] if punkte else jetzt
async def fahrt_beenden(
k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime
) -> None:
fahrt = leere_fahrt(start_ts, ende_ts, "ha")
await k.ablage.fahrt_anhaengen(fahrt)
await k.fahrt_start_setzen(None)
await k.fahrten_veroeffentlichen()
_LOGGER.info(
"Fahrt %s beendet, Status 'offen', Screening angestoßen", fahrt["trip_id"]
)
await screening.durchfuehren(k)
# ------------------------------------------------------- Aus der Oberfläche
HANDFELDER = {"start_address", "end_address", "odo_start", "odo_end", "distance_km"}
def _handfelder(
start_ort: str | None,
ziel_ort: str | None,
odo_start: float | None,
odo_end: float | None,
distanz: float | None,
) -> dict:
"""Die von Hand befüllbaren Felder auf ihre Datensatz-Namen abgebildet,
ohne die leer gelassenen. Der Aufrufer schreibt die Schlüssel zusätzlich
nach edited_fields - das schützt sie vor dem automatischen
Kilometerstand-Screening, das sonst später darüberschreiben würde."""
werte = {
"start_address": start_ort or None,
"end_address": ziel_ort or None,
"odo_start": odo_start,
"odo_end": odo_end,
"distance_km": distanz,
}
felder = {k: v for k, v in werte.items() if v is not None}
# Fehlt die Distanz, ergibt sie sich aus den beiden Kilometerständen -
# dieselbe Rechnung wie im Screening.
if "distance_km" not in felder and "odo_start" in felder and "odo_end" in felder:
felder["distance_km"] = round(felder["odo_end"] - felder["odo_start"], 1)
return felder
async def manuell_anlegen(k: Koordinator, daten: dict) -> None:
"""Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der
automatischen Zündungs-Erkennung. Alles außer Start- und Endzeitpunkt ist
optional und bleibt leer, wenn es das Screening später selbst ergänzen
soll."""
start_ts = datetime.datetime.fromisoformat(daten["ts_start"])
ende_ts = datetime.datetime.fromisoformat(daten["ts_end"])
hand = _handfelder(
daten.get("start_ort"), daten.get("ziel_ort"),
daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"),
)
fahrt = leere_fahrt(start_ts, ende_ts, "manual")
fahrt["art"] = daten.get("art") or "privat"
fahrt.update(hand)
fahrt["edited_fields"] = sorted(hand)
if fahrt["distance_km"] is not None:
fahrt["km_quelle"] = "manuell"
fahrt["status"] = "vollständig"
await k.ablage.fahrt_anhaengen(fahrt)
await k.fahrten_veroeffentlichen()
await screening.durchfuehren(k)
_LOGGER.info("Fahrt %s von Hand angelegt", fahrt["trip_id"])
async def aktualisieren(k: Koordinator, daten: dict) -> None:
"""Bearbeitet eine bestehende Fahrt (Einzelfahrt-Seite), egal ob sie
automatisch erkannt oder von Hand angelegt wurde."""
trip_id = daten.get("trip_id")
if not trip_id:
_LOGGER.warning("fahrt_aktualisieren ohne trip_id aufgerufen")
return
aenderungen = _handfelder(
daten.get("start_ort"), daten.get("ziel_ort"),
daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"),
)
if daten.get("art"):
aenderungen["art"] = daten["art"]
if daten.get("ts_start") and daten.get("ts_end"):
start_ts = datetime.datetime.fromisoformat(daten["ts_start"])
ende_ts = datetime.datetime.fromisoformat(daten["ts_end"])
aenderungen["ts_start"] = start_ts.isoformat()
aenderungen["ts_end"] = ende_ts.isoformat()
aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds())
if aenderungen.get("distance_km") is not None:
aenderungen["km_quelle"] = "manuell"
aenderungen["status"] = "vollständig"
# Von Hand gesetzte Felder bleiben von Hand gesetzt: die Liste wächst mit,
# damit ein späteres Screening sie nicht wieder überschreibt.
passend = [f for f in await k.ablage.fahrten_lesen() if f.get("trip_id") == trip_id]
bisher = set(passend[0].get("edited_fields", [])) if passend else set()
aenderungen["edited_fields"] = sorted(bisher | (HANDFELDER & set(aenderungen)))
if await k.ablage.fahrt_bearbeiten(trip_id, aenderungen):
await k.fahrten_veroeffentlichen()
_LOGGER.info("Fahrt %s aktualisiert", trip_id)
else:
_LOGGER.warning("Fahrt %s nicht gefunden", trip_id)
async def loeschen(k: Koordinator, trip_id: str | None) -> None:
if not trip_id:
_LOGGER.warning("fahrt_loeschen ohne trip_id aufgerufen")
return
if await k.ablage.fahrt_loeschen(trip_id):
await k.fahrten_veroeffentlichen()
_LOGGER.info("Fahrt %s gelöscht", trip_id)
else:
_LOGGER.warning("Fahrt %s nicht gefunden", trip_id)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,344 @@
/* ==================================================================
iOS-Auflage über audi-dashboard.css — Audi CI (Rot F50537, Audi Type,
dunkle Fläche) in der Formensprache einer modernen iOS-App:
Große Titel, gruppierte Listen mit 44-px-Zeilen, transluente Tab-Leiste,
iOS-Schalter und -Segmente, gefüllte Aktionsknöpfe.
Reine Auflage: audi-dashboard.css und die App-Logik bleiben unberührt.
================================================================== */
:host{
display:block;height:100%;container-type:inline-size;
font-family:"Audi Type",Helvetica,Arial,sans-serif;
--ios-tint:#F50537;
--ios-sep:rgba(255,255,255,.13);
--ios-grau:#8E8E93;
--ios-fill:rgba(120,120,128,.24);
--r-tile:16px;
/* deckende Kachelfarbe fuer Flaechen, die etwas verdecken muessen
(Wischzeile ueber dem Loeschen-Feld, Kartenfeld) */
--tile-deckend:#171B21;
}
:host([data-theme="nacht"]){
--canvas:#0C1014;
--tile:rgba(255,255,255,.055);
--tile-2:rgba(255,255,255,.10);
--line:rgba(255,255,255,.13);
--line-strong:rgba(255,255,255,.24);
--fg:#FFFFFF; --fg2:#BFC4CC; --fg3:#8E8E93;
--ok:#30D158; --warn:#FFD60A; --bad:#FF453A;
/* Ohne diese Angabe rendert der Browser native Steuerelemente (Dropdown-
Aufklapp-Liste bei <select>, Scrollbars) in seiner Standard-Hellansicht,
egal wie dunkel die App selbst ist - das liess z. B. "Modell" beim
Aufklappen grell-weiss aufblitzen. Betrifft alle <select>-Felder. */
color-scheme: dark;
}
:host([data-theme="tag"]){
--canvas:#F2F2F7;
--tile:#FFFFFF;
--tile-2:#EFEFF4;
--line:rgba(60,60,67,.13);
--line-strong:rgba(60,60,67,.24);
--fg:#000000; --fg2:#3C3C43; --fg3:#8E8E93;
--ok:#34C759; --warn:#FF9F0A; --bad:#FF3B30;
--ios-sep:rgba(60,60,67,.16);
--ios-fill:rgba(120,120,128,.16);
--tile-deckend:#FFFFFF;
color-scheme: light;
}
body{padding:0;background:var(--canvas)}
html,body{height:100%}
/* Der von _aufbauen() eingefuegte Wrapper zwischen Host und .phone. Die
412px-Deckelung aus audi-dashboard.css (fuer die alte Einzelgroesse
gedacht) wird hier unbedingt aufgehoben, nicht nur ab 860px: auf
schmalen Bildschirmen macht der Viewport selbst die Breite eng genug,
:host-Selektoren innerhalb @container werden von diesem Browser aber
offenbar verworfen - deshalb unbedingt statt bedingt.
Die Hoehe bleibt dagegen viewport-verankert (100dvh statt 100%): das ist
der Grund, warum audi-dashboard.css das ueberhaupt so baute - HA gibt der
panel_custom-Kette keine verlaessliche Hoehe weiter, height:100% liess den
Hauptinhalt hier auf 0px kollabieren (Tabbar direkt unter dem Header,
main#view unsichtbar). Erst ab 860px (siehe @container unten) darf die
880px-Deckelung fallen, damit die App auf grossen Bildschirmen mehr als
eine schwebende Telefonkarte ist. */
:host > div{height:min(880px, calc(100dvh - 24px));max-width:none}
.phone{width:100%;max-width:none;height:100%;border-radius:0;
font-family:"Audi Type",Helvetica,Arial,sans-serif;
-webkit-font-smoothing:antialiased;letter-spacing:0}
/* ---------------- Navigationsleiste ---------------- */
/* Sichere Bereiche des Geräterahmens: Statusleiste oben, Home-Indikator unten */
/* Linker Innenabstand = --seitenrand von main#view (16px), damit der Titel
exakt auf der Kante der Kacheln darunter sitzt. */
.topbar{align-items:center;gap:4px;padding:56px 20px 4px 16px}
.head{display:flex;flex-direction:column}
.eyebrow{display:none}
.title{font-size:22px;font-weight:400;letter-spacing:-.01em;line-height:1.2}
.back{width:34px;height:34px;margin-left:-8px;justify-content:center}
/* Design-Audit 2026-08-13, auf allen Breiten nachgezogen 2026-08-17: .back ist
auch inaktiv (opacity:0) noch ein Flex-Element plus Gap - auf Wurzelseiten
ohne Zurueck-Pfeil schob das die Kopfzeile (.head/.title) sichtbar weiter
nach rechts, als main#view darunter beginnt. Das negative margin-right hebt
zusaetzlich den Gap auf, sonst blieben 4px Versatz. Auf Detailseiten
(.back.on) bleibt der Platz bewusst reserviert, das ist dort ein echter,
bedienbarer Knopf. */
.back:not(.on){width:0;min-width:0;margin:0 -4px 0 0;overflow:hidden}
/* Rot bleibt Akzent/destruktiv, nicht Navigationsfarbe (dieselbe Regel wie
bei den Aktions-Buttons und dem aktiven Menü-Icon) - derselbe neutrale Ton
wie die Kopfzeile selbst. */
.back svg{width:22px;height:22px;color:var(--fg)}
.sync{margin-top:3px;font-size:12.5px;color:var(--fg3)}
.sync svg{width:13px;height:13px}
.themebtn{width:36px;height:36px;border:0;background:var(--ios-fill);color:var(--fg2)}
.themebtn:hover{background:var(--tile-2);color:var(--fg)}
/* Telefonbreite: die Ringe stehen oben rechts und oeffnen die Einstellungen
(Stand nach dem Design-Audit). Das Zahnrad entfaellt dort, damit oben rechts
nur ein Element sitzt. */
.rings{order:9;margin-left:8px;margin-right:-10px;cursor:pointer;
min-width:44px;min-height:44px;height:44px;justify-content:flex-end;padding:0 10px}
.rings svg{height:17px}
.profilbtn{display:none}
/* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste */
.phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px}
.phone:has(.back.on) .head{align-items:center}
.phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center}
.phone:has(.back.on) .sync{margin-top:2px;font-size:11.5px}
main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smooth}
/* ---------------- Karten und Listen ---------------- */
.tile{padding:16px 18px;margin-bottom:14px;border-radius:var(--r-tile)}
.tile.flat{padding:0}
.tilebtn:hover{background:var(--tile-2)}
/* top/margin-top NICHT von der Basis uebernehmen: alle .go-Kacheln
(Fahrzeug/Service/Versicherung/Reifen/Schutzbrief) sind mehrzeilige
Kacheln mit .label oben plus einer ganzen dl.rows-Liste darunter - eine
vertikale 50%-Zentrierung landet dann mitten in der Zeilenliste statt
neben dem Titel und ueberlappt umgebrochenen Zeilentext (Design-Audit
2026-08-13). Bleibt bei top:var(--sp-5) aus der Basis, nur rechter
Abstand/Farbe werden fuer die iOS-Optik angepasst. */
.tilebtn .go{right:18px;stroke:var(--ios-grau);stroke-width:1.6}
.label{font-size:13px;letter-spacing:.01em;text-transform:none;color:var(--fg3)}
.row{min-height:44px;padding:11px 0;border-bottom:1px solid var(--ios-sep)}
.row dt{font-size:16px;color:var(--fg)}
.row dd{font-size:16px;color:var(--fg2)}
.row dd small{font-size:12.5px;margin-top:2px}
.row-fussnote{font-size:12.5px}
.rows-fest dt{flex:0 0 150px}
.chev{stroke:var(--ios-grau);stroke-width:1.6}
.acc-h{padding:13px 0;border-bottom:1px solid var(--ios-sep)}
.acc-h .k{font-size:16px}
.acc-h .v{font-size:12.5px}
.leaf{padding:12px 0 12px 40px;border-bottom:1px solid var(--ios-sep)}
.leaf .k{font-size:16px}
.leaf .v{font-size:15px}
.leaf .k small,.leaf .v small{font-size:12.5px}
.mark::before,.mark::after{background:var(--ios-grau)}
/* ---------------- Bedienelemente ---------------- */
.seg{gap:0;padding:2px;background:var(--ios-fill);border-radius:9px;margin-top:14px}
.seg button{border:0;border-radius:7px;padding:7px 8px;font-size:13px;font-weight:400;
letter-spacing:0;text-transform:none;color:var(--fg2)}
.seg button.on{background:var(--tile-2);color:var(--fg);box-shadow:0 1px 3px rgba(0,0,0,.25)}
/* Aktive/Primär-Buttons sind grau, nicht rot (Nutzerwunsch 2026-08-16): Rot
bleibt nur noch für destruktive Aktionen (.loeschen). .aktion allein ist
die neutrale Sekundäraktion, .primaer die grau gefüllte Hauptaktion -
dieselbe Fläche wie die Grundauflage (var(--fg)/var(--canvas)). */
.aktion{margin-top:18px;padding:15px;border:0;border-radius:14px;
background:var(--ios-fill);color:var(--fg);font-size:16px;font-weight:400;
letter-spacing:0;text-transform:none}
.aktion:hover{background:var(--tile-2)}
.aktion.primaer{background:var(--fg);color:var(--canvas)}
.aktion.primaer:hover{filter:brightness(1.08)}
.aktion.loeschen{background:var(--ios-fill);color:var(--bad);border:0}
.aktion.loeschen:hover{background:var(--bad);color:#fff;filter:none}
.feld{min-height:44px;padding:9px 0;border-bottom:1px solid var(--ios-sep)}
.feld label{font-size:16px;color:var(--fg)}
.feld input,.feld select{background:transparent;border:0;border-radius:8px;font-size:16px;
padding:8px 4px;color:var(--fg2)}
/* Design-Audit 2026-08-13/16: die graue Flaeche ist das einheitliche Signal
"das hier kann ich aendern". Sie trugen bisher nur die Felder mit Einheit
(.mitEinheit input, z.B. "Pause bis [15] Minuten"); alles andere - <select>
wie "10.000 km"/"1 Jahr", aber auch FIN oder Kennzeichen - stand ohne Kasten
da und war von reinem Anzeigetext nicht zu unterscheiden. Jetzt bekommt
jedes editierbare Feld denselben Kasten. Ausgenommen sind Eingaben mit
eigener Optik (Schalter, Auswahlkaestchen, Regler, Dateiauswahl) - die
behalten die transparente Grundregel darueber.
.mitEinheit input braucht deshalb keine eigene Regel mehr; Breite und
Rechtsbuendigkeit dort kommen weiterhin aus audi-dashboard.css. */
.feld input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),
.feld select{background:var(--ios-fill);padding:8px 10px}
.feld input:focus,.feld select:focus{color:var(--fg)}
/* Pull-down-Button-Optik (Apple HIG): ein einzelner Abwaerts-Chevron statt
des (durch appearance:none in audi-dashboard.css bereits entfernten)
nativen Browser-Pfeils - macht die Auswahlfelder als solche erkennbar,
ohne ein eigenes Menue-Widget nachzubauen. Farbe fix auf --ios-grau, das
ist bereits die Farbe aller anderen Chevrons (.chev) in beiden Themes. */
.feld select{
padding-right:28px;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5l3.5 3.5 3.5-3.5' fill='none' stroke='%238E8E93' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat:no-repeat;
background-position:right 9px center;
background-size:10px 10px;
}
.notiz{background:var(--ios-fill);border:0;border-radius:12px;font-size:16px}
.switch{width:51px;height:31px}
.switch .track{background:var(--ios-fill);border:0}
.switch .track::before{left:2px;top:2px;width:27px;height:27px;box-shadow:0 2px 6px rgba(0,0,0,.3)}
/* Grün statt Rot: Schalter-Ein-Zustand ist kein destruktiver/Primär-Akzent,
sondern ein neutrales "an" - deckt sich mit dem echten iOS-Systemgrün, das
--ok hier ohnehin schon trägt (Design-Review §B). */
.switch input:checked+.track{background:var(--ok)}
.switch input:checked+.track::before{transform:translateX(20px)}
.pill{font-size:12px;letter-spacing:.01em;text-transform:none;padding:5px 11px;
border:0;background:var(--ios-fill);color:var(--fg2)}
.status{padding:14px 0 2px}
.status .t{font-size:16px}
.dot{width:10px;height:10px}
/* ---------------- Zahlen (Audi CI bleibt führend) ---------------- */
.fig{letter-spacing:-.03em}
.unit{color:var(--fg3)}
.balken{height:6px;background:var(--ios-fill)}
.balken i{background:var(--ok)}
.quad .l{font-size:11px;letter-spacing:.01em;text-transform:none}
.legend span{font-size:12.5px}
/* ---------------- Menüs und Popups ---------------- */
.bildmenu,.sdpopup{border:0;border-radius:14px;background:rgba(40,44,52,.92);
-webkit-backdrop-filter:saturate(180%) blur(22px);backdrop-filter:saturate(180%) blur(22px);
box-shadow:0 12px 40px rgba(0,0,0,.5)}
:host([data-theme="tag"]) .bildmenu,:host([data-theme="tag"]) .sdpopup{background:rgba(250,250,252,.92)}
.bildmenu button{padding:13px 16px;font-size:16px;border-bottom:1px solid var(--ios-sep)}
/* ---------------- Tab-Leiste ---------------- */
.tabbar{border-top:1px solid var(--ios-sep);padding-bottom:26px;
background:rgba(12,16,20,.72);
-webkit-backdrop-filter:saturate(180%) blur(24px);backdrop-filter:saturate(180%) blur(24px)}
:host([data-theme="tag"]) .tabbar{background:rgba(242,242,247,.78)}
.tab{border-top:0;margin-top:0;padding:7px 2px 6px;gap:3px;
font-size:10px;letter-spacing:0;text-transform:none;color:var(--ios-grau)}
/* Beschriftung folgt weiter dem Schalter in den Einstellungen.
:not(.tabpille) ist Pflicht: die Icon-Huelle ist ebenfalls ein <span> (span
class="tabpille"), ein blosses ".tab span" traf also nicht nur das Label
sondern auch die Pille selbst - das Icon verschwand mit, nicht nur der
Text. Genau das war der gemeldete "ganze Menueleiste verschwindet"-Bug. */
.tabbar.ohne .tab span:not(.tabpille){display:none}
.tab svg{width:26px;height:26px;stroke-width:1.5}
/* myAudi-Menueleiste: das aktive Symbol sitzt hell in einer gefuellten Pille,
die uebrigen bleiben grau - kein farbiger Tint, kein Oberkantenstrich. */
.tabpille{width:63px;height:39px;border-radius:20px}
.tab.on{color:var(--fg);border-top-color:transparent}
.tab.on .tabpille{background:rgba(255,255,255,.14)}
:host([data-theme="tag"]) .tab.on .tabpille{background:rgba(0,0,0,.07)}
/* Ist die Beschriftung sichtbar (nicht .tabbar.ohne), soll die graue Flaeche
Icon UND Text umschliessen statt nur die enge Pille ums Icon - die Flaeche
wandert dafuer vom Icon-Symbol auf den ganzen Tab-Knopf. Im reinen
Icon-Modus bleibt die enge Pille wie zuvor. */
.tabbar:not(.ohne) .tab.on{background:rgba(255,255,255,.14);border-radius:14px}
:host([data-theme="tag"]) .tabbar:not(.ohne) .tab.on{background:rgba(0,0,0,.07)}
.tabbar:not(.ohne) .tab.on .tabpille{background:none}
.tabbar.ohne .tab{padding:11px 2px 10px}
:focus-visible{outline:2px solid var(--ios-tint);outline-offset:2px}
/* Die Wischzeile muss das darunterliegende Loeschen-Feld verdecken - das
halbtransparente --tile der Auflage liess es durchscheinen. */
.swipe-wrap .swipe-content{background:var(--tile-deckend)}
.mapbox,.mapbox .leaflet-container{background:var(--tile-deckend)}
/* ==================================================================
Große Bildschirme (Tablet, Desktop): dieselbe App, aber die Tab-Leiste
wird zur seitlichen Navigation und der Inhalt bekommt eine ruhige,
begrenzte Spalte. Container-Abfrage statt @media, weil die App in einem
eigenen Rahmen sitzt und nicht die Fensterbreite messen darf. Schwelle 860 px.
================================================================== */
/* Klon der Ringe in der Menueleiste - nur im Spaltenlayout sichtbar */
.navmarke{display:none}
@container (min-width:860px){
/* Ab hier keine schwebende Telefonkarte mehr - die 880px-Deckelung faellt,
die App fuellt die tatsaechliche (weiterhin viewport-verankerte) Hoehe. */
:host > div{height:calc(100dvh - 24px)}
.phone{display:grid;grid-template-columns:264px minmax(0,1fr);
grid-template-rows:auto auto minmax(0,1fr)}
/* max-width:860px muss zu main#view passen, sonst treibt der Flex-Inhalt
(.head{flex:1} schiebt .profilbtn nach rechts) das Zahnrad bis an den
Rand der vollen Grid-Spalte statt an den Rand der Inhaltsspalte darunter -
auf breiten Bildschirmen klaffte da eine sichtbare Luecke. */
.topbar{grid-column:2;grid-row:1;padding:38px 44px 8px;max-width:860px}
.phone:has(.back.on) .topbar{grid-column:2;grid-row:1;padding:34px 44px 8px;justify-content:flex-start;max-width:860px}
.phone:has(.back.on) .head{align-items:flex-start}
.phone:has(.back.on) .title{font-size:26px;text-align:left}
.title{font-size:34px}
.ptr{grid-column:2;grid-row:2;max-width:860px}
main#view{--seitenrand:44px;grid-column:2;grid-row:3;padding:8px var(--seitenrand) 48px;max-width:860px}
.tabbar{grid-column:1;grid-row:1 / 4;display:flex;flex-direction:column;gap:4px;
align-content:start;width:264px;padding:38px 14px 20px;
border-top:0;border-right:1px solid var(--ios-sep);background:none;
-webkit-backdrop-filter:none;backdrop-filter:none}
.tab{flex-direction:row;justify-content:flex-start;align-items:center;gap:14px;
padding:12px 14px;border-radius:12px;font-size:15px;letter-spacing:0}
.tab span{display:block}
.tab svg{width:22px;height:22px}
.tabpille{width:auto;height:auto;border-radius:0;background:none}
.tab.on .tabpille{background:none}
.tab:hover{background:var(--tile)}
/* Aktives Symbol: dieselbe Farbe wie die Kopfzeile (--fg), nicht mehr Rot -
Fläche bleibt neutral hinterlegt statt rosa (Design-Review §B). */
.tab.on{background:var(--ios-fill);color:var(--fg)}
.tabbar.ohne .tab span{display:block}
.tabbar.ohne .tab{padding:12px 14px;gap:14px}
.tabbar.ohne .tab svg{width:22px;height:22px}
/* Marke am Kopf der Seitenspalte, dafuer keine Ringe in der Kopfzeile
(Design-Audit: die Marke soll nur einmal im Bild stehen). */
.navmarke{display:block;margin:2px 0 26px 14px;color:var(--fg)}
.navmarke svg{width:54px;height:auto;display:block}
/* Grosser Bildschirm: Marke am Kopf der Spalte, Zahnrad wieder oben rechts */
.profilbtn{display:inline-flex}
/* Die Kopf-Ringe setzt die App per Inline-Stil (display:inline-flex),
deshalb hier mit !important. */
.rings{display:none !important}
/* Nur die Box selbst hoeher setzen, nicht jedes Element mit .carfix: die
Klasse haengt auch am <img> und am Platzhalter-<div> (siehe
bildMitPlatzhalter()). Der Platzhalter ist position:absolute;inset:0 -
eine feste Hoehe schlaegt dort das bottom:0 und machte ihn 260px hoch in
einer 132px hohen, overflow:hidden-Box (leerer Zustand), also sichtbar
abgeschnitten. Bild und Platzhalter fuellen die Box ohnehin per 100%. */
.bildbox.carfix{height:260px}
.bildbox.carfix.klein{height:220px}
.bildgrid{grid-template-columns:repeat(4,1fr)}
.quad{gap:14px}
/* Design-Audit 2026-08-13: .setup-popup/.sdpopup/.sheet/.standortmenu sind
position:absolute relativ zu .phone (der vollen Rasterbreite, nicht nur
der Inhaltsspalte) mit left/right auf einen festen Abstand - auf breiten
Bildschirmen ergab das einen fast bildschirmbreiten Dialog statt einer
ruhigen Karte. max-width + margin:auto zentriert sie innerhalb des
bestehenden left/right-Abstands, ohne die transform-basierte
Einblend-Animation dieser Elemente zu beruehren. */
.setup-popup,.sdpopup,.sheet,.beleg-popup{max-width:560px;margin-left:auto;margin-right:auto}
.standortmenu{max-width:640px;margin-left:auto;margin-right:auto}
}
/* Fahrzeugbild randlos wie in einer Fahrzeug-App: der Platzhalter bleibt
sichtbar, aber ohne technischen Strichrahmen. */
.szene{margin-bottom:20px}
/* --tile statt --ios-fill waere hier naheliegend, faellt im Tag-Theme aber
auf reines Weiss (#FFFFFF) - ununterscheidbar von Canvas und den meisten
Kacheln drumherum, die Box verschwand. --ios-fill ist in beiden Themes ein
deutlich sichtbares transluzentes Grau, dieselbe Konvention wie
.feld select/.aktion ("das hier ist eine Flaeche"). */
.bildbox.bild-fehlt .platzhalter{border:0;background:var(--ios-fill);border-radius:var(--r-tile)}
.platzhalter-datei{display:none}
/* Zeilen mit Wert plus Statuspunkt rechts (Sicherheitscheck) ruhiger setzen */
.row dd .dot{width:8px;height:8px}
@@ -0,0 +1,37 @@
/* ================================================================
Audi-Dashboard — Lade-Stub für das Panel
================================================================
Home Assistant lädt diese Datei als Modul des Sidebar-Eintrags. Ihre
einzige Aufgabe ist, den eigentlichen Code nachzuladen.
Der Cache-Brecher steht in der eigenen URL: die Integration hängt beim
Anmelden des Panels die installierte Version als ?v=… an (siehe
_panel_anmelden in __init__.py). Diese Datei reicht sie unverändert an
audi-dashboard-app.js weiter. Jede neue Version ist damit aus Sicht des
Browsers eine neue URL und wird nie aus einem alten Cache bedient - und
umgekehrt lädt eine unveränderte Installation nichts unnötig neu.
Das ersetzt die frühere Lösung, bei der diese Datei erst
audi-dashboard-version.json mit cache:"no-store" holen musste, um an eine
Zahl zu kommen. Die brauchte es, weil /local/ von Home Assistant 31 Tage
lang gecacht wird und die Datei sonst nie wieder aktualisiert worden wäre.
Als Integration liefert die App ihre Dateien selbst aus - mit
cache_headers=False und mit der Version schon in der URL. Eine zusätzliche
Netzwerkanfrage bei jedem Seitenaufruf ist dafür nicht mehr nötig.
Bekannte HA-Eigenheit (an der Testinstanz reproduziert, nicht vermutet):
ha-panel-custom cached geladene Skripte nur für den alten js_url-Typ,
nicht für module_url. Bei einem direkten/harten Seitenaufruf kann das
dazu führen, dass dieses Skript zweimal als <script type=module> eingefügt
wird, bevor das erste fertig geladen hat - beobachtet als zwei
Netzwerk-Requests und ein hängenbleibendes "Lädt …" ohne Fehler. Der
Cache unten macht das Starten robust dagegen: der zweite Aufruf wartet auf
das Ergebnis des ersten, statt selbst nochmal zu importieren. */
window.__audiDashboardStarten =
window.__audiDashboardStarten ||
(async () => {
const eigene = new URL(import.meta.url);
const version = eigene.searchParams.get("v") || Date.now();
await import(new URL(`audi-dashboard-app.js?v=${version}`, eigene).href);
})();
await window.__audiDashboardStarten;
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-02ab3f2e-9065-406b-b3a2-96f948fc1e4e" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M426.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-3d56eca2-3609-4ab9-a4bc-ffc6d9c94e2a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M426.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-3cc43eeb-ac3a-4285-9c6d-681d610de30a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path id="uuid-b98eb56c-eb39-485f-8e3f-36f1bc8fa622" d="M408.4969,59l16.0201-44h-24.75l-80.2974,39.2816-6.4489,17.7184h74.25l-4.7316,13h16.5s4.7316-13,4.7316-13h16.4977l4.7316-13h-16.5031ZM392.0022,59h-53.6829l64.966-31h0l-11.2831,31Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-887f53b2-a25f-4c1a-b207-be4bc0e6cad6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path id="uuid-2c6229e3-766a-4640-9e66-076dd5bbca77" d="M408.4969,59l16.0201-44h-24.75l-80.2974,39.2816-6.4489,17.7184h74.25l-4.7316,13h16.5s4.7316-13,4.7316-13h16.4977l4.7316-13h-16.5031ZM392.0022,59h-53.6829l64.966-31h0l-11.2831,31Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-bb9d6aae-5c61-449e-a3f7-9dc3eca29a10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 427 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M324.3052,54l14.1948-39h88.5l-4.7316,13h-72l-4.7316,13h55.5c16.5,0,23.2243,9,18.4927,22-5.8003,15.9362-9.6585,22-34.4519,22h-47.5555c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h47.5555c9.4358,0,11.1533-3.3211,13.2203-9,2.3539-6.4674-.3962-9-6.7243-9h-72Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-78d414ea-ca1f-4f07-a6dd-815839547cb5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 427 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M324.3052,54l14.1948-39h88.5l-4.7316,13h-72l-4.7316,13h55.5c16.5,0,23.2243,9,18.4927,22-5.8003,15.9362-9.6585,22-34.4519,22h-47.5555c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h47.5555c9.4358,0,11.1533-3.3211,13.2203-9,2.3539-6.4674-.3962-9-6.7243-9h-72Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-da9a622c-841b-4217-8357-91e7b2ea0fa7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M434.7542,37.5c4.6956-12.9009-4.4207-22.5-18.3107-22.5h-51.5045c-26.0165,0-28.7311,6.1298-33.8705,20.25l-9.5543,26.25c-3.3206,9.1232-.1075,23.5,17.9468,23.5h48.5c25.7614,0,32.1276-7.2191,37.2344-21.25,5.6993-15.6587-5.968-21.25-18.7656-21.25h-51.5c-3.8594,0-7.5554.4423-10.3879,1.0658l3.0266-8.3158c1.6863-4.6331,2.6389-7.25,12.6389-7.25h51.5046c4.0569,0,8.7417,3.4572,6.5422,9.5h16.5ZM350.198,55.5h51.5c6.2949,0,9.3513,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-51.5001c-8.4935,0-7.6956-6.3314-6.1783-10.5,2.1838-6,5.1783-6,12.1838-6Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-eab43470-9ed9-4ef3-88a5-73d45fc6a05a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M434.7542,37.5c4.6956-12.9009-4.4207-22.5-18.3107-22.5h-51.5045c-26.0165,0-28.7311,6.1298-33.8705,20.25l-9.5543,26.25c-3.3206,9.1232-.1075,23.5,17.9468,23.5h48.5c25.7614,0,32.1276-7.2191,37.2344-21.25,5.6993-15.6587-5.968-21.25-18.7656-21.25h-51.5c-3.8594,0-7.5554.4423-10.3879,1.0658l3.0266-8.3158c1.6863-4.6331,2.6389-7.25,12.6389-7.25h51.5046c4.0569,0,8.7417,3.4572,6.5422,9.5h16.5ZM350.198,55.5h51.5c6.2949,0,9.3513,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-51.5001c-8.4935,0-7.6956-6.3314-6.1783-10.5,2.1838-6,5.1783-6,12.1838-6Z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-441d88e6-447f-439d-bb01-ecd138048170" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><polygon id="uuid-04405ac0-17bc-4ab9-8f1b-d9c06ed1387e" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-e2ec85d2-1c31-42a4-badb-6dc585c3e159" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><polygon id="uuid-140489d1-1cc4-4640-9981-d5f2efde7a30" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-6be63f45-3fa3-45ba-97e7-17573e0bd3f7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M310.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-bcf865bd-1403-4135-bc2a-39f0ea8693ef" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M310.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-dcc2e61f-be44-4609-aa28-1b6186c8cb58" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><polygon points="276.2715 46.0815 254.8152 46.0815 296.6135 85 318.0698 85 276.2715 46.0815" style="fill:#fff;"/><path d="M312.8315,37.5c4.6956-12.9009-4.4207-22.5-18.3105-22.5h-54.5c-26.0165,0-28.731,6.1298-33.8704,20.25l-9.5543,26.25c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c8.5029,0,14.5557-.7916,19.0469-2.3499l-11.4382-10.6501h-57.377c-8.4937,0-7.6956-6.3314-6.1783-10.5l9.5542-26.25c1.6863-4.633,2.6388-7.25,12.6388-7.25h54.5c4.0569,0,8.7417,3.4573,6.5422,9.5l-5.8916,16.1871,12.3236,11.4745,10.068-27.6617Z" style="fill:#fff;"/><polygon id="uuid-4eb1f8b9-3e2f-414b-927c-5027877a6367" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-dcb70d1d-da3e-4814-a4c9-ff91d6217f07" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><polygon points="276.2715 46.0815 254.8152 46.0815 296.6135 85 318.0698 85 276.2715 46.0815"/><path d="M312.8315,37.5c4.6956-12.9009-4.4207-22.5-18.3105-22.5h-54.5c-26.0165,0-28.731,6.1298-33.8704,20.25l-9.5543,26.25c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c8.5029,0,14.5557-.7916,19.0469-2.3499l-11.4382-10.6501h-57.377c-8.4937,0-7.6956-6.3314-6.1783-10.5l9.5542-26.25c1.6863-4.633,2.6388-7.25,12.6388-7.25h54.5c4.0569,0,8.7417,3.4573,6.5422,9.5l-5.8916,16.1871,12.3236,11.4745,10.068-27.6617Z"/><polygon id="uuid-76d49135-5520-455b-8e33-d0d14e5c227c" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,8 @@
<svg id="Shell_x5F_2012_x5F_PECTEN_x5F_RGB" version="1.1" viewBox="0 0 24 24" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<rect x="-12.1" y="-103.7" width="141.7" height="141.7" fill="none"/>
<g transform="matrix(.22054 0 0 .22054 -.94555 -.53756)">
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4 19.6 0 35.4 15.9 35.4 35.4 0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1z" fill="none" stroke="#fff" stroke-miterlimit="40" stroke-width="7"/>
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4 19.6 0 35.4 15.9 35.4 35.4 0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1z" fill="#fbce07"/>
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4s35.4 15.9 35.4 35.4c0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1zm0.6-6.2h-13.1l-1.3-10.6-10.8-7.8c-0.3-1.2-0.5-2.5-0.5-3.8 0-2.1 0.4-4.2 1.3-6.1l19.1 19.3-18.5-22.9c0.7-3 2.3-5.6 4.6-7.7l16.6 28.7-14.7-31.3c1.9-2.5 4.6-4.4 7.6-5.3l10.2 35.6-7.4-37.1c2.3-1.3 4.8-1.9 7.4-1.9 0.5 0 1.1 0 1.6 0.1l1.5 38.5 1.5-38.5c0.5-0.1 1.1-0.1 1.6-0.1 2.6 0 5.1 0.7 7.4 1.9l-7.4 37.1 10.2-35.7c3 0.9 5.7 2.8 7.6 5.3l-14.8 31.4 16.6-28.7c2.3 2 3.9 4.7 4.6 7.7l-18.5 22.9 19.1-19.3c0.9 1.9 1.3 4 1.3 6.1 0 1.3-0.2 2.5-0.5 3.8l-10.8 7.8-1.3 10.6h-13l-2 1.4c-0.5 0.4-1.1 0.5-1.7 0.5s-1.2-0.2-1.7-0.5z" fill="#dd1d21" fill-rule="evenodd"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,375 @@
"""Nachträglicher Import vergangener Zeiträume aus dem Home-Assistant-Verlauf
(Einstellungen -> "Daten importieren aus Home Assistant").
WOZU
----
Fahrterkennung, Tankerkennung und Batterieverlauf arbeiten alle nur ab dem
Moment, in dem sie laufen. Alles, was das Fahrzeug gemeldet hat, BEVOR die App
lief (oder während Home Assistant aus war, oder bevor ein Sensor überhaupt
zugeordnet war), taucht in den Beständen der App deshalb nie auf - obwohl der
recorder es längst aufgezeichnet hat. Dieser Import schließt genau diese
Lücke: er liest denselben Verlauf, den die Live-Erkennung sonst in Echtzeit
sieht, und leitet daraus rückwirkend dieselben Datensätze ab.
GRENZE, DIE MAN KENNEN MUSS
---------------------------
Weiter zurück als der recorder aufbewahrt, geht es nicht - was dort gelöscht
ist, ist endgültig weg. Home Assistant löscht standardmäßig nach 10 Tagen;
recorder_snippet.yaml hebt das auf ein Jahr an. Der Import meldet deshalb im
Ergebnis mit, ab wann im gewählten Zeitraum überhaupt Daten vorlagen
(`ab_wann_daten`), damit ein leeres Ergebnis nicht wie ein Fehler aussieht.
DOPPELTE DATENSÄTZE
-------------------
Der Import ist absichtlich mehrfach ausführbar (überlappende Zeiträume,
zweiter Versuch nach einem Abbruch): jede erzeugte Fahrt wird gegen die
bereits vorhandenen geprüft und übersprungen, wenn sich ihr Zeitraum mit einer
bestehenden Fahrt überschneidet - egal ob die live erkannt, von Hand angelegt
oder aus einem früheren Import stammt. Tankvorgänge werden über ein
Zeitfenster (TANK_DUBLETTE_MIN) entdoppelt, Batteriewerte über den Tag als
Schlüssel (dort führt die Ablage min/max ohnehin zusammen, statt Zeilen zu
vervielfachen).
Erzeugte Datensätze tragen `source: "import"` - dieselbe Rolle wie "ha" (live
erkannt), "manual" (von Hand) und "auto" (Tankerkennung), damit später
nachvollziehbar bleibt, woher ein Eintrag stammt.
"""
from __future__ import annotations
import datetime
import logging
from typing import TYPE_CHECKING
from .fahrterkennung import leere_fahrt, pausenzeit_sekunden
from .tankerkennung import leerer_tankvorgang, schwelle_prozent
from .verlauf import Verlaufspunkt, verlauf_lesen, wert_bei, zahl
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
# Zwei Tankvorgänge innerhalb dieser Spanne gelten als derselbe - schützt
# gegen Dubletten, wenn derselbe Zeitraum zweimal importiert wird oder sich
# Import und Live-Erkennung am Rand überschneiden.
TANK_DUBLETTE_MIN = 90
# Fahrten, die kürzer sind, sind Zündung-an-ohne-Fahrt (Radio, Tür öffnen mit
# Zündung, Diagnose) - die Live-Erkennung legt sie zwar an, im Rückblick
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
MINDESTDAUER_S = 60
def als_zeit(wert: object) -> datetime.datetime | None:
"""Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone.
Ohne Zeitzone gilt die lokale Zeit von Home Assistant - der Nutzer wählt
im Formular schließlich Ortszeit, keine UTC."""
if not wert:
return None
ts = datetime.datetime.fromisoformat(str(wert))
if ts.tzinfo is None:
ts = ts.astimezone()
return ts.astimezone(datetime.UTC)
def _fahrtfenster(
zuendung_verlauf: list[Verlaufspunkt], pausenzeit_s: int
) -> list[tuple[datetime.datetime, datetime.datetime]]:
"""Aus dem Zündungsverlauf die Zeiträume, in denen gefahren wurde.
Zwei Schritte, die zusammen die Pausenregel aus §7.1 nachbilden: erst
jeden zusammenhängenden "on"-Abschnitt sammeln, dann benachbarte
Abschnitte verschmelzen, deren Lücke kürzer als die Pausenzeit ist. Genau
das tut die Live-Erkennung über die abbrechbare Wartezeit, nur eben im
Nachhinein und ohne Warten."""
roh: list[tuple[datetime.datetime, datetime.datetime]] = []
offen: datetime.datetime | None = None
for ts, wert in zuendung_verlauf:
an = str(wert).lower() in ("on", "true", "1")
if an and offen is None:
offen = ts
elif not an and offen is not None:
roh.append((offen, ts))
offen = None
# Ein am Ende des Zeitraums noch offener Abschnitt wird verworfen: die
# Fahrt ist zu diesem Zeitpunkt noch nicht beendet, ihr Ende läge hinter
# dem gewählten Fenster. Sie beim Fensterende abzuschneiden würde eine
# Fahrt mit erfundener Endzeit erzeugen.
if not roh:
return []
verschmolzen = [roh[0]]
for start, ende in roh[1:]:
vorheriger_start, vorheriges_ende = verschmolzen[-1]
if (start - vorheriges_ende).total_seconds() < pausenzeit_s:
verschmolzen[-1] = (vorheriger_start, ende)
else:
verschmolzen.append((start, ende))
return verschmolzen
def _ueberschneidet(
start: datetime.datetime, ende: datetime.datetime, bestehende: list[dict]
) -> bool:
"""True, wenn sich [start, ende] mit einer bereits erfassten Fahrt
überschneidet. Verhindert Dubletten beim wiederholten Import."""
for f in bestehende:
try:
f_start = datetime.datetime.fromisoformat(f.get("ts_start")) # type: ignore[arg-type]
f_ende = datetime.datetime.fromisoformat(f.get("ts_end")) # type: ignore[arg-type]
except (TypeError, ValueError):
continue
if f_start.tzinfo is None or f_ende.tzinfo is None:
continue
if start < f_ende and f_start < ende:
return True
return False
async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"""Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/
Zielkoordinaten aus den übrigen Verläufen ergänzt."""
fenster = _fahrtfenster(verlaeufe["zuendung"], await pausenzeit_sekunden(k))
if not fenster:
return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0}
bestehende = await k.ablage.fahrten_lesen()
km_verlauf = verlaeufe["km"]
lat_verlauf = verlaeufe["lat"]
lon_verlauf = verlaeufe["lon"]
angelegt = uebersprungen = zu_kurz = 0
neue: list[dict] = []
for f_start, f_ende in fenster:
dauer_s = int((f_ende - f_start).total_seconds())
if dauer_s < MINDESTDAUER_S:
zu_kurz += 1
continue
if _ueberschneidet(f_start, f_ende, bestehende):
uebersprungen += 1
continue
odo_start = wert_bei(km_verlauf, f_start)
odo_end = wert_bei(km_verlauf, f_ende)
distanz = None
if odo_start is not None and odo_end is not None and odo_end >= odo_start:
distanz = round(odo_end - odo_start, 1)
durchschnitt = None
if distanz is not None and dauer_s > 0:
durchschnitt = round(distanz / (dauer_s / 3600.0), 1)
fahrt = leere_fahrt(f_start, f_ende, "import")
fahrt.update({
"distance_km": distanz,
"km_quelle": "sensor" if distanz is not None else None,
"odo_start": odo_start,
"odo_end": odo_end,
"avg_speed_kmh": durchschnitt,
"start_lat": wert_bei(lat_verlauf, f_start),
"start_lon": wert_bei(lon_verlauf, f_start),
"end_lat": wert_bei(lat_verlauf, f_ende),
"end_lon": wert_bei(lon_verlauf, f_ende),
"status": "vollständig" if distanz is not None else "offen",
})
neue.append(fahrt)
bestehende.append(fahrt)
angelegt += 1
await k.ablage.fahrten_ergaenzen(neue)
return {"angelegt": angelegt, "uebersprungen": uebersprungen, "zu_kurz": zu_kurz}
async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"""Tankvorgänge aus dem Füllstandsverlauf - dieselbe Tiefststand-Logik wie
in der Live-Erkennung: jeder Anstieg über die Schwelle gegen den zuletzt
gesehenen Tiefststand ist ein Tankvorgang, nicht jeder Anstieg gegen den
unmittelbar vorherigen Wert."""
verlauf = verlaeufe["tank"]
if not verlauf:
return {"angelegt": 0, "uebersprungen": 0}
schwelle = schwelle_prozent(await k.ablage.profil_lesen())
km_verlauf = verlaeufe["km"]
bestehende = await k.ablage.tankvorgaenge_lesen()
fenster_s = TANK_DUBLETTE_MIN * 60
bekannte_zeiten: list[datetime.datetime] = []
for t in bestehende:
try:
ts = datetime.datetime.fromisoformat(t.get("ts")) # type: ignore[arg-type]
except (TypeError, ValueError):
continue
if ts.tzinfo is not None:
bekannte_zeiten.append(ts)
angelegt = uebersprungen = 0
neue: list[dict] = []
tiefststand: float | None = None
for ts, wert in verlauf:
aktuell = zahl(wert)
if aktuell is None:
continue
if tiefststand is None or aktuell <= tiefststand:
tiefststand = aktuell
continue
if aktuell - tiefststand < schwelle:
continue
if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
uebersprungen += 1
tiefststand = aktuell
continue
odometer_km = wert_bei(km_verlauf, ts)
tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
tankvorgang["odometer_km"] = odometer_km
tankvorgang["distance_km"] = _distanz_zum_vorherigen(
ts.isoformat(), odometer_km, neue + bestehende
)
neue.append(tankvorgang)
bekannte_zeiten.append(ts)
angelegt += 1
tiefststand = aktuell
await k.ablage.tankvorgaenge_ergaenzen(neue)
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
def _distanz_zum_vorherigen(
eigene_ts: str, odometer_km: float | None, kandidaten: list[dict]
) -> float | None:
""""Gefahren seit der letzten Tankung" heißt: seit der letzten Tankung VOR
dieser hier - nicht seit der zeitlich jüngsten überhaupt.
Beim Import eines vergangenen Zeitraums liegen im Bestand regelmäßig
bereits neuere Tankvorgänge; die als Bezug zu nehmen ergäbe eine negative
Strecke (und damit, nach der Prüfung unten, gar keine)."""
if odometer_km is None:
return None
vorheriger: tuple[str, float] | None = None
for t in kandidaten:
t_ts = t.get("ts") or ""
if t.get("odometer_km") is None or not t_ts or t_ts >= eigene_ts:
continue
if vorheriger is None or t_ts > vorheriger[0]:
vorheriger = (t_ts, t["odometer_km"])
if vorheriger is None:
return None
distanz = round(odometer_km - vorheriger[1], 1)
return None if distanz < 0 else distanz
async def _batterie_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"""Tagesminimum/-maximum der 12V-Spannung je Tag des Zeitraums.
Die Ablage führt bestehende und neue Werte pro Tag zusammen (min bleibt
min, max bleibt max), deshalb braucht es hier keine eigene
Dubletten-Prüfung: ein zweiter Import desselben Zeitraums verändert die
Einträge nicht mehr."""
verlauf = verlaeufe["batterie"]
if not verlauf:
return {"tage": 0}
tage: dict[str, dict] = {}
for ts, wert in verlauf:
spannung = zahl(wert)
if spannung is None:
continue
tag = ts.date().isoformat()
eintrag = tage.get(tag)
if eintrag is None:
tage[tag] = {"min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts}
continue
if spannung < eintrag["min"]:
eintrag["min"] = spannung
eintrag["min_ts"] = ts
if spannung > eintrag["max"]:
eintrag["max"] = spannung
eintrag["max_ts"] = ts
for tag in sorted(tage):
werte = tage[tag]
await k.ablage.batterieverlauf_tageswert_aktualisieren(
tag, werte["min_ts"].isoformat(), werte["min"]
)
await k.ablage.batterieverlauf_tageswert_aktualisieren(
tag, werte["max_ts"].isoformat(), werte["max"]
)
return {"tage": len(tage)}
async def importieren(k: Koordinator, start: object, ende: object) -> None:
"""Liest den Verlauf im gewählten Zeitraum und leitet daraus Fahrten,
Tankvorgänge und Batteriewerte ab.
Der Fortschritt geht über die Entität sensor.audi_dashboard_import_status
an die Oberfläche - ein Dienstaufruf kehrt sofort zurück und kann kein
Ergebnis mitbringen."""
await k.ablage.ordner_sicherstellen()
try:
von = als_zeit(start)
bis = als_zeit(ende)
except ValueError as fehler:
_LOGGER.error("Import mit unlesbarem Zeitraum aufgerufen (%s)", fehler)
await k.import_status_veroeffentlichen("fehler", {"meldung": "Zeitraum nicht lesbar"})
return
if von is None or bis is None or von >= bis:
_LOGGER.warning("Import ohne gültigen Zeitraum aufgerufen")
await k.import_status_veroeffentlichen("fehler", {
"meldung": "Bitte einen Zeitraum wählen, dessen Ende nach dem Start liegt."
})
return
await k.import_status_veroeffentlichen(
"laeuft", {"von": von.isoformat(), "bis": bis.isoformat()}
)
_LOGGER.info("Import gestartet für %s bis %s", von.isoformat(), bis.isoformat())
werte = k.zuordnung.werte
verlaeufe = {
"zuendung": await verlauf_lesen(k.hass, werte.ZUENDUNG_SENSOR, von, bis),
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis),
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis),
"batterie": await verlauf_lesen(k.hass, werte.BATTERIE_SENSOR, von, bis),
"lat": await verlauf_lesen(k.hass, werte.STANDORT_LAT_SENSOR, von, bis),
"lon": await verlauf_lesen(k.hass, werte.STANDORT_LON_SENSOR, von, bis),
}
# Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas
# aufgezeichnet war - damit ein leeres Ergebnis erklärbar wird
# ("recorder reicht nur bis ...") statt wie ein Fehler auszusehen.
frueheste = None
for punkte in verlaeufe.values():
if punkte and (frueheste is None or punkte[0][0] < frueheste):
frueheste = punkte[0][0]
fahrten = await _fahrten_importieren(k, verlaeufe)
tank = await _tankvorgaenge_importieren(k, verlaeufe)
batterie = await _batterie_importieren(k, verlaeufe)
await k.fahrten_veroeffentlichen()
await k.tankvorgaenge_veroeffentlichen()
await k.batterieverlauf_veroeffentlichen()
ergebnis = {
"von": von.isoformat(),
"bis": bis.isoformat(),
"ab_wann_daten": frueheste.isoformat() if frueheste else None,
"fahrten_angelegt": fahrten["angelegt"],
"fahrten_uebersprungen": fahrten["uebersprungen"],
"fahrten_zu_kurz": fahrten["zu_kurz"],
"tankvorgaenge_angelegt": tank["angelegt"],
"tankvorgaenge_uebersprungen": tank["uebersprungen"],
"batterie_tage": batterie["tage"],
}
await k.import_status_veroeffentlichen("fertig", ergebnis)
_LOGGER.info("Import abgeschlossen - %s", ergebnis)
@@ -0,0 +1,315 @@
"""Der Koordinator: hält den Zustand der App und verdrahtet alles miteinander.
Was hier zusammenkommt, lag in der pyscript-Fassung über die Dateien verteilt,
weil es dort keinen Ort dafür gab: die Dekoratoren @service, @state_trigger und
@time_trigger banden ihre Registrierung direkt an die Funktion daneben. Eine
Integration hat einen Lebenszyklus (Einrichten, Entladen, Neu-Laden), und
genau deshalb braucht es eine Stelle, die weiß, was sie angemeldet hat und es
wieder abmelden kann.
DREI DINGE, DIE DIESER AUFBAU MÖGLICH MACHT UND DIE ES VORHER NICHT GAB
-----------------------------------------------------------------------
1. Zustandsbeobachter lassen sich zur Laufzeit neu binden. Früher backte
@state_trigger die Entity-ID einmalig beim Modul-Laden ein - ein im
Setup-Menü geänderter Zündungs-, Kilometer- oder Tanksensor wirkte erst
nach einem HA-Neustart, und die Oberfläche musste einen eigenen
Neustart-Knopf dafür anbieten. trigger_neu_binden() ersetzt beides.
2. Der Zwischenstand einer laufenden Fahrt überlebt einen Neustart. Er liegt
in einem Store statt nur im Arbeitsspeicher; dasselbe gilt für den
Tank-Tiefststand. Beides war als bekannte Schwäche vermerkt.
3. Die Nutzlast landet nicht mehr in der Recorder-Datenbank (siehe sensor.py).
Der Weg von hier zur Oberfläche: `setzen()` legt Zustand und Nutzlast ab und
schickt ein Signal; die Entität in sensor.py hört darauf und schreibt sich
neu. Die Entitäten holen sich ihren Wert beim Anlegen selbst aus `werte` -
deshalb darf schon veröffentlicht werden, bevor die Plattform überhaupt
eingerichtet ist.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
import os
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, Event, EventStateChangedData, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_change,
async_track_time_interval,
)
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.storage import Store
from . import batterie, fahrterkennung, reifen, screening, sicherung, tankerkennung
from .ablage import Ablage
from .const import (
E_APP_VERSION,
E_BATTERIEVERLAUF,
E_BELEG_ERGEBNIS,
E_FAHRTEN,
E_FAHRZEUGSTATUS,
E_IMPORT_STATUS,
E_PROFIL,
E_REIFEN_AKTIV,
E_REIFEN_SOMMER,
E_REIFEN_WINTER,
E_TANKVORGAENGE,
E_ZUORDNUNG,
SIGNAL_AKTUALISIERT,
)
from .veroeffentlichung import fahrzeugstatus
from .zuordnung import Zuordnung
_LOGGER = logging.getLogger(__name__)
# Wie oft der Live-Fahrzeugstatus neu aus den Quellsensoren gelesen wird.
TAKT_STATUS = datetime.timedelta(seconds=20)
# Profil/Fahrten/Tankvorgänge ändern sich normalerweise nur durch Aktionen,
# die ohnehin sofort selbst veröffentlichen. Dieser Takt ist nur das
# Sicherheitsnetz für Änderungen direkt im Dateisystem.
TAKT_DATEN = datetime.timedelta(seconds=60)
TAKT_BATTERIE = datetime.timedelta(minutes=5)
STORE_VERSION = 1
class Koordinator:
"""Zustand, Zeittakte und Zustandsbeobachter der Integration."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, version: str) -> None:
self.hass = hass
self.entry = entry
self.version = version
self.ablage = Ablage(hass, hass.config.path("audi_dashboard"))
self.zuordnung = Zuordnung(self.ablage)
# Was die Entitäten anzeigen: Schlüssel -> (Zustand, Nutzlast).
self.werte: dict[str, tuple[str, dict | list | None]] = {}
self._store = Store[dict](hass, STORE_VERSION, f"{entry.entry_id}_laufzeit")
self.fahrt_start_ts: datetime.datetime | None = None
self.tiefststand_pct: float | None = None
self._takte: list[CALLBACK_TYPE] = []
self._beobachter: list[CALLBACK_TYPE] = []
self._ende_aufgabe: asyncio.Task | None = None
# ------------------------------------------------------------ Lebenszyklus
async def starten(self) -> None:
await self.ablage.ordner_sicherstellen()
if await self.ablage.vorlage_anlegen(
os.path.join(os.path.dirname(__file__), "vorlage", "fahrzeugprofil.json")
):
_LOGGER.info(
"Erste Einrichtung: Fahrzeugprofil aus der Vorlage angelegt. "
"Die Fahrzeugdaten werden in der App unter Einstellungen -> "
"Fahrzeug einrichten eingetragen."
)
await self.zuordnung.anwenden()
await self._laufzeit_laden()
await self.alles_veroeffentlichen()
# Der Reifenzähler schreibt beim Start einmal fort: Kilometer, die
# während eines HA-Ausfalls gefahren wurden, gehören noch dem damals
# aktiven Satz (siehe reifen.py).
await reifen.fortschreiben(self, reifen.aktueller_km(self))
self._takte_anmelden()
await self.trigger_neu_binden()
# Erst wenn Home Assistant vollständig oben ist: bis dahin haben die
# Datenquellen ihre Zustände oft noch nicht gemeldet, und eine noch
# nicht gemeldete Zündung ließe sich nicht von "aus" unterscheiden
# (siehe fahrterkennung.nach_neustart_fortsetzen).
self._takte.append(
async_at_started(self.hass, self._nach_start)
)
async def _nach_start(self, _hass: HomeAssistant) -> None:
await fahrterkennung.nach_neustart_fortsetzen(self)
async def beenden(self) -> None:
for abmelden in (*self._takte, *self._beobachter):
abmelden()
self._takte.clear()
self._beobachter.clear()
self.warte_ende_ab_abbrechen()
# ------------------------------------------------------------- Zeittakte
def _takte_anmelden(self) -> None:
self._takte.append(
async_track_time_interval(self.hass, self._takt_status, TAKT_STATUS)
)
self._takte.append(
async_track_time_interval(self.hass, self._takt_daten, TAKT_DATEN)
)
self._takte.append(
async_track_time_interval(self.hass, self._takt_batterie, TAKT_BATTERIE)
)
# Ein einzelner täglicher Termin genügt für alle Sicherungs-Intervalle
# - welche heute fällig ist, entscheidet sicherung.automatisch().
self._takte.append(
async_track_time_change(self.hass, self._takt_sicherung, hour=4, minute=0, second=0)
)
async def _takt_status(self, _jetzt: datetime.datetime) -> None:
await self.fahrzeugstatus_veroeffentlichen()
async def _takt_daten(self, _jetzt: datetime.datetime) -> None:
await self.profil_veroeffentlichen()
await self.fahrten_veroeffentlichen()
await self.tankvorgaenge_veroeffentlichen()
async def _takt_batterie(self, _jetzt: datetime.datetime) -> None:
await batterie.pruefen(self)
async def _takt_sicherung(self, _jetzt: datetime.datetime) -> None:
await sicherung.automatisch(self)
# -------------------------------------------------- Zustandsbeobachter
async def trigger_neu_binden(self) -> None:
"""Meldet die Beobachter für Zündung, Kilometerstand und Tankfüllstand
neu an - nach jeder Änderung der Sensor-Zuordnung.
Ohne zugeordnete Entität wird bewusst gar kein Beobachter angemeldet,
statt einen gegen eine nicht existierende Entity-ID zu registrieren."""
for abmelden in self._beobachter:
abmelden()
self._beobachter.clear()
werte = self.zuordnung.werte
self._beobachten(werte.ZUENDUNG_SENSOR, self._zuendung_geaendert)
self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert)
self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert)
def _beobachten(
self, entity_id: str, rueckruf: Callable[[Event[EventStateChangedData]], Coroutine[Any, Any, None]]
) -> None:
if not entity_id:
return
self._beobachter.append(
async_track_state_change_event(self.hass, [entity_id], rueckruf)
)
@staticmethod
def _zustaende(ereignis: Event[EventStateChangedData]) -> tuple[str | None, str | None]:
alt = ereignis.data["old_state"]
neu = ereignis.data["new_state"]
return (neu.state if neu else None, alt.state if alt else None)
async def _zuendung_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
neu, alt = self._zustaende(ereignis)
await fahrterkennung.zuendung_geaendert(self, neu, alt)
async def _kilometerstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
neu, alt = self._zustaende(ereignis)
if neu is None or alt is None:
return
await reifen.fortschreiben(self, reifen.aktueller_km(self))
await screening.durchfuehren(self)
async def _tankfuellstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
neu, _alt = self._zustaende(ereignis)
await tankerkennung.fuellstand_geaendert(self, neu)
# ------------------------------------------------- Laufende Fahrt merken
async def _laufzeit_laden(self) -> None:
gespeichert = await self._store.async_load() or {}
roh = gespeichert.get("fahrt_start_ts")
if roh:
try:
self.fahrt_start_ts = datetime.datetime.fromisoformat(roh)
_LOGGER.info(
"Laufende Fahrt seit %s aus dem letzten Lauf übernommen", roh
)
except ValueError:
self.fahrt_start_ts = None
self.tiefststand_pct = gespeichert.get("tiefststand_pct")
async def _laufzeit_sichern(self) -> None:
await self._store.async_save({
"fahrt_start_ts": self.fahrt_start_ts.isoformat() if self.fahrt_start_ts else None,
"tiefststand_pct": self.tiefststand_pct,
})
async def fahrt_start_setzen(self, ts: datetime.datetime | None) -> None:
self.fahrt_start_ts = ts
await self._laufzeit_sichern()
async def tiefststand_setzen(self, wert: float | None) -> None:
self.tiefststand_pct = wert
await self._laufzeit_sichern()
def warte_ende_ab(self, koroutine: Coroutine[Any, Any, None]) -> None:
self.warte_ende_ab_abbrechen()
self._ende_aufgabe = self.entry.async_create_background_task(
self.hass, koroutine, "audi_dashboard_fahrtende"
)
def warte_ende_ab_abbrechen(self) -> None:
if self._ende_aufgabe is not None and not self._ende_aufgabe.done():
self._ende_aufgabe.cancel()
self._ende_aufgabe = None
# ---------------------------------------------------------- Veröffentlichen
def setzen(
self, schluessel: str, zustand: str | float | None, daten: dict | list | None = None
) -> None:
self.werte[schluessel] = (zustand, daten)
async_dispatcher_send(self.hass, f"{SIGNAL_AKTUALISIERT}_{schluessel}")
async def profil_veroeffentlichen(self, profil: dict | None = None) -> None:
if profil is None:
profil = await self.ablage.profil_lesen()
if profil is None:
return
self.setzen(E_PROFIL, "aktuell", profil)
# Die drei Reifen-Kontrollwerte hängen am selben Profil - zusammen
# veröffentlichen, damit sie nicht auseinanderlaufen können.
# Die beiden km-Stände bleiben Zahlen (die Entitäten tragen die
# Einheit km); None heißt "noch nie gezählt" und wird von Home
# Assistant als "unknown" angezeigt - richtiger als eine erfundene 0.
stand = reifen.zustaende(profil)
self.setzen(E_REIFEN_SOMMER, stand["sommer"])
self.setzen(E_REIFEN_WINTER, stand["winter"])
self.setzen(E_REIFEN_AKTIV, str(stand["aktiv"]))
async def fahrten_veroeffentlichen(self) -> None:
self.setzen(E_FAHRTEN, "aktuell", await self.ablage.fahrten_lesen())
async def tankvorgaenge_veroeffentlichen(self) -> None:
self.setzen(E_TANKVORGAENGE, "aktuell", await self.ablage.tankvorgaenge_lesen())
async def batterieverlauf_veroeffentlichen(self) -> None:
self.setzen(E_BATTERIEVERLAUF, "aktuell", await self.ablage.batterieverlauf_lesen())
async def zuordnung_veroeffentlichen(self) -> None:
self.setzen(E_ZUORDNUNG, "aktuell", self.zuordnung.aktueller_stand())
async def fahrzeugstatus_veroeffentlichen(self) -> None:
self.setzen(E_FAHRZEUGSTATUS, "aktuell", fahrzeugstatus(self.hass, self.zuordnung.werte))
async def beleg_ergebnis_veroeffentlichen(self, daten: dict) -> None:
self.setzen(E_BELEG_ERGEBNIS, "aktuell", daten)
async def import_status_veroeffentlichen(self, zustand: str, daten: dict) -> None:
self.setzen(E_IMPORT_STATUS, zustand, daten)
async def alles_veroeffentlichen(self) -> None:
self.setzen(E_APP_VERSION, self.version, {"app": self.version})
await self.profil_veroeffentlichen()
await self.fahrten_veroeffentlichen()
await self.tankvorgaenge_veroeffentlichen()
await self.fahrzeugstatus_veroeffentlichen()
await self.batterieverlauf_veroeffentlichen()
await self.zuordnung_veroeffentlichen()
@@ -0,0 +1,14 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.23.2",
"documentation": "https://github.com/paulnothaft/audi-app/blob/main/README.md",
"issue_tracker": "https://github.com/paulnothaft/audi-app/issues",
"codeowners": ["@paulnothaft"],
"config_flow": true,
"integration_type": "service",
"single_config_entry": true,
"iot_class": "local_polling",
"dependencies": ["http", "frontend", "panel_custom", "recorder"],
"requirements": ["pypdf>=4.0.0"]
}
+121
View File
@@ -0,0 +1,121 @@
"""Reifenzähler: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum
Zeitpunkt der Fahrt aktiv war.
Früher war der Zähler = aktueller Kilometerstand minus fester Startwert je
Satz. 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 führt
reifen.saetze.<satz>.km selbst den fortlaufenden Gesamtstand, und
reifen.referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest,
gegen den die nächste Änderung als Delta gemessen wird.
Keine Abhängigkeit von total_increasing oder utility_meter: die Fortschreibung
braucht nur den zuletzt bekannten Absolutwert des Kilometerstand-Sensors. Ein
zeitweise "unavailable" Quellsensor 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 Kilometer, die
während eines HA-Ausfalls gefahren wurden, werden so noch dem damals aktiven
Satz zugerechnet.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .veroeffentlichung import zustand_oder_none
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
SAETZE = ("sommer", "winter")
def aktueller_km(k: Koordinator) -> float | None:
try:
return float(zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR)) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
async def fortschreiben(k: Koordinator, neuer_km: float | None, neuer_aktiv: str | None = None) -> 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 Kilometer dem neuen Satz
zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden."""
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.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
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
async def wechseln(k: Koordinator, satz: str | None) -> None:
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Reifensatz '%s'", satz)
return
await fortschreiben(k, aktueller_km(k), neuer_aktiv=satz)
_LOGGER.info("Aktiver Reifensatz auf '%s' umgeschaltet", satz)
async def km_setzen(k: Koordinator, satz: str | None, km: object) -> None:
"""Manuelle Korrektur des gefahrenen Kilometerstands eines Reifensatzes.
Überschreibt nur den gespeicherten Zähler selbst - referenz_odo_km (der
zuletzt verarbeitete Fahrzeug-Kilometerstand) bleibt unverändert, damit
die nächste Fortschreibung weiterhin korrekt von hier aus weiterzählt,
statt auf den neuen Wert erneut ein Delta seit dem alten Stand
aufzuschlagen."""
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Reifensatz '%s'", satz)
return
try:
wert = round(float(km), 1) # type: ignore[arg-type]
except (TypeError, ValueError):
_LOGGER.warning("Ungültiger km-Wert '%s'", km)
return
if wert < 0:
_LOGGER.warning("Negativer km-Wert '%s' abgelehnt", wert)
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.setdefault("reifen", {})
reifen.setdefault("saetze", {}).setdefault(satz, {})["km"] = wert
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("km-Stand '%s' manuell auf %s gesetzt", satz, wert)
def zustaende(profil: dict | None) -> dict[str, object]:
"""Die drei Kontrollwerte für die eigenen Reifen-Entitäten.
Das Frontend liest die Zahlen direkt aus dem veröffentlichten Profil -
diese Entitäten sind nur zur Kontrolle in Entwicklerwerkzeuge -> Zustände
und für eigene Automationen des Nutzers da."""
reifen = (profil or {}).get("reifen", {})
saetze = reifen.get("saetze", {})
return {
"sommer": saetze.get("sommer", {}).get("km"),
"winter": saetze.get("winter", {}).get("km"),
"aktiv": reifen.get("aktiv") or "unbekannt",
}
@@ -0,0 +1,135 @@
"""Zweistufiger Fahrtabschluss: offene Fahrten nachträglich mit Start- und
Endkilometerstand vervollständigen (SPECIFICATION.md §7.2).
Der Kilometerstand kommt laut Datenquelle nicht sicher mit Fahrtende, sondern
teils erst mit Beginn oder während der nächsten Fahrt. Statt auf einen festen
Zeitpunkt zu warten, wird deshalb der aufgezeichnete Verlauf des
Kilometerstand-Sensors nach dem Wert durchsucht, dessen Zeitstempel am
nächsten am Fahrtbeginn bzw. -ende liegt.
Läuft nach jedem Fahrtende und zusätzlich bei jeder Änderung des
Kilometerstand-Sensors - unabhängig vom Fahrtende-Ereignis selbst, eben weil
der Wert auch erst mit der nächsten Fahrt eintreffen kann.
VORBEHALT: Der GPS-Fallback (km_quelle="gps") ist weiterhin nicht umgesetzt,
da die Adressauflösung ein eigenes Arbeitspaket ist. Fahrten ohne passenden
Kilometerstand bleiben "offen" statt auf GPS-Distanz auszuweichen.
"""
from __future__ import annotations
import datetime
import logging
from typing import TYPE_CHECKING
from .verlauf import naechster_wert, verlauf_lesen
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
# Wie weit vor/zurück gesucht wird, ausgehend von Fahrtbeginn bzw. -ende.
# 48h deckt auch länger stehende Fahrzeuge ab, ohne bei jedem Screening die
# komplette Historie zu laden.
SUCHFENSTER = datetime.timedelta(hours=48)
def _als_zeit(wert: str | None) -> datetime.datetime | None:
if not wert:
return None
try:
return datetime.datetime.fromisoformat(wert)
except ValueError:
return None
def _vollstaendig(aenderungen: dict, fahrt: dict) -> dict:
"""Ergänzt Strecke, Quelle und Status, sobald beide Kilometerstände da
sind. Eine Fahrt ist genau dann "vollständig", wenn ihre Strecke feststeht
- "offen" heißt nicht "unterwegs", sondern "Daten fehlen noch"."""
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"
return aenderungen
async def durchfuehren(k: Koordinator) -> None:
"""Versucht, alle offenen Fahrten zu vervollständigen."""
km_sensor = k.zuordnung.werte.KM_SENSOR
fahrten = await k.ablage.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", ""))
if km_sensor:
for fahrt in offene:
await _fahrt_screenen(k, km_sensor, fahrt)
await _lueckenlos_verketten(k)
await k.fahrten_veroeffentlichen()
async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None:
start = _als_zeit(fahrt.get("ts_start"))
ende = _als_zeit(fahrt.get("ts_end"))
if start is None or ende is None:
_LOGGER.warning(
"Fahrt %s hat keinen lesbaren Zeitraum - Screening übersprungen",
fahrt.get("trip_id"),
)
return
# Ein Abruf für beide Zeitpunkte statt zwei: die Fenster überlappen sich
# bei einer normalen Fahrt ohnehin fast vollständig.
punkte = await verlauf_lesen(k.hass, km_sensor, start - SUCHFENSTER, ende + SUCHFENSTER)
if not punkte:
return
geaendert = False
if fahrt.get("odo_end") is None:
wert = naechster_wert(ende, punkte)
if wert is not None:
fahrt["odo_end"] = wert
geaendert = True
if fahrt.get("odo_start") is None:
wert = naechster_wert(start, punkte)
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")}
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
async def _lueckenlos_verketten(k: Koordinator) -> None:
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
wurde.
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
Ereignisse. Das ist der Normalfall, aber keine Garantie."""
fahrten = sorted(await k.ablage.fahrten_lesen(), key=lambda f: f.get("ts_start", ""))
geaendert: set[str] = set()
for aktuelle, naechste in zip(fahrten, fahrten[1:]):
if aktuelle.get("odo_end") is not None and naechste.get("odo_start") is None:
naechste["odo_start"] = aktuelle["odo_end"]
geaendert.add(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.add(aktuelle["trip_id"])
for fahrt in fahrten:
if fahrt["trip_id"] not in geaendert:
continue
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
+162
View File
@@ -0,0 +1,162 @@
"""Die Entitäten, über die beide Oberflächen die Daten der App lesen.
Das Frontend liest hass.states["sensor.audi_dashboard_profil"] usw. direkt und
holt sich die Nutzlast aus dem Attribut `daten` - kein Dienstaufruf mit
Rückgabewert, dessen Verhalten aus Sicht des Browsers nicht durchgängig
dokumentiert ist.
WARUM ECHTE ENTITÄTEN UND NICHT NUR EINTRÄGE IN DER ZUSTANDSMASCHINE
--------------------------------------------------------------------
Wegen einer einzigen Zeile: `_unrecorded_attributes`. Die Nutzlast dieser
Entitäten ist groß - das Fahrtenarchiv wächst über die Jahre auf Hunderte
Kilobyte. Home Assistant zeichnet Zustands-Attribute standardmäßig bei jeder
Änderung in der Recorder-Datenbank auf und warnt ab ~16 KB, dass genau das
Datenbankprobleme macht. In der pyscript-Fassung war das eine offene Flanke:
jede Veröffentlichung schrieb den kompletten Bestand erneut in die Datenbank,
alle 60 Sekunden.
Nur eine Entität, die zu einer Integration gehört, kann Attribute von der
Aufzeichnung ausnehmen. Ein roher Eintrag in der Zustandsmaschine (was
pyscripts state.set() macht) kann das nicht. Die Nutzlast geht damit
weiterhin an jede Oberfläche, landet aber nie in der Datenbank.
Die Zustände selbst sind bewusst schlicht ("aktuell"): der Inhalt steckt im
Attribut, der Zustand sagt nur, dass etwas da ist. Bei den drei
Reifen-Entitäten und der App-Version ist der Zustand dagegen der eigentliche
Wert - die sind zur Kontrolle in Entwicklerwerkzeuge -> Zustände und für
eigene Automationen des Nutzers gedacht.
"""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
DOMAIN,
E_APP_VERSION,
E_BATTERIEVERLAUF,
E_BELEG_ERGEBNIS,
E_FAHRTEN,
E_FAHRZEUGSTATUS,
E_IMPORT_STATUS,
E_PROFIL,
E_REIFEN_AKTIV,
E_REIFEN_SOMMER,
E_REIFEN_WINTER,
E_TANKVORGAENGE,
E_ZUORDNUNG,
SIGNAL_AKTUALISIERT,
)
from .koordinator import Koordinator
@dataclass(frozen=True)
class Beschreibung:
schluessel: str
name: str
icon: str
einheit: str | None = None
kategorie: EntityCategory | None = None
BESCHREIBUNGEN: tuple[Beschreibung, ...] = (
Beschreibung(E_PROFIL, "Audi Dashboard Profil", "mdi:car-info"),
Beschreibung(E_FAHRTEN, "Audi Dashboard Fahrten", "mdi:road-variant"),
Beschreibung(E_TANKVORGAENGE, "Audi Dashboard Tankvorgänge", "mdi:gas-station"),
Beschreibung(E_FAHRZEUGSTATUS, "Audi Dashboard Fahrzeugstatus", "mdi:car-connected"),
Beschreibung(E_BATTERIEVERLAUF, "Audi Dashboard Batterieverlauf", "mdi:car-battery"),
Beschreibung(E_REIFEN_SOMMER, "Audi Dashboard Reifen Sommer km", "mdi:tire", UnitOfLength.KILOMETERS),
Beschreibung(E_REIFEN_WINTER, "Audi Dashboard Reifen Winter km", "mdi:snowflake", UnitOfLength.KILOMETERS),
Beschreibung(E_REIFEN_AKTIV, "Audi Dashboard Reifen aktiver Satz", "mdi:tire"),
Beschreibung(E_ZUORDNUNG, "Audi Dashboard Entitäten", "mdi:link-variant", None, EntityCategory.DIAGNOSTIC),
Beschreibung(E_BELEG_ERGEBNIS, "Audi Dashboard Beleg Ergebnis", "mdi:receipt-text", None, EntityCategory.DIAGNOSTIC),
Beschreibung(E_IMPORT_STATUS, "Audi Dashboard Import Status", "mdi:database-import", None, EntityCategory.DIAGNOSTIC),
Beschreibung(E_APP_VERSION, "Audi Dashboard App Version", "mdi:tag-outline", None, EntityCategory.DIAGNOSTIC),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
koordinator: Koordinator = entry.runtime_data
async_add_entities(
AudiEntitaet(koordinator, beschreibung) for beschreibung in BESCHREIBUNGEN
)
class AudiEntitaet(SensorEntity):
"""Eine Entität je Datenbestand. Zustand kurz, Inhalt im Attribut `daten`."""
# Der eigentliche Grund für echte Entitäten - siehe Kopfkommentar.
_unrecorded_attributes = frozenset({"daten"})
# Bewusst KEIN has_entity_name: die Entity-IDs (sensor.audi_dashboard_*)
# sind ein Vertrag mit beiden Oberflächen (siehe const.py). Mit
# has_entity_name würde Home Assistant den Gerätenamen davorsetzen und die
# IDs hingen am - vom Nutzer änderbaren - Gerätenamen.
_attr_has_entity_name = False
_attr_should_poll = False
def __init__(self, koordinator: Koordinator, beschreibung: Beschreibung) -> None:
self._koordinator = koordinator
self._beschreibung = beschreibung
self._attr_name = beschreibung.name
self._attr_icon = beschreibung.icon
self._attr_native_unit_of_measurement = beschreibung.einheit
self._attr_entity_category = beschreibung.kategorie
self._attr_unique_id = f"{koordinator.entry.entry_id}_{beschreibung.schluessel}"
# Die gewünschte Objekt-ID ausdrücklich vorgeben, statt sie aus dem
# Namen ableiten zu lassen: aus dem Namen käme zwar dasselbe heraus,
# aber nur solange niemand den Namen anfasst.
self.internal_integration_suggested_object_id = (
f"audi_dashboard_{beschreibung.schluessel}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, koordinator.entry.entry_id)},
name="Audi Dashboard",
manufacturer="Audi Dashboard",
sw_version=koordinator.version,
entry_type=None,
)
async def async_added_to_hass(self) -> None:
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_AKTUALISIERT}_{self._beschreibung.schluessel}",
self._neu_zeichnen,
)
)
@callback
def _neu_zeichnen(self) -> None:
self.async_write_ha_state()
def _wert(self) -> tuple[str, object]:
return self._koordinator.werte.get(self._beschreibung.schluessel, ("unbekannt", None))
@property
def available(self) -> bool:
"""Bis zur ersten Veröffentlichung gibt es nichts zu zeigen.
"unavailable" ist hier ehrlicher als ein Platzhalterwert: die
Oberfläche unterscheidet ausdrücklich zwischen "kein Wert" und "Wert
unbekannt" und darf sich darauf verlassen."""
return self._beschreibung.schluessel in self._koordinator.werte
@property
def native_value(self) -> str:
return self._wert()[0]
@property
def extra_state_attributes(self) -> dict[str, object]:
return {"daten": self._wert()[1]}
@@ -0,0 +1,257 @@
# Felder und Eingabehilfen für Entwicklerwerkzeuge -> Aktionen.
# Namen und Beschreibungen stehen in strings.json bzw. translations/, nicht
# hier - so steht jeder Text genau einmal.
#
# Beide Oberflächen rufen diese Dienste über die WebSocket- bzw. REST-API auf
# und brauchen diese Datei nicht. Sie ist dafür da, dass die Dienste in Home
# Assistant selbst bedienbar und nachvollziehbar sind - zum Nachschauen, zum
# Ausprobieren und für eigene Automationen des Nutzers.
jetzt_aktualisieren:
screening_jetzt:
backup_jetzt:
profil_schreiben:
fields:
profil_json:
required: true
selector:
object:
entitaeten_schreiben:
fields:
zuordnung_json:
required: true
selector:
object:
fahrt_manuell_anlegen:
fields:
ts_start:
required: true
example: "2026-08-23T07:30:00+02:00"
selector:
text:
ts_end:
required: true
example: "2026-08-23T08:05:00+02:00"
selector:
text:
art:
selector:
select:
options: ["privat", "arbeitsweg"]
start_ort:
selector:
text:
ziel_ort:
selector:
text:
odo_start:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
odo_end:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
distanz:
selector:
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
fahrt_aktualisieren:
fields:
trip_id:
required: true
example: t-4f2a91c30b7e
selector:
text:
ts_start:
selector:
text:
ts_end:
selector:
text:
art:
selector:
select:
options: ["privat", "arbeitsweg"]
start_ort:
selector:
text:
ziel_ort:
selector:
text:
odo_start:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
odo_end:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
distanz:
selector:
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
fahrt_loeschen:
fields:
trip_id:
required: true
selector:
text:
beleg_hochladen:
fields:
pdf_base64:
required: true
selector:
text:
multiline: true
dateiname:
selector:
text:
tank_id:
selector:
text:
tankvorgang_manuell:
fields:
ts:
selector:
text:
liter:
selector:
number: { min: 0, max: 200, step: 0.01, mode: box, unit_of_measurement: L }
kosten:
selector:
number: { min: 0, max: 2000, step: 0.01, mode: box, unit_of_measurement: EUR }
km:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
ersparnis:
selector:
number: { min: 0, max: 500, step: 0.01, mode: box, unit_of_measurement: EUR }
station:
selector:
text:
distanz:
selector:
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
kraftstoff:
selector:
text:
receipt_key:
selector:
text:
receipt_file:
selector:
text:
tankvorgang_aktualisieren:
fields:
tank_id:
required: true
selector:
text:
ts:
selector:
text:
liter:
selector:
number: { min: 0, max: 200, step: 0.01, mode: box, unit_of_measurement: L }
kosten:
selector:
number: { min: 0, max: 2000, step: 0.01, mode: box, unit_of_measurement: EUR }
km:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
ersparnis:
selector:
number: { min: 0, max: 500, step: 0.01, mode: box, unit_of_measurement: EUR }
station:
selector:
text:
distanz:
selector:
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
kraftstoff:
selector:
text:
receipt_key:
selector:
text:
receipt_file:
selector:
text:
tankvorgang_loeschen:
fields:
tank_id:
required: true
selector:
text:
bild_hochladen:
fields:
dateiname:
required: true
example: seitenansicht.webp
selector:
text:
daten_base64:
required: true
selector:
text:
multiline: true
bild_loeschen:
fields:
dateiname:
required: true
selector:
text:
reifen_wechseln:
fields:
satz:
required: true
selector:
select:
options: ["sommer", "winter"]
reifen_km_setzen:
fields:
satz:
required: true
selector:
select:
options: ["sommer", "winter"]
km:
required: true
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
backup_wiederherstellen:
fields:
profil_json:
selector:
object:
fahrten_json:
selector:
object:
tankvorgaenge_json:
selector:
object:
entitaeten_json:
selector:
object:
historie_importieren:
fields:
start:
required: true
example: "2026-01-01T00:00:00"
selector:
text:
ende:
required: true
example: "2026-08-23T00:00:00"
selector:
text:
@@ -0,0 +1,466 @@
#!/usr/bin/env python3
"""Parser für Shell-Tankbelege (PDF), aufgerufen von belegverarbeitung.py.
Diese Datei fehlte beim Bau im Projektordner (siehe Kopfkommentar in
pyscript/belegverarbeitung.py und README.md, Abschnitt "Bekannte Lücken").
Sie ist hier gegen echte Shell-eReceipts neu geschrieben und gegen deren
tatsächlich von pypdf extrahierten Text geprüft - nicht gegen eine
angenommene Struktur. Regressionstest mit zehn realen Belegen aus vier
Stationen liegt unter tests/test_shell_beleg_parser.py (Aufruf: python3
tests/test_shell_beleg_parser.py aus diesem Ordner). Die Belege
unterscheiden sich u. a. in der Markenzeile ("SHELL STATION" vs.
"Shell Station" vs. "Shell-Station") und der Rabattbezeichnung
("V-Power Smart Deal" vs. "ClubSmartRabatt") - deshalb ist die
Stations- und Rabatterkennung unten bewusst nicht an deren genauen
Wortlaut gebunden, sondern an feste Beleg-Struktur-Marker (Obj.-Nr.,
Betragsmuster vor "EUR #A").
Abhängigkeit: pypdf (reine Textextraktion, der Beleg ist ein normales
Text-PDF, kein Scan/Bild - kein OCR nötig). Läuft als eigener Prozess über
`python3 shell_beleg_parser.py <pfad>` (system-python3 im Container, nicht
die pyscript-Sandbox - siehe _parser_aufrufen() in belegverarbeitung.py),
deshalb reicht `pip install pypdf` einmalig im selben Python, das
`python3` in configuration.yaml/Docker auch sonst benutzt.
Kommandozeilen-Schnittstelle wie von belegverarbeitung.py angenommen:
Ein Pfad zu einer PDF-Datei als einziges Argument, JSON-Objekt auf stdout
bei Erfolg, Exit-Code ungleich 0 und Fehlertext auf stderr bei Misserfolg
(z. B. wenn der Beleg keiner der erkannten Vorlagen entspricht).
Aufbau eines Shell-eReceipts (aus dem echten Beispiel, Reihenfolge fix):
SHELL STATION
<Betreibername, z.B. "A. Zrenner GmbH">
<Straße>
<PLZ Ort>
Obj.-Nr.: <Stations-ID> Tel. ...
Beleg-Nr. <receipt_no> <TT.MM.JJ> <HH:MM>
*<article_no> <Produktname> <Listenbetrag> EUR #A*
*Zp <Zapfsäule> <Liter> l <Listenpreis/l> EUR/l # *
[<Rabattname> -<Rabattbetrag> EUR #A -- optional, nur mit SmartDeal
<Rabatt/l> EUR/Liter -- optional]
Gesamtbetrag <Endbetrag> EUR
Typ Netto Mwst Brutto
A:<Mwst-Satz>% <Netto> <Mwst> <Brutto>
...
Mobile Payment
<TT.MM.JJJJ> <HH:MM:SS> -- Zahlungszeitstempel
...
(Seite 2) POS:...-...-... TSE-Beleg-Nr: <tse_beleg_nr>
Start/Ende: <TT.MM.JJJJ HH:MM:SS> / ...
"""
import datetime
import hashlib
import json
import re
import sys
try:
import pypdf
except ImportError:
print(
"pypdf fehlt. Einmalig installieren mit: pip install pypdf "
"(im selben Python, das diesen Parser ausführt, siehe Kopfkommentar).",
file=sys.stderr,
)
sys.exit(1)
def _text_aus_pdf(pdf_pfad):
leser = pypdf.PdfReader(pdf_pfad)
return "\n".join(seite.extract_text() or "" for seite in leser.pages)
def _de_zahl(text):
""""103,06" -> 103.06 / "2,399" -> 2.399. Kein Tausenderpunkt bei
Tankbeträgen dieser Größenordnung zu erwarten, trotzdem robust: Punkte
vor dem Umwandeln entfernen, nicht nur das Komma ersetzen."""
if text is None:
return None
return float(text.strip().replace(".", "").replace(",", "."))
def _iso(tag, monat, jahr, stunde, minute, sekunde="0"):
jahr = int(jahr)
if jahr < 100:
jahr += 2000
return datetime.datetime(
jahr, int(monat), int(tag), int(stunde), int(minute), int(sekunde)
).isoformat()
_ZAHL = r"[\d.,]+"
# Echte Geldangabe: Nachkommastellen sind Pflicht, damit im stationsunabhängigen
# Parser (_parsen_generisch) keine Artikel-/Belegnummer als Betrag durchgeht.
_GELD = r"\d{1,3}(?:[ .]\d{3})*,\d{2}"
_GESAMT_WOERTER = ("GESAMT", "ABSOLUT", "ENDBETRAG", "ZUZAHLEN", "TOTAL", "SUMME")
# Steuer- und Nettozeilen tragen dieselben Schlüsselwörter ("MWST GESAMT"),
# nennen aber nie den bezahlten Rechnungsbetrag.
_STEUER_WOERTER = ("MWST", "UST", "VAT", "STEUER", "NETTO")
# Kraftstoffmarken, wie sie im Belegkopf stehen. Bewusst nur dort gesucht (die
# ersten Zeilen), nicht im ganzen Text: "Total" wäre sonst z. B. auch als
# Summenzeile ein Treffer. Längere Namen zuerst, damit "TotalEnergies" nicht
# als "Total" endet. Die Schreibweise hier ist die, die angezeigt wird - auf
# Bons steht die Marke meist in Großbuchstaben ("SHELL STATION").
_MARKEN = (
"TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV",
"Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth",
"Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET",
"bft", "BP", "Q1",
)
_STRASSE_WOERTER = r"(?:stra(?:ss|ß)e|str\.|gasse|weg|platz|allee|ring|damm|chaussee)"
def _marke(zeilen):
"""Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn
keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen."""
kopf = "\n".join(zeilen[:8])
for marke in _MARKEN:
if re.search(r"\b" + re.escape(marke) + r"\b", kopf, re.IGNORECASE):
return marke
return None
def _ist_strasse(zeile):
"""Straßenzeilen tragen eine Hausnummer oder ein Straßenwort - der
Betreibername ("AUTO B. FRISCHMANN GMBH") tut beides nicht."""
if not zeile:
return False
return bool(re.search(r"\d", zeile) or re.search(_STRASSE_WOERTER, zeile, re.IGNORECASE))
def _tankstelle(marke, strasse, ort, ersatzname=None):
"""Anzeigename der Tankstelle im Format "Marke, Straße, Ort" (§ Anzeige im
Frontend, Feld station_name). Fehlende Teile fallen weg statt als leere
Kommastelle stehen zu bleiben - nicht jeder Beleg nennt eine Straße. Ohne
erkennbare Marke tritt der Betreibername an ihre Stelle."""
teile = [t for t in (marke or ersatzname, strasse, ort) if t]
return ", ".join(teile) if teile else None
def _parsen(text):
def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
treffer = re.search(muster, text, flags)
if treffer is None and pflicht:
raise ValueError(f"Muster nicht gefunden: {muster!r}")
return treffer
# ---- Station ---------------------------------------------------------
# Nicht auf den Wortlaut der Markenzeile selbst verankert ("SHELL
# STATION" vs. "Shell-STATION" - beide an echten Belegen gesehen,
# offenbar je nach Pächter/Kassensystem unterschiedlich geschrieben).
# Stattdessen einfach die drei Zeilen direkt vor "Obj.-Nr." nehmen - die
# Reihenfolge Betreiber/Straße/Ort direkt davor ist auf beiden echten
# Belegen gleich.
station = suchen(
r"(?P<name>.+?)\s*\n\s*(?P<strasse>.+?)\s*\n\s*(?P<ort>.+?)\s*\n\s*Obj\.-Nr\."
)
station_id = suchen(r"Obj\.-Nr\.:\s*(\d+)").group(1)
# ---- Beleg-Kopf (Beleg-Nr., Datum/Zeit auf dem Kassenbon) ----------
kopf = suchen(
r"Beleg-Nr\.\s*(?P<receipt_no>[\d/]+)\s+(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{2})\s+(?P<stunde>\d{2}):(?P<minute>\d{2})"
)
receipt_no = kopf.group("receipt_no")
ts = _iso(kopf.group("tag"), kopf.group("monat"), kopf.group("jahr"),
kopf.group("stunde"), kopf.group("minute"))
# ---- Kraftstoff-Position --------------------------------------------
position = suchen(
r"\*(?P<article_no>\d+)\s+(?P<produkt>.+?)\s+" + _ZAHL + r"\s*EUR\s*#A\*"
)
article_no = position.group("article_no")
product_name = position.group("produkt").strip()
zapfsaeule = suchen(
r"\*Zp\s*\d+\s+(?P<liter>" + _ZAHL + r")\s*l\s+(?P<preis>" + _ZAHL + r")\s*EUR/l"
)
liters = _de_zahl(zapfsaeule.group("liter"))
list_price_per_l = _de_zahl(zapfsaeule.group("preis"))
# ---- SmartDeal-Rabatt (optional - nicht jeder Tankvorgang hat einen) --
rabatt_treffer = suchen(r"-(" + _ZAHL + r")\s*EUR\s*#A\s*$", pflicht=False)
discount = _de_zahl(rabatt_treffer.group(1)) if rabatt_treffer else None
rabatt_l_treffer = suchen(r"(" + _ZAHL + r")\s*EUR/Liter", pflicht=False)
discount_per_l = _de_zahl(rabatt_l_treffer.group(1)) if rabatt_l_treffer else None
# ---- Beträge ----------------------------------------------------------
fuel_total_eur = _de_zahl(suchen(r"Gesamtbetrag\s+(" + _ZAHL + r")\s*EUR").group(1))
steuerzeile = suchen(
r"A:" + _ZAHL + r"%\s+(?P<netto>" + _ZAHL + r")\s+(?P<mwst>" + _ZAHL + r")\s+(?P<brutto>" + _ZAHL + r")"
)
net_eur = _de_zahl(steuerzeile.group("netto"))
vat_eur = _de_zahl(steuerzeile.group("mwst"))
receipt_total_eur = _de_zahl(steuerzeile.group("brutto"))
# ---- Zahlungszeitstempel (Mobile Payment) - optional, falls anderes
# Zahlungsverfahren auf dem Beleg steht ---------------------------------
zahlung = suchen(
r"Mobile Payment\s*\n+\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
pflicht=False,
)
ts_payment = (
_iso(zahlung.group("tag"), zahlung.group("monat"), zahlung.group("jahr"),
zahlung.group("stunde"), zahlung.group("minute"), zahlung.group("sekunde"))
if zahlung else None
)
# ---- TSE-Signatur (Seite 2) - optional -------------------------------
tse_nr = suchen(r"TSE-Beleg-Nr:\s*(\d+)", pflicht=False)
tse_beleg_nr = tse_nr.group(1) if tse_nr else None
tse_zeit = suchen(
r"Start/Ende:\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
pflicht=False,
)
ts_tse = (
_iso(tse_zeit.group("tag"), tse_zeit.group("monat"), tse_zeit.group("jahr"),
tse_zeit.group("stunde"), tse_zeit.group("minute"), tse_zeit.group("sekunde"))
if tse_zeit else None
)
# receipt_key: minutengenauer Deduplizierungs-Schlüssel (§6.4/§7.7 Regel 3)
receipt_key = f"{station_id}_{receipt_no.replace('/', '-')}_{ts[:16]}"
return {
"receipt_key": receipt_key,
"receipt_no": receipt_no,
"tse_beleg_nr": tse_beleg_nr,
"ts": ts,
"ts_payment": ts_payment,
"ts_tse": ts_tse,
"station_id": station_id,
# Anzeigename "Marke, Straße, Ort" (siehe _tankstelle). Der Ort steht
# auf dem Bon als "85057 Ingolstadt" - die PLZ gehört nur in die
# ausführliche station_address darunter, nicht in den Anzeigenamen.
"station_name": _tankstelle(
_marke(text.splitlines()),
station.group("strasse").strip(),
re.sub(r"^\d{4,5}\s+", "", station.group("ort").strip()),
ersatzname=station.group("name").strip(),
),
"station_address": f"{station.group('strasse').strip()}, {station.group('ort').strip()}",
"article_no": article_no,
"product_name": product_name,
"fuel_type": product_name,
"liters": liters,
"list_price_per_l": list_price_per_l,
"discount": discount,
"discount_per_l": discount_per_l,
"fuel_total_eur": fuel_total_eur,
"receipt_total_eur": receipt_total_eur,
"net_eur": net_eur,
"vat_eur": vat_eur,
}
def _parsen_generisch(text):
"""Stationsunabhängiger Fallback für Belege, die keinem der oben fest an
Shells Beleg-Struktur verankerten Muster entsprechen (_parsen() wirft in
dem Fall ValueError - siehe main()). Arbeitet nach demselben Verfahren,
das an einem echten Beleg einer bislang unbekannten Tankstelle von Hand
nachvollzogen wurde: Adresse suchen, Gesamtbetrag suchen ("Gesamt.../
Absolut..."), Literangabe suchen ("Menge"/"Amount" oder eine an "l"/
"Liter" hängende Zahl), daraus den tatsächlich bezahlten Preis/Liter
errechnen (Gesamtbetrag / Liter). Steht daneben auch ein aufgedruckter
Preis/Liter auf dem Beleg und weicht er vom errechneten ab, wurde ein
Rabatt gewährt - auf dem Beleg meist als eigener Betrag mit einem Minus
davor erkennbar, danach wird zuerst gesucht, sonst aus der Preisdifferenz
hergeleitet. Ob ein so gefundener Rabatt als "SmartDeal" behandelt wird,
entscheidet einzig station_name (Shell-Logo/-Label im Frontend, siehe
frontend_veroeffentlichung.py) - kein Sonderfall hier nötig, die Frontend-
Anzeige hängt nur an einem gesetzten discount, nicht an einem eigenen
Kennzeichen.
Liefert deutlich weniger Felder als _parsen() (kein Bon-Kleingedrucktes
wie TSE-Nummer/Zahlungszeitstempel) - unproblematisch, denn
belegverarbeitung.py liest nur receipt_key als echtes Pflichtfeld,
alles andere per .get()."""
def suchen(muster, flags=re.IGNORECASE | re.MULTILINE):
return re.search(muster, text, flags)
zeilen = [z.rstrip() for z in text.splitlines()]
# ---- Adresse -----------------------------------------------------------
# Die PLZ-Ort-Angabe steht nicht zuverlässig auf einer eigenen Zeile (ein
# österreichischer Beleg führt sie als "SHELL TANKSTELLE, 6450 SÖLDEN"
# zusammen mit der Filialbezeichnung) und PLZ sind vier- *oder*
# fünfstellig (AT/DE). Deshalb nicht mehr drei feste Zeilen hintereinander
# verlangen, sondern im Belegkopf die erste Zeile mit PLZ+Ort suchen; der
# Name ist die erste Textzeile darüber.
plz_treffer = None
plz_zeile = None
for i, zeile in enumerate(zeilen[:12]):
treffer = re.search(r"\b\d{4,5}\s+([A-Za-zÄÖÜäöüß][^\n]*)$", zeile)
if treffer:
plz_treffer, plz_zeile = treffer, i
break
ort = None
strasse = None
betreiber = None
station_address = None
if plz_treffer is not None:
ort = plz_treffer.group(1).strip(" ,.-")
station_address = zeilen[plz_zeile].strip()
# Die Straße steht entweder vor der PLZ auf derselben Zeile
# ("Musterstr. 5, 80331 München") oder auf der Zeile darüber.
vor_plz = zeilen[plz_zeile][: plz_treffer.start()].strip(" ,.-")
if _ist_strasse(vor_plz):
strasse = vor_plz
for zeile in reversed(zeilen[:plz_zeile]):
if not zeile.strip():
continue
if strasse is None and _ist_strasse(zeile.strip()):
strasse = zeile.strip()
continue
betreiber = zeile.strip()
break
if betreiber is None:
for zeile in zeilen[:5]:
if zeile.strip():
betreiber = zeile.strip()
break
if station_address and strasse and strasse not in station_address:
station_address = f"{strasse}, {station_address}"
station_name = _tankstelle(_marke(zeilen), strasse, ort, ersatzname=betreiber)
# ---- Gesamtbetrag ------------------------------------------------------
# Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
# einem echten Beleg aufgefallen sind: Bondrucker sperren Überschriften
# gern buchstabenweise ("G E S A M T BETRAG EUR: 92,60"), und ein frei
# laufendes Muster fand vorher die Spaltenüberschrift "SUMME-EUR" und
# las die Artikelnummer der Folgezeile als Betrag. Deshalb: Schlüsselwort
# auf der leerzeichenfreien Zeile prüfen, Betrag nur als echte Geldangabe
# (mit Nachkommastellen) akzeptieren und Steuerzeilen ausschließen.
kandidaten = []
for zeile in zeilen:
kompakt = re.sub(r"\s+", "", zeile).upper()
if not any(wort in kompakt for wort in _GESAMT_WOERTER):
continue
if any(wort in kompakt for wort in _STEUER_WOERTER):
continue
betraege = re.findall(_GELD, zeile)
if betraege:
kandidaten.append(_de_zahl(betraege[-1]))
if not kandidaten:
raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')")
# Mehrere Treffer (z. B. Summenzeile und Kartenzahlungszeile): der
# Rechnungsbetrag ist der größte - Teilbeträge sind nie größer.
fuel_total_eur = max(kandidaten)
# ---- Menge in Litern ----------------------------------------------------
menge = suchen(r"(?:Menge|Amount)\D{0,15}?(" + _ZAHL + r")\s*(?:l\b|L\b|Liter|Ltr\.?)")
if not menge:
menge = suchen(r"(" + _ZAHL + r")\s*(?:Liter|Ltr\.?|l)\b")
if not menge:
raise ValueError("Keine Literangabe gefunden (weder 'Menge'/'Amount' noch 'X l')")
liters = _de_zahl(menge.group(1))
if not liters:
raise ValueError("Literangabe ist 0 oder unlesbar")
berechneter_preis_l = round(fuel_total_eur / liters, 3)
# ---- Aufgedruckter Preis/Liter - Abweichung verrät einen Rabatt -------
# "Preis/L" (ohne ausgeschriebenes "Liter") kommt auf echten Belegen vor,
# deshalb ist alles nach dem L optional.
aufgedruckt = suchen(
r"(?:Preis\s*/?\s*(?:je\s*)?L(?:iter|tr\.?)?|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")"
)
list_price_per_l = _de_zahl(aufgedruckt.group(1)) if aufgedruckt else berechneter_preis_l
discount = None
discount_per_l = None
if aufgedruckt and abs(list_price_per_l - berechneter_preis_l) > 0.005:
minus = suchen(r"-\s*(" + _ZAHL + r")\s*(?:€|EUR)")
if minus:
discount = _de_zahl(minus.group(1))
discount_per_l = round(discount / liters, 3)
else:
discount_per_l = round(list_price_per_l - berechneter_preis_l, 3)
discount = round(discount_per_l * liters, 2)
# ---- Zeitstempel: irgendein Datum+Uhrzeit auf dem Beleg ----------------
zeit = suchen(r"(\d{2})\.(\d{2})\.(\d{2,4})\D{0,10}?(\d{2}):(\d{2})")
ts = _iso(zeit.group(1), zeit.group(2), zeit.group(3), zeit.group(4), zeit.group(5)) if zeit else None
# ---- Belegnummer, sonst deterministischer Ersatzschlüssel --------------
# Wichtig: kein eingebautes hash() (pro Prozess zufällig gesalzen, siehe
# PYTHONHASHSEED) - der Parser läuft für jeden Beleg als eigener Prozess
# (Kopfkommentar oben), ein instabiler Schlüssel würde den §7.7-Regel-3-
# Dedup-Vergleich in belegverarbeitung.py unbrauchbar machen.
beleg_nr = suchen(
r"(?:Beleg-?Nr\.?|Bon-?Nr\.?|Rechnungs-?Nr\.?|Receipt\s*No\.?|Trans(?:aktions)?-?Nr\.?)\D{0,5}?([\dA-Za-z/\-]+)"
)
receipt_no = beleg_nr.group(1) if beleg_nr else None
schluessel_basis = "|".join(
str(teil) for teil in (receipt_no, ts, station_address, fuel_total_eur, liters) if teil
) or text[:80]
receipt_key = "generisch_" + hashlib.sha1(schluessel_basis.encode("utf-8")).hexdigest()[:16]
return {
"receipt_key": receipt_key,
"receipt_no": receipt_no,
"tse_beleg_nr": None,
"ts": ts,
"ts_payment": None,
"ts_tse": None,
"station_id": None,
"station_name": station_name,
"station_address": station_address,
"article_no": None,
"product_name": None,
"fuel_type": None,
"liters": liters,
"list_price_per_l": list_price_per_l,
"discount": discount,
"discount_per_l": discount_per_l,
"fuel_total_eur": fuel_total_eur,
"receipt_total_eur": fuel_total_eur,
"net_eur": None,
"vat_eur": None,
}
def beleg_lesen(pdf_pfad):
"""Liest einen Tankbeleg und gibt die Felder als dict zurück.
Der eine Einstiegspunkt für Aufrufer im selben Prozess. Die Integration
(belege.py) ruft genau diese Funktion im Executor auf - früher lief der
Parser als eigener Unterprozess über main() unten, weil pyscript keine
fremden Pakete laden konnte und pypdf deshalb von Hand ins Container-
Python installiert werden musste. Als echte Integration steht pypdf über
manifest.json bereit; der Unterprozess und der Handinstallations-Schritt
entfallen damit ersatzlos.
Wirft eine Exception, wenn weder das Shell-Format noch der
stationsunabhängige Rückfall greifen - der Aufrufer entscheidet, was er
dem Nutzer davon zeigt."""
text = _text_aus_pdf(pdf_pfad)
try:
return _parsen(text)
except Exception:
# Kein Shell-Beleg im bekannten Format - Versuch mit dem
# stationsunabhängigen Fallback (_parsen_generisch() oben), bevor
# endgültig aufgegeben wird.
return _parsen_generisch(text)
def main():
"""Kommandozeilen-Aufruf, weiterhin nutzbar zum Nachschauen von Hand:
`python3 shell_beleg_parser.py <pfad-zur-pdf>`."""
if len(sys.argv) != 2:
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
sys.exit(1)
try:
ergebnis = beleg_lesen(sys.argv[1])
except Exception as exc:
print(f"Beleg konnte nicht gelesen werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
print(json.dumps(ergebnis, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,115 @@
"""Sicherung von Fahrzeugprofil, Fahrten, Tankvorgängen und Sensor-Zuordnung.
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 Termin genügt für alle drei Intervalle: wöchentlich läuft nur
montags, monatlich nur am Monatsersten, täglich jeden Tag.
Jede Sicherung ist ein eigener Zeitstempel-Unterordner mit Kopien der vier
Bestände, keine ZIP-Datei - eine Sicherung, in die man ohne Werkzeug
hineinschauen und aus der man eine einzelne Datei zurückkopieren kann, ist im
Ernstfall mehr wert als ein paar gesparte Kilobyte.
entitaeten.json gehört ausdrücklich 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.
"Backup exportieren"/"importieren" in der Oberfläche laufen dagegen rein über
den Browser (aktuell geladenes Profil + Fahrten + Tankvorgänge als eine
JSON-Datei); wiederherstellen() hier ist das Gegenstück zum Import, das die
Bestände wieder komplett überschreibt.
"""
from __future__ import annotations
import datetime
import json
import logging
import os
import shutil
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
DATEIEN = (
"fahrzeugprofil.json",
"fahrten.jsonl",
"tankvorgaenge.jsonl",
"entitaeten.json",
)
def _kopieren(basis: str, ziel: str) -> str:
os.makedirs(ziel, exist_ok=True)
for name in DATEIEN:
quelle = os.path.join(basis, name)
if os.path.exists(quelle):
shutil.copy(quelle, os.path.join(ziel, name))
return ziel
async def jetzt(k: Koordinator) -> str:
zeitstempel = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
ziel = os.path.join(k.ablage.backup_ordner, zeitstempel)
await k.hass.async_add_executor_job(_kopieren, k.ablage.basis, ziel)
await _vermerken(k)
_LOGGER.info("Sicherung erstellt unter %s", ziel)
return ziel
async def _vermerken(k: Koordinator) -> None:
profil = await k.ablage.profil_lesen()
if profil is None:
return
profil.setdefault("einstellungen", {})["letztes_backup"] = (
datetime.datetime.now().isoformat()
)
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
async def automatisch(k: Koordinator) -> None:
"""Der tägliche 04:00-Termin - prüft selbst, ob heute überhaupt eine
Sicherung fällig ist."""
profil = await k.ablage.profil_lesen()
if profil is None:
return
intervall = profil.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 = await jetzt(k)
_LOGGER.info("Automatische Sicherung (%s) erstellt unter %s", intervall, ziel)
def _als_objekt(wert: object) -> object:
return json.loads(wert) if isinstance(wert, str) else wert
async def wiederherstellen(k: Koordinator, daten: dict) -> None:
"""Überschreibt Profil, Fahrten, Tankvorgänge und die Sensor-Zuordnung
komplett.
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 daten.get("profil_json"):
await k.ablage.profil_schreiben(_als_objekt(daten["profil_json"])) # type: ignore[arg-type]
if daten.get("fahrten_json") is not None:
await k.ablage.fahrten_schreiben(_als_objekt(daten["fahrten_json"])) # type: ignore[arg-type]
if daten.get("tankvorgaenge_json") is not None:
await k.ablage.tankvorgaenge_schreiben(_als_objekt(daten["tankvorgaenge_json"])) # type: ignore[arg-type]
if daten.get("entitaeten_json"):
await k.zuordnung.speichern(_als_objekt(daten["entitaeten_json"])) # type: ignore[arg-type]
await k.trigger_neu_binden()
await k.alles_veroeffentlichen()
_LOGGER.info("Sicherung wiederhergestellt")
@@ -0,0 +1,174 @@
{
"config": {
"step": {
"user": {
"title": "Audi Dashboard",
"description": "Richtet das Panel \"Mein Audi\" ein. Welcher Sensor welche Rolle im Fahrzeug spielt, wird danach im Setup-Menü der App selbst zugeordnet (Einstellungen → Fahrzeug einrichten → Setup)."
}
},
"abort": {
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
}
},
"services": {
"jetzt_aktualisieren": {
"name": "Jetzt aktualisieren",
"description": "Stößt eine sofortige Neuabfrage beim Fahrzeug an und veröffentlicht den Fahrzeugstatus neu."
},
"screening_jetzt": {
"name": "Screening jetzt",
"description": "Versucht sofort, offene Fahrten mit Start- und Endkilometerstand zu vervollständigen."
},
"backup_jetzt": {
"name": "Sicherung jetzt",
"description": "Legt sofort eine Sicherung von Profil, Fahrten, Tankvorgängen und Sensor-Zuordnung an."
},
"profil_schreiben": {
"name": "Profil schreiben",
"description": "Überschreibt das komplette Fahrzeugprofil.",
"fields": {
"profil_json": { "name": "Profil", "description": "Das vollständige Fahrzeugprofil als JSON." }
}
},
"entitaeten_schreiben": {
"name": "Sensor-Zuordnung schreiben",
"description": "Speichert die Zuordnung von Home-Assistant-Entitäten zu den Rollen der App und wendet sie sofort an.",
"fields": {
"zuordnung_json": { "name": "Zuordnung", "description": "Feldname zu Entity-ID als JSON." }
}
},
"fahrt_manuell_anlegen": {
"name": "Fahrt anlegen",
"description": "Legt eine Fahrt von Hand an, unabhängig von der automatischen Zündungs-Erkennung.",
"fields": {
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
"start_ort": { "name": "Startort", "description": "Freitext." },
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
"distanz": { "name": "Strecke", "description": "Nur nötig, wenn keine Kilometerstände vorliegen." }
}
},
"fahrt_aktualisieren": {
"name": "Fahrt bearbeiten",
"description": "Ändert eine bestehende Fahrt.",
"fields": {
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." },
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
"start_ort": { "name": "Startort", "description": "Freitext." },
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
"distanz": { "name": "Strecke", "description": "Von Hand gesetzt." }
}
},
"fahrt_loeschen": {
"name": "Fahrt löschen",
"description": "Löscht eine Fahrt unwiderruflich.",
"fields": {
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." }
}
},
"beleg_hochladen": {
"name": "Beleg hochladen",
"description": "Liest einen Tankbeleg (PDF) und trägt ihn in einen bestehenden Tankvorgang nach oder gibt die Werte an die Oberfläche zurück.",
"fields": {
"pdf_base64": { "name": "PDF", "description": "Der Beleg als Base64." },
"dateiname": { "name": "Dateiname", "description": "Unter diesem Namen wird der Beleg abgelegt." },
"tank_id": { "name": "Tankvorgang", "description": "Leer lassen, um nur zu lesen, ohne etwas zu speichern." }
}
},
"tankvorgang_manuell": {
"name": "Tankvorgang anlegen",
"description": "Erfasst einen Tankvorgang von Hand.",
"fields": {
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel; leer = jetzt." },
"liter": { "name": "Liter", "description": "Getankte Menge." },
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
"km": { "name": "Kilometerstand", "description": "Leer = aus dem zugeordneten Sensor." },
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
"station": { "name": "Station", "description": "Name der Tankstelle." },
"distanz": { "name": "Gefahrene Strecke", "description": "Leer = aus der Differenz zum letzten Tankvorgang." },
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
}
},
"tankvorgang_aktualisieren": {
"name": "Tankvorgang bearbeiten",
"description": "Ändert einen bestehenden Tankvorgang.",
"fields": {
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." },
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel." },
"liter": { "name": "Liter", "description": "Getankte Menge." },
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
"km": { "name": "Kilometerstand", "description": "Kilometerstand beim Tanken." },
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
"station": { "name": "Station", "description": "Name der Tankstelle." },
"distanz": { "name": "Gefahrene Strecke", "description": "Seit dem letzten Tankvorgang." },
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
}
},
"tankvorgang_loeschen": {
"name": "Tankvorgang löschen",
"description": "Löscht einen Tankvorgang unwiderruflich.",
"fields": {
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." }
}
},
"bild_hochladen": {
"name": "Fahrzeugbild hochladen",
"description": "Legt ein Fahrzeugfoto unter einem der erwarteten Namen ab.",
"fields": {
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." },
"daten_base64": { "name": "Bild", "description": "Die Bilddaten als Base64." }
}
},
"bild_loeschen": {
"name": "Fahrzeugbild löschen",
"description": "Entfernt ein zuvor hochgeladenes Fahrzeugfoto.",
"fields": {
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." }
}
},
"reifen_wechseln": {
"name": "Reifensatz wechseln",
"description": "Schaltet den aktiven Reifensatz um.",
"fields": {
"satz": { "name": "Satz", "description": "sommer oder winter." }
}
},
"reifen_km_setzen": {
"name": "Reifen-Kilometerstand setzen",
"description": "Korrigiert den gezählten Kilometerstand eines Reifensatzes von Hand.",
"fields": {
"satz": { "name": "Satz", "description": "sommer oder winter." },
"km": { "name": "Kilometer", "description": "Der neue Zählerstand." }
}
},
"backup_wiederherstellen": {
"name": "Sicherung wiederherstellen",
"description": "Überschreibt Profil, Fahrten, Tankvorgänge und Sensor-Zuordnung mit einer zuvor exportierten Sicherung.",
"fields": {
"profil_json": { "name": "Profil", "description": "Fahrzeugprofil als JSON." },
"fahrten_json": { "name": "Fahrten", "description": "Fahrten als JSON-Liste." },
"tankvorgaenge_json": { "name": "Tankvorgänge", "description": "Tankvorgänge als JSON-Liste." },
"entitaeten_json": { "name": "Sensor-Zuordnung", "description": "Optional; fehlt sie, bleibt die aktuelle Zuordnung unangetastet." }
}
},
"historie_importieren": {
"name": "Daten aus dem Verlauf importieren",
"description": "Leitet Fahrten, Tankvorgänge und Batteriewerte rückwirkend aus dem aufgezeichneten Verlauf ab.",
"fields": {
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
}
}
}
}
@@ -0,0 +1,117 @@
"""Automatische Tankerkennung über den Füllstandssensor.
Beobachtung: der Füllstand (in Prozent) springt beim Fahren nie nach oben, er
sinkt nur - jeder Anstieg ist also ein Tankvorgang. Schwelle nach Vorgabe: 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
verglichen werden können; es gilt jeweils die empfindlichere (kleinere) der
beiden Schwellen.
Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich: die Datenquelle
liefert einen Tankvorgang oft in mehreren kleinen Schritten (40% -> 45% ->
60%), von denen keiner allein die Schwelle überschreiten muss. Deshalb wird
der zuletzt bekannte Tiefststand gespeichert und der Anstieg dagegen gemessen.
Nach Anlage eines Tankvorgangs wird der Tiefststand auf den aktuellen Wert
zurückgesetzt, damit derselbe Vorgang nicht mehrfach Datensätze erzeugt.
Der so angelegte Tankvorgang ist bewusst ein unvollständiger Platzhalter
(status "unvollständig", ohne Liter/Kosten/Station): erwartet wird ein
automatisch erkannter Tankvorgang mit Zeitstempel, Kilometerstand und
gefahrener Distanz, den der Nutzer später per Beleg-Nachtrag vervollständigt.
Auch hier gilt die Härtung aus fahrterkennung.py: der Tiefststand lebt nicht
mehr nur im Arbeitsspeicher, sondern im Store des Koordinators und überlebt
damit einen Neustart.
"""
from __future__ import annotations
import datetime
import logging
from typing import TYPE_CHECKING
from .ablage import neue_id
from .veroeffentlichung import zustand_oder_none
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
LITER_SCHWELLE = 5
PROZENT_SCHWELLE = 9
STANDARD_TANKVOLUMEN_LITER = 58 # Rückfall, falls im Profil nicht hinterlegt
def _als_zahl(wert: object) -> float | None:
try:
return float(wert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def schwelle_prozent(profil: dict | None) -> float:
"""Die empfindlichere der beiden Schwellen, in Prozentpunkten."""
if profil is None:
tankvolumen = STANDARD_TANKVOLUMEN_LITER
else:
tankvolumen = (
profil.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
)
return min((LITER_SCHWELLE / tankvolumen) * 100, PROZENT_SCHWELLE)
def leerer_tankvorgang(ts: str, quelle: str) -> dict:
"""Das vollständige Tankvorgang-Schema, alles Unbekannte auf None."""
return {
"tank_id": neue_id("f"),
"receipt_key": None,
"ts": ts,
"liters": None,
"fuel_total_eur": None,
"price_per_l": None,
"discount": None,
"station_name": None,
"odometer_km": None,
"distance_km": None,
"fuel_type": None,
"source": quelle,
"status": "unvollständig",
"receipt_file": None,
"edited_fields": [],
}
async def fuellstand_geaendert(k: Koordinator, neu: str | None) -> None:
aktuell = _als_zahl(neu)
if aktuell is None:
return
tiefststand = k.tiefststand_pct
if tiefststand is None or aktuell <= tiefststand:
await k.tiefststand_setzen(aktuell)
return
anstieg = aktuell - tiefststand
if anstieg < schwelle_prozent(await k.ablage.profil_lesen()):
return
await _automatisch_anlegen(k, anstieg)
await k.tiefststand_setzen(aktuell)
async def _automatisch_anlegen(k: Koordinator, anstieg_pct: float) -> None:
odometer_km = _als_zahl(zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR))
tankvorgang = leerer_tankvorgang(
datetime.datetime.now(datetime.UTC).isoformat(), "auto"
)
tankvorgang["odometer_km"] = odometer_km
tankvorgang["distance_km"] = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
await k.ablage.tankvorgang_anhaengen(tankvorgang)
await k.tankvorgaenge_veroeffentlichen()
_LOGGER.info(
"Tankvorgang %s automatisch erkannt (Füllstandsanstieg %s Prozentpunkte)",
tankvorgang["tank_id"], round(anstieg_pct, 1),
)
@@ -0,0 +1,174 @@
{
"config": {
"step": {
"user": {
"title": "Audi Dashboard",
"description": "Richtet das Panel \"Mein Audi\" ein. Welcher Sensor welche Rolle im Fahrzeug spielt, wird danach im Setup-Menü der App selbst zugeordnet (Einstellungen → Fahrzeug einrichten → Setup)."
}
},
"abort": {
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
}
},
"services": {
"jetzt_aktualisieren": {
"name": "Jetzt aktualisieren",
"description": "Stößt eine sofortige Neuabfrage beim Fahrzeug an und veröffentlicht den Fahrzeugstatus neu."
},
"screening_jetzt": {
"name": "Screening jetzt",
"description": "Versucht sofort, offene Fahrten mit Start- und Endkilometerstand zu vervollständigen."
},
"backup_jetzt": {
"name": "Sicherung jetzt",
"description": "Legt sofort eine Sicherung von Profil, Fahrten, Tankvorgängen und Sensor-Zuordnung an."
},
"profil_schreiben": {
"name": "Profil schreiben",
"description": "Überschreibt das komplette Fahrzeugprofil.",
"fields": {
"profil_json": { "name": "Profil", "description": "Das vollständige Fahrzeugprofil als JSON." }
}
},
"entitaeten_schreiben": {
"name": "Sensor-Zuordnung schreiben",
"description": "Speichert die Zuordnung von Home-Assistant-Entitäten zu den Rollen der App und wendet sie sofort an.",
"fields": {
"zuordnung_json": { "name": "Zuordnung", "description": "Feldname zu Entity-ID als JSON." }
}
},
"fahrt_manuell_anlegen": {
"name": "Fahrt anlegen",
"description": "Legt eine Fahrt von Hand an, unabhängig von der automatischen Zündungs-Erkennung.",
"fields": {
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
"start_ort": { "name": "Startort", "description": "Freitext." },
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
"distanz": { "name": "Strecke", "description": "Nur nötig, wenn keine Kilometerstände vorliegen." }
}
},
"fahrt_aktualisieren": {
"name": "Fahrt bearbeiten",
"description": "Ändert eine bestehende Fahrt.",
"fields": {
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." },
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
"start_ort": { "name": "Startort", "description": "Freitext." },
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
"distanz": { "name": "Strecke", "description": "Von Hand gesetzt." }
}
},
"fahrt_loeschen": {
"name": "Fahrt löschen",
"description": "Löscht eine Fahrt unwiderruflich.",
"fields": {
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." }
}
},
"beleg_hochladen": {
"name": "Beleg hochladen",
"description": "Liest einen Tankbeleg (PDF) und trägt ihn in einen bestehenden Tankvorgang nach oder gibt die Werte an die Oberfläche zurück.",
"fields": {
"pdf_base64": { "name": "PDF", "description": "Der Beleg als Base64." },
"dateiname": { "name": "Dateiname", "description": "Unter diesem Namen wird der Beleg abgelegt." },
"tank_id": { "name": "Tankvorgang", "description": "Leer lassen, um nur zu lesen, ohne etwas zu speichern." }
}
},
"tankvorgang_manuell": {
"name": "Tankvorgang anlegen",
"description": "Erfasst einen Tankvorgang von Hand.",
"fields": {
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel; leer = jetzt." },
"liter": { "name": "Liter", "description": "Getankte Menge." },
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
"km": { "name": "Kilometerstand", "description": "Leer = aus dem zugeordneten Sensor." },
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
"station": { "name": "Station", "description": "Name der Tankstelle." },
"distanz": { "name": "Gefahrene Strecke", "description": "Leer = aus der Differenz zum letzten Tankvorgang." },
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
}
},
"tankvorgang_aktualisieren": {
"name": "Tankvorgang bearbeiten",
"description": "Ändert einen bestehenden Tankvorgang.",
"fields": {
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." },
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel." },
"liter": { "name": "Liter", "description": "Getankte Menge." },
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
"km": { "name": "Kilometerstand", "description": "Kilometerstand beim Tanken." },
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
"station": { "name": "Station", "description": "Name der Tankstelle." },
"distanz": { "name": "Gefahrene Strecke", "description": "Seit dem letzten Tankvorgang." },
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
}
},
"tankvorgang_loeschen": {
"name": "Tankvorgang löschen",
"description": "Löscht einen Tankvorgang unwiderruflich.",
"fields": {
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." }
}
},
"bild_hochladen": {
"name": "Fahrzeugbild hochladen",
"description": "Legt ein Fahrzeugfoto unter einem der erwarteten Namen ab.",
"fields": {
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." },
"daten_base64": { "name": "Bild", "description": "Die Bilddaten als Base64." }
}
},
"bild_loeschen": {
"name": "Fahrzeugbild löschen",
"description": "Entfernt ein zuvor hochgeladenes Fahrzeugfoto.",
"fields": {
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." }
}
},
"reifen_wechseln": {
"name": "Reifensatz wechseln",
"description": "Schaltet den aktiven Reifensatz um.",
"fields": {
"satz": { "name": "Satz", "description": "sommer oder winter." }
}
},
"reifen_km_setzen": {
"name": "Reifen-Kilometerstand setzen",
"description": "Korrigiert den gezählten Kilometerstand eines Reifensatzes von Hand.",
"fields": {
"satz": { "name": "Satz", "description": "sommer oder winter." },
"km": { "name": "Kilometer", "description": "Der neue Zählerstand." }
}
},
"backup_wiederherstellen": {
"name": "Sicherung wiederherstellen",
"description": "Überschreibt Profil, Fahrten, Tankvorgänge und Sensor-Zuordnung mit einer zuvor exportierten Sicherung.",
"fields": {
"profil_json": { "name": "Profil", "description": "Fahrzeugprofil als JSON." },
"fahrten_json": { "name": "Fahrten", "description": "Fahrten als JSON-Liste." },
"tankvorgaenge_json": { "name": "Tankvorgänge", "description": "Tankvorgänge als JSON-Liste." },
"entitaeten_json": { "name": "Sensor-Zuordnung", "description": "Optional; fehlt sie, bleibt die aktuelle Zuordnung unangetastet." }
}
},
"historie_importieren": {
"name": "Daten aus dem Verlauf importieren",
"description": "Leitet Fahrten, Tankvorgänge und Batteriewerte rückwirkend aus dem aufgezeichneten Verlauf ab.",
"fields": {
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
}
}
}
}
+127
View File
@@ -0,0 +1,127 @@
"""Lesezugriff auf den aufgezeichneten Zustandsverlauf (recorder).
Über Home Assistants eigene recorder-API
(`homeassistant.components.recorder.history.get_significant_states`), nicht
über direkten SQL-Zugriff auf home-assistant_v2.db: das Datenbankschema des
recorders ist HA-intern und ändert sich zwischen Versionen, die Funktion
dagegen ist die von HA selbst benutzte und stabile Schnittstelle.
`significant_changes_only=False` ist wichtig: bei numerischen Sensoren
(Kilometerstand, Tankfüllstand) liefert der Standardmodus nur "auffällige"
Änderungen und verschluckt genau die kleinen Schritte, aus denen sich
Fahrstrecke und Tankvorgänge zusammensetzen.
WAS HIER GEGENÜBER DER PYSCRIPT-FASSUNG WEGFÄLLT: das Fahrtabschluss-
Screening las den Verlauf früher über die HTTP-REST-API (/api/history/period)
und brauchte dafür ein langlebiges Zugriffstoken in
audi_dashboard/ha_token.txt - ein eigener Installationsschritt, der bei jeder
Neuinstallation vergessen werden konnte und dessen Fehlen sich nur als
Warnung im Protokoll zeigte (Fahrten blieben dann stumm ohne Strecke). Als
echte Integration liest die App den Verlauf direkt; Token und
Installationsschritt entfallen ersatzlos.
Die recorder-Abfrage läuft über den Executor: ein Datenbankzugriff hat im
Event-Loop nichts verloren.
"""
from __future__ import annotations
import datetime
import logging
from homeassistant.components.recorder import get_instance, history
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
Verlaufspunkt = tuple[datetime.datetime, str]
def _rohverlauf(
hass: HomeAssistant, entity_id: str, start: datetime.datetime, ende: datetime.datetime
) -> list[Verlaufspunkt]:
roh = history.get_significant_states(
hass, start, ende, [entity_id], None, True, False
)
ergebnis: list[Verlaufspunkt] = []
for zustand in roh.get(entity_id) or []:
# "unknown"/"unavailable" bedeuten "keine Meldung", nicht "Wert 0" -
# würden sie durchgereicht, ergäbe ein Ausfall der Datenquelle eine
# Fahrt mit absurder Kilometerdifferenz.
if zustand.state in ("unknown", "unavailable", None, ""):
continue
ergebnis.append((zustand.last_updated, zustand.state))
ergebnis.sort(key=lambda p: p[0])
return ergebnis
async def verlauf_lesen(
hass: HomeAssistant,
entity_id: str | None,
start: datetime.datetime,
ende: datetime.datetime,
) -> list[Verlaufspunkt]:
"""Zustandsverlauf einer Entität als aufsteigende Liste von
(Zeitpunkt, Rohwert). Leere Liste, wenn die Entität nicht zugeordnet ist,
im Zeitraum nichts vorliegt oder der recorder nicht erreichbar ist."""
if not entity_id:
return []
try:
return await get_instance(hass).async_add_executor_job(
_rohverlauf, hass, entity_id, start, ende
)
except Exception: # noqa: BLE001 - der Verlauf ist Beiwerk, nie der Kern
# Ohne Verlauf bleibt eine Fahrt ohne Strecke bzw. der Import leer -
# beides ist verkraftbar. Den aufrufenden Ablauf deswegen abzubrechen
# wäre es nicht.
_LOGGER.warning("Verlauf von %s nicht lesbar", entity_id, exc_info=True)
return []
def zahl(wert: object) -> float | None:
try:
return float(wert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def wert_bei(
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
) -> float | None:
"""Der zuletzt vor `zeitpunkt` gemeldete Zahlenwert, sonst der erste
danach, sonst None.
"Zuletzt davor" ist die richtige Wahl für einen Zählerstand: der
Kilometerstand bei Fahrtbeginn ist der, der zuletzt gemeldet wurde, nicht
der nächste (der schon Strecke enthält)."""
davor = None
for ts, wert in verlauf:
gezahlt = zahl(wert)
if gezahlt is None:
continue
if ts <= zeitpunkt:
davor = gezahlt
else:
return davor if davor is not None else gezahlt
return davor
def naechster_wert(
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt]
) -> float | None:
"""Der Wert, dessen Zeitstempel am nächsten an `zielzeit` liegt.
Anders als wert_bei() ist hier bewusst egal, ob der Wert davor oder danach
liegt: der Kilometerstand kommt laut Datenquelle nicht sicher mit
Fahrtende, sondern teils erst mit Beginn der nächsten Fahrt."""
bester_wert = None
beste_diff = None
for ts, wert in verlauf:
gezahlt = zahl(wert)
if gezahlt is None:
continue
diff = abs((ts - zielzeit).total_seconds())
if beste_diff is None or diff < beste_diff:
bester_wert = gezahlt
beste_diff = diff
return bester_wert
@@ -0,0 +1,161 @@
"""Baut die Nutzlasten, die als Entitäts-Attribut `daten` an die Oberfläche
gehen.
Das Frontend liest die Werte über das ganz normale hass.states statt über
einen Dienst mit Rückgabewert: Zustände lesen ist ein seit Jahren stabiler,
einfacher Weg, während das Verhalten response-fähiger Dienste aus Sicht des
Browsers nicht durchgängig dokumentiert ist.
Hier stehen nur die reinen Rechenfunktionen - wer sie wann veröffentlicht,
entscheidet der Koordinator (koordinator.py).
GRÖSSENGRENZE, DIE JETZT KEINE MEHR IST: Zustands-Attribute sind in Home
Assistant nicht für beliebig große Datenmengen gedacht; ab ~16 KB warnt der
Recorder und schreibt sie trotzdem in die Datenbank. Die Fahrten- und
Tankvorgänge-Archive überschreiten das nach ein paar Jahren zwangsläufig. In
der pyscript-Fassung war das eine bekannte, offene Flanke. Diese Integration
markiert das Attribut `daten` als nicht aufzuzeichnen (siehe
_unrecorded_attributes in sensor.py) - die Nutzlast geht weiterhin an jede
Oberfläche, landet aber nie in der Datenbank.
"""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .einstellungen import POSITIONEN, Sensorzuordnung
def zustand_oder_none(hass: HomeAssistant, entity_id: str | None) -> str | None:
"""Sicherer Zustandszugriff.
Eine nicht (mehr) existierende Entität ist hier der Normalfall, solange
Rollen im Setup-Menü unbelegt sind, und bleibt auch danach relevant: fällt
eine Datenquelle aus, soll die Oberfläche das zeigen, nicht an einem
Fehler hängen bleiben."""
if not entity_id:
return None
zustand = hass.states.get(entity_id)
if zustand is None or zustand.state in (None, "", "unknown", "unavailable"):
return None
return zustand.state
def _zu_zahl(wert: object) -> float | None:
try:
return float(wert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _abs_zahl(wert: object) -> float | None:
zahl = _zu_zahl(wert)
return None if zahl is None else abs(zahl)
def _zu_bool(wert: object) -> bool | None:
"""binary_sensor-Zustand als echtes True/False, None bei fehlender Meldung.
None ist hier ausdrücklich kein "nein": ohne zugeordneten Sensor weiß die
App schlicht nicht, ob gefahren wird - und muss das anzeigen dürfen, statt
"steht" zu behaupten."""
if wert is None:
return None
return str(wert).lower() in ("on", "true", "1", "open", "yes")
def _standort(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
"""Live-GPS-Position - 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."""
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
lat_id, lon_id = werte.STANDORT_LAT_SENSOR, werte.STANDORT_LON_SENSOR
if not lat_id or not lon_id:
return leer
lat_zustand = hass.states.get(lat_id)
lon_zustand = hass.states.get(lon_id)
if lat_zustand is None or lon_zustand is None:
return leer
lat = _zu_zahl(zustand_oder_none(hass, lat_id))
lon = _zu_zahl(zustand_oder_none(hass, lon_id))
if lat is None or lon is None:
return leer
return {
"lat": lat,
"lon": lon,
"genauigkeit_m": None,
"zeit": lat_zustand.last_updated.isoformat(),
}
def _sicherheitscheck(hass: HomeAssistant, werte: Sensorzuordnung) -> list[dict]:
"""Die einzeln geprüften Punkte hinter "Sicher abgestellt", 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 Kennzahl unten ab, damit
beide nie auseinanderlaufen können."""
eintraege: list[dict] = []
for pos, sensor in zip(POSITIONEN, werte.TUER_SENSOREN):
w = zustand_oder_none(hass, sensor)
eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off"})
for pos, sensor in zip(POSITIONEN, werte.FENSTER_SENSOREN):
w = zustand_oder_none(hass, sensor)
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"})
for label, sensor in (
("Heckklappe", werte.HECKKLAPPE_SENSOR),
("Motorhaube", werte.HAUBE_SENSOR),
):
w = zustand_oder_none(hass, sensor)
eintraege.append({"label": label, "ok": None if w is None else w == "off"})
return eintraege
def fahrzeugstatus(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss."""
sicherheitscheck = _sicherheitscheck(hass, werte)
# "Sicher abgestellt": 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.
if any(e["ok"] is None for e in sicherheitscheck):
gesichert = None
else:
gesichert = all(e["ok"] for e in sicherheitscheck)
standort = _standort(hass, werte)
return {
"km": _zu_zahl(zustand_oder_none(hass, werte.KM_SENSOR)),
"tankprozent": _zu_zahl(zustand_oder_none(hass, werte.TANK_SENSOR)),
"reichweite_km": _zu_zahl(zustand_oder_none(hass, werte.RANGE_SENSOR)),
"batteriespannung": _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR)),
"gesichert": gesichert,
"sicherheitscheck": sicherheitscheck,
# Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die
# auch die Fahrterkennung als maßgebliches Signal für Fahrtbeginn und
# -ende nimmt - damit sagen Anzeige und Erfassung zwangsläufig
# dasselbe. Vorher wurde dieser Zustand im Frontend aus dem Status der
# Fahrten abgeleitet ("offen" = fährt). Das war falsch: "offen" heißt
# unvollständige Daten, nicht "unterwegs" - eine Fahrt ohne
# Kilometerstand blieb dauerhaft "offen" und das Fahrzeug damit
# dauerhaft "fahrend". None bedeutet: kein Zündungssensor zugeordnet.
"zuendung": _zu_bool(zustand_oder_none(hass, werte.ZUENDUNG_SENSOR)),
"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) - die Streckensensoren liefern
# negative Restkilometer-Werte, hier deshalb der Betrag.
"oelwechsel_faellig_ts": zustand_oder_none(hass, werte.NAECHSTER_OELWECHSEL_SENSOR),
"oelwechsel_faellig_km": _abs_zahl(zustand_oder_none(hass, werte.OELWECHSEL_STRECKE_SENSOR)),
"inspektion_faellig_ts": zustand_oder_none(hass, werte.NAECHSTE_INSPEKTION_SENSOR),
"inspektion_faellig_km": _abs_zahl(zustand_oder_none(hass, werte.INSPEKTION_STRECKE_SENSOR)),
}
@@ -0,0 +1,184 @@
{
"_hinweis": "Vorlage ohne echte Fahrzeug-/Personendaten. Die Integration legt daraus bei der ersten Einrichtung /config/audi_dashboard/fahrzeugprofil.json an und fasst sie danach nie wieder an. Die Platzhalter (VIN, Kennzeichen, Erstzulassung, HU-Termin, Versicherung, Werkstatt, Servicebuch) werden in der App unter Einstellungen -> Fahrzeug einrichten ersetzt, nicht hier.",
"fahrzeug": {
"fin": "",
"kennzeichen": "",
"titel": "RS 4 Avant competition",
"zusatz": "Avant competition",
"untertitel": "quattro · 331 kW",
"details": "2.9 TFSI quattro competition · 331 kW · 450 PS",
"erstzulassung": "",
"hauptuntersuchung_faellig": "",
"tankvolumen_liter": 58,
"ausfuehrung": "competition"
},
"einstellungen": {
"nacht_von": "19:00",
"nacht_bis": "06:00",
"fahrten_pausenzeit_min": 15,
"uebersichtsbild": "Frontansicht",
"kraftstoffanbieter": "Shell",
"smartdeal": { "aktiv": false, "laeuft_ab": "" },
"oelwechsel_intervall": {
"modus": "hersteller",
"km": 15000,
"monate": 12,
"hersteller_km": 30000,
"hersteller_monate": 24
},
"backup_intervall": "aus",
"letztes_backup": null
},
"versicherung": {
"gesellschaft": "",
"vertrag": "",
"nummer": "",
"service": "",
"notfall": "",
"beitrag": 0,
"steueranteil": 0,
"steuersatz": "19 %",
"gueltigAb": "",
"zahlweise": "jährlich",
"teile": [
{ "bez": "Haftpflicht", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
{ "bez": "Vollkasko inklusive Teilkasko", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
{ "bez": "Schutzbrief Mobilität", "betrag": 0, "sf": null, "sfAlt": null, "satz": null, "satzAlt": null }
],
"selbst": [["Vollkasko", "0 €"], ["Teilkasko", "0 €"]],
"schutzbrief": {
"stand": "",
"gilt": "",
"versichert": "",
"ausloeser": [],
"entweder": [],
"gruppen": [],
"nicht": [],
"pflichten": [],
"rang": ""
},
"leistungen": []
},
"steuer": {
"betrag": 0,
"zeitraum": "jährlich",
"faellig": null,
"grundlage": "Hubraum und CO₂-Ausstoß",
"lastschrift": true,
"finanzamt": ""
},
"reifen": {
"aktiv": "sommer",
"anzugsmoment_nm": 120,
"wechsel": { "datum": "", "vereinbart": false },
"referenz_odo_km": null,
"saetze": {
"sommer": {
"km": 0,
"bild": "/local/bilder/rad-sommer.webp",
"marke": "",
"modell": "",
"dot": "",
"mass": "275/30 R20 97Y XL",
"druck_vorne": "2,5",
"druck_hinten": "2,4",
"kommentar": ""
},
"winter": {
"km": 0,
"bild": "/local/bilder/rad-winter.webp",
"marke": "",
"modell": "",
"dot": "",
"mass": "265/35 R19",
"druck_vorne": "2,6",
"druck_hinten": "2,5",
"kommentar": ""
}
}
},
"service": {
"vereinbart": {},
"autohaus": {
"name": "",
"adresse": "",
"tel": "",
"mail": "",
"pos": null
},
"buch": []
},
"technik": [
{ "gruppe": "Motor", "posten": [
["Bauart", "V6 Ottomotor, Direkteinspritzung, Biturbo"],
["Hubraum", "2 894 cm³"],
["Leistung", "331 kW / 450 PS bei 5 7006 700 min⁻¹"],
["Maximales Drehmoment", "600 Nm bei 2 0005 000 min⁻¹"],
["Abgasreinigung", "Katalysator, Lambdasonde, Ottopartikelfilter"],
["Abgasnorm", "EU6 AP"],
["Motormanagement", "vollelektronisch"]
]},
{ "gruppe": "Kraftübertragung", "posten": [
["Getriebe", "8-Gang tiptronic"],
["Antrieb", "quattro, permanent, selbstsperrendes Mittendifferenzial"],
["Kupplung", "hydraulischer Drehmomentwandler mit Überbrückung"]
]},
{ "gruppe": "Fahrleistungen", "posten": [
["0 auf 100 km/h", "3,9 s"],
["Höchstgeschwindigkeit", "290 km/h"],
["Effizienzklasse", "E"],
["Fahrgeräusch", "70 dB(A)"]
]},
{ "gruppe": "Fahrwerk, Lenkung, Bremsen", "posten": [
["Fahrwerk", "RS-Sportfahrwerk"],
["Vorderachse", "5-Lenker, Rohrstabilisator"],
["Hinterachse", "5-Lenker, Rohrstabilisator"],
["Lenkung", "elektromechanisch, geschwindigkeitsabhängig"],
["Wendekreis", "ca. 11,7 m"],
["Bremsanlage", "RS-Stahlbremse, Sättel rot glänzend"],
["Vorn", "Faustsattel, Scheibe innenbelüftet"],
["Hinten", "Faustsattel mit elektrischer Parkbremse, innenbelüftet"]
]},
{ "gruppe": "Räder und Reifen", "posten": [
["Räder", "Leichtmetall 9J × 20, Audi Sport"],
["Reifen", "275/30 R20 97Y XL"],
["Pannenhilfe", "Reifenreparaturset, kein Ersatzrad"],
["Radschrauben", "Standard"],
["Anzugsmoment", "120 Nm"]
]},
{ "gruppe": "Maße, Gewichte, Volumen", "posten": [
["Leergewicht", "1 820 kg"],
["Zulässiges Gesamtgewicht", "2 350 kg"],
["Dachlast / Stützlast", "90 / 80 kg"],
["Anhängelast gebremst", "2 100 kg bei 8 %, 1 900 kg bei 12 %"],
["Anhängelast ungebremst", "750 kg"],
["Gepäckraum", "495 / 1 495 l"],
["Tankinhalt", "58 l, erhöhtes Füllvolumen für quattro"]
]},
{ "gruppe": "Bordnetz", "posten": [
["Batterie", "420 A, 75 Ah"],
["Generator", "100150 A"],
["Kältemittel", "R1234yf"]
]},
{ "gruppe": "Wartung", "posten": [
["Serviceanzeige", "30 000 km oder 2 Jahre, variabel"],
["Garantie", "Anschlussgarantie 3 Jahre, max. 100 000 km"]
]}
],
"ausstattung": [
{ "gruppe": "RS-Paket", "sonder": ["RS competition", "RS-Designpaket rot", "RS-Komfortpaket", "RS-Sportabgasanlage plus", "RS-Sportfahrwerk", "RS-Stahlbremsanlage, Sättel rot glänzend", "Bremssättel rot lackiert", "Anhebung der Höchstgeschwindigkeit auf 290 km/h", "Sicherheitsgurte schwarz mit rotem Rand", "RS-Einstiegsleisten"] },
{ "gruppe": "Außen", "sonder": ["Optikpaket schwarz plus", "Optikpaket schwarz glänzend", "Panorama-Glasdach", "Dachreling schwarz", "Anhängevorrichtung", "Gepäckraumklappe elektrisch öffnend und schließend", "Matrix-LED-Scheinwerfer mit dynamischer Lichtinszenierung", "LED-Heckleuchten mit dynamischer Lichtinszenierung", "Außenspiegel elektrisch, beheiz- und anklappbar, abblendend, mit Memory", "Außenspiegelgehäuse in Wagenfarbe", "Sonnenschutzverglasung abgedunkelt"] },
{ "gruppe": "Innen", "sonder": ["Mikrofaser Dinamica/Leder mit Wabensteppung und RS-Stickung", "Sportsitze plus vorn", "Vordersitze elektrisch mit Memory für den Fahrersitz", "Sitzheizung vorn und hinten", "Lendenwirbelstütze mit Massagefunktion", "Dekoreinlagen Carbon Köper matt", "Ambiente-Lichtpaket plus", "3-Zonen-Komfortklimaautomatik", "Lederlenkrad 3-Speichen mit Lenkradheizung", "Innenspiegel automatisch abblendend, rahmenlos", "USB-Ladeschnittstellen im Fond", "Sonnenschutzrollo hinten", "Fußmatten RS-Designpaket"] },
{ "gruppe": "Infotainment", "sonder": ["MMI Navigation plus mit MMI touch", "Audi virtual cockpit plus", "Head-up-Display", "Bang & Olufsen Premium Sound System mit 3D-Klang", "Audi phone box", "LTE-Unterstützung für Audi phone box", "Audi connect Navigation & Infotainment"] },
{ "gruppe": "Assistenz", "sonder": ["Assistenzpaket Tour", "Assistenzpaket Stadt mit Parken", "Audi adaptive cruise control", "Audi active lane assist", "Halteassistent", "Parkassistent mit Einparkhilfe plus", "Umgebungskameras", "Kamerabasierte Verkehrszeichenerkennung", "Audi pre sense rear und basic", "Garagentoröffner", "Komfortschlüssel mit sensorgesteuerter Heckklappe"] },
{ "gruppe": "Serie, ausgewählt", "sonder": ["Audi drive select", "Audi pre sense city", "Spurwechselwarnung", "Reifendruck-Kontrollanzeige", "Start-Stop-System", "Audi connect Notruf & Service mit Remote & Control", "Smartphone-Interface", "Kraftstoffbehälter mit erhöhtem Füllvolumen", "Pedalerie und Fußstütze aus Edelstahl"] }
]
}
@@ -0,0 +1,76 @@
"""Sensor-zu-Entität-Zuordnung fürs Setup-Menü (Einstellungen -> Fahrzeug
einrichten -> Setup).
audi_dashboard/entitaeten.json enthält nur die vom Nutzer über das Setup-Menü
geänderten Zuordnungen, keyed nach den Feldnamen aus einstellungen.py.
anwenden() legt sie über die eingebauten Standardwerte.
Wichtig ist, dass JEDES bekannte Feld bei jedem Durchlauf gesetzt wird, nicht
nur die belegten. Eine frühere Fassung übersprang leere Werte - weil die
Zuordnung aber ein dauerhaftes Objekt ist, 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.
"""
from __future__ import annotations
from . import einstellungen
from .ablage import Ablage
from .einstellungen import FELDER, SCHLUESSEL, STANDARDWERTE, Sensorzuordnung
def _ist_leer(wert: object) -> bool:
"""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."""
if wert in (None, ""):
return True
if isinstance(wert, list):
return not any(eintrag for eintrag in wert)
return False
class Zuordnung:
"""Hält die wirksame Sensorzuordnung und pflegt sie aus entitaeten.json."""
def __init__(self, ablage: Ablage) -> None:
self._ablage = ablage
self.werte = Sensorzuordnung()
async def anwenden(self) -> None:
"""Setzt für jedes bekannte Feld den wirksamen Wert - den Override,
wenn einer hinterlegt ist, sonst den eingebauten Standardwert."""
overrides = await self._ablage.zuordnung_lesen()
for key in SCHLUESSEL:
wert = overrides.get(key)
standard = STANDARDWERTE[key]
gesetzt = standard if _ist_leer(wert) else wert
setattr(self.werte, key, list(gesetzt) if isinstance(gesetzt, list) else gesetzt)
async def speichern(self, mapping: dict) -> None:
"""Schreibt nur bekannte Schlüssel; 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}
await self._ablage.zuordnung_schreiben(bereinigt)
await self.anwenden()
def aktueller_stand(self) -> dict:
"""Katalog + aktuell wirksamer Wert je Feld, Grundlage für die Entität
sensor.audi_dashboard_entitaeten. Die Werte kommen aus dem
Zuordnungsobjekt selbst, nicht direkt aus der JSON-Datei - damit zeigt
die Oberfläche bereits angewendete Overrides und eingebaute
Standardwerte einheitlich."""
werte = {feld["key"]: getattr(self.werte, feld["key"], None) for feld in FELDER}
return {"katalog": FELDER, "werte": werte, "standardwerte": STANDARDWERTE}
__all__ = ["Zuordnung", "einstellungen"]