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 <[email protected]>
This commit is contained in:
2026-09-04 02:18:12 +02:00
co-authored by Claude Opus 5
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.