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,189 @@
|
||||
/* Rauchtest mit echtem Zugangstoken gegen die laufende HA-Testinstanz.
|
||||
Ergänzt scripts/smoke.ts um alles, was ohne Anmeldung nicht prüfbar ist:
|
||||
echte Lesevorgänge, ein echter Dienstaufruf und ein vollständiger Umlauf
|
||||
durch die Warteschlange.
|
||||
|
||||
Zugangsdaten kommen aus companion-app/.env.local (schreibt
|
||||
testumgebung/aufsetzen.sh). Ohne sie bricht der Test mit einem Hinweis ab,
|
||||
ohne zu scheitern - damit er in einer Umgebung ohne Testinstanz nicht
|
||||
fälschlich rot wird.
|
||||
|
||||
Aufruf: npm run smoke:auth */
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { dirname, resolve } from "node:path"
|
||||
|
||||
import { ENTITAETEN } from "../src/api/types.ts"
|
||||
import { HassRest } from "../src/api/rest.ts"
|
||||
import { Warteschlange } from "../src/api/warteschlange.ts"
|
||||
import { ablageSetzen, websocketUrl, type Ablage } from "../src/api/umgebung.ts"
|
||||
|
||||
/* ------------------------------------------------------ Zugangsdaten */
|
||||
|
||||
const hier = dirname(fileURLToPath(import.meta.url))
|
||||
const umgebungsdatei = resolve(hier, "../.env.local")
|
||||
|
||||
function umgebungLesen(): Record<string, string> {
|
||||
try {
|
||||
const inhalt = readFileSync(umgebungsdatei, "utf8")
|
||||
const werte: Record<string, string> = {}
|
||||
for (const zeile of inhalt.split("\n")) {
|
||||
const treffer = /^([A-Z_]+)=(.*)$/.exec(zeile.trim())
|
||||
if (treffer?.[1] && treffer[2] !== undefined) werte[treffer[1]] = treffer[2]
|
||||
}
|
||||
return werte
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const umgebung = umgebungLesen()
|
||||
const BASIS = umgebung["VITE_TEST_URL"] ?? "http://localhost:18123"
|
||||
const TOKEN = umgebung["VITE_TEST_TOKEN"] ?? process.env["VITE_TEST_TOKEN"] ?? ""
|
||||
|
||||
if (!TOKEN) {
|
||||
console.log("übersprungen: kein Token in companion-app/.env.local")
|
||||
console.log(" testumgebung/aufsetzen.sh ausführen, dann erneut versuchen.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Rahmen */
|
||||
|
||||
let fehler = 0
|
||||
function pruefe(bezeichnung: string, bedingung: boolean, zusatz = ""): void {
|
||||
console.log(`${bedingung ? "PASS" : "FAIL"} ${bezeichnung}${zusatz ? ` (${zusatz})` : ""}`)
|
||||
if (!bedingung) fehler += 1
|
||||
}
|
||||
|
||||
/** Ablage im Arbeitsspeicher — Node hat kein localStorage. */
|
||||
class SpeicherAblage implements Ablage {
|
||||
#werte = new Map<string, string>()
|
||||
async lesen(schluessel: string) {
|
||||
return this.#werte.get(schluessel) ?? null
|
||||
}
|
||||
async schreiben(schluessel: string, wert: string) {
|
||||
this.#werte.set(schluessel, wert)
|
||||
}
|
||||
async loeschen(schluessel: string) {
|
||||
this.#werte.delete(schluessel)
|
||||
}
|
||||
}
|
||||
ablageSetzen(new SpeicherAblage())
|
||||
|
||||
const zugang = { basisUrl: BASIS, token: TOKEN }
|
||||
const rest = new HassRest(zugang)
|
||||
|
||||
/* ------------------------------------------------------------- Lesen */
|
||||
|
||||
try {
|
||||
const antwort = await rest.verbindungPruefen()
|
||||
pruefe("verbindungPruefen mit Token", typeof antwort.message === "string", antwort.message)
|
||||
} catch (e) {
|
||||
pruefe("verbindungPruefen mit Token", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
try {
|
||||
const profil = await rest.datenLesen<Record<string, unknown>>(ENTITAETEN.profil)
|
||||
pruefe(
|
||||
"Profil liefert die erwarteten Abschnitte",
|
||||
Boolean(profil["fahrzeug"] && profil["einstellungen"] && profil["reifen"]),
|
||||
Object.keys(profil).join(", "),
|
||||
)
|
||||
} catch (e) {
|
||||
pruefe("Profil lesen", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await rest.datenLesen<Record<string, unknown>>(ENTITAETEN.fahrzeugstatus)
|
||||
/* Die Feldnamen sind der Grund für diesen Test: ein früherer Entwurf der
|
||||
Typen hatte tank_prozent/sicher_abgestellt/sicherheit angenommen, das
|
||||
Backend schreibt aber tankprozent/gesichert/sicherheitscheck. */
|
||||
pruefe(
|
||||
"Fahrzeugstatus benutzt die erwarteten Feldnamen",
|
||||
"tankprozent" in status && "gesichert" in status && "sicherheitscheck" in status,
|
||||
Object.keys(status).slice(0, 6).join(", "),
|
||||
)
|
||||
pruefe(
|
||||
"Sicherheitscheck liefert alle 16 Einzelprüfungen",
|
||||
Array.isArray(status["sicherheitscheck"]) && (status["sicherheitscheck"] as unknown[]).length === 16,
|
||||
`${(status["sicherheitscheck"] as unknown[] | undefined)?.length ?? 0} Punkte`,
|
||||
)
|
||||
} catch (e) {
|
||||
pruefe("Fahrzeugstatus lesen", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
for (const [name, id] of [
|
||||
["Fahrten", ENTITAETEN.fahrten],
|
||||
["Tankvorgänge", ENTITAETEN.tankvorgaenge],
|
||||
] as const) {
|
||||
try {
|
||||
const liste = await rest.datenLesen<unknown[]>(id)
|
||||
pruefe(`${name} sind eine Liste`, Array.isArray(liste), `${liste.length} Einträge`)
|
||||
} catch (e) {
|
||||
pruefe(`${name} lesen`, false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Dienstaufruf */
|
||||
|
||||
try {
|
||||
await rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren")
|
||||
pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", true)
|
||||
} catch (e) {
|
||||
pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Warteschlange */
|
||||
|
||||
try {
|
||||
const warteschlange = new Warteschlange(rest)
|
||||
await warteschlange.einreihen("pyscript", "audi_dashboard_jetzt_aktualisieren", {}, "Rauchtest")
|
||||
|
||||
/* einreihen() stößt die Abarbeitung selbst an, ohne sie abzuwarten - ein
|
||||
sofortiger eigener abarbeiten()-Aufruf liefe deshalb ins Leere (der Lauf
|
||||
ist schon aktiv). Also warten, bis die Schlange tatsächlich leer ist. */
|
||||
const frist = Date.now() + 10_000
|
||||
while (warteschlange.anzahl > 0 && Date.now() < frist) {
|
||||
await new Promise((weiter) => setTimeout(weiter, 100))
|
||||
}
|
||||
pruefe(
|
||||
"Warteschlange arbeitet einen Auftrag vollständig ab",
|
||||
warteschlange.anzahl === 0,
|
||||
`${warteschlange.anzahl} verblieben`,
|
||||
)
|
||||
} catch (e) {
|
||||
pruefe("Warteschlangen-Umlauf", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
/* ----------------------------------------- WebSocket mit Anmeldung */
|
||||
|
||||
try {
|
||||
const ergebnis = await new Promise<string>((aufloesen, ablehnen) => {
|
||||
const ws = new WebSocket(websocketUrl(BASIS))
|
||||
const frist = setTimeout(() => {
|
||||
ablehnen(new Error("Zeitüberschreitung"))
|
||||
ws.close()
|
||||
}, 10_000)
|
||||
ws.onmessage = (nachricht) => {
|
||||
const daten = JSON.parse(String(nachricht.data)) as { type: string }
|
||||
if (daten.type === "auth_required") {
|
||||
ws.send(JSON.stringify({ type: "auth", access_token: TOKEN }))
|
||||
} else if (daten.type === "auth_ok" || daten.type === "auth_invalid") {
|
||||
clearTimeout(frist)
|
||||
aufloesen(daten.type)
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
clearTimeout(frist)
|
||||
ablehnen(new Error("WebSocket-Fehler"))
|
||||
}
|
||||
})
|
||||
pruefe("WebSocket meldet sich mit dem Token an", ergebnis === "auth_ok", ergebnis)
|
||||
} catch (e) {
|
||||
pruefe("WebSocket-Anmeldung", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
console.log(fehler === 0 ? "\nAlle Prüfungen bestanden." : `\n${fehler} Prüfung(en) fehlgeschlagen.`)
|
||||
process.exitCode = fehler === 0 ? 0 : 1
|
||||
+62
-16
@@ -1,35 +1,81 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { DataMetricApi, zugangLesen } from "./api"
|
||||
import { DatenAnbieter, useDaten } from "./daten/DatenKontext"
|
||||
import { Shell } from "./Shell"
|
||||
import type { Route, SeitenName } from "./navigation"
|
||||
import { useTabBeschriftung, useTheme } from "./theme"
|
||||
import { Platzhalter } from "./screens/Platzhalter"
|
||||
import { Einrichtung } from "./screens/Einrichtung"
|
||||
import { Hinweisleiste } from "./screens/Hinweisleiste"
|
||||
import { SeiteFuer } from "./screens/register"
|
||||
|
||||
import "./stile/rahmen.css"
|
||||
import "./stile/screens.css"
|
||||
|
||||
type Startzustand = "pruefen" | "einrichten" | "bereit"
|
||||
|
||||
/**
|
||||
* Wurzel der App. Hält den Navigationszustand und das Theme; alles Weitere
|
||||
* hängt darunter. Das äußerste Element trägt `ads-root` und `data-theme` —
|
||||
* ohne diese Wurzelklasse greifen die Design-Tokens nicht
|
||||
* (design-system/.design-sync/conventions.md).
|
||||
* Wurzel der App. Entscheidet zuerst, ob Zugangsdaten vorliegen — ohne sie
|
||||
* gibt es nichts anzuzeigen außer der Ersteinrichtung. Danach hängt alles
|
||||
* unter dem Datenkontext.
|
||||
*/
|
||||
export function App() {
|
||||
const [route, setzeRoute] = useState<Route>({ name: "home" })
|
||||
const [theme] = useTheme()
|
||||
const [tabBeschriftung] = useTabBeschriftung()
|
||||
const [start, setzeStart] = useState<Startzustand>("pruefen")
|
||||
|
||||
const geheZu = useCallback((name: SeitenName, id?: string) => {
|
||||
setzeRoute(id === undefined ? { name } : { name, id })
|
||||
// Beim Seitenwechsel immer oben anfangen — sonst erbt die neue Seite die
|
||||
// Scrollposition der alten.
|
||||
document.querySelector(".dm-inhalt")?.scrollTo({ top: 0 })
|
||||
// Genau eine Instanz für die Lebensdauer der App: sie hält die
|
||||
// WebSocket-Verbindung und die Warteschlange.
|
||||
const api = useMemo(() => new DataMetricApi(), [])
|
||||
|
||||
useEffect(() => {
|
||||
void zugangLesen().then((zugang) => setzeStart(zugang ? "bereit" : "einrichten"))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="ads-root dm-wurzel" data-theme={theme}>
|
||||
<Shell route={route} geheZu={geheZu} tabBeschriftung={tabBeschriftung}>
|
||||
<Platzhalter route={route} geheZu={geheZu} />
|
||||
</Shell>
|
||||
{start === "pruefen" && <div className="dm-laden">Einen Moment …</div>}
|
||||
{start === "einrichten" && <Einrichtung fertig={() => setzeStart("bereit")} />}
|
||||
{start === "bereit" && (
|
||||
<DatenAnbieter api={api}>
|
||||
<AngemeldeteApp beiAbmeldung={() => setzeStart("einrichten")} />
|
||||
</DatenAnbieter>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
|
||||
const [route, setzeRoute] = useState<Route>({ name: "home" })
|
||||
const [tabBeschriftung, setzeTabBeschriftung] = useTabBeschriftung()
|
||||
const daten = useDaten()
|
||||
|
||||
const geheZu = useCallback((name: SeitenName, id?: string) => {
|
||||
setzeRoute(id === undefined ? { name } : { name, id })
|
||||
document.querySelector(".dm-inhalt")?.scrollTo({ top: 0 })
|
||||
}, [])
|
||||
|
||||
// Ein abgelaufener oder zurückgezogener Token führt zurück in die
|
||||
// Ersteinrichtung statt in eine endlose Fehlerschleife.
|
||||
useEffect(() => {
|
||||
if (daten.verbindung === "nicht_angemeldet") beiAbmeldung()
|
||||
}, [daten.verbindung, beiAbmeldung])
|
||||
|
||||
return (
|
||||
<Shell
|
||||
route={route}
|
||||
geheZu={geheZu}
|
||||
tabBeschriftung={tabBeschriftung}
|
||||
hinweis={
|
||||
<Hinweisleiste verbindung={daten.verbindung} warteschlange={daten.warteschlange} />
|
||||
}
|
||||
>
|
||||
<SeiteFuer
|
||||
route={route}
|
||||
geheZu={geheZu}
|
||||
tabBeschriftung={tabBeschriftung}
|
||||
setzeTabBeschriftung={setzeTabBeschriftung}
|
||||
beiAbmeldung={beiAbmeldung}
|
||||
/>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,13 +12,17 @@ import type { DatenEntity, HassState } from "./types.ts";
|
||||
import { zugangLesen, type Zugang } from "./umgebung.ts";
|
||||
|
||||
export class ApiFehler extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number | null,
|
||||
readonly ursache?: unknown,
|
||||
) {
|
||||
/* Eigene Felder statt Parameter-Eigenschaften: Node kann letztere im
|
||||
Strip-Modus (--experimental-strip-types) nicht übersetzen, und darüber
|
||||
laufen die Rauchtests dieses Pakets. */
|
||||
readonly status: number | null;
|
||||
readonly ursache: unknown;
|
||||
|
||||
constructor(message: string, status: number | null, ursache?: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiFehler";
|
||||
this.status = status;
|
||||
this.ursache = ursache;
|
||||
}
|
||||
|
||||
/** Fehlt oder taugt das Token nicht mehr - dann muss die Oberfläche zurück
|
||||
|
||||
@@ -94,12 +94,22 @@ export interface SicherheitsPunkt {
|
||||
ok: boolean | null;
|
||||
}
|
||||
|
||||
/** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation
|
||||
abgeleitet: frontend_veroeffentlichung.py schreibt "tankprozent",
|
||||
"gesichert" und "sicherheitscheck" — ein früherer Entwurf dieser Datei
|
||||
hatte "tank_prozent"/"sicher_abgestellt"/"sicherheit" angenommen, womit
|
||||
die Oberfläche still überall undefined gelesen hätte. */
|
||||
export interface Fahrzeugstatus {
|
||||
km?: number | null;
|
||||
tank_prozent?: number | null;
|
||||
tankprozent?: number | null;
|
||||
reichweite_km?: number | null;
|
||||
sicher_abgestellt?: boolean | null;
|
||||
sicherheit?: SicherheitsPunkt[];
|
||||
batteriespannung?: number | null;
|
||||
gesichert?: boolean | null;
|
||||
sicherheitscheck?: SicherheitsPunkt[];
|
||||
oelwechsel_faellig_ts?: string | null;
|
||||
oelwechsel_faellig_km?: number | null;
|
||||
inspektion_faellig_ts?: string | null;
|
||||
inspektion_faellig_km?: number | null;
|
||||
[weitere: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,14 @@ export class Warteschlange {
|
||||
#geladen = false;
|
||||
#laeuft = false;
|
||||
#horcher = new Set<WarteschlangenHorcher>();
|
||||
/* Bewusst als eigenes Feld statt als Parameter-Eigenschaft im Konstruktor:
|
||||
Node kann Parameter-Eigenschaften im Strip-Modus (--experimental-strip-types)
|
||||
nicht übersetzen, und genau darüber laufen die Rauchtests dieses Pakets. */
|
||||
readonly rest: HassRest;
|
||||
|
||||
constructor(private readonly rest: HassRest) {}
|
||||
constructor(rest: HassRest) {
|
||||
this.rest = rest;
|
||||
}
|
||||
|
||||
async laden(): Promise<void> {
|
||||
if (this.#geladen) return;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Zahlen, Geld, Datum und Dauer im deutschen Format. Aus dem alten Panel
|
||||
* übernommen (`de()`/`eur()`), damit App und Panel identisch formatieren.
|
||||
*/
|
||||
|
||||
export function de(wert: number | null | undefined, nachkomma = 0): string {
|
||||
return Number(wert ?? 0).toLocaleString("de-DE", {
|
||||
minimumFractionDigits: nachkomma,
|
||||
maximumFractionDigits: nachkomma,
|
||||
})
|
||||
}
|
||||
|
||||
export function eur(wert: number | null | undefined): string {
|
||||
return de(wert, 2)
|
||||
}
|
||||
|
||||
/** Wie de(), aber „—" statt „0" wenn nichts bekannt ist. */
|
||||
export function deOderStrich(wert: number | null | undefined, nachkomma = 0): string {
|
||||
return wert === null || wert === undefined ? "—" : de(wert, nachkomma)
|
||||
}
|
||||
|
||||
/** Summe über eine Liste. */
|
||||
export function summe<T>(liste: readonly T[], auswahl: (eintrag: T) => number): number {
|
||||
return liste.reduce((zwischenstand, eintrag) => zwischenstand + (auswahl(eintrag) || 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Durchschnittspreis je Liter — immer mengengewichtet (Summe Kosten geteilt
|
||||
* durch Summe Liter), nie als Mittel der Einzelpreise. Eine kleine Tankung zu
|
||||
* hohem Preis darf den Schnitt nicht so stark heben wie eine große.
|
||||
*/
|
||||
export function schnittpreis(
|
||||
liste: readonly { liters?: number | null; fuel_total_eur?: number | null }[],
|
||||
): number {
|
||||
const liter = summe(liste, (t) => t.liters ?? 0)
|
||||
if (liter <= 0) return 0
|
||||
return summe(liste, (t) => t.fuel_total_eur ?? 0) / liter
|
||||
}
|
||||
|
||||
const DATUM_KURZ = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
const DATUM_LANG = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
const UHRZEIT = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" })
|
||||
|
||||
const MONAT_JAHR = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" })
|
||||
|
||||
function alsDatum(wert: string | number | Date | null | undefined): Date | null {
|
||||
if (wert === null || wert === undefined || wert === "") return null
|
||||
const datum = wert instanceof Date ? wert : new Date(wert)
|
||||
return Number.isNaN(datum.getTime()) ? null : datum
|
||||
}
|
||||
|
||||
export function datum(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? DATUM_KURZ.format(d) : "—"
|
||||
}
|
||||
|
||||
export function datumLang(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? DATUM_LANG.format(d) : "—"
|
||||
}
|
||||
|
||||
export function uhrzeit(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? UHRZEIT.format(d) : "—"
|
||||
}
|
||||
|
||||
export function datumZeit(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : "—"
|
||||
}
|
||||
|
||||
export function monatJahr(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? MONAT_JAHR.format(d) : "—"
|
||||
}
|
||||
|
||||
/** Dauer in Sekunden als „2 h 14 min" bzw. „14 min". */
|
||||
export function dauer(sekunden: number | null | undefined): string {
|
||||
if (!sekunden || sekunden <= 0) return "—"
|
||||
const minutenGesamt = Math.round(sekunden / 60)
|
||||
const stunden = Math.floor(minutenGesamt / 60)
|
||||
const minuten = minutenGesamt % 60
|
||||
if (stunden === 0) return `${minuten} min`
|
||||
return `${stunden} h ${String(minuten).padStart(2, "0")} min`
|
||||
}
|
||||
|
||||
/** ISO-Tagesdatum (YYYY-MM-DD) für Vergleiche und Eingabefelder. */
|
||||
export function isoTag(wert: string | number | Date | null | undefined = new Date()): string {
|
||||
const d = alsDatum(wert)
|
||||
if (!d) return ""
|
||||
const versatz = d.getTimezoneOffset() * 60_000
|
||||
return new Date(d.getTime() - versatz).toISOString().slice(0, 10)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Ersteinrichtung: Die App holt ihre Daten vom eigenen Server, also braucht
|
||||
* sie einmalig dessen Adresse und ein Zugriffstoken.
|
||||
*
|
||||
* Entwurf: `design/DM360.dc.html`, Abschnitt `isSetup`. Fehlermeldungen sind
|
||||
* bewusst nach Ursache getrennt — „Token abgelehnt" und „Server nicht
|
||||
* erreichbar" verlangen völlig verschiedene Reaktionen des Benutzers.
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api"
|
||||
|
||||
export function Einrichtung({ fertig }: { fertig: () => void }) {
|
||||
const [adresse, setzeAdresse] = useState("")
|
||||
const [token, setzeToken] = useState("")
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
|
||||
const verbinden = async () => {
|
||||
setzeFehler(null)
|
||||
if (!adresse.trim()) {
|
||||
setzeFehler("Bitte die Adresse deines Servers eintragen.")
|
||||
return
|
||||
}
|
||||
if (!token.trim()) {
|
||||
setzeFehler("Bitte den Zugangs-Token einfügen.")
|
||||
return
|
||||
}
|
||||
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
const zugang = { basisUrl: basisUrlNormalisieren(adresse), token: token.trim() }
|
||||
// Erst prüfen, dann speichern — sonst merkt sich die App Zugangsdaten,
|
||||
// die nachweislich nicht funktionieren.
|
||||
await new HassRest(zugang).verbindungPruefen()
|
||||
await zugangSpeichern(zugang)
|
||||
fertig()
|
||||
} catch (ursache) {
|
||||
if (ursache instanceof ApiFehler && ursache.istAnmeldeproblem) {
|
||||
setzeFehler("Der Server hat den Token abgelehnt. Wurde er vollständig eingefügt?")
|
||||
} else if (ursache instanceof ApiFehler && ursache.istNetzproblem) {
|
||||
setzeFehler(
|
||||
"Der Server ist unter dieser Adresse nicht erreichbar. Stimmen Adresse und Port, und ist das Gerät im richtigen Netz?",
|
||||
)
|
||||
} else {
|
||||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||||
}
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dm-einrichtung">
|
||||
<div className="dm-einrichtung__karte">
|
||||
<span className="ads-eyebrow">DataMetric360</span>
|
||||
<h1 className="dm-einrichtung__titel">Willkommen</h1>
|
||||
<p className="dm-einrichtung__text">
|
||||
Die App holt ihre Daten von deinem eigenen Server. Trag einmal die Adresse ein und füge
|
||||
deinen Zugangs-Token ein. Danach läuft alles von selbst.
|
||||
</p>
|
||||
|
||||
<Tile>
|
||||
<Feld label="Server-Adresse">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="192.168.1.20:8123"
|
||||
value={adresse}
|
||||
onChange={(e) => setzeAdresse(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Zugangs-Token" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="password"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="einfügen"
|
||||
value={token}
|
||||
onChange={(e) => setzeToken(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
</Tile>
|
||||
|
||||
{fehler && (
|
||||
<p className="dm-einrichtung__fehler" role="alert">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="dm-einrichtung__knoepfe">
|
||||
<ActionButton onClick={() => void verbinden()} disabled={laeuft}>
|
||||
{laeuft ? "Verbinde …" : "Verbinden"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<p className="dm-einrichtung__hinweis">
|
||||
Den Token erzeugst du in Home Assistant unter „Profil → Sicherheit → Langlebige
|
||||
Zugriffstoken". Deine Daten bleiben auf deinem Server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Offline-Hinweis und Warteschlange über der Kopfzeile.
|
||||
*
|
||||
* Entscheidung aus COMPANION_APP_ARCHITECTURE.md §3: Ohne Verbindung bleiben
|
||||
* die zuletzt geladenen Daten sichtbar, sind aber erkennbar nicht aktuell —
|
||||
* nie ein leerer Bildschirm und nie eine stille Veraltung.
|
||||
*/
|
||||
|
||||
import type { Verbindungszustand, WartenderAuftrag } from "../api"
|
||||
import { SymbolWarnung } from "../symbole"
|
||||
|
||||
export function Hinweisleiste({
|
||||
verbindung,
|
||||
warteschlange,
|
||||
}: {
|
||||
verbindung: Verbindungszustand
|
||||
warteschlange: readonly WartenderAuftrag[]
|
||||
}) {
|
||||
const offline = verbindung !== "verbunden"
|
||||
const wartend = warteschlange.length
|
||||
|
||||
// "verbindet" gleich beim Start nicht als Offline melden — das würde bei
|
||||
// jedem App-Start kurz aufblitzen.
|
||||
if (!offline && wartend === 0) return null
|
||||
if (verbindung === "verbindet" && wartend === 0) return null
|
||||
|
||||
const gescheitert = warteschlange.filter((a) => a.fehlversuche > 0).length
|
||||
|
||||
return (
|
||||
<div className={offline ? "dm-hinweis dm-hinweis--offline" : "dm-hinweis"} role="status">
|
||||
<SymbolWarnung groesse={16} />
|
||||
<span>
|
||||
{offline
|
||||
? verbindung === "nicht_angemeldet"
|
||||
? "Nicht angemeldet — bitte Zugang prüfen."
|
||||
: "Offline — angezeigte Daten sind der letzte bekannte Stand."
|
||||
: null}
|
||||
{offline && wartend > 0 ? " " : null}
|
||||
{wartend > 0
|
||||
? wartend === 1
|
||||
? "Eine Änderung wartet auf Übertragung."
|
||||
: `${wartend} Änderungen warten auf Übertragung.`
|
||||
: null}
|
||||
{gescheitert > 0 ? ` ${gescheitert} davon mit Fehlversuchen.` : null}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Zuordnung Route → Seite. Eine Stelle, an der jede Seite eingetragen ist;
|
||||
* die Shell weiß nichts über Inhalte, die Seiten nichts über Navigation.
|
||||
*/
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import type { Route, SeitenName } from "../navigation"
|
||||
import { Platzhalter } from "./Platzhalter"
|
||||
|
||||
export interface SeitenProps {
|
||||
route: Route
|
||||
geheZu: (name: SeitenName, id?: string) => void
|
||||
tabBeschriftung: boolean
|
||||
setzeTabBeschriftung: (an: boolean) => void
|
||||
beiAbmeldung: () => void
|
||||
}
|
||||
|
||||
export function SeiteFuer(props: SeitenProps) {
|
||||
const daten = useDaten()
|
||||
|
||||
// Erste Ladung: nichts anzeigen außer einem Hinweis. Ein leeres Gerüst
|
||||
// wäre schlimmer als ein ehrliches „lädt".
|
||||
if (!daten.bereit) {
|
||||
if (daten.ladefehler) {
|
||||
return (
|
||||
<div className="dm-leer">
|
||||
<div className="dm-leer__titel">Daten nicht erreichbar</div>
|
||||
<p className="dm-leer__text">{daten.ladefehler}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="dm-laden">Daten werden geladen …</div>
|
||||
}
|
||||
|
||||
return <Platzhalter route={props.route} geheZu={props.geheZu} />
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Screen-übergreifende Bausteine: Eingabefelder, Hinweisleiste,
|
||||
* Ersteinrichtung, Leerzustände, Ladeanzeige.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------- Eingabefelder */
|
||||
|
||||
.dm-eingabe,
|
||||
.dm-auswahl {
|
||||
font: inherit;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
color: var(--fg);
|
||||
background: none;
|
||||
border: 0;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
min-width: 120px;
|
||||
max-width: 60%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dm-eingabe::placeholder {
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
.dm-auswahl {
|
||||
text-align: right;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-auswahl option {
|
||||
color: var(--fg);
|
||||
background: var(--tile);
|
||||
}
|
||||
|
||||
.dm-notiz {
|
||||
font: inherit;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
color: var(--fg);
|
||||
background: var(--tile-2);
|
||||
border: 0;
|
||||
border-radius: var(--r-func);
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Hinweisleiste */
|
||||
|
||||
.dm-hinweis {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 9px var(--sp-5);
|
||||
padding-top: calc(env(safe-area-inset-top, 0px) + 9px);
|
||||
font-size: 12.5px;
|
||||
font-weight: 300;
|
||||
color: var(--fg);
|
||||
background: var(--tile-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dm-hinweis--offline {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.dm-hinweis svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Ersteinrichtung */
|
||||
|
||||
.dm-einrichtung {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dm-einrichtung__karte {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.dm-einrichtung__titel {
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 10px 0 12px;
|
||||
}
|
||||
|
||||
.dm-einrichtung__text {
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.55;
|
||||
color: var(--fg2);
|
||||
margin: 0 0 22px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dm-einrichtung__fehler {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--bad);
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.dm-einrichtung__knoepfe {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.dm-einrichtung__hinweis {
|
||||
font-size: 11.5px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--fg3);
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Leer- und Ladezustände */
|
||||
|
||||
.dm-leer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 44px 24px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-leer__titel {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-leer__text {
|
||||
font-size: 13.5px;
|
||||
font-weight: 300;
|
||||
line-height: 1.55;
|
||||
max-width: 34ch;
|
||||
margin: 0;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dm-laden {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 44px 24px;
|
||||
color: var(--fg2);
|
||||
font-size: 13.5px;
|
||||
font-weight: 300;
|
||||
}
|
||||
Reference in New Issue
Block a user