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
+153 -1
View File
@@ -1,6 +1,8 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-09-03** (Der Regler stellt den Schlaf-Timeout des Dongles, flespi lesend und
**Last updated: 2026-09-04** (Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt:
das Fahrtfenster stand in Geraetezeit, der Verlauf war nach Ankunftszeit sortiert. Dazu die neun
Paritaetsbefunde und die verunreinigte Batteriehistorie, `2026.9.4.1`, Abschnitt CB. Davor: Der Regler stellt den Schlaf-Timeout des Dongles, flespi lesend und
schreibend, die eigene Warteschlange wieder entfallen, `2026.9.3.10`, Abschnitt BZ. Davor: Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie
zurueck, `2026.9.3.8`, Abschnitt BY. Davor: „Fahrt beenden“ auf Knopfdruck mit vorlaeufigem Ende, Schieberegler in
5-Minuten-Schritten und im Tagmodus sichtbar, `2026.9.3.7`, Abschnitt BX.
@@ -9310,3 +9312,153 @@ performance.getEntriesByType("resource")
gespeicherte Stand stammt von vor dem Filter aus `.11`, und `wunsch_uebernehmen()`
liest zu Recht nicht neu, solange das Ziel schon unterwegs ist. Ein Druck auf
„Jetzt lesen" räumt es weg.
## CB. Device time vs. arrival time — the root cause behind a 3-km trip (2026.9.4.1)
Owner report: the last trip is 3 km long, "and in reality considerably longer". It became the
longest single investigation of this project, and almost every hypothesis along the way was wrong.
The findings are recorded in that order on purpose — the wrong turns are the useful part.
### What the device actually did
Nothing wrong. Measured against the recorder, the FMM003 recorded the drive **completely**: 48 GPS
records in the trip window, first at 23:09:40 in Schottenau/Eichstaett, last at 23:17:48 at Am Anger
in Adelschlag, largest gap between two records 30 s, and three independent distance measures that
agree — track 6,758 km, GNSS counter 6,877 km, CAN odometer 7,0 km.
What ended at 23:17:48 was the *supply*: the owner pulled the plug about 40 s after the car came to
rest at home, with the engine still running (`run_time_since_engine_start` counted 144 to 632 s
without a break). From then on there was no CAN, hence no RPM, hence — `ignition_detection.source`
is 8, RPM only — an "ignition off" report 180 s later, which the device could only send at 23:24:27.
**Discarded along the way, each after measuring:** a loose OBD contact (the supply oscillation on
03.09 was the *vehicle's* own charging behaviour — its `control_module_voltage` swung just as much);
a vehicle-side power cut (a gateway blocks CAN, not 12 V, and the supply came back *while parked*,
the opposite of any known behaviour); overcurrent (an FMM003 draws about 0,1 A against a 7,515 A
fuse, and a blown fuse reads 0,00 V, not the observed 1,1 V); a firmware update (the GNSS counter
ran through the event without a reset, 13,725 to 13,753 to 14,015); deep sleep during the drive.
**The 1,1 V is not the internal battery** — that is a separate AVL element (67, range 3,04,2 V)
which this device does not send at all. It is the residual on a floating input after the plug comes
out.
### Why nothing arrived for ten minutes — and it was not a radio hole
The device sends no network elements (signal strength, RSRP, cell) in its records, so "radio hole"
was an assumption, not a finding. flespi's own connection log settled it: the session opened at
23:12:57 stayed **open for 703 seconds and carried 16 messages**, then closed with `close_code 12` at
23:24:37 — the same second in which the device opened a new one to a different flespi address, over
which 35 messages went out in 119 s. The proof that the old session was dead in both directions:
flespi pushed our own `sleep_mode` setting over it at 23:19:15 and got nothing for 5 1/2 minutes;
over the fresh connection the same setting was confirmed in 17 seconds. The device kept writing into
a socket that no longer carried anything, and recovered only because losing external power forced it
to reinitialise. Pulling the plug is what delivered the data.
`/gw/devices/{id}/logs` is readable with the existing "device configuration" token (telemetry and
messages are not — 403). **It is the fastest way to tell a coverage problem from a dead session**
use it before theorising.
**Confirmed from the device's own `config_24.cfg`** (gzip'd plaintext, `gunzip` reads it), which was
the configuration running that evening — 1000 Open Link Timeout 60 s, 1001 Response Timeout 30 s,
1002 Sort by = 1 (Oldest), **1003 Network Ping Timeout = 0, disabled**, and **1004 Acknowledgment
Type = 0, TCP/IP**. With ACK over TCP/IP the device trusts its own stack instead of an
application-level answer, so the 30-second Response Timeout never gets anything to act on; with
Network Ping at 0 there is no independent liveness check either. There was no mechanism that *could*
have noticed. Recommended to the owner: **ACK Type to AVL** and **Network Ping to 60 s**; 1000 and
1001 unchanged.
The same file confirms **`400 = 180`** — `ZUENDUNG_NACHLAUF_S = 180` is right, and the earlier doubt
("maybe 60, from flespi's `ign.stop`") is settled. Independently measured at three clean
ignition-off events: 183 / 184 / 184 s. Also `11800 = 0`: the trip scenario is switched off in the
device, so the 900-second `NACHLAUF_S` currently does not exist at all.
### The defect, and it is ours
**The trip window is in device time; the recorder is indexed by arrival time.** Since 2026-09-01
(section AY) `ts_start`/`ts_end` come from `MELDEZEIT_SENSOR`, while `verlauf._rohverlauf()` stamped
every point with `zustand.last_updated`. Two clocks — and the device buffers.
Measured lag *during drives*: 02.09. median 4 s (11 % over 60 s); 03.09. evening median 83 s
(60 % over 60 s, worst 641 s). Over the whole recorder the median is 78 s. This is normal operation
of a buffering device, not an exception.
Consequences, all from that one cause: 26 of 48 route points stored; 3,0 instead of 7 km; and the
backlog arriving out of order (the current record first, then the buffer oldest-first), which
`strecke_aus_zaehler()` correctly read as a counter reset and turned into 9,8 km.
**The fix sits at the one place every consumer passes through.** `verlauf_lesen()` takes an optional
`meldezeit_id`; with it, every point carries the **device time** of the record it belongs to, and
`wert_bei()`, `wert_ab()`, `zaehlerstrecke()`, `route_aus_verlauf()`, `hoechstwert_im_fenster()` and
`naechster_wert()` all work in the same clock as the window. New: `NACHREICHFENSTER` (6 h — the read
window must extend past `ende`, or the late records are never read at all), `ZUORDNUNG_MAX_S` (1 s;
a point with no mark in reach keeps its arrival time, which also protects entities of *other*
integrations), and the pure, testable `geraetezeit_zuordnen()`.
**Second riegel, from the same investigation:** `RUECKSETZ_ANTEIL = 0.5` in `strecke_aus_zaehler()`.
A backward step only counts as a counter reset if the new value falls far below the old one. The
real reset went 0,415 to 0; the ordering artefact went 6,891 to 6,848. Reading the latter as a reset
booked the whole counter as distance — 87 metres became 13,7 km.
**Third: `ts_end` is clamped to the last sign of life.** `_letzter_datensatz()` in
`fahrterkennung.py`. The nachlauf subtraction is a calculation, not a measurement, and on 03.09 it
placed the end four minutes after the last record that exists.
**Proven against the real recording**, read-only, with the production functions: CAN odometer 3,0 to
**7,0 km** (21332 to 21339), GNSS counter 2,952 to **6,877 km**, route 26 to **47 points** with the
last one in Adelschlag, trip end 23:21:57 (736 s) to **23:17:48** (487 s).
12 regression tests in `tests/verlauf/test_geraetezeit.py`; all 60 backend tests green. Deployed to
`audi_ha_test`, clean restart, and it immediately gave two trips a distance that had **none** before
(`t-f5837cd8160b` 6,958 km and `t-98425e4f140e` 6,857 km, both previously rejected as a backward
jump).
**What it does not do:** repair `t-66c4209276e7`. The screening only ever fills empty fields, so the
stored 3,0 km and the wrong end stay until someone clears them — the owner's data and their call.
### Tooling note that cost real time
`.replace(/.$/, "")` — an unescaped dot, which strips *any* last character — was in both codebases
("Gelesen vor 3 Tag."). Fixing it through a Node script written by a quoted heredoc failed silently:
a doubled backslash in the heredoc arrives single in the JS source and vanishes in the string, so
search and replacement were identical and the script reported success. **Build a backslash as
`String.fromCharCode(92)`, or pass the strings through a JSON file, and always verify afterwards
with `grep -c -F`.**
### Parity findings of 2026-09-03/04, all resolved
Source-level comparison of routes, services, visible strings, map code and tile order. Clean: 24
routes identical, all 28 services wired on the correct side (`entitaeten_schreiben` is panel-only by
the parity rule's own carve-out; `screening_jetzt` is called by neither frontend).
1. **`replace(/.$/, "")`** — real bug, both sides (see above).
2. **Dongle tile wording** — the app said "15 ist unterwegs zum Geraet" and "wir rechnen mit 15", the
panel "-> 15, unterwegs" and "(wir: 15)". Aligned to the panel; `Wertzeile` gained a `warnung`
prop so a deviating value is coloured `var(--warn)` as it is in the panel.
3. **"Zugaenge" tile** sat tenth in the app, second in the panel. Moved.
4. **Einzelbeleg map** — the app drew the generic black start dot with a "Start" tooltip. Now the CI
`poi` pin with its silhouette (verbatim from the panel), a popup with station name and address,
and the centre button; new `.dm-kartenbox`, `.dm-kartensteuerung--klein`, `.dm-nadel`.
5. **Einzelfahrt map**`bindTooltip("Start")` became `bindPopup("Start · " + address)` as in the
panel. A tooltip is practically invisible on a phone, and the address is the point.
6. **Einzelfahrt row order** — Verbrauch moved behind Hoechstgeschwindigkeit.
7. **PDF rejection** — "Bitte den Beleg als PDF auswaehlen." to "Nur PDF-Belege werden unterstuetzt".
8. **Token dialog** — "Token fuer Gitea loeschen?" to "Token loeschen?"; footnote aligned.
9. **"Fahrt beenden"** in the panel had no loading state, unlike its own dongle button. Now
`FAHRTENDE_LAEUFT` with "Wird beendet ...", the same mechanism.
Still open, unchanged: `.dm-fussnote` across 35 sites; `Versicherung.tsx`'s sub-screens never
individually re-verified; the unreproduced map-layer icon report; the unreproduced "Batteriespannung
row not clickable on desktop".
### Battery history is contaminated by bench measurements
Found while clarifying that the 12,2 V after the drive was the owner's desk power supply.
`batterie.py` only checks 1015,5 V, and a power supply at 12,2 V passes. Checked every stored daily
minimum against whether the vehicle was reporting over CAN within plus/minus 5 minutes: **3 of 16
days pass, 13 do not** — including 2026-09-03 (12,149 V at 23:56, the desk). These values feed the
resting voltage, the SOC, the SOH trend and the fallback shown in "Mein Audi / Zustand".
**Not built, proposed:** require a fresh CAN value near the voltage reading before recording it —
the same mechanism as `wert_in_naehe()` with its 300 s window for the outside temperature. Without a
vehicle, nothing gets recorded. The 13 existing entries are the owner's data; removal is via the
swipe gesture in the measurement list.
+64 -62
View File
@@ -396,6 +396,66 @@ export function Einstellungen({
</ActionButton>
</Tile>
{zugaenge.length > 0 && (
<Tile>
<span className="ads-eyebrow">Zugänge</span>
{/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
{zugaenge.map((z) => (
<Accordion
key={z.dienst}
title={z.titel}
summary={
<span className={z.gesetzt ? "dm-token-gesetzt" : "dm-token-offen"}>
{z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"}
</span>
}
>
<p className="dm-erklaerung">{z.zweck}</p>
<Feld label="Neuer Token" last>
<input
className="dm-eingabe"
type="password"
autoComplete="off"
placeholder={z.gesetzt ? "ersetzt den hinterlegten" : "noch keiner hinterlegt"}
value={tokenEntwurf[z.dienst] ?? ""}
onChange={(e) =>
setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value }))
}
/>
</Feld>
<div className="dm-knopfreihe">
<ActionButton
variant="primary"
disabled={tokenLaeuft !== null || !(tokenEntwurf[z.dienst] ?? "").trim()}
onClick={() => void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")}
>
{tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"}
</ActionButton>
{z.gesetzt && (
<ActionButton
disabled={tokenLaeuft !== null}
onClick={() => {
if (bestaetigen("Token löschen?")) {
void tokenSchreiben(z.dienst, "")
}
}}
>
Löschen
</ActionButton>
)}
</div>
</Accordion>
))}
{tokenFehler && <p className="dm-fehler">{tokenFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Ein gespeicherter Token wird nie wieder angezeigt nur ob einer hinterlegt ist.
Dieselben Felder gibt es unter Einstellungen Geräte &amp;
Dienste Audi Dashboard Konfigurieren.
</span>
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Fahrt beenden</span>
<Slider
@@ -429,11 +489,12 @@ export function Einstellungen({
key={w.schluessel}
label={`${w.titel} ${w.nummer}`}
wert={`${w.geraet ?? ""}${w.einheit ? " " + w.einheit : ""}`}
warnung={Boolean(w.abweichung)}
zusatz={
w.offen != null
? `${w.offen} ist unterwegs zum Gerät`
? `${w.offen}, unterwegs`
: w.abweichung
? `wir rechnen mit ${w.unser}`
? `(wir: ${w.unser})`
: undefined
}
/>
@@ -448,7 +509,7 @@ export function Einstellungen({
{dongle.stand?.gelesen_am
// Der Punkt der Abkürzung ist zugleich der Satzpunkt - sonst
// stünde dort "vor 1 Std..".
? `Gelesen ${standAlter(dongle.stand.gelesen_am).replace(/.$/, "")}. `
? `Gelesen ${standAlter(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.
@@ -744,65 +805,6 @@ export function Einstellungen({
</Tile>
)}
{zugaenge.length > 0 && (
<Tile>
<span className="ads-eyebrow">Zugänge</span>
{/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
{zugaenge.map((z) => (
<Accordion
key={z.dienst}
title={z.titel}
summary={
<span className={z.gesetzt ? "dm-token-gesetzt" : "dm-token-offen"}>
{z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"}
</span>
}
>
<p className="dm-erklaerung">{z.zweck}</p>
<Feld label="Neuer Token" last>
<input
className="dm-eingabe"
type="password"
autoComplete="off"
placeholder={z.gesetzt ? "ersetzt den hinterlegten" : "noch keiner hinterlegt"}
value={tokenEntwurf[z.dienst] ?? ""}
onChange={(e) =>
setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value }))
}
/>
</Feld>
<div className="dm-knopfreihe">
<ActionButton
variant="primary"
disabled={tokenLaeuft !== null || !(tokenEntwurf[z.dienst] ?? "").trim()}
onClick={() => void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")}
>
{tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"}
</ActionButton>
{z.gesetzt && (
<ActionButton
disabled={tokenLaeuft !== null}
onClick={() => {
if (bestaetigen(`Token für ${z.titel} löschen?`)) {
void tokenSchreiben(z.dienst, "")
}
}}
>
Löschen
</ActionButton>
)}
</div>
</Accordion>
))}
{tokenFehler && <p className="dm-fehler">{tokenFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Ein gespeicherter Token wird nie wieder angezeigt nur ob einer hinterlegt ist.
Dieselben Felder gibt es in Home Assistant unter Einstellungen Geräte &amp;
Dienste Audi Dashboard Konfigurieren.
</span>
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Version</span>
+10 -8
View File
@@ -115,6 +115,8 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
? { ziel: { lat: fahrt.end_lat, lon: fahrt.end_lon } }
: {})}
{...(fahrt.route && fahrt.route.length >= 2 ? { route: fahrt.route } : {})}
startText={fahrt.start_address ?? fahrt.start_stadt ?? ""}
zielText={fahrt.end_address ?? fahrt.end_stadt ?? ""}
/>
) : (
<Leerzustand
@@ -166,14 +168,6 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "noch offen"}
/>
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
<Wertzeile
label="Verbrauch"
wert={
fahrt.verbrauch_l_100km != null
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
: "liegt nicht vor"
}
/>
<Wertzeile
label="Ø Geschwindigkeit"
wert={
@@ -188,6 +182,14 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
fahrt.vmax_kmh != null ? `${de(fahrt.vmax_kmh)} km/h` : "liegt nicht vor"
}
/>
<Wertzeile
label="Verbrauch"
wert={
fahrt.verbrauch_l_100km != null
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
: "liegt nicht vor"
}
/>
</>
}
/>
+86 -8
View File
@@ -28,6 +28,24 @@ const KACHEL_ZUSATZ = "© OpenStreetMap contributors"
const ROT = "#F50537"
/* Nadel der Tankstelle: die CI-Symbole poi-s und seine Silhouette, verbatim
aus dem Panel (tankstellenMarkerSVG). Bewusst die schlichte Nadel und nicht
poi-car, damit Fahrzeug und Tankstelle unterscheidbar bleiben. Die
Silhouette liegt darunter, weil die reine Kontur auf Kartenkacheln fast
unsichtbar ist - der Befund vom 16.08.2026, gemessen an der Deckung. */
const CI_POI_SIL = '<path fill="#182634" d="M4 8.5C4 4.32 7.32 1 11.5 1S19 4.32 19 8.5c0 1.09-.48 2.53-1.14 4.03a49 49 0 0 1-2.43 4.68A92 92 0 0 1 12 22.67l-.06.08-.01.03-.42-.28-.41.28-.02-.03-.06-.08A66 66 0 0 1 10 21.13a92 92 0 0 1-2.42-3.92 49 49 0 0 1-2.43-4.68C4.48 11.03 4 9.6 4 8.5"/>'
const CI_POI = '<path fill="currentColor" d="M4 8.5C4 4.32 7.32 1 11.5 1S19 4.32 19 8.5c0 1.09-.48 2.53-1.14 4.03a49 49 0 0 1-2.43 4.68A92 92 0 0 1 12 22.67l-.06.08-.01.03-.42-.28-.41.28-.02-.03-.06-.08A66 66 0 0 1 10 21.13a92 92 0 0 1-2.42-3.92 49 49 0 0 1-2.43-4.68C4.48 11.03 4 9.6 4 8.5m7.5 14-.41.28.41.6.41-.6zm0-.89a92 92 0 0 0 3.07-4.9c.87-1.5 1.73-3.1 2.38-4.58A10.4 10.4 0 0 0 18 8.5C18 4.88 15.12 2 11.5 2A6.46 6.46 0 0 0 5 8.5c0 .86.4 2.14 1.05 3.63a48 48 0 0 0 2.38 4.58 91 91 0 0 0 3.07 4.9M8 8.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0M11.5 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5"/>'
const CI_GPS = 'M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z'
function nadelSvg(px = 32): string {
return (
`<svg viewBox="0 0 24 24" width="${px}" height="${px}" fill="none" aria-hidden="true">` +
CI_POI_SIL +
CI_POI +
"</svg>"
)
}
/* Fahrtrichtung auf der Streckenlinie. Deckungsgleich mit spurPfeile() im
Panel - dort steht die ausfuehrliche Begruendung.
@@ -105,13 +123,28 @@ export function Karte({
start,
ziel,
route,
startText,
zielText,
nadel,
}: {
start: Punkt
ziel?: Punkt
/** Echte Streckenlinie aus dem GPS-Verlauf (Backend: screening.py/historienimport.py). */
route?: [number, number][]
/** Anschrift zum Startpunkt - steht im Popup, wie im Panel (initMap). */
startText?: string | null
/** Anschrift zum Zielpunkt. */
zielText?: string | null
/**
* Ein einzelner Ort statt Start und Ziel: die CI-Nadel mit Popup und einem
* Knopf, der wieder darauf zentriert. So zeichnet das Panel den Einzelbeleg
* (tankstelleAufKarteZeigen/mapBoxMitZentrieren); die App zeigte hier bis
* zum 04.09.2026 nur den schwarzen Startpunkt ohne Namen.
*/
nadel?: { name?: string | null; adresse?: string | null }
}) {
const behaelter = useRef<HTMLDivElement | null>(null)
const karteRef = useRef<import("leaflet").Map | null>(null)
useEffect(() => {
const element = behaelter.current
@@ -127,17 +160,32 @@ export function Karte({
karte = L.map(element, { attributionControl: true, scrollWheelZoom: false })
L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
karteRef.current = karte
const punkte: [number, number][] = [[start.lat, start.lon]]
// Deckungsgleich mit initMap() im Panel: Start schwarz mit weißem Ring,
// Ziel rot - erkennbar unterschiedlich, nicht beide in derselben Farbe.
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 })
.bindTooltip("Start")
.addTo(karte)
if (nadel) {
const symbol = L.divIcon({
className: "dm-nadel",
html: nadelSvg(32),
iconSize: [32, 32],
iconAnchor: [15, 31],
})
L.marker([start.lat, start.lon], { icon: symbol })
.bindPopup([nadel.name, nadel.adresse].filter(Boolean).join("<br>"))
.addTo(karte)
} else {
// Deckungsgleich mit initMap() im Panel: Start schwarz mit weißem Ring,
// Ziel rot - erkennbar unterschiedlich, nicht beide in derselben Farbe.
// Popup statt Tooltip, ebenfalls wie im Panel: auf dem Telefon ist ein
// Tooltip praktisch unsichtbar, und die Anschrift ist die Auskunft.
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 })
.bindPopup("Start · " + (startText ?? ""))
.addTo(karte)
}
if (ziel) {
punkte.push([ziel.lat, ziel.lon])
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: ROT, weight: 2, fillColor: ROT, fillOpacity: 1 })
.bindTooltip("Ziel")
.bindPopup("Ziel · " + (zielText ?? ""))
.addTo(karte)
// Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen
// den beiden echten Punkten, kein erfundener Verlauf dazwischen.
@@ -162,9 +210,39 @@ export function Karte({
return () => {
abgebrochen = true
karteRef.current = null
karte?.remove()
}
}, [start, ziel, route])
}, [start, ziel, route, startText, zielText, nadel])
return <div ref={behaelter} className="dm-karte" role="img" aria-label="Karte der Fahrt" />
const karte = (
<div
ref={behaelter}
className="dm-karte"
role="img"
aria-label={nadel ? "Karte der Tankstelle" : "Karte der Fahrt"}
/>
)
if (!nadel) return karte
return (
<div className="dm-kartenbox">
{karte}
<div className="dm-kartensteuerung dm-kartensteuerung--klein">
<button
type="button"
aria-label="Auf die Tankstelle zentrieren"
onClick={() =>
karteRef.current?.flyTo(
[start.lat, start.lon],
Math.max(karteRef.current.getZoom(), 14),
)
}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path fill="currentColor" d={CI_GPS} />
</svg>
</button>
</div>
</div>
)
}
+2 -2
View File
@@ -69,7 +69,7 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na
return (
<>
<Tile className="dm-kartenkachel">
<TankstellenKarte adresse={tank.station_address} />
<TankstellenKarte name={tank.station_name} adresse={tank.station_address} />
</Tile>
<Tile variant="flat" className="dm-distanzkachel">
@@ -367,7 +367,7 @@ function BelegKnopf({
const hochladen = async (datei: File) => {
setzeMeldung(null)
if (datei.type !== "application/pdf" && !datei.name.toLowerCase().endsWith(".pdf")) {
setzeMeldung({ art: "fehler", text: "Bitte den Beleg als PDF auswählen." })
setzeMeldung({ art: "fehler", text: "Nur PDF-Belege werden unterstützt" })
return
}
setzeLaeuft(true)
+1 -1
View File
@@ -223,7 +223,7 @@ function TankFormular({
const belegLesen = async (datei: File) => {
setzeFehler(null)
if (datei.type !== "application/pdf" && !datei.name.toLowerCase().endsWith(".pdf")) {
setzeFehler("Bitte den Beleg als PDF auswählen.")
setzeFehler("Nur PDF-Belege werden unterstützt")
return
}
setzeBelegLaeuft(true)
+11 -2
View File
@@ -15,7 +15,13 @@ import type { Koordinate } from "../daten/geokodierung"
import { Leerzustand } from "./bausteine"
import { Karte } from "./Karte"
export function TankstellenKarte({ adresse }: { adresse: string | null | undefined }) {
export function TankstellenKarte({
name,
adresse,
}: {
name?: string | null | undefined
adresse: string | null | undefined
}) {
const [position, setzePosition] = useState<Koordinate | null>(null)
const [geprueft, setzeGeprueft] = useState(false)
@@ -32,7 +38,10 @@ export function TankstellenKarte({ adresse }: { adresse: string | null | undefin
}
}, [adresse])
if (position) return <Karte start={position} />
// Wie im Panel (tankstelleAufKarteZeigen): die CI-Nadel mit Name und
// Anschrift im Popup, dazu der Knopf, der wieder darauf zentriert.
if (position)
return <Karte start={position} nadel={{ name: name ?? null, adresse: adresse ?? null }} />
// Solange die Auflösung läuft: eine leere Kartenfläche, wie im Panel (die
// Karte steht dort schon, die Nadel kommt nach). Bewusst nicht die
// Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da".
+11 -1
View File
@@ -256,6 +256,7 @@ export function Wertzeile({
wert,
zusatz,
hinweis,
warnung,
}: {
label: ReactNode
wert: ReactNode
@@ -264,11 +265,20 @@ export function Wertzeile({
Angabe - fuer Zeilen, deren rechte Seite nur beschreibt, was hinter der
Kachel liegt, statt selbst ein Messwert zu sein. */
hinweis?: boolean
/** Hebt den Wert als abweichend hervor (var(--warn)), wie das Panel es bei
einem Dongle-Wert tut, der nicht zu unserer Einstellung passt. */
warnung?: boolean
}) {
return (
<div className="dm-wertzeile">
<dt>{label}</dt>
<dd className={hinweis ? "dm-wertzeile__hinweis" : undefined}>
<dd
className={
[hinweis ? "dm-wertzeile__hinweis" : "", warnung ? "dm-wertzeile__warnung" : ""]
.filter(Boolean)
.join(" ") || undefined
}
>
{wert}
{zusatz && <small>{zusatz}</small>}
</dd>
+29
View File
@@ -1696,6 +1696,30 @@ button.dm-listenzeile {
transform: scale(0.94);
}
/* Karte mit eigener Bedienung darauf (Einzelbeleg). Der Kasten traegt die
Positionierung, damit der Knopf sich an DIESER Karte ausrichtet und nicht am
naechsten Vorfahren weiter oben - dieselbe Ueberlegung wie .mapbox im Panel. */
.dm-kartenbox {
position: relative;
border-radius: var(--r-tile);
overflow: hidden;
}
/* Kleine Variante: die Belegkarte ist nur gut 200px hoch, ein 48er-Knopf mit
16px Abstand verdeckte davon ein Viertel. */
.dm-kartensteuerung--klein {
top: 10px;
right: 10px;
}
.dm-kartensteuerung--klein button {
width: 36px;
height: 36px;
}
/* Die CI-Nadel traegt ihre Farben selbst; der Schatten hebt sie von den
Kartenkacheln ab, wie .fahrzeug-pin im Panel. */
.dm-nadel {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45));
}
.dm-standortmenu {
position: absolute;
z-index: 410;
@@ -1995,3 +2019,8 @@ button.dm-listenzeile {
.dm-token-gesetzt { color: var(--ok); }
.dm-token-offen { color: var(--fg3); }
/* Abweichender Wert - im Panel steht er in var(--warn), hier ebenso. */
.dm-wertzeile__warnung {
color: var(--warn);
}
@@ -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)
+2 -1
View File
@@ -34,6 +34,7 @@ LEBENSZEICHEN = datetime.datetime(2026, 9, 3, 8, 20, 0, tzinfo=datetime.UTC)
class FakeWerte:
TRIP_SENSOR = ""
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
MELDEZEIT_SENSOR = ""
class FakeZuordnung:
@@ -299,7 +300,7 @@ class Lebenszeichen(unittest.IsolatedAsyncioTestCase):
@staticmethod
def _k(punkte, zustand):
async def lesen(hass, sensor, start, ende):
async def lesen(hass, sensor, start, ende, meldezeit=None):
return punkte
f.verlauf_lesen = lesen
@@ -42,6 +42,9 @@ class FakeWerte:
def __init__(self, trip=""):
self.TRIP_SENSOR = trip
self.ZUENDUNG_SENSOR = "binary_sensor.zuendung"
# Ohne Meldezeit-Sensor keine Geraetezeit und keine Klemme - die Tests
# pruefen die Nachlaufrechnung, nicht die Zuordnung.
self.MELDEZEIT_SENSOR = ""
class FakeZuordnung:
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Geraetezeit statt Ankunftszeit - der Fehler hinter der Fahrt vom 03.09.2026.
Das Geraet stempelt jeden Datensatz selbst (MELDEZEIT_SENSOR); der recorder
stempelt ihn mit dem Zeitpunkt, zu dem Home Assistant ihn verarbeitet hat.
Solange nichts puffert, sind die beiden Uhren dicht beieinander. Puffert das
Geraet, laufen sie auseinander - und dann schneidet ein Fahrtfenster, das in
Geraetezeit steht, aus einem nach Ankunftszeit sortierten Verlauf das Falsche
heraus.
Die Zahlen in diesen Tests sind gemessen, nicht erfunden: Fahrt Eichstaett -
Adelschlag am 03.09.2026, 23:09:41 bis 23:17:49 Ortszeit.
Aufruf: python3 tests/verlauf/test_geraetezeit.py
"""
import datetime
import os
import sys
import unittest
_HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(os.path.dirname(_HIER)))
from custom_components.audi_dashboard import verlauf as v # noqa: E402
def t(h, m, s=0, us=0):
return datetime.datetime(2026, 9, 3, h, m, s, us, tzinfo=datetime.UTC)
class Zuordnung(unittest.TestCase):
"""geraetezeit_zuordnen(): jeder Wert bekommt die Zeit SEINES Datensatzes."""
def test_normalbetrieb_kaum_versatz(self):
"""Ohne Puffer aendert sich fast nichts - nur die paar Sekunden Laufzeit."""
marken = [(t(21, 13, 47), t(21, 13, 43)), (t(21, 13, 57), t(21, 13, 53))]
punkte = [(t(21, 13, 47), "21335"), (t(21, 13, 57), "21336")]
neu, ohne = v.geraetezeit_zuordnen(punkte, marken)
self.assertEqual(ohne, 0)
self.assertEqual([p[0] for p in neu], [t(21, 13, 43), t(21, 13, 53)])
def test_nachlieferung_landet_im_fahrtfenster(self):
"""Der Kern des Fehlers.
Die Kilometerstaende 21336 bis 21339 entstanden zwischen 21:14:13 und
21:17:03 Geraetezeit, kamen aber erst um 21:24:54 an - also nach dem
gerechneten Fahrtende. Nach Ankunftszeit fallen sie aus dem Fenster,
nach Geraetezeit liegen sie mitten darin."""
ende = t(21, 17, 49)
marken = [
(t(21, 13, 47), t(21, 13, 43)),
(t(21, 24, 54), t(21, 14, 13)),
(t(21, 24, 54, 100000), t(21, 14, 23)),
(t(21, 24, 56), t(21, 16, 43)),
(t(21, 24, 56, 100000), t(21, 17, 3)),
]
punkte = [
(t(21, 13, 47), "21335"),
(t(21, 24, 54), "21336"),
(t(21, 24, 54, 100000), "21337"),
(t(21, 24, 56), "21338"),
(t(21, 24, 56, 100000), "21339"),
]
# Vorher: nur der erste Wert liegt vor dem Fahrtende.
self.assertEqual(len([p for p in punkte if p[0] <= ende]), 1)
neu, ohne = v.geraetezeit_zuordnen(punkte, marken)
self.assertEqual(ohne, 0)
self.assertEqual(len([p for p in neu if p[0] <= ende]), 5)
# Und damit stimmt die Strecke wieder: 21339 - 21332 = 7 km statt 3.
self.assertEqual(v.wert_bei(neu, ende), 21339.0)
def test_verkehrte_reihenfolge_wird_gerade_gezogen(self):
"""Der Rueckstau kommt nicht in Entstehungsreihenfolge an.
Das Geraet schickt beim Verbindungsaufbau erst den aktuellen Datensatz
und danach den Puffer ab dem aeltesten. Nach Ankunftszeit sieht der
GNSS-Zaehler dadurch aus, als sei er zurueckgesprungen - und
strecke_aus_zaehler() liest einen Ruecksprung zu Recht als
Zaehlerruecksetzung. Genau so entstanden am 03.09. die 9,8 km statt
2,6 km."""
marken = [
(t(21, 9, 58), t(21, 9, 41)),
(t(21, 10, 6), t(21, 9, 40)),
(t(21, 10, 6, 100000), t(21, 9, 51)),
]
punkte = [
(t(21, 9, 58), "6.891"),
(t(21, 10, 6), "6.848"),
(t(21, 10, 6, 100000), "6.935"),
]
# Nach Ankunftszeit steht der Zaehler nicht monoton - genau daraus
# entstanden die gemeldeten 9,8 km. Zwei Riegel dagegen: RUECKSETZ_ANTEIL
# (ein Rueckschritt von 43 m ist keine Ruecksetzung) und die
# Umstempelung hier, die die Reihenfolge ueberhaupt erst richtigstellt.
roh = [float(w) for _t, w in punkte]
self.assertFalse(all(a <= b for a, b in zip(roh, roh[1:])))
neu, _ = v.geraetezeit_zuordnen(punkte, marken)
self.assertEqual([p[1] for p in neu], ["6.848", "6.891", "6.935"])
self.assertAlmostEqual(v.strecke_aus_zaehler(neu), 0.087, places=3)
def test_marke_gehoert_zum_datensatz_nicht_zum_neuesten_stand(self):
"""Innerhalb eines Schwalls darf nicht die juengste Marke gewinnen."""
marken = [
(t(21, 24, 54), t(21, 14, 13)),
(t(21, 24, 56), t(21, 17, 3)),
(t(21, 24, 57), t(21, 24, 27)),
]
neu, _ = v.geraetezeit_zuordnen([(t(21, 24, 54), "21336")], marken)
self.assertEqual(neu[0][0], t(21, 14, 13))
def test_ohne_marke_bleibt_die_ankunftszeit(self):
"""Ein Wert einer fremden Integration behaelt seine eigene Zeit."""
marken = [(t(21, 13, 47), t(21, 13, 43))]
punkte = [(t(20, 0, 0), "auf"), (t(21, 13, 47), "21335")]
neu, ohne = v.geraetezeit_zuordnen(punkte, marken)
self.assertEqual(ohne, 1)
self.assertEqual(neu[0], (t(20, 0, 0), "auf"))
def test_grenze_der_zuordnung(self):
"""Genau auf ZUORDNUNG_MAX_S wird noch zugeordnet, darueber nicht."""
marken = [(t(21, 13, 48), t(21, 13, 43))]
knapp, ohne_knapp = v.geraetezeit_zuordnen([(t(21, 13, 47), "a")], marken, 1.0)
self.assertEqual((knapp[0][0], ohne_knapp), (t(21, 13, 43), 0))
weit, ohne_weit = v.geraetezeit_zuordnen([(t(21, 13, 46), "a")], marken, 1.0)
self.assertEqual((weit[0][0], ohne_weit), (t(21, 13, 46), 1))
def test_ohne_marken_bleibt_alles_wie_es_war(self):
punkte = [(t(21, 13, 47), "21335")]
neu, ohne = v.geraetezeit_zuordnen(punkte, [])
self.assertEqual(neu, punkte)
self.assertEqual(ohne, 1)
def test_unplausible_marke_zieht_nichts_nach_1970(self):
"""_marken_aus() wirft eine 0 im Meldezeit-Sensor heraus."""
class Z:
def __init__(self, ts, state):
self.last_updated, self.state = ts, state
jetzt = t(21, 30)
marken = v._marken_aus(
[Z(t(21, 13, 47), "0"), Z(t(21, 13, 57), str(t(21, 13, 53).timestamp()))],
jetzt,
)
self.assertEqual(len(marken), 1)
self.assertEqual(marken[0][1], t(21, 13, 53))
class Ruecksetzung(unittest.TestCase):
"""Ein Rueckschritt ist nicht automatisch eine Zaehlerruecksetzung."""
def test_echte_ruecksetzung_zaehlt_voll(self):
"""Fahrt vom 01.09.2026: 0,415 km, dann Sprung auf 0, dann 6,543."""
punkte = [(t(1, 0), "0.0"), (t(1, 1), "0.415"), (t(1, 2), "0.0"),
(t(1, 3), "6.543")]
self.assertAlmostEqual(v.strecke_aus_zaehler(punkte), 6.958, places=3)
def test_kleiner_rueckschritt_ist_kein_reset(self):
"""6,891 -> 6,848 ist Reihenfolge, nicht Ruecksetzung.
Vorher buchte diese Zeile den vollen Zaehlerstand als Strecke: aus
87 Metern wurden 13,7 km."""
punkte = [(t(1, 0), "6.891"), (t(1, 1), "6.848"), (t(1, 2), "6.935")]
self.assertAlmostEqual(v.strecke_aus_zaehler(punkte), 0.087, places=3)
def test_grenzfall_haelfte(self):
knapp_darunter = [(t(1, 0), "10.0"), (t(1, 1), "4.9")]
self.assertAlmostEqual(v.strecke_aus_zaehler(knapp_darunter), 4.9, places=3)
knapp_darueber = [(t(1, 0), "10.0"), (t(1, 1), "5.1")]
self.assertAlmostEqual(v.strecke_aus_zaehler(knapp_darueber), 0.0, places=3)
class GnssStrecke(unittest.TestCase):
"""Die gemessene Fahrt, einmal ganz."""
def test_volle_strecke_statt_halber(self):
"""48 Datensaetze, 6,877 km - und nach Ankunftszeit blieben davon 26."""
werte = [6.891, 7.335, 8.186, 9.5, 10.61, 11.777, 12.95, 13.698, 13.725]
geraet = [t(21, 9, 41), t(21, 10, 33), t(21, 12, 23), t(21, 13, 43),
t(21, 14, 23), t(21, 15, 23), t(21, 16, 3), t(21, 17, 3), t(21, 17, 34)]
# Alles ab 21:14:13 Geraetezeit kam erst um 21:24:54 an.
ankunft = [g if g <= t(21, 13, 43) else t(21, 24, 54) for g in geraet]
marken = list(zip(ankunft, geraet))
punkte = [(a, str(w)) for a, w in zip(ankunft, werte)]
start, ende = t(21, 9, 41), t(21, 17, 49)
alt = v.zaehlerstrecke(punkte, start, ende)
self.assertAlmostEqual(alt, 2.609, places=3) # der falsche Wert von damals
neu, _ = v.geraetezeit_zuordnen(punkte, marken)
self.assertAlmostEqual(v.zaehlerstrecke(neu, start, ende), 6.834, places=3)
if __name__ == "__main__":
unittest.main(verbosity=2)