Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt (2026.9.4.1)

Das Fahrtfenster stand seit dem 01.09. in der Gerätezeit, der Verlauf war nach
Ankunftszeit sortiert. Zwei Uhren, und das Gerät puffert: was verspätet ankam,
fiel aus einem Fenster heraus, in dem es der Sache nach lag. Am 03.09. hat das
eine Fahrt gekostet - 26 von 48 GPS-Punkten und 3,0 statt 7 km.

verlauf_lesen() stempelt jeden Punkt jetzt mit der Gerätezeit seines
Datensatzes. Eine Stelle, an der alle Verbraucher vorbeikommen. Dazu zwei
Riegel aus derselben Untersuchung: ein Rückschritt von 43 Metern ist keine
Zählerrücksetzung mehr (aus 87 Metern wurden vorher 13,7 km), und das
Fahrtende wird auf das letzte Lebenszeichen des Geräts geklemmt - der Nachlauf
ist eine Rechnung, kein Messwert.

Am echten Recorder nachgerechnet: Tacho 3,0 -> 7,0 km, GNSS 2,952 -> 6,877 km,
Route 26 -> 47 Punkte, Ende 23:21:57 -> 23:17:48. Beim ersten Lauf haben zwei
Fahrten eine Strecke bekommen, die vorher gar keine hatten.

Nicht das Gerät war schuld: es hat lückenlos aufgezeichnet. Die zehn Minuten
Funkstille waren eine offene, aber tote TCP-Sitzung (flespi-Log: 703 s, 16
Nachrichten) - kein Funkloch. Begründung und die Empfehlung fürs Gerät stehen
in AGENTS.md, Abschnitt CB.

