diff --git a/AGENTS.md b/AGENTS.md index 3111414..985cf5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ # AGENTS.md — Project state, review findings, open items, and working rules -**Last updated: 2026-09-01** (Phantomfahrten beim Neustart abgestellt, Tankstand auf `wert_bei()` +**Last updated: 2026-09-01** (Fahrtbeginn und -ende kommen jetzt vom Gerät (`MELDEZEIT_SENSOR`), +Manifest `2026.9.1.3`, siehe Abschnitt AY: dazu die Trennung von +Trip- und Zündungsrolle und die Gerätezeit für Tankvorgänge. Davor am selben Tag: Phantomfahrten beim Neustart abgestellt, Tankstand auf `wert_bei()` nachgezogen, zwölf Nullfahrten gelöscht, „Open items" von vier MQTT-Karteileichen befreit; Manifest `2026.9.1.1`, siehe Abschnitt AX. Davor am 2026-08-31: Streckenberechnung: `wert_bei()` statt `naechster_wert()` und @@ -6393,6 +6395,129 @@ am 2026-09-01 die zwölf Fahrten mit `distance_km == 0` gelöscht — über den Das ist eine Abkehr von der Linie in Abschnitt W und AV, wo Nullfahrten bewusst stehen blieben. Sie gilt, weil der Eigentümer sie ausdrücklich getroffen hat — nicht als neue Gewohnheit. +## AY. Der Dongle ist jetzt führend für Fahrtbeginn und -ende (2026.9.1.2) + +Bis hierher war der Fahrtbeginn `datetime.now()` — die Uhrzeit, zu der Home Assistant den +Zustandswechsel *verarbeitet*. Hat das Gerät gepuffert (Funkloch, Tiefgarage, Tiefschlaf), liegt +die Wahrheit beliebig weit davor. Am 01.09.2026 gemessen: der Datensatz mit dem Fahrtende trug +die Gerätezeit **07:29:04** und kam um **07:35:23** an — sechs Minuten und 19 Sekunden später. + +Neue Sensorrolle **`MELDEZEIT_SENSOR`** (`…_message_timestamp`, Unix-Sekunden). Ist sie +zugeordnet, stammen Beginn und Ende der Fahrt daraus statt aus der Ankunftszeit. + +### Die Zeitzonenfrage, gemessen statt vermutet + +Der Zeitstempel ist **UTC**, keine Ortszeit. Der Versatz zur UTC-Uhr von Home Assistant lag bei +Sekunden (1,3 s / 2,3 s / 232,8 s nach einer Funklücke), nicht bei zwei Stunden. Eine +Zeitzonenumrechnung wäre nicht nur unnötig — sie baute einen Zwei-Stunden-Fehler ein. Die +Gerätekonfiguration stützt das: `107 = After Time Sync` (das Gerät speichert erst, wenn seine +Uhr steht), NTP-Resync alle drei Stunden. + +### Die Wartezeit, und warum sie da sein muss + +Die flespi-Integration setzt die Entitäten eines Datensatzes **nacheinander**: gemessen stand die +Zündung um 07:35:23.633 und die zugehörige Meldezeit 2 ms später um 07:35:23.635. Wer sofort +liest, bekommt den Zeitstempel des *vorigen* Datensatzes — beim Fahren zehn Sekunden daneben, im +Stand aber möglicherweise **Stunden**, weil das Gerät dort nur alle paar Stunden einen Satz +schreibt (siehe die Data-Acquisition-Werte unten). + +`_geraetezeit()` wartet deshalb bis zu `MELDEZEIT_FRIST_S = 2.0` in 50-ms-Schritten darauf, +dass `last_updated` der Meldezeit den Zeitpunkt des Zündungswechsels erreicht. Dafür reicht der +Koordinator die Ereigniszeit jetzt mit durch (`zuendung_geaendert(..., ereigniszeit)`). + +Zwei Notbremsen, beide live ausgelöst und im Protokoll nachgewiesen: + +- **Zukunft** (mehr als 5 Minuten voraus) → verworfen, Ankunftszeit gilt. Eine falsch gestellte + Geräteuhr darf die Auswertung nicht mitreißen. +- **Ende vor dem Beginn** → verworfen, Ankunftszeit gilt. + +### Live nachgewiesen, alle vier Wege + +| Fall | Ergebnis | +|---|---| +| Beginn mit Gerätezeit | Fahrt beginnt `08:04:40`, Ankunft war `08:11:04` — 6:24 früher | +| Ende mit Gerätezeit | Fahrt `08:04:40 → 08:09:22`, 282 s; Ankunft war `08:11:24` | +| Meldezeit vor dem Beginn | Warnung, Ankunft genommen, danach von `MINDESTDAUER_S` verworfen | +| Meldezeit in der Zukunft | Warnung, Ankunft genommen | + +Geprüft über den echten Dienst `entitaeten_schreiben` (Rolle zugeordnet, alle übrigen 35 +Einträge unangetastet); die dabei entstandene Testfahrt wurde über `fahrt_loeschen` wieder +entfernt. Bestand danach unverändert 11 Fahrten, keine mit 0 km, keine offene Fahrt. + +### Was das NICHT löst — die Gerätekonfiguration + +Die `.cfg` vom 01.09.2026 (gzip-gepackte Klartext-Parameterliste, mit `gunzip` lesbar) erklärt +den Rest, und er liegt nicht im Code: + +| ID | Bedeutung | Wert | Standard | +|---|---|---|---| +| 102 / 103 | Sleep settings / Timeout | Deep Sleep / 15 min | Deep Sleep / 1 min | +| 10000 | *On Stop* Min Period | **43.200 s (12 h)** | 3.600 s | +| 10005 | *On Stop* Send Period | **86.400 s (24 h)** | 120 s | +| 10050 / 10055 | *Moving* Min Period / Send Period | 10 s / 60 s | 300 s / — | +| 11800 | Trip-Szenario | **1 = Low priority** | 0 | +| 11803 / 11804 | Start speed / Ignition off timeout | 3 km/h / 900 s | 5 / 60 | +| 11806 | Odometer Calculation source | **1 = OBD** | 0 = GNSS | +| 101 | Ignition settings | 8 = Engine RPM | 4 = Power Voltage | + +Zwei Werte bestimmen alles Weitere: + +1. **`11800 = Low priority`.** Laut Teltonika-Wiki macht Low priority „an additional record", + High priority dagegen „sends event packet **immediately** to the server using GPRS". Fahrtbeginn + und -ende warten also auf das nächste Sendefenster — im Stand bis zu 24 Stunden. +2. **`11806 = OBD`.** Der Kilometerzähler des Geräts ist damit eine Kopie des Fahrzeugwerts und + kann nie feiner sein als der: **ganze Kilometer**. Mit `GNSS` lieferte er nachweislich + Meterauflösung (209177,016 → ,019 → ,022). Gemessen an allen vom CAN gelesenen Streckenwerten + dieses Fahrzeugs gibt es **keinen** mit einer Nachkommastelle: Distanz seit Tanken, Distanz seit + Fehlerlöschung und Reichweite springen in ganzen Kilometern, die Servicedistanz in + Zehnerschritten. `segment_mileage` (die Strecke seit dem letzten Datensatz, `11802 = Between + records`) folgt derselben Quelle: mit GNSS Meter, mit OBD ganze Kilometer. + +### Trip und Zündung sind zwei Rollen, nicht eine (2026.9.1.3) + +Bis hierher zeigte `ZUENDUNG_SENSOR` auf das Trip-Signal — im Setup stand dann „Zündung/ACC-Status" +über einer Trip-Entität, und die echte Zündung war nirgends mehr zugeordnet. Der Eigentümer hat das +getrennt haben wollen, zu Recht: es sind zwei verschiedene Aussagen. + +- **`TRIP_SENSOR`** (neu) — löst Fahrtbeginn und -ende aus. Das gefilterte Signal des Geräts. +- **`ZUENDUNG_SENSOR`** — nur noch die Anzeige „fährt/steht" im Fahrzeugstatus und auf der + Standortkarte. Am Gerät steht die Zündungsquelle jetzt auf `101 = 8` (Engine RPM); ACC und + Bordspannung sind raus, weil unzuverlässig. Der Entitätsname trägt „acc" weiterhin im Text, der + Inhalt kommt aber aus der Drehzahl. + +`verlauf.fahrtsignal(werte)` ist die eine Stelle, die entscheidet, was Fahrten auslöst: der +Trip-Status, wenn zugeordnet, sonst die Zündung. Damit laufen bestehende Installationen unverändert +weiter, und Livepfad (`fahrterkennung.py`, `koordinator.py`) und Rückblick +(`historienimport.py`) können nicht auseinanderlaufen — dieselbe Regel wie bei +`UNPLAUSIBLE_KMH` und `MINDESTDAUER_S`. + +Der Koordinator beobachtet beide: das Fahrtsignal für die Erkennung, die Zündung nur, um den Status +sofort neu zu veröffentlichen. Zeigen beide auf dieselbe Entität, entfällt der zweite Beobachter. + +### Auch der Tankvorgang trägt jetzt die Gerätezeit + +`tankerkennung._automatisch_anlegen()` stempelte mit `datetime.now()` — derselbe Fehler wie bei +den Fahrten, nur unbemerkt. `geraetezeit()` liegt deshalb jetzt in `verlauf.py` und wird von +beiden benutzt; `fahrterkennung._geraetezeit()` ist nur noch eine Weiterleitung. + +Live nachgewiesen: ein aus einem gepufferten Datensatz erkannter Tankvorgang trägt `08:18:45`, +während Home Assistant ihn um `08:38:51` verarbeitet hat — **20 Minuten früher**. + +**Der Livetest hat dabei einen echten Fehler gefangen**, den `py_compile` nicht sehen konnte: der +Import von `geraetezeit` in `tankerkennung.py` fehlte, und der `NameError` trat erst beim +Anlegen auf. Ein weiterer Beleg für die Regel dieses Projekts — kompiliert ist nicht verifiziert. + +### Was am Gerät noch offen ist + +`11800` steht jetzt auf High priority (Fahrtgrenzen werden sofort gesendet), `11806` wird auf +GNSS umgestellt. **Damit ist die Streckenrechnung noch nicht angepasst:** geplant ist, die Strecke +aus dem GNSS-Zähler zu nehmen (Meterauflösung) und den CAN-Kilometerstand als Anker zu behalten, an +dem sich Drift erkennen lässt. Noch nicht gebaut — erst messen, wenn das Gerät wirklich GNSS liefert, +sonst testet man gegen ganze Kilometer. + +`11807` (Odometer Value) steht auf 0. Für die geplante Rechnung ist das gleichgültig, weil sie mit +Zuwächsen arbeitet; wer den absoluten Wert lesbar haben will, trägt dort den echten Kilometerstand ein. + ## Working conventions (observed — keep them) - German is the project language: identifiers, comments, commits, UI texts. Exceptions: diff --git a/custom_components/audi_dashboard/einstellungen.py b/custom_components/audi_dashboard/einstellungen.py index 7d0f168..3dc94a5 100644 --- a/custom_components/audi_dashboard/einstellungen.py +++ b/custom_components/audi_dashboard/einstellungen.py @@ -60,6 +60,34 @@ class Sensorzuordnung: # Kilometerstand der EU-Data-Act-Integration ist der richtige. KM_SENSOR: str = "" + # Das Trip-Signal des Geräts: es beginnt erst, wenn Zündung UND Bewegung + # UND "Start Speed" zusammenkommen, und endet erst nach dem + # "Ignition OFF Timeout" des Geräts. Damit bringt es die Pausentoleranz + # selbst mit, statt dass wir einen zweiten Zeitgeber bauen. + # + # Bewusst getrennt von ZUENDUNG_SENSOR: die rohe Zündung prellt (am + # 29.08.2026 drei Wechsel in 30 ms) und stand am 30./31.08. dreizehn + # Stunden am Stück auf "an", ohne dass gefahren wurde. Als Auslöser für + # Fahrten taugt sie nicht, als Anzeige "Zündung an" sehr wohl. + # + # Leer: dann übernimmt ZUENDUNG_SENSOR die Fahrterkennung wie früher - + # bestehende Installationen laufen ohne Zutun weiter. + TRIP_SENSOR: str = "" + + # Der Zeitstempel, den das Gerät dem Datensatz selbst mitgegeben hat + # (Unix-Sekunden, UTC). Optional, aber der einzige Weg zu einem + # ehrlichen Fahrtbeginn: ohne ihn ist der Beginn der Moment, in dem + # Home Assistant den Wechsel verarbeitet - und der liegt hinter dem + # tatsächlichen, sobald das Gerät gepuffert hat. Am 01.09.2026 + # gemessen: ein Datensatz mit Fahrtende trug die Gerätezeit 07:29:04 + # und kam um 07:35:23 an, sechs Minuten später. + # + # UTC, nicht Ortszeit - ebenfalls nachgemessen, der Versatz zur + # UTC-Uhr von Home Assistant lag bei Sekunden, nicht bei zwei Stunden. + # Eine Zeitzonenumrechnung wäre also nicht nur unnötig, sie baute + # einen Zwei-Stunden-Fehler ein. + MELDEZEIT_SENSOR: str = "" + # Tankfüllstand in Prozent - für die automatische Tankerkennung und die # Anzeige in der Übersicht. TANK_SENSOR: str = "" @@ -157,8 +185,13 @@ SCHLUESSEL: set[str] = set(STANDARDWERTE) # "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.", + {"key": "TRIP_SENSOR", "label": "Trip-Status", "gruppe": "fahrterkennung", + "hinweis": "on = Fahrt läuft. Das gefilterte Fahrtsignal des Geräts - erkennt Fahrtbeginn und -ende. Ohne Zuordnung übernimmt die Zündung diese Aufgabe.", + "domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, + "beispiel": "trip_status_true_if_trip_started_false_if_stopped", + "stichworte": ["trip", "fahrt", "trip status", "journey"]}, + {"key": "ZUENDUNG_SENSOR", "label": "Zündung", "gruppe": "fahrterkennung", + "hinweis": "on = Zündung an. Für die Anzeige \"fährt/steht\"; ohne zugeordneten Trip-Status erkennt sie zusätzlich Fahrtbeginn und -ende.", "domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True, "beispiel": "engine_ignition_or_acc_status", "stichworte": ["zündung", "ignition", "acc", "motor", "engine"]}, @@ -167,6 +200,11 @@ FELDER: list[dict] = [ "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, "beispiel": "total_vehicle_mileage_read_from_can", "stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]}, + {"key": "MELDEZEIT_SENSOR", "label": "Meldezeit des Geräts", "gruppe": "fahrterkennung", + "hinweis": "Zeitstempel des Datensatzes in Unix-Sekunden (UTC). Damit stammen Fahrtbeginn und -ende vom Gerät statt von der Ankunft in Home Assistant.", + "domains": ["sensor"], "device_classes": [], "units": ["s", "seconds"], "liste": False, "pflicht": False, + "beispiel": "message_timestamp", + "stichworte": ["meldezeit", "zeitstempel", "timestamp", "message", "zeit"]}, {"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, diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index e083b7f..1caa8fd 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -26,7 +26,13 @@ from typing import TYPE_CHECKING from . import screening from .ablage import neue_id from .veroeffentlichung import zustand_oder_none -from .verlauf import MINDESTDAUER_S, verlauf_lesen, zeit_normalisiert +from .verlauf import ( + MINDESTDAUER_S, + fahrtsignal, + geraetezeit, + verlauf_lesen, + zeit_normalisiert, +) if TYPE_CHECKING: from .koordinator import Koordinator @@ -77,7 +83,23 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: -async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) -> None: +async def _geraetezeit( + k: Koordinator, + ereigniszeit: datetime.datetime | None, + standard: datetime.datetime, +) -> datetime.datetime: + """Die Meldezeit des Geräts - siehe verlauf.geraetezeit().""" + return await geraetezeit( + k.hass, k.zuordnung.werte.MELDEZEIT_SENSOR, ereigniszeit, standard + ) + + +async def zuendung_geaendert( + k: Koordinator, + neu: str | None, + alt: str | None, + ereigniszeit: datetime.datetime | None = None, +) -> None: """Reagiert auf jede Zustandsänderung der Zündungs-Entität. "unavailable"/"unknown" ist kein Zündungszustand, sondern Funkstille des @@ -119,9 +141,16 @@ async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) - # ganze Zeit lief. fahrt_laeuft = k.fahrt_start_ts is not None + jetzt = datetime.datetime.now(datetime.UTC) + if an_jetzt and not fahrt_laeuft: - await k.fahrt_start_setzen(datetime.datetime.now(datetime.UTC)) - _LOGGER.info("Fahrt gestartet um %s", k.fahrt_start_ts) + beginn = await _geraetezeit(k, ereigniszeit, jetzt) + await k.fahrt_start_setzen(beginn) + _LOGGER.info( + "Fahrt gestartet um %s (%s)", + k.fahrt_start_ts, + "Gerätezeit" if beginn != jetzt else "Ankunft in HA", + ) # 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() @@ -134,7 +163,17 @@ async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) - # zu warten haette daraus eine halbe Stunde gemacht und zwei # aufeinanderfolgende Fahrten verschmolzen, die das Geraet sauber # getrennt hatte. - await fahrt_beenden(k, k.fahrt_start_ts, datetime.datetime.now(datetime.UTC)) + ende = await _geraetezeit(k, ereigniszeit, jetzt) + # Ein Ende vor dem Beginn kann es nicht geben. Passiert, wenn die + # Meldezeit aus einem älteren Datensatz stammt als der Fahrtbeginn - + # dann ist die Ankunftszeit die ehrlichere Angabe. + if ende < k.fahrt_start_ts: + _LOGGER.warning( + "Meldezeit %s liegt vor dem Fahrtbeginn %s - nehme die Ankunft", + ende.isoformat(), k.fahrt_start_ts.isoformat(), + ) + ende = jetzt + await fahrt_beenden(k, k.fahrt_start_ts, ende) await k.fahrzeugstatus_veroeffentlichen() @@ -157,7 +196,7 @@ async def nach_neustart_fortsetzen(k: Koordinator) -> None: if k.fahrt_start_ts is None: return - sensor = k.zuordnung.werte.ZUENDUNG_SENSOR + sensor = fahrtsignal(k.zuordnung.werte) if zustand_oder_none(k.hass, sensor) == "on": return # fährt noch - der Beobachter übernimmt wie sonst auch diff --git a/custom_components/audi_dashboard/historienimport.py b/custom_components/audi_dashboard/historienimport.py index 6f49106..d624c32 100644 --- a/custom_components/audi_dashboard/historienimport.py +++ b/custom_components/audi_dashboard/historienimport.py @@ -47,6 +47,7 @@ from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent from .verlauf import ( MINDESTDAUER_S, durchschnitt_kmh, + fahrtsignal, hoechstwert_im_fenster, route_aus_verlauf, UNPLAUSIBLE_KMH, @@ -440,7 +441,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None: werte = k.zuordnung.werte verlaeufe = { - "zuendung": await verlauf_lesen(k.hass, werte.ZUENDUNG_SENSOR, von, bis), + "zuendung": await verlauf_lesen(k.hass, fahrtsignal(werte), von, bis), "km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis), "tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis), "tank_liter": await verlauf_lesen(k.hass, werte.TANK_LITER_SENSOR, von, bis), diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index 00c5c72..a7c2edf 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -47,7 +47,16 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.start import async_at_started from homeassistant.helpers.storage import Store -from . import batterie, fahrterkennung, identitaet, reifen, screening, sicherung, tankerkennung +from . import ( + batterie, + fahrterkennung, + identitaet, + reifen, + screening, + sicherung, + tankerkennung, + verlauf, +) from .ablage import Ablage from .const import ( BUENDEL_INFO, @@ -207,7 +216,13 @@ class Koordinator: self._beobachter.clear() werte = self.zuordnung.werte - self._beobachten(werte.ZUENDUNG_SENSOR, self._zuendung_geaendert) + # Fahrten löst der Trip-Status aus (oder die Zündung, wenn er fehlt). + self._beobachten(verlauf.fahrtsignal(werte), self._fahrtsignal_geaendert) + # Die Zündung selbst nur noch für die Anzeige "fährt/steht" - sofort + # neu veröffentlichen, statt bis zum nächsten 20-Sekunden-Takt zu + # warten. Entfällt, wenn sie ohnehin schon das Fahrtsignal ist. + if werte.ZUENDUNG_SENSOR != verlauf.fahrtsignal(werte): + self._beobachten(werte.ZUENDUNG_SENSOR, self._zuendung_angezeigt) self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert) self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert) self._beobachten(werte.TANK_LITER_SENSOR, self._tankvolumen_geaendert) @@ -227,9 +242,18 @@ class Koordinator: 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: + async def _zuendung_angezeigt(self, _ereignis: Event[EventStateChangedData]) -> None: + await self.fahrzeugstatus_veroeffentlichen() + + async def _fahrtsignal_geaendert(self, ereignis: Event[EventStateChangedData]) -> None: neu, alt = self._zustaende(ereignis) - await fahrterkennung.zuendung_geaendert(self, neu, alt) + # Der Zeitpunkt, zu dem Home Assistant diesen Wechsel gesehen hat - + # nicht "jetzt". Die Fahrterkennung braucht ihn, um die Meldezeit des + # Geräts demselben Datensatz zuzuordnen (siehe _geraetezeit dort). + zustand = ereignis.data["new_state"] + await fahrterkennung.zuendung_geaendert( + self, neu, alt, zustand.last_updated if zustand else None + ) async def _kilometerstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None: neu, alt = self._zustaende(ereignis) @@ -240,11 +264,17 @@ class Koordinator: async def _tankfuellstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None: neu, _alt = self._zustaende(ereignis) - await tankerkennung.fuellstand_geaendert(self, neu) + zustand = ereignis.data["new_state"] + await tankerkennung.fuellstand_geaendert( + self, neu, zustand.last_updated if zustand else None + ) async def _tankvolumen_geaendert(self, ereignis: Event[EventStateChangedData]) -> None: neu, _alt = self._zustaende(ereignis) - await tankerkennung.tankvolumen_geaendert(self, neu) + zustand = ereignis.data["new_state"] + await tankerkennung.tankvolumen_geaendert( + self, neu, zustand.last_updated if zustand else None + ) # ------------------------------------------------- Laufende Fahrt merken diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 6054d09..4a286d8 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.1.1", + "version": "2026.9.1.3", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": ["@paul"], diff --git a/custom_components/audi_dashboard/tankerkennung.py b/custom_components/audi_dashboard/tankerkennung.py index 8b507a1..1e6cca8 100644 --- a/custom_components/audi_dashboard/tankerkennung.py +++ b/custom_components/audi_dashboard/tankerkennung.py @@ -45,6 +45,7 @@ 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 @@ -95,7 +96,9 @@ def leerer_tankvorgang(ts: str, quelle: str) -> dict: } -async def fuellstand_geaendert(k: Koordinator, neu: str | None) -> None: +async def fuellstand_geaendert( + k: Koordinator, neu: str | None, ereigniszeit: datetime.datetime | None = None +) -> None: aktuell = _als_zahl(neu) if aktuell is None: return @@ -114,11 +117,15 @@ async def fuellstand_geaendert(k: Koordinator, neu: str | None) -> None: # 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") + 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) -> None: +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 @@ -138,7 +145,10 @@ async def tankvolumen_geaendert(k: Koordinator, neu: str | None) -> None: return await _automatisch_anlegen( - k, f"Tankvolumenanstieg {round(anstieg, 1)} l", liter_schaetzung=anstieg + k, + f"Tankvolumenanstieg {round(anstieg, 1)} l", + liter_schaetzung=anstieg, + ereigniszeit=ereigniszeit, ) await k.tiefststand_liter_setzen(aktuell) @@ -155,12 +165,24 @@ async def distanz_seit_tankung(k: Koordinator, odometer_km: float | None) -> flo async def _automatisch_anlegen( - k: Koordinator, anlass: str, liter_schaetzung: float | None = None + 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)) - tankvorgang = leerer_tankvorgang( - datetime.datetime.now(datetime.UTC).isoformat(), "auto" + # 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: diff --git a/custom_components/audi_dashboard/verlauf.py b/custom_components/audi_dashboard/verlauf.py index af008d8..395108e 100644 --- a/custom_components/audi_dashboard/verlauf.py +++ b/custom_components/audi_dashboard/verlauf.py @@ -29,6 +29,8 @@ from __future__ import annotations import datetime import logging +import asyncio + from homeassistant.components.recorder import get_instance, history from homeassistant.core import HomeAssistant @@ -78,6 +80,19 @@ async def verlauf_lesen( return [] +def fahrtsignal(werte: object) -> str: + """Die Entität, die Fahrtbeginn und -ende auslöst. + + Der Trip-Status des Geräts, wenn er zugeordnet ist - sonst die Zündung, + wie vor der Trennung der beiden Rollen. Damit laufen bestehende + Installationen unverändert weiter, ohne dass jemand etwas zuordnen muss. + + Eine gemeinsame Stelle, weil sonst Livepfad (fahrterkennung.py) und + Rückblick (historienimport.py) auseinanderlaufen könnten - dieselbe Regel + wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S.""" + return getattr(werte, "TRIP_SENSOR", "") or getattr(werte, "ZUENDUNG_SENSOR", "") + + def zeit_normalisiert(wert: object) -> datetime.datetime | None: """Akzeptiert ISO-Zeit mit oder ohne Zeitzone und normalisiert auf UTC. @@ -139,6 +154,75 @@ def durchschnitt_kmh(distanz_km: float | None, dauer_s: float | None) -> float | return round(distanz_km / (dauer_s / 3600.0), 1) +# Wie lange auf die Meldezeit desselben Datensatzes gewartet wird. +# +# Die flespi-Integration setzt die Entitäten eines Datensatzes nacheinander: +# am 01.09.2026 gemessen stand die Zündung um 07:35:23.633 und die zugehörige +# Meldezeit 2 ms später um 07:35:23.635. Ohne diese kurze Wartezeit läse man +# den Zeitstempel des VORIGEN Datensatzes - beim Fahren zehn Sekunden daneben, +# im Stand aber möglicherweise Stunden, weil das Gerät dort nur alle paar +# Stunden einen Satz schreibt (Data Acquisition, On Stop: 43200 s). +MELDEZEIT_FRIST_S = 2.0 +MELDEZEIT_TAKT_S = 0.05 + +# Wie weit die Geräteuhr der von Home Assistant vorauslaufen darf, bevor der +# Zeitstempel verworfen wird. Etwas Luft für Drift, aber keine Stunden - eine +# falsch gestellte Geräteuhr darf die Auswertung nicht mitreißen. +MELDEZEIT_VORLAUF = datetime.timedelta(minutes=5) + + +async def geraetezeit( + hass: HomeAssistant, + sensor: str | None, + ereigniszeit: datetime.datetime | None, + standard: datetime.datetime, +) -> datetime.datetime: + """Der Zeitstempel, den das Gerät seinem Datensatz mitgegeben hat - oder + `standard`, wenn er fehlt oder unglaubwürdig ist. + + Warum das nötig ist: `standard` ist die Uhrzeit, zu der Home Assistant den + Zustandswechsel verarbeitet hat. Hat das Gerät gepuffert - Funkloch, + Tiefgarage, Tiefschlaf -, liegt die Wahrheit beliebig weit davor. Am + 01.09.2026 gemessen: ein Datensatz mit Fahrtende trug die Gerätezeit + 07:29:04 und kam um 07:35:23 an, sechs Minuten später. + + Der Wert ist eine Unix-Sekunde in UTC, nicht Ortszeit - ebenfalls + nachgemessen: der Versatz zur UTC-Uhr von Home Assistant lag bei Sekunden, + nicht bei zwei Stunden. Eine Zeitzonenumrechnung wäre nicht nur unnötig, + sie baute einen Zwei-Stunden-Fehler ein. + + Gemeinsam für Fahrterkennung und Tankerkennung: beide legen Datensätze mit + einem Zeitstempel an und dürfen ihn nie unterschiedlich bestimmen - dieselbe + Regel wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S.""" + if not sensor: + return standard + + if ereigniszeit is not None: + wartezeit = 0.0 + while wartezeit < MELDEZEIT_FRIST_S: + zustand = hass.states.get(sensor) + if zustand is not None and zustand.last_updated >= ereigniszeit: + break + await asyncio.sleep(MELDEZEIT_TAKT_S) + wartezeit += MELDEZEIT_TAKT_S + + zustand = hass.states.get(sensor) + try: + gemeldet = datetime.datetime.fromtimestamp( + float(zustand.state), datetime.UTC # type: ignore[union-attr] + ) + except (AttributeError, TypeError, ValueError, OSError, OverflowError): + return standard + + if gemeldet > standard + MELDEZEIT_VORLAUF: + _LOGGER.warning( + "Meldezeit %s liegt in der Zukunft (jetzt %s) - verworfen", + gemeldet.isoformat(), standard.isoformat(), + ) + return standard + return gemeldet + + def wert_bei( verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime ) -> float | None: