Files
audi-app/companion-app/src/api/index.ts
T
tobias e1ebe6d694 Vollständiger Panel-companion-app-Paritätsaudit (11 Kapitel)
Systematischer Bildvergleich der companion-app gegen das HA-Panel (companion-app/AGENTS.md
Kapitel 1-11): Farbtoken/Radien aus dem falschen Stylesheet gelesen (iOS-Overlay statt Basis-CSS,
betraf fast jede Kachel/Farbe/Radius app-weit), vier app-weite @audi-dash/ui-Bugs (Switch/Seg/Feld
rot statt neutral bzw. falsche Feldbreite), Reifen-Seite strukturell neu gebaut (beide Radsätze
gleichzeitig statt Umschalter, editierbare Felder, Anzugsmoment-/km-Korrektur), Standort-Feature
komplett neu (fehlte bisher ganz), sowie diverse Struktur-/Typografie-/Datenlücken in
MeinAudi/Service/Versicherung/Sicherheit/Fahrten/Tanken/Statistik/Batterie/Einstellungen.

Manifest auf 2026.8.30.2 angehoben, OTA-Bündel neu gebaut und in audi_ha_test verifiziert
(sauberer Neustart, keine Tracebacks).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 12:21:58 +02:00

341 lines
12 KiB
TypeScript