Dazu die neun Paritätsbefunde, alle Richtung Panel gelöst - darunter ein
unmaskierter Punkt in beiden Codebasen, der aus "vor 3 Tage" ein "vor 3 Tag"
machte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 02:18:12 +02:00
parent 4a6c48e81c
commit 7f386d19ea
21 changed files with 826 additions and 120 deletions
@@ -31,6 +31,7 @@ from .verlauf import (
MINDESTDAUER_S,
fahrtsignal,
geraetezeit,
geraetezeit_plausibel,
verlauf_lesen,
zeit_normalisiert,
)
@@ -180,6 +181,34 @@ def leere_fahrt(
async def _letzter_datensatz(
k: Koordinator, start_ts: datetime.datetime, bis: datetime.datetime
) -> datetime.datetime | None:
"""Der letzte Datensatz, den das Geraet im Fenster [start_ts, bis]
geschrieben hat - in seiner eigenen Zeit.
Der Meldezeit-Sensor traegt als WERT die Geraetezeit; hier ist also nichts
umzustempeln, der Wert ist die Antwort. Gelesen wird bis jetzt, weil ein
Datensatz aus dem Fenster erst Minuten spaeter ankommen kann (siehe
verlauf.NACHREICHFENSTER)."""
sensor = k.zuordnung.werte.MELDEZEIT_SENSOR
if not sensor:
return None
jetzt = datetime.datetime.now(datetime.UTC)
reihe = await verlauf_lesen(k.hass, sensor, start_ts, max(jetzt, bis))
letzter: datetime.datetime | None = None
for _ts, wert in reihe:
try:
gemeldet = datetime.datetime.fromtimestamp(float(wert), datetime.UTC)
except (TypeError, ValueError, OSError, OverflowError):
continue
if not geraetezeit_plausibel(gemeldet, jetzt):
continue
if start_ts < gemeldet <= bis and (letzter is None or gemeldet > letzter):
letzter = gemeldet
return letzter
async def _echtes_ende(
k: Koordinator,
start_ts: datetime.datetime,
@@ -220,6 +249,27 @@ async def _echtes_ende(
return start_ts
if ueber_trip:
await _nachlauf_gegenpruefen(k, start_ts, signal_ende, gerechnet)
# Nie hinter dem letzten Lebenszeichen des Geraets.
#
# Der Nachlauf ist eine Rechnung, kein Messwert: er unterstellt, dass die
# gemeldete Zuendung wirklich eine verzoegerte Zuendung war. Am 03.09.2026
# war sie das nicht - der Dongle war um 23:17:48 stromlos (Stecker
# gezogen), meldete deshalb ZUENDUNG_NACHLAUF_S spaeter mangels Drehzahl
# -aus- und konnte den Datensatz erst um 23:24:27 absetzen. Abzueglich der
# 180 s ergab das ein Fahrtende um 23:21:57 - vier Minuten nach dem
# letzten Datensatz, den es ueberhaupt gibt. Das Auto stand da laengst.
#
# Was wir wissen koennen, endet beim letzten Datensatz. Alles danach waere
# erfunden, und eine erfundene Zahl ist schlechter als eine knappe.
letzter = await _letzter_datensatz(k, start_ts, gerechnet)
if letzter is not None and letzter < gerechnet:
_LOGGER.info(
"Fahrtende auf %s geklemmt - danach hat das Geraet nichts mehr "
"gemeldet (gerechnet waere %s gewesen)",
letzter.isoformat(), gerechnet.isoformat(),
)
return letzter
return gerechnet
@@ -244,7 +294,9 @@ async def _nachlauf_gegenpruefen(
sensor = k.zuordnung.werte.ZUENDUNG_SENSOR
if not sensor or sensor == fahrtsignal(k.zuordnung.werte):
return
punkte = await verlauf_lesen(k.hass, sensor, start_ts, signal_ende)
punkte = await verlauf_lesen(
k.hass, sensor, start_ts, signal_ende, k.zuordnung.werte.MELDEZEIT_SENSOR
)
aus = [ts for ts, wert in punkte if wert == "off"]
if not aus:
return
@@ -724,7 +776,9 @@ async def _letztes_lebenszeichen(
Kilometerstände ohnehin nach.
"""
jetzt = datetime.datetime.now(datetime.UTC)
punkte = await verlauf_lesen(k.hass, sensor, seit, jetzt)
punkte = await verlauf_lesen(
k.hass, sensor, seit, jetzt, k.zuordnung.werte.MELDEZEIT_SENSOR
)
if punkte:
return punkte[-1][0]
@@ -1 +1 @@
{"version":"2026.9.3.15","sha256":"00c71560c2cd991fa145a79a8d475a6cc4359eaa39342056f406bd3c2ee272c5","bytes":269191,"gebaut":"2026-09-03T19:21:06Z"}
{"version":"2026.9.4.1","sha256":"473807fbe06bc94882a3b53fa76d167a64a15d9470c0a6f993c7042a8bf009b6","bytes":270085,"gebaut":"2026-09-04T00:09:13Z"}
@@ -1624,7 +1624,9 @@ function teaser() {
weder Strecke noch Verbrauch. Ein Strich behauptete, ein Wert fehle -
richtig ist, dass es ihn noch nicht gibt. */""}
<span class="k">Aktuelle Fahrt<small class="laeuft">läuft</small></span>
<button class="zeilenknopf" data-fahrtbeenden>Fahrt beenden</button></div>`
<button class="zeilenknopf" data-fahrtbeenden ${FAHRTENDE_LAEUFT ? "disabled" : ""}>${
FAHRTENDE_LAEUFT ? "Wird beendet …" : "Fahrt beenden"
}</button></div>`
: t ? `<button class="leaf" style="padding-left:0" data-go="trip:${t.trip_id}">
<span class="k">Letzte Fahrt<small>${strecke(t)}</small></span>
<span class="v">${t.ende_vorlaeufig ? "~ " : ""}${streckeText(t.distance_km, "—")}<small${t.ende_vorlaeufig ? " class=\"vorlaeufig\"" : ""}>${t.ende_vorlaeufig ? "vorläufig" : fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "—"}</small></span>
@@ -3145,7 +3147,7 @@ function vEinst() {
${DONGLE.stand && DONGLE.stand.gelesen_am
// Der Punkt der Abkuerzung ist zugleich der Satzpunkt - sonst stuende
// dort "vor 1 Std..". "vor 3 Tage" hat keinen und bekommt einen.
? `Gelesen ${esc(alterText(Date.parse(DONGLE.stand.gelesen_am)).replace(/.$/, ""))}. `
? `Gelesen ${esc(alterText(Date.parse(DONGLE.stand.gelesen_am)).replace(/\.$/, ""))}. `
: "Noch nicht gelesen. "}Der Regler oben schreibt den Schlaf-Timeout sofort
hierher; bis das Gerät sich das nächste Mal meldet, steht die Änderung als
unterwegs" daneben.</span>
@@ -4205,6 +4207,9 @@ let DONGLE = null;
// demselben Grund: der Abruf geht ueber zwei Netze (wir -> flespi -> Geraet)
// und darf nicht wie ein toter Knopf aussehen.
let DONGLE_LAEUFT = false;
// Laeuft gerade ein "Fahrt beenden"? Wie DONGLE_LAEUFT: der Knopf soll zeigen,
// dass etwas passiert, statt still zu bleiben, bis der Push zurueckkommt.
let FAHRTENDE_LAEUFT = false;
// "pruefen" | "installieren" | "neustart" | null - während einer dieser drei
// Aktionen läuft, siehe vEinst()'s Integration-Update-Kachel. Eigens statt
// über serviceRufen() (das feuert-und-vergisst), weil diese drei Aktionen
@@ -6047,7 +6052,16 @@ function ereignisseVerdrahten() {
// in die Einzelfahrt navigiert. Die Zeile ist waehrend der Fahrt zwar ein
// div ohne data-go, aber die Reihenfolge haelt das auch dann, wenn die
// Kachel spaeter einmal anders gebaut wird.
if (e.target.closest("[data-fahrtbeenden]")) { serviceRufen("fahrt_jetzt_beenden", {}); return; }
if (e.target.closest("[data-fahrtbeenden]")) {
// Nicht ueber serviceRufen(): das feuert und vergisst. Hier soll der Knopf
// zeigen, dass gerade geschlossen wird - dieselbe Mechanik wie beim Dongle.
if (FAHRTENDE_LAEUFT) return;
FAHRTENDE_LAEUFT = true; render();
HASS.callService(DOMAIN, "fahrt_jetzt_beenden", {})
.catch((f) => console.error("fahrt_jetzt_beenden", f))
.finally(() => { FAHRTENDE_LAEUFT = false; render(); });
return;
}
if (e.target.closest("[data-dongle]")) {
// Nicht ueber serviceRufen(): das feuert und vergisst, hier soll der Knopf
// aber zeigen, dass gerade zwei Netze befragt werden - und danach wieder
@@ -495,18 +495,21 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
_LOGGER.info("Import gestartet für %s bis %s", von.isoformat(), bis.isoformat())
werte = k.zuordnung.werte
# Gerätezeit statt Ankunftszeit, siehe verlauf.NACHREICHFENSTER - der
# Rückblick muss dieselbe Uhr benutzen wie der Livepfad.
mz = werte.MELDEZEIT_SENSOR
verlaeufe = {
"zuendung": await verlauf_lesen(k.hass, fahrtsignal(werte), von, bis),
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis),
"gnss_km": await verlauf_lesen(k.hass, werte.GNSS_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),
"tank_distanz": await verlauf_lesen(k.hass, werte.TANK_DISTANZ_SENSOR, von, bis),
"batterie": await verlauf_lesen(k.hass, werte.BATTERIE_SENSOR, von, bis),
"aussentemp": await verlauf_lesen(k.hass, werte.AUSSENTEMP_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),
"tempo": await verlauf_lesen(k.hass, werte.GESCHWINDIGKEIT_SENSOR, von, bis),
"zuendung": await verlauf_lesen(k.hass, fahrtsignal(werte), von, bis, mz),
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis, mz),
"gnss_km": await verlauf_lesen(k.hass, werte.GNSS_KM_SENSOR, von, bis, mz),
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis, mz),
"tank_liter": await verlauf_lesen(k.hass, werte.TANK_LITER_SENSOR, von, bis, mz),
"tank_distanz": await verlauf_lesen(k.hass, werte.TANK_DISTANZ_SENSOR, von, bis, mz),
"batterie": await verlauf_lesen(k.hass, werte.BATTERIE_SENSOR, von, bis, mz),
"aussentemp": await verlauf_lesen(k.hass, werte.AUSSENTEMP_SENSOR, von, bis, mz),
"lat": await verlauf_lesen(k.hass, werte.STANDORT_LAT_SENSOR, von, bis, mz),
"lon": await verlauf_lesen(k.hass, werte.STANDORT_LON_SENSOR, von, bis, mz),
"tempo": await verlauf_lesen(k.hass, werte.GESCHWINDIGKEIT_SENSOR, von, bis, mz),
}
# Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.3.15",
"version": "2026.9.4.1",
"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"],
+16 -6
View File
@@ -273,7 +273,9 @@ async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None:
# 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)
punkte = await verlauf_lesen(
k.hass, km_sensor, start - SUCHFENSTER, ende + SUCHFENSTER, k.zuordnung.werte.MELDEZEIT_SENSOR
)
if not punkte:
return
@@ -332,7 +334,9 @@ async def _gnss_verfeinern(k: Koordinator, gnss_sensor: str, fahrt: dict) -> Non
# Etwas vor dem Fahrtbeginn mitlesen, damit zaehlerstrecke() ihren Anker
# findet - den letzten Datensatz am oder vor dem Beginn.
punkte = await verlauf_lesen(k.hass, gnss_sensor, start - SUCHFENSTER, ende)
punkte = await verlauf_lesen(
k.hass, gnss_sensor, start - SUCHFENSTER, ende, k.zuordnung.werte.MELDEZEIT_SENSOR
)
fein = zaehlerstrecke(punkte, start, ende)
gewaehlt, genommen = strecke_waehlen(grob, fein)
if not genommen:
@@ -368,8 +372,12 @@ async def _position_screenen(
if start is None or ende is None:
return
lat_verlauf = await verlauf_lesen(k.hass, lat_sensor, start - SUCHFENSTER, ende + SUCHFENSTER)
lon_verlauf = await verlauf_lesen(k.hass, lon_sensor, start - SUCHFENSTER, ende + SUCHFENSTER)
lat_verlauf = await verlauf_lesen(
k.hass, lat_sensor, start - SUCHFENSTER, ende + SUCHFENSTER, k.zuordnung.werte.MELDEZEIT_SENSOR
)
lon_verlauf = await verlauf_lesen(
k.hass, lon_sensor, start - SUCHFENSTER, ende + SUCHFENSTER, k.zuordnung.werte.MELDEZEIT_SENSOR
)
if not lat_verlauf or not lon_verlauf:
return
@@ -445,7 +453,9 @@ async def _verbrauch_screenen(
if start is None or ende is None:
return
verlauf = await verlauf_lesen(k.hass, tank_liter_sensor, start - SUCHFENSTER, ende + SUCHFENSTER)
verlauf = await verlauf_lesen(
k.hass, tank_liter_sensor, start - SUCHFENSTER, ende + SUCHFENSTER, k.zuordnung.werte.MELDEZEIT_SENSOR
)
if not verlauf:
return
@@ -487,7 +497,7 @@ async def _vmax_screenen(k: Koordinator, tempo_sensor: str, fahrt: dict) -> None
if start is None or ende is None:
return
verlauf = await verlauf_lesen(k.hass, tempo_sensor, start, ende)
verlauf = await verlauf_lesen(k.hass, tempo_sensor, start, ende, k.zuordnung.werte.MELDEZEIT_SENSOR)
if not verlauf:
return
@@ -171,7 +171,9 @@ async def _tacho_spanne(
Der Kilometerstand ist der Tacho des Fahrzeugs: er zaehlt jeden Kilometer,
auch die einer nicht erkannten Fahrt. Genau deshalb taugt er als Mass
dafuer, ob die Fahrtmenge vollstaendig ist."""
verlauf = await verlauf_lesen(k.hass, km_sensor, von - PUFFER, bis + PUFFER)
verlauf = await verlauf_lesen(
k.hass, km_sensor, von - PUFFER, bis + PUFFER, k.zuordnung.werte.MELDEZEIT_SENSOR
)
if not verlauf:
return None
odo_von = wert_bei(verlauf, von)
@@ -216,7 +218,8 @@ async def _ist_voll(k: Koordinator, tank_sensor: str, zeitpunkt: datetime.dateti
Gemessen SETZZEIT nach dem Zeitpunkt des Tankvorgangs - siehe dort, warum
die Zahl lieber knapp als grosszuegig ist."""
verlauf = await verlauf_lesen(
k.hass, tank_sensor, zeitpunkt - PUFFER, zeitpunkt + SETZZEIT + PUFFER
k.hass, tank_sensor, zeitpunkt - PUFFER, zeitpunkt + SETZZEIT + PUFFER,
k.zuordnung.werte.MELDEZEIT_SENSOR,
)
if not verlauf:
return False
+150 -8
View File
@@ -26,6 +26,7 @@ Event-Loop nichts verloren.
from __future__ import annotations
import bisect
import datetime
import logging
@@ -39,12 +40,115 @@ _LOGGER = logging.getLogger(__name__)
Verlaufspunkt = tuple[datetime.datetime, str]
# ---------------------------------------------------------------------------
# Gerätezeit statt Ankunftszeit
#
# Der recorder stempelt jeden Zustand mit `last_updated` - dem Zeitpunkt, zu
# dem Home Assistant ihn VERARBEITET hat. Die Fahrtgrenzen dagegen stehen seit
# dem 01.09.2026 in der GERÄTEZEIT (MELDEZEIT_SENSOR, siehe geraetezeit()).
# Zwei Uhren, und das Gerät puffert: dann fällt alles, was verspätet ankommt,
# aus einem Fenster heraus, in dem es der Sache nach liegt.
#
# Am 03.09.2026 an der Fahrt Eichstätt - Adelschlag gemessen. Das Gerät hat
# lückenlos aufgezeichnet (48 GPS-Punkte, größte Lücke 30 s, 6,88 km), aber ab
# Gerätezeit 23:14:13 nichts mehr abgeliefert - erst um 23:24:52 kam der ganze
# Stau, bis zu 641 s verspätet. Gespeichert wurden davon 26 von 48 Punkten und
# 3,0 statt 7 km, weil das Fenster nach Ankunftszeit schnitt. Dazu kam der
# Rückstau in verkehrter Reihenfolge (der aktuelle Datensatz zuerst, dann der
# Puffer ab dem ältesten), was strecke_aus_zaehler() als Zählerrücksetzung las
# und 9,8 km statt 2,6 km ergab.
#
# Der Riegel sitzt hier, an der einen Stelle, an der alle Verbraucher
# vorbeikommen: wert_bei(), wert_ab(), zaehlerstrecke(), route_aus_verlauf(),
# hoechstwert_im_fenster(), naechster_wert(). Damit rechnen Fenster und Werte
# in derselben Uhr, und die Reihenfolge stimmt wieder.
#
# Der Rückstand während der Fahrt lag am 02.09. bei 4 s im Median, am 03.09.
# abends bei 83 s - das ist kein Ausnahmefall, sondern der Normalbetrieb eines
# puffernden Geräts.
NACHREICHFENSTER = datetime.timedelta(hours=6)
# Wie dicht die Meldezeit am Wert desselben Datensatzes liegen muss.
#
# Die flespi-Integration schreibt die Entitäten eines Datensatzes innerhalb
# weniger Millisekunden und den Zeitstempel zuletzt (am 03.09.2026 an den
# Rohdaten nachgesehen). Eine Sekunde ist dafür reichlich - und eng genug, dass
# ein Zustand einer FREMDEN Integration, der zufällig danebenliegt, nicht die
# Gerätezeit des Dongles aufgedrückt bekommt: er behält dann seine
# Ankunftszeit.
ZUORDNUNG_MAX_S = 1.0
def _marken_aus(zustaende, jetzt: datetime.datetime) -> list[tuple[datetime.datetime, datetime.datetime]]:
"""(Ankunftszeit, Gerätezeit) je Datensatz, aufsteigend nach Ankunft.
Unplausible Zeitstempel fallen hier schon heraus (geraetezeit_plausibel) -
eine 0 im Meldezeit-Sensor ergäbe sonst 1970 und zöge jeden Wert, der zu
diesem Datensatz gehört, mit dorthin."""
marken: list[tuple[datetime.datetime, datetime.datetime]] = []
for zustand in zustaende or []:
if zustand.state in ("unknown", "unavailable", None, ""):
continue
try:
gemeldet = datetime.datetime.fromtimestamp(float(zustand.state), datetime.UTC)
except (TypeError, ValueError, OSError, OverflowError):
continue
if not geraetezeit_plausibel(gemeldet, jetzt):
continue
marken.append((zustand.last_updated, gemeldet))
marken.sort(key=lambda m: m[0])
return marken
def geraetezeit_zuordnen(
punkte: list[Verlaufspunkt],
marken: list[tuple[datetime.datetime, datetime.datetime]],
max_abstand_s: float = ZUORDNUNG_MAX_S,
) -> tuple[list[Verlaufspunkt], int]:
"""Stempelt jeden Verlaufspunkt mit der Gerätezeit SEINES Datensatzes um.
Gesucht wird die Marke, die am oder direkt nach dem Punkt liegt - denn die
Integration schreibt den Zeitstempel zuletzt. Erst wenn dort nichts in
Reichweite ist, zählt die davor. Findet sich gar keine, behält der Punkt
seine Ankunftszeit; die Zahl solcher Punkte kommt als zweiter Rückgabewert
zurück, damit der Aufrufer es merken kann.
Reine Funktion auf Listen, damit sie ohne Home Assistant prüfbar ist."""
if not marken:
return list(punkte), len(punkte)
ankunft = [m[0] for m in marken]
neu: list[Verlaufspunkt] = []
ohne = 0
for ts, wert in punkte:
i = bisect.bisect_left(ankunft, ts)
gemeldet: datetime.datetime | None = None
if i < len(marken) and (marken[i][0] - ts).total_seconds() <= max_abstand_s:
gemeldet = marken[i][1]
elif i > 0 and (ts - marken[i - 1][0]).total_seconds() <= max_abstand_s:
gemeldet = marken[i - 1][1]
if gemeldet is None:
ohne += 1
neu.append((ts, wert))
else:
neu.append((gemeldet, wert))
neu.sort(key=lambda p: p[0])
return neu, ohne
def _rohverlauf(
hass: HomeAssistant, entity_id: str, start: datetime.datetime, ende: datetime.datetime
hass: HomeAssistant,
entity_id: str,
start: datetime.datetime,
ende: datetime.datetime,
meldezeit_id: str | None = None,
) -> list[Verlaufspunkt]:
roh = history.get_significant_states(
hass, start, ende, [entity_id], None, True, False
)
umstempeln = bool(meldezeit_id) and meldezeit_id != entity_id
ids = [entity_id, meldezeit_id] if umstempeln else [entity_id]
# Nachgereichte Datensätze kommen später an, als sie entstanden sind - der
# Lesebereich muss also nach hinten reichen, sonst fehlen genau die, um die
# es geht.
bis = ende + NACHREICHFENSTER if umstempeln else ende
roh = history.get_significant_states(hass, start, bis, ids, None, True, False) # type: ignore[arg-type]
ergebnis: list[Verlaufspunkt] = []
for zustand in roh.get(entity_id) or []:
# "unknown"/"unavailable" bedeuten "keine Meldung", nicht "Wert 0" -
@@ -54,7 +158,19 @@ def _rohverlauf(
continue
ergebnis.append((zustand.last_updated, zustand.state))
ergebnis.sort(key=lambda p: p[0])
return ergebnis
if not umstempeln:
return ergebnis
marken = _marken_aus(roh.get(meldezeit_id), datetime.datetime.now(datetime.UTC))
ergebnis, ohne = geraetezeit_zuordnen(ergebnis, marken)
if ohne:
_LOGGER.debug(
"%s: %d von %d Werten ohne Meldezeit in Reichweite - sie behalten "
"ihre Ankunftszeit", entity_id, ohne, len(ergebnis),
)
# Was jetzt hinter dem Fenster liegt, gehört auch nicht hinein. Der Rand am
# Anfang bleibt: der Wert VOR dem Fenster ist der Anker (siehe wert_bei()).
return [p for p in ergebnis if p[0] <= ende]
async def verlauf_lesen(
@@ -62,15 +178,22 @@ async def verlauf_lesen(
entity_id: str | None,
start: datetime.datetime,
ende: datetime.datetime,
meldezeit_id: str | None = None,
) -> 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."""
im Zeitraum nichts vorliegt oder der recorder nicht erreichbar ist.
Mit `meldezeit_id` tragen die Punkte die **Gerätezeit** statt der
Ankunftszeit - siehe NACHREICHFENSTER, warum das der Normalfall sein muss.
Zu setzen ist sie nur für Entitäten DIESES Geräts; für Sensoren einer
anderen Integration wäre sie falsch, und ZUORDNUNG_MAX_S fängt den Fall
zusätzlich ab."""
if not entity_id:
return []
try:
return await get_instance(hass).async_add_executor_job(
_rohverlauf, hass, entity_id, start, ende
_rohverlauf, hass, entity_id, start, ende, meldezeit_id
)
except Exception: # noqa: BLE001 - der Verlauf ist Beiwerk, nie der Kern
# Ohne Verlauf bleibt eine Fahrt ohne Strecke bzw. der Import leer -
@@ -448,6 +571,22 @@ def ist_gefahren(distanz: float | None) -> bool:
return distanz is None or round(distanz, 1) != 0
# Ab wann ein Rueckwaertsschritt eine Ruecksetzung ist und kein Rauschen.
#
# Eine echte Ruecksetzung faellt auf null: am 01.09.2026 sprang der Zaehler
# mitten in der Fahrt von 0,415 auf 0, als das Geraet seinen konfigurierten
# Startwert uebernahm. Ein Rueckschritt von 6,891 auf 6,848 km ist etwas ganz
# anderes - er entsteht, wenn zwei Datensaetze in derselben Millisekunde
# ankommen und ihre Reihenfolge dadurch nicht mehr eindeutig ist (am
# 03.09.2026 in der Nachlieferung real vorgekommen). Ihn als Ruecksetzung zu
# lesen hiesse, den ganzen Zaehlerstand noch einmal als Strecke zu buchen:
# aus 87 Metern wurden so 13,7 km.
#
# Die beiden Faelle liegen weit auseinander (Verhaeltnis 0,00 gegen 0,99), die
# Grenze in der Mitte ist deshalb unkritisch. Ein kleinerer Rueckschritt zaehlt
# gar nicht - weder vorwaerts noch als Ruecksetzung.
RUECKSETZ_ANTEIL = 0.5
def strecke_aus_zaehler(punkte: list[Verlaufspunkt]) -> float | None:
"""Gefahrene Strecke aus einem laufenden Kilometerzaehler des Geraets.
@@ -478,7 +617,10 @@ def strecke_aus_zaehler(punkte: list[Verlaufspunkt]) -> float | None:
return None
strecke = 0.0
for vorher, jetzt in zip(werte, werte[1:]):
strecke += jetzt if jetzt < vorher else jetzt - vorher
if jetzt >= vorher:
strecke += jetzt - vorher
elif jetzt < vorher * RUECKSETZ_ANTEIL:
strecke += jetzt
return round(strecke, 3)