Files
audi-app/companion-app/src/screens/Einstellungen.tsx
T
tobias e76252505f Fehlerspeicher, Amber statt Gruen, Tankbalken rot ab 90 km
- Neue Sensorrolle FEHLERSPEICHER_SENSOR (number_of_diagnostic_trouble_codes
  vom CAN) und eine fuenfte Zeile auf der Sicherheitsseite: "Keine
  Fehlermeldungen". Bewusst NICHT im _sicherheitscheck(): "gesichert"
  rechnet ein UND ueber diese Liste, und ein Eintrag im Fehlerspeicher sagt
  nichts darueber, ob der Wagen verschlossen dasteht.
- "Zuendung an" ist Amber statt Gruen. Gruen las sich als "alles in Ordnung
  und sicher"; unterwegs ist kein guter Zustand, sondern ein anderer.
- Das Modell-Auswahlfeld bekam dm-eingabe statt dm-auswahl - nur letzteres
  setzt appearance:none, deshalb zeichnete der Browser seinen nativen Pfeil
  neben den des Design-Systems. Die Breitenregel heisst jetzt
  dm-feld--fahrzeug und gilt fuer input und select.
- "aktuell" erscheint erst nach einer Pruefung, und fuer beide Abschnitte
  gemeinsam. Der App-Abschnitt zeigte die Marke schon, sobald ein Buendel
  vorlag - also unmittelbar nach einem Update wieder.
