Files
tobias d8b12da36d pyscript-Backend zur echten HA-Integration umgebaut (HACS-fähig)
Das Backend liegt jetzt als custom_components/audi_dashboard/ vor - eine
normale Home-Assistant-Integration mit Config-Flow, einer sensor-Plattform
und 18 Diensten. Damit ist die App über HACS installierbar; bis das Repo auf
GitHub gespiegelt ist (HACS spricht ausschließlich mit GitHub), installiert
homeassistant/installationspaket/install.ps1 denselben Ordner ohne HACS.

Fünf Installationsschritte entfallen ersatzlos: der pyscript:-Block, der
panel_custom:-Block, das Kopieren der Oberfläche nach www/, das langlebige
Zugriffstoken (der Verlauf wird direkt über die recorder-API gelesen) und
"pip install pypdf" (steht in manifest.json). Das Fahrzeugprofil legt die
Integration beim ersten Start aus ihrer Vorlage an.

Drei alte Schwächen sind dabei mit erledigt:
- Die Nutzlast landet nicht mehr in der Recorder-Datenbank
  (_unrecorded_attributes - das kann nur eine echte Entität).
- Eine laufende Fahrt überlebt einen Neustart (Store statt Arbeitsspeicher);
  fiel sie während eines Ausfalls ins Ende, schließt
  nach_neustart_fortsetzen() sie beim letzten aufgezeichneten Zeitpunkt.
- Sensor-Zuordnungen wirken sofort - die Zustandsbeobachter werden neu
  gebunden, der Neustart-Hinweis und der Neustart-Dienst sind weg.

Namensvertrag geändert, beide Oberflächen mitgezogen:
pyscript.audi_dashboard_x -> sensor.audi_dashboard_x,
pyscript.audi_dashboard_y -> audi_dashboard.y. Eine Companion-App vom alten
Stand findet nach dem Umstieg nichts mehr und muss neu gebaut werden; das
Panel liegt in der Integration und kann nicht driften.

Der selbstgebaute Updater entfällt - HACS ist die Update-Mechanik, die Home
Assistant kennt. Die Versionierung schrumpft auf eine Quelle: manifest.json.

Geprüft am laufenden Testcontainer (Container byteweise identisch mit dem
Repo): alle 18 Dienste, Panel, Config-Entry neu laden, Historienimport,
echter Shell-Beleg in-process, Neuinstallation im Wegwerf-Container blank mit
automatisch nachinstalliertem pypdf. Companion-App: tsc sauber, 112/112
Tests, beide Rauchtests gegen das laufende Backend grün. Belegparser 8/8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:53:56 +02:00

199 lines
7.5 KiB
TypeScript

/* 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 { DIENST_DOMAIN, 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(", "),
)
/* Wie viele Punkte es sind, hängt an der Sensor-Zuordnung der Instanz: vier
Türen, vier Fenster, Heckklappe, Motorhaube — unbelegte Rollen liefern gar
keinen Eintrag. Eine feste Zahl zu erwarten hieße, die Zuordnung der
Testinstanz zu prüfen statt das Backend; genau daran scheiterte diese
Prüfung seit dem Entfernen der Türschloss-Erkennung (16 → 10), ohne dass
irgendetwas kaputt gewesen wäre. Geprüft wird deshalb die vereinbarte
Form: label ist Text, ok ist true/false/null (null = Sensor unbekannt). */
const punkte = (status["sicherheitscheck"] ?? []) as { label?: unknown; ok?: unknown }[]
pruefe(
"Sicherheitscheck liefert Punkte in der vereinbarten Form",
Array.isArray(punkte) &&
punkte.every((p) => typeof p.label === "string" && (p.ok === null || typeof p.ok === "boolean")),
`${punkte.length} 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(DIENST_DOMAIN, "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(DIENST_DOMAIN, "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