From 982bb04c1a258e26b3a49cd690fc15896a42c293 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 10:44:50 +0200 Subject: [PATCH] Phase 1: reproduzierbare HA-Testumgebung als Skript Die urspruengliche Wegwerf-Testinstanz war verloren, weil sie nur als Anleitung existierte. testumgebung/aufsetzen.sh baut sie jetzt vollstaendig neu auf: Container, Nutzer, langlebiges Token, pyscript als Custom Component, Projektdateien und nachgebildete Fahrzeugsensoren als Template-Entitaeten, die ueber Helfer zur Laufzeit steuerbar sind. Verifiziert: pyscript veroeffentlicht alle acht Entitaeten, Fahrzeugsensoren liefern Werte, beide Smoke-Tests gruen. --- UMSETZUNGSPLAN.md | 7 +- design-system/package-lock.json | 47 +--------- testumgebung/README.md | 67 ++++++++++++++ testumgebung/aufsetzen.sh | 84 +++++++++++++++++ testumgebung/einrichten.mjs | 102 ++++++++++++++++++++ testumgebung/konfiguration.yaml | 159 ++++++++++++++++++++++++++++++++ 6 files changed, 423 insertions(+), 43 deletions(-) create mode 100644 testumgebung/README.md create mode 100755 testumgebung/aufsetzen.sh create mode 100644 testumgebung/einrichten.mjs create mode 100644 testumgebung/konfiguration.yaml diff --git a/UMSETZUNGSPLAN.md b/UMSETZUNGSPLAN.md index 1050526..4cc674f 100644 --- a/UMSETZUNGSPLAN.md +++ b/UMSETZUNGSPLAN.md @@ -62,7 +62,12 @@ einzige, die zwingend auf Hardware wartet. --- -## Phase 1 — Arbeitsumgebung herstellen und verifizieren +## Phase 1 — Arbeitsumgebung herstellen und verifizieren ✅ ERLEDIGT (2026-08-11) + +> Ergebnis: design-system baut und besteht 21/21, companion-app typecheckt und besteht 7/7. +> Die verlorene Testinstanz wurde als reproduzierbares Skript neu aufgebaut — siehe +> `testumgebung/` (Abweichung vom Plan, bewusst: das Original ging genau deshalb verloren, +> weil es nur als Anleitung existierte). **Ziel:** Beide npm-Pakete bauen, beide Smoke-Tests laufen, die HA-Testinstanz ist erreichbar. diff --git a/design-system/package-lock.json b/design-system/package-lock.json index b3f1b31..3b4a58d 100644 --- a/design-system/package-lock.json +++ b/design-system/package-lock.json @@ -509,9 +509,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -613,9 +610,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -630,9 +624,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -647,9 +638,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -664,9 +652,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -681,9 +666,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -698,9 +680,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -715,9 +694,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -732,9 +708,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -749,9 +722,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -766,9 +736,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -783,9 +750,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -800,9 +764,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -817,9 +778,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -930,6 +888,7 @@ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1066,6 +1025,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1278,6 +1238,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1356,6 +1317,7 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1613,6 +1575,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/testumgebung/README.md b/testumgebung/README.md new file mode 100644 index 0000000..6543dd8 --- /dev/null +++ b/testumgebung/README.md @@ -0,0 +1,67 @@ +# testumgebung/ — Wegwerf-Home-Assistant für Entwicklung und Tests + +Setzt eine vollständige Home-Assistant-Instanz in Docker auf, in der das +pyscript-Backend dieses Projekts echt läuft: eigene Entitäten, echte Service-Aufrufe, +nachgebildete Fahrzeugsensoren. Gedacht als Ziel für die Abnahmekriterien aus +`../UMSETZUNGSPLAN.md` (Phasen 1, 6, 9). + +Die ursprüngliche Testinstanz aus der Entwicklung des HA-Panels war eine +Wegwerfinstanz ohne Skript und ist verloren gegangen — deshalb liegt der Aufbau +hier als ausführbares Skript statt als Anleitung. + +## Aufsetzen + +```bash +./aufsetzen.sh +``` + +Das Skript ist mehrfach ausführbar (löscht einen vorhandenen Container vorher). +Es lädt das offizielle Image, startet den Container auf **Port 18123**, legt per +API einen Testnutzer an, erzeugt ein langlebiges Zugriffstoken, installiert +pyscript als Custom Component, kopiert `../homeassistant/{pyscript,www,data}` +hinein und startet neu. + +Am Ende schreibt es das Token nach `../companion-app/.env.local` +(Variable `VITE_TEST_TOKEN`, gitignored) — von dort holen es die +authentifizierten Tests. + +**Zugang:** http://localhost:18123 · Benutzer `test` · Passwort `testtest123` + +## Was nachgebildet wird + +`konfiguration.yaml` bildet die Entitäten der HACS-Integration +`TommiG1/HA_VAG-EU-Data-Act` als Template-Sensoren nach, damit das Backend echte +Werte sieht. Steuerbar zur Laufzeit über Helfer — damit lassen sich die +`@state_trigger`-Abläufe wirklich auslösen: + +| Helfer | Steuert | Wofür | +|---|---|---| +| `input_number.test_kilometerstand` | `sensor.audi_rs_4_avant_mileage` | Reifenzähler, Fahrtabschluss-Screening | +| `input_number.test_tankfuellstand` | `sensor.audi_rs_4_avant_fuel_level` | automatische Tankerkennung | +| `input_number.test_reichweite` | `sensor.audi_rs_4_avant_range_primary` | Übersichtsanzeige | +| `input_text.test_wlan` | `sensor.iphone_wifi_connection` | Fahrterkennung (Start/Ende) | +| `input_boolean.test_tuer_vl` | Tür vorne links | „sicher abgestellt" | +| `input_boolean.test_fenster_vl` | Fenster vorne links | „sicher abgestellt" | +| `input_boolean.test_entriegelt` | Türschloss vorne links | „sicher abgestellt" | + +Beispiel — Fahrt auslösen (WLAN des Fahrzeugs verbinden, dann trennen): + +```bash +T=$(grep VITE_TEST_TOKEN ../companion-app/.env.local | cut -d= -f2) +curl -X POST -H "Authorization: Bearer $T" -H "Content-Type: application/json" \ + -d '{"entity_id":"input_text.test_wlan","value":"Audi_MMI"}' \ + http://localhost:18123/api/services/input_text/set_value +``` + +Der WLAN-Name muss zu `fahrzeug.wlan_ssid` in +`audi_dashboard/fahrzeugprofil.json` passen. + +## Aufräumen + +```bash +docker rm -f audi_ha_test +``` + +Die Konfiguration liegt außerhalb des Repos unter dem im Skript gesetzten +`ZIEL`-Pfad; es werden keine echten Fahrzeugdaten verwendet, sondern +`fahrzeugprofil.example.json`. diff --git a/testumgebung/aufsetzen.sh b/testumgebung/aufsetzen.sh new file mode 100755 index 0000000..20b1006 --- /dev/null +++ b/testumgebung/aufsetzen.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Setzt die Wegwerf-Testinstanz auf. Mehrfach ausfuehrbar. +# Siehe README.md im selben Ordner. +set -euo pipefail + +HIER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJEKT="$(dirname "$HIER")" +NAME="audi_ha_test" +PORT="18123" +ZIEL="${AUDI_TEST_CONFIG:-${TMPDIR:-/tmp}/audi-ha-test-config}" +BASIS="http://localhost:${PORT}" + +echo "==> Zielordner: $ZIEL" +rm -rf "$ZIEL" +mkdir -p "$ZIEL" + +echo "==> Alten Container entfernen (falls vorhanden)" +docker rm -f "$NAME" >/dev/null 2>&1 || true + +echo "==> Image laden" +docker pull ghcr.io/home-assistant/home-assistant:stable + +echo "==> Container starten" +docker run -d --name "$NAME" -p "${PORT}:8123" -v "$ZIEL:/config" \ + ghcr.io/home-assistant/home-assistant:stable >/dev/null + +echo "==> Auf Home Assistant warten" +for _ in $(seq 1 60); do + if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$BASIS/" || true)" != "000" ]; then + break + fi + sleep 5 +done + +echo "==> Nutzer anlegen und Token erzeugen" +TOKEN="$(node "$HIER/einrichten.mjs" "$BASIS")" +if [ -z "$TOKEN" ]; then + echo "FEHLER: kein Token erhalten" >&2 + exit 1 +fi + +echo "==> pyscript installieren" +VERSION="$(curl -s https://api.github.com/repos/custom-components/pyscript/releases/latest \ + | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)" +TMPDL="$(mktemp -d)" +curl -sL -o "$TMPDL/pyscript.zip" \ + "https://github.com/custom-components/pyscript/archive/refs/tags/${VERSION}.zip" +unzip -oq "$TMPDL/pyscript.zip" -d "$TMPDL" +mkdir -p "$ZIEL/custom_components" +cp -r "$TMPDL/pyscript-${VERSION}/custom_components/pyscript" "$ZIEL/custom_components/" +rm -rf "$TMPDL" +echo " pyscript $VERSION" + +echo "==> Projektdateien einspielen" +cp "$HIER/konfiguration.yaml" "$ZIEL/configuration.yaml" +cp -r "$PROJEKT/homeassistant/pyscript" "$ZIEL/pyscript" +mkdir -p "$ZIEL/www" "$ZIEL/audi_dashboard" +cp -r "$PROJEKT/homeassistant/www/." "$ZIEL/www/" +cp "$PROJEKT/homeassistant/data/fahrzeugprofil.example.json" \ + "$ZIEL/audi_dashboard/fahrzeugprofil.json" +cp "$PROJEKT/homeassistant/data/shell_beleg_parser.py" "$ZIEL/audi_dashboard/" +: > "$ZIEL/audi_dashboard/fahrten.jsonl" +: > "$ZIEL/audi_dashboard/tankvorgaenge.jsonl" +printf '%s' "$TOKEN" > "$ZIEL/audi_dashboard/ha_token.txt" + +echo "==> Neu starten" +docker restart "$NAME" >/dev/null + +echo "==> Auf Backend warten" +for _ in $(seq 1 60); do + if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \ + -H "Authorization: Bearer $TOKEN" "$BASIS/api/" || true)" = "200" ]; then + break + fi + sleep 5 +done + +echo "==> Token nach companion-app/.env.local schreiben" +printf 'VITE_TEST_URL=%s\nVITE_TEST_TOKEN=%s\n' "$BASIS" "$TOKEN" \ + > "$PROJEKT/companion-app/.env.local" + +echo +echo "Fertig. $BASIS · Benutzer test · Passwort testtest123" +echo "Token liegt in companion-app/.env.local (gitignored)." diff --git a/testumgebung/einrichten.mjs b/testumgebung/einrichten.mjs new file mode 100644 index 0000000..506108d --- /dev/null +++ b/testumgebung/einrichten.mjs @@ -0,0 +1,102 @@ +// Legt in einer frischen Home-Assistant-Instanz einen Testnutzer an und gibt ein +// langlebiges Zugriffstoken auf der Standardausgabe aus. Meldungen gehen auf die +// Fehlerausgabe, damit das aufrufende Skript nur das Token einliest. +// Aufruf: node einrichten.mjs http://localhost:18123 + +const BASIS = process.argv[2] ?? "http://localhost:18123" +const CLIENT_ID = BASIS + "/" +const BENUTZER = "test" +const PASSWORT = "testtest123" + +const melden = (text) => process.stderr.write(` ${text}\n`) + +async function jsonPost(pfad, koerper, token) { + const kopf = { "Content-Type": "application/json" } + if (token) kopf.Authorization = `Bearer ${token}` + const antwort = await fetch(BASIS + pfad, { + method: "POST", + headers: kopf, + body: JSON.stringify(koerper), + }) + const text = await antwort.text() + if (!antwort.ok) throw new Error(`${pfad} -> ${antwort.status}: ${text.slice(0, 200)}`) + return text ? JSON.parse(text) : {} +} + +// 1. Onboarding-Schritt "user" +const { auth_code: authCode } = await jsonPost("/api/onboarding/users", { + client_id: CLIENT_ID, + name: "Test Nutzer", + username: BENUTZER, + password: PASSWORT, + language: "de", +}) +melden("Nutzer angelegt") + +// 2. Auth-Code gegen ein kurzlebiges Zugriffstoken tauschen +const tokenAntwort = await fetch(BASIS + "/auth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: authCode, + client_id: CLIENT_ID, + }).toString(), +}) +const token = await tokenAntwort.json() +if (!token.access_token) throw new Error("kein access_token: " + JSON.stringify(token)) +melden("Zugriffstoken erhalten") + +// 3. Restliche Onboarding-Schritte abschliessen (Fehler sind unkritisch) +for (const [pfad, koerper] of [ + ["/api/onboarding/core_config", {}], + ["/api/onboarding/analytics", {}], + ["/api/onboarding/integration", { client_id: CLIENT_ID, redirect_uri: CLIENT_ID }], +]) { + try { + await jsonPost(pfad, koerper, token.access_token) + } catch { + // Schritt bereits erledigt oder in dieser Version nicht vorhanden + } +} + +// 4. Langlebiges Token ueber die WebSocket-API erzeugen +const wsUrl = BASIS.replace(/^http/, "ws") + "/api/websocket" +const llat = await new Promise((aufloesen, ablehnen) => { + const ws = new WebSocket(wsUrl) + const zeitgeber = setTimeout(() => { + ablehnen(new Error("Zeitueberschreitung beim Erzeugen des Tokens")) + ws.close() + }, 20000) + ws.onmessage = (nachricht) => { + const daten = JSON.parse(nachricht.data) + if (daten.type === "auth_required") { + ws.send(JSON.stringify({ type: "auth", access_token: token.access_token })) + } else if (daten.type === "auth_ok") { + ws.send( + JSON.stringify({ + id: 1, + type: "auth/long_lived_access_token", + client_name: `DataMetric360 Test ${Date.now()}`, + lifespan: 365, + }), + ) + } else if (daten.type === "result") { + clearTimeout(zeitgeber) + if (daten.success) aufloesen(daten.result) + else ablehnen(new Error(JSON.stringify(daten.error))) + ws.close() + } else if (daten.type === "auth_invalid") { + clearTimeout(zeitgeber) + ablehnen(new Error("auth_invalid")) + ws.close() + } + } + ws.onerror = () => { + clearTimeout(zeitgeber) + ablehnen(new Error("WebSocket-Verbindung fehlgeschlagen")) + } +}) +melden("langlebiges Token erzeugt") + +process.stdout.write(llat) diff --git a/testumgebung/konfiguration.yaml b/testumgebung/konfiguration.yaml new file mode 100644 index 0000000..159a4fd --- /dev/null +++ b/testumgebung/konfiguration.yaml @@ -0,0 +1,159 @@ +# Wegwerf-Testinstanz fuer DataMetric360. +# Bildet die Entitaeten der HACS-Integration TommiG1/HA_VAG-EU-Data-Act nach, +# damit das pyscript-Backend echte Werte sieht. Ueber die input_*-Helfer sind +# die Werte zur Laufzeit aenderbar (Fahrterkennung, Reifenzaehler, Tankerkennung +# haengen an @state_trigger). + +default_config: + +pyscript: + allow_all_imports: true + +panel_custom: + - name: audi-dashboard-panel + sidebar_title: Mein Audi + sidebar_icon: mdi:car-sports + module_url: /local/audi-dashboard-panel.js?v=1 + embed_iframe: false + trust_external_script: false + +input_number: + test_kilometerstand: + name: Test Kilometerstand + min: 0 + max: 999999 + step: 1 + initial: 48250 + mode: box + test_tankfuellstand: + name: Test Tankfuellstand + min: 0 + max: 100 + step: 1 + initial: 62 + mode: box + test_reichweite: + name: Test Reichweite + min: 0 + max: 1200 + step: 1 + initial: 385 + mode: box + +input_text: + test_wlan: + name: Test WLAN + initial: Zuhause + max: 60 + +input_boolean: + test_tuer_vl: + name: Test Tuer vorne links + test_fenster_vl: + name: Test Fenster vorne links + test_entriegelt: + name: Test entriegelt + +template: + - sensor: + - name: "iPhone WiFi Connection" + unique_id: test_iphone_wifi + state: "{{ states('input_text.test_wlan') }}" + - name: "Audi RS 4 Avant Mileage" + unique_id: test_audi_mileage + state: "{{ states('input_number.test_kilometerstand') | int(0) }}" + unit_of_measurement: km + state_class: total_increasing + - name: "Audi RS 4 Avant Fuel Level" + unique_id: test_audi_fuel + state: "{{ states('input_number.test_tankfuellstand') | int(0) }}" + unit_of_measurement: "%" + - name: "Audi RS 4 Avant Range Primary" + unique_id: test_audi_range + state: "{{ states('input_number.test_reichweite') | int(0) }}" + unit_of_measurement: km + - name: "Audi RS 4 Avant Oil Change Due" + unique_id: test_audi_oil_due + state: "2027-03-14T00:00:00+00:00" + device_class: timestamp + - name: "Audi RS 4 Avant Oil Change Distance" + unique_id: test_audi_oil_dist + state: "-11750" + unit_of_measurement: km + - name: "Audi RS 4 Avant Inspection Due" + unique_id: test_audi_insp_due + state: "2027-03-14T00:00:00+00:00" + device_class: timestamp + - name: "Audi RS 4 Avant Inspection Distance" + unique_id: test_audi_insp_dist + state: "-11750" + unit_of_measurement: km + - binary_sensor: + - name: "Audi RS 4 Avant Front Left Door" + unique_id: test_audi_door_fl + state: "{{ is_state('input_boolean.test_tuer_vl', 'on') }}" + device_class: door + - name: "Audi RS 4 Avant Front Right Door" + unique_id: test_audi_door_fr + state: "off" + device_class: door + - name: "Audi RS 4 Avant Rear Left Door" + unique_id: test_audi_door_rl + state: "off" + device_class: door + - name: "Audi RS 4 Avant Rear Right Door" + unique_id: test_audi_door_rr + state: "off" + device_class: door + - name: "Audi RS 4 Avant Front Left Window" + unique_id: test_audi_win_fl + state: "{{ is_state('input_boolean.test_fenster_vl', 'on') }}" + device_class: window + - name: "Audi RS 4 Avant Front Right Window" + unique_id: test_audi_win_fr + state: "off" + device_class: window + - name: "Audi RS 4 Avant Rear Left Window" + unique_id: test_audi_win_rl + state: "off" + device_class: window + - name: "Audi RS 4 Avant Rear Right Window" + unique_id: test_audi_win_rr + state: "off" + device_class: window + - name: "Audi RS 4 Avant Front Left Door Lock" + unique_id: test_audi_lock_fl + state: "{{ is_state('input_boolean.test_entriegelt', 'on') }}" + device_class: lock + - name: "Audi RS 4 Avant Front Right Door Lock" + unique_id: test_audi_lock_fr + state: "off" + device_class: lock + - name: "Audi RS 4 Avant Rear Left Door Lock" + unique_id: test_audi_lock_rl + state: "off" + device_class: lock + - name: "Audi RS 4 Avant Rear Right Door Lock" + unique_id: test_audi_lock_rr + state: "off" + device_class: lock + - name: "Audi RS 4 Avant Tailgate" + unique_id: test_audi_tailgate + state: "off" + device_class: door + - name: "Audi RS 4 Avant Tailgate Lock" + unique_id: test_audi_tailgate_lock + state: "off" + device_class: lock + - name: "Audi RS 4 Avant Hood" + unique_id: test_audi_hood + state: "off" + device_class: door + - name: "Audi RS 4 Avant Hood Lock" + unique_id: test_audi_hood_lock + state: "off" + device_class: lock + +input_button: + test_refresh: + name: Test Refresh Now