2be6b99667
- "Daten ausgeben" zu "Fahrzeugprofil" umgebaut, gleiche zwei Knoepfe wie im Panel: Datensatz sichern/laden, popup mit vier Aktionen - CSV-Spaltennamen jetzt byte-identisch zum Panel-Export (gemeinsamer Backend-Parser, csv_import.py) - vorher eigene, abweichende Kopfzeilen ohne Reimport-Moeglichkeit - neue api.csvImportieren(), Fahrzeugprofil-Import ueber das bestehende api.profilSchreiben() (volles Profil, nicht die teilweise zusammenfuehrende profilSpeichern()-Bequemlichkeitsfunktion) - Blinden Fleck geprueft: importStatusLesen()/zustandLesen() lesen per REST, nicht aus lokalem Cache - dieselbe Racebedingung wie im Panel kann hier strukturell nicht auftreten - Toten dateiwahl-Scaffolding entfernt (nie verdrahtet) Beim Bauen echten, vorbestehenden Fehler gefunden: companion-app und Panel verwenden unterschiedliche Feldnamen fuer Wartungsplan-Eintraege (betrieb/notiz vs. werkstatt/kosten) - als eigene Aufgabe geflaggt statt hier mitgefixt. tsc --noEmit sauber, 146/146 Tests, vite build erfolgreich. Nicht live getestet (kein laufender companion-app-Dev-Server mit Backend-Zugang in dieser Sitzung). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
327 lines
11 KiB
TypeScript
327 lines
11 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",
|
|
);
|
|
}
|
|
|
|
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 ?? {}) };
|
|
}
|
|
}
|