Files
audi-app/companion-app/src/api/index.ts
T
tobias 4a58021670 Paritaetsrunden 2 und 3, Designpruefung umgesetzt (2026.8.30.8)
Drei zusammenhaengende Runden, alle live bei 375x812 gegen audi_ha_test
geprueft und in beiden Codebasen angewandt.

Paritaetsrunde 2: der gemeinsame Rahmen war das eigentliche Problem -
Seitenrand, Kachelabstaende, Kopfleiste, Tableiste und vier Bausteine des
Design-Systems trugen noch den Stand vor der iOS-Entscheidung. Dazu sieben
Bildschirme neu aufgebaut. Zwei Panel-Fehler dabei mitbehoben: teaser()
zeigte unter "Letzte Fahrt" den aeltesten Datensatz, und "Daten bearbeiten"
war ein toter Knopf.

Paritaetsrunde 3: das Panel hat nie Audi Type gerendert. @font-face in einem
Shadow Root wird ignoriert - Schriftschnitte registriert der Browser pro
Dokument, nie pro Shadow Tree. Die drei Schnitte liegen jetzt in
audi-dashboard-schriften.css und werden ins Dokument gehaengt. Damit erledigt
sich eine ganze Reihe von "die Schrift sieht anders aus"-Eindruecken: die
beiden Anwendungen zeigten tatsaechlich verschiedene Schriften.
Ausserdem "Daten bearbeiten" im Panel gebaut und portiert, das Zeilenmenue
entfernt und die letzten neun Bildschirme angeglichen.

Designpruefung: die fuenf Punkte der Reihenfolge. Beim vierten hat das Messen
den Befund veraendert - gezaehlt waren die deklarierten Groessen, wirksam war
laengst eine saubere Sieben-Schritt-Skala mit 27 Ausreissern; die sind jetzt
auf den naechsten Schritt gezogen, keiner verschiebt sich um mehr als 1px.

Zuletzt: die Standortvorschau zeichnete die falsche Nadel (das Panel wechselt
den Icon-Satz ab 34px, die App nahm immer den grossen), und der Kopfabstand
der App ist auf den sicheren Bereich reduziert - die 56px des Panels liegen
dort unter der Kopfleiste von Home Assistant, in der nativen Huelle steht
darueber nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:31:46 +02:00

369 lines
13 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;
/** Aus einem vorab gelesenen Beleg (Upload ohne tank_id) - so übergibt sie
auch das Panel beim Speichern eines neuen Tankvorgangs mit Beleg. */
receipt_key?: string | null;
receipt_file?: 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`,
);
}
/**
* Fahrzeugfoto hochladen bzw. ersetzen. Dieselben Dienste, die das Panel
* über `data-bildupload` anspricht (`bild_hochladen`/`bild_loeschen` in
* services.yaml). Die App konnte Fotos bisher gar nicht ändern — sie
* verwies stattdessen auf die Verwaltung in Home Assistant.
*/
bildHochladen(dateiname: string, datenBase64: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"bild_hochladen",
{ dateiname, daten_base64: datenBase64 },
`Foto „${dateiname}" hochladen`,
)
}
bildLoeschen(dateiname: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"bild_loeschen",
{ dateiname },
`Foto „${dateiname}" löschen`,
)
}
/** 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 ?? {}) };
}
}