- Der Tankbalken wird rot ab 90 km Restreichweite, in Kilometern und nicht
  in Prozent: die Reichweite ist die Aussage, um die es geht, und das
  Fahrzeug rechnet die Fahrweise darin bereits ein. Farbe --red (Audi-CI,
  #F50537), nicht --bad-flaeche - letzteres ist fuer weisse Schrift darauf
  gewaehlt, und hier steht keine drauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:31:52 +02:00

1182 lines
52 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, versionOrdnung } from "../daten/appVersion"
import { Datumsfeld } from "./Datumsfeld"
import { useDaten } from "../daten/DatenKontext"
import { modellauswahl } from "../daten/fahrzeugmodelle"
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 && (
<>
{/* Auswahlfeld, nicht Freitext - wie im Panel (<select id="einModell">).
Bis zum 05.09.2026 stand hier ein einfaches Eingabefeld, vom
Eigentümer bemerkt: „einrichten/modell ist in der app kein
dropdown". Ein frei getippter Titel führt zu keinem oder zum
falschen Typenschild, denn das Bild wird über den Modellnamen
gesucht. Die Liste selbst steht in daten/fahrzeugmodelle.ts und
wird von einem Test gegen die Panel-Fassung geprüft. */}
<Feld label="Modell">
{/* dm-auswahl, NICHT dm-eingabe: nur dort steht `appearance: none`.
Der Aufklapp-Pfeil kommt als Hintergrundbild aus dem
Design-System (.ads-feld select); ohne den Reset zeichnet der
Browser seinen eigenen daneben — vom Eigentümer am 05.09.2026
als „Doppelpfeil" gemeldet. */}
<select
className="dm-auswahl dm-feld--fahrzeug"
value={werte.fahrzeugtitel}
onChange={(e) => {
aendern({ fahrzeugtitel: e.target.value })
sichern({ fahrzeugtitel: e.target.value })
}}
>
{modellauswahl(werte.fahrzeugtitel).map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Feld>
<Feld label="Kennzeichen">
<input
className="dm-eingabe dm-feld--fahrzeug"
value={werte.kennzeichen}
onChange={(e) => aendern({ kennzeichen: e.target.value })}
onBlur={() => sichern()}
/>
</Feld>
<Feld label="Erstzulassung">
<input
className="dm-eingabe dm-feld--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-feld--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">Update</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 || updateNeustartLaeuft ? (
/* Kein eigener Text mehr - was gerade läuft, steht im Knopf ganz
unten, der dabei selbst den Fortschritt zeigt. */
<></>
) : integrationUpdate?.installiert && integrationUpdate.neustart_noetig === false ? (
/* Gar keine Meldung. Geändert wurden Dateien unter frontend/, und das
sind die des PANELS - die App lädt ihren eigenen Code aus
/local/dmapp bzw. aus der nativen Hülle und hat hier nichts zu tun.
Damit gilt in der App durchgehend: steht hier nichts, ist alles in
Ordnung; steht ein Neustart-Knopf da, muss ich handeln (Vorgabe des
Eigentümers, 05.09.2026). Ein "App neu laden" stand hier kurzzeitig
und versprach etwas, das es nicht einlösen konnte. Kam ein neues
OTA-Bündel mit, erscheint es im Abschnitt "App" darunter. */
<></>
) : integrationUpdate?.installiert ? (
<>
<p className="dm-erklaerung">
Version {integrationUpdate.version} installiert - die neue Fassung wird erst nach
einem Neustart geladen.
</p>
</>
) : 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
}
/>
}
/>
</>
) : (
<>
{/* Nur nach einer Prüfung. „aktuell" ohne Prüfung wäre eine
Behauptung ohne Grundlage — dieselbe Bedingung trägt der
App-Abschnitt darunter, damit beide gemeinsam grün werden
(Vorgabe des Eigentümers, 05.09.2026). */}
{integrationUpdate?.geprueft_am && (
<span className="dm-standmarke">aktuell</span>
)}
</>
)}
{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>
{otaFehler && <p className="dm-fehler">{otaFehler}</p>}
</>
) : otaBuendel &&
(versionOrdnung(otaBuendel.version, eigeneVersion()) ?? 0) > 0 ? (
/* Ein NEUERES Bündel liegt da, wird aber nicht angeboten - weil
buendelPasst() verlangt, dass es der Fassung entspricht, die
diese Installation gerade ausliefert. Genau das ist der Zustand
zwischen "Update installiert" und dem Neustart: auf der Platte
liegt schon das neue Bündel, der laufende Server meldet aber
noch seine alte Versionsnummer.
Hier "aktuell" zu schreiben wäre falsch - vom Eigentümer am
05.09.2026 bemerkt (App zeigte 5.22 aktuell, während 5.29
danebenlag). Ein Knopf gehört auch nicht her: zu tun ist erst
nach dem Neustart etwas. */
<p className="dm-erklaerung">Update {otaBuendel.version} vorhanden</p>
) : otaBuendel ? (
/* Dieselbe grüne Marke wie bei der Integration - und an
dieselbe Bedingung geknüpft: erst nach einer Prüfung. Das
Bündel meldet der Server zwar bei jedem Laden mit, aber
„aktuell" stand damit schon unmittelbar nach einem Update
wieder da, ohne dass jemand nachgesehen hätte (vom Eigentümer
am 05.09.2026 bemerkt). Der eine Knopf unten prüft beides,
also werden auch beide zusammen grün. */
integrationUpdate?.geprueft_am ? (
<span className="dm-standmarke">aktuell</span>
) : null
) : (
<p className="dm-erklaerung">
Der Server hat gerade kein Update-Bündel hinterlegt.
</p>
)}
</div>
)}
{/* EIN Knopf für die ganze Kachel, ganz unten: er steht unter beiden
Abschnitten und trägt in jedem Zustand den nächsten fälligen
Schritt - suchen, installieren, Neustart. Bis zum 05.09.2026 stand
darüber noch ein zweiter Knopf im jeweiligen Abschnitt; der
Eigentümer: „warum nicht den update suchen button dafür verwenden
und nur die optik und schrift zur installation anpassen?" - genau
das passiert hier, Beschriftung und Füllung wechseln mit dem
Zustand. Wortgleich mit dem Panel (updateKnopf()).
Die Reihenfolge ist eine Rangfolge, keine Auswahl: liegt ein
Neustart an, ist das der nächste Schritt, egal was sonst noch
bereitliegt. Integrations- UND App-Update gleichzeitig gibt es
dabei praktisch nicht - das Bündel wird erst angeboten, wenn es zur
laufenden Serverfassung passt (buendelPasst), also nach der
Integration. */}
<div className="dm-knopfreihe" style={{ marginTop: "var(--sp-4)" }}>
{(() => {
const laufend = (text: string) => (
<ActionButton disabled>
<span className="dm-knopflader">
<span className="dm-spinner" aria-hidden="true" />
{text}
</span>
</ActionButton>
)
if (updateInstallierenLaeuft) return laufend("Installiere …")
if (updateNeustartLaeuft) return laufend("Home Assistant startet neu …")
if (updatePruefenLaeuft) return laufend("Prüfe …")
if (otaLaeuft) return laufend("Lade Update …")
if (integrationUpdate?.installiert && integrationUpdate.neustart_noetig !== false)
return (
<ActionButton variant="primary" onClick={() => void updateNeustartAusloesen()}>
Installation abschließen und neu starten
</ActionButton>
)
if (integrationUpdate?.verfuegbar)
return (
<ActionButton variant="primary" onClick={() => void updateInstallierenAusloesen()}>
Update installieren
</ActionButton>
)
if (otaUpdateVerfuegbar && otaBuendel)
return (
<ActionButton variant="primary" onClick={() => void otaAusloesen()}>
Update installieren
</ActionButton>
)
return (
<ActionButton onClick={() => void updatePruefenAusloesen()}>
Auf Update prüfen
</ActionButton>
)
})()}
</div>
{/* Der Zeitpunkt gehört zum Suchvorgang, nicht zu einem der beiden
Abschnitte - er stand bis zum 05.09.2026 an der grünen Marke der
Integration und sah dort aus, als gälte er nur für sie, obwohl der
Knopf für die ganze Kachel steht (vom Eigentümer bemerkt). */}
{integrationUpdate?.geprueft_am && (
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
Zuletzt geprüft {datumZeit(integrationUpdate.geprueft_am)}
</span>
)}
</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>
)
}