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>
This commit is contained in:
2026-08-23 23:53:56 +02:00
parent 99cef7c393
commit d8b12da36d
136 changed files with 5289 additions and 17641 deletions
+21 -21
View File
@@ -13,7 +13,7 @@ export * from "./live.ts";
export * from "./warteschlange.ts";
export * from "./ablageNativ.ts";
import { ENTITAETEN } from "./types.ts";
import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts";
import type { Fahrt, Fahrzeugstatus, ImportErgebnis, Profil, Tankvorgang } from "./types.ts";
import { HassRest } from "./rest.ts";
import { HassLive } from "./live.ts";
@@ -34,7 +34,7 @@ export interface TankvorgangFelder {
}
/** Eingabefelder für Fahrten - Namen wie beim Backend (fahrterkennung.py:
audi_dashboard_fahrt_manuell_anlegen/audi_dashboard_fahrt_aktualisieren).
fahrt_manuell_anlegen/fahrt_aktualisieren).
Alles außer den Zeitpunkten ist optional: leer bleibt es liegen, bis das
Kilometerstand-Screening (§7.2) oder eine spätere Bearbeitung es füllt. */
export interface FahrtFelder {
@@ -92,8 +92,8 @@ export class DataMetricApi {
profilSchreiben(profil: Profil): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_profil_schreiben",
DIENST_DOMAIN,
"profil_schreiben",
{ profil_json: JSON.stringify(profil) },
"Fahrzeugdaten speichern",
);
@@ -101,8 +101,8 @@ export class DataMetricApi {
belegHochladen(pdfBase64: string, dateiname: string, tankId?: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_beleg_hochladen",
DIENST_DOMAIN,
"beleg_hochladen",
{ pdf_base64: pdfBase64, dateiname, ...(tankId ? { tank_id: tankId } : {}) },
`Beleg „${dateiname}" hochladen`,
);
@@ -111,8 +111,8 @@ export class DataMetricApi {
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
fahrtAnlegen(felder: FahrtFelder): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_fahrt_manuell_anlegen",
DIENST_DOMAIN,
"fahrt_manuell_anlegen",
{ ...felder },
"Fahrt eintragen",
);
@@ -122,8 +122,8 @@ export class DataMetricApi {
Hand angelegt. */
fahrtAktualisieren(tripId: string, felder: FahrtFelder): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_fahrt_aktualisieren",
DIENST_DOMAIN,
"fahrt_aktualisieren",
{ trip_id: tripId, ...felder },
"Fahrt ändern",
);
@@ -131,8 +131,8 @@ export class DataMetricApi {
fahrtLoeschen(tripId: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_fahrt_loeschen",
DIENST_DOMAIN,
"fahrt_loeschen",
{ trip_id: tripId },
"Fahrt löschen",
);
@@ -142,8 +142,8 @@ export class DataMetricApi {
Parameternamen — belegverarbeitung.py). */
tankvorgangAnlegen(felder: TankvorgangFelder): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_tankvorgang_manuell",
DIENST_DOMAIN,
"tankvorgang_manuell",
{ ...felder },
"Tankvorgang eintragen",
);
@@ -151,8 +151,8 @@ export class DataMetricApi {
tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_tankvorgang_aktualisieren",
DIENST_DOMAIN,
"tankvorgang_aktualisieren",
{ tank_id: tankId, ...felder },
"Tankvorgang ändern",
);
@@ -160,8 +160,8 @@ export class DataMetricApi {
tankvorgangLoeschen(tankId: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_tankvorgang_loeschen",
DIENST_DOMAIN,
"tankvorgang_loeschen",
{ tank_id: tankId },
"Tankvorgang löschen",
);
@@ -169,10 +169,10 @@ export class DataMetricApi {
/** Stößt eine sofortige Aktualisierung im Backend an (Pull-to-refresh). */
jetztAktualisieren(): Promise<unknown> {
return this.rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren");
return this.rest.dienstAufrufen(DIENST_DOMAIN, "jetzt_aktualisieren");
}
/** Welchen Oberflächen-Stand das Backend ausliefert (Datei VERSION, siehe
/** Welchen Oberflächen-Stand das Backend ausliefert (Version der Integration, siehe
VERSIONIERUNG.md). `null`, wenn die Entität fehlt — etwa weil das Backend
älter ist als diese Funktion; dann wird nichts verglichen und nichts
gemeldet, statt einen Fehlalarm auszulösen. */
@@ -198,7 +198,7 @@ export class DataMetricApi {
/** Startet den Import. Kehrt zurück, sobald das Backend den Auftrag
angenommen hat — nicht, wenn er fertig ist; dafür importStatusLesen(). */
historieImportieren(start: string, ende: string): Promise<unknown> {
return this.rest.dienstAufrufen("pyscript", "audi_dashboard_historie_importieren", {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", {
start,
ende,
});
+4 -4
View File
@@ -5,8 +5,8 @@
hass-Objekt geleistet hat (SPECIFICATION.md §3 "Data flow"):
hass.states[id].attributes.daten -> zustandLesen()/datenLesen()
hass.callService(...) -> dienstAufrufen()
Dieselben Entitäten, dieselben pyscript-Dienste - nur über HTTP statt über
ein Objekt, das nur innerhalb des HA-Frontends existiert. */
Dieselben Entitäten, dieselben Dienste der Integration - nur über HTTP statt
über ein Objekt, das nur innerhalb des HA-Frontends existiert. */
import type { DatenEntity, HassState } from "./types.ts";
import { zugangLesen, type Zugang } from "./umgebung.ts";
@@ -124,8 +124,8 @@ export class HassRest {
return (zustand as DatenEntity<T>).attributes.daten;
}
/** Ruft einen Dienst auf, z. B. dienstAufrufen("pyscript",
"audi_dashboard_profil_schreiben", { profil_json }). */
/** Ruft einen Dienst auf, z. B. dienstAufrufen("audi_dashboard",
"profil_schreiben", { profil_json }). */
async dienstAufrufen(
bereich: string,
dienst: string,
+25 -14
View File
@@ -19,7 +19,7 @@ export interface HassState<A = Record<string, unknown>> {
context?: { id: string; parent_id: string | null; user_id: string | null };
}
/** Die pyscript.*-Entitäten transportieren ihre Nutzlast im Attribut "daten". */
/** Die Entitäten der Integration transportieren ihre Nutzlast im Attribut "daten". */
export interface DatenAttribute<T> {
daten: T;
[weitere: string]: unknown;
@@ -95,7 +95,7 @@ export interface SicherheitsPunkt {
}
/** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation
abgeleitet: frontend_veroeffentlichung.py schreibt "tankprozent",
abgeleitet: veroeffentlichung.py schreibt "tankprozent",
"gesichert" und "sicherheitscheck" — ein früherer Entwurf dieser Datei
hatte "tank_prozent"/"sicher_abgestellt"/"sicherheit" angenommen, womit
die Oberfläche still überall undefined gelesen hätte. */
@@ -138,7 +138,7 @@ export interface Profil {
[weitere: string]: unknown;
}
/** Ergebnis eines Historien-Imports (pyscript/historienimport.py). Die
/** Ergebnis eines Historien-Imports (custom_components/audi_dashboard/historienimport.py). Die
Zählwerte fehlen, solange der Lauf noch nicht fertig ist; `meldung` steht
nur im Fehlerfall. */
export interface ImportErgebnis {
@@ -158,19 +158,30 @@ export interface ImportErgebnis {
/* -------------------------------------------------- Entitäts-Verzeichnis */
/** Alle vom Backend veröffentlichten pyscript-Entitäten an einer Stelle.
/** Alle vom Backend veröffentlichten Entitäten an einer Stelle.
Kein Hardcoding über die App verstreut: wer eine Entität umbenennt,
ändert genau diese Tabelle. */
ändert genau diese Tabelle.
Die Namen sind ein Vertrag mit custom_components/audi_dashboard/const.py;
das HA-Panel führt dieselbe Tabelle in audi-dashboard-app.js. Bis
2026-08-23 hießen sie pyscript.audi_dashboard_*, weil pyscript sie
bereitstellte und die Domain besaß. Seit dem Umbau zur eigenen Integration
sind es echte Entitäten dieser Integration. */
export const ENTITAETEN = {
profil: "pyscript.audi_dashboard_profil",
fahrten: "pyscript.audi_dashboard_fahrten",
tankvorgaenge: "pyscript.audi_dashboard_tankvorgaenge",
fahrzeugstatus: "pyscript.audi_dashboard_fahrzeugstatus",
batterieverlauf: "pyscript.audi_dashboard_batterieverlauf",
belegErgebnis: "pyscript.audi_dashboard_beleg_ergebnis",
updateStatus: "pyscript.audi_dashboard_update_status",
importStatus: "pyscript.audi_dashboard_import_status",
appVersion: "pyscript.audi_dashboard_app_version",
profil: "sensor.audi_dashboard_profil",
fahrten: "sensor.audi_dashboard_fahrten",
tankvorgaenge: "sensor.audi_dashboard_tankvorgaenge",
fahrzeugstatus: "sensor.audi_dashboard_fahrzeugstatus",
batterieverlauf: "sensor.audi_dashboard_batterieverlauf",
belegErgebnis: "sensor.audi_dashboard_beleg_ergebnis",
importStatus: "sensor.audi_dashboard_import_status",
appVersion: "sensor.audi_dashboard_app_version",
} as const;
export type EntitaetsSchluessel = keyof typeof ENTITAETEN;
/** Die Domain, unter der das Backend seine Dienste anbietet:
`audi_dashboard.<name>`. Früher `pyscript.audi_dashboard_<name>` - der
doppelte Präfix war nur nötig, weil sich alle pyscript-Dienste eine Domain
teilten. Siehe custom_components/audi_dashboard/dienste.py. */
export const DIENST_DOMAIN = "audi_dashboard";
+1 -1
View File
@@ -8,7 +8,7 @@
* unbemerkt. Diese Datei macht daraus ein sichtbares Signal.
*
* Bewusst kein „größer/kleiner"-Vergleich: die Version ist eine Kennung
* (`2026.08.23.1`), keine Zahl. Ein Sortierversuch wäre nur scheingenau und
* (`2026.8.23.2`), keine Zahl. Ein Sortierversuch wäre nur scheingenau und
* würde bei einem Formatwechsel still falsche Antworten geben. Verglichen wird
* auf Gleichheit — alles andere heißt „stimmt nicht überein", und was davon
* älter ist, entscheidet der Mensch.
+2 -2
View File
@@ -8,7 +8,7 @@ import { useRef, useState } from "react"
import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui"
import { zugangVerwerfen } from "../api"
import { DIENST_DOMAIN, zugangVerwerfen } from "../api"
import { useDaten } from "../daten/DatenKontext"
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
import { datumZeit, de, isoTag } from "../format"
@@ -294,7 +294,7 @@ export function Einstellungen({
<div className="dm-knopfreihe">
<ActionButton
onClick={() =>
void api.rest.dienstAufrufen("pyscript", "audi_dashboard_backup_jetzt")
void api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_jetzt")
}
>
Jetzt sichern
@@ -9,7 +9,7 @@
* abgeschlossenen Schritt — deckungsgleich mit dem Panel.
*
* Der eigentliche Lauf passiert im Backend. Es meldet seinen Stand über die
* Entität `pyscript.audi_dashboard_import_status`; hier wird sie im
* Entität `sensor.audi_dashboard_import_status`; hier wird sie im
* Sekundentakt gelesen, bis sie „fertig" oder „fehler" sagt. Ein Poll statt
* eines Abwartens des Dienstaufrufs, weil pyscript-Dienste sofort
* zurückkehren, während die Arbeit im Hintergrund weiterläuft.
+2 -1
View File
@@ -11,6 +11,7 @@ import { useState } from "react"
import { ActionButton, Feld, Seg, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import type { Reifensatz } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format"
@@ -38,7 +39,7 @@ export function Reifen() {
// Eigener Dienst: das Backend rechnet zuerst den alten Satz ab und
// schaltet erst dann um, damit keine Kilometer auf den neuen Satz
// rutschen, die noch auf dem alten gefahren wurden.
await api.rest.dienstAufrufen("pyscript", "audi_dashboard_reifen_wechseln", { satz: ziel })
await api.rest.dienstAufrufen(DIENST_DOMAIN, "reifen_wechseln", { satz: ziel })
} finally {
setzeLaeuft(false)
}
+1 -1
View File
@@ -3,7 +3,7 @@
*
* Der Beleg wird als PDF hochgeladen; das Ergebnis kommt **nicht** als
* Rückgabewert des Dienstaufrufs zurück (pyscript liefert keine), sondern
* asynchron über die Entität `pyscript.audi_dashboard_beleg_ergebnis`.
* asynchron über die Entität `sensor.audi_dashboard_beleg_ergebnis`.
* Genau dieses Muster hatte auch das alte Panel.
*/
+1 -1
View File
@@ -167,7 +167,7 @@ export function beispielApi(): DataMetricApi {
return {
rest: {
zustandLesen: async () => ({
entity_id: "pyscript.audi_dashboard_fahrzeugstatus",
entity_id: "sensor.audi_dashboard_fahrzeugstatus",
state: "aktuell",
attributes: { daten: beispielStatus },
last_changed: "2026-08-11T09:00:00+00:00",