/* ================================================================ 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 { return this.rest.datenLesen(ENTITAETEN.profil); } fahrtenLesen(): Promise { return this.rest.datenLesen(ENTITAETEN.fahrten); } tankvorgaengeLesen(): Promise { return this.rest.datenLesen(ENTITAETEN.tankvorgaenge); } fahrzeugstatusLesen(): Promise { return this.rest.datenLesen(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 { return this.warteschlange.einreihen( DIENST_DOMAIN, "profil_schreiben", { profil_json: JSON.stringify(profil) }, "Fahrzeugdaten speichern", ); } belegHochladen(pdfBase64: string, dateiname: string, tankId?: string): Promise { 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 { return this.warteschlange.einreihen( DIENST_DOMAIN, "bild_hochladen", { dateiname, daten_base64: datenBase64 }, `Foto „${dateiname}" hochladen`, ) } bildLoeschen(dateiname: string): Promise { 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 { 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 { return this.warteschlange.einreihen( DIENST_DOMAIN, "fahrt_aktualisieren", { trip_id: tripId, ...felder }, "Fahrt ändern", ); } fahrtLoeschen(tripId: string): Promise { 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 { return this.warteschlange.einreihen( DIENST_DOMAIN, "tankvorgang_manuell", { ...felder }, "Tankvorgang eintragen", ); } tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise { return this.warteschlange.einreihen( DIENST_DOMAIN, "tankvorgang_aktualisieren", { tank_id: tankId, ...felder }, "Tankvorgang ändern", ); } tankvorgangLoeschen(tankId: string): Promise { 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 { 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 { 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 { return this.warteschlange.einreihen( DIENST_DOMAIN, "reifen_km_setzen", { satz, km }, "Kilometerstand korrigieren", ); } reifenArchivAktualisieren(id: string, felder: ReifenArchivFelder): Promise { return this.warteschlange.einreihen( DIENST_DOMAIN, "reifen_archiv_aktualisieren", { id, ...felder }, "Archivierten Reifensatz ändern", ); } reifenArchivLoeschen(id: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 ?? {}) }; } }