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,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} />
|
||||
}
|
||||
Reference in New Issue
Block a user