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:
@@ -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)
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -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() {
|
||||
</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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user