diff --git a/AGENTS.md b/AGENTS.md index d4a3f20..76b72d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ # AGENTS.md — Project state, review findings, open items, and working rules -**Last updated: 2026-08-30** (owner findings batch - vehicle pin, tile chevrons, select arrows, +**Last updated: 2026-08-31** (Neustart-Festhänger der App, OTA-Prüfsumme, Statistik-Kopfzahl, +Setup-Filter und die gemessene Wahrheit über die Dongle-Konfiguration; Manifest `2026.8.31.4`, +siehe Abschnitte AT und AU. Davor: 2026-08-30 (owner findings batch - vehicle pin, tile chevrons, select arrows, SmartDeal dialog, address cache and pull-to-refresh; manifest `2026.8.30.19`, see sections AO, AP and AQ. Before that: design-audit follow-through - the five priority items applied to both codebases, plus the location preview's wrong pin; manifest `2026.8.30.8`, see section AN. @@ -6050,6 +6052,143 @@ rendering bug in a view. --- +## AT. Neustart-Festhänger, OTA-Prüfsumme, Statistik-Kopfzahl, Setup-Filter (2026.8.31.4) + +### Die App blieb nach einem Neustart von Home Assistant hängen + +Gemeldet: während Update und Neustart zeigte die App „502 Error" unter einem längst +gedrückten „Jetzt neu starten", und die Meldung blieb stehen, nachdem Home Assistant +wieder lief — erst ein Beenden der App half. Zwei Ursachen, beide in +`DatenKontext.tsx`: + +1. **502 galt als harter Ladefehler.** `ApiFehler.istNetzproblem` ist nur `status === null`; + ein Vorschaltserver, der antwortet, während Home Assistant noch hochfährt, liefert + aber 502/503/504. Neu: `istVoruebergehend` fasst beides zusammen (500 bewusst **nicht** — + das bessert sich durch Warten nicht). Solange schon Daten da sind, erscheint keine + Fehlerzeile mehr. +2. **Nach dem Wiederverbinden wurde genau einmal nachgeladen.** Die Websocket-Verbindung + steht regelmäßig ein paar Sekunden vor der REST-Schnittstelle. Schlug dieser eine + Versuch fehl, kam nie wieder ein `"verbunden"` — und damit nie wieder ein Versuch; die + App blieb bis zum Beenden auf dem Stand von vor dem Neustart, mitsamt Fehlerzeile. + Neu: `nachladenBisEsKlappt()` mit wachsenden Abständen (2/4/8/15/30/60 s, zusammen gut + zwei Minuten), sauber abgeräumt beim Verlassen. + +Dazu ein Fehler, den dieselbe Stelle verdeckt hat: `neuLaden` hing an `bereit`, der Effekt +darunter aber nur an `api` — die dort festgehaltene Fassung sah `bereit` für immer als +`false`. `bereit` liegt jetzt in einem Ref, `neuLaden` ist stabil. Vier Regressionstests +(`DatenKontext.test.tsx`), gegen den alten Stand als fehlschlagend nachgewiesen. + +Panel-seitig kein Gegenstück nötig: es hängt an Home Assistants eigener Verbindung, deren +Wiederanlauf schon in Abschnitt N behandelt ist. + +### „App-Update: failed to download / checksum failed" + +**Das Bündel ist nachweislich in Ordnung.** Geprüft von Ende zu Ende: der Blob bei +`HEAD`, die Datei auf der Platte und die tatsächlich über HTTP ausgelieferten Bytes +haben alle denselben SHA-256, den auch `bundle.json` nennt; git behandelt die Zip als +binär (`i/-text`), `core.autocrlf` kann ihr nichts anhaben. Die Prüfsumme, die das +Plugin vergleicht, ist genau dieser Wert (`CapgoUpdater.swift`, `calcChecksum` über die +heruntergeladene Zip vor dem Entpacken). + +Der Fehler fiel zusammen mit dem Neustart — ein Download, der mitten hineinläuft, liefert +je nach Zeitpunkt einen Abbruch („failed to download") oder unvollständige Bytes +(„checksum"). Zwei Änderungen in `ota.ts`: + +- Die Fassung hängt jetzt als `?v=` an der Bündel-Adresse. Home Assistant liefert + `/audi_dashboard_static/` **ohne** `Cache-Control`, aber mit `ETag` und + `Last-Modified` (nachgemessen) — genau der Fall, in dem jeder Zwischenspeicher nach + eigenem Ermessen eine Weile die alte Datei behalten darf. Dieselbe Vorsorge trifft das + Panel seit jeher für sein eigenes Bündel. +- Ein Prüfsummenfehler wird nicht mehr wortwörtlich durchgereicht, sondern erklärt: „Das + Update kam unvollständig an … sobald Home Assistant wieder läuft, noch einmal + versuchen." + +### Statistik: der Gesamtwert steht in der Kopfzeile + +Erst sollte „Absolut" eine fünfte Spalte links werden. Am laufenden Panel gemessen passt +das nicht: die Kachel ist auf dem Telefon innen **307px** breit, ein sechsstelliger +Kilometerstand wie `209.177` misst in Audi Type Wide 300 **67px** bei 16px und 54px bei +13px — fünf Spalten gingen nur mit kleinerer Schrift. Der Eigentümer hat daraufhin +entschieden, den Gesamtwert dorthin zu setzen, wo man ihn zuerst liest: **die große Zahl +oben in „Distanz und Fahrzeit" und „Getankte Menge und Kosten" ist jetzt der Gesamtwert, +nicht mehr der des laufenden Jahres** — die Jahreszahl im Titel entfällt entsprechend. +Darunter bleiben vier Spalten (Jahr/Monat/Woche/Tag), jetzt als `repeat(4, minmax(0,1fr))` +statt `auto-fit`, das auf dem Telefon nur drei Spalten zuließ und die vierte umbrach. +`ZEITRAEUME` rechnet weiter alle fünf, angezeigt wird `ZEITRAUM_SPALTEN`. + +### Kein Schatten, kein Beschnitt — und kein Schalter mehr dafür + +Gemessen statt geschätzt: das Foto ist 2000×1125 (16:9), der Kasten stand auf 16:8 und +`object-fit: cover` schnitt oben und unten je ein Viertel ab — das Dach lag darin. Statt +das Verhältnis auf 16:9 festzuschreiben (was beim nächsten Foto in einem anderen Format +wieder schneiden würde) bestimmt jetzt das Bild selbst die Höhe; der Platzhalter behält +sein festes Verhältnis, er hat keines von sich aus. Nachgemessen: Kasten 367×207 zu +2000×1125 — kein Beschnitt. + +Gleichzeitig sind der Ausblend-Verlauf und der Bodenschatten entfallen (beide Codebasen, +inklusive der `height:220px`-Regel im iOS-Overlay, die den Beschnitt sonst auf breiten +Schirmen wieder erzwungen hätte). Damit hatte der Schalter **„Fahrbahn unter dem +Fahrzeug"** nichts mehr zu schalten und ist ebenfalls weg — samt `bodenAn`/`szeneKlasse()` +im Panel und `useBoden`/`bodenKlasse`/`OHNE_SZENE` in der App. Der Regressionstest für +den geteilten Einstellungsspeicher (`theme.test.tsx`) prüft das jetzt an +`useTabBeschriftung` statt an `useBoden` — dieselbe Aussage, ein noch vorhandener Schalter. + +### Setup: „Nur passende Sensoren" sucht nach dem Signalnamen + +Domain und Einheit lassen bei einem Fahrzeug mit siebzig Sensoren immer noch Dutzende +übrig. Der Name, der neben der Überschrift als „(z. B. …)" steht, ist der tatsächliche +Signalname der Integration (`beispiel` in `FELDER`) und trifft genau einen. Er filtert +jetzt mit — **mit Rückfall**: findet er nichts, bleibt die ungefilterte Liste stehen, denn +er stammt aus dieser einen Installation. Zusätzlich zählt er in `entitaetScore()` mit 8 +Punkten, damit auch der Vorschlag für ein leeres Feld davon profitiert. + +## AU. Der Dongle: was die Konfiguration wirklich bewirkt (2026-08-31) + +Nachgemessen an der echten Aufzeichnung im Testcontainer (7 bzw. 30 Tage Recorder), nicht +aus der Konfigurationsanzeige abgelesen. + +**Was gut funktioniert.** Beim Fahren liefert das Gerät alle **10 s** (p90) einen Satz — +das ist die eingestellte `Min Period` für „Moving", und sie gilt für Geschwindigkeit wie +für Position gleichermaßen (771 bzw. 1165 Punkte in 7 Tagen). Für Strecke, Route, +Durchschnitt und vmax reicht das: in denselben 7 Tagen wurde ein Höchstwert von 118 km/h +erfasst. `can_fuel_volume` löst **0,1 l** auf (Werte wie 18,9 / 19,1 / 19,5 über 30 Tage; +in einzelnen Wochen sieht man nur ganze Liter, das ist die Meldung des Fahrzeugs, nicht +die Auflösung des Sensors) — die Grenze `MIN_STRECKE_VERBRAUCH_KM = 3.0` bleibt damit +richtig hergeleitet. Der Füllstand in Prozent springt in 1-%-Schritten. + +**Was nicht funktioniert: das rohe Zündsignal als Fahrterkennung.** +`engine_ignition_or_acc_status` ist der ungefilterte Zündungseingang. In der Aufzeichnung: +am 29.08. drei Wechsel innerhalb von **30 Millisekunden**, danach ein Aus/Ein-Paar mit +**10 Sekunden** Abstand — und am 30./31.08. dreizehn Stunden am Stück „an", ohne Fahrt. +Solange die App eine Pausentoleranz hatte, hat die das aufgefangen. Seit deren Entfernung +(Abschnitt AS) trennt jeder solche Aussetzer eine Fahrt in zwei. + +**Die Begründung für das Entfernen war falsch.** Der `Ignition OFF Timeout` von 900 s +unter *Trip \ Odometer* verzögert **nicht** dieses Signal, sondern ausschließlich das +geräteeigene Trip-Element ([Teltonika-Wiki](https://wiki.teltonika-gps.com/view/FMM003_Trip/Odometer_settings): +„timeout value to detect Trip end once the Ignition … is off"). Es gab also nie zwei +Zeitgeber hintereinander — es gab unseren, und daneben einen ungenutzten des Geräts. + +**Die Empfehlung, noch nicht umgesetzt (Entscheidung des Eigentümers):** +`ZUENDUNG_SENSOR` auf +`binary_sensor.…_trip_status_true_if_trip_started_false_if_stopped` legen. Dieses Signal + +- beginnt erst, wenn Zündung **und** Bewegung **und** `Start Speed` zusammenkommen — + dreizehn Stunden Standlicht-Zündung erzeugen damit keine Fahrt, +- endet erst 900 s nach Zündung aus — die Pausentoleranz kommt vom Gerät, ohne dass wir + einen zweiten Zeitgeber bauen, +- ist millisekundenfestes Prellen los. + +Der Preis: das Fahrtende liegt 15 Minuten hinter dem tatsächlichen. Das war mit der +eigenen Pausentoleranz genauso und ist damit kein Rückschritt; wer es genauer will, müsste +das Ende nachträglich auf den letzten Punkt mit Geschwindigkeit > 0 zurückrechnen — eigenes +Arbeitspaket, hier bewusst nicht angefangen. + +**Weiter offen, unabhängig davon:** echte Funklöcher. Im gemessenen Zeitraum gab es +mitten in der Fahrt eine Lücke von **2739 s** ohne einen einzigen Satz. Dagegen hilft keine +Einstellung am Gerät; das ist der in Abschnitt P/W beschriebene Fall, den die +Plausibilitätsgrenze (`UNPLAUSIBLE_KMH`) abfängt. + ## Working conventions (observed — keep them) - German is the project language: identifiers, comments, commits, UI texts. Exceptions: diff --git a/companion-app/src/api/rest.ts b/companion-app/src/api/rest.ts index 42b0a25..a25930d 100644 --- a/companion-app/src/api/rest.ts +++ b/companion-app/src/api/rest.ts @@ -36,6 +36,20 @@ export class ApiFehler extends Error { get istNetzproblem(): boolean { return this.status === null; } + + /** Vorübergehend nicht erreichbar: kein Netz, oder ein Server, der + gerade hochfährt. + + 502/503/504 sind genau das, was ein Neustart von Home Assistant + liefert - der Vorschaltserver antwortet schon, Home Assistant selbst + noch nicht. Das ist für die Oberfläche dasselbe wie kein Netz: warten + und den zwischengespeicherten Stand zeigen, nicht eine Fehlerzeile + stehen lassen, die niemand mehr wegbekommt. 500 gehört bewusst NICHT + dazu - das ist ein echter Fehler in der Anfrage, der sich durch + Warten nicht bessert. */ + get istVoruebergehend(): boolean { + return this.istNetzproblem || this.status === 502 || this.status === 503 || this.status === 504; + } } export interface AnfrageOptionen { diff --git a/companion-app/src/daten/DatenKontext.test.tsx b/companion-app/src/daten/DatenKontext.test.tsx new file mode 100644 index 0000000..ad5250c --- /dev/null +++ b/companion-app/src/daten/DatenKontext.test.tsx @@ -0,0 +1,134 @@ +/** + * Was passiert, wenn Home Assistant neu startet. + * + * Gemeldet vom Eigentümer (2026-08-31): während eines Neustarts zeigte die App + * „502 Error“, und die Meldung blieb stehen, nachdem Home Assistant längst + * wieder lief — erst ein Beenden der App half. Zwei Ursachen, beide hier + * abgesichert: + * + * 1. 502 galt als harter Ladefehler statt als „Server kommt gerade hoch“. + * 2. Nach dem Wiederverbinden wurde genau einmal nachgeladen. Schlug das + * fehl (die Websocket-Verbindung steht regelmäßig ein paar Sekunden vor + * der REST-Schnittstelle), kam nie wieder ein „verbunden“ — und damit nie + * wieder ein Versuch. + */ + +import { render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { ApiFehler, type DataMetricApi } from "../api" +import { DatenAnbieter, useDaten } from "./DatenKontext" +import { beispielApi } from "../tests/beispieldaten" + +function Anzeige() { + const { bereit, ladefehler } = useDaten() + return ( +
+ {bereit ? "ja" : "nein"} + {ladefehler ?? "-"} +
+ ) +} + +/** Eine API, deren `profilLesen` sich auf Kommando wie ein gerade startender + Home Assistant verhält: die nächsten n Aufrufe mit 502 abweisen, danach + wieder normal antworten. + + Die Fehlschläge werden bewusst gezählt und nicht an der Aufrufnummer + festgemacht: der Horcher meldet beim Abonnieren sofort "verbunden", die + Zählung liefe sonst schon beim Aufbau los. */ +function apiMitNeustart() { + const echt = beispielApi() + let verbindungHorcher: ((z: string) => void) | null = null + let offeneFehlschlaege = 0 + const profilLesen = vi.fn(async () => { + if (offeneFehlschlaege > 0) { + offeneFehlschlaege -= 1 + throw new ApiFehler("Bad Gateway", 502) + } + return echt.profilLesen() + }) + const api = { + ...echt, + profilLesen, + live: { + ...echt.live, + aufVerbindung: (h: (z: string) => void) => { + verbindungHorcher = h + h("verbunden") + return () => {} + }, + }, + } as unknown as DataMetricApi + return { + api, + profilLesen, + /** Home Assistant startet neu: die nächsten n Ladeversuche laufen ins + 502, dann meldet sich die Websocket-Verbindung zurück. */ + neustartMelden: (fehlschlaege: number) => { + offeneFehlschlaege = fehlschlaege + verbindungHorcher?.("verbunden") + }, + } +} + +describe("Neustart von Home Assistant", () => { + it("meldet 502 nicht als Ladefehler, solange Daten da sind", async () => { + const { api, neustartMelden } = apiMitNeustart() + render( + + + , + ) + await waitFor(() => expect(screen.getByTestId("bereit").textContent).toBe("ja")) + + neustartMelden(1) + + // Der fehlgeschlagene Versuch darf keine Fehlerzeile hinterlassen. + await waitFor(() => expect(screen.getByTestId("fehler").textContent).toBe("-")) + expect(screen.getByTestId("bereit").textContent).toBe("ja") + }) + + it("versucht es nach einem fehlgeschlagenen Nachladen erneut", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + const { api, profilLesen, neustartMelden } = apiMitNeustart() + render( + + + , + ) + await waitFor(() => expect(screen.getByTestId("bereit").textContent).toBe("ja")) + const vorher = profilLesen.mock.calls.length + + neustartMelden(2) + await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 1)) + + // Ohne Wiederholung bliebe es bei diesem einen Versuch — und die App + // auf dem Stand von vor dem Neustart, bis jemand sie beendet. + await vi.advanceTimersByTimeAsync(3000) + await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 2)) + await vi.advanceTimersByTimeAsync(5000) + await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 3)) + + expect(screen.getByTestId("fehler").textContent).toBe("-") + } finally { + vi.useRealTimers() + } + }) +}) + +describe("ApiFehler.istVoruebergehend", () => { + it("gilt für kein Netz und für einen startenden Server", () => { + expect(new ApiFehler("offline", null).istVoruebergehend).toBe(true) + expect(new ApiFehler("bad gateway", 502).istVoruebergehend).toBe(true) + expect(new ApiFehler("unavailable", 503).istVoruebergehend).toBe(true) + expect(new ApiFehler("timeout", 504).istVoruebergehend).toBe(true) + }) + + it("gilt nicht für einen echten Serverfehler", () => { + // 500 bessert sich durch Warten nicht — das muss sichtbar bleiben. + expect(new ApiFehler("kaputt", 500).istVoruebergehend).toBe(false) + expect(new ApiFehler("nicht da", 404).istVoruebergehend).toBe(false) + }) +}) diff --git a/companion-app/src/daten/DatenKontext.tsx b/companion-app/src/daten/DatenKontext.tsx index 732e186..f80184a 100644 --- a/companion-app/src/daten/DatenKontext.tsx +++ b/companion-app/src/daten/DatenKontext.tsx @@ -36,6 +36,14 @@ import { eigeneVersion, versionVergleichen } from "./appVersion" import type { Einstellungen, Fahrzeug } from "./profilAdapter" import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter" +/** Abstaende zwischen zwei Ladeversuchen nach einem Wiederverbinden. + + Wachsend, in Millisekunden, zusammen gut zwei Minuten. Der erste + Abstand ist kurz, weil Home Assistant nach einem Neustart meist binnen + Sekunden antwortet; die spaeteren sind lang, damit ein wirklich + abgestuerzter Server nicht dauernd angefragt wird. */ +const WIEDERHOLABSTAENDE_MS = [2000, 4000, 8000, 15000, 30000, 60000] + export interface DatenWert { bereit: boolean /** Erste Ladung fehlgeschlagen und noch nie Daten gesehen. */ @@ -69,7 +77,10 @@ export interface DatenWert { rohprofil: Profil | null api: DataMetricApi - neuLaden: () => Promise + /** Laedt alles neu; `true`, wenn es geklappt hat. Aufrufer duerfen den + Rueckgabewert ignorieren - er ist fuer die Wiederholung nach einem + Wiederverbinden da. */ + neuLaden: () => Promise jetztAktualisieren: () => Promise /** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */ profilSpeichern: (aenderung: { @@ -113,7 +124,18 @@ export function DatenAnbieter({ const rohprofilRef = useRef(null) rohprofilRef.current = rohprofil - const neuLaden = useCallback(async () => { + // Dasselbe fuer `bereit`: neuLaden liest den Wert nur, darf aber nicht + // davon abhaengen. Stand er in den Abhaengigkeiten, bekaeme der Effekt + // unten (der bewusst nur an `api` haengt) fuer immer die Fassung vom + // ersten Rendern mit `bereit === false` - und wuerde nach einem + // Wiederverbinden jeden Aussetzer als harten Ladefehler melden, obwohl + // laengst Daten da sind. + const bereitRef = useRef(false) + bereitRef.current = bereit + + /** Laedt alles neu. Gibt zurueck, ob es geklappt hat - der Aufrufer + entscheidet, ob er es noch einmal versucht. */ + const neuLaden = useCallback(async (): Promise => { try { const [p, f, t, s] = await Promise.all([ api.profilLesen(), @@ -136,18 +158,39 @@ export function DatenAnbieter({ setzeServerVersion(versionsangabe?.app ?? null) setzeOtaBuendel(versionsangabe?.buendel ?? null) setzeIntegrationUpdate(versionsangabe?.integration_update ?? null) + return true } catch (fehler) { - // Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann - // zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis. - const netzproblem = fehler instanceof ApiFehler && fehler.istNetzproblem - if (!netzproblem || !bereit) { + // Ein voruebergehender Ausfall ist kein Ladefehler, solange schon + // Daten da sind — dann zeigt die App den zwischengespeicherten Stand + // mit Offline-Hinweis. Dazu zaehlt auch ein Home Assistant, der + // gerade neu startet (502/503/504), siehe istVoruebergehend. + const wartet = fehler instanceof ApiFehler && fehler.istVoruebergehend + if (!wartet || !bereitRef.current) { setzeLadefehler(fehler instanceof Error ? fehler.message : String(fehler)) } + return false } - }, [api, bereit]) + }, [api]) // Erstladung plus Live-Verbindung. useEffect(() => { + let abgemeldet = false + let nachladeUhr: ReturnType | null = null + + /** Versucht es so lange, bis eine Ladung durchgeht - mit wachsendem + Abstand, damit ein dauerhaft nicht erreichbarer Server nicht im + Sekundentakt angefragt wird. Nach gut zwei Minuten ist Schluss; + dann hilft nur noch das naechste "verbunden" oder ein Zug nach + unten auf der Uebersicht. */ + const nachladenBisEsKlappt = async (versuch = 0): Promise => { + if (abgemeldet) return + if (await neuLaden()) return + if (abgemeldet || versuch >= WIEDERHOLABSTAENDE_MS.length) return + nachladeUhr = setTimeout(() => { + void nachladenBisEsKlappt(versuch + 1) + }, WIEDERHOLABSTAENDE_MS[versuch]) + } + void neuLaden() void api.warteschlange.laden() @@ -155,7 +198,15 @@ export function DatenAnbieter({ setzeVerbindung(zustand) // Nach einem Wiederverbinden kann während der Trennung etwas passiert // sein, das kein Ereignis mehr ausgelöst hat. - if (zustand === "verbunden") void neuLaden() + // + // Warum das nicht ein einzelner Aufruf sein darf: nach einem Neustart + // von Home Assistant steht die Websocket-Verbindung regelmaessig ein + // paar Sekunden, bevor die REST-Schnittstelle antwortet - dazwischen + // kommt 502. Ein einziger Versuch schlug dann fehl, und weil danach + // kein weiteres "verbunden" mehr folgt, blieb die App bis zum + // Beenden auf dem Stand von vor dem Neustart stehen, mitsamt der + // Fehlerzeile (vom Eigentuemer gemeldet, 2026-08-31). + if (zustand === "verbunden") void nachladenBisEsKlappt() }) const abZustand = api.live.aufZustand(({ entity_id, new_state }) => { @@ -184,6 +235,8 @@ export function DatenAnbieter({ void api.live.starten() return () => { + abgemeldet = true + if (nachladeUhr) clearTimeout(nachladeUhr) abVerbindung() abZustand() abWarteschlange() diff --git a/companion-app/src/daten/ota.ts b/companion-app/src/daten/ota.ts index 05da5c8..c4fb615 100644 --- a/companion-app/src/daten/ota.ts +++ b/companion-app/src/daten/ota.ts @@ -83,6 +83,29 @@ export function buendelPasst(buendel: Buendelangabe | null, eigene: string | nul export class OtaFehler extends Error {} +/** + * Erklärt einen fehlgeschlagenen Download, statt die Meldung des Plugins + * durchzureichen. + * + * „Checksum" heißt: die geladenen Bytes sind nicht die erwarteten. In diesem + * Projekt war die Ursache bisher nie ein kaputtes Bündel (Ablage, Auslieferung + * und Prüfsumme sind nachweislich deckungsgleich), sondern ein Download, der + * mitten in einen Neustart von Home Assistant lief. Das ist keine Sackgasse, + * sondern ein Grund, es gleich noch einmal zu versuchen - und genau das soll + * dort stehen, wo der Fehler steht. + */ +function ladefehlerText(fehler: unknown): string { + const meldung = fehler instanceof Error ? fehler.message : String(fehler) + if (/checksum|prüfsumme|pruefsumme/i.test(meldung)) { + return ( + "Das Update kam unvollständig an. Das passiert, wenn Home Assistant " + + "während des Ladens neu startet. Sobald es wieder läuft, noch einmal " + + `versuchen. (${meldung})` + ) + } + return `Das Update konnte nicht geladen werden (${meldung}).` +} + /** * Lädt das Bündel und schaltet darauf um. * @@ -100,11 +123,21 @@ export async function buendelAnwenden( ): Promise { if (!otaMoeglich()) throw new OtaFehler("Updates gibt es nur in der App auf dem Gerät.") - const url = new URL(buendel.url, basisUrl).href + // Die Fassung hängt als Abfrageteil an der Adresse. + // + // Home Assistant liefert /audi_dashboard_static/ ohne Cache-Control, aber + // mit ETag und Last-Modified (nachgemessen). Genau dann darf jeder Zwischen- + // speicher - der des Geräts wie einer im Weg - die Datei nach eigenem + // Ermessen eine Weile behalten. Käme dabei die vorherige Zip zurück, während + // die Prüfsumme aus der Entität längst die neue nennt, scheiterte jedes + // Update mit einem Prüfsummenfehler, ohne dass irgendetwas kaputt wäre. + // Dieselbe Vorsorge trifft das Panel seit jeher für sein eigenes Bündel. + const adresse = new URL(buendel.url, basisUrl) + adresse.searchParams.set("v", buendel.version) let geladen try { geladen = await CapacitorUpdater.download({ - url, + url: adresse.href, version: buendel.version, // Das Plugin rechnet auf dem Gerät SHA-256 über die heruntergeladene // Zip und verwirft sie bei Abweichung (calcChecksum in @@ -113,9 +146,7 @@ export async function buendelAnwenden( checksum: buendel.sha256, }) } catch (fehler) { - throw new OtaFehler( - `Das Update konnte nicht geladen werden (${fehler instanceof Error ? fehler.message : String(fehler)}).`, - ) + throw new OtaFehler(ladefehlerText(fehler)) } try { diff --git a/companion-app/src/daten/statistik.ts b/companion-app/src/daten/statistik.ts index 4ee45f2..09bd424 100644 --- a/companion-app/src/daten/statistik.ts +++ b/companion-app/src/daten/statistik.ts @@ -12,8 +12,17 @@ import { summe } from "../format" /** "absolut" ist kein Zeitraum, sondern alles je Erfasste - die Summe. */ export type Zeitraum = "jahr" | "monat" | "woche" | "tag" | "absolut" +/** Alle Zeiträume, die berechnet werden - einschließlich "absolut". */ export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag", "absolut"] +/** Die Spalten, die eine Kachel zeigt. + + "absolut" fehlt hier bewusst: der Gesamtwert steht seit dem 2026-08-31 + als große Zahl in der Kopfzeile der Kachel (Ansage des Eigentümers). Eine + fünfte Spalte passte auf dem Telefon ohnehin nicht in eine Zeile - die + Kachel ist dort innen 307px breit, ein sechsstelliger Wert 67px. */ +export const ZEITRAUM_SPALTEN: Zeitraum[] = ["jahr", "monat", "woche", "tag"] + export const ZEITRAUM_NAME: Record = { jahr: "Jahr", monat: "Monat", @@ -146,6 +155,11 @@ export interface Jahresauswertung { stundenJahr: number literJahr: number kostenJahr: number + /** Über den gesamten erfassten Zeitraum - die Zahl in der Kopfzeile. */ + kmGesamt: number + stundenGesamt: number + literGesamt: number + kostenGesamt: number tagKm: number nachtKm: number arbeitKm: number @@ -206,6 +220,10 @@ export function jahresauswertung( stundenJahr: jeZeitraum.jahr.stunden, literJahr: jeZeitraum.jahr.liter, kostenJahr: jeZeitraum.jahr.kosten, + kmGesamt: jeZeitraum.absolut.km, + stundenGesamt: jeZeitraum.absolut.stunden, + literGesamt: jeZeitraum.absolut.liter, + kostenGesamt: jeZeitraum.absolut.kosten, tagKm, nachtKm: kmJahr - tagKm, arbeitKm, diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 97b4f08..0cd351d 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -16,7 +16,7 @@ import { useDaten } from "../daten/DatenKontext" import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota" import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter" import { datum, datumZeit, isoTag } from "../format" -import { useBoden, useTheme } from "../theme" +import { useTheme } from "../theme" import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine" import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder" import { DatensatzPopup } from "./DatensatzPopup" @@ -49,7 +49,6 @@ export function Einstellungen({ neuLaden, } = useDaten() const [theme, themeSetzen] = useTheme() - const [boden, setzeBoden] = useBoden() // "Fahrzeug einrichten" ist im Panel zugeklappt und oeffnet sich ueber den // Knopf darunter (einrichtenOffen in vEinst()). const [einrichtenOffen, setzeEinrichtenOffen] = useState(false) @@ -356,12 +355,6 @@ export function Einstellungen({ Aus zeigt nur die Symbole und gibt etwas Höhe frei - - - - - Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten - diff --git a/companion-app/src/screens/Statistik.tsx b/companion-app/src/screens/Statistik.tsx index 8f5ef10..0d0e19c 100644 --- a/companion-app/src/screens/Statistik.tsx +++ b/companion-app/src/screens/Statistik.tsx @@ -14,7 +14,7 @@ import { useState, type ReactNode } from "react" import { Fig, ProgressBar, Tile } from "@audi-dash/ui" import { useDaten } from "../daten/DatenKontext" -import { ZEITRAEUME, ZEITRAUM_NAME, jahresauswertung } from "../daten/statistik" +import { ZEITRAUM_NAME, ZEITRAUM_SPALTEN, jahresauswertung } from "../daten/statistik" import type { Zeitraum } from "../daten/statistik" import { datum, de, deOderStrich } from "../format" import { Leerzustand, Wertzeile, Werteliste } from "./bausteine" @@ -105,15 +105,18 @@ export function Statistik() { // vier Mal für dieselbe Sache und fehlte in der Überschrift, die man // zuerst liest. const jeZeitraum = (auswahl: (z: Zeitraum) => string): [string, string][] => - ZEITRAEUME.map((z) => [auswahl(z), ZEITRAUM_NAME[z]]) + ZEITRAUM_SPALTEN.map((z) => [auswahl(z), ZEITRAUM_NAME[z]]) return ( <> + {/* Die große Zahl ist der Gesamtwert, nicht der des laufenden Jahres + - deshalb steht im Titel auch keine Jahreszahl mehr. Die vier + Spalten darunter bleiben die Zeiträume. */} > = { "Inspektion": SymbolInspektion, } -/* Innenraumaufnahmen bekommen keinen Bodenschatten — `OHNE_SZENE` im Panel. */ -const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"] export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { const { fahrzeug, einstellungen, fahrten, tankvorgaenge } = useDaten() - const [boden] = useBoden() if (!fahrzeug || !einstellungen) return null - // Bodenschatten unter dem Fahrzeug, wie szeneKlasse() im Panel: abschaltbar, - // nicht bei Innenraumaufnahmen, blaustichig bei montierten Winterrädern. const winterraeder = fahrzeug.reifen.aktiv === "Winter" /* Der gespeicherte Wert ist der NAME der Ansicht, nicht der Dateiname - siehe die Begruendung in Einstellungen.tsx. Der Vergleich gegen datei @@ -50,12 +44,6 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) // Bei Winterrädern zeigt die Seitenansicht das Winterfoto, wie bildInfo() // im Panel — das fehlte hier ganz. const startbild = bildInfo(platz, winterraeder) - const bodenKlasse = - !boden || OHNE_SZENE.includes(platz.name) - ? "" - : winterraeder - ? " dm-szene--winter" - : " dm-szene--strasse" // Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild) // plus der eigens gepflegten Ausführung — so macht es auch das alte Panel. @@ -104,7 +92,7 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) Typenschild, Kennzeichen und Statuszeile — dieselbe Aufteilung wie vHome() im Panel (`.szene.bleed` gefolgt von einer `.tile.flat`). Der Status stand hier bisher als eigenes Element unter der Szene. */} -
+
null, tankvorgangLoeschen: async () => null, jetztAktualisieren: async () => null, + // Die echte Fassung schluckt ihre Fehler selbst und liefert dann null. + // Fehlte sie hier ganz, endete jede Ladung im Fangzweig von neuLaden - + // die Seiten rendern trotzdem, aber ein Test ueber den Ladefehler waere + // wertlos. + appVersionAngabeLesen: async () => null, } as unknown as DataMetricApi } diff --git a/companion-app/src/theme.test.tsx b/companion-app/src/theme.test.tsx index a15aff5..60aed57 100644 --- a/companion-app/src/theme.test.tsx +++ b/companion-app/src/theme.test.tsx @@ -4,8 +4,8 @@ * Jeder Aufruf von `useTheme()` legte bis 2026-08-31 sein eigenes `useState` * an. Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie; * die Kopie in `App.tsx`, die `data-theme` setzt, blieb stehen — sichtbar - * wurde die Wahl erst nach einem Neustart der App. Der Boden-Schalter hatte - * denselben Fehler. + * wurde die Wahl erst nach einem Neustart der App. Die uebrigen Schalter + * hatten denselben Fehler. * * Der Test stellt genau diese Konstellation nach: zwei getrennte Komponenten, * eine schaltet, die andere zeigt an. @@ -14,26 +14,26 @@ import { fireEvent, render, screen } from "@testing-library/react" import { beforeEach, describe, expect, it } from "vitest" -import { useBoden, useTheme } from "./theme" +import { useTabBeschriftung, useTheme } from "./theme" function Anzeige() { const [theme] = useTheme() - const [boden] = useBoden() + const [beschriftung] = useTabBeschriftung() return (
{theme} - {boden ? "an" : "aus"} + {beschriftung ? "an" : "aus"}
) } function Schalter() { const [theme, setzeTheme] = useTheme() - const [boden, setzeBoden] = useBoden() + const [beschriftung, setzeBeschriftung] = useTabBeschriftung() return (
- +
) } @@ -57,17 +57,17 @@ describe("Ansichtseinstellungen, geteilt zwischen Komponenten", () => { expect(screen.getByTestId("theme").textContent).not.toBe(vorher) }) - it("der Boden-Schalter der einen Komponente erreicht die andere", () => { + it("der Beschriftungs-Schalter der einen Komponente erreicht die andere", () => { render( <> , ) - const vorher = screen.getByTestId("boden").textContent + const vorher = screen.getByTestId("beschriftung").textContent - fireEvent.click(screen.getByText("Boden")) + fireEvent.click(screen.getByText("Beschriftung")) - expect(screen.getByTestId("boden").textContent).not.toBe(vorher) + expect(screen.getByTestId("beschriftung").textContent).not.toBe(vorher) }) }) diff --git a/companion-app/src/theme.ts b/companion-app/src/theme.ts index 3782f5a..00ddd2a 100644 --- a/companion-app/src/theme.ts +++ b/companion-app/src/theme.ts @@ -11,13 +11,13 @@ * * useTheme() → App.tsx (setzt data-theme), Einstellungen.tsx (der Schalter), * Typenschild.tsx (zweimal, für die Farbe des Schilds) - * useBoden() → Einstellungen.tsx (der Schalter), Uebersicht.tsx (die Anzeige) + * useTabBeschriftung() → Einstellungen.tsx (der Schalter), App.tsx (die Leiste) * * Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie und * den Speicher der Ablage — die Kopie in App.tsx blieb stehen, `data-theme` * wechselte nie, und die Umschaltung wirkte erst nach einem Neustart der App. - * Genau so gemeldet: „Tag/Nacht Umschaltung wirkungslos". Der Boden-Schalter - * hatte denselben Fehler, nur ist er weniger auffällig. + * Genau so gemeldet: „Tag/Nacht Umschaltung wirkungslos". Die übrigen + * Schalter hatten denselben Fehler, nur ist er dort weniger auffällig. * * `useSyncExternalStore` (in React eingebaut) löst das an der Wurzel: es gibt * je Einstellung genau einen Wert, jeder Aufrufer sieht denselben, und ein @@ -95,11 +95,6 @@ const THEMA = einstellung( (wert) => wert, ) -const BODEN = einstellung( - "dm360.boden", - (roh) => roh !== "aus", - (an) => (an ? "an" : "aus"), -) const TABBESCHRIFTUNG = einstellung( "dm360.tabbeschriftung", @@ -129,16 +124,10 @@ export function useTheme(): [Theme, (theme: Theme) => void] { return [theme, setzen] } -/** - * Bodenschatten unter dem Fahrzeugbild — im Panel der Schalter „Boden" in den - * Einstellungen (`bodenAn`). Dort lebt er nur im Speicher und ist nach einem - * Neuladen wieder an; hier bleibt er erhalten, Standard wie im Panel: an. - */ -export function useBoden(): [boolean, (an: boolean) => void] { - const an = useSyncExternalStore(BODEN.horchen, BODEN.stand, BODEN.stand) - const setzen = useCallback((neu: boolean) => BODEN.setzen(neu), []) - return [an, setzen] -} +/* useBoden() ist am 2026-08-31 entfallen: der Bodenschatten unter dem + Fahrzeug ist weg (Ansage des Eigentuemers), damit hatte der Schalter + nichts mehr zu schalten. Der abgelegte Wert unter "dm360.boden" bleibt + liegen und stoert nicht - er wird schlicht nicht mehr gelesen. */ /** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */ export function useTabBeschriftung(): [boolean, (an: boolean) => void] { diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index c659ce3..0815347 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.8.31.3","sha256":"c0b6aa9b9740fa9c9390a6d200a30aeb8430937a22129e7d61fa88e5452f9283","bytes":266044,"gebaut":"2026-08-31T13:02:37Z"} \ No newline at end of file +{"version":"2026.8.31.4","sha256":"48d4965f198c06dbcad882c76eca656de6cc8c18a17c7ff24a39c5c9a1e8c5a9","bytes":266032,"gebaut":"2026-08-31T13:30:52Z"} \ 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 496e2fd..09e0cb1 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 a05b191..c239f96 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -368,12 +368,12 @@ function markenlogo() { ${esc(label)} `; } -const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"]; -function szeneKlasse(i) { - if (!bodenAn) return ""; - if (OHNE_SZENE.includes(BILDER[i].name)) return ""; - return CAR.reifen.aktiv === "Winter" ? "winter" : "strasse"; -} +/* Der angedeutete Boden unter dem Fahrzeug ist am 2026-08-31 entfallen - + der Eigentuemer wollte den Schatten weg, und damit hatte auch der + Schalter dafuer nichts mehr zu schalten. Frueher stand hier + szeneKlasse(), das je nach Bild und Reifensatz "strasse" oder "winter" + an .szene haengte; die zugehoerigen ::after-Regeln sind in + audi-dashboard.css beschrieben. */ function bildWeiter() { bild = (bild + 1) % BILDER.length; const img = ROOT.getElementById("carimg"); @@ -387,7 +387,7 @@ function bildWeiter() { box.querySelector(".platzhalter-label").textContent = info.label; } const sz = ROOT.getElementById("szene"); - if (sz) sz.className = "szene " + szeneKlasse(bild); + if (sz) sz.className = "szene"; [...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", i === bild)); } @@ -1654,7 +1654,7 @@ function vHome() { zwei kurze, haeufig gelesene Textzeilen lagen bisher unter einer 190px hohen Karte und damit auf dem Telefon unterhalb des ersten Bildschirms. */ return ` -
${carImg("fest")}
+
${carImg("fest")}
${badge()} ${esc(CONFIG.kennzeichen)} @@ -1815,7 +1815,7 @@ function vTuerenKlappen() { function vAudi() { const r = CAR.reifen, a = r[r.aktiv.toLowerCase()]; return ` -
${carImg("galerie")}
+
${carImg("galerie")}
${badge()} ${esc(CONFIG.kennzeichen)} @@ -2793,7 +2793,6 @@ const SCHRIFT_KEY = "audi-dashboard-tableiste-schrift"; let beschriftung = (() => { try { return localStorage.getItem(SCHRIFT_KEY) !== "aus"; } catch (e) { return true; } })(); -let bodenAn = true; function csvDatei(name, kopf, zeilen) { const txt = [kopf.join(";")].concat(zeilen.map((r) => r.join(";"))).join("\r\n"); const a = document.createElement("a"); @@ -2858,10 +2857,6 @@ function vEinst() {
Aus zeigt nur die Symbole und gibt etwas Höhe frei -
- -
- Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten
Bild der Übersicht
@@ -3040,6 +3035,10 @@ function entitaetScore(feld, e, suchtext) { if (feld.domains && feld.domains.includes(e.domain)) score += 3; if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3; if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2; + // Der Signalname aus dem Katalog ("z. B. can_fuel_volume") ist der + // treffsicherste Hinweis, den es gibt - deutlich mehr wert als ein + // einzelnes Stichwort. + if (feld.beispiel && hay.includes(feld.beispiel.toLowerCase())) score += 8; if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10; return score; } @@ -3048,7 +3047,7 @@ function entitaetKandidaten(feldKey, suchtext, aktuellerWert) { const feld = setupKatalog().find((f) => f.key === feldKey); if (!feld) return []; const text = (suchtext || "").trim().toLowerCase(); - const liste = ALLE_ENTITAETEN.filter((e) => { + let liste = ALLE_ENTITAETEN.filter((e) => { if (e.id === aktuellerWert) return true; if (setupNurPassend) { if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false; @@ -3065,6 +3064,27 @@ function entitaetKandidaten(feldKey, suchtext, aktuellerWert) { if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false; return true; }); + /* "Nur passende Sensoren" sucht zusaetzlich nach dem Namen, der neben der + Ueberschrift als "(z. B. ...)" steht - dem tatsaechlichen Signalnamen der + Integration, nicht einem ausgedachten Beispiel (siehe FELDER in + einstellungen.py). Domain und Einheit allein lassen bei einem Fahrzeug + mit siebzig Sensoren immer noch Dutzende uebrig; der Signalname trifft + genau einen. + + Mit Rueckfall: findet der Name nichts, bleibt die ungefilterte Liste + stehen. Eine leere Auswahl waere schlimmer als eine lange - der Beispiel- + name stammt aus dieser einen Installation, eine andere kann ihre Signale + anders nennen. */ + if (setupNurPassend && feld.beispiel) { + const beispiel = feld.beispiel.toLowerCase(); + const genau = liste.filter( + (e) => + e.id === aktuellerWert || + e.id.toLowerCase().includes(beispiel) || + e.name.toLowerCase().includes(beispiel.replace(/_/g, " ")), + ); + if (genau.length) liste = genau; + } liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext)); return liste.slice(0, 60); } @@ -3647,6 +3667,13 @@ function vStat() { const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag"); const fAbs = fahrtenSeit("absolut"), tAbs = tankSeit("absolut"); const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600; + // Die grosse Zahl oben in den beiden Kacheln zeigt seit dem 2026-08-31 den + // Gesamtwert, nicht mehr den des laufenden Jahres (Ansage des Eigentuemers). + // Damit steht "Absolut" dort, wo man es zuerst liest, und die Spalten + // darunter bleiben die vier Zeitraeume - fuenf passten auf dem Telefon + // nachweislich nicht in eine Zeile. + const kmAbs = sum(fAbs, (t) => t.distance_km), hAbs = sum(fAbs, (t) => t.duration_s) / 3600; + const lAbs = sum(tAbs, (f) => f.liters), eAbs = sum(tAbs, (f) => f.fuel_total_eur || 0); const tJahr = tankSeit("jahr"); const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0); const tagKm = sum(fJahr.filter((t) => !istNachtZeit(t.ts_start)), (t) => t.distance_km); @@ -3655,12 +3682,12 @@ function vStat() { const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm; const lv = langzeitverbrauch(); return ` - ${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h", - quad("Gefahrene Distanz [km]", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"], [de(sum(fAbs, (t) => t.distance_km)), "Absolut"]]) + - quad("Fahrzeit [h]", [[de(hJahr), "Jahr"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag"], [de(sum(fAbs, (t) => t.duration_s) / 3600), "Absolut"]]))} - ${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €", - quad("Getankte Liter [l]", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"], [de(sum(tAbs, (f) => f.liters)), "Absolut"]]) + - quad("Kraftstoffkosten [€]", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"], [de(sum(tAbs, (f) => f.fuel_total_eur || 0)), "Absolut"]]))} + ${statTile("s1", "Distanz und Fahrzeit", de(kmAbs), "km", de(hAbs) + " h", + quad("Gefahrene Distanz [km]", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) + + quad("Fahrzeit [h]", [[de(hJahr), "Jahr"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag"]]))} + ${statTile("s2", "Getankte Menge und Kosten", de(lAbs), "l", de(eAbs) + " €", + quad("Getankte Liter [l]", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) + + quad("Kraftstoffkosten [€]", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"]]))} ${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "–", `
Getankt insgesamt
${de(lv.liter, 1)} l
@@ -3668,7 +3695,7 @@ function vStat() {
Letzter Tankstop
${lv.bis ? dedat(lv.bis) : "–"}
${lv.unplausibel ? `Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.` : ""} - ${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"], [schnittFahrtVerbrauch(fAbs), "Absolut"]])} + ${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])} Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der einzelnen Fahrten, keine Meldung des Fahrzeugs.`)} ${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km", `
@@ -5773,7 +5800,6 @@ function ereignisseVerdrahten() { } if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; } if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; } - if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; } if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; } if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; } if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; } diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css b/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css index 12fc092..4a413b4 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css @@ -349,7 +349,9 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo einer 132px hohen, overflow:hidden-Box (leerer Zustand), also sichtbar abgeschnitten. Bild und Platzhalter fuellen die Box ohnehin per 100%. */ .bildbox.carfix{height:260px} - .bildbox.carfix.klein{height:220px} + /* Feste 220px hier wuerden das Foto wieder beschneiden - die Hoehe + bestimmt seit dem 2026-08-31 das Bild selbst (siehe audi-dashboard.css). */ + .bildbox.carfix.klein{height:auto} .bildgrid{grid-template-columns:repeat(4,1fr)} .quad{gap:14px} diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard.css b/custom_components/audi_dashboard/frontend/audi-dashboard.css index 8e834e8..9721e83 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard.css @@ -596,30 +596,27 @@ button.leaf:active { background: var(--tile-2); } blendet nach unten in die Fläche aus. */ .szene.bleed { margin: -10px -20px 0; padding: 0 0 10px; } .szene.bleed .carbox { padding: 0 8px; } -.szene.bleed::before { - content: ""; - position: absolute; left: 0; right: 0; bottom: 0; - /* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden, - nicht das Fahrzeug. Mit 42% Hoehe und Vollton ab 78% war das untere - Drittel des Fotos praktisch einfarbig - Raeder und Schweller lagen - darunter (gemeldet vom Eigentuemer, 2026-08-31). */ - height: 26%; - background: linear-gradient(to bottom, transparent, var(--canvas) 96%); - pointer-events: none; - z-index: 1; -} -.szene.strasse::after, .szene.winter::after { - content: ""; - position: absolute; - left: 8%; right: 8%; bottom: 6px; - height: 26px; - border-radius: 50%; - background: radial-gradient(ellipse at center, var(--shade), transparent 70%); -} -.szene.winter::after { background: radial-gradient(ellipse at center, rgba(150,190,255,.16), transparent 70%); } +/* Der Ausblend-Verlauf und der Bodenschatten sind am 2026-08-31 entfallen. + Beide lagen ueber dem unteren Teil des Fotos, und der Eigentuemer hat + zweimal darum gebeten, das Fahrzeug unverdeckt zu sehen. Zurueckholen + waere ein linear-gradient auf .szene.bleed::before bzw. ein + radial-gradient auf .szene.strasse::after - beides steht in der + Versionsgeschichte. */ .carbox { position: relative; } .carfix { width: 100%; aspect-ratio: 16 / 9; } -.carfix.klein { aspect-ratio: 16 / 8; } +/* Das Fahrzeugfoto wird nicht mehr beschnitten. + + Gemessen am echten Foto (2026-08-31): es ist 2000x1125 gross, also 16:9. + Der Kasten stand auf 16:8 - bei object-fit:cover wurde das Bild auf die + Kastenbreite skaliert und oben wie unten je ein Viertel abgeschnitten + (796x220 Kasten, 796x448 skaliertes Bild). Das Dach lag darin. + + Statt das Seitenverhaeltnis auf 16:9 festzuschreiben - was beim naechsten + Foto in einem anderen Format wieder schneiden wuerde - bestimmt jetzt das + Bild selbst die Hoehe. Der Platzhalter behaelt sein festes Verhaeltnis, + er hat ja keines von sich aus. */ +.bildbox.carfix.klein { aspect-ratio: auto; height: auto; } +img.carfix.klein { aspect-ratio: auto; height: auto; } /* Reifenfoto ist oft eine breite Aufnahme; statt beide Ränder symmetrisch wegzuschneiden (object-fit:cover-Standard), bleibt bewusst der rechte Rand des Originalfotos erhalten - dort sitzt das Rad meist im Bild. */ @@ -890,7 +887,23 @@ button.leaf:active { background: var(--tile-2); } nebeneinander: bei 307px Innenbreite blieben je 51px, und "209.177" braucht bei 16px Audi Type Wide rund 60. auto-fit bricht deshalb um - auf dem Telefon drei plus zwei, auf einem breiten Bildschirm alle fuenf in einer Reihe. */ -.quad { display: grid; grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); gap: var(--sp-3); margin-top: var(--sp-3); } +/* Vier feste Spalten - Jahr, Monat, Woche, Tag muessen in EINE Zeile passen. + + Vorher stand hier repeat(auto-fit, minmax(84px, 1fr)). Auf dem Telefon ist + die Kachel innen 307px breit, damit passten bei 12px Abstand nur drei + Spalten (3 x 84 + 2 x 12 = 276; vier waeren 372) - die vierte rutschte in + eine zweite Zeile. + + Mit vier festen Spalten bleiben 67,75px je Spalte. Der laengste realistisch + vorkommende Wert ist ein sechsstelliger Kilometerstand wie "209.177": am + laufenden Panel gemessen 67px in Audi Type Wide 300 bei 16px. Es passt + also, aber ohne Reserve - eine fuenfte Spalte gaebe es nur mit kleinerer + Schrift, und genau deshalb steht der Gesamtwert jetzt in der Kopfzeile der + Kachel statt in einer eigenen Spalte. + + Bewusst kein white-space:nowrap: waere ein Wert doch einmal breiter, + umbricht er innerhalb seiner Spalte, statt aus der Kachel zu laufen. */ +.quad { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); margin-top: var(--sp-3); } .quad .n { display: block; font-family: "Audi Type Wide", var(--font-stack); font-size: 16px; font-weight: 300; color: var(--fg); line-height: 1.2; } .quad .l { display: block; margin-top: 4px; font-size: 11px; letter-spacing: normal; color: var(--fg3); line-height: 1.3; } .bar { display: flex; height: 4px; border-radius: var(--r-pill); overflow: hidden; gap: 2px; } diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 5c13536..1d90a11 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.31.3", + "version": "2026.8.31.4", "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"],