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:
@@ -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"}
|
||||
Binary file not shown.
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user