99cef7c393
BUG "Fahrzeug faehrt" aenderte sich nie. standortZustand() leitete den
Fahrzustand aus TRIPS[0].status === "offen" ab - zwei Fehler
uebereinander, die sich gegenseitig verstaerkt haben.
Erstens heisst "offen" nicht "unterwegs", sondern "Daten noch
unvollstaendig": _fahrt_beenden() legt die Fahrt mit diesem Status an,
wenn sie ENDET, und das Kilometerstand-Screening fuellt sie spaeter. Eine
Fahrt, die nie eine Strecke bekam - etwa weil damals kein KM_SENSOR
zugeordnet war -, bleibt fuer immer "offen". Zweitens ist TRIPS[0] die
AELTESTE Fahrt, nicht die neueste: fahrten_veroeffentlichen() reicht
profil.fahrten_lesen() unveraendert in Dateireihenfolge weiter, und die
ist aufsteigend. Zusammen hat die aelteste jemals unvollstaendig
gebliebene Fahrt das Fahrzeug dauerhaft als fahrend angezeigt; an der
Testinstanz war das eine seit 18 Tagen beendete Fahrt.
Nicht den Index geflickt, sondern die Quelle korrigiert: das Backend
veroeffentlicht jetzt "zuendung" im Fahrzeugstatus, gelesen aus dem
ohnehin zugeordneten ZUENDUNG_SENSOR - demselben Signal, das auch
fahrterkennung.py als massgeblich nimmt. Anzeige und Erfassung koennen
dadurch gar nicht mehr auseinanderlaufen. Ohne zugeordneten Sensor
(null) steht "Fahrzustand unbekannt" statt einer Behauptung.
companion-app hatte denselben Fehler spiegelverkehrt: liveZustandLesen()
las "zuendung" (gab es nie) und "lat"/"lon" (das Backend liefert
standort_lat/standort_lon), und der Typ Fahrzeug kannte keines dieser
Felder. Die Live-Ansicht meldete deshalb dauerhaft "Das Fahrzeug steht"
und zeigte nie eine Position. Felder in Fahrzeugstatus/Fahrzeug und im
Adapter ergaenzt, damit der Typ diese Fehlerklasse kuenftig faengt -
wovor sein eigener Kopfkommentar seit einem frueheren Vorfall warnt.
BUG "Standortzugriff verweigert" war irrefuehrend. Die Zeile steht
direkt unter dem Fahrzeugnamen, wo sonst der Abstand zum Auto steht,
handelt aber vom Standort DIESES Geraets - sie las sich, als sei der
Standort des Autos nicht abrufbar. Jetzt "GPS offline" wie gewuenscht.
Die verweigerte Freigabe behaelt eine eigene Meldung ("GPS-Freigabe
fehlt"): sie ist der einzige Fall mit anderer Abhilfe, und "GPS offline"
wuerde dort zur Signalsuche statt zum Freigabeschalter schicken.
VERSIONIERUNG: die Zahl, die alle fuer "die Version" hielten, ist keine.
Der Integer in audi-dashboard-version.json ist ein Cache-Brecher, den
install.ps1/update.ps1 bei jedem Deploy mit UtcNow neu setzen -
unabhaengig davon, ob sich Code geaendert hat. Zwei Builds derselben
Quelle bekommen verschiedene Zahlen. Er kann die Frage "ist das derselbe
Stand?" grundsaetzlich nicht beantworten.
Deshalb beide Aufgaben getrennt: neue Datei VERSION im Projektstamm
(2026.08.23.1) als Identitaet, von Hand erhoeht; der Integer bleibt
unveraendert der Cache-Brecher. VERSION fliesst in beide Seiten - als
zweites Feld "app" in audi-dashboard-version.json (alle drei
Deploy-Skripte uebernehmen es jetzt; sie haben die Datei bisher komplett
ueberschrieben und haetten es still zerstoert) und ueber vite define als
__APP_VERSION__ in den Companion-Build. Das Backend veroeffentlicht
pyscript.audi_dashboard_app_version, die App vergleicht und meldet eine
Abweichung in der Hinweisleiste - deren erklaerter Grundsatz "nie eine
stille Veraltung" genau dieser Fall ist, nur dass hier nicht die Anzeige
veraltet, sondern die App selbst.
Bewusst nur Gleichheitsvergleich, nie groesser/kleiner: die Version ist
eine Kennung, keine Zahl; Sortieren waere scheingenau und wuerde bei
einem Formatwechsel still falsch antworten. Fehlt eine der beiden
Seiten, wird nicht verglichen und nichts gemeldet - ein aelteres Backend
oder ein Start ohne Netz darf keinen Fehlalarm ausloesen. Der
vite-Build bricht dagegen hart ab, wenn VERSION fehlt, statt eine App zu
erzeugen, die ihre eigene Veraltung nicht erkennen kann. Das Panel
braucht nichts davon: es laedt bei jedem Seitenaufruf neu.
Geprueft: Backend meldet zuendung: False und app_version 2026.08.23.1 im
Testcontainer, Panel zeigt statt "Fahrzeug faehrt" jetzt "Geparkt seit
13 Tg. 13 Std." und statt der alten Meldung "GPS-Freigabe fehlt";
VERSION landet nachweislich im Build (im Bundle gegriffen) und der Build
bricht ohne die Datei ab (gegengeprueft); tsc sauber, Tests 112/112,
vite build sauber, HA-Start ohne Fehler.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
7.3 KiB
TypeScript
226 lines
7.3 KiB
TypeScript
/**
|
|
* Ein Zugang zu allen Daten für die gesamte Oberfläche.
|
|
*
|
|
* Hält die vier Datentöpfe (Profil, Fahrten, Tankvorgänge, Fahrzeugstatus),
|
|
* lauscht über die WebSocket-Verbindung auf Änderungen und reicht den
|
|
* Verbindungszustand sowie die Warteschlange nach außen. Die Screens kennen
|
|
* weder REST noch WebSocket — sie lesen aus diesem Kontext.
|
|
*/
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react"
|
|
|
|
import {
|
|
ApiFehler,
|
|
DataMetricApi,
|
|
ENTITAETEN,
|
|
type Fahrt,
|
|
type Fahrzeugstatus,
|
|
type Profil,
|
|
type Tankvorgang,
|
|
type Verbindungszustand,
|
|
type WartenderAuftrag,
|
|
} from "../api"
|
|
import type { Versionsstand } from "./appVersion"
|
|
import { eigeneVersion, versionVergleichen } from "./appVersion"
|
|
import type { Einstellungen, Fahrzeug } from "./profilAdapter"
|
|
import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter"
|
|
|
|
export interface DatenWert {
|
|
bereit: boolean
|
|
/** Erste Ladung fehlgeschlagen und noch nie Daten gesehen. */
|
|
ladefehler: string | null
|
|
verbindung: Verbindungszustand
|
|
warteschlange: readonly WartenderAuftrag[]
|
|
/** Zeitpunkt der letzten Statusmeldung — treibt die Veraltet-Anzeige. */
|
|
statusStand: string | null
|
|
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
|
|
Siehe appVersion.ts und VERSIONIERUNG.md. */
|
|
versionsstand: Versionsstand
|
|
|
|
einstellungen: Einstellungen | null
|
|
fahrzeug: Fahrzeug | null
|
|
fahrten: readonly Fahrt[]
|
|
tankvorgaenge: readonly Tankvorgang[]
|
|
rohprofil: Profil | null
|
|
|
|
api: DataMetricApi
|
|
neuLaden: () => Promise<void>
|
|
jetztAktualisieren: () => Promise<void>
|
|
/** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */
|
|
profilSpeichern: (aenderung: {
|
|
einstellungen?: Einstellungen
|
|
fahrzeug?: Fahrzeug
|
|
}) => Promise<void>
|
|
}
|
|
|
|
const Kontext = createContext<DatenWert | null>(null)
|
|
|
|
export function useDaten(): DatenWert {
|
|
const wert = useContext(Kontext)
|
|
if (!wert) throw new Error("useDaten außerhalb von <DatenAnbieter> verwendet")
|
|
return wert
|
|
}
|
|
|
|
export function DatenAnbieter({
|
|
api,
|
|
children,
|
|
}: {
|
|
api: DataMetricApi
|
|
children: ReactNode
|
|
}) {
|
|
const [rohprofil, setzeRohprofil] = useState<Profil | null>(null)
|
|
const [status, setzeStatus] = useState<Fahrzeugstatus>({})
|
|
const [statusStand, setzeStatusStand] = useState<string | null>(null)
|
|
const [fahrten, setzeFahrten] = useState<readonly Fahrt[]>([])
|
|
const [tankvorgaenge, setzeTankvorgaenge] = useState<readonly Tankvorgang[]>([])
|
|
const [bereit, setzeBereit] = useState(false)
|
|
const [ladefehler, setzeLadefehler] = useState<string | null>(null)
|
|
const [verbindung, setzeVerbindung] = useState<Verbindungszustand>("getrennt")
|
|
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
|
|
const [serverVersion, setzeServerVersion] = useState<string | null>(null)
|
|
|
|
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
|
|
// wenn zwischendurch ein Push hereinkam.
|
|
const rohprofilRef = useRef<Profil | null>(null)
|
|
rohprofilRef.current = rohprofil
|
|
|
|
const neuLaden = useCallback(async () => {
|
|
try {
|
|
const [p, f, t, s] = await Promise.all([
|
|
api.profilLesen(),
|
|
api.fahrtenLesen(),
|
|
api.tankvorgaengeLesen(),
|
|
api.rest.zustandLesen<{ daten: Fahrzeugstatus }>(ENTITAETEN.fahrzeugstatus),
|
|
])
|
|
setzeRohprofil(p)
|
|
setzeFahrten(f ?? [])
|
|
setzeTankvorgaenge(t ?? [])
|
|
setzeStatus(s.attributes.daten ?? {})
|
|
setzeStatusStand(s.last_updated)
|
|
setzeLadefehler(null)
|
|
setzeBereit(true)
|
|
// Bewusst außerhalb des Promise.all oben und ohne eigenes catch hier:
|
|
// appVersionLesen() schluckt seine Fehler selbst und liefert dann null.
|
|
// Ein fehlender Versionsvergleich darf die Erstladung nie aufhalten -
|
|
// er ist ein Hinweis, keine Betriebsvoraussetzung.
|
|
setzeServerVersion(await api.appVersionLesen())
|
|
} catch (fehler) {
|
|
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
|
|
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
|
|
const netzproblem = fehler instanceof ApiFehler && fehler.istNetzproblem
|
|
if (!netzproblem || !bereit) {
|
|
setzeLadefehler(fehler instanceof Error ? fehler.message : String(fehler))
|
|
}
|
|
}
|
|
}, [api, bereit])
|
|
|
|
// Erstladung plus Live-Verbindung.
|
|
useEffect(() => {
|
|
void neuLaden()
|
|
void api.warteschlange.laden()
|
|
|
|
const abVerbindung = api.live.aufVerbindung((zustand) => {
|
|
setzeVerbindung(zustand)
|
|
// Nach einem Wiederverbinden kann während der Trennung etwas passiert
|
|
// sein, das kein Ereignis mehr ausgelöst hat.
|
|
if (zustand === "verbunden") void neuLaden()
|
|
})
|
|
|
|
const abZustand = api.live.aufZustand(({ entity_id, new_state }) => {
|
|
if (!new_state) return
|
|
const daten = (new_state.attributes as { daten?: unknown }).daten
|
|
switch (entity_id) {
|
|
case ENTITAETEN.profil:
|
|
setzeRohprofil((daten ?? null) as Profil | null)
|
|
break
|
|
case ENTITAETEN.fahrten:
|
|
setzeFahrten((daten ?? []) as Fahrt[])
|
|
break
|
|
case ENTITAETEN.tankvorgaenge:
|
|
setzeTankvorgaenge((daten ?? []) as Tankvorgang[])
|
|
break
|
|
case ENTITAETEN.fahrzeugstatus:
|
|
setzeStatus((daten ?? {}) as Fahrzeugstatus)
|
|
setzeStatusStand(new_state.last_updated)
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
})
|
|
|
|
const abWarteschlange = api.warteschlange.aufAenderung(setzeWarteschlange)
|
|
void api.live.starten()
|
|
|
|
return () => {
|
|
abVerbindung()
|
|
abZustand()
|
|
abWarteschlange()
|
|
api.live.beenden()
|
|
}
|
|
// Absichtlich nur an `api` gebunden: neuLaden ändert sich mit `bereit`,
|
|
// was hier sonst die Verbindung bei jeder Erstladung neu aufbauen würde.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [api])
|
|
|
|
const einstellungen = useMemo(
|
|
() => (rohprofil ? profilZuEinstellungen(rohprofil) : null),
|
|
[rohprofil],
|
|
)
|
|
const fahrzeug = useMemo(
|
|
() => (rohprofil ? profilZuFahrzeug(rohprofil, status) : null),
|
|
[rohprofil, status],
|
|
)
|
|
|
|
const profilSpeichern = useCallback(
|
|
async (aenderung: { einstellungen?: Einstellungen; fahrzeug?: Fahrzeug }) => {
|
|
const roh = rohprofilRef.current
|
|
if (!roh) throw new Error("Kein Profil geladen — Speichern nicht möglich.")
|
|
const neu = zusammenfuehren(
|
|
roh,
|
|
aenderung.einstellungen ?? profilZuEinstellungen(roh),
|
|
aenderung.fahrzeug ?? profilZuFahrzeug(roh, status),
|
|
)
|
|
// Sofort lokal übernehmen, damit die Oberfläche nicht auf den
|
|
// Rückweg über das Backend warten muss (die Warteschlange kann im
|
|
// Funkloch beliebig lange brauchen).
|
|
setzeRohprofil(neu)
|
|
await api.profilSchreiben(neu)
|
|
},
|
|
[api, status],
|
|
)
|
|
|
|
const jetztAktualisieren = useCallback(async () => {
|
|
await api.jetztAktualisieren()
|
|
await neuLaden()
|
|
}, [api, neuLaden])
|
|
|
|
const wert: DatenWert = {
|
|
bereit,
|
|
ladefehler,
|
|
verbindung,
|
|
warteschlange,
|
|
statusStand,
|
|
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
|
|
einstellungen,
|
|
fahrzeug,
|
|
fahrten,
|
|
tankvorgaenge,
|
|
rohprofil,
|
|
api,
|
|
neuLaden,
|
|
jetztAktualisieren,
|
|
profilSpeichern,
|
|
}
|
|
|
|
return <Kontext.Provider value={wert}>{children}</Kontext.Provider>
|
|
}
|