4cc91c461b
Enthaelt 2026.9.1.2, das nie einzeln ausgeliefert wurde. FAHRT- UND TANKZEITEN KOMMEN VOM GERAET Fahrtbeginn war datetime.now() - die Uhrzeit, zu der Home Assistant den Wechsel verarbeitet. Hat das Geraet gepuffert (Funkloch, Tiefgarage, Tiefschlaf), liegt die Wahrheit beliebig weit davor. Gemessen: ein Datensatz mit Fahrtende trug die Geraetezeit 07:29:04 und kam um 07:35:23 an. Neue Sensorrolle MELDEZEIT_SENSOR (message_timestamp, Unix-Sekunden). Der Wert ist UTC, keine Ortszeit - nachgemessen lag der Versatz zur UTC-Uhr von HA bei Sekunden, nicht bei zwei Stunden; eine Zeitzonenumrechnung baute einen Zwei-Stunden-Fehler ein. geraetezeit() liegt in verlauf.py, weil Fahrt- und Tankerkennung denselben Zeitstempel bestimmen muessen. tankerkennung._automatisch_anlegen() stempelte bisher ebenfalls mit now(); ein aus einem gepufferten Datensatz erkannter Tankvorgang trug damit die Uhrzeit des Auftauchens. Die flespi-Integration setzt die Entitaeten eines Datensatzes nacheinander - gemessen Zuendung 07:35:23.633, zugehoerige Meldezeit 2 ms spaeter. Wer sofort liest, bekommt den vorigen Datensatz: beim Fahren zehn Sekunden daneben, im Stand Stunden (On Stop Min Period = 43200 s). geraetezeit() wartet deshalb bis zu 2 s auf den passenden Wert. Zwei Notbremsen, beide live ausgeloest: Zeitstempel in der Zukunft und Ende vor dem Beginn. TRIP UND ZUENDUNG SIND ZWEI ROLLEN ZUENDUNG_SENSOR zeigte auf das Trip-Signal; im Setup stand "Zuendung/ACC" ueber einer Trip-Entitaet und die echte Zuendung war nirgends zugeordnet. Neu: TRIP_SENSOR loest Fahrten aus, ZUENDUNG_SENSOR liefert nur noch die Anzeige "faehrt/steht". verlauf.fahrtsignal() ist die eine Stelle, die entscheidet - Trip-Status wenn zugeordnet, sonst Zuendung, damit bestehende Installationen unveraendert weiterlaufen und Livepfad und Rueckblick nicht auseinanderlaufen. VERIFIZIERT in audi_ha_test, jeder Weg einzeln: - Fahrtbeginn 08:30:03 (Geraetezeit) statt 08:38:27 (Ankunft) - Fahrtende 08:04:40 -> 08:09:22 statt 08:11:04 -> 08:11:24 - Tankvorgang 08:18:45 statt 08:38:51 - 20 Minuten frueher - Meldezeit in der Zukunft und Ende vor Beginn: Warnung, Ankunft genommen - Zuendung schalten legt keine Fahrt mehr an, Trip-Signal schon Der Livetest fing dabei einen NameError, den py_compile nicht sehen konnte: der Import von geraetezeit in tankerkennung.py fehlte. Kompiliert ist nicht verifiziert. Alle Testdatensaetze wurden ueber die eigenen Dienste wieder entfernt; Bestand unveraendert 11 Fahrten, 13 Tankvorgaenge, keine offene Fahrt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
200 lines
7.7 KiB
Python
200 lines
7.7 KiB
Python
"""Automatische Tankerkennung über den Füllstandssensor - oder, wenn
|
|
zugeordnet, direkt über einen Tankvolumen-Sensor in Litern.
|
|
|
|
Beobachtung: der Füllstand springt beim Fahren nie nach oben, er sinkt nur -
|
|
jeder Anstieg ist also ein Tankvorgang. Zwei Datenquellen dafür, in
|
|
absteigender Genauigkeit:
|
|
|
|
- TANK_LITER_SENSOR (optional, z. B. vom CAN): Anstieg direkt in Litern,
|
|
Schwelle LITER_SCHWELLE. Braucht keine Umrechnung und liefert den Anstieg
|
|
selbst gleich als Vorbelegung für die getankte Menge mit.
|
|
- TANK_SENSOR (Prozent, siehe fuellstand_geaendert()): Schwelle nach Vorgabe
|
|
ab +5 Liter ODER +9 Prozentpunkten Anstieg, wobei die 5-Liter-Vorgabe über
|
|
fahrzeug.tankvolumen_liter aus dem Fahrzeugprofil in Prozentpunkte
|
|
umgerechnet wird (beide Angaben so auf derselben Einheit); es gilt jeweils
|
|
die empfindlichere (kleinere) der beiden Schwellen.
|
|
|
|
Ist der Litersensor zugeordnet, übernimmt er die Erkennung vollständig -
|
|
beide gleichzeitig auszuwerten würde denselben Tankvorgang zweimal anlegen.
|
|
Ohne ihn bleibt es beim Prozentsensor wie bisher.
|
|
|
|
Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich, für beide
|
|
Quellen gleichermaßen: 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 Kosten/Station, Liter nur als grobe Schätzung
|
|
vom Litersensor oder ganz ohne bei reiner Prozent-Erkennung): 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
|
|
from .verlauf import geraetezeit
|
|
|
|
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, ereigniszeit: datetime.datetime | None = 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
|
|
|
|
# Ist ein direkter Litersensor zugeordnet, übernimmt der die Erkennung
|
|
# (siehe tankvolumen_geaendert) - genauer, weil ohne den Umweg über das im
|
|
# Fahrzeugprofil hinterlegte Tankvolumen. Beide gleichzeitig auswerten
|
|
# würde denselben Tankvorgang zweimal anlegen.
|
|
if not k.zuordnung.werte.TANK_LITER_SENSOR:
|
|
await _automatisch_anlegen(
|
|
k, f"Füllstandsanstieg {round(anstieg, 1)} Prozentpunkte", ereigniszeit=ereigniszeit
|
|
)
|
|
await k.tiefststand_setzen(aktuell)
|
|
|
|
|
|
async def tankvolumen_geaendert(
|
|
k: Koordinator, neu: str | None, ereigniszeit: datetime.datetime | None = None
|
|
) -> None:
|
|
"""Wie fuellstand_geaendert(), aber direkt in Litern statt in Prozent -
|
|
braucht deshalb keine Umrechnung über das Fahrzeugprofil und liefert mit
|
|
dem gemessenen Anstieg selbst eine sinnvolle Vorbelegung für die getankte
|
|
Menge (siehe leerer_tankvorgang() - "liters" bleibt sonst None, bis der
|
|
Nutzer sie von Hand einträgt oder ein Beleg sie liefert)."""
|
|
aktuell = _als_zahl(neu)
|
|
if aktuell is None:
|
|
return
|
|
|
|
tiefststand = k.tiefststand_liter
|
|
if tiefststand is None or aktuell <= tiefststand:
|
|
await k.tiefststand_liter_setzen(aktuell)
|
|
return
|
|
|
|
anstieg = aktuell - tiefststand
|
|
if anstieg < LITER_SCHWELLE:
|
|
return
|
|
|
|
await _automatisch_anlegen(
|
|
k,
|
|
f"Tankvolumenanstieg {round(anstieg, 1)} l",
|
|
liter_schaetzung=anstieg,
|
|
ereigniszeit=ereigniszeit,
|
|
)
|
|
await k.tiefststand_liter_setzen(aktuell)
|
|
|
|
|
|
async def distanz_seit_tankung(k: Koordinator, odometer_km: float | None) -> float | None:
|
|
"""Bevorzugt TANK_DISTANZ_SENSOR (vom Fahrzeug selbst berechnet), fällt
|
|
ohne ihn auf die eigene Kilometerstand-Subtraktion zurück - dieselbe
|
|
"Sensor bevorzugt, Berechnung als Rückfall"-Präferenz wie bei
|
|
TANK_LITER_SENSOR gegenüber TANK_SENSOR oben."""
|
|
sensor = _als_zahl(zustand_oder_none(k.hass, k.zuordnung.werte.TANK_DISTANZ_SENSOR))
|
|
if sensor is not None:
|
|
return sensor
|
|
return await k.ablage.distanz_seit_letzter_tankung(odometer_km)
|
|
|
|
|
|
async def _automatisch_anlegen(
|
|
k: Koordinator,
|
|
anlass: str,
|
|
liter_schaetzung: float | None = None,
|
|
ereigniszeit: datetime.datetime | None = None,
|
|
) -> None:
|
|
odometer_km = _als_zahl(zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR))
|
|
# Der Zeitstempel des Geräts, nicht die Ankunft in Home Assistant -
|
|
# dieselbe Begründung wie bei Fahrtbeginn und -ende, siehe
|
|
# verlauf.geraetezeit(). Ein Tankvorgang, der aus einem gepufferten
|
|
# Datensatz erkannt wird, trug bisher die Uhrzeit des Auftauchens; bei
|
|
# 43200 s Aufzeichnungstakt im Stand konnten das Stunden sein.
|
|
ts = await geraetezeit(
|
|
k.hass,
|
|
k.zuordnung.werte.MELDEZEIT_SENSOR,
|
|
ereigniszeit,
|
|
datetime.datetime.now(datetime.UTC),
|
|
)
|
|
tankvorgang = leerer_tankvorgang(ts.isoformat(), "auto")
|
|
tankvorgang["odometer_km"] = odometer_km
|
|
tankvorgang["distance_km"] = await distanz_seit_tankung(k, odometer_km)
|
|
if liter_schaetzung is not None:
|
|
# Grobe Vorbelegung, keine feststehende Menge - der Status bleibt
|
|
# "unvollständig" (siehe leerer_tankvorgang), bis Preis/Tankstelle
|
|
# dazukommen. Der Nutzer korrigiert sie bei Bedarf selbst, ein
|
|
# nachgetragener Beleg überschreibt sie ohnehin.
|
|
tankvorgang["liters"] = round(liter_schaetzung, 1)
|
|
|
|
await k.ablage.tankvorgang_anhaengen(tankvorgang)
|
|
await k.tankvorgaenge_veroeffentlichen()
|
|
_LOGGER.info(
|
|
"Tankvorgang %s automatisch erkannt (%s)", tankvorgang["tank_id"], anlass
|
|
)
|