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.
This commit is contained in:
Paul Nothaft
2026-08-11 10:44:50 +02:00
parent ab2c3db679
commit 982bb04c1a
6 changed files with 423 additions and 43 deletions
+6 -1
View File
@@ -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.
+5 -42
View File
@@ -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"
+67
View File
@@ -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`.
+84
View File
@@ -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)."
+102
View File
@@ -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)
+159
View File
@@ -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