Files
audi-app/companion-app/src/screens/Einstellungen.tsx
T
tobiasandClaude Opus 5 81808dbc10 Update ohne Neustart, wenn nur Oberflaechendateien wechseln
Bisher verlangte jedes Update einen Home-Assistant-Neustart. Noetig ist er
aber nur fuer Python-Code: HA importiert die Module einmal beim Start und
haengt danach mit lebenden Objekten daran. frontend/ dagegen wird direkt von
der Platte ausgeliefert (StaticPathConfig, cache_headers=False) - eine
ersetzte .js ist sofort wirksam, es braucht nur ein Neuladen im Browser.

aktualisierung.neustart_noetig() vergleicht die alte gegen die neue Fassung
ueber sha256 je Datei und meldet "kein Neustart" nur, wenn JEDE Abweichung
unter frontend/ liegt oder die manifest.json ist. Alles andere - .py,
services.yaml, translations/, vorlage/ - gilt als neustartpflichtig, auch wo
es das im Einzelfall nicht waere. Die Schieflage ist Absicht: ein
faelschlich ausgelassener Neustart laesst neuen Python-Code nie anlaufen, und
der Fehler wird woanders gesucht.

manifest.json ist ausgenommen, weil sich seine Versionsnummer bei jeder
Veroeffentlichung aendert - sonst waere die Unterscheidung wertlos. Weil HA
das Manifest fuer die Laufzeit festhaelt (loader.py: hass.data[
DATA_INTEGRATIONS]), liest version_von_platte() die Nummer direkt von der
Platte; koordinator.version_neu_lesen() zieht sie nach einem Update ohne
Neustart nach, damit Panel und App die neue Fassung auch anzeigen.

Panel und App zeigen im Neustart-freien Fall "Seite neu laden" statt
"Installation abschliessen - Jetzt neu starten".

Neun neue Testfaelle fuer die Unterscheidung, 23 Tests in der
Aktualisierungs-Suite gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 13:30:25 +02:00

