Files
audi-app/custom_components/audi_dashboard/tankerkennung.py
T
tobias d8b12da36d 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>
2026-08-23 23:53:56 +02:00

118 lines
4.1 KiB
Python

"""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),
)