Debug-Mode, eine Version-Kachel, Knoepfe nach HIG, Bilder ohne Verzoegerung (2026.9.4.11)

Debug-Mode: Zugaenge und Dongle stehen nicht mehr verstreut oben in den
Einstellungen, sondern zusammen hinter einem Schalter am Seitenende - dazu
neu der letzte Datensatz des Dongles (geraet.py). "Letzter Datensatz" heisst
dabei: alle Entitaeten des Geraets, deren Aktualisierung hoechstens zwei
Sekunden nach der juengsten liegt - die flespi-Integration setzt einen
Datensatz in Millisekunden. Angezeigt mit Ankunft, Geraetezeit und dem
Rueckstand zwischen beiden.

Beim Verifizieren gefunden: app_version_veroeffentlichen() lief nur beim
Start und nach einer Aktion, und beim Start haben die Entitaeten des Geraets
noch keinen Zustand - die Kachel waere dauerhaft leer geblieben. Laeuft
jetzt im Minutentakt mit, ohne Netzaufruf.

Ausserdem:
- "Version" und "Integration-Update" sind eine Kachel
- Speichern/Verwerfen als zwei gleich breite Knoepfe, der bestaetigende
  gefuellt und rechts; "Verwerfen" war in der App blosser Text
- Bilder werden nach dem ersten Zeichnen in der Leerlaufzeit vorgeladen UND
  dekodiert. Die Verzoegerung beim Seitenwechsel kam nicht vom Netz (31 Tage
  Cache), sondern vom asynchronen Dekodieren eines frisch erzeugten <img>.

