Phase 6: Datenanbindung, Ersteinrichtung und Offline-Anzeige
Die App spricht jetzt echt mit Home Assistant. Ein Datenkontext buendelt die vier Datentoepfe, haengt sich an den WebSocket-Ereignisstrom und reicht Verbindungszustand und Warteschlange nach aussen; die Screens kennen weder REST noch WebSocket. Ersteinrichtung prueft die Zugangsdaten, bevor sie sie speichert, und unterscheidet in der Fehlermeldung zwischen abgelehntem Token und nicht erreichbarem Server - zwei Faelle, die voellig verschiedene Reaktionen verlangen. Zwei echte Fehler, die erst der Test gegen die laufende Instanz zutage brachte: 1. Die Typen der Datenschicht deklarierten tank_prozent, sicher_abgestellt und sicherheit. Das Backend schreibt tankprozent, gesichert und sicherheitscheck - die Oberflaeche haette still ueberall undefined gelesen, ohne dass irgendetwas fehlgeschlagen waere. 2. Der Profil-Adapter reichte lebende Verweise ins Rohprofil durch. Ein Formular haette damit den Rohstand mitveraendert und die Zusage gebrochen, den vom Backend fortgeschriebenen Reifen-Kilometerstand nie zu ueberschreiben. Abschnitte werden jetzt kopiert. Ausserdem zwei Parameter-Eigenschaften in der Datenschicht aufgeloest, weil Node sie im Strip-Modus nicht uebersetzt und genau darueber die Rauchtests laufen. Belegt durch neun Pruefungen gegen die laufende Instanz (Lesen, Dienstaufruf, vollstaendiger Warteschlangenumlauf, WebSocket-Anmeldung) und 21 Unit-Tests.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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 { 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
|
||||
|
||||
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[]>([])
|
||||
|
||||
// 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)
|
||||
} 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,
|
||||
einstellungen,
|
||||
fahrzeug,
|
||||
fahrten,
|
||||
tankvorgaenge,
|
||||
rohprofil,
|
||||
api,
|
||||
neuLaden,
|
||||
jetztAktualisieren,
|
||||
profilSpeichern,
|
||||
}
|
||||
|
||||
return <Kontext.Provider value={wert}>{children}</Kontext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import type { Profil } from "../api"
|
||||
import { isoTag } from "../format"
|
||||
import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter"
|
||||
|
||||
function tagVersetzt(tage: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + tage)
|
||||
return isoTag(d)
|
||||
}
|
||||
|
||||
function beispielprofil(aenderung: Partial<Profil> = {}): Profil {
|
||||
return {
|
||||
fahrzeug: {
|
||||
fin: "WUAZZZF48PA902804",
|
||||
titel: "RS 4 Avant",
|
||||
zusatz: "competition",
|
||||
untertitel: "quattro · 331 kW",
|
||||
kennzeichen: "M AB 1234",
|
||||
tankvolumen_liter: 58,
|
||||
wlan_name: "Audi_MMI",
|
||||
},
|
||||
einstellungen: {
|
||||
uebersichtsbild: "seitenansicht.webp",
|
||||
fahrten_pausenzeit_min: 15,
|
||||
smartdeal: { aktiv: true, laeuft_ab: null },
|
||||
oelwechsel_intervall: { modus: "hersteller", hersteller_km: 30000, hersteller_monate: 24 },
|
||||
},
|
||||
reifen: {
|
||||
aktiv: "sommer",
|
||||
anzugsmoment_nm: 140,
|
||||
saetze: { sommer: { km: 12000, marke: "Michelin" }, winter: { km: 8000 } },
|
||||
},
|
||||
steuer: { betrag_eur: 320 },
|
||||
...aenderung,
|
||||
}
|
||||
}
|
||||
|
||||
describe("profilZuEinstellungen", () => {
|
||||
it("liest die Fahrzeug- und Einstellungsfelder", () => {
|
||||
const e = profilZuEinstellungen(beispielprofil())
|
||||
expect(e.fahrzeugtitel).toBe("RS 4 Avant")
|
||||
expect(e.kennzeichen).toBe("M AB 1234")
|
||||
expect(e.tankvolumen).toBe(58)
|
||||
expect(e.pauseMin).toBe(15)
|
||||
})
|
||||
|
||||
it("fällt auf sinnvolle Werte zurück, wenn Abschnitte fehlen", () => {
|
||||
const e = profilZuEinstellungen({})
|
||||
expect(e.tankvolumen).toBe(58)
|
||||
expect(e.pauseMin).toBe(15)
|
||||
expect(e.backupIntervall).toBe("aus")
|
||||
})
|
||||
})
|
||||
|
||||
describe("SmartDeal-Gültigkeit", () => {
|
||||
it("gilt ohne Ablaufdatum, solange der Schalter an ist", () => {
|
||||
const f = profilZuFahrzeug(beispielprofil(), {})
|
||||
expect(f.smartdeal.aktiv).toBe(true)
|
||||
})
|
||||
|
||||
it("gilt am Ablauftag noch — das Datum ist einschließlich", () => {
|
||||
const profil = beispielprofil()
|
||||
;(profil.einstellungen as Record<string, unknown>)["smartdeal"] = {
|
||||
aktiv: true,
|
||||
laeuft_ab: isoTag(),
|
||||
}
|
||||
expect(profilZuFahrzeug(profil, {}).smartdeal.aktiv).toBe(true)
|
||||
})
|
||||
|
||||
it("gilt am Folgetag nicht mehr", () => {
|
||||
const profil = beispielprofil()
|
||||
;(profil.einstellungen as Record<string, unknown>)["smartdeal"] = {
|
||||
aktiv: true,
|
||||
laeuft_ab: tagVersetzt(-1),
|
||||
}
|
||||
expect(profilZuFahrzeug(profil, {}).smartdeal.aktiv).toBe(false)
|
||||
})
|
||||
|
||||
it("bleibt aus, wenn der Schalter aus ist — auch bei gültigem Datum", () => {
|
||||
const profil = beispielprofil()
|
||||
;(profil.einstellungen as Record<string, unknown>)["smartdeal"] = {
|
||||
aktiv: false,
|
||||
laeuft_ab: tagVersetzt(30),
|
||||
}
|
||||
expect(profilZuFahrzeug(profil, {}).smartdeal.aktiv).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("profilZuFahrzeug mit Fahrzeugstatus", () => {
|
||||
it("übernimmt die Statuswerte unter den Namen des Backends", () => {
|
||||
const f = profilZuFahrzeug(beispielprofil(), {
|
||||
km: 48250,
|
||||
tankprozent: 62,
|
||||
reichweite_km: 385,
|
||||
gesichert: true,
|
||||
sicherheitscheck: [{ label: "Tür vorne links", ok: true }],
|
||||
})
|
||||
expect(f.odo).toBe(48250)
|
||||
expect(f.odoBekannt).toBe(true)
|
||||
expect(f.tankPct).toBe(62)
|
||||
expect(f.reichweite).toBe(385)
|
||||
expect(f.gesichert).toBe(true)
|
||||
expect(f.sicherheitscheck).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("unterscheidet unbekannten Kilometerstand von null", () => {
|
||||
const f = profilZuFahrzeug(beispielprofil(), {})
|
||||
expect(f.odo).toBe(0)
|
||||
expect(f.odoBekannt).toBe(false)
|
||||
})
|
||||
|
||||
it("übersetzt den aktiven Reifensatz in die Anzeigeform", () => {
|
||||
const profil = beispielprofil()
|
||||
;(profil.reifen as Record<string, unknown>)["aktiv"] = "winter"
|
||||
expect(profilZuFahrzeug(profil, {}).reifen.aktiv).toBe("Winter")
|
||||
})
|
||||
})
|
||||
|
||||
describe("zusammenfuehren", () => {
|
||||
it("schreibt geänderte Einstellungen zurück, ohne Unbekanntes zu verlieren", () => {
|
||||
const roh = beispielprofil({ technik: { hubraum: "2.9 V6" } })
|
||||
const einstellungen = profilZuEinstellungen(roh)
|
||||
const fahrzeug = profilZuFahrzeug(roh, {})
|
||||
einstellungen.kennzeichen = "M XY 9999"
|
||||
|
||||
const neu = zusammenfuehren(roh, einstellungen, fahrzeug)
|
||||
expect((neu.fahrzeug as Record<string, unknown>)["kennzeichen"]).toBe("M XY 9999")
|
||||
// Abschnitte, die die Oberfläche nicht anfasst, bleiben unverändert.
|
||||
expect(neu.technik).toEqual({ hubraum: "2.9 V6" })
|
||||
})
|
||||
|
||||
it("schreibt den Reifen-Kilometerstand nie mit", () => {
|
||||
// Das Backend (reifenzaehler.py) schreibt km bei jeder Änderung des
|
||||
// Kilometerstands fort. Ein im Browser gehaltener älterer Stand darf das
|
||||
// nicht überschreiben.
|
||||
const roh = beispielprofil()
|
||||
const fahrzeug = profilZuFahrzeug(roh, {})
|
||||
fahrzeug.reifen.sommer.km = 1
|
||||
fahrzeug.reifen.sommer.marke = "Continental"
|
||||
|
||||
const neu = zusammenfuehren(roh, profilZuEinstellungen(roh), fahrzeug)
|
||||
const saetze = (neu.reifen as Record<string, unknown>)["saetze"] as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
expect(saetze["sommer"]?.["km"]).toBe(12000)
|
||||
expect(saetze["sommer"]?.["marke"]).toBe("Continental")
|
||||
})
|
||||
|
||||
it("macht aus einem leeren Steuerdatum null statt einer leeren Zeichenkette", () => {
|
||||
const roh = beispielprofil()
|
||||
const fahrzeug = profilZuFahrzeug(roh, {})
|
||||
const neu = zusammenfuehren(roh, profilZuEinstellungen(roh), fahrzeug)
|
||||
expect((neu.steuer as Record<string, unknown>)["faellig"]).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Übersetzt das Fahrzeugprofil des Backends in die Form, mit der die
|
||||
* Oberfläche arbeitet — und wieder zurück.
|
||||
*
|
||||
* Aus dem alten Panel portiert (`profilZuConfig`/`profilZuCar`/
|
||||
* `configCarZuProfil`). Die Rechenregeln sind unverändert übernommen; nur die
|
||||
* Typen sind neu, weil das Panel ohne auskam.
|
||||
*/
|
||||
|
||||
import type { Fahrzeugstatus, Profil, SicherheitsPunkt } from "../api"
|
||||
import { isoTag } from "../format"
|
||||
|
||||
export interface Einstellungen {
|
||||
fahrzeugtitel: string
|
||||
fahrzeugzusatz: string
|
||||
fahrzeuguntertitel: string
|
||||
kennzeichen: string
|
||||
startbild: string
|
||||
tankvolumen: number
|
||||
nachtVon: string
|
||||
nachtBis: string
|
||||
pauseMin: number
|
||||
markenname: string
|
||||
wlanName: string
|
||||
backupIntervall: string
|
||||
letztesBackup: string | null
|
||||
ausfuehrung: string
|
||||
}
|
||||
|
||||
export interface Reifensatz {
|
||||
km?: number | null
|
||||
groesse?: string | null
|
||||
marke?: string | null
|
||||
modell?: string | null
|
||||
dot?: string | null
|
||||
profiltiefe_mm?: number | null
|
||||
[weitere: string]: unknown
|
||||
}
|
||||
|
||||
export interface Fahrzeug {
|
||||
fin: string
|
||||
details: string
|
||||
erstzulassung: string
|
||||
hu: string
|
||||
odo: number
|
||||
odoBekannt: boolean
|
||||
tankPct: number
|
||||
reichweite: number | null
|
||||
batteriespannung: number | null
|
||||
gesichert: boolean | null
|
||||
sicherheitscheck: SicherheitsPunkt[]
|
||||
oelwechselFaelligTs: string | null
|
||||
oelwechselFaelligKm: number | null
|
||||
inspektionFaelligTs: string | null
|
||||
inspektionFaelligKm: number | null
|
||||
smartdeal: { aktiv: boolean; laeuftAb: string | null }
|
||||
oel: {
|
||||
modus: string
|
||||
km: number | null
|
||||
monate: number | null
|
||||
herstellerKm: number | null
|
||||
herstellerMonate: number | null
|
||||
}
|
||||
versicherung: Record<string, unknown>
|
||||
steuer: Record<string, unknown> & { faellig: string }
|
||||
reifen: {
|
||||
aktiv: "Sommer" | "Winter"
|
||||
anzugsmoment: number | null
|
||||
wechsel: Record<string, unknown>
|
||||
sommer: Reifensatz
|
||||
winter: Reifensatz
|
||||
}
|
||||
service: Record<string, unknown>
|
||||
technik: Record<string, unknown>
|
||||
ausstattung: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest einen Abschnitt des Profils **als Kopie**.
|
||||
*
|
||||
* Die Kopie ist der Punkt: Ohne sie bekämen die Screens lebende Verweise ins
|
||||
* Rohprofil, und eine Bearbeitung im Formular würde den Rohstand still
|
||||
* mitverändern. Damit wäre er als Vergleichsbasis wertlos — und die Zusage,
|
||||
* den vom Backend fortgeschriebenen Reifen-Kilometerstand nie zu
|
||||
* überschreiben, ließe sich nicht mehr halten.
|
||||
*/
|
||||
function abschnitt(profil: Profil, name: string): Record<string, unknown> {
|
||||
const wert = profil[name]
|
||||
if (!wert || typeof wert !== "object") return {}
|
||||
return structuredClone(wert) as Record<string, unknown>
|
||||
}
|
||||
|
||||
function text(quelle: Record<string, unknown>, feld: string, ersatz = ""): string {
|
||||
const wert = quelle[feld]
|
||||
return typeof wert === "string" ? wert : ersatz
|
||||
}
|
||||
|
||||
function zahl(quelle: Record<string, unknown>, feld: string): number | null {
|
||||
const wert = quelle[feld]
|
||||
return typeof wert === "number" ? wert : null
|
||||
}
|
||||
|
||||
export function profilZuEinstellungen(profil: Profil): Einstellungen {
|
||||
const fahrzeug = abschnitt(profil, "fahrzeug")
|
||||
const einst = abschnitt(profil, "einstellungen")
|
||||
return {
|
||||
fahrzeugtitel: text(fahrzeug, "titel"),
|
||||
fahrzeugzusatz: text(fahrzeug, "zusatz"),
|
||||
fahrzeuguntertitel: text(fahrzeug, "untertitel"),
|
||||
kennzeichen: text(fahrzeug, "kennzeichen"),
|
||||
startbild: text(einst, "uebersichtsbild", "seitenansicht.webp"),
|
||||
tankvolumen: zahl(fahrzeug, "tankvolumen_liter") ?? 58,
|
||||
nachtVon: text(einst, "nacht_von", "22:00"),
|
||||
nachtBis: text(einst, "nacht_bis", "06:00"),
|
||||
pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15,
|
||||
markenname: text(einst, "kraftstoffanbieter", "Shell"),
|
||||
wlanName: text(fahrzeug, "wlan_name"),
|
||||
backupIntervall: text(einst, "backup_intervall", "aus"),
|
||||
letztesBackup: text(einst, "letztes_backup") || null,
|
||||
ausfuehrung: text(fahrzeug, "ausfuehrung"),
|
||||
}
|
||||
}
|
||||
|
||||
export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrzeug {
|
||||
const fahrzeug = abschnitt(profil, "fahrzeug")
|
||||
const einst = abschnitt(profil, "einstellungen")
|
||||
// Bereits eine Kopie (siehe abschnitt()), darf also gefahrlos weitergereicht
|
||||
// und von den Formularen bearbeitet werden.
|
||||
const reifen = abschnitt(profil, "reifen")
|
||||
const saetze = (reifen["saetze"] ?? {}) as Record<string, Reifensatz>
|
||||
const smartdeal = (einst["smartdeal"] ?? {}) as Record<string, unknown>
|
||||
const oel = (einst["oelwechsel_intervall"] ?? {}) as Record<string, unknown>
|
||||
const steuer = abschnitt(profil, "steuer")
|
||||
|
||||
const laeuftAb = typeof smartdeal["laeuft_ab"] === "string" ? (smartdeal["laeuft_ab"] as string) : null
|
||||
|
||||
return {
|
||||
fin: text(fahrzeug, "fin"),
|
||||
details: text(fahrzeug, "details"),
|
||||
erstzulassung: text(fahrzeug, "erstzulassung"),
|
||||
hu: text(fahrzeug, "hauptuntersuchung_faellig"),
|
||||
odo: status.km ?? 0,
|
||||
odoBekannt: status.km !== null && status.km !== undefined,
|
||||
tankPct: status.tankprozent ?? 0,
|
||||
reichweite: status.reichweite_km ?? null,
|
||||
batteriespannung: status.batteriespannung ?? null,
|
||||
gesichert: status.gesichert ?? null,
|
||||
sicherheitscheck: status.sicherheitscheck ?? [],
|
||||
oelwechselFaelligTs: status.oelwechsel_faellig_ts ?? null,
|
||||
oelwechselFaelligKm: status.oelwechsel_faellig_km ?? null,
|
||||
inspektionFaelligTs: status.inspektion_faellig_ts ?? null,
|
||||
inspektionFaelligKm: status.inspektion_faellig_km ?? null,
|
||||
// „Gültig bis" ist einschließlich: der Rabatt gilt noch am Stichtag selbst,
|
||||
// erst am Folgetag schaltet er ab. Unverändert aus dem alten Panel.
|
||||
smartdeal: {
|
||||
aktiv: smartdeal["aktiv"] === true && (!laeuftAb || laeuftAb >= isoTag()),
|
||||
laeuftAb,
|
||||
},
|
||||
oel: {
|
||||
modus: text(oel, "modus", "hersteller"),
|
||||
km: zahl(oel, "km"),
|
||||
monate: zahl(oel, "monate"),
|
||||
herstellerKm: zahl(oel, "hersteller_km"),
|
||||
herstellerMonate: zahl(oel, "hersteller_monate"),
|
||||
},
|
||||
versicherung: abschnitt(profil, "versicherung"),
|
||||
steuer: { ...steuer, faellig: text(steuer, "faellig") },
|
||||
reifen: {
|
||||
aktiv: reifen["aktiv"] === "winter" ? "Winter" : "Sommer",
|
||||
anzugsmoment: zahl(reifen, "anzugsmoment_nm"),
|
||||
wechsel: (reifen["wechsel"] ?? {}) as Record<string, unknown>,
|
||||
sommer: saetze["sommer"] ?? {},
|
||||
winter: saetze["winter"] ?? {},
|
||||
},
|
||||
service: abschnitt(profil, "service"),
|
||||
technik: abschnitt(profil, "technik"),
|
||||
// Als Einzige eine Liste statt eines Objekts, deshalb eigens kopiert.
|
||||
ausstattung: profil["ausstattung"] ? structuredClone(profil["ausstattung"]) : [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rückrichtung für `audi_dashboard_profil_schreiben`. Das Backend kennt keinen
|
||||
* Dienst für einzelne Felder — es wird immer das ganze Profil geschrieben,
|
||||
* deshalb wird der zuletzt gelesene Rohstand kopiert und nur das Geänderte
|
||||
* überschrieben.
|
||||
*/
|
||||
export function zusammenfuehren(
|
||||
rohprofil: Profil,
|
||||
einstellungen: Einstellungen,
|
||||
fahrzeug: Fahrzeug,
|
||||
): Profil {
|
||||
const p = structuredClone(rohprofil) as Profil
|
||||
const f = (p["fahrzeug"] ??= {}) as Record<string, unknown>
|
||||
const e = (p["einstellungen"] ??= {}) as Record<string, unknown>
|
||||
const r = (p["reifen"] ??= {}) as Record<string, unknown>
|
||||
|
||||
f["titel"] = einstellungen.fahrzeugtitel
|
||||
f["zusatz"] = einstellungen.fahrzeugzusatz
|
||||
f["untertitel"] = einstellungen.fahrzeuguntertitel
|
||||
f["kennzeichen"] = einstellungen.kennzeichen
|
||||
f["wlan_name"] = einstellungen.wlanName
|
||||
f["ausfuehrung"] = einstellungen.ausfuehrung
|
||||
f["tankvolumen_liter"] = einstellungen.tankvolumen
|
||||
f["fin"] = fahrzeug.fin
|
||||
f["erstzulassung"] = fahrzeug.erstzulassung
|
||||
f["hauptuntersuchung_faellig"] = fahrzeug.hu
|
||||
|
||||
e["uebersichtsbild"] = einstellungen.startbild
|
||||
e["fahrten_pausenzeit_min"] = einstellungen.pauseMin
|
||||
e["backup_intervall"] = einstellungen.backupIntervall
|
||||
e["nacht_von"] = einstellungen.nachtVon
|
||||
e["nacht_bis"] = einstellungen.nachtBis
|
||||
e["smartdeal"] = { aktiv: fahrzeug.smartdeal.aktiv, laeuft_ab: fahrzeug.smartdeal.laeuftAb }
|
||||
e["oelwechsel_intervall"] = {
|
||||
modus: fahrzeug.oel.modus,
|
||||
km: fahrzeug.oel.km,
|
||||
monate: fahrzeug.oel.monate,
|
||||
hersteller_km: fahrzeug.oel.herstellerKm,
|
||||
hersteller_monate: fahrzeug.oel.herstellerMonate,
|
||||
}
|
||||
|
||||
p["versicherung"] = fahrzeug.versicherung
|
||||
p["steuer"] = { ...fahrzeug.steuer, faellig: fahrzeug.steuer.faellig || null }
|
||||
p["service"] = fahrzeug.service
|
||||
|
||||
r["aktiv"] = fahrzeug.reifen.aktiv.toLowerCase()
|
||||
r["anzugsmoment_nm"] = fahrzeug.reifen.anzugsmoment
|
||||
r["wechsel"] = fahrzeug.reifen.wechsel
|
||||
const saetze = (r["saetze"] ??= {}) as Record<string, Reifensatz>
|
||||
for (const name of ["sommer", "winter"] as const) {
|
||||
// km bewusst NICHT mitschreiben: das Backend (reifenzaehler.py) schreibt
|
||||
// den Wert bei jeder Kilometerstandsänderung fort. Ein im Browser
|
||||
// gehaltener älterer Stand würde eine zwischenzeitliche Fortschreibung
|
||||
// überschreiben.
|
||||
const { km: _km, ...rest } = fahrzeug.reifen[name]
|
||||
saetze[name] = { ...(saetze[name] ?? {}), ...rest }
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
Reference in New Issue
Block a user