1128 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Einstellungen. Vorlage: `vEinst()` im alten Panel — die umfangreichste
* Seite: Fahrzeugdaten, Darstellung, Fotos, SmartDeal, Pausenzeit, Sicherung,
* Datenausgabe und Zugang.
*/
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 { 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, de, isoTag, standAlter } from "../format"
import { useTheme } from "../theme"
import { BildMitMenue, Markenlogo, Werteliste, Wertzeile } from "./bausteine"
import { bestaetigen } from "./bestaetigung"
import { BILDPLAETZE, SEITE_WINTER } from "./bilder"
import { DatensatzPopup } from "./DatensatzPopup"
import { HistorienImport } from "./HistorienImport"
import type { ServicebuchEintrag } from "./Service"
import { zugangLesenSynchron, zugangVergessen } from "./zugang"
/* Wann das Gerät einen Wert zuletzt bestätigt hat — oder dass es das nie
getan hat. Steht dort, wo vorher nichts stand, wenn kein Urteil möglich
ist: ein stiller Gleichstand mit einem veralteten Wert ist schlimmer als
gar kein Vergleich (04.09.2026). Wortgleich mit dongleStand() im Panel. */
function dongleStand(w: { gemeldet_am?: string | null }): string {
if (!w.gemeldet_am) return "nie bestätigt"
const d = new Date(w.gemeldet_am)
if (Number.isNaN(d.getTime())) return "nie bestätigt"
const tage = Math.floor((Date.now() - d.getTime()) / 86400000)
const wann = datum(d)
return tage >= 1 ? `bestätigt ${wann} (${tage} T. alt)` : `bestätigt ${wann}`
}
export function Einstellungen({
tabBeschriftung,
setzeTabBeschriftung,
beiAbmeldung,
}: {
tabBeschriftung: boolean
setzeTabBeschriftung: (an: boolean) => void
beiAbmeldung: () => void
}) {
const {
einstellungen,
fahrzeug,
fahrten,
tankvorgaenge,
rohprofil,
otaBuendel,
integrationUpdate,
serverVersion,
zugaenge,
dongle,
letzterDatensatz,
verbindung,
api,
profilSpeichern,
jetztAktualisieren,
neuLaden,
} = useDaten()
const [theme, themeSetzen] = useTheme()
// "Fahrzeug einrichten" ist im Panel zugeklappt und oeffnet sich ueber den
// Knopf darunter (einrichtenOffen in vEinst()).
const [einrichtenOffen, setzeEinrichtenOffen] = useState(false)
// Erstzulassung liegt am Fahrzeug, nicht an den Einstellungen - deshalb ein
// eigener Entwurf, der beim Speichern mitgeschrieben wird.
const [erstzulassung, setzeErstzulassung] = useState<string | null>(null)
const [entwurf, setzeEntwurf] = useState<EinstellungenWerte | null>(null)
/* Der Regler "Fahrt beenden" fuehrt einen EIGENEN Entwurf, nicht den der
Seite. Grund: sein Wert stellt zugleich den Schlaf-Timeout des Dongles -
eine Aenderung am Fahrzeug, die ueber zwei Netze geht. Sie soll bewusst
ausgeloest werden, nicht als Nebenwirkung des Loslassens (Vorgabe des
Eigentuemers, 04.09.2026). `null` heisst: nichts angefasst. */
const [pausenzeitEntwurf, setzePausenzeitEntwurf] = useState<number | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const [importOffen, setzeImportOffen] = useState(false)
/* Abfrage "bis wann gilt der Rabatt", die das Einschalten des SmartDeals
ausloest (sdpopup im Panel). */
const [sdAbfrage, setzeSdAbfrage] = useState(false)
const [sdEntwurf, setzeSdEntwurf] = useState("")
const [otaLaeuft, setzeOtaLaeuft] = useState(false)
const [otaFehler, setzeOtaFehler] = useState<string | null>(null)
const [updatePruefenLaeuft, setzeUpdatePruefenLaeuft] = useState(false)
const [updateInstallierenLaeuft, setzeUpdateInstallierenLaeuft] = useState(false)
const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false)
const [updateAktionFehler, setzeUpdateAktionFehler] = useState<string | null>(null)
const [datensatzPopup, setzeDatensatzPopup] = useState<"sichern" | "laden" | null>(null)
const [backupImportLaeuft, setzeBackupImportLaeuft] = useState(false)
const [backupImportFehler, setzeBackupImportFehler] = useState<string | null>(null)
const backupDateiwahl = useRef<HTMLInputElement | null>(null)
const otaUpdateVerfuegbar =
otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion(), serverVersion)
const otaAusloesen = async () => {
if (!otaBuendel) return
setzeOtaLaeuft(true)
setzeOtaFehler(null)
try {
const zugang = await zugangLesen()
if (!zugang) throw new OtaFehler("Kein Zugang eingerichtet.")
// Kehrt im Erfolgsfall nicht zurück - set() lädt die App neu, siehe
// daten/ota.ts. Der catch-Block unten fängt sowohl echte Fehlschläge
// als auch den defensiven letzten throw in buendelAnwenden() ab.
await buendelAnwenden(otaBuendel, zugang.basisUrl)
} catch (fehler) {
setzeOtaFehler(
fehler instanceof Error ? fehler.message : "Das Update konnte nicht installiert werden.",
)
setzeOtaLaeuft(false)
}
}
// Selbst-Update der Integration (aktualisierung.py) - Ersatz für
// install.ps1, das Windows Smart App Control blockiert. Beide Aktionen
// laufen sofort (keine Warteschlange, siehe api/index.ts) und laden
// danach über neuLaden() den neuen Stand von
// sensor.audi_dashboard_app_version - der Fehlertext bei fehlendem Token
// kommt von dort mit, eine eigene "ist ein Token eingetragen?"-Prüfung
// gibt es bewusst nicht. updateAktionFehler fängt zusätzlich echte
// Verbindungsfehler ab (dienste.py behandelt AktualisierungsFehler selbst
// und liefert 200 zurück - hier landet nur, was gar nicht erst bei Home
// Assistant ankam), sonst bliebe ein solcher Fehlschlag ganz ohne
// Rückmeldung.
const updatePruefenAusloesen = async () => {
setzeUpdatePruefenLaeuft(true)
setzeUpdateAktionFehler(null)
try {
await api.updatePruefen()
await neuLaden()
} catch (fehler) {
setzeUpdateAktionFehler(
fehler instanceof Error ? fehler.message : "Die Prüfung konnte nicht ausgeführt werden.",
)
} finally {
setzeUpdatePruefenLaeuft(false)
}
}
const updateInstallierenAusloesen = async () => {
setzeUpdateInstallierenLaeuft(true)
setzeUpdateAktionFehler(null)
try {
await api.updateInstallieren()
await neuLaden()
} catch (fehler) {
setzeUpdateAktionFehler(
fehler instanceof Error ? fehler.message : "Das Update konnte nicht installiert werden.",
)
} finally {
setzeUpdateInstallierenLaeuft(false)
}
}
// Nach einer erfolgreichen Installation zeigt die Kachel direkt den
// nächsten nötigen Schritt statt den Nutzer selbst suchen zu lassen - ein
// reiner Reload reicht nicht, siehe AGENTS.md Abschnitt J. Kein try/finally
// um setzeUpdateNeustartLaeuft: bricht die Verbindung normalerweise durch
// den Neustart selbst ab, bevor der Aufruf überhaupt zurückkehrt.
const updateNeustartAusloesen = async () => {
setzeUpdateNeustartLaeuft(true)
setzeUpdateAktionFehler(null)
try {
await api.homeAssistantNeuStarten()
} catch (fehler) {
// Waehrend Home Assistant neu startet, ist ein Fehlschlag der Normalfall
// und kein Fehler: der Vorschaltserver antwortet mit 502/503/504, oder
// die Verbindung bricht ganz ab. Beides als Fehlerzeile zu zeigen war
// irrefuehrend - der Knopf sprang zurueck auf "Jetzt neu starten" und
// darunter stand "502", waehrend Home Assistant ordnungsgemaess hochfuhr.
// Der Nutzer musste annehmen, der Neustart sei gescheitert, und drueckte
// erneut (gemeldet vom Eigentuemer am 2026-09-01).
//
// Der Bildschirm bleibt deshalb auf "Home Assistant startet neu ...";
// der Effekt darunter raeumt ihn ab, sobald die Verbindung wieder steht.
// Dieselbe Einstufung wie im DatenKontext, damit Anzeige und
// Wiederverbindung denselben Fall gleich bewerten.
if (fehler instanceof ApiFehler && fehler.istVoruebergehend) return
setzeUpdateAktionFehler(
fehler instanceof Error
? fehler.message
: "Home Assistant konnte nicht neu gestartet werden.",
)
setzeUpdateNeustartLaeuft(false)
}
}
// Ergänzt das absichtliche Fehlen des try/finally oben: die Verbindung
// trennt sich für die Dauer des HA-Neustarts, DatenKontext meldet das über
// `verbindung`/`api.live.aufVerbindung()` und lädt bei "verbunden" bereits
// integrationUpdate & Co. neu (siehe DatenKontext.tsx) - aber
// updateNeustartLaeuft ist reiner Bildschirm-Zustand hier und blieb davon
// unberührt. Ohne diesen Effekt stand "Home Assistant startet neu …" nach
// einem erfolgreichen Neustart weiter da, bis die Seite von Hand neu
// geladen wurde.
useEffect(() => {
if (verbindung === "verbunden" && updateNeustartLaeuft) setzeUpdateNeustartLaeuft(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [verbindung])
if (!einstellungen || !fahrzeug) return null
const werte = entwurf ?? einstellungen
const pausenzeit = pausenzeitEntwurf ?? werte.pausenzeitMin
const pausenzeitGeaendert = pausenzeitEntwurf !== null && pausenzeitEntwurf !== werte.pausenzeitMin
const erstzulassungWert = erstzulassung ?? fahrzeug.erstzulassung
// Bei Winterraedern zeigt die Seitenansicht das Winterfoto - wie bildInfo()
// im Panel, das hier dieselbe Vorschau liefert.
const gewaehltesBild =
/* Verglichen wird der NAME der Ansicht, nicht der Dateiname.
Das Panel speichert in `einstellungen.uebersichtsbild` seit jeher den
Namen ("Seitenansicht"), die App verglich hier gegen den Dateinamen
("seitenansicht.webp") - der Vergleich konnte also nie zutreffen, die
Auswahl fiel immer auf den ersten Eintrag zurueck, und was die App
schrieb, erkannte das Panel seinerseits nicht wieder. Nachgesehen im
echten Profil der Testinstanz (2026-08-31): dort steht
'Seitenansicht'. Kanonisch ist der Name - die aeltere, bereits
gespeicherte Form, dieselbe Entscheidung wie beim Wartungsplan. Die
Namen beider Kataloge stimmen ueberein. */
werte.startbild === "Seitenansicht" && fahrzeug.reifen.aktiv === "Winter"
? { datei: SEITE_WINTER.datei, beschreibung: SEITE_WINTER.beschreibung }
: (BILDPLAETZE.find((b) => b.name === werte.startbild) ?? BILDPLAETZE[0]!)
const aendern = (teil: Partial<EinstellungenWerte>) =>
setzeEntwurf({ ...werte, ...teil })
/* `zusatz` kommt vom Regler der Kachel "Fahrt beenden": der fuehrt einen
eigenen Entwurf und uebergibt ihn erst beim Druck auf Speichern. Ohne den
Umweg wuerde `entwurf` im selben Rendern noch den alten Wert tragen -
setState wirkt erst beim naechsten Durchlauf. */
/* Es gibt keine Speicherleiste mehr. Jedes Feld wirkt beim Verlassen,
Auswahlfelder sofort - wie im Panel, wo der `data-einrichten`-Handler
seit jeher bei jeder Aenderung ins Profil schreibt.
Ansage des Eigentuemers am 05.09.2026: "speichern: ersatzlos streichen".
Drei Gruende sprachen dafuer:
* iOS kennt keine dauerhafte Speichern/Verwerfen-Leiste auf einer
geschobenen Einstellungsseite. Apples Muster sind "wirkt sofort"
(Einstellungen-App) oder ein modales Blatt mit Abbrechen/Fertig in
der Navigationsleiste.
* Die Leiste schwebte ueber der Tab-Leiste und deckte dabei einen Knopf
der Kachel dahinter zu (gemessen 05.09.2026).
* Sie war die Ursache eines echten Datenverlusts: wer nur die
Erstzulassung aenderte, bekam gar keinen Speichern-Knopf zu sehen.
Eine Leiste, die eine Eingabe erst gueltig macht, kann sie auch
verschlucken.
`speichern()` bleibt fuer den Regler "Fahrt beenden": ein Schieberegler
bei jedem Zwischenschritt zu sichern hiesse ein Dutzend Schreibvorgaenge
je Bedienung, deshalb behaelt er seinen eigenen Bestaetigungsschritt. */
const speichern = async (zusatz?: Partial<EinstellungenWerte>) => {
if (!entwurf && !zusatz && erstzulassung === null) return
setzeLaeuft(true)
try {
await profilSpeichern({
einstellungen: { ...werte, ...(zusatz ?? {}) },
...(erstzulassung !== null ? { fahrzeug: { ...fahrzeug, erstzulassung } } : {}),
})
setzeEntwurf(null)
setzeErstzulassung(null)
setzePausenzeitEntwurf(null)
} finally {
setzeLaeuft(false)
}
}
/** Uebernimmt den aktuellen Stand des Bildschirms - beim Verlassen eines
Feldes oder sofort bei einem Auswahlfeld. Ohne Zusatz und ohne Knopf. */
const sichern = (zusatz?: Partial<EinstellungenWerte>) => void speichern(zusatz)
/* Zugaenge: je Dienst ein Entwurf im Eingabefeld. Der gespeicherte Token
kommt NIE zurueck (siehe zugaenge.py), das Feld startet deshalb immer leer
und zeigt als Platzhalter, ob schon einer hinterlegt ist. */
const [tokenEntwurf, setzeTokenEntwurf] = useState<Record<string, string>>({})
const [tokenLaeuft, setzeTokenLaeuft] = useState<string | null>(null)
const [tokenFehler, setzeTokenFehler] = useState<string | null>(null)
// Wie tokenLaeuft daneben: der Abruf geht ueber zwei Netze (wir -> flespi ->
// 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)
const dongleAbrufen = async () => {
setzeDongleLaeuft(true)
setzeDongleFehler(null)
try {
await api.dongleLesen()
await neuLaden()
} catch (fehler) {
setzeDongleFehler(
fehler instanceof Error ? fehler.message : "Der Abruf ist gescheitert.",
)
} finally {
setzeDongleLaeuft(false)
}
}
const tokenSchreiben = async (dienst: string, wert: string) => {
setzeTokenLaeuft(dienst)
setzeTokenFehler(null)
try {
await api.zugangSetzen(dienst, wert)
setzeTokenEntwurf((alt) => ({ ...alt, [dienst]: "" }))
await neuLaden()
} catch (fehler) {
setzeTokenFehler(
fehler instanceof Error ? fehler.message : "Der Token konnte nicht gespeichert werden.",
)
} finally {
setzeTokenLaeuft(null)
}
}
const abmelden = async () => {
if (
!(await bestaetigen("Zugang von diesem Gerät entfernen?", {
text: "Adresse und Token werden von diesem Gerät gelöscht. Die Daten auf dem Server bleiben unberührt.",
knopf: "Trennen",
}))
)
return
await zugangVerwerfen()
zugangVergessen()
beiAbmeldung()
}
// Anders als profilSpeichern() (Kontext) - das nur einstellungen/fahrzeug
// teilweise zusammenführt - schreibt eine geladene Datei das GANZE Profil,
// deckungsgleich mit profil_schreiben im Panel.
const profilGeladen = async (profil: Profil) => {
await api.profilSchreiben(profil)
await neuLaden()
}
// Gegenstück zu profilExportieren() - dieselbe Paketform ({profil, fahrten,
// tankvorgaenge}) über den bestehenden backup_wiederherstellen-Dienst,
// deckungsgleich mit dem data-backup="import"-Handler im Panel. Fehlte hier
// bisher komplett - "Auf dieses Gerät laden" hatte kein Gegenstück, ein
// exportiertes Backup ließ sich nie wieder einspielen.
const backupImportieren = async (datei: File) => {
setzeBackupImportFehler(null)
setzeBackupImportLaeuft(true)
try {
const text = await datei.text()
const paket = JSON.parse(text) as {
profil?: unknown
fahrten?: unknown
tankvorgaenge?: unknown
entitaeten?: unknown
}
if (!paket.profil) throw new Error("Keine gültige Backup-Datei (Profil fehlt).")
await api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_wiederherstellen", {
profil_json: JSON.stringify(paket.profil),
fahrten_json: JSON.stringify(paket.fahrten ?? []),
tankvorgaenge_json: JSON.stringify(paket.tankvorgaenge ?? []),
...(paket.entitaeten ? { entitaeten_json: JSON.stringify(paket.entitaeten) } : {}),
})
await neuLaden()
} catch (ursache) {
setzeBackupImportFehler(
ursache instanceof Error ? ursache.message : "Die Datei ließ sich nicht lesen.",
)
} finally {
setzeBackupImportLaeuft(false)
}
}
const profilExportieren = () => {
const inhalt = JSON.stringify(
{ profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() },
null,
2,
)
const blob = new Blob([inhalt], { type: "application/json" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = `datametric360-sicherung-${isoTag()}.json`
verweis.click()
URL.revokeObjectURL(url)
}
return (
<>
{/* Aufbau, Reihenfolge und Wortlaut wie vEinst(): „Fahrzeug einrichten"
(aufklappbar), Darstellung, Bild der Übersicht, Kraftstoff-Rabatt,
Fahrten zusammenfassen, Fahrzeugprofil, Backup, Version,
Integration-Update. Vorher standen die Abschnitte in anderer
Reihenfolge und unter eigenen Namen („Tankrabatt", „Sicherung",
„Vergangene Daten", „Bild auf der Übersicht"). */}
<Tile>
<span className="ads-eyebrow">Fahrzeug einrichten</span>
{einrichtenOffen && (
<>
<Feld label="Modell">
<input
className="dm-eingabe dm-eingabe--fahrzeug"
value={werte.fahrzeugtitel}
onChange={(e) => aendern({ fahrzeugtitel: e.target.value })}
onBlur={() => sichern()}
/>
</Feld>
<Feld label="Kennzeichen">
<input
className="dm-eingabe dm-eingabe--fahrzeug"
value={werte.kennzeichen}
onChange={(e) => aendern({ kennzeichen: e.target.value })}
onBlur={() => sichern()}
/>
</Feld>
<Feld label="Erstzulassung">
<input
className="dm-eingabe dm-eingabe--fahrzeug"
placeholder="TT.MM.JJJJ"
value={erstzulassungWert}
onChange={(e) => setzeErstzulassung(e.target.value)}
onBlur={() => sichern()}
/>
</Feld>
<Feld label="Ausführung" last>
<input
className="dm-eingabe dm-eingabe--fahrzeug"
value={werte.ausfuehrung}
onChange={(e) => aendern({ ausfuehrung: e.target.value })}
onBlur={() => sichern()}
/>
</Feld>
{/* „Setup — Sensoren zuordnen" bleibt dem Panel vorbehalten: die
Zuordnung von Home-Assistant-Entitäten setzt eine angemeldete
HA-Sitzung voraus, die diese App nicht hat. */}
<ActionButton onClick={() => setzeImportOffen(true)}>
Daten importieren aus Home Assistant
</ActionButton>
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
nachträglich an aus dem Verlauf, den Home Assistant zu den zugeordneten Sensoren
bereits aufgezeichnet hat.
</span>
</>
)}
<ActionButton
variant={einrichtenOffen ? "primary" : "default"}
onClick={() => setzeEinrichtenOffen((offen) => !offen)}
>
{einrichtenOffen ? "Fertig" : "Einrichten"}
</ActionButton>
</Tile>
<Tile>
<span className="ads-eyebrow">Fahrt beenden</span>
<Slider
min={1}
max={60}
schritt={1}
wert={pausenzeit}
onChange={setzePausenzeitEntwurf}
anzeige={pausenzeit + " Min."}
aria-label="Minuten bis zum automatischen Fahrtende"
/>
{/* Erscheint erst, wenn der Regler wirklich woanders steht - ein
dauerhaft sichtbarer, meist wirkungsloser Knopf waere Beiwerk. Die
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>
</div>
)}
{/* Fusszeile statt Info-Knopf: Apples eigenes Muster fuer erklaerenden
Text unter einer Einstellung ist der Footer der Gruppe. */}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die
Fahrt automatisch beendet werden soll. Derselbe Wert stellt den Schlaf-Timeout
des Dongles: so lange bleibt er nach dem Abstellen wach und wartet darauf, dass
es gleich weitergeht. Danach schläft er das spart Strom, kostet beim Fortsetzen
aber ein paar Sekunden fürs Aufwachen.
</span>
</Tile>
<Tile>
<span className="ads-eyebrow">Darstellung</span>
<Seg
options={[
{ value: "nacht", label: "Nacht" },
{ value: "tag", label: "Tag" },
]}
value={theme}
onChange={(neu) => themeSetzen(neu as "nacht" | "tag")}
/>
<Feld label="Beschriftung in der Menüleiste" last>
<Switch
checked={tabBeschriftung}
onChange={setzeTabBeschriftung}
aria-label="Beschriftung in der Menüleiste"
/>
</Feld>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Aus zeigt nur die Symbole und gibt etwas Höhe frei
</span>
</Tile>
<Tile>
<span className="ads-eyebrow">Bild der Übersicht</span>
{/* Sofort gespeichert, nicht ueber die Speicherleiste am Seitenende.
Das Umschalten der Ansicht ist keine Aenderung, die man sammelt
und spaeter bestaetigt - man will das andere Foto sehen. Das
Panel macht es genauso (data-startbild ruft direkt
profilSpeichern()); in der App tauchte stattdessen unten die
Leiste "Aenderungen speichern" auf (gemeldet 2026-08-31). */}
<Feld label="Ansicht" last>
<select
className="dm-auswahl"
value={werte.startbild}
onChange={(e) => {
const startbild = e.target.value
void profilSpeichern({ einstellungen: { ...werte, startbild } })
}}
>
{BILDPLAETZE.map((platz) => (
<option key={platz.datei} value={platz.name}>
{platz.name}
</option>
))}
</select>
</Feld>
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
Auf das Bild tippen, um für diese Ansicht ein eigenes Foto hochzuladen oder zu ersetzen.
</span>
{/* Vorher stand hier ein Raster aller sieben Miniaturen und der Hinweis,
Fotos seien nur über Home Assistant zu pflegen — das Panel zeigt
genau die gewählte Ansicht groß und lädt direkt hoch. */}
<div className="dm-einstellbild" style={{ marginTop: "var(--sp-3)" }}>
<BildMitMenue
datei={gewaehltesBild.datei}
beschreibung={gewaehltesBild.beschreibung}
name={gewaehltesBild.beschreibung}
dateiZeigen
/>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Kraftstoff-Rabatt</span>
<Feld
label={
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<Markenlogo alt={werte.markenname} className="dm-markenlogo" />
{/* Das Panel nennt den Schalter "SmartDeal aktiv" - fest, nicht
aus dem Markennamen gebaut. */}
SmartDeal aktiv
</span>
}
last={!fahrzeug.smartdeal.aktiv}
>
<Switch
checked={fahrzeug.smartdeal.aktiv}
onChange={(an) => {
/* Einschalten fragt zuerst, bis wann der Rabatt gilt - ohne
dieses Datum kann die App ihn nie von selbst auslaufen
lassen, und genau daran haengt smartdeal.aktiv im
Profiladapter. Ausschalten braucht keine Rueckfrage.
Gleiches Verhalten wie data-sd/sdpopup im Panel. */
if (an) {
setzeSdEntwurf(fahrzeug.smartdeal.laeuftAb ?? "")
setzeSdAbfrage(true)
return
}
void profilSpeichern({
fahrzeug: {
...fahrzeug,
smartdeal: { ...fahrzeug.smartdeal, aktiv: false },
},
})
}}
aria-label="SmartDeal aktiv"
/>
</Feld>
{fahrzeug.smartdeal.aktiv ? (
<Feld label="Gültig bis" last>
{/* Reine Anzeige, wie im Panel: geaendert wird das Datum ueber den
Schalter, der die Abfrage oeffnet - so gibt es nur einen Weg
dorthin und keine zwei Eingabestellen fuer denselben Wert. */}
<span>
{fahrzeug.smartdeal.laeuftAb ? datum(fahrzeug.smartdeal.laeuftAb) : ""}
</span>
</Feld>
) : (
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Ersparnis und Bildmarke werden ausgeblendet
</span>
)}
</Tile>
{/* Blatt statt schwebender Karte: eine Eingabe, die der Nutzer selbst
angestoßen hat, kommt nach Apples Vorgabe von der unteren Kante -
in Daumenreichweite und mit sichtbarem Weg hinaus. Die alte Karte
saß mittig und war obendrein durchscheinend. */}
<Sheet
open={sdAbfrage}
onClose={() => setzeSdAbfrage(false)}
title="SmartDeal"
description="Bis wann gilt der Rabatt?"
>
<Feld label="Gültig bis" last>
<Datumsfeld wert={sdEntwurf} beiAenderung={setzeSdEntwurf} />
</Feld>
<div className="dm-sd-knoepfe">
<ActionButton
variant="primary"
disabled={!sdEntwurf}
onClick={() => {
if (!sdEntwurf) return
setzeSdAbfrage(false)
void profilSpeichern({
fahrzeug: {
...fahrzeug,
smartdeal: { aktiv: true, laeuftAb: sdEntwurf },
},
})
}}
>
Bestätigen
</ActionButton>
<ActionButton onClick={() => setzeSdAbfrage(false)}>Abbrechen</ActionButton>
</div>
</Sheet>
{/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie
in den Einstellungen nicht, die Nachtgrenze steht dort nur als
Erlaeuterung auf der Statistik-Seite. */}
{/* Die Kachel „Fahrten zusammenfassen" ist am 2026-08-31 entfallen:
der FMM003 wendet die Pausenregel selbst an (Ignition OFF Timeout
= 900 s), eine zweite Wartezeit in der App haette daraus eine
halbe Stunde gemacht. */}
<HistorienImport
offen={importOffen}
beiSchliessen={() => setzeImportOffen(false)}
api={api}
beiFertig={() => void jetztAktualisieren()}
/>
<DatensatzPopup
offen={datensatzPopup !== null}
modus={datensatzPopup ?? "sichern"}
beiSchliessen={() => setzeDatensatzPopup(null)}
api={api}
rohprofil={rohprofil}
fahrten={fahrten}
tankvorgaenge={tankvorgaenge}
wartungsplan={(fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]}
beiProfilGeladen={profilGeladen}
beiFertig={() => void jetztAktualisieren()}
/>
<Tile>
<span className="ads-eyebrow">Fahrzeugprofil</span>
<p className="dm-erklaerung">
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
Ausstattung, Räder, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der
Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen eine
Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt
unangetastet.
</p>
<div className="dm-knopfreihe">
<ActionButton onClick={() => setzeDatensatzPopup("sichern")}>
Datensatz sichern
</ActionButton>
<ActionButton onClick={() => setzeDatensatzPopup("laden")}>Datensatz laden</ActionButton>
</div>
</Tile>
{/* Wortlaut, Reihenfolge und Knopfbeschriftungen wie im Panel: die
Kachel hiess hier „Sicherung", der Erlaeuterungstext fehlte, und die
Knoepfe waren eigenstaendig formuliert („Jetzt sichern", „Auf dieses
Geraet laden", „Von diesem Geraet einspielen"). */}
<Tile>
<span className="ads-eyebrow">Backup</span>
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. &quot;Jetzt erstellen&quot;
legt eine Kopie auf dem Server ab, &quot;Exportieren&quot;/&quot;Importieren&quot;
übertragen den aktuellen Gesamtstand als einzelne Datei.
</span>
{werte.letztesBackup && (
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Letztes Backup: {datumZeit(werte.letztesBackup)}
</span>
)}
<ActionButton onClick={() => void api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_jetzt")}>
Backup jetzt erstellen
</ActionButton>
<Feld label="Autom. Backup" last>
<select
className="dm-auswahl"
value={werte.backupIntervall}
onChange={(e) => sichern({ backupIntervall: e.target.value })}
>
<option value="aus">Aus</option>
<option value="taeglich">Täglich</option>
<option value="woechentlich">Wöchentlich</option>
<option value="monatlich">Monatlich</option>
</select>
</Feld>
<ActionButton onClick={profilExportieren}>Backup exportieren</ActionButton>
<ActionButton
onClick={() => backupDateiwahl.current?.click()}
disabled={backupImportLaeuft}
>
{backupImportLaeuft ? "Lade …" : "Backup importieren"}
</ActionButton>
<input
ref={backupDateiwahl}
type="file"
accept="application/json"
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
if (datei) void backupImportieren(datei)
e.target.value = ""
}}
/>
{backupImportFehler && <p className="dm-fehler">{backupImportFehler}</p>}
</Tile>
{/* 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).
Seit dem 05.09.2026 gilt das auch fuer die App selbst: sie hatte eine
eigene Kachel "App-Update" daneben, obwohl es dieselbe Frage ist -
"was laeuft hier, und gibt es etwas Neueres". Zusammengelegt auf
Vorgabe des Eigentuemers.
Die beiden Abschnitte sind BENANNT und durch eine Haarlinie getrennt.
Ohne das staenden zwei gleich aussehende "Update installieren"-Knoepfe
untereinander, ohne dass man ihnen ansieht, was sie erneuern. Das
Panel kennt nur den Integrationsteil - die App traegt ihre Dateien
fest gebuendelt, das Panel laedt sie bei jedem Aufruf neu. */}
<Tile>
<span className="ads-eyebrow">Version</span>
{/* Die Zeile benennt selbst, WAS in welcher Fassung auf dem Geraet
liegt - vorher hiess das Label "Installiert" und der Gegenstand
stand klein darunter. Damit braucht der Abschnitt auch keine
Ueberschrift mehr: sie saegte dasselbe ein zweites Mal (Vorgabe
des Eigentuemers, 05.09.2026: "damit wird es nochmal schlanker"). */}
<div className="dm-kachelabschnitt">
<Werteliste
kinder={<Wertzeile label="Integration" wert={serverVersion ?? "unbekannt"} />}
/>
{/* HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt
lang, ohne verlässliche Fortschrittsangabe - ein laufender
Indikator statt eines nur deaktivierten Knopfes verhindert, dass
das wie ein Hänger aussieht. Nach Erfolg führt der Knopf direkt
zum nächsten nötigen Schritt (Neustart) statt den Nutzer selbst
suchen zu lassen - ein reiner Reload reicht nicht (AGENTS.md
Abschnitt J). */}
{updateInstallierenLaeuft ? (
<div className="dm-ladezeile">
<span className="dm-spinner" aria-hidden="true" />
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
Installiere
</span>
</div>
) : updateNeustartLaeuft ? (
<div className="dm-ladezeile">
<span className="dm-spinner" aria-hidden="true" />
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
Home Assistant startet neu
</span>
</div>
) : integrationUpdate?.installiert && integrationUpdate.neustart_noetig === false ? (
/* Nur Oberflächendateien geändert - die liegen als statische Dateien
auf der Platte und werden ohne lange Cache-Vorgabe ausgeliefert.
Ein Neuladen genügt, Home Assistant muss nicht neu starten. */
<>
<p className="dm-erklaerung">
Version {integrationUpdate.version} installiert - nur Oberflächendateien, kein
Neustart nötig.
</p>
<div className="dm-knopfreihe">
<ActionButton variant="primary" onClick={() => window.location.reload()}>
App neu laden
</ActionButton>
</div>
</>
) : integrationUpdate?.installiert ? (
<>
<p className="dm-erklaerung">
Version {integrationUpdate.version} installiert - die neue Fassung wird erst nach
einem Neustart geladen.
</p>
<div className="dm-knopfreihe">
<ActionButton onClick={() => void updateNeustartAusloesen()}>
Installation abschließen - Jetzt neu starten
</ActionButton>
</div>
</>
) : integrationUpdate?.verfuegbar ? (
<>
{/* Zeitstempel und ein zweiter Prüfknopf gehören hierher, nicht nur
in den Zweig „ist aktuell": die angezeigte Version ist das
gespeicherte Ergebnis der letzten Prüfung, nicht der Live-Stand.
Ohne beides sah eine Wochen alte Zahl aus wie eine Tatsache, und
es gab keinen Weg, sie aufzufrischen, solange überhaupt ein
Update angeboten wurde. */}
<Werteliste
kinder={
<Wertzeile
label="Verfügbare Version"
wert={integrationUpdate.version ?? "?"}
zusatz={
integrationUpdate.geprueft_am
? `geprüft ${datumZeit(integrationUpdate.geprueft_am)}`
: undefined
}
/>
}
/>
<p className="dm-erklaerung">
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
muss danach neu gestartet werden. Fahrzeugprofil, Fahrten und Tankvorgänge sind davon
nicht betroffen.
</p>
<div className="dm-knopfreihe">
<ActionButton variant="primary" onClick={() => void updateInstallierenAusloesen()}>
Update installieren
</ActionButton>
<ActionButton onClick={() => void updatePruefenAusloesen()}>
Erneut prüfen
</ActionButton>
</div>
</>
) : updatePruefenLaeuft ? (
<div className="dm-ladezeile">
<span className="dm-spinner" aria-hidden="true" />
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
Prüfe
</span>
</div>
) : (
<>
{integrationUpdate && (
<span className="dm-standmarke">
aktuell
{integrationUpdate.geprueft_am && (
<small>geprüft {datumZeit(integrationUpdate.geprueft_am)}</small>
)}
</span>
)}
<div className="dm-knopfreihe">
<ActionButton onClick={() => void updatePruefenAusloesen()}>
Auf Update prüfen
</ActionButton>
</div>
</>
)}
{updateAktionFehler && <p className="dm-fehler">{updateAktionFehler}</p>}
{!updateAktionFehler &&
integrationUpdate?.fehler &&
!updatePruefenLaeuft &&
!updateInstallierenLaeuft &&
!updateNeustartLaeuft && <p className="dm-fehler">{integrationUpdate.fehler}</p>}
</div>
{otaMoeglich() && (
<div className="dm-kachelabschnitt">
<Werteliste kinder={<Wertzeile label="App" wert={eigeneVersion() ?? "unbekannt"} />} />
{otaUpdateVerfuegbar && otaBuendel ? (
<>
<Werteliste
kinder={
<Wertzeile
label="Verfügbare Version"
wert={otaBuendel.version}
zusatz={`${Math.round(otaBuendel.bytes / 1024)} KB`}
/>
}
/>
<p className="dm-erklaerung">
Lädt die Oberfläche neu und startet die App neu. Fahrzeugprofil, Fahrten und
Tankvorgänge sind davon nicht betroffen die liegen in Home Assistant.
</p>
<div className="dm-knopfreihe">
<ActionButton onClick={() => void otaAusloesen()} disabled={otaLaeuft}>
{otaLaeuft ? "Lade Update …" : "Update installieren"}
</ActionButton>
</div>
{otaFehler && <p className="dm-fehler">{otaFehler}</p>}
</>
) : otaBuendel ? (
/* Dieselbe grüne Marke wie bei der Integration, statt eines
Satzes - Vorgabe des Eigentümers, 05.09.2026. Ohne Zeitpunkt:
anders als bei der Integration wird hier nicht auf Knopfdruck
geprüft, das Bündel meldet der Server beim Laden mit. */
<span className="dm-standmarke">aktuell</span>
) : (
<p className="dm-erklaerung">
Der Server hat gerade kein Update-Bündel hinterlegt.
</p>
)}
</div>
)}
</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.
Bewusst KEINE Kachel und keine Erlaeuterung, sondern ein stiller
Textknopf am Seitenende (Vorgabe des Eigentuemers, 04.09.2026:
„moeglichst unauffaellig"). */}
<button type="button" className="dm-debugknopf" onClick={() => setzeDebugOffen(!debugOffen)}>
{debugOffen ? "Debug-Mode schließen" : "Debug-Mode"}
</button>
{debugOffen && (
<>
{/* Der Zugang zu Home Assistant selbst steht hier mit drin (Vorgabe des
Eigentuemers, 04.09.2026): es ist dieselbe Sorte Angabe wie die
Dienste-Token, und getrennt wird eine Verbindung ungefaehr so oft,
wie ein Token getauscht wird - also fast nie. Ohne Entsprechung im
Panel, und zwar notwendigerweise: das Panel laeuft INNERHALB von
Home Assistant und kennt weder Adresse noch Token. */}
<Tile>
<span className="ads-eyebrow">Zugänge</span>
<Accordion
title="Home Assistant"
summary={<span className="dm-token-gesetzt">verbunden</span>}
>
<p className="dm-erklaerung">
Adresse und Zugangs-Token dieser Instanz. Verbindung trennen löscht beides vom
Gerät die App fragt beim nächsten Start wieder danach, die Daten auf dem Server
bleiben unberührt.
</p>
<Wertzeile label="Server" wert={zugangLesenSynchron()?.basisUrl ?? "—"} />
<ActionButton variant="destructive" onClick={() => void abmelden()}>
Verbindung trennen
</ActionButton>
</Accordion>
{/* 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={() => {
void (async () => {
if (
!(await bestaetigen("Token löschen?", {
text: "Der hinterlegte Token wird entfernt. Bis ein neuer eingetragen ist, kann die Integration diesen Dienst nicht mehr erreichen.",
}))
)
return
await 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={w.abweichung === true}
zusatz={
w.offen != null
? `→ ${w.offen}, unterwegs`
: w.abweichung === true
? `(wir: ${w.unser})`
: dongleStand(w)
}
/>
))}
/>
)}
<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} />
</>
)}
</>
)
}
/* 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>
)
}