Fahrten, Statistik, Trip-Timer (2026.8.31.3)

Der 15-Minuten-Timer war doppelt: der FMM003 wartet laut Konfiguration
(Trip \ Odometer, Ignition OFF Timeout) selbst 900 s, die App noch einmal
15 Minuten. Zusammen eine halbe Stunde. Die Pausenregel ist damit entfallen -
in der Live-Erkennung, im Historienimport und in beiden Oberflaechen. Der
Schwellwert "ab wann ist es ein Parkplatz" hing an derselben Einstellung, hat
damit aber nichts zu tun und steht jetzt als eigene Zahl.

Hoechst- und Durchschnittsgeschwindigkeit je Fahrt. Der Durchschnitt folgt aus
Strecke und Dauer, vmax aus der neuen Rolle GESCHWINDIGKEIT_SENSOR - laut
Konfiguration liest das Geraet die Geschwindigkeit vom OBD/CAN. Es ist der
groesste gemeldete Wert, nicht die tatsaechliche Spitze: alle zehn Sekunden
ein Datensatz. So ist es auch beschriftet.

Der Langzeitverbrauch war um einen ganzen Tankinhalt zu hoch. Gemessen wird
die Strecke ZWISCHEN erstem und letztem Tankstop; verbraucht wurde darauf,
was ab dem ZWEITEN hineinkam - der erste fuellte die Strecke davor. Bei 13
Tankvorgaengen rund 8 Prozent, im Testfall 16,7 statt 8,3 l/100 km. Der Test
hatte die falsche Rechnung festgeschrieben und ist mitkorrigiert. Eine
negative Strecke (falscher Kilometerstand an einem Tankvorgang) gilt jetzt
als unplausibel statt als Null ohne Erklaerung.

Verbrauch je Fahrt bekommt Grenzen: am Sensor nachgemessen loest
can_fuel_volume 0,1 l auf, ein Schritt entspricht also 10/Strecke l/100 km
Fehler. Unter 3 km sagt die Differenz nichts mehr, ueber 60 l/100 km ist es
kein Verbrauch.

Statistik: zusaetzlich "Absolut", und die Einheit steht jetzt in eckigen
Klammern an der Rubrik statt vier Mal am Zeitraum. Fuenf Spalten passen auf
einem Telefon nicht nebeneinander, das Raster bricht um.

