diff --git a/AGENTS.md b/AGENTS.md index 2c18faa..fda1fa4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,12 +259,14 @@ formatting hold everywhere; `design-system/` is verifiably brand-free. ### HA panel — known gaps (most also in SPECIFICATION.md §7) -- **GPS is dead schema for trip records specifically:** `start_lat/lon`, addresses, `route`, - `avg_speed_kmh` on individual trips never populated. The trip-detail map draws a **fabricated** - line via `fakeTrack()` (`audi-dashboard-app.js:414`) — not a real track. (Unrelated: the - Übersicht's live Standort-Kachel is no longer in this state — `STANDORT_TRACKER` now points at - a real `device_tracker`, see section B/C. This item is only about per-trip route data, which - nothing currently populates.) +- ✅ **FIXED 2026-08-27 (section U):** `start_lat/lon`, `end_lat/lon` and `route` on individual + trips were dead schema — only `historienimport.py` ever populated them, the live detection path + never did, and the trip-detail map drew a **fabricated** line via `fakeTrack()` — not a real + track. Both are now populated (`screening.py`'s new `_position_screenen()` for live trips, + `route` added to the import path too), and `fakeTrack()` is removed — the map now draws the real + route, or an honest straight line between known points, never an invented curve. + `addresses`/`avg_speed_kmh` on individual trips remain unpopulated (a separate, still-open gap — + reverse geocoding is its own work package, see historienimport.py's own note). - **No GPS fallback in trip completion:** trips without an odometer match stay `status="offen"` forever (`modules/fahrtabschluss_logik.py:16-20`). - **RAM-only state:** running trip (`_fahrt_start_ts`) and fuel low-water-mark @@ -3086,6 +3088,127 @@ at each step from `.6` onward — screenshots taken of the battery list (Generat correctly, including a real entry below the threshold correctly *not* tagged), the Statistik tab's `polls-s` icon, and the Reifen archive section before and after both fixes. +## T. Battery plausibility floor, and the archive button moved onto the "Montiert" row (2026.8.25.9) + +**Owner-set plausibility floor: `SPANNUNG_MIN_V = 8.0` in `batterie.py`.** A 12V lead-acid/AGM battery's +resting voltage doesn't fall meaningfully below this even deeply discharged — a reading under it is a +sensor/connection fault, not a real measurement, and per the owner's explicit instruction it must not be +tracked or listed at all (not just hidden at display time, unlike the existing `AGM_RUHE_MAX_V` upper +bound in `Batterie.tsx`/`vBatterieverlauf()`, which still stores out-of-range readings and only filters them +when computing SOC/trend/the chart's own point set). `pruefen()` now rejects a reading below the floor +before it ever reaches `ablage.batterieverlauf_tageswert_aktualisieren()`, logging a warning instead. +`historienimport.py`'s `_batterie_importieren()` got the identical floor (imports `SPANNUNG_MIN_V` from +`batterie.py` rather than duplicating the number) — the recurring live/import parity rule from sections P +and Q: a stray sub-8V blip in the recorder history must not become a "tracked" day-minimum on import just +because the live path would have rejected it. + +**Reifen page: the package-s "Neue Räder anlegen" button now sits on the same row as "Montiert"** (panel +only — companion-app has no equivalent "Montiert" button in this tile to align it with; its own +Fahrzeugwechsel button lives in a separate tile entirely, so there was nothing to move there). Was its own +row below `Montiert` with `justify-content:flex-end`; now both buttons share one +`justify-content:space-between` flex row. Confirmed live — the two align correctly on one line at their +respective ends. + +Verified: `py_compile` clean on `batterie.py`/`historienimport.py`, `node --check` clean on the panel JS, +manifest bumped to `2026.8.25.9`, `audi_ha_test` restart confirmed clean via log **and confirmed live in the +browser** (screenshot of the Reifen page showing `Montiert` and the package icon sharing one row). + +## U. Fahrten start/end position and a real track, live-detected trips included; map tile provider fixed; +Start/Ziel restructured; Status line removed; battery diagram uses the full column width (2026.8.27.1) + +**Bug report: "Start and Endposition as well as Track is not visible at Fahrten - even if the Sensors for GPS +provides Data."** Root cause: `start_lat`/`start_lon`/`end_lat`/`end_lon`/`route` were only ever populated by +`historienimport.py` (added when `STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR` were introduced, 2026-08-16) - +the **live** trip-detection path (`fahrterkennung.py`'s `leere_fahrt()`/`fahrt_beenden()`) never filled them, +so any trip the FMM003 detected in real time (the normal case) stayed without a map, only trips created via +"Daten importieren aus Home Assistant" ever had one. This was the documented "GPS is dead schema for trip +records" gap (see the HA-panel known-gaps list above) - now closed for the live path too, using the same +GPS sensors that already work for the Standort tile and for import. + +**Backend**, mirroring the existing odometer-screening pattern (`_fahrt_screenen()`/`naechster_wert()`) rather +than inventing a new mechanism: `verlauf.py` gained `route_aus_verlauf(lat_verlauf, lon_verlauf, start, ende)` +- pairs each latitude point inside the trip window with its nearest-in-time longitude point (the two are +independent sensors on the FMM003, not guaranteed to update together) into a chronological `[[lat, lon], ...]` +list, `None` below two points. `screening.py`'s `durchfuehren()` now also runs a `_position_screenen()` pass +over every trip still missing `start_lat`/`end_lat`/`route` - **decoupled from the odometer `status` field** +(`offen`/`vollständig`), since GPS and odometer data can each arrive late independently; a trip already +`vollständig` on kilometers can still be missing position, and vice versa. Re-entrant like the odometer path: +already-filled fields are left alone, so repeated screening passes (every trip end, every KM_SENSOR change) +converge without re-work. `historienimport.py`'s `_fahrten_importieren()` now also writes `route` (previously +only `start_lat`/`end_lat`), using the same helper, for the same live/import parity reason section P and Q +already established for other fields. + +**A real, live-reproduced timing edge case, found and understood, not worked around with a special case:** +first-attempt screening immediately after a trip ends can occasionally read the recorder microseconds before +the just-posted GPS points are committed, landing on a stale nearest-value instead. Reproduced deliberately +(temporarily set `fahrten_pausenzeit_min` to 0 to make a synthetic trip end instantly, restored afterward) - +the first screening pass picked a 5-minute-old position; nudging `KM_SENSOR` a few seconds later re-triggered +`durchfuehren()`, which then read the fully-committed history and filled in the correct, precise points and +route. Under the real default pause (15 minutes), the recorder has settled long before screening ever runs, +so this isn't a practical concern - and even under the artificial 0-minute test it self-healed on the very +next screening trigger, which is the same "eventually consistent, never stuck" property `_lueckenlos_verketten` +already relies on elsewhere in this file. No code change needed for this - it's an inherent, already-handled +property of the retry-on-next-trigger design, not a defect. + +**Frontend, both codebases - stopped fabricating a track, matches an existing project value.** The panel's +`fakeTrack()` (a seeded-random wiggle between two points, flagged in the known-gaps list as "not a real +track") is removed; `initMap()`'s trip branch now draws the real `route` polyline when present ("Streckenlinie +aus dem GPS-Verlauf ... echte Route"), or an honest straight (dashed) line between the two known points when +only start/end are known but no route - matching the stance `companion-app/src/screens/Karte.tsx` already +took from the start (its own header comment: "Das ist hier nicht übernommen... zeigt die Seite ehrlich, dass +keine Route bekannt ist, statt eine glaubwürdig aussehende Erfindung zu zeigen"). `Karte.tsx` gained an +optional `route` prop (drawn solid when present, falling back to the existing dashed straight line) so it can +now also render a real track, not just the two endpoints; `FahrtDetail.tsx` passes `fahrt.route` through. +`api/types.ts`'s `Fahrt.route` was typed `unknown | null` (dead field, per its own header comment) - now +`[number, number][] | null`, matching what the backend actually sends. + +**Map tile provider was showing "API key required" watermark tiles - found while verifying the above, a +separate real bug.** `TILE_URL` (panel) and `KACHELN.tag/.nacht` (companion-app `Karte.tsx`) pointed at +CARTO's anonymous `basemaps.cartocdn.com` tiles, which now require an account/API key - without one, every +tile rendered as a plain "API key required" graphic instead of a map, on **every** map in both apps (Standort, +trip detail, tank receipt), not just the newly-fixed trip track. Switched both to the standard +`tile.openstreetmap.org` raster tiles, which remain free and keyless. companion-app's `Karte.tsx` also lost +its per-theme dark/light tile switch in the same change (it had never gotten the panel's 2026-08-16 +"maps are always light" decision) - now themeless like the panel, for the same reason (`useTheme` import and +the now-unused `theme` dependency removed). + +**Owner-requested restructure of the Start/Ziel rows** (Einzelfahrt detail page, both codebases): was +address-first with the date as a small caption (Start row only; Ziel had no date at all). Now both rows show +date + time on the primary line and the address below as the secondary line - panel via a new `dezeitpunkt()` +helper (mirrors the existing inline `dedat(x) + " · " + toLocaleTimeString(...) + " Uhr"` pattern used +elsewhere in the file), companion-app via `Wertzeile`'s existing `zusatz` prop (the same mechanism already +used for other value+caption pairs). + +**Owner-requested removal: "Status is not working. It says 'vollständig' but that's not correct. Remove +Status line."** Removed the panel's `
Status
${t.status}...
` row from `vTrip()` entirely, per +the explicit instruction - not replaced with a corrected version, removed. companion-app's `FahrtDetail.tsx` +never had an equivalent explicit "Status:" row (only a conditional "noch offen" `Pill`, which never shows the +literal text "vollständig") - nothing to change there, a documented non-issue rather than a silent skip. + +**Owner-requested: "The Diagram for Battery Voltage shall use the max. available screen wide. In +Large-Screen-Mode it is unnecessary small."** The `max-width:400px` cap added 2026-08-25 (section R) to stop +the SVG chart stretching into a flat band on wide screens (fixed pixel `height:170px` + `width:100%` + +`preserveAspectRatio="none"` at 772px column width) had the side effect of capping it far below the available +772px column, on any screen. Root-fixed instead of re-tuning the cap: the `` now carries +`aspect-ratio:${BV_W}/${BV_H}` (320/170) instead of a fixed pixel height, so height scales with width and the +chart can never distort again regardless of container width - the `max-width:400px` rule is removed outright, +the chart now uses the full content column. The x-axis zoom/pan pointer handlers (`bvPlotRahmen()`, wheel +listener) were checked and confirmed unaffected: they derive everything from `rect.width`/`clientX`, never a +fixed pixel height. + +**Verified live end-to-end in `audi_ha_test` at `http://localhost:18123`** (not just compiled/typechecked - +this file's own section S lesson: a clean compile pass doesn't prove a map or a layout is correct). Simulated +a full trip via direct HA REST state posts (ignition on -> three moving GPS points -> ignition off, pause +temporarily set to 0 for a fast test, restored after) and confirmed via screenshot: real OpenStreetMap tiles +render (no "API key required"), a genuine polyline follows the three simulated points with correct start/end +markers, "Start"/"Ziel" show date+time above the (unknown) address, no "Status" row appears anywhere on the +page. Battery diagram confirmed at a 1400px viewport: SVG measured 736×391px, ratio 1.882 matching +320/170 exactly (no distortion), using the full column instead of the previous 400px cap. Deleted the +synthetic test trip afterward; console showed only the pre-existing ServiceWorker/404 noise (ungenerated +vehicle photos, PWA service worker in this test environment), nothing new. companion-app: `tsc --noEmit` +clean, full suite green at 146/146, `npm run build` and `npm run ota` both succeeded (bundle rebuilt at +`2026.8.27.1`). Manifest bumped `2026.8.25.9` → `2026.8.27.1`. + --- ## Working conventions (observed — keep them) diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index a17f1cb..15dd683 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -4,10 +4,10 @@ Die Fachtypen (Fahrt, Tankvorgang, ...) sind aus SPECIFICATION.md §5 "Data files" abgeleitet - also aus dem, was das Backend tatsächlich in fahrten.jsonl / tankvorgaenge.jsonl schreibt, nicht aus Wunschdenken. - Felder, die laut §7 im Schema stehen, aber von keinem Codepfad je gefüllt - werden (start_lat, avg_speed_kmh, route, ...), sind hier bewusst als - optional markiert - sie können null/undefined sein und die Oberfläche darf - sich nie darauf verlassen. */ + Mehrere Felder (start_lat/-lon, end_lat/-lon, route, avg_speed_kmh) sind + nur befüllt, wenn ein GPS-Sensor zugeordnet ist (screening.py/ + historienimport.py) - ohne Zuordnung bleiben sie null. Deshalb hier bewusst + optional markiert; die Oberfläche darf sich nie auf ihre Existenz verlassen. */ /** Roher Zustand einer HA-Entität, wie ihn /api/states liefert. */ export interface HassState> { @@ -55,7 +55,7 @@ export interface Fahrt { end_lon?: number | null; start_address?: string | null; end_address?: string | null; - route?: unknown | null; + route?: [number, number][] | null; pausen?: unknown[]; } diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx index 1406a0a..99e7b89 100644 --- a/companion-app/src/screens/FahrtDetail.tsx +++ b/companion-app/src/screens/FahrtDetail.tsx @@ -110,6 +110,7 @@ export function FahrtDetail({ id }: { id: string | undefined }) { {...(fahrt.end_lat != null && fahrt.end_lon != null ? { ziel: { lat: fahrt.end_lat, lon: fahrt.end_lon } } : {})} + {...(fahrt.route && fahrt.route.length >= 2 ? { route: fahrt.route } : {})} /> ) : ( )} - {(fahrt.start_address || fahrt.end_address) && ( - - - - - } - /> - )} + + + + + } + /> ) diff --git a/companion-app/src/screens/Karte.tsx b/companion-app/src/screens/Karte.tsx index 8e0901f..69a3fad 100644 --- a/companion-app/src/screens/Karte.tsx +++ b/companion-app/src/screens/Karte.tsx @@ -13,27 +13,30 @@ import { useEffect, useRef } from "react" import "leaflet/dist/leaflet.css" -import { useTheme } from "../theme" - export interface Punkt { lat: number lon: number } -const KACHELN = { - nacht: { - url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", - zusatz: "© OpenStreetMap, © CARTO", - }, - tag: { - url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", - zusatz: "© OpenStreetMap, © CARTO", - }, -} as const +// Themeunabhaengig hell wie im Panel (Kartendienste zeigen die +// Standard-Strassenkarte immer hell) - und seit CARTOs anonyme +// basemaps.cartocdn.com-Kacheln einen eigenen API-Schluessel verlangen +// (ohne ihn nur noch "API key required"-Kacheln), die einzige hier weiterhin +// ohne Schluessel nutzbare Quelle. +const KACHEL_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" +const KACHEL_ZUSATZ = "© OpenStreetMap contributors" -export function Karte({ start, ziel }: { start: Punkt; ziel?: Punkt }) { +export function Karte({ + start, + ziel, + route, +}: { + start: Punkt + ziel?: Punkt + /** Echte Streckenlinie aus dem GPS-Verlauf (Backend: screening.py/historienimport.py). */ + route?: [number, number][] +}) { const behaelter = useRef(null) - const [theme] = useTheme() useEffect(() => { const element = behaelter.current @@ -47,8 +50,7 @@ export function Karte({ start, ziel }: { start: Punkt; ziel?: Punkt }) { void import("leaflet").then((L) => { if (abgebrochen) return karte = L.map(element, { attributionControl: true, scrollWheelZoom: false }) - const kachel = KACHELN[theme] - L.tileLayer(kachel.url, { attribution: kachel.zusatz, maxZoom: 19 }).addTo(karte) + L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte) const punkte: [number, number][] = [[start.lat, start.lon]] L.circleMarker([start.lat, start.lon], { radius: 6, color: "#F50537" }) @@ -60,8 +62,11 @@ export function Karte({ start, ziel }: { start: Punkt; ziel?: Punkt }) { L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: "#F50537", fill: true }) .bindTooltip("Ziel") .addTo(karte) - // Verbindungslinie NUR zwischen echten Punkten, kein erfundener Verlauf. - L.polyline(punkte, { color: "#F50537", weight: 2, dashArray: "4 6" }).addTo(karte) + // Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen + // den beiden echten Punkten, kein erfundener Verlauf dazwischen. + const linie = route && route.length >= 2 ? route : punkte + L.polyline(linie, { color: "#F50537", weight: 2, dashArray: route && route.length >= 2 ? undefined : "4 6" }).addTo(karte) + if (route && route.length >= 2) punkte.push(...route) } if (punkte.length > 1) { @@ -75,7 +80,7 @@ export function Karte({ start, ziel }: { start: Punkt; ziel?: Punkt }) { abgebrochen = true karte?.remove() } - }, [start, ziel, theme]) + }, [start, ziel, route]) return
} diff --git a/custom_components/audi_dashboard/batterie.py b/custom_components/audi_dashboard/batterie.py index 24d9abe..ca91a5a 100644 --- a/custom_components/audi_dashboard/batterie.py +++ b/custom_components/audi_dashboard/batterie.py @@ -20,11 +20,19 @@ Ladezustands-/Gesundheitsauswertung im Frontend (AGM_RUHE_MAX_V) den Tagesminimalwert, nicht den Maximalwert. Es wird bewusst NICHT gefiltert, welche Messwerte gespeichert werden - min UND -max jedes Tages werden roh aufgezeichnet. Die AGM-Ruhespannungs-Grenze wird +max jedes Tages werden roh aufgezeichnet, solange die Messung überhaupt +plausibel ist (siehe SPANNUNG_MIN_V unten). Die AGM-Ruhespannungs-Grenze wird erst beim Anzeigen angewendet: Tage, an denen selbst der Minimalwert über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet, z. B. bei einer sehr langen Fahrt), tauchen dort nicht in der Statistik auf. +Eine Ausnahme von "roh aufzeichnen" gibt es: Werte unter SPANNUNG_MIN_V sind +für eine 12V-Bleibatterie physikalisch nicht plausibel (selbst tiefentladen +liegt die Ruhespannung deutlich darüber) und deuten auf einen Sensor-/ +Verbindungsfehler statt auf eine echte Messung hin - sie werden gar nicht erst +aufgezeichnet, damit ein einzelner Ausreißer nicht als Tagesminimum in der +Liste und im Diagramm landet. + Ohne zugeordneten BATTERIE_SENSOR passiert hier bewusst nichts, statt Platzhalter-Nullwerte aufzuzeichnen. @@ -48,6 +56,10 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +# Physikalisch plausible Untergrenze für eine 12V-Bleibatterie - darunter ist +# es ein Sensor-/Verbindungsfehler, keine echte Messung (siehe Moduldocstring). +SPANNUNG_MIN_V = 8.0 + async def pruefen(k: Koordinator) -> None: sensor = k.zuordnung.werte.BATTERIE_SENSOR @@ -60,6 +72,12 @@ async def pruefen(k: Koordinator) -> None: spannung = float(wert) except (TypeError, ValueError): return + if spannung < SPANNUNG_MIN_V: + _LOGGER.warning( + "Batteriespannung %s V unter der Plausibilitätsgrenze (%s V) - nicht erfasst", + spannung, SPANNUNG_MIN_V, + ) + return aussentemp = None temp_wert = zustand_oder_none(k.hass, k.zuordnung.werte.AUSSENTEMP_SENSOR) diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 07725b2..ba9f4b0 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.8.25.8","sha256":"464c294c6c07dcb636a8ea3bd93c7e525b21722defb41f573e2b31fefade49ea","bytes":235525,"gebaut":"2026-08-27T09:40:45Z"} \ No newline at end of file +{"version":"2026.8.27.1","sha256":"d7cd168032a93ed8430e3a86ee583c635004fc3518eecf0d1e69abd78741ebc4","bytes":235552,"gebaut":"2026-08-27T15:13:25Z"} \ 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 6cd17ec..5b6c5ea 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 e78d4e4..05dfcf7 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -377,6 +377,10 @@ const dedat = (d) => (gueltigesDatum(d) ? d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }) : "–"); function dat(s) { const [d, m, j] = s.split(".").map(Number); return new Date(j, m - 1, d); } +/* Datum + Uhrzeit in einer Zeile, für Start-/Zielzeitpunkt der Einzelfahrt. */ +const dezeitpunkt = (d) => (gueltigesDatum(d) + ? `${dedat(d)} · ${d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` + : "–"); const MISCHDAUER = 180; function tagesleistung() { @@ -449,9 +453,14 @@ let BELEG_STATION_POS = null; /* Bewusst themeunabhaengig hell: Kartendienste (Google Maps & Co.) zeigen die Standard-Strassenkarte immer im hellen Stil, weil Strassen, Beschriftungen und Wegfarben darauf lesbar bleiben. Die frueher bei Nacht eingeblendeten - dark_all-Kacheln liessen die Karte im Panel schwarz wirken. */ -const TILE_URL = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"; -const ATTR = "© OpenStreetMap · CARTO"; + dark_all-Kacheln liessen die Karte im Panel schwarz wirken. + CARTOs anonyme basemaps.cartocdn.com-Kacheln (vorher hier) verlangen + inzwischen einen eigenen API-Schlüssel - ohne ihn zeigten die Kacheln nur + noch "API key required". Die Standard-OpenStreetMap-Kacheln sind weiterhin + ohne Schlüssel nutzbar und decken diesen Bedarf (ein Fahrzeug, geringes + Kartenaufkommen) ohne eigenen Dienst. */ +const TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; +const ATTR = "© OpenStreetMap contributors"; /* Satellitenansicht der Standort-Vollbildkarte (Layer-Knopf) - wie die CARTO-Straßenkacheln oben ohne eigenen Schlüssel nutzbar. */ const SAT_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"; @@ -527,15 +536,6 @@ function kartenMittelpunkt() { return pos && pos.length === 2 ? pos : [51.1657, 10.4515]; } -function fakeTrack(a, b, n = 13) { - const pts = []; let seed = Math.round((a[0] + b[1]) * 1000); - const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff - 0.5; }; - for (let i = 0; i <= n; i++) { - const t = i / n, k = Math.sin(t * Math.PI); - pts.push([a[0] + (b[0] - a[0]) * t + rnd() * 0.05 * k, a[1] + (b[1] - a[1]) * t + rnd() * 0.05 * k]); - } - pts[0] = a; pts[n] = b; return pts; -} function tiles() { if (!MAP || !window.L) return; const url = TILE_URL; @@ -552,14 +552,20 @@ async function initMap() { if (route.name === "trip") { const t = TRIPS.find((x) => x.trip_id === route.id); if (t && t.start_lat != null && t.end_lat != null) { - const track = fakeTrack([t.start_lat, t.start_lon], [t.end_lat, t.end_lon]); - const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9 }).addTo(MAP); + // Echte Streckenlinie aus dem GPS-Verlauf (route, screening.py), wenn + // vorhanden - sonst eine ehrliche gerade Linie zwischen Start und Ziel, + // keine erfundene Kurve mehr (siehe companion-app/Karte.tsx, dieselbe + // Haltung: keine Route zu zeigen, die es so nicht gab). + const track = Array.isArray(t.route) && t.route.length >= 2 + ? t.route + : [[t.start_lat, t.start_lon], [t.end_lat, t.end_lon]]; + const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9, dashArray: Array.isArray(t.route) && t.route.length >= 2 ? null : "4 6" }).addTo(MAP); track.slice(1, -1).forEach((pt) => window.L.circleMarker(pt, { radius: 2.5, color: rot, weight: 0, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP)); window.L.circleMarker([t.start_lat, t.start_lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 }).addTo(MAP).bindPopup("Start · " + (t.start_address || "")); window.L.circleMarker([t.end_lat, t.end_lon], { radius: 6, color: rot, weight: 2, fillColor: rot, fillOpacity: 1 }).addTo(MAP).bindPopup("Ziel · " + (t.end_address || "")); MAP.fitBounds(line.getBounds(), { padding: [26, 26] }); } else { - MAP.setView(kartenMittelpunkt(), 11); // kein Track ohne GPS-Daten (§7a-Backend-Lücke) + MAP.setView(kartenMittelpunkt(), 11); // kein Track ohne GPS-Daten } } else if (route.name === "fill") { BELEG_STATION_POS = null; // gehört zum vorherigen Beleg, sonst zentriert der Knopf falsch @@ -1804,7 +1810,7 @@ function vBatterieverlauf() {
${genug ? `
- +
@@ -2886,8 +2892,8 @@ function vReifen() {
` : ""} - -
+
+
@@ -3150,13 +3156,12 @@ function vTrip(id) { style="cursor:pointer;font-family:inherit">${artText(t.art)}
-
Startort
${esc(t.start_address || "unbekannt")}${dedat(new Date(t.ts_start))}
-
Zielort
${esc(t.end_address || "unbekannt")}
+
Start
${dezeitpunkt(new Date(t.ts_start))}${esc(t.start_address || "unbekannt")}
+
Ziel
${dezeitpunkt(new Date(t.ts_end))}${esc(t.end_address || "unbekannt")}
Startkilometer
${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}
Endkilometer
${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}
Dauer
${dauerText(t.duration_s)}
Verbrauch
${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}vom Fahrzeug gemeldet
-
Status
${t.status}Quelle: ${t.km_quelle || "–"}
diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css b/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css index 24588cb..ffc5d90 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css @@ -336,12 +336,14 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo .standortmenu{max-width:640px;margin-left:auto;margin-right:auto} /* Batteriespannungs-Diagramm ist auf BV_W=320 SVG-Einheiten gezeichnet - (audi-dashboard-app.js) und haelt seine Hoehe fest bei 170px - bei voller - Spaltenbreite (772px Inhaltsbreite: 860 - 2*44px Seitenrand) zog - preserveAspectRatio="none" das Diagramm zu einem extrem breiten, flachen - Band auseinander. Gedeckelt wie die Popups oben, statt die feste Hoehe - oder die BV_*-Zeichen-Logik anzufassen. */ - #bvChart{max-width:400px;margin-left:auto;margin-right:auto} + (audi-dashboard-app.js), vorher mit fester Hoehe von 170px bei + preserveAspectRatio="none" - bei voller Spaltenbreite (772px + Inhaltsbreite: 860 - 2*44px Seitenrand) zog das Diagramm zu einem extrem + breiten, flachen Band auseinander, gedeckelt mit max-width:400px (macht + es dafuer auf grossen Bildschirmen unnoetig klein). Das SVG traegt jetzt + ein aspect-ratio passend zum viewBox-Verhaeltnis (320/170), wodurch die + Hoehe mit der Breite mitwaechst - das Diagramm darf deshalb die volle + Spaltenbreite nutzen, ohne wieder zu verzerren. */ } /* Fahrzeugbild randlos wie in einer Fahrzeug-App: der Platzhalter bleibt diff --git a/custom_components/audi_dashboard/historienimport.py b/custom_components/audi_dashboard/historienimport.py index 7003b61..af15a3f 100644 --- a/custom_components/audi_dashboard/historienimport.py +++ b/custom_components/audi_dashboard/historienimport.py @@ -41,9 +41,10 @@ import datetime import logging from typing import TYPE_CHECKING +from .batterie import SPANNUNG_MIN_V from .fahrterkennung import leere_fahrt, pausenzeit_sekunden from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent -from .verlauf import Verlaufspunkt, verlauf_lesen, wert_bei, zahl +from .verlauf import Verlaufspunkt, route_aus_verlauf, verlauf_lesen, wert_bei, zahl if TYPE_CHECKING: from .koordinator import Koordinator @@ -193,6 +194,7 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict: "start_lon": wert_bei(lon_verlauf, f_start), "end_lat": wert_bei(lat_verlauf, f_ende), "end_lon": wert_bei(lon_verlauf, f_ende), + "route": route_aus_verlauf(lat_verlauf, lon_verlauf, f_start, f_ende), "status": "vollständig" if distanz is not None else "offen", }) neue.append(fahrt) @@ -305,7 +307,12 @@ async def _batterie_importieren(k: Koordinator, verlaeufe: dict) -> dict: tage: dict[str, dict] = {} for ts, wert in verlauf: spannung = zahl(wert) - if spannung is None: + # Unter SPANNUNG_MIN_V ist es kein echter Messwert, sondern ein + # Sensor-/Verbindungsfehler (siehe batterie.py) - live wie + # rückwirkend gleich behandelt, sonst landete ein Ausreißer aus dem + # Verlauf als Tagesminimum, wo die Live-Erfassung ihn nie hätte + # aufzeichnen können. + if spannung is None or spannung < SPANNUNG_MIN_V: continue tag = ts.date().isoformat() eintrag = tage.get(tag) diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 83d0da5..a5258ce 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.8.25.8", + "version": "2026.8.27.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"], diff --git a/custom_components/audi_dashboard/screening.py b/custom_components/audi_dashboard/screening.py index 50241cf..7e9c49b 100644 --- a/custom_components/audi_dashboard/screening.py +++ b/custom_components/audi_dashboard/screening.py @@ -22,7 +22,7 @@ import datetime import logging from typing import TYPE_CHECKING -from .verlauf import naechster_wert, verlauf_lesen +from .verlauf import naechster_wert, route_aus_verlauf, verlauf_lesen if TYPE_CHECKING: from .koordinator import Koordinator @@ -56,18 +56,31 @@ def _vollstaendig(aenderungen: dict, fahrt: dict) -> dict: async def durchfuehren(k: Koordinator) -> None: - """Versucht, alle offenen Fahrten zu vervollständigen.""" + """Versucht, alle offenen Fahrten zu vervollständigen, und trägt allen + Fahrten ohne Standortdaten Start-/Zielposition sowie eine Streckenlinie + nach - unabhängig vom Kilometerstand-Status, da beide Datenquellen + unabhängig voneinander verspätet eintreffen können.""" km_sensor = k.zuordnung.werte.KM_SENSOR fahrten = await k.ablage.fahrten_lesen() - offene = [f for f in fahrten if f.get("status") == "offen"] - if not offene: + if not fahrten: return - offene.sort(key=lambda f: f.get("ts_start", "")) + offene = [f for f in fahrten if f.get("status") == "offen"] if km_sensor: + offene.sort(key=lambda f: f.get("ts_start", "")) for fahrt in offene: await _fahrt_screenen(k, km_sensor, fahrt) + lat_sensor = k.zuordnung.werte.STANDORT_LAT_SENSOR + lon_sensor = k.zuordnung.werte.STANDORT_LON_SENSOR + if lat_sensor and lon_sensor: + ohne_position = [ + f for f in fahrten + if f.get("start_lat") is None or f.get("end_lat") is None or f.get("route") is None + ] + for fahrt in ohne_position: + await _position_screenen(k, lat_sensor, lon_sensor, fahrt) + await _lueckenlos_verketten(k) await k.fahrten_veroeffentlichen() @@ -105,6 +118,49 @@ async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None: await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt)) +async def _position_screenen( + k: Koordinator, lat_sensor: str, lon_sensor: str, fahrt: dict +) -> None: + """Trägt Start-/Zielposition und eine echte Streckenlinie nach, sobald der + GPS-Verlauf sie hergibt - unabhängig vom Kilometerstand-Status. + + Dieselbe "Fahrt gehört der Vergangenheit an" wie bei den Kilometerständen: + kein Live-Zustand zum Fahrtbeginn/-ende wird herangezogen (der wäre bei + einer Funklücke des Trackers unzuverlässig, siehe fahrterkennung.py), nur + der aufgezeichnete Verlauf im Fahrtfenster.""" + start = _als_zeit(fahrt.get("ts_start")) + ende = _als_zeit(fahrt.get("ts_end")) + 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) + if not lat_verlauf or not lon_verlauf: + return + + aenderungen: dict = {} + if fahrt.get("start_lat") is None: + lat = naechster_wert(start, lat_verlauf) + lon = naechster_wert(start, lon_verlauf) + if lat is not None and lon is not None: + aenderungen["start_lat"] = lat + aenderungen["start_lon"] = lon + if fahrt.get("end_lat") is None: + lat = naechster_wert(ende, lat_verlauf) + lon = naechster_wert(ende, lon_verlauf) + if lat is not None and lon is not None: + aenderungen["end_lat"] = lat + aenderungen["end_lon"] = lon + if fahrt.get("route") is None: + route = route_aus_verlauf(lat_verlauf, lon_verlauf, start, ende) + if route is not None: + aenderungen["route"] = route + + if aenderungen: + await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], aenderungen) + fahrt.update(aenderungen) + + async def _lueckenlos_verketten(k: Koordinator) -> None: """Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren wurde. diff --git a/custom_components/audi_dashboard/verlauf.py b/custom_components/audi_dashboard/verlauf.py index 0a3f589..c3afdb9 100644 --- a/custom_components/audi_dashboard/verlauf.py +++ b/custom_components/audi_dashboard/verlauf.py @@ -106,6 +106,34 @@ def wert_bei( return davor +def route_aus_verlauf( + lat_verlauf: list[Verlaufspunkt], + lon_verlauf: list[Verlaufspunkt], + start: datetime.datetime, + ende: datetime.datetime, +) -> list[list[float]] | None: + """Baut eine chronologische Liste von [lat, lon]-Punkten für die + Fahrt-Detailkarte, aus zwei getrennt geführten Sensor-Verläufen (die + FMM003 liefert Breiten-/Längengrad als zwei unabhängige Sensoren, siehe + STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR). Jeder Breitengrad-Punkt im + Fenster [start, ende] bekommt den zeitlich nächstgelegenen Längengrad-Wert + zugeordnet - beide Sensoren aktualisieren unabhängig voneinander, exakt + gleiche Zeitstempel sind nicht garantiert. None bei weniger als zwei + Punkten (keine sinnvolle Linie).""" + punkte: list[list[float]] = [] + for ts, wert in lat_verlauf: + if not (start <= ts <= ende): + continue + lat = zahl(wert) + if lat is None: + continue + lon = naechster_wert(ts, lon_verlauf) + if lon is None: + continue + punkte.append([lat, lon]) + return punkte if len(punkte) >= 2 else None + + def naechster_wert( zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt] ) -> float | None: