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