/* ================================================================
Datenschicht DataMetric360 - Sammelexport
================================================================
Ein Zugang für die gesamte Oberfläche: REST für Lesen und Schreiben,
WebSocket für Push, Warteschlange fürs Funkloch. Welche Bildschirme es
gibt, ist hier bewusst unbekannt - diese Schicht überlebt jeden
Entwurfsdurchlauf unverändert. */
export * from "./types.ts";
export * from "./umgebung.ts";
export * from "./rest.ts";
export * from "./live.ts";
export * from "./warteschlange.ts";
export * from "./ablageNativ.ts";
import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts";
import type {
AppVersionAngabe,
CsvImportErgebnis,
Fahrt,
Fahrzeugstatus,
ImportErgebnis,
Profil,
Tankvorgang,
} from "./types.ts";
import { HassRest } from "./rest.ts";
import { HassLive } from "./live.ts";
import { Warteschlange } from "./warteschlange.ts";
/** Eingabefelder für Tankvorgänge. Die Namen sind die des Backends
(belegverarbeitung.py), nicht die des Datensatzes - deshalb "liter" statt
"liters" und "kosten" statt "fuel_total_eur". */
export interface TankvorgangFelder {
ts?: string;
liter?: number | null;
kosten?: number | null;
km?: number | null;
ersparnis?: number | null;
station?: string | null;
distanz?: number | null;
kraftstoff?: string | null;
}
/** Eingabefelder für Fahrten - Namen wie beim Backend (fahrterkennung.py:
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. */
/** Eingabefelder für einen archivierten Reifensatz - Namen wie beim Backend
(reifen.py: reifen_archiv_aktualisieren). */
export interface ReifenArchivFelder {
ersetzt_am?: string;
km?: number | null;
marke?: string | null;
modell?: string | null;
dot?: string | null;
mass?: string | null;
druck_vorne?: string | null;
druck_hinten?: string | null;
kommentar?: string | null;
}
export interface FahrtFelder {
ts_start?: string;
ts_end?: string;
art?: "privat" | "arbeitsweg";
start_ort?: string | null;
ziel_ort?: string | null;
odo_start?: number | null;
odo_end?: number | null;
distanz?: number | null;
}
/** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte
Dashboard über hass.callService erledigt hat. */
export class DataMetricApi {
readonly rest: HassRest;
readonly live: HassLive;
readonly warteschlange: Warteschlange;
constructor() {
this.rest = new HassRest();
this.live = new HassLive();
this.warteschlange = new Warteschlange(this.rest);
/* Sobald die Verbindung wieder steht, wartende Änderungen nachliefern -
ohne dass die Oberfläche etwas anstoßen muss. */
this.live.aufVerbindung((zustand) => {
if (zustand === "verbunden") void this.warteschlange.abarbeiten();
});
}
/* ------------------------------------------------------------ Lesen */
profilLesen(): Promise<Profil> {
return this.rest.datenLesen<Profil>(ENTITAETEN.profil);
}
fahrtenLesen(): Promise<Fahrt[]> {
return this.rest.datenLesen<Fahrt[]>(ENTITAETEN.fahrten);
}
tankvorgaengeLesen(): Promise<Tankvorgang[]> {
return this.rest.datenLesen<Tankvorgang[]>(ENTITAETEN.tankvorgaenge);
}
fahrzeugstatusLesen(): Promise<Fahrzeugstatus> {
return this.rest.datenLesen<Fahrzeugstatus>(ENTITAETEN.fahrzeugstatus);
}
/* --------------------------------------------------------- Schreiben
Alle schreibenden Vorgänge laufen über die Warteschlange, nicht direkt
über rest.dienstAufrufen(). Damit verhält sich die App im Funkloch
genauso wie mit Netz - nur eben zeitversetzt. */
profilSchreiben(profil: Profil): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"profil_schreiben",
{ profil_json: JSON.stringify(profil) },
"Fahrzeugdaten speichern",
);
}
belegHochladen(pdfBase64: string, dateiname: string, tankId?: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"beleg_hochladen",
{ pdf_base64: pdfBase64, dateiname, ...(tankId ? { tank_id: tankId } : {}) },
`Beleg „${dateiname}" hochladen`,
);
}
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
fahrtAnlegen(felder: FahrtFelder): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"fahrt_manuell_anlegen",
{ ...felder },
"Fahrt eintragen",
);
}
/** Bearbeitet eine bestehende Fahrt, gleich ob automatisch erkannt oder von
Hand angelegt. */
fahrtAktualisieren(tripId: string, felder: FahrtFelder): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"fahrt_aktualisieren",
{ trip_id: tripId, ...felder },
"Fahrt ändern",
);
}
fahrtLoeschen(tripId: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"fahrt_loeschen",
{ trip_id: tripId },
"Fahrt löschen",
);
}
/** Felder eines Tankvorgangs, wie sie das Backend erwartet (deutsche
Parameternamen — belegverarbeitung.py). */
tankvorgangAnlegen(felder: TankvorgangFelder): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"tankvorgang_manuell",
{ ...felder },
"Tankvorgang eintragen",
);
}
tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"tankvorgang_aktualisieren",
{ tank_id: tankId, ...felder },
"Tankvorgang ändern",
);
}
tankvorgangLoeschen(tankId: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"tankvorgang_loeschen",
{ tank_id: tankId },
"Tankvorgang löschen",
);
}
/** Löscht einen Tageseintrag aus der Batteriespannungs-Messwertliste. */
batterieverlaufEintragLoeschen(datum: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"batterieverlauf_loeschen",
{ datum },
"Messwert löschen",
);
}
/** "Neue Räder anlegen": archiviert den aktuellen Stand von `satz` und legt
einen frischen (km 0) an. */
reifenArchivieren(satz: "sommer" | "winter"): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_archivieren",
{ satz },
"Neue Räder anlegen",
);
}
/** Korrigiert nur den Kilometerzähler von `satz` - bewusst kein
profilSpeichern() (siehe reifenzaehler.py): ein eigener Dienst, der
referenz_odo_km unangetastet lässt, damit die nächste automatische
Fortschreibung ab dem neuen Stand weiterzählt statt ihn zu
überschreiben. Deckungsgleich mit data-kmspeichern im Panel. */
reifenKmSetzen(satz: "sommer" | "winter", km: number): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_km_setzen",
{ satz, km },
"Kilometerstand korrigieren",
);
}
reifenArchivAktualisieren(id: string, felder: ReifenArchivFelder): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_archiv_aktualisieren",
{ id, ...felder },
"Archivierten Reifensatz ändern",
);
}
reifenArchivLoeschen(id: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_archiv_loeschen",
{ id },
"Archivierten Reifensatz löschen",
);
}
/** Stößt eine sofortige Aktualisierung im Backend an (Pull-to-refresh). */
jetztAktualisieren(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "jetzt_aktualisieren");
}
/** Nutzlast von sensor.audi_dashboard_app_version: welchen Stand das
Backend ausliefert (`app`, siehe VERSIONIERUNG.md) und ob ein
OTA-Bündel bereitliegt (`buendel`, siehe daten/ota.ts). Beides kommt
aus derselben Entität und wird deshalb in einem Aufruf gelesen statt in
zweien. `null` bei jedem Fehler — fehlende Entität (älteres Backend),
Netzproblem, was auch immer; dann wird nichts verglichen und nichts
gemeldet, statt einen Fehlalarm auszulösen. */
async appVersionAngabeLesen(): Promise<AppVersionAngabe | null> {
try {
const zustand = await this.rest.zustandLesen<{ daten?: AppVersionAngabe }>(
ENTITAETEN.appVersion,
);
return zustand.attributes?.daten ?? null;
} catch {
return null;
}
}
/* ------------------------------------------------- Selbst-Update der
Integration (aktualisierung.py) - Ersatz für install.ps1, das Windows
Smart App Control blockiert. Bewusst NICHT über die Warteschlange, wie
historieImportieren() oben: beides sind ausdrückliche, sofortige
Aktionen, kein Zustand, den man im Funkloch absetzt und später
nachgeholt haben will. Ergebnis kommt über
appVersionAngabeLesen()/daten.integration_update zurück, nicht über den
Rückgabewert dieser Aufrufe. */
/** Fragt nur nach, ob eine neuere Fassung vorliegt - lädt nichts herunter. */
updatePruefen(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_pruefen");
}
/** Lädt die neueste Fassung und ersetzt den Integrationsordner. Home
Assistant muss danach neu gestartet werden. */
updateInstallieren(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_installieren");
}
/** Startet Home Assistant komplett neu - der letzte Schritt nach
updateInstallieren(), damit die neue Fassung tatsächlich geladen wird
(ein reiner Reload reicht dafür nicht, siehe AGENTS.md Abschnitt J).
Kein audi_dashboard-Dienst, deshalb der Bereich "homeassistant" statt
DIENST_DOMAIN. */
homeAssistantNeuStarten(): Promise<unknown> {
return this.rest.dienstAufrufen("homeassistant", "restart");
}
/* ------------------------------------------- Import aus dem HA-Verlauf
Bewusst NICHT über die Warteschlange, anders als die übrigen
schreibenden Vorgänge: der Import ist keine Eingabe, die man im Funkloch
absetzt und später ausgeführt haben will. Er dauert lange, sein Ergebnis
ist der eigentliche Zweck des Aufrufs, und ein nachträglich aus der
Warteschlange abgefeuerter Lauf käme für den Nutzer aus dem Nichts.
Ohne Netz gehört er schlicht nicht angeboten. */
/** 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(DIENST_DOMAIN, "historie_importieren", {
start,
ende,
});
}
/** Stand des laufenden bzw. letzten Imports: "laeuft" | "fertig" |
"fehler", plus Zählwerte im Attribut "daten". */
async importStatusLesen(): Promise<{ zustand: string; daten: ImportErgebnis }> {
const zustand = await this.rest.zustandLesen<{ daten: ImportErgebnis }>(
ENTITAETEN.importStatus,
);
return { zustand: zustand.state, daten: zustand.attributes?.daten ?? {} };
}
/* --------------------------------------------------- CSV-Import
Wie historieImportieren() bewusst NICHT über die Warteschlange: eine
bewusste, einmalige Aktion, deren Ergebnis der eigentliche Zweck des
Aufrufs ist, kein Formularfeld, das man auch offline abschicken würde.
Anders als dort aber synchron im Backend (csv_import.py) - der
Dienstaufruf kehrt erst zurück, wenn sensor.audi_dashboard_import_status
den neuen Stand schon trägt, keine Wartezeit nötig. */
async csvImportieren(
art: "fahrten" | "tanken" | "service",
inhalt: string,
): Promise<CsvImportErgebnis> {
await this.rest.dienstAufrufen(DIENST_DOMAIN, "csv_importieren", { art, inhalt });
const zustand = await this.rest.zustandLesen<{ daten: CsvImportErgebnis }>(
ENTITAETEN.importStatus,
);
return { art, ...(zustand.attributes?.daten ?? {}) };
}
}