Files
audi-app/companion-app/src/screens/Einstellungen.tsx
T
tobias 90efcc5b00 Neustart-Festhaenger, OTA-Pruefsumme, Statistik-Kopfzahl (2026.8.31.4)
Die App blieb nach einem Neustart von Home Assistant auf dem alten Stand
stehen, mit einer 502-Fehlerzeile, die erst ein Beenden der App wegbekam.
Zwei Ursachen: 502/503/504 galten als harter Ladefehler statt als "Server
faehrt gerade hoch", und nach dem Wiederverbinden wurde genau einmal
nachgeladen - schlug das fehl, kam nie wieder ein Versuch. Beides behoben,
vier Regressionstests dazu.

OTA: das Buendel ist von HEAD ueber die Platte bis zu den ausgelieferten
Bytes nachweislich deckungsgleich. Die Fassung haengt jetzt trotzdem als
?v= an der Adresse (der statische Pfad kommt ohne Cache-Control, aber mit
ETag), und ein Pruefsummenfehler wird erklaert statt durchgereicht.

Statistik: der Gesamtwert steht jetzt als grosse Zahl in der Kopfzeile,
darunter bleiben vier Spalten - fuenf passen auf dem Telefon nachweislich
nicht in eine Zeile.

Fahrzeugfoto ohne Beschnitt und ohne Bodenschatten, damit auch ohne den
Schalter dafuer. Setup filtert zusaetzlich nach dem Signalnamen aus dem
Katalog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:35:56 +02:00

765 lines
30 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 { ActionButton, Feld, Seg, Sheet, Switch, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
import type { 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 } from "../format"
import { useTheme } from "../theme"
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder"
import { DatensatzPopup } from "./DatensatzPopup"
import { HistorienImport } from "./HistorienImport"
import type { ServicebuchEintrag } from "./Service"
import { zugangVergessen } from "./zugang"
export function Einstellungen({
tabBeschriftung,
setzeTabBeschriftung,
beiAbmeldung,
}: {
tabBeschriftung: boolean
setzeTabBeschriftung: (an: boolean) => void
beiAbmeldung: () => void
}) {
const {
einstellungen,
fahrzeug,
fahrten,
tankvorgaenge,
rohprofil,
otaBuendel,
integrationUpdate,
serverVersion,
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)
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = 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())
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) {
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 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 })
const speichern = async () => {
if (!entwurf) return
setzeLaeuft(true)
setzeGespeichert(false)
try {
await profilSpeichern({
einstellungen: entwurf,
...(erstzulassung !== null ? { fahrzeug: { ...fahrzeug, erstzulassung } } : {}),
})
setzeEntwurf(null)
setzeErstzulassung(null)
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
const abmelden = async () => {
if (!bestaetigen("Zugang von diesem Gerät entfernen? Die Daten auf dem Server bleiben.")) 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"
value={werte.fahrzeugtitel}
onChange={(e) => aendern({ fahrzeugtitel: e.target.value })}
/>
</Feld>
<Feld label="Kennzeichen">
<input
className="dm-eingabe"
value={werte.kennzeichen}
onChange={(e) => aendern({ kennzeichen: e.target.value })}
/>
</Feld>
<Feld label="Erstzulassung">
<input
className="dm-eingabe"
placeholder="MM/JJJJ"
value={erstzulassungWert}
onChange={(e) => setzeErstzulassung(e.target.value)}
/>
</Feld>
<Feld label="Ausführung" last>
<input
className="dm-eingabe"
value={werte.ausfuehrung}
onChange={(e) => aendern({ ausfuehrung: e.target.value })}
/>
</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">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 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 }}>
<img
src={markenlogoUrl()}
alt={werte.markenname}
className="dm-markenlogo"
onError={(e) => {
e.currentTarget.style.display = "none"
}}
/>
{/* 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, Reifen, 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) => aendern({ 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>
{otaMoeglich() && (
<Tile>
<span className="ads-eyebrow">App-Update</span>
{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>}
</>
) : (
<p className="dm-erklaerung">
{otaBuendel
? `Diese App ist aktuell (${eigeneVersion() ?? "unbekannte Version"}).`
: "Der Server hat gerade kein Update-Bündel hinterlegt."}
</p>
)}
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Version</span>
<Werteliste
kinder={
<Wertzeile
label="Installiert"
wert={serverVersion ?? "unbekannt"}
zusatz="Home-Assistant-Integration"
/>
}
/>
</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
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 ? (
<>
<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>
) : (
<>
<p className="dm-erklaerung">
{integrationUpdate
? `Integration ist aktuell (${integrationUpdate.geprueft_am ? datumZeit(integrationUpdate.geprueft_am) : ""}).`
: "Noch nicht geprüft."}
</p>
<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>}
</Tile>
{/* Ohne Entsprechung im Panel - und zwar notwendigerweise: das Panel
laeuft innerhalb von Home Assistant und kennt keine Serveradresse und
kein Token, die man wechseln koennte. Bleibt deshalb bestehen (wie
der Willkommensbildschirm). Die beiden reinen Zaehlzeilen darueber
(„Fahrten gespeichert"/„Tankvorgaenge gespeichert") sind entfallen -
die waren Zusatz ohne Panel-Vorbild. */}
<Tile>
<span className="ads-eyebrow">Zugang</span>
<ActionButton variant="destructive" onClick={() => void abmelden()}>
Verbindung trennen
</ActionButton>
</Tile>
{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)}>
Verwerfen
</button>
</div>
)}
</>
)
}