/** * 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 jetztAktualisieren: () => Promise /** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */ profilSpeichern: (aenderung: { einstellungen?: Einstellungen fahrzeug?: Fahrzeug }) => Promise } const Kontext = createContext(null) export function useDaten(): DatenWert { const wert = useContext(Kontext) if (!wert) throw new Error("useDaten außerhalb von verwendet") return wert } export function DatenAnbieter({ api, children, }: { api: DataMetricApi children: ReactNode }) { const [rohprofil, setzeRohprofil] = useState(null) const [status, setzeStatus] = useState({}) const [statusStand, setzeStatusStand] = useState(null) const [fahrten, setzeFahrten] = useState([]) const [tankvorgaenge, setzeTankvorgaenge] = useState([]) const [bereit, setzeBereit] = useState(false) const [ladefehler, setzeLadefehler] = useState(null) const [verbindung, setzeVerbindung] = useState("getrennt") const [warteschlange, setzeWarteschlange] = useState([]) const [serverVersion, setzeServerVersion] = useState(null) // Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch // wenn zwischendurch ein Push hereinkam. const rohprofilRef = useRef(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 {children} }