e76252505f
- 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>
1182 lines
52 KiB
TypeScript
1182 lines
52 KiB
TypeScript
/**
|
||
* 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. "Jetzt erstellen"
|
||
legt eine Kopie auf dem Server ab, "Exportieren"/"Importieren"
|
||
ü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 &
|
||
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>
|
||
)
|
||
}
|