Radfoto in Reifen war ein reines <Bild> - Ersetzen und Loeschen von dort aus
gar nicht erreichbar. Jetzt BildMitMenue wie im Panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 15:03:28 +02:00
parent 17dc42e645
commit 4af38d7e65
23 changed files with 379 additions and 129 deletions
@@ -83,6 +83,7 @@ class Sensorzuordnung:
TANK_DISTANZ_SENSOR: str = ""
# Reichweite (Übersicht).
GESCHWINDIGKEIT_SENSOR: str = ""
RANGE_SENSOR: str = ""
# 12V-Batteriespannung. Beim FMM003 ist das external_power_voltage - die
@@ -183,6 +184,11 @@ FELDER: list[dict] = [
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"beispiel": "calculated_distance_covered_after_the_latest_refueling",
"stichworte": ["strecke tankung", "distance refuel", "distance since refueling", "refuelling"]},
{"key": "GESCHWINDIGKEIT_SENSOR", "label": "Geschwindigkeit", "gruppe": "fahrterkennung",
"hinweis": "Optional - fuer die Hoechstgeschwindigkeit einer Fahrt. Der FMM003 liest sie laut seiner Konfiguration vom OBD/CAN, nicht aus GPS.",
"domains": ["sensor"], "device_classes": ["speed"], "units": ["km/h", "mph"], "liste": False, "pflicht": False,
"beispiel": "vehicle_speed_read_from_can",
"stichworte": ["geschwindigkeit", "speed", "tempo", "kmh"]},
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
"hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
@@ -19,7 +19,6 @@ hier behoben: der Zwischenstand liegt in einem Store (siehe koordinator.py),
from __future__ import annotations
import asyncio
import datetime
import logging
from typing import TYPE_CHECKING
@@ -34,7 +33,15 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
STANDARD_PAUSENZEIT_MIN = 15
# Die frühere Pausenregel (STANDARD_PAUSENZEIT_MIN = 15) ist am 2026-08-31
# entfallen. Der FMM003 wendet sie selbst an - in seiner Konfiguration unter
# "Trip Odometer" steht "Ignition OFF Timeout" auf 900 Sekunden. Eine
# zweite Wartezeit hier oben drauf hätte daraus 30 Minuten gemacht.
#
# Zurückholen, falls die Erkennung je wieder ohne das Gerät auskommen muss:
# eine abbrechbare Wartezeit vor fahrt_beenden() legen (k.warte_ende_ab /
# k.warte_ende_ab_abbrechen im Koordinator sind dafür noch da) und die
# Einstellung fahrten_pausenzeit_min in beiden Oberflächen wieder anbieten.
def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: str) -> dict:
@@ -52,6 +59,7 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle:
"odo_start": None,
"odo_end": None,
"avg_speed_kmh": None,
"vmax_kmh": None,
"verbrauch_l_100km": None,
"start_lat": None,
"start_lon": None,
@@ -68,15 +76,6 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle:
}
async def pausenzeit_sekunden(k: Koordinator) -> int:
profil = await k.ablage.profil_lesen()
if profil is None:
return STANDARD_PAUSENZEIT_MIN * 60
minuten = profil.get("einstellungen", {}).get(
"fahrten_pausenzeit_min", STANDARD_PAUSENZEIT_MIN
)
return int(minuten) * 60
async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) -> None:
"""Reagiert auf jede Zustandsänderung der Zündungs-Entität.
@@ -112,28 +111,17 @@ async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) -
return
if not an_jetzt and fahrt_laeuft:
start_ts = k.fahrt_start_ts
abbruch_ts = datetime.datetime.now(datetime.UTC)
wartezeit = await pausenzeit_sekunden(k)
k.warte_ende_ab(_ende_bestaetigen(k, start_ts, abbruch_ts, wartezeit))
# Ohne Wartezeit: der FMM003 bringt die Pausenregel selbst mit
# (Trip Odometer -> "Ignition OFF Timeout" = 900 s). Was hier
# ankommt, ist bereits eine beendete Fahrt - noch einmal 15 Minuten
# 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))
await k.fahrzeugstatus_veroeffentlichen()
async def _ende_bestaetigen(
k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime, wartezeit: int
) -> None:
"""Wartet die Pausenzeit ab und beendet die Fahrt dann.
Kommt die Zündung vorher zurück, bricht zuendung_geaendert() genau diese
Aufgabe ab und der Code hinter dem sleep läuft nie."""
try:
await asyncio.sleep(wartezeit)
except asyncio.CancelledError:
_LOGGER.debug("Fahrtende verworfen - Zündung kam innerhalb der Pausenzeit zurück")
raise
await fahrt_beenden(k, start_ts, ende_ts)
async def nach_neustart_fortsetzen(k: Koordinator) -> None:
"""Schließt eine Fahrt ab, die während eines HA-Ausfalls zu Ende ging.
@@ -1 +1 @@
{"version":"2026.8.31.1","sha256":"bd990f600b5bf81ce66105af8d49a77d31a2a4e3b4e5070858c38e1e8d23fcc9","bytes":266016,"gebaut":"2026-08-31T12:23:41Z"}
{"version":"2026.8.31.3","sha256":"c0b6aa9b9740fa9c9390a6d200a30aeb8430937a22129e7d61fa88e5452f9283","bytes":266044,"gebaut":"2026-08-31T13:02:37Z"}
@@ -146,7 +146,6 @@ function profilZuConfig(p) {
tankvolumen: p.fahrzeug.tankvolumen_liter,
nachtVon: p.einstellungen.nacht_von,
nachtBis: p.einstellungen.nacht_bis,
pauseMin: p.einstellungen.fahrten_pausenzeit_min,
markenname: p.einstellungen.kraftstoffanbieter,
backupIntervall: p.einstellungen.backup_intervall || "aus",
letztesBackup: p.einstellungen.letztes_backup || null,
@@ -233,7 +232,6 @@ function configCarZuProfil(vorherigesProfil) {
p.fahrzeug.erstzulassung = CAR.erstzulassung;
p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung;
p.einstellungen.uebersichtsbild = CONFIG.startbild;
p.einstellungen.fahrten_pausenzeit_min = CONFIG.pauseMin;
p.einstellungen.backup_intervall = CONFIG.backupIntervall;
p.einstellungen.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb };
p.einstellungen.oelwechsel_intervall = {
@@ -792,9 +790,14 @@ function standortZustand() {
faehrt: false,
unbekannt: CAR.zuendung == null,
seitTs,
parkplatz: minuten != null && minuten >= (CONFIG.pauseMin ?? 15),
parkplatz: minuten != null && minuten >= PARKPLATZ_AB_MIN,
};
}
/* Ab wann aus "steht gerade" ein "Parkplatz" wird. Hing bis 2026-08-31 an
der Einstellung fahrten_pausenzeit_min - die ist mit der Pausenregel
entfallen (der FMM003 bringt sie selbst mit), der Schwellwert hier hat
damit aber nichts zu tun und bleibt als eigene Zahl stehen. */
const PARKPLATZ_AB_MIN = 15;
function dauerSeitText(ts) {
if (ts == null) return "unbekannt";
const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000));
@@ -2901,13 +2904,6 @@ function vEinst() {
</div>
</div>` : ""}
</div>
<div class="tile"><span class="label">Fahrten zusammenfassen</span>
<div class="feld" style="border-bottom:0"><label for="pause">Pause bis</label>
<span class="mitEinheit"><input id="pause" type="number" step="5" min="0"
value="${CONFIG.pauseMin}" data-pause><span>Minuten</span></span></div>
<span class="label" style="margin-top:14px">Steigst du kürzer als diese Zeit aus etwa zum Tanken ,
bleibt es eine zusammenhängende Fahrt statt zweier</span>
</div>
<div class="tile"><span class="label">Fahrzeugprofil</span>
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
@@ -3530,6 +3526,8 @@ function vTrip(id) {
<div class="row"><dt>Startkilometer</dt><dd>${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}</dd></div>
<div class="row"><dt>Endkilometer</dt><dd>${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}</dd></div>
<div class="row"><dt>Dauer</dt><dd>${dauerText(t.duration_s)}</dd></div>
<div class="row"><dt>Ø Geschwindigkeit</dt><dd>${t.avg_speed_kmh != null ? de(t.avg_speed_kmh, 1) + " km/h" : "liegt nicht vor"}<small>Strecke ÷ Dauer, Standzeiten eingerechnet</small></dd></div>
<div class="row"><dt>Höchstgeschwindigkeit</dt><dd>${t.vmax_kmh != null ? de(t.vmax_kmh, 1) + " km/h" : "liegt nicht vor"}<small>größter gemeldeter Wert, alle 10 s ein Datensatz</small></dd></div>
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>Näherung aus Tankfüllstand</small></dd></div>
</dl>
<button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
@@ -3559,6 +3557,8 @@ function statTile(id, label, fig, unit, extra, body) {
tragen - erst über einen Zeitraum gemittelt ergibt das einen sinnvollen
l/100km-Wert. */
function periodStart(art) {
// Alles, was je erfasst wurde - kein Zeitraum, sondern die Summe.
if (art === "absolut") return new Date(0);
const jetzt = new Date();
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1);
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1);
@@ -3606,27 +3606,46 @@ function langzeitverbrauch() {
if (!tanks.length) return { wert: "", km: 0, liter: 0, bis: null, basis: "" };
const letzter = tanks[tanks.length - 1];
const bis = new Date(letzter.ts);
const liter = sum(tanks, (f) => f.liters);
/* Kilometerbasis: bevorzugt der Kilometerstand an den Tankvorgängen selbst -
er zählt auch Strecke mit, für die keine Fahrt aufgezeichnet wurde. Fehlt
er, bleibt die Summe der erfassten Fahrten bis zum letzten Tankstop. */
const mitOdo = tanks.filter((f) => f.odometer_km != null);
let km = 0, basis = "erfasste Fahrten";
let km = 0, basis = "erfasste Fahrten", liter;
if (mitOdo.length >= 2) {
km = mitOdo[mitOdo.length - 1].odometer_km - mitOdo[0].odometer_km;
basis = "Kilometerstand an den Tankvorgängen";
/* Von-Tank-zu-Tank: gemessen wird die Strecke ZWISCHEN erstem und
letztem Tankstop, verbraucht wurde auf ihr alles, was ab dem zweiten
Tankstop hineingefüllt wurde. Der erste Tankvorgang füllte die
Strecke DAVOR - ihn mitzuzählen hat den Verbrauch um einen ganzen
Tankinhalt zu hoch ausgewiesen (bei 13 Tankvorgängen rund 8 %).
Korrigiert am 2026-08-31. */
liter = sum(mitOdo.slice(1), (f) => f.liters);
} else {
km = sum(TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) <= bis), (t) => t.distance_km);
/* Ohne Kilometerstände ist die Bezugsstrecke alles Erfasste bis zum
letzten Tankstop - dann zählt auch der erste Tankvorgang dazu. */
liter = sum(tanks, (f) => f.liters);
}
/* Eine negative Strecke ist kein Messwert, sondern ein falscher
Kilometerstand an einem Tankvorgang (schon einmal aufgetreten, siehe
AGENTS.md Abschnitt AF). Sie darf nicht als Zahl durchgehen. */
if (km < 0) km = 0;
const roh = km > 0 ? (liter / km) * 100 : null;
/* Plausibilitätsfenster: alles außerhalb gilt als unbekannt, statt einen
physikalisch unmöglichen Wert als Tatsache anzuzeigen. */
const gueltig = roh != null && roh >= 3 && roh <= 30;
return { wert: gueltig ? de(roh, 1) : "", km, liter, bis, basis, unplausibel: roh != null && !gueltig };
/* Auch eine Strecke von null zaehlt als unplausibel, wenn sie aus
Kilometerstaenden kommt: dann laufen die Werte an den Tankvorgaengen
rueckwaerts. Ohne diesen Zweig staende dort nur "0 km" und "-", ohne
jeden Hinweis, woran es liegt. */
const streckeFehlt = basis === "Kilometerstand an den Tankvorgängen" && km <= 0;
return { wert: gueltig ? de(roh, 1) : "", km, liter, bis, basis, unplausibel: (roh != null && !gueltig) || streckeFehlt };
}
function vStat() {
const jahr = new Date().getFullYear();
const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag");
const fAbs = fahrtenSeit("absolut"), tAbs = tankSeit("absolut");
const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600;
const tJahr = tankSeit("jahr");
const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0);
@@ -3637,11 +3656,11 @@ function vStat() {
const lv = langzeitverbrauch();
return `
${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h",
quad("Gefahrene Distanz", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) +
quad("Fahrzeit", [[de(hJahr), "Jahr h"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat h"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche h"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag h"]]))}
quad("Gefahrene Distanz [km]", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"], [de(sum(fAbs, (t) => t.distance_km)), "Absolut"]]) +
quad("Fahrzeit [h]", [[de(hJahr), "Jahr"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag"], [de(sum(fAbs, (t) => t.duration_s) / 3600), "Absolut"]]))}
${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €",
quad("Getankte Liter", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) +
quad("Kraftstoffkosten", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"]]))}
quad("Getankte Liter [l]", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"], [de(sum(tAbs, (f) => f.liters)), "Absolut"]]) +
quad("Kraftstoffkosten [€]", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"], [de(sum(tAbs, (f) => f.fuel_total_eur || 0)), "Absolut"]]))}
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "",
`<dl class="rows">
<div class="row"><dt>Getankt insgesamt</dt><dd>${de(lv.liter, 1)} l</dd></div>
@@ -3649,7 +3668,7 @@ function vStat() {
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : ""}</dd></div>
</dl>
${lv.unplausibel ? `<span class="label" style="margin-top:16px;color:var(--warn)">Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.</span>` : ""}
${quad("Näherung je Fahrt, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"], [schnittFahrtVerbrauch(fAbs), "Absolut"]])}
<span class="label" style="margin-top:16px">Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der einzelnen Fahrten, keine Meldung des Fahrzeugs.</span>`)}
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "", "% bei Tag", de(tagKm) + " km",
`<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
@@ -5747,7 +5766,6 @@ function ereignisseVerdrahten() {
CAR.versicherung.selbst[+i][+feld] = e.target.value;
profilSpeichern(); render(); return;
}
if (e.target.dataset.pause !== undefined) { CONFIG.pauseMin = parseInt(e.target.value, 10) || 0; profilSpeichern(); return; }
if (e.target.dataset.sd !== undefined) {
if (e.target.checked) { smartdealPopupOffen = true; render(); }
else { CAR.smartdeal.aktiv = false; profilSpeichern(); render(); }
@@ -886,7 +886,11 @@ button.leaf:active { background: var(--tile-2); }
.tile-b.on { display: block; }
/* -------------------------------------------------------------- Statistik */
.quad { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-3); margin-top: var(--sp-3); }
/* Fuenf Spalten (Jahr/Monat/Woche/Tag/Absolut) passen auf einem Telefon nicht
nebeneinander: bei 307px Innenbreite blieben je 51px, und "209.177" braucht
bei 16px Audi Type Wide rund 60. auto-fit bricht deshalb um - auf dem Telefon
drei plus zwei, auf einem breiten Bildschirm alle fuenf in einer Reihe. */
.quad { display: grid; grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); gap: var(--sp-3); margin-top: var(--sp-3); }
.quad .n { display: block; font-family: "Audi Type Wide", var(--font-stack); font-size: 16px; font-weight: 300; color: var(--fg); line-height: 1.2; }
.quad .l { display: block; margin-top: 4px; font-size: 11px; letter-spacing: normal; color: var(--fg3); line-height: 1.3; }
.bar { display: flex; height: 4px; border-radius: var(--r-pill); overflow: hidden; gap: 2px; }
@@ -42,15 +42,16 @@ import logging
from typing import TYPE_CHECKING
from .batterie import SPANNUNG_MIN_V
from .fahrterkennung import leere_fahrt, pausenzeit_sekunden
from .fahrterkennung import leere_fahrt
from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
from .verlauf import (
UNPLAUSIBLE_KMH,
Verlaufspunkt,
durchschnitt_kmh,
hoechstwert_im_fenster,
route_aus_verlauf,
UNPLAUSIBLE_KMH,
verbrauch_aus_literstaenden,
verlauf_lesen,
Verlaufspunkt,
wert_bei,
wert_in_naehe,
zahl,
@@ -83,15 +84,17 @@ als_zeit = zeit_normalisiert # lokaler Name beibehalten, Funktion lebt jetzt in
def _fahrtfenster(
zuendung_verlauf: list[Verlaufspunkt], pausenzeit_s: int
zuendung_verlauf: list[Verlaufspunkt],
) -> list[tuple[datetime.datetime, datetime.datetime]]:
"""Aus dem Zündungsverlauf die Zeiträume, in denen gefahren wurde.
Zwei Schritte, die zusammen die Pausenregel aus §7.1 nachbilden: erst
jeden zusammenhängenden "on"-Abschnitt sammeln, dann benachbarte
Abschnitte verschmelzen, deren Lücke kürzer als die Pausenzeit ist. Genau
das tut die Live-Erkennung über die abbrechbare Wartezeit, nur eben im
Nachhinein und ohne Warten."""
Jeder zusammenhängende "on"-Abschnitt ist eine Fahrt. Bis 2026-08-31
wurden benachbarte Abschnitte hier zusätzlich verschmolzen, wenn ihre
Lücke kürzer war als eine Pausenzeit - das bildete die damalige
Live-Erkennung nach. Beide sind entfallen, weil der FMM003 die Regel
selbst anwendet ("Ignition OFF Timeout" = 900 s): was im Verlauf als
getrennte Abschnitte ankommt, hat das Gerät bereits als getrennte
Fahrten beurteilt."""
roh: list[tuple[datetime.datetime, datetime.datetime]] = []
offen: datetime.datetime | None = None
for ts, wert in zuendung_verlauf:
@@ -106,17 +109,7 @@ def _fahrtfenster(
# dem gewählten Fenster. Sie beim Fensterende abzuschneiden würde eine
# Fahrt mit erfundener Endzeit erzeugen.
if not roh:
return []
verschmolzen = [roh[0]]
for start, ende in roh[1:]:
vorheriger_start, vorheriges_ende = verschmolzen[-1]
if (start - vorheriges_ende).total_seconds() < pausenzeit_s:
verschmolzen[-1] = (vorheriger_start, ende)
else:
verschmolzen.append((start, ende))
return verschmolzen
return roh
def _ueberschneidet(
@@ -147,10 +140,24 @@ def _ueberschneidet(
return False
def _vmax(
verlauf: list[Verlaufspunkt],
start: datetime.datetime,
ende: datetime.datetime,
) -> float | None:
"""Höchstgeschwindigkeit im Fahrtfenster, unplausible Werte verworfen -
dieselbe Grenze und dieselbe Begründung wie beim Durchschnitt oben und
wie im Live-Weg (screening._vmax_screenen())."""
wert = hoechstwert_im_fenster(verlauf, start, ende)
if wert is None or wert <= 0 or wert > UNPLAUSIBLE_KMH:
return None
return wert
async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"""Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/
Zielkoordinaten aus den übrigen Verläufen ergänzt."""
fenster = _fahrtfenster(verlaeufe["zuendung"], await pausenzeit_sekunden(k))
fenster = _fahrtfenster(verlaeufe["zuendung"])
if not fenster:
return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0}
@@ -159,6 +166,7 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
lat_verlauf = verlaeufe["lat"]
lon_verlauf = verlaeufe["lon"]
tank_liter_verlauf = verlaeufe["tank_liter"]
tempo_verlauf = verlaeufe["tempo"]
angelegt = uebersprungen = zu_kurz = 0
neue: list[dict] = []
@@ -197,6 +205,9 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"odo_start": odo_start,
"odo_end": odo_end,
"avg_speed_kmh": durchschnitt,
# Der größte gemeldete Wert im Fahrtfenster, nicht die
# tatsächliche Spitze - siehe hoechstwert_im_fenster().
"vmax_kmh": _vmax(tempo_verlauf, f_start, f_ende),
"start_lat": wert_bei(lat_verlauf, f_start),
"start_lon": wert_bei(lon_verlauf, f_start),
"end_lat": wert_bei(lat_verlauf, f_ende),
@@ -439,6 +450,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
"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),
}
# Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.31.1",
"version": "2026.8.31.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"],
@@ -25,6 +25,7 @@ from typing import TYPE_CHECKING
from .verlauf import (
UNPLAUSIBLE_KMH,
durchschnitt_kmh,
hoechstwert_im_fenster,
naechster_wert,
route_aus_verlauf,
verbrauch_aus_literstaenden,
@@ -109,6 +110,26 @@ async def durchfuehren(k: Koordinator) -> None:
for fahrt in ohne_position:
await _position_screenen(k, lat_sensor, lon_sensor, fahrt)
# Durchschnitt braucht keinen Sensor - er folgt aus Strecke und Dauer,
# die beide schon dastehen. Deshalb vor dem Geschwindigkeitssensor und
# unabhaengig davon, ob einer zugeordnet ist.
for fahrt in fahrten:
if fahrt.get("avg_speed_kmh") is not None:
continue
schnitt = durchschnitt_kmh(fahrt.get("distance_km"), fahrt.get("duration_s"))
if schnitt is None:
continue
await k.ablage.fahrt_aktualisieren(
fahrt["trip_id"], {"avg_speed_kmh": schnitt}
)
fahrt["avg_speed_kmh"] = schnitt
tempo_sensor = k.zuordnung.werte.GESCHWINDIGKEIT_SENSOR
if tempo_sensor:
ohne_vmax = [f for f in fahrten if f.get("vmax_kmh") is None]
for fahrt in ohne_vmax:
await _vmax_screenen(k, tempo_sensor, fahrt)
tank_liter_sensor = k.zuordnung.werte.TANK_LITER_SENSOR
if tank_liter_sensor:
ohne_verbrauch = [
@@ -229,6 +250,34 @@ async def _verbrauch_screenen(
fahrt["verbrauch_l_100km"] = verbrauch
async def _vmax_screenen(k: Koordinator, tempo_sensor: str, fahrt: dict) -> None:
"""Trägt die Höchstgeschwindigkeit der Fahrt nach.
Anders als beim Kilometerstand wird hier nicht der nächstgelegene Wert
gesucht, sondern der größte innerhalb des Fahrtfensters - und ohne
Suchfenster drumherum, weil ein Wert von vor oder nach der Fahrt nicht
zu ihr gehört.
Unplausible Werte werden verworfen statt angezeigt: dieselbe Grenze wie
beim Durchschnitt (UNPLAUSIBLE_KMH), denn ein Sensorsprung sieht in
beiden Fällen gleich aus."""
start = _als_zeit(fahrt.get("ts_start"))
ende = _als_zeit(fahrt.get("ts_end"))
if start is None or ende is None:
return
verlauf = await verlauf_lesen(k.hass, tempo_sensor, start, ende)
if not verlauf:
return
vmax = hoechstwert_im_fenster(verlauf, start, ende)
if vmax is None or vmax <= 0 or vmax > UNPLAUSIBLE_KMH:
return
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], {"vmax_kmh": vmax})
fahrt["vmax_kmh"] = vmax
async def _lueckenlos_verketten(k: Koordinator) -> None:
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
wurde.
+49 -2
View File
@@ -176,6 +176,50 @@ def route_aus_verlauf(
return punkte if len(punkte) >= 2 else None
def hoechstwert_im_fenster(
verlauf: list[Verlaufspunkt],
start: datetime.datetime,
ende: datetime.datetime,
) -> float | None:
"""Der größte Zahlenwert eines Verlaufs innerhalb von [start, ende].
Für die Höchstgeschwindigkeit einer Fahrt. Das ist der größte
GEMELDETE Wert, nicht die tatsächliche Spitze: der FMM003 schreibt
während der Fahrt alle zehn Sekunden einen Datensatz (Data
Acquisition -> Moving -> "Min Period" = 10 s), eine kurze Spitze
dazwischen sieht niemand. Für eine Fahrtenübersicht ist das genau
genug; als Beweismittel taugt es nicht, und so ist es auch
beschriftet.
None, wenn im Fenster kein einziger Zahlenwert liegt."""
werte = [
float(wert)
for ts, wert in verlauf
if start <= ts <= ende and _als_zahl(wert) is not None
]
return round(max(werte), 1) if werte else None
def _als_zahl(wert: object) -> float | None:
"""Ein Verlaufswert als Zahl, oder None - Zustände wie "unavailable"
kommen hier genauso an wie Messwerte."""
try:
return float(wert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
# Unterhalb dieser Strecke sagt die Literdifferenz nichts mehr: der
# Tanksensor löst 0,1 l auf (an der echten Aufzeichnung nachgemessen,
# 2026-08-31), ein Quantisierungsschritt entspricht also 10/Strecke
# l/100 km Fehler - bei 3 km sind das ±3,3, bei 1 km schon ±10.
MIN_STRECKE_VERBRAUCH_KM = 3.0
# Ein Verbrennerwert darüber ist kein Verbrauch, sondern ein Messfehler
# (Sensorsprung, Tankvorgang kurz vor Fahrtende, Betankung im Stand).
UNPLAUSIBLE_L_100KM = 60.0
def verbrauch_aus_literstaenden(
start_liter: float | None, end_liter: float | None, distance_km: float | None
) -> float | None:
@@ -188,12 +232,15 @@ def verbrauch_aus_literstaenden(
Literstand nicht gefallen ist (z. B. ein Tankvorgang während der Fahrt,
oder unveränderter Füllstand) - in beiden Fällen ist der Quotient nicht
aussagekräftig."""
if start_liter is None or end_liter is None or not distance_km or distance_km <= 0:
if start_liter is None or end_liter is None or not distance_km:
return None
if distance_km < MIN_STRECKE_VERBRAUCH_KM:
return None
verbraucht = start_liter - end_liter
if verbraucht <= 0:
return None
return round(verbraucht / distance_km * 100, 1)
wert = round(verbraucht / distance_km * 100, 1)
return wert if wert <= UNPLAUSIBLE_L_100KM else None
def naechster_wert(