214/214 Tests, Panel als Modul geparst, live gegen audi_ha_test geprueft.
This commit is contained in:
2026-09-04 12:15:33 +02:00
parent 63a45d4ee2
commit 4ab6b9fca5
14 changed files with 695 additions and 182 deletions
+100 -2
View File
@@ -6,8 +6,9 @@ Speichern-Knopf, und die Share-Erweiterung trug eine andere Versionsnummer als d
dazu die Kopfmarke statt der Firmierung des Betreibers und ein
Cache-Brecher auf der .ipa-Adresse; die .ipa liess sich zuletzt nicht mehr
installieren - der Erweiterung fehlte die CFBundleVersion; dazu der QR-Scanner
fuer den Zugangs-Token, `2026.9.4.8`,
Abschnitte CC bis CE. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt:
fuer den Zugangs-Token; dazu Debug-Mode, eine Version-Kachel, das Knopfpaar nach
HIG und Bilder ohne Verzoegerung, `2026.9.4.11`,
Abschnitte CC bis CF. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt:
das Fahrtfenster stand in Geraetezeit, der Verlauf war nach Ankunftszeit sortiert. Dazu die neun
Paritaetsbefunde und die verunreinigte Batteriehistorie, `2026.9.4.1`, Abschnitt CB. Davor: Der Regler stellt den Schlaf-Timeout des Dongles, flespi lesend und
schreibend, die eigene Warteschlange wieder entfallen, `2026.9.3.10`, Abschnitt BZ. Davor: Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie
@@ -9844,3 +9845,100 @@ unter „Zugangs-Token", das Blatt oeffnet mit Videofeld und Zielrahmen, und ohn
Kamerafreigabe steht dort die erklaerende Meldung statt einer leeren Flaeche.
**Nicht geprueft, weil es in dieser Umgebung nicht geht:** ein echter Scan - die
Browser-Vorschau gibt keine Kamera heraus. Das zeigt sich erst auf dem Geraet.
## CF. Debug-Mode, eine Version-Kachel, Knoepfe nach HIG, Bilder ohne Verzoegerung (2026.9.4.11)
Vier Punkte aus demselben Stapel des Eigentuemers, beide Codebasen.
### Debug-Mode: Zugaenge, Dongle und der letzte Datensatz, zugeklappt und unten
„Zugaenge" stand bisher als zweite Kachel ganz oben, „Dongle" mitten in der
Seite - beides Dinge, die man einmal im Vierteljahr anfasst. Sie stehen jetzt
zusammen hinter einem Schalter am Seitenende, zusammen mit einer dritten,
neuen Kachel.
**`geraet.py` (neu): der letzte Datensatz des Dongles.** Die flespi-Integration
setzt die Entitaeten EINES Geraetedatensatzes nacheinander, aber innerhalb
weniger Millisekunden (am 01.09.2026 gemessen: 2 ms zwischen Zuendung und
zugehoeriger Meldezeit, Abschnitt AY). „Der letzte Datensatz" ist deshalb
definiert als: alle Entitaeten des Geraets, deren letzte Aktualisierung
hoechstens `FENSTER_S` (2 s) nach der juengsten liegt. Das Geraet wird nicht
geraten, sondern ueber das Entitaetsregister der zugeordneten Meldezeit
bestimmt (sonst Fahrtsignal, sonst Zuendung); findet sich keines, bleibt der
Wert `None`.
Angezeigt werden Geraetename, **Ankunft**, **Geraetezeit** und der **Rueckstand**
zwischen beiden - genau die Angabe, deren Missachtung die Wurzel des
3-km-Fahrt-Fehlers war (Abschnitt CB).
**Ein echter Fund beim Verifizieren:** die Kachel blieb leer.
`app_version_veroeffentlichen()` laeuft naemlich **nur** beim Start und nach
einer Aktion (Token, Dongle, Update) - beim Start haben die Entitaeten des
Geraets aber noch gar keinen Zustand, und danach wurde nie wieder
veroeffentlicht. Der Minutentakt ruft sie jetzt mit. Kosten: ein kleiner
Dateilesevorgang (`bundle.json`) je Minute, **kein** Netzaufruf - die Pruefung
bei Gitea bleibt an ihrem Dienst (Abschnitt J).
Live nachgewiesen im Panel: 46 Werte aus derselben Meldung, Geraetezeit
04.09. 00:49 gegen Ankunft 12:08 - Rueckstand 40.745 s, weil der Dongle seit
dem Vorabend schweigt und „Angekommen" nach einem Neustart Home Assistants
eigener Zeitpunkt ist. Genau das sagt die Fussnote der Kachel auch.
### „Version" und „Integration-Update" sind eine Kachel
Es ist dieselbe Sache aus zwei Blickwinkeln; zwei Kacheln nebeneinander
zwangen dazu, zwei Zahlen ueber eine Kachelgrenze hinweg zu vergleichen.
### Das Knopfpaar Speichern/Verwerfen
Gemeldet: „Verwerfen ist kein richtiger Button". Stimmt - in der App war es
ein blosser Textknopf neben einem vollen. Jetzt **zwei gleich breite Knoepfe**,
der bestaetigende gefuellt und rechts, der Ausweg daneben als zweite Rolle:
ein Paar aus Bestaetigen und Abbrechen ist eine Entscheidung zwischen zwei
Wegen, keine Haupt- und Nebensache, und welcher gewollt ist, sagt die Fuellung
und nicht die Groesse.
Geaendert an **beiden** Stellen der App (Regler-Kachel und Speicherleiste am
Seitenende), sonst haette dieselbe Sache zwei Muster auf einer Seite. Im Panel
standen beide bereits als volle Knoepfe, nur untereinander - dort jetzt
dieselbe Zeile (`.knopfpaar`).
**Nicht angefasst:** die „Abbrechen"-Textknoepfe der Bearbeiten-Formulare in
`FahrtDetail.tsx`/`TankDetail.tsx`. Dieselbe Frage, aber nicht gemeldet und ein
anderer Zusammenhang (Formular statt Einstellung) - hier vermerkt, nicht still
mitgeaendert.
### Bilder erscheinen ohne Verzoegerung
Gemeldet: „Bilder laden beim Umschalten der Menues einige ms verzoegert."
**Am Netz liegt es nicht**, und das ist der Punkt: die Adresse ist innerhalb
einer Sitzung stabil, und Home Assistant liefert `/local/` mit 31 Tagen
Cache-Dauer (in Abschnitt AR gemessen). Verzoegert wird das Bild selbst - ein
frisch erzeugtes `<img>` wird standardmaessig **asynchron dekodiert** und erst
danach gezeichnet, und beim Seitenwechsel entsteht jedes Mal ein neues.
`bilderVorladen()` holt alle Fahrzeugfotos einmal und ruft `decode()` darauf.
Danach zeichnet dasselbe Bild beim naechsten Einhaengen sofort. Ausgefuehrt in
der **Leerlaufzeit** nach dem ersten Zeichnen (`requestIdleCallback`, sonst
`setTimeout`), damit es nicht mit dem Abruf von Profil, Fahrten und
Tankvorgaengen konkurriert; das Datenvolumen ist dasselbe wie beim
Durchblaettern aller Seiten, nur frueher.
Live nachgewiesen im Panel: nach dem Laden stehen alle neun Bildplaetze plus
die Winter-Seitenansicht in `performance.getEntriesByType("resource")`.
### Verifiziert
`py_compile` auf `geraet.py` und `koordinator.py`, Panel als **Modul** geparst,
`tsc --noEmit` sauber, **214/214** Tests, `vite build` sauber, `audi_ha_test`
auf `2026.9.4.11` sauber gestartet, und im Panel live nachgesehen: Debug-Mode
klappt auf und zeigt Zugaenge, Dongle und die 49 Zeilen des letzten
Datensatzes; „Version" traegt jetzt Installiert **und** Update.
**Nicht live geprueft:** dieselben Kacheln in der App. Die Browser-Vorschau
kennt keinen gespeicherten Zugang, und einen Token anzulegen oder einzugeben
ist ausgeschlossen. Abgedeckt sind sie durch `tsc`, den
Alle-Seiten-Rendertest und die Tatsache, dass das Panel dieselbe Struktur aus
denselben Backend-Daten baut - der Zweig MIT Datensatz ist in der App damit
strukturell, nicht optisch belegt.
+9
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { bilderVorladen } from "./screens/bilder"
import { App as CapacitorApp } from "@capacitor/app"
import { DataMetricApi, zugangLesen } from "./api"
@@ -79,6 +81,13 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
void startklarMelden()
}, [])
/* Fahrzeugfotos einmal vorab holen und dekodieren, sobald Daten da sind -
siehe bilderVorladen(). Damit erscheint beim Umschalten der Seiten sofort
das Bild statt kurz des Platzhalters. */
useEffect(() => {
if (daten.verbindung === "verbunden") bilderVorladen()
}, [daten.verbindung])
/* Belege aus dem Teilen-Blatt.
Die Erweiterung legt das PDF ab und oeffnet die App - beim ersten Start
+19
View File
@@ -271,6 +271,24 @@ export interface DongleAngabe {
stand: DongleStand | null;
}
/** Ein einzelner Wert aus der letzten Meldung des Dongles (geraet.py). */
export interface DatensatzWert {
name: string;
wert: string;
einheit: string | null;
}
/** Die letzte Meldung des Dongles, so wie sie in Home Assistant ankam.
"angekommen" ist die Ankunftszeit, "meldezeit" die Zeit des Geraets -
der Abstand zwischen beiden ist der Puffer-Rueckstand. */
export interface LetzterDatensatz {
geraet: string;
angekommen: string;
meldezeit: string | null;
werte: DatensatzWert[];
gesamt: number;
}
/** Nutzlast von sensor.audi_dashboard_app_version. */
export interface AppVersionAngabe {
app: string | null;
@@ -278,6 +296,7 @@ export interface AppVersionAngabe {
integration_update: IntegrationUpdateAngabe | null;
zugaenge?: ZugangAngabe[];
dongle?: DongleAngabe;
letzter_datensatz?: LetzterDatensatz | null;
}
/* -------------------------------------------------- Entitäts-Verzeichnis */
+5
View File
@@ -27,6 +27,7 @@ import {
type Fahrzeugstatus,
type IntegrationUpdateAngabe,
type DongleAngabe,
type LetzterDatensatz,
type ZugangAngabe,
type Profil,
type Tankvorgang,
@@ -75,6 +76,7 @@ export interface DatenWert {
/** Konfiguration des Dongles und eine ggf. vorgemerkte Aenderung
(flespi.py). null, solange kein flespi-Token hinterlegt ist. */
dongle: DongleAngabe | null
letzterDatensatz: LetzterDatensatz | null
einstellungen: Einstellungen | null
fahrzeug: Fahrzeug | null
@@ -123,6 +125,7 @@ export function DatenAnbieter({
const [otaBuendel, setzeOtaBuendel] = useState<Buendelangabe | null>(null)
const [zugaenge, setzeZugaenge] = useState<ZugangAngabe[]>([])
const [dongle, setzeDongle] = useState<DongleAngabe | null>(null)
const [letzterDatensatz, setzeLetzterDatensatz] = useState<LetzterDatensatz | null>(null)
const [integrationUpdate, setzeIntegrationUpdate] = useState<IntegrationUpdateAngabe | null>(
null,
)
@@ -168,6 +171,7 @@ export function DatenAnbieter({
setzeIntegrationUpdate(versionsangabe?.integration_update ?? null)
setzeZugaenge(versionsangabe?.zugaenge ?? [])
setzeDongle(versionsangabe?.dongle ?? null)
setzeLetzterDatensatz(versionsangabe?.letzter_datensatz ?? null)
return true
} catch (fehler) {
// Ein voruebergehender Ausfall ist kein Ladefehler, solange schon
@@ -301,6 +305,7 @@ export function DatenAnbieter({
integrationUpdate,
zugaenge,
dongle,
letzterDatensatz,
einstellungen,
fahrzeug,
fahrten,
+198 -119
View File
@@ -9,13 +9,13 @@ import { useEffect, useRef, useState } from "react"
import { Accordion, ActionButton, Feld, Seg, Sheet, Slider, Switch, Tile } from "@audi-dash/ui"
import { ApiFehler, DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
import type { Profil } from "../api"
import type { LetzterDatensatz, Profil } from "../api"
import { eigeneVersion } from "../daten/appVersion"
import { Datumsfeld } from "./Datumsfeld"
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, standAlter } from "../format"
import { datum, datumZeit, de, isoTag, standAlter } from "../format"
import { useTheme } from "../theme"
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder"
@@ -44,6 +44,7 @@ export function Einstellungen({
serverVersion,
zugaenge,
dongle,
letzterDatensatz,
verbindung,
api,
profilSpeichern,
@@ -250,6 +251,7 @@ export function Einstellungen({
// Geraet) und darf nicht wie ein toter Knopf aussehen. Das Ergebnis selbst
// kommt ueber die veroeffentlichte Entitaet zurueck (flespi.py), hier steht
// nur, was den Aufruf gar nicht erst erreicht hat.
const [debugOffen, setzeDebugOffen] = useState(false)
const [dongleLaeuft, setzeDongleLaeuft] = useState(false)
const [dongleFehler, setzeDongleFehler] = useState<string | null>(null)
@@ -409,66 +411,6 @@ export function Einstellungen({
</ActionButton>
</Tile>
{zugaenge.length > 0 && (
<Tile>
<span className="ads-eyebrow">Zugänge</span>
{/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
{zugaenge.map((z) => (
<Accordion
key={z.dienst}
title={z.titel}
summary={
<span className={z.gesetzt ? "dm-token-gesetzt" : "dm-token-offen"}>
{z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"}
</span>
}
>
<p className="dm-erklaerung">{z.zweck}</p>
<Feld label="Neuer Token" last>
<input
className="dm-eingabe"
type="password"
autoComplete="off"
placeholder={z.gesetzt ? "ersetzt den hinterlegten" : "noch keiner hinterlegt"}
value={tokenEntwurf[z.dienst] ?? ""}
onChange={(e) =>
setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value }))
}
/>
</Feld>
<div className="dm-knopfreihe">
<ActionButton
variant="primary"
disabled={tokenLaeuft !== null || !(tokenEntwurf[z.dienst] ?? "").trim()}
onClick={() => void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")}
>
{tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"}
</ActionButton>
{z.gesetzt && (
<ActionButton
disabled={tokenLaeuft !== null}
onClick={() => {
if (bestaetigen("Token löschen?")) {
void tokenSchreiben(z.dienst, "")
}
}}
>
Löschen
</ActionButton>
)}
</div>
</Accordion>
))}
{tokenFehler && <p className="dm-fehler">{tokenFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Ein gespeicherter Token wird nie wieder angezeigt nur ob einer hinterlegt ist.
Dieselben Felder gibt es unter Einstellungen Geräte &amp;
Dienste Audi Dashboard Konfigurieren.
</span>
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Fahrt beenden</span>
<Slider
@@ -482,24 +424,24 @@ export function Einstellungen({
/>
{/* Erscheint erst, wenn der Regler wirklich woanders steht - ein
dauerhaft sichtbarer, meist wirkungsloser Knopf waere Beiwerk. Die
Anordnung folgt der Speicherleiste am Seitenende: bestaetigende
Aktion als voller Knopf, der Ausweg daneben als schlichter Text. */}
Anordnung folgt Apples eigener Aufteilung fuer ein Wertepaar aus
Bestaetigen und Abbrechen: ZWEI gleich grosse Knoepfe, der
bestaetigende gefuellt und rechts (die fuehrende Hand erreicht ihn
zuerst), der Ausweg daneben als zweite Rolle. Vorher war "Verwerfen"
blosser Text und dadurch kaum als Bedienelement zu erkennen -
gemeldet vom Eigentuemer am 04.09.2026. */}
{pausenzeitGeaendert && (
<div className="dm-speicherleiste dm-speicherleiste--kachel">
<ActionButton onClick={() => setzePausenzeitEntwurf(null)} disabled={laeuft}>
Verwerfen
</ActionButton>
<ActionButton
variant="primary"
onClick={() => void speichern({ pausenzeitMin: pausenzeitEntwurf as number })}
disabled={laeuft}
>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
<button
type="button"
className="dm-textknopf"
onClick={() => setzePausenzeitEntwurf(null)}
disabled={laeuft}
>
Verwerfen
</button>
</div>
)}
{/* Fusszeile statt Info-Knopf: Apples eigenes Muster fuer erklaerenden
@@ -513,44 +455,6 @@ export function Einstellungen({
</span>
</Tile>
{dongle?.aktiv && (
<Tile>
<span className="ads-eyebrow">Dongle</span>
{dongle.stand?.fehler && <p className="dm-fehler">{dongle.stand.fehler}</p>}
{(dongle.stand?.werte ?? []).length > 0 && (
<Werteliste
kinder={(dongle.stand?.werte ?? []).map((w) => (
<Wertzeile
key={w.schluessel}
label={`${w.titel} ${w.nummer}`}
wert={`${w.geraet ?? ""}${w.einheit ? " " + w.einheit : ""}`}
warnung={Boolean(w.abweichung)}
zusatz={
w.offen != null
? `${w.offen}, unterwegs`
: w.abweichung
? `(wir: ${w.unser})`
: undefined
}
/>
))}
/>
)}
<ActionButton disabled={dongleLaeuft} onClick={() => void dongleAbrufen()}>
{dongleLaeuft ? "Liest …" : "Jetzt lesen"}
</ActionButton>
{dongleFehler && <p className="dm-fehler">{dongleFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
{dongle.stand?.gelesen_am
// Der Punkt der Abkürzung ist zugleich der Satzpunkt - sonst
// stünde dort "vor 1 Std..".
? `Gelesen ${standAlter(dongle.stand.gelesen_am).replace(/\.$/, "")}. `
: "Noch nicht gelesen. "}
Der Regler oben schreibt den Schlaf-Timeout sofort hierher; bis das Gerät sich
das nächste Mal meldet, steht die Änderung als unterwegs" daneben.
</span>
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Darstellung</span>
@@ -841,6 +745,10 @@ export function Einstellungen({
)}
{/* Installierte Fassung und ihr Update stehen in EINER Kachel: es ist
dieselbe Sache aus zwei Blickwinkeln, und zwei Kacheln nebeneinander
zwangen zum Vergleichen zweier Zahlen ueber eine Kachelgrenze hinweg
(Vorgabe des Eigentuemers, 04.09.2026). */}
<Tile>
<span className="ads-eyebrow">Version</span>
<Werteliste
@@ -852,10 +760,6 @@ export function Einstellungen({
/>
}
/>
</Tile>
<Tile>
<span className="ads-eyebrow">Integration-Update</span>
{/* HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt
lang, ohne verlässliche Fortschrittsangabe - ein laufender
Indikator statt eines nur deaktivierten Knopfes verhindert, dass
@@ -966,17 +870,192 @@ export function Einstellungen({
</ActionButton>
</Tile>
{/* Debug-Mode: Zugaenge, Dongle und der letzte Datensatz des Geraets.
Drei Kacheln, die im Alltag nichts zu suchen haben und beim Suchen
eines Fehlers alles - deshalb zusammen, zugeklappt und ganz unten
(Vorgabe des Eigentuemers, 04.09.2026). */}
<Tile>
<span className="ads-eyebrow">Debug-Mode</span>
<p className="dm-erklaerung">
Zugangs-Token, die Einstellungen des Dongles und der letzte Datensatz, den das Gerät
gemeldet hat. Für die Fehlersuche, nicht für den Alltag.
</p>
<ActionButton
variant={debugOffen ? "primary" : "default"}
onClick={() => setzeDebugOffen(!debugOffen)}
>
{debugOffen ? "Fertig" : "Öffnen"}
</ActionButton>
</Tile>
{debugOffen && (
<>
{zugaenge.length > 0 && (
<Tile>
<span className="ads-eyebrow">Zugänge</span>
{/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
{zugaenge.map((z) => (
<Accordion
key={z.dienst}
title={z.titel}
summary={
<span className={z.gesetzt ? "dm-token-gesetzt" : "dm-token-offen"}>
{z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"}
</span>
}
>
<p className="dm-erklaerung">{z.zweck}</p>
<Feld label="Neuer Token" last>
<input
className="dm-eingabe"
type="password"
autoComplete="off"
placeholder={z.gesetzt ? "ersetzt den hinterlegten" : "noch keiner hinterlegt"}
value={tokenEntwurf[z.dienst] ?? ""}
onChange={(e) =>
setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value }))
}
/>
</Feld>
<div className="dm-knopfreihe">
<ActionButton
variant="primary"
disabled={tokenLaeuft !== null || !(tokenEntwurf[z.dienst] ?? "").trim()}
onClick={() => void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")}
>
{tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"}
</ActionButton>
{z.gesetzt && (
<ActionButton
disabled={tokenLaeuft !== null}
onClick={() => {
if (bestaetigen("Token löschen?")) {
void tokenSchreiben(z.dienst, "")
}
}}
>
Löschen
</ActionButton>
)}
</div>
</Accordion>
))}
{tokenFehler && <p className="dm-fehler">{tokenFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Ein gespeicherter Token wird nie wieder angezeigt nur ob einer hinterlegt ist.
Dieselben Felder gibt es unter Einstellungen Geräte &amp;
Dienste Audi Dashboard Konfigurieren.
</span>
</Tile>
)}
{dongle?.aktiv && (
<Tile>
<span className="ads-eyebrow">Dongle</span>
{dongle.stand?.fehler && <p className="dm-fehler">{dongle.stand.fehler}</p>}
{(dongle.stand?.werte ?? []).length > 0 && (
<Werteliste
kinder={(dongle.stand?.werte ?? []).map((w) => (
<Wertzeile
key={w.schluessel}
label={`${w.titel} ${w.nummer}`}
wert={`${w.geraet ?? ""}${w.einheit ? " " + w.einheit : ""}`}
warnung={Boolean(w.abweichung)}
zusatz={
w.offen != null
? `${w.offen}, unterwegs`
: w.abweichung
? `(wir: ${w.unser})`
: undefined
}
/>
))}
/>
)}
<ActionButton disabled={dongleLaeuft} onClick={() => void dongleAbrufen()}>
{dongleLaeuft ? "Liest …" : "Jetzt lesen"}
</ActionButton>
{dongleFehler && <p className="dm-fehler">{dongleFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
{dongle.stand?.gelesen_am
// Der Punkt der Abkürzung ist zugleich der Satzpunkt - sonst
// stünde dort "vor 1 Std..".
? `Gelesen ${standAlter(dongle.stand.gelesen_am).replace(/\.$/, "")}. `
: "Noch nicht gelesen. "}
Der Regler oben schreibt den Schlaf-Timeout sofort hierher; bis das Gerät sich
das nächste Mal meldet, steht die Änderung als unterwegs" daneben.
</span>
</Tile>
)}
<LetzterDatensatzKachel datensatz={letzterDatensatz} />
</>
)}
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
{entwurf && (
<div className="dm-speicherleiste">
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Änderungen speichern"}
</ActionButton>
<button type="button" className="dm-textknopf" onClick={() => setzeEntwurf(null)}>
<ActionButton onClick={() => setzeEntwurf(null)} disabled={laeuft}>
Verwerfen
</button>
</ActionButton>
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere " : "Speichern"}
</ActionButton>
</div>
)}
</>
)
}
/* Der letzte Datensatz des Dongles. Bewusst eine eigene Komponente: die Liste
ist lang, hat mit den uebrigen Einstellungen nichts zu tun, und so bleibt
die Hauptkomponente lesbar. */
function LetzterDatensatzKachel({ datensatz }: { datensatz: LetzterDatensatz | null }) {
if (!datensatz) {
return (
<Tile>
<span className="ads-eyebrow">Letzter Datensatz</span>
<p className="dm-erklaerung">
Noch nichts gemeldet. Dafür braucht es die Meldezeit oder das Fahrtsignal im Setup —
und mindestens einen Datensatz des Geräts, seit Home Assistant läuft.
</p>
</Tile>
)
}
const rueckstand =
datensatz.meldezeit && datensatz.angekommen
? Math.round(
(Date.parse(datensatz.angekommen) - Date.parse(datensatz.meldezeit)) / 1000,
)
: null
return (
<Tile>
<span className="ads-eyebrow">Letzter Datensatz</span>
<Werteliste
kinder={
<>
<Wertzeile label="Gerät" wert={datensatz.geraet} />
<Wertzeile label="Angekommen" wert={datumZeit(datensatz.angekommen)} />
<Wertzeile
label="Gerätezeit"
wert={datensatz.meldezeit ? datumZeit(datensatz.meldezeit) : ""}
zusatz={rueckstand !== null ? `Rückstand ${de(rueckstand)} s` : undefined}
/>
{datensatz.werte.map((w) => (
<Wertzeile
key={w.name}
label={w.name}
wert={w.einheit ? `${w.wert} ${w.einheit}` : w.wert}
/>
))}
</>
}
/>
<p className="dm-erklaerung">
{de(datensatz.gesamt)} Werte aus derselben Meldung. Was das Gerät in diesem Datensatz
nicht geschickt hat, steht hier nicht es behält in Home Assistant seinen alten Wert.
Nach einem Neustart ist Angekommen der Zeitpunkt des Neustarts, nicht der einer
Übertragung der Rückstand sagt dann, wie lange das Gerät schon schweigt.
</p>
</Tile>
)
}
+43
View File
@@ -229,3 +229,46 @@ export async function bildVerkleinern(datei: File): Promise<Blob> {
}
return datei
}
/**
* Alle Fahrzeugfotos einmal im Hintergrund holen und dekodieren.
*
* WARUM
* -----
* Gemeldet als "Bilder laden beim Umschalten der Menues einige ms verzoegert".
* Innerhalb einer Sitzung liegt das nicht am Netz - die Adresse ist stabil und
* Home Assistant liefert `/local/` mit `max-age` von 31 Tagen, der Abruf kommt
* also aus dem Zwischenspeicher. Verzoegert wird das BILD selbst: ein frisch
* erzeugtes <img> wird standardmaessig asynchron dekodiert und erst danach
* gezeichnet, und beim Seitenwechsel entsteht jedes Mal ein neues.
*
* `decode()` erledigt genau diesen Schritt vorab. Danach zeichnet dasselbe
* Bild beim naechsten Einhaengen sofort.
*
* WANN
* ----
* In der Leerlaufzeit nach dem ersten Datenladen, nicht beim Start - sonst
* konkurriert es mit dem Abruf von Profil, Fahrten und Tankvorgaengen, den der
* Nutzer wirklich braucht. Das Datenvolumen ist dasselbe wie beim Durchblaettern
* aller Seiten, nur frueher und ohne Warten.
*/
export function bilderVorladen(): void {
if (typeof window === "undefined" || typeof Image === "undefined") return
const starten = () => {
const namen = [...BILDPLAETZE.map((b) => b.datei), SEITE_WINTER.datei]
for (const name of namen) {
const url = bildUrl(name)
if (!url) continue
const bild = new Image()
bild.src = url
// Ein fehlendes Foto ist der Normalfall (nicht jeder Platz ist belegt) -
// deshalb still schlucken statt zu melden.
void bild.decode?.().catch(() => undefined)
}
}
const leerlauf = (window as unknown as {
requestIdleCallback?: (r: () => void, o?: { timeout: number }) => number
}).requestIdleCallback
if (leerlauf) leerlauf(starten, { timeout: 4000 })
else window.setTimeout(starten, 2000)
}
+5 -2
View File
@@ -1590,8 +1590,11 @@ button.dm-listenzeile {
background: none;
border-top: 0;
}
.dm-speicherleiste--kachel > :first-child {
flex: 1 1 auto;
/* Beide Knoepfe gleich breit: ein Paar aus Bestaetigen und Abbrechen ist
eine Entscheidung zwischen zwei Wegen, keine Haupt- und Nebensache. Welcher
davon der gewollte ist, sagt die Fuellung, nicht die Groesse. */
.dm-speicherleiste > * {
flex: 1 1 0;
}
.dm-szene__zusatz {
@@ -1 +1 @@
{"version":"2026.9.4.8","sha256":"d6fe041f31480ff67d9b39884e9ef8b5ac5795d8575716d4638a9f557198bc49","bytes":319995,"gebaut":"2026-09-04T09:50:03Z"}
{"version":"2026.9.4.11","sha256":"937302e85e87c62326c4f7013cb026f50243b8cc5d521d752b5a3708120d2b91","bytes":320679,"gebaut":"2026-09-04T10:13:31Z"}
@@ -503,6 +503,33 @@ function bildInfo(i) {
}
return { label: b.label, datei: b.datei, src: `/local/bilder/${b.datei}?v=${bildVersion}` };
}
/* Alle Fahrzeugfotos einmal vorab holen UND dekodieren.
Gemeldet als "Bilder laden beim Umschalten der Menues verzoegert". Am Netz
liegt es nicht: die Adresse ist innerhalb einer Sitzung stabil und Home
Assistant liefert /local/ mit 31 Tagen Cache-Dauer. Verzoegert wird das Bild
selbst - ein frisch erzeugtes <img> wird standardmaessig asynchron dekodiert
und erst danach gezeichnet, und render() erzeugt bei jedem Seitenwechsel ein
neues. decode() erledigt genau diesen Schritt vorab.
Laeuft in der Leerlaufzeit, damit es nicht mit dem ersten Datenabruf
konkurriert, und nur einmal je Bildstand. */
let bilderVorgeladen = null;
function bilderVorladen() {
if (bilderVorgeladen === bildVersion) return;
bilderVorgeladen = bildVersion;
const starten = () => {
for (const datei of [...BILDER.map((b) => b.datei), SEITE_WINTER.datei]) {
const img = new Image();
img.src = `/local/bilder/${datei}?v=${bildVersion}`;
// Ein nicht hochgeladenes Foto ist der Normalfall - still schlucken.
if (img.decode) img.decode().catch(() => {});
}
};
if (window.requestIdleCallback) window.requestIdleCallback(starten, { timeout: 4000 });
else setTimeout(starten, 2000);
}
function carImg(modus) {
if (modus === "fest") {
const info = bildInfo(startIndex());
@@ -3242,28 +3269,6 @@ function vEinst() {
` : ""}
<button class="aktion ${einrichtenOffen ? "primaer" : ""}" style="margin-top:16px" data-einrichten-auf>${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
</div>
${ZUGAENGE.length ? `<div class="tile"><span class="label">Zugänge</span>
${ZUGAENGE.map((z, i) => `
<button class="acc-h" aria-expanded="false" data-acc="zug${i}"><span class="mark"></span>
<span class="k">${esc(z.titel)}</span>
<span class="v" style="color:${z.gesetzt ? "var(--ok)" : "var(--fg3)"}">${
z.gesetzt ? (z.rest ? "gesetzt · …" + esc(z.rest) : "gesetzt") : "nicht gesetzt"
}</span></button>
<div class="acc-b" id="zug${i}">
<span class="label" style="margin-top:8px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">${esc(z.zweck)}</span>
<div class="feld" style="border-bottom:0"><label for="tok${i}">Neuer Token</label>
<input id="tok${i}" type="password" autocomplete="off" data-tokenfeld="${esc(z.dienst)}"
placeholder="${z.gesetzt ? "ersetzt den hinterlegten" : "noch keiner hinterlegt"}"></div>
<button class="aktion primaer" data-tokenspeichern="${esc(z.dienst)}">Speichern</button>
${z.gesetzt ? `<button class="aktion loeschen" data-tokenloeschen="${esc(z.dienst)}">Löschen</button>` : ""}
</div>`).join("")}
<span class="label" style="margin-top:14px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Ein gespeicherter Token wird nie wieder angezeigt nur ob einer hinterlegt ist.
Dieselben Felder gibt es unter Einstellungen Geräte &amp; Dienste Audi Dashboard
Konfigurieren.</span>
</div>` : ""}
${(() => {
/* Der Regler schreibt NICHT mehr beim Loslassen. Sein Wert stellt zugleich
den Schlaf-Timeout des Dongles - eine Änderung am Fahrzeug, die über zwei
@@ -3280,8 +3285,10 @@ function vEinst() {
aria-label="Minuten bis zum automatischen Fahrtende">
<output>${minuten} Min.</output>
</div>
${geaendert ? `<button class="aktion primaer" style="margin-top:14px" data-pausenzeitspeichern>Speichern</button>
<button class="aktion" data-pausenzeitverwerfen>Verwerfen</button>` : ""}
${geaendert ? `<div class="knopfpaar">
<button class="aktion" data-pausenzeitverwerfen>Verwerfen</button>
<button class="aktion primaer" data-pausenzeitspeichern>Speichern</button>
</div>` : ""}
<span class="label" style="margin-top:14px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die
@@ -3291,36 +3298,6 @@ function vEinst() {
Fortsetzen aber ein paar Sekunden fürs Aufwachen.</span>
</div>`;
})()}
${DONGLE && DONGLE.aktiv ? `<div class="tile"><span class="label">Dongle</span>
${DONGLE.stand && DONGLE.stand.fehler
? `<span class="label" style="margin-top:8px;color:var(--bad);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">${esc(DONGLE.stand.fehler)}</span>`
: ""}
${DONGLE.stand && (DONGLE.stand.werte || []).length ? `<dl class="rows">
${DONGLE.stand.werte.map((w) => `<div class="row">
<dt>${esc(w.titel)} <small style="color:var(--fg3)">${esc(w.nummer)}</small></dt>
<dd${w.abweichung ? ` style="color:var(--warn)"` : ""}>${
w.geraet === null || w.geraet === undefined ? "" : esc(String(w.geraet))
}${w.einheit ? " " + esc(w.einheit) : ""}${
w.offen !== null && w.offen !== undefined
? ` <small>→ ${esc(String(w.offen))}, unterwegs</small>`
: w.abweichung ? ` <small>(wir: ${esc(String(w.unser))})</small>` : ""
}</dd>
</div>`).join("")}
</dl>` : ""}
<button class="aktion" data-dongle ${DONGLE_LAEUFT ? "disabled" : ""}>${
DONGLE_LAEUFT ? `<span class="lade-spinner"></span>Liest …` : "Jetzt lesen"
}</button>
<span class="label" style="margin-top:14px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
${DONGLE.stand && DONGLE.stand.gelesen_am
// Der Punkt der Abkuerzung ist zugleich der Satzpunkt - sonst stuende
// dort "vor 1 Std..". "vor 3 Tage" hat keinen und bekommt einen.
? `Gelesen ${esc(alterText(Date.parse(DONGLE.stand.gelesen_am)).replace(/\.$/, ""))}. `
: "Noch nicht gelesen. "}Der Regler oben schreibt den Schlaf-Timeout sofort
hierher; bis das Gerät sich das nächste Mal meldet, steht die Änderung als
unterwegs" daneben.</span>
</div>` : ""}
<div class="tile"><span class="label">Darstellung</span>
<div class="seg">
<button data-mode="nacht" class="${HOST.dataset.theme === "nacht" ? "on" : ""}">Nacht</button>
@@ -3404,12 +3381,14 @@ function vEinst() {
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
Backup importieren<input type="file" accept="application/json" data-backup="import" hidden></label>
</div>
<!-- Installierte Fassung und ihr Update stehen in EINER Kachel: es ist
dieselbe Sache aus zwei Blickwinkeln, und zwei Kacheln nebeneinander
zwangen zum Vergleichen zweier Zahlen ueber eine Kachelgrenze hinweg
(Vorgabe des Eigentuemers, 04.09.2026). -->
<div class="tile"><span class="label">Version</span>
<dl class="rows">
<div class="row" style="border-bottom:0"><dt>Installiert</dt><dd>${esc(APP_VERSION || "unbekannt")}<small>Home-Assistant-Integration</small></dd></div>
</dl>
</div>
<div class="tile"><span class="label">Integration-Update</span>
${(() => {
const u = INTEGRATION_UPDATE;
const laeuft = INTEGRATION_UPDATE_LAEUFT;
@@ -3460,11 +3439,112 @@ function vEinst() {
${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler && !INTEGRATION_UPDATE_LAEUFT
? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(INTEGRATION_UPDATE.fehler)}</span>`
: ""}
</div>`;
</div>
<!-- Debug-Mode: Zugaenge, Dongle und der letzte Datensatz des Geraets.
Drei Kacheln, die im Alltag nichts zu suchen haben und beim Suchen
eines Fehlers alles - deshalb zusammen, zugeklappt und ganz unten
(Vorgabe des Eigentuemers, 04.09.2026). -->
<div class="tile"><span class="label">Debug-Mode</span>
<span class="label" style="margin-top:8px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Zugangs-Token, die Einstellungen des Dongles und der letzte Datensatz, den
das Gerät gemeldet hat. Für die Fehlersuche, nicht für den Alltag.</span>
<button class="aktion ${debugOffen ? "primaer" : ""}" style="margin-top:16px" data-debug-auf>${debugOffen ? "Fertig" : "Öffnen"}</button>
</div>
${debugOffen ? `
${ZUGAENGE.length ? `<div class="tile"><span class="label">Zugänge</span>
${ZUGAENGE.map((z, i) => `
<button class="acc-h" aria-expanded="false" data-acc="zug${i}"><span class="mark"></span>
<span class="k">${esc(z.titel)}</span>
<span class="v" style="color:${z.gesetzt ? "var(--ok)" : "var(--fg3)"}">${
z.gesetzt ? (z.rest ? "gesetzt · …" + esc(z.rest) : "gesetzt") : "nicht gesetzt"
}</span></button>
<div class="acc-b" id="zug${i}">
<span class="label" style="margin-top:8px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">${esc(z.zweck)}</span>
<div class="feld" style="border-bottom:0"><label for="tok${i}">Neuer Token</label>
<input id="tok${i}" type="password" autocomplete="off" data-tokenfeld="${esc(z.dienst)}"
placeholder="${z.gesetzt ? "ersetzt den hinterlegten" : "noch keiner hinterlegt"}"></div>
<button class="aktion primaer" data-tokenspeichern="${esc(z.dienst)}">Speichern</button>
${z.gesetzt ? `<button class="aktion loeschen" data-tokenloeschen="${esc(z.dienst)}">Löschen</button>` : ""}
</div>`).join("")}
<span class="label" style="margin-top:14px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Ein gespeicherter Token wird nie wieder angezeigt nur ob einer hinterlegt ist.
Dieselben Felder gibt es unter Einstellungen Geräte &amp; Dienste Audi Dashboard
Konfigurieren.</span>
</div>` : ""}
${DONGLE && DONGLE.aktiv ? `<div class="tile"><span class="label">Dongle</span>
${DONGLE.stand && DONGLE.stand.fehler
? `<span class="label" style="margin-top:8px;color:var(--bad);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">${esc(DONGLE.stand.fehler)}</span>`
: ""}
${DONGLE.stand && (DONGLE.stand.werte || []).length ? `<dl class="rows">
${DONGLE.stand.werte.map((w) => `<div class="row">
<dt>${esc(w.titel)} <small style="color:var(--fg3)">${esc(w.nummer)}</small></dt>
<dd${w.abweichung ? ` style="color:var(--warn)"` : ""}>${
w.geraet === null || w.geraet === undefined ? "" : esc(String(w.geraet))
}${w.einheit ? " " + esc(w.einheit) : ""}${
w.offen !== null && w.offen !== undefined
? ` <small>→ ${esc(String(w.offen))}, unterwegs</small>`
: w.abweichung ? ` <small>(wir: ${esc(String(w.unser))})</small>` : ""
}</dd>
</div>`).join("")}
</dl>` : ""}
<button class="aktion" data-dongle ${DONGLE_LAEUFT ? "disabled" : ""}>${
DONGLE_LAEUFT ? `<span class="lade-spinner"></span>Liest …` : "Jetzt lesen"
}</button>
<span class="label" style="margin-top:14px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
${DONGLE.stand && DONGLE.stand.gelesen_am
// Der Punkt der Abkuerzung ist zugleich der Satzpunkt - sonst stuende
// dort "vor 1 Std..". "vor 3 Tage" hat keinen und bekommt einen.
? `Gelesen ${esc(alterText(Date.parse(DONGLE.stand.gelesen_am)).replace(/\.$/, ""))}. `
: "Noch nicht gelesen. "}Der Regler oben schreibt den Schlaf-Timeout sofort
hierher; bis das Gerät sich das nächste Mal meldet, steht die Änderung als
unterwegs" daneben.</span>
</div>` : ""}
${(() => {
/* Der letzte Datensatz, wie er in Home Assistant angekommen ist - siehe
geraet.py. Der Abstand zwischen Gerätezeit und Ankunft ist die Angabe,
die bei gepufferten Datensätzen zählt: genau dieser Rückstand war die
Wurzel des 3-km-Fahrt-Fehlers. */
const d = LETZTER_DATENSATZ;
if (!d) return `<div class="tile"><span class="label">Letzter Datensatz</span>
<span class="label" style="margin-top:8px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Noch nichts gemeldet. Dafür braucht es die Meldezeit oder das
Fahrtsignal im Setup - und mindestens einen Datensatz des Geräts,
seit Home Assistant läuft.</span></div>`;
const rueckstand = d.meldezeit && d.angekommen
? Math.round((Date.parse(d.angekommen) - Date.parse(d.meldezeit)) / 1000)
: null;
return `<div class="tile"><span class="label">Letzter Datensatz</span>
<dl class="rows">
<div class="row"><dt>Gerät</dt><dd>${esc(d.geraet || "unbekannt")}</dd></div>
<div class="row"><dt>Angekommen</dt><dd>${dezeitpunkt(new Date(d.angekommen))}</dd></div>
<div class="row"><dt>Gerätezeit</dt><dd>${d.meldezeit ? dezeitpunkt(new Date(d.meldezeit)) : ""}${
rueckstand !== null ? `<small>Rückstand ${de(rueckstand)} s</small>` : ""
}</dd></div>
${d.werte.map((w) => `<div class="row"><dt>${esc(w.name)}</dt><dd>${esc(String(w.wert))}${
w.einheit ? " " + esc(w.einheit) : ""
}</dd></div>`).join("")}
</dl>
<span class="label" style="margin-top:14px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
${de(d.gesamt)} Werte aus derselben Meldung. Was das Gerät in diesem
Datensatz nicht geschickt hat, steht hier nicht - es behält in Home
Assistant seinen alten Wert. Nach einem Neustart ist Angekommen" der
Zeitpunkt des Neustarts, nicht der einer Übertragung der Rückstand
sagt dann, wie lange das Gerät schon schweigt.</span>
</div>`;
})()}
` : ""}`;
}
let nmOffen = false;
let einrichtenOffen = false;
let debugOffen = false;
/* Entwurf des Reglers "Fahrt beenden" (Minuten) - null heisst: nicht angefasst.
Er liegt bewusst NEBEN CONFIG.pausenzeit und nicht darin: erst der Knopf
"Speichern" uebernimmt ihn, weil derselbe Wert den Schlaf-Timeout des
@@ -4377,6 +4457,9 @@ let ZUGAENGE = [];
/* Was am Dongle steht, was noch aussteht - siehe flespi.py im Backend.
null, solange kein flespi-Token hinterlegt ist. */
let DONGLE = null;
/* Der letzte Datensatz, den das Geraet gemeldet hat - siehe geraet.py im
Backend. null, solange weder Meldezeit noch Fahrtsignal zugeordnet sind. */
let LETZTER_DATENSATZ = null;
// true, waehrend "Jetzt lesen" laeuft. Wie INTEGRATION_UPDATE_LAEUFT und aus
// demselben Grund: der Abruf geht ueber zwei Netze (wir -> flespi -> Geraet)
// und darf nicht wie ein toter Knopf aussehen.
@@ -5977,6 +6060,7 @@ function ereignisseVerdrahten() {
}
if (e.target.closest("[data-nmauf]")) { nmOffen = !nmOffen; render(); return; }
if (e.target.closest("[data-einrichten-auf]")) { einrichtenOffen = !einrichtenOffen; render(); return; }
if (e.target.closest("[data-debug-auf]")) { debugOffen = !debugOffen; render(); return; }
if (e.target.closest("[data-nmspeichern]")) {
const wert = parseInt($("#nm").value, 10);
if (!isNaN(wert) && wert > 0) { CAR.reifen.anzugsmoment = wert; profilSpeichern(); }
@@ -6701,6 +6785,9 @@ async function datenLaden(erzwingen) {
DONGLE = versionState.attributes.daten
? versionState.attributes.daten.dongle ?? null
: null;
LETZTER_DATENSATZ = versionState.attributes.daten
? versionState.attributes.daten.letzter_datensatz ?? null
: null;
}
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
@@ -6743,6 +6830,9 @@ async function datenLaden(erzwingen) {
nachladeVersuche = 0;
render();
// Erst nach dem ersten Zeichnen: die Fotos sollen den Aufbau der Seite
// nicht ausbremsen, sondern den naechsten Seitenwechsel beschleunigen.
bilderVorladen();
return;
}
}
@@ -1657,3 +1657,17 @@ img.carfix.klein { aspect-ratio: auto; height: auto; }
scroll-behavior: auto !important;
}
}
/* Ein Paar aus Bestaetigen und Abbrechen steht nebeneinander, beide gleich
breit: es ist eine Entscheidung zwischen zwei Wegen, keine Haupt- und
Nebensache. Welcher der gewollte ist, sagt die Fuellung, nicht die Groesse -
und der bestaetigende steht rechts, wie ueberall in iOS. */
.knopfpaar {
display: flex;
gap: 10px;
margin-top: var(--sp-4);
}
.knopfpaar .aktion {
flex: 1 1 0;
margin-top: 0;
}
+140
View File
@@ -0,0 +1,140 @@
"""Der letzte Datensatz des Dongles, so wie er in Home Assistant angekommen ist.
WOFUER
------
Fuer die Fehlersuche am Fahrzeug, nicht fuer den taeglichen Gebrauch: Steht das
Fahrzeug wirklich, oder ist nur die Meldung alt? Liefert das Geraet gerade
ueberhaupt CAN-Werte? Die Antwort steht in Home Assistants Zustaenden, aber
verteilt auf siebzig Entitaeten - hier zusammengefasst an einer Stelle, die
beide Oberflaechen ohne eigene Registerabfrage lesen koennen.
WAS "EIN DATENSATZ" IST
-----------------------
Die flespi-Integration setzt die Entitaeten EINES Geraetedatensatzes
nacheinander, aber innerhalb weniger Millisekunden (am 01.09.2026 gemessen:
Zuendung 07:35:23.633, zugehoerige Meldezeit 07:35:23.635 - siehe Abschnitt AY
der AGENTS.md). Der letzte Datensatz ist deshalb: alle Entitaeten des Geraets,
deren letzte Aktualisierung hoechstens FENSTER_S nach der juengsten liegt.
Das ist bewusst eine Naeherung ueber die ANKUNFTSzeit. Die Zeit des Geraets
steht daneben (aus MELDEZEIT_SENSOR), und genau der Abstand zwischen beiden ist
die Angabe, die bei gepufferten Datensaetzen interessiert - er war die Wurzel
des 3-km-Fahrt-Fehlers (Abschnitt CB).
WELCHES GERAET
--------------
Nicht geraten: genommen wird das Geraet der Entitaet, die MELDEZEIT_SENSOR
zugeordnet ist, sonst das des Fahrtsignals. Ist keines von beiden zugeordnet,
gibt es nichts zu zeigen - dann bleibt der Rueckgabewert None, statt
irgendein Geraet der Instanz zu waehlen.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from . import verlauf
if TYPE_CHECKING:
from .einstellungen import Sensorzuordnung
_LOGGER = logging.getLogger(__name__)
# Wie weit ein Wert hinter dem juengsten liegen darf, um noch zum selben
# Datensatz gezaehlt zu werden. Gemessen wurden Millisekunden; zwei Sekunden
# sind grosszuegig genug fuer eine langsame Instanz und immer noch weit von der
# kuerzesten Sendeperiode des Geraets entfernt (10 s beim Fahren).
FENSTER_S = 2.0
# Ein Datensatz des FMM003 traegt je nach Konfiguration einige Dutzend Werte.
# Die Obergrenze ist kein fachliches Limit, sondern verhindert, dass eine
# fehlerhafte Zuordnung die veroeffentlichten Attribute aufblaeht.
MAX_WERTE = 80
def _meldezeit(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
"""Die Zeit, die das Geraet dem letzten Datensatz gegeben hat."""
if not werte.MELDEZEIT_SENSOR:
return None
zustand = hass.states.get(werte.MELDEZEIT_SENSOR)
if zustand is None:
return None
try:
gemeldet = datetime.fromtimestamp(float(zustand.state), tz=timezone.utc)
except (TypeError, ValueError, OSError, OverflowError):
return None
# Dieselbe Pruefung wie in der Fahrterkennung: eine Sensor-ID statt eines
# Zeitstempels ergibt sonst einen Zeitpunkt kurz nach 1970 (Abschnitt BO).
if not verlauf.geraetezeit_plausibel(gemeldet, datetime.now(timezone.utc)):
return None
return gemeldet.isoformat()
def _geraet_id(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
register = er.async_get(hass)
for eid in (werte.MELDEZEIT_SENSOR, verlauf.fahrtsignal(werte), werte.ZUENDUNG_SENSOR):
if not eid:
continue
eintrag = register.async_get(eid)
if eintrag and eintrag.device_id:
return eintrag.device_id
return None
def letzter_datensatz(hass: HomeAssistant, werte: Sensorzuordnung) -> dict[str, Any] | None:
"""Die Werte der juengsten Meldung des Dongles - None, wenn keine da ist."""
geraet_id = _geraet_id(hass, werte)
if not geraet_id:
return None
register = er.async_get(hass)
eintraege = er.async_entries_for_device(register, geraet_id, include_disabled_entities=False)
zustaende = []
for eintrag in eintraege:
zustand = hass.states.get(eintrag.entity_id)
# unavailable/unknown tragen keinen Wert - sie stehen fuer eine
# Entitaet, die das Geraet in diesem Datensatz nicht gemeldet hat.
if zustand is None or zustand.state in ("unavailable", "unknown"):
continue
zustaende.append(zustand)
if not zustaende:
return None
juengste = max(z.last_updated for z in zustaende)
grenze = juengste - timedelta(seconds=FENSTER_S)
im_datensatz = sorted(
(z for z in zustaende if z.last_updated >= grenze),
key=lambda z: z.attributes.get("friendly_name") or z.entity_id,
)
geraete_register = dr.async_get(hass)
geraet = geraete_register.async_get(geraet_id)
name = (geraet.name if geraet else None) or geraet_id
def kurz(voll: str) -> str:
"""Der Geraetename steht schon in der Kopfzeile - hier nur das Signal."""
rest = voll[len(name):].strip() if voll.startswith(name) else voll
return rest or voll
return {
"geraet": name,
"angekommen": juengste.isoformat(),
# Die Zeit, die das GERAET dem Datensatz gegeben hat. Der Abstand zur
# Ankunft ist der Puffer-Rueckstand.
"meldezeit": _meldezeit(hass, werte),
"werte": [
{
"name": kurz(z.attributes.get("friendly_name") or z.entity_id),
"wert": z.state,
"einheit": z.attributes.get("unit_of_measurement"),
}
for z in im_datensatz[:MAX_WERTE]
],
"gesamt": len(im_datensatz),
}
@@ -51,6 +51,7 @@ from . import (
batterie,
fahrterkennung,
flespi,
geraet,
identitaet,
reifen,
screening,
@@ -253,6 +254,12 @@ class Koordinator:
await self.profil_veroeffentlichen()
await self.fahrten_veroeffentlichen()
await self.tankvorgaenge_veroeffentlichen()
# Muss mitlaufen, seit dort der letzte Datensatz des Dongles steht:
# beim Start haben dessen Entitaeten noch gar keinen Zustand, die
# Angabe waere sonst dauerhaft leer. Kostet einen kleinen Dateilesevorgang
# (bundle.json) je Minute und KEINEN Netzaufruf - die Pruefung bei Gitea
# bleibt an ihrem Dienst, siehe Abschnitt J.
await self.app_version_veroeffentlichen()
async def _takt_batterie(self, _jetzt: datetime.datetime) -> None:
await batterie.pruefen(self)
@@ -636,6 +643,12 @@ class Koordinator:
"aktiv": flespi.zugang(self) is not None,
"stand": self.flespi_stand,
},
# Der letzte Datensatz des Dongles, wie er hier angekommen
# ist - fuer die Fehlersuche, siehe geraet.py. None, solange
# weder Meldezeit noch Fahrtsignal zugeordnet sind.
"letzter_datensatz": geraet.letzter_datensatz(
self.hass, self.zuordnung.werte
),
},
)
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.4.8",
"version": "2026.9.4.11",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": [