Batteriespannung: 8V-Plausibilitätsgrenze; Fahrten: echte Start-/Zielposition und Streckenlinie

Batteriespannung: Werte unter 8V (Sensor-/Verbindungsfehler, nicht physikalisch
plausibel für eine 12V-Bleibatterie) werden nicht mehr erfasst - live und beim
Import. "Neue Räder anlegen"-Knopf sitzt jetzt auf derselben Zeile wie "Montiert".

Fahrten: start_lat/-lon, end_lat/-lon und route wurden bisher nur beim
rückwirkenden Import befüllt, nie bei live erkannten Fahrten (dem Normalfall) -
Screening trägt sie jetzt aus dem GPS-Verlauf nach, unabhängig vom
Kilometerstand-Status. fakeTrack() (eine erfundene Linie) ist entfernt, die
Karte zeichnet jetzt die echte Route oder eine ehrliche Gerade zwischen den
bekannten Punkten. CARTOs anonyme Kartenkacheln verlangen inzwischen einen
API-Schlüssel ("API key required" auf jeder Karte in beiden Apps) - auf
schlüssellose OpenStreetMap-Kacheln umgestellt. Start/Ziel zeigen jetzt Datum
und Uhrzeit über der Adresse; die fehlerhafte "Status"-Zeile ist entfernt.
Batteriespannungs-Diagramm nutzt auf großen Bildschirmen die volle
Spaltenbreite statt einer festen 400px-Deckelung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 17:23:32 +02:00
parent 17149937a6
commit f337820fc8
13 changed files with 328 additions and 77 deletions
+129 -6
View File
@@ -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 `<dt>Status</dt><dd>${t.status}...</dd>` 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 `<svg>` 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)
+5 -5
View File
@@ -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<A = Record<string, unknown>> {
@@ -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[];
}
+17 -10
View File
@@ -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 } : {})}
/>
) : (
<Leerzustand
@@ -117,16 +118,22 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
text="Das Fahrzeug liefert derzeit keine Positionsdaten. Sobald es das tut, erscheint hier die gefahrene Strecke auf der Karte."
/>
)}
{(fahrt.start_address || fahrt.end_address) && (
<Werteliste
kinder={
<>
<Wertzeile label="Start" wert={fahrt.start_address ?? "—"} />
<Wertzeile label="Ziel" wert={fahrt.end_address ?? "—"} />
</>
}
/>
)}
<Werteliste
kinder={
<>
<Wertzeile
label="Start"
wert={`${datum(fahrt.ts_start)} · ${uhrzeit(fahrt.ts_start)} Uhr`}
zusatz={fahrt.start_address ?? "unbekannt"}
/>
<Wertzeile
label="Ziel"
wert={`${datum(fahrt.ts_end)} · ${uhrzeit(fahrt.ts_end)} Uhr`}
zusatz={fahrt.end_address ?? "unbekannt"}
/>
</>
}
/>
</Tile>
</>
)
+24 -19
View File
@@ -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<HTMLDivElement | null>(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 <div ref={behaelter} className="dm-karte" role="img" aria-label="Karte der Fahrt" />
}
+19 -1
View File
@@ -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)
@@ -1 +1 @@
{"version":"2026.8.25.8","sha256":"464c294c6c07dcb636a8ea3bd93c7e525b21722defb41f573e2b31fefade49ea","bytes":235525,"gebaut":"2026-08-27T09:40:45Z"}
{"version":"2026.8.27.1","sha256":"d7cd168032a93ed8430e3a86ee583c635004fc3518eecf0d1e69abd78741ebc4","bytes":235552,"gebaut":"2026-08-27T15:13:25Z"}
@@ -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 = "&copy; OpenStreetMap &middot; 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 = "&copy; 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() {
</div>
${genug ? `
<div id="bvChart" style="margin-top:14px;touch-action:none;position:relative">
<svg id="bvSvg" viewBox="0 0 ${BV_W} ${BV_H}" preserveAspectRatio="none" style="width:100%;height:170px;display:block"></svg>
<svg id="bvSvg" viewBox="0 0 ${BV_W} ${BV_H}" preserveAspectRatio="none" style="width:100%;aspect-ratio:${BV_W}/${BV_H};display:block"></svg>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px">
<span class="label" id="bvRange" style="margin-top:0"></span>
@@ -2886,8 +2892,8 @@ function vReifen() {
<button type="button" data-bildersetzen="${radDatei}">Foto ersetzen</button>
<button type="button" class="loeschen" data-bildloeschen="${radDatei}">Löschen</button>
</div>` : ""}
<button data-satz="${k === "sommer" ? "Sommer" : "Winter"}" class="montiert ${aktiv ? "on" : ""}">Montiert</button>
<div style="display:flex;justify-content:flex-end;margin-top:10px">
<div style="display:flex;justify-content:space-between;align-items:center">
<button data-satz="${k === "sommer" ? "Sommer" : "Winter"}" class="montiert ${aktiv ? "on" : ""}">Montiert</button>
<button class="iconbtn" data-reifenarchivieren="${k}" aria-label="Neue Räder anlegen">${ciSVG(CI.packageS, 18)}</button>
</div>
<div style="margin-top:12px">
@@ -3150,13 +3156,12 @@ function vTrip(id) {
style="cursor:pointer;font-family:inherit">${artText(t.art)}</button></div>
</div>
<div class="tile"><dl class="rows">
<div class="row"><dt>Startort</dt><dd>${esc(t.start_address || "unbekannt")}<small>${dedat(new Date(t.ts_start))}</small></dd></div>
<div class="row"><dt>Zielort</dt><dd>${esc(t.end_address || "unbekannt")}</dd></div>
<div class="row"><dt>Start</dt><dd>${dezeitpunkt(new Date(t.ts_start))}<small>${esc(t.start_address || "unbekannt")}</small></dd></div>
<div class="row"><dt>Ziel</dt><dd>${dezeitpunkt(new Date(t.ts_end))}<small>${esc(t.end_address || "unbekannt")}</small></dd></div>
<div class="row"><dt>Startkilometer</dt><dd>${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}</dd></div>
<div class="row"><dt>Endkilometer</dt><dd>${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}</dd></div>
<div class="row"><dt>Dauer</dt><dd>${dauerText(t.duration_s)}</dd></div>
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>vom Fahrzeug gemeldet</small></dd></div>
<div class="row"><dt>Status</dt><dd>${t.status}<small>Quelle: ${t.km_quelle || ""}</small></dd></div>
</dl>
<button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
</div>
@@ -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
@@ -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)
@@ -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"],
+61 -5
View File
@@ -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.
@@ -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: