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:
Paul Nothaft
2026-08-11 11:02:44 +02:00
parent 5c0c2d1898
commit 0103508cea
13 changed files with 1358 additions and 25 deletions
+112
View File
@@ -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>
)
}
+36
View File
@@ -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} />
}