diff --git a/AGENTS.md b/AGENTS.md index dec1760..7ff0546 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 3495bab..004ae24 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -396,6 +396,66 @@ export function Einstellungen({ + {zugaenge.length > 0 && ( + + Zugänge + {/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben — + man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */} + {zugaenge.map((z) => ( + + {z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"} + + } + > +

{z.zweck}

+ + + setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value })) + } + /> + +
+ void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")} + > + {tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"} + + {z.gesetzt && ( + { + if (bestaetigen("Token löschen?")) { + void tokenSchreiben(z.dienst, "") + } + }} + > + Löschen + + )} +
+
+ ))} + {tokenFehler &&

{tokenFehler}

} + + Ein gespeicherter Token wird nie wieder angezeigt — nur ob einer hinterlegt ist. + Dieselben Felder gibt es unter Einstellungen → Geräte & + Dienste → Audi Dashboard → Konfigurieren. + +
+ )} + Fahrt beenden @@ -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({ )} - {zugaenge.length > 0 && ( - - Zugänge - {/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben — - man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */} - {zugaenge.map((z) => ( - - {z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"} - - } - > -

{z.zweck}

- - - setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value })) - } - /> - -
- void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")} - > - {tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"} - - {z.gesetzt && ( - { - if (bestaetigen(`Token für ${z.titel} löschen?`)) { - void tokenSchreiben(z.dienst, "") - } - }} - > - Löschen - - )} -
-
- ))} - {tokenFehler &&

{tokenFehler}

} - - 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. - -
- )} Version diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx index 543a6d0..b9b895d 100644 --- a/companion-app/src/screens/FahrtDetail.tsx +++ b/companion-app/src/screens/FahrtDetail.tsx @@ -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 ?? ""} /> ) : ( - + } /> diff --git a/companion-app/src/screens/Karte.tsx b/companion-app/src/screens/Karte.tsx index 1680e20..7af1ecb 100644 --- a/companion-app/src/screens/Karte.tsx +++ b/companion-app/src/screens/Karte.tsx @@ -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 = '' +const CI_POI = '' +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 ( + `" + ) +} + /* 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(null) + const karteRef = useRef(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("
")) + .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
+ const karte = ( +
+ ) + if (!nadel) return karte + return ( +
+ {karte} +
+ +
+
+ ) } diff --git a/companion-app/src/screens/TankDetail.tsx b/companion-app/src/screens/TankDetail.tsx index 677c9f8..50bfec7 100644 --- a/companion-app/src/screens/TankDetail.tsx +++ b/companion-app/src/screens/TankDetail.tsx @@ -69,7 +69,7 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na return ( <> - + @@ -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) diff --git a/companion-app/src/screens/Tanken.tsx b/companion-app/src/screens/Tanken.tsx index b17d9d5..d9e5d70 100644 --- a/companion-app/src/screens/Tanken.tsx +++ b/companion-app/src/screens/Tanken.tsx @@ -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) diff --git a/companion-app/src/screens/TankstellenKarte.tsx b/companion-app/src/screens/TankstellenKarte.tsx index ad38de4..080fc26 100644 --- a/companion-app/src/screens/TankstellenKarte.tsx +++ b/companion-app/src/screens/TankstellenKarte.tsx @@ -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(null) const [geprueft, setzeGeprueft] = useState(false) @@ -32,7 +38,10 @@ export function TankstellenKarte({ adresse }: { adresse: string | null | undefin } }, [adresse]) - if (position) return + // Wie im Panel (tankstelleAufKarteZeigen): die CI-Nadel mit Name und + // Anschrift im Popup, dazu der Knopf, der wieder darauf zentriert. + if (position) + return // 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". diff --git a/companion-app/src/screens/bausteine.tsx b/companion-app/src/screens/bausteine.tsx index 4417671..f111e99 100644 --- a/companion-app/src/screens/bausteine.tsx +++ b/companion-app/src/screens/bausteine.tsx @@ -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 (
{label}
-
+
{wert} {zusatz && {zusatz}}
diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index a5cb9f6..c16b73c 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -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); +} diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index 1f35bbc..8713c8f 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -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] diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 288f6e8..060fcba 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.3.15","sha256":"00c71560c2cd991fa145a79a8d475a6cc4359eaa39342056f406bd3c2ee272c5","bytes":269191,"gebaut":"2026-09-03T19:21:06Z"} \ No newline at end of file +{"version":"2026.9.4.1","sha256":"473807fbe06bc94882a3b53fa76d167a64a15d9470c0a6f993c7042a8bf009b6","bytes":270085,"gebaut":"2026-09-04T00:09:13Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 290bae8..793d380 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index 4e85cd1..d37c469 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -1624,7 +1624,9 @@ function teaser() { weder Strecke noch Verbrauch. Ein Strich behauptete, ein Wert fehle - richtig ist, dass es ihn noch nicht gibt. */""} Aktuelle Fahrtläuft -
` +
` : t ? `