Initialer Import: HA-Panel, Design-System, Companion-App
Drei zusammengehörige Teile in einem Repository: - homeassistant/ Das fertige, im Einsatz befindliche Home-Assistant-Panel (panel_custom Custom Element + pyscript-Backend). Echte Fahrzeug- und Personendaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl, Tankbelege) bleiben per .gitignore außen vor; die anonymisierte Vorlage fahrzeugprofil.example.json ist mit dabei. - design-system/ Eigenständige React-Komponentenbibliothek (@audi-dash/ui), die die visuelle Sprache des Panels nachbildet - ohne Audi-Markenzeichen und ohne die lizenzierte Hausschrift. Dient als Grundlage für Claude Design. War bis hierher ein eigenes Repository und ist in dieses eingeschmolzen worden. - companion-app/ Datenschicht der neuen App DataMetric360 (iOS/Android via Capacitor, zusätzlich als Iframe im HA-Dashboard). Noch ohne Oberfläche: REST- und WebSocket-Zugriff auf Home Assistant plus Warteschlange für Änderungen ohne Netz. Ersetzt das eingespritzte hass-Objekt, das nur innerhalb des HA-Frontends existiert. Dazu die Projektdokumentation: SPECIFICATION.md (Ist-Stand des Panels), COMPANION_APP_ARCHITECTURE.md (Architekturentscheidungen der neuen App), AUDIT_2026-08-10.md, DESIGN_BRIEF_DATAMETRIC360.md und der ursprüngliche Bauauftrag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/* ================================================================
|
||||
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";
|
||||
|
||||
import { ENTITAETEN } from "./types.ts";
|
||||
import type { Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "./types.ts";
|
||||
import { HassRest } from "./rest.ts";
|
||||
import { HassLive } from "./live.ts";
|
||||
import { Warteschlange } from "./warteschlange.ts";
|
||||
|
||||
/** 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(
|
||||
"pyscript",
|
||||
"audi_dashboard_profil_schreiben",
|
||||
{ profil_json: JSON.stringify(profil) },
|
||||
"Fahrzeugdaten speichern",
|
||||
);
|
||||
}
|
||||
|
||||
belegHochladen(pdfBase64: string, dateiname: string, tankId?: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_beleg_hochladen",
|
||||
{ pdf_base64: pdfBase64, dateiname, ...(tankId ? { tank_id: tankId } : {}) },
|
||||
`Beleg „${dateiname}" hochladen`,
|
||||
);
|
||||
}
|
||||
|
||||
fahrtLoeschen(tripId: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_fahrt_loeschen",
|
||||
{ trip_id: tripId },
|
||||
"Fahrt löschen",
|
||||
);
|
||||
}
|
||||
|
||||
tankvorgangLoeschen(tankId: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_tankvorgang_loeschen",
|
||||
{ tank_id: tankId },
|
||||
"Tankvorgang löschen",
|
||||
);
|
||||
}
|
||||
|
||||
/** Stößt eine sofortige Aktualisierung im Backend an (Pull-to-refresh). */
|
||||
jetztAktualisieren(): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/* ================================================================
|
||||
WebSocket-Verbindung: Push statt Abfragen
|
||||
================================================================
|
||||
Ohne das eingespritzte hass-Objekt gibt es keine automatischen Updates
|
||||
mehr. Statt im Sekundentakt zu pollen (teuer auf dem Mobilfunknetz und
|
||||
für den Akku) hängt sich die App an HAs eigenen Ereignisstrom.
|
||||
|
||||
Ablauf laut HA-WebSocket-API:
|
||||
Server: {type:"auth_required"}
|
||||
Client: {type:"auth", access_token:"..."}
|
||||
Server: {type:"auth_ok"} oder {type:"auth_invalid"}
|
||||
Client: {id:1, type:"subscribe_events", event_type:"state_changed"}
|
||||
|
||||
Der Verbindungszustand ist zugleich die Quelle für den Offline-Hinweis in
|
||||
der Kopfzeile (COMPANION_APP_ARCHITECTURE.md §3 "Offline behavior"). */
|
||||
|
||||
import type { HassState } from "./types.ts";
|
||||
import { websocketUrl, zugangLesen, type Zugang } from "./umgebung.ts";
|
||||
|
||||
export type Verbindungszustand = "getrennt" | "verbindet" | "verbunden" | "nicht_angemeldet";
|
||||
|
||||
export interface ZustandsWechsel {
|
||||
entity_id: string;
|
||||
new_state: HassState | null;
|
||||
old_state: HassState | null;
|
||||
}
|
||||
|
||||
type ZustandsHorcher = (wechsel: ZustandsWechsel) => void;
|
||||
type VerbindungsHorcher = (zustand: Verbindungszustand) => void;
|
||||
|
||||
/* Wartezeiten beim Wiederverbinden: schnell beim ersten Versuch (kurze
|
||||
Funklöcher), dann zunehmend geduldiger, gedeckelt bei 30 s. Ohne Deckel
|
||||
würde die App nach einer langen Nacht ohne Netz stundenlang schlafen. */
|
||||
const WARTEZEITEN_MS = [1_000, 2_000, 5_000, 10_000, 30_000] as const;
|
||||
|
||||
export class HassLive {
|
||||
#socket: WebSocket | null = null;
|
||||
#zustand: Verbindungszustand = "getrennt";
|
||||
#naechsteId = 1;
|
||||
#versuch = 0;
|
||||
#wiederholung: ReturnType<typeof setTimeout> | null = null;
|
||||
#beendet = false;
|
||||
|
||||
#zustandsHorcher = new Set<ZustandsHorcher>();
|
||||
#verbindungsHorcher = new Set<VerbindungsHorcher>();
|
||||
|
||||
get zustand(): Verbindungszustand {
|
||||
return this.#zustand;
|
||||
}
|
||||
|
||||
get istVerbunden(): boolean {
|
||||
return this.#zustand === "verbunden";
|
||||
}
|
||||
|
||||
/** Meldet jede Zustandsänderung einer Entität. Rückgabe: Abmeldefunktion. */
|
||||
aufZustand(horcher: ZustandsHorcher): () => void {
|
||||
this.#zustandsHorcher.add(horcher);
|
||||
return () => this.#zustandsHorcher.delete(horcher);
|
||||
}
|
||||
|
||||
/** Meldet Verbindungswechsel - treibt den Offline-Hinweis in der Kopfzeile. */
|
||||
aufVerbindung(horcher: VerbindungsHorcher): () => void {
|
||||
this.#verbindungsHorcher.add(horcher);
|
||||
horcher(this.#zustand);
|
||||
return () => this.#verbindungsHorcher.delete(horcher);
|
||||
}
|
||||
|
||||
#zustandSetzen(neu: Verbindungszustand): void {
|
||||
if (this.#zustand === neu) return;
|
||||
this.#zustand = neu;
|
||||
for (const h of this.#verbindungsHorcher) h(neu);
|
||||
}
|
||||
|
||||
async starten(zugang?: Zugang): Promise<void> {
|
||||
this.#beendet = false;
|
||||
const z = zugang ?? (await zugangLesen());
|
||||
if (!z) {
|
||||
this.#zustandSetzen("nicht_angemeldet");
|
||||
return;
|
||||
}
|
||||
this.#verbinden(z);
|
||||
}
|
||||
|
||||
beenden(): void {
|
||||
this.#beendet = true;
|
||||
if (this.#wiederholung) {
|
||||
clearTimeout(this.#wiederholung);
|
||||
this.#wiederholung = null;
|
||||
}
|
||||
this.#socket?.close();
|
||||
this.#socket = null;
|
||||
this.#zustandSetzen("getrennt");
|
||||
}
|
||||
|
||||
#verbinden(zugang: Zugang): void {
|
||||
this.#zustandSetzen("verbindet");
|
||||
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(websocketUrl(zugang.basisUrl));
|
||||
} catch {
|
||||
this.#erneutVersuchen(zugang);
|
||||
return;
|
||||
}
|
||||
this.#socket = socket;
|
||||
|
||||
socket.addEventListener("message", (ereignis) => {
|
||||
let nachricht: Record<string, unknown>;
|
||||
try {
|
||||
nachricht = JSON.parse(String(ereignis.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (nachricht["type"]) {
|
||||
case "auth_required":
|
||||
socket.send(JSON.stringify({ type: "auth", access_token: zugang.token }));
|
||||
break;
|
||||
|
||||
case "auth_ok":
|
||||
this.#versuch = 0;
|
||||
this.#zustandSetzen("verbunden");
|
||||
socket.send(
|
||||
JSON.stringify({ id: this.#naechsteId++, type: "subscribe_events", event_type: "state_changed" }),
|
||||
);
|
||||
break;
|
||||
|
||||
case "auth_invalid":
|
||||
/* Token ungültig - erneutes Verbinden hilft nicht, es braucht ein
|
||||
neues Token. Deshalb hier kein Wiederholungsversuch. */
|
||||
this.#beendet = true;
|
||||
this.#zustandSetzen("nicht_angemeldet");
|
||||
socket.close();
|
||||
break;
|
||||
|
||||
case "event": {
|
||||
const ereignisDaten = nachricht["event"] as { event_type?: string; data?: ZustandsWechsel } | undefined;
|
||||
if (ereignisDaten?.event_type === "state_changed" && ereignisDaten.data) {
|
||||
for (const h of this.#zustandsHorcher) h(ereignisDaten.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
if (this.#socket === socket) this.#socket = null;
|
||||
if (this.#zustand !== "nicht_angemeldet") this.#erneutVersuchen(zugang);
|
||||
});
|
||||
|
||||
socket.addEventListener("error", () => {
|
||||
/* "error" kommt immer zusammen mit "close" - dort wird wiederholt. */
|
||||
});
|
||||
}
|
||||
|
||||
#erneutVersuchen(zugang: Zugang): void {
|
||||
if (this.#beendet) return;
|
||||
this.#zustandSetzen("getrennt");
|
||||
|
||||
const wartezeit = WARTEZEITEN_MS[Math.min(this.#versuch, WARTEZEITEN_MS.length - 1)] ?? 30_000;
|
||||
this.#versuch += 1;
|
||||
this.#wiederholung = setTimeout(() => this.#verbinden(zugang), wartezeit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/* ================================================================
|
||||
REST-Zugriff auf Home Assistant
|
||||
================================================================
|
||||
Ersetzt exakt das, was im alten panel_custom-Dashboard das eingespritzte
|
||||
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. */
|
||||
|
||||
import type { DatenEntity, HassState } from "./types.ts";
|
||||
import { zugangLesen, type Zugang } from "./umgebung.ts";
|
||||
|
||||
export class ApiFehler extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number | null,
|
||||
readonly ursache?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiFehler";
|
||||
}
|
||||
|
||||
/** Fehlt oder taugt das Token nicht mehr - dann muss die Oberfläche zurück
|
||||
in die Ersteinrichtung, statt es endlos weiterzuversuchen. */
|
||||
get istAnmeldeproblem(): boolean {
|
||||
return this.status === 401 || this.status === 403;
|
||||
}
|
||||
|
||||
/** Kein Netz erreichbar (status===null) - Offline-Fall, keine echte
|
||||
Fehlermeldung: die Oberfläche zeigt zwischengespeicherte Daten. */
|
||||
get istNetzproblem(): boolean {
|
||||
return this.status === null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AnfrageOptionen {
|
||||
/** Bricht die Anfrage ab, statt im Funkloch minutenlang zu hängen. */
|
||||
zeitlimitMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const ZEITLIMIT_STANDARD_MS = 15_000;
|
||||
|
||||
export class HassRest {
|
||||
#zugang: Zugang | null = null;
|
||||
|
||||
constructor(zugang?: Zugang) {
|
||||
this.#zugang = zugang ?? null;
|
||||
}
|
||||
|
||||
/** Holt den Zugang beim ersten Bedarf aus der Ablage nach. */
|
||||
async #zugangHolen(): Promise<Zugang> {
|
||||
if (this.#zugang) return this.#zugang;
|
||||
const gelesen = await zugangLesen();
|
||||
if (!gelesen) throw new ApiFehler("Nicht eingerichtet: Server-Adresse oder Token fehlt.", 401);
|
||||
this.#zugang = gelesen;
|
||||
return gelesen;
|
||||
}
|
||||
|
||||
zugangSetzen(zugang: Zugang | null): void {
|
||||
this.#zugang = zugang;
|
||||
}
|
||||
|
||||
async #anfrage<T>(pfad: string, init: RequestInit, opt: AnfrageOptionen = {}): Promise<T> {
|
||||
const zugang = await this.#zugangHolen();
|
||||
|
||||
/* Eigener Abbruch-Zeitgeber, zusätzlich zu einem evtl. übergebenen
|
||||
Signal - beide sollen abbrechen können. */
|
||||
const uhr = new AbortController();
|
||||
const frist = setTimeout(() => uhr.abort(), opt.zeitlimitMs ?? ZEITLIMIT_STANDARD_MS);
|
||||
const abbruch = () => uhr.abort();
|
||||
opt.signal?.addEventListener("abort", abbruch);
|
||||
|
||||
try {
|
||||
const antwort = await fetch(`${zugang.basisUrl}${pfad}`, {
|
||||
...init,
|
||||
signal: uhr.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${zugang.token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!antwort.ok) {
|
||||
throw new ApiFehler(
|
||||
`Home Assistant antwortete mit ${antwort.status} ${antwort.statusText} auf ${pfad}`,
|
||||
antwort.status,
|
||||
);
|
||||
}
|
||||
|
||||
/* Manche Dienste antworten mit leerem Rumpf - dann kein JSON erzwingen. */
|
||||
const text = await antwort.text();
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ApiFehler) throw fehler;
|
||||
throw new ApiFehler(`Home Assistant nicht erreichbar (${pfad})`, null, fehler);
|
||||
} finally {
|
||||
clearTimeout(frist);
|
||||
opt.signal?.removeEventListener("abort", abbruch);
|
||||
}
|
||||
}
|
||||
|
||||
/** Prüft Adresse und Token - für den letzten Schritt der Ersteinrichtung. */
|
||||
async verbindungPruefen(opt?: AnfrageOptionen): Promise<{ message: string }> {
|
||||
return this.#anfrage<{ message: string }>("/api/", { method: "GET" }, opt);
|
||||
}
|
||||
|
||||
async zustandLesen<A extends Record<string, unknown>>(
|
||||
entityId: string,
|
||||
opt?: AnfrageOptionen,
|
||||
): Promise<HassState<A>> {
|
||||
return this.#anfrage<HassState<A>>(`/api/states/${encodeURIComponent(entityId)}`, { method: "GET" }, opt);
|
||||
}
|
||||
|
||||
/** Liest eine pyscript-Entität und packt gleich die Nutzlast aus. */
|
||||
async datenLesen<T>(entityId: string, opt?: AnfrageOptionen): Promise<T> {
|
||||
const zustand = await this.zustandLesen<{ daten: T }>(entityId, opt);
|
||||
return (zustand as DatenEntity<T>).attributes.daten;
|
||||
}
|
||||
|
||||
/** Ruft einen Dienst auf, z. B. dienstAufrufen("pyscript",
|
||||
"audi_dashboard_profil_schreiben", { profil_json }). */
|
||||
async dienstAufrufen(
|
||||
bereich: string,
|
||||
dienst: string,
|
||||
daten: Record<string, unknown> = {},
|
||||
opt?: AnfrageOptionen,
|
||||
): Promise<unknown> {
|
||||
return this.#anfrage<unknown>(
|
||||
`/api/services/${encodeURIComponent(bereich)}/${encodeURIComponent(dienst)}`,
|
||||
{ method: "POST", body: JSON.stringify(daten) },
|
||||
opt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/* ================================================================
|
||||
Typen der Home-Assistant-Schnittstelle und der Fachdaten
|
||||
================================================================
|
||||
Die Fachtypen (Fahrt, Tankvorgang, ...) sind aus SPECIFICATION.md §5
|
||||
"Data files" abgeleitet - also aus dem, was das Backend tatsächlich in
|
||||
fahrten.jsonl / tankvorgaenge.jsonl schreibt, nicht aus Wunschdenken.
|
||||
Felder, die laut §7 im Schema stehen, aber von keinem Codepfad je gefüllt
|
||||
werden (start_lat, avg_speed_kmh, route, ...), sind hier bewusst als
|
||||
optional markiert - sie können null/undefined sein und die Oberfläche darf
|
||||
sich nie darauf verlassen. */
|
||||
|
||||
/** Roher Zustand einer HA-Entität, wie ihn /api/states liefert. */
|
||||
export interface HassState<A = Record<string, unknown>> {
|
||||
entity_id: string;
|
||||
state: string;
|
||||
attributes: A;
|
||||
last_changed: string;
|
||||
last_updated: string;
|
||||
context?: { id: string; parent_id: string | null; user_id: string | null };
|
||||
}
|
||||
|
||||
/** Die pyscript.*-Entitäten transportieren ihre Nutzlast im Attribut "daten". */
|
||||
export interface DatenAttribute<T> {
|
||||
daten: T;
|
||||
[weitere: string]: unknown;
|
||||
}
|
||||
|
||||
export type DatenEntity<T> = HassState<DatenAttribute<T>>;
|
||||
|
||||
/* ----------------------------------------------------------- Fachdaten */
|
||||
|
||||
export type Fahrtart = "privat" | "arbeitsweg";
|
||||
export type FahrtStatus = "offen" | "vollständig";
|
||||
export type FahrtQuelle = "ha" | "manual";
|
||||
|
||||
export interface Fahrt {
|
||||
trip_id: string;
|
||||
ts_start: string;
|
||||
ts_end: string;
|
||||
duration_s: number;
|
||||
distance_km: number | null;
|
||||
/** "odometer" ist der einzige Wert, den das Backend je erzeugt (§7.1). */
|
||||
km_quelle: "odometer" | "gps" | null;
|
||||
odo_start: number | null;
|
||||
odo_end: number | null;
|
||||
art: Fahrtart;
|
||||
source: FahrtQuelle;
|
||||
status: FahrtStatus;
|
||||
edited_fields?: string[];
|
||||
/* Ab FMM003 erstmals real befüllbar - bis dahin durchgehend null (§7.2). */
|
||||
avg_speed_kmh?: number | null;
|
||||
start_lat?: number | null;
|
||||
start_lon?: number | null;
|
||||
end_lat?: number | null;
|
||||
end_lon?: number | null;
|
||||
start_address?: string | null;
|
||||
end_address?: string | null;
|
||||
route?: unknown | null;
|
||||
pausen?: unknown[];
|
||||
}
|
||||
|
||||
export type TankStatus = "unvollständig" | "vollständig";
|
||||
export type TankQuelle = "auto" | "manual" | "beleg";
|
||||
|
||||
export interface Tankvorgang {
|
||||
tank_id: string;
|
||||
ts: string;
|
||||
liters: number | null;
|
||||
fuel_total_eur: number | null;
|
||||
/** Immer aus fuel_total_eur/liters berechnet, nie aus dem Beleg gelesen. */
|
||||
price_per_l: number | null;
|
||||
station_name?: string | null;
|
||||
station_id?: string | null;
|
||||
station_address?: string | null;
|
||||
fuel_type?: string | null;
|
||||
product_name?: string | null;
|
||||
discount?: number | null;
|
||||
discount_per_l?: number | null;
|
||||
list_price_per_l?: number | null;
|
||||
receipt_total_eur?: number | null;
|
||||
receipt_key?: string | null;
|
||||
receipt_no?: string | null;
|
||||
receipt_file?: string | null;
|
||||
odometer_km?: number | null;
|
||||
distance_km?: number | null;
|
||||
source: TankQuelle;
|
||||
status: TankStatus;
|
||||
edited_fields?: string[];
|
||||
}
|
||||
|
||||
/** Einzelprüfung aus _sicherheitscheck: ok===null heißt "Sensor unbekannt". */
|
||||
export interface SicherheitsPunkt {
|
||||
label: string;
|
||||
ok: boolean | null;
|
||||
}
|
||||
|
||||
export interface Fahrzeugstatus {
|
||||
km?: number | null;
|
||||
tank_prozent?: number | null;
|
||||
reichweite_km?: number | null;
|
||||
sicher_abgestellt?: boolean | null;
|
||||
sicherheit?: SicherheitsPunkt[];
|
||||
[weitere: string]: unknown;
|
||||
}
|
||||
|
||||
/** Das Profil ist ein großer, frei geformter JSON-Blob (§5). Die Oberfläche
|
||||
greift gezielt auf Abschnitte zu; ein vollständiger Typ wäre hier nur
|
||||
scheingenau, solange das Backend ihn nicht erzwingt. */
|
||||
export interface Profil {
|
||||
fahrzeug?: Record<string, unknown>;
|
||||
einstellungen?: Record<string, unknown>;
|
||||
versicherung?: Record<string, unknown>;
|
||||
steuer?: Record<string, unknown>;
|
||||
reifen?: Record<string, unknown>;
|
||||
service?: Record<string, unknown>;
|
||||
technik?: Record<string, unknown>;
|
||||
ausstattung?: Record<string, unknown>;
|
||||
[weitere: string]: unknown;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- Entitäts-Verzeichnis */
|
||||
|
||||
/** Alle vom Backend veröffentlichten pyscript-Entitäten an einer Stelle.
|
||||
Kein Hardcoding über die App verstreut: wer eine Entität umbenennt,
|
||||
ändert genau diese Tabelle. */
|
||||
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",
|
||||
} as const;
|
||||
|
||||
export type EntitaetsSchluessel = keyof typeof ENTITAETEN;
|
||||
@@ -0,0 +1,141 @@
|
||||
/* ================================================================
|
||||
Laufzeitumgebung und Ablage von Zugangsdaten
|
||||
================================================================
|
||||
Die App läuft in drei Umgebungen (COMPANION_APP_ARCHITECTURE.md §1):
|
||||
nativ per Capacitor (iOS/Android), als Iframe im HA-Dashboard und im
|
||||
normalen Browser. Der Unterschied betrifft nur zwei Dinge - wo das Token
|
||||
liegt und welche Basis-URL gilt -, deshalb ist genau das hier gekapselt
|
||||
und der Rest der Datenschicht umgebungsblind. */
|
||||
|
||||
export type Umgebung = "capacitor" | "iframe" | "browser";
|
||||
|
||||
export function umgebungErkennen(): Umgebung {
|
||||
if (typeof window === "undefined") return "browser";
|
||||
|
||||
/* Capacitor meldet sich über ein globales Objekt an. Bewusst defensiv
|
||||
gelesen: das Paket ist hier (noch) keine Abhängigkeit, wir prüfen nur,
|
||||
ob die Laufzeit es bereitstellt. */
|
||||
const cap = (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
|
||||
if (cap?.isNativePlatform?.()) return "capacitor";
|
||||
|
||||
/* window.top !== window heißt: wir stecken in einem Iframe. Der Zugriff
|
||||
kann bei fremder Herkunft werfen - dann sind wir erst recht eingebettet. */
|
||||
try {
|
||||
if (window.top !== window.self) return "iframe";
|
||||
} catch {
|
||||
return "iframe";
|
||||
}
|
||||
|
||||
return "browser";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Ablage */
|
||||
|
||||
/** Minimale, asynchrone Schlüssel-Wert-Ablage. Async, weil die native
|
||||
Variante (Keychain/Keystore über Capacitor Preferences) asynchron ist -
|
||||
lieber alle Aufrufer von Anfang an asynchron als später umbauen. */
|
||||
export interface Ablage {
|
||||
lesen(schluessel: string): Promise<string | null>;
|
||||
schreiben(schluessel: string, wert: string): Promise<void>;
|
||||
loeschen(schluessel: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** localStorage-Variante für Browser und Iframe. Alle Zugriffe abgesichert:
|
||||
im privaten Modus und in Iframes mit blockierten Drittanbieter-Daten
|
||||
wirft schon der bloße Zugriff auf localStorage. */
|
||||
export class BrowserAblage implements Ablage {
|
||||
#speicher = new Map<string, string>();
|
||||
|
||||
#ls(): Storage | null {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async lesen(schluessel: string): Promise<string | null> {
|
||||
const ls = this.#ls();
|
||||
if (!ls) return this.#speicher.get(schluessel) ?? null;
|
||||
try {
|
||||
return ls.getItem(schluessel);
|
||||
} catch {
|
||||
return this.#speicher.get(schluessel) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async schreiben(schluessel: string, wert: string): Promise<void> {
|
||||
this.#speicher.set(schluessel, wert);
|
||||
try {
|
||||
this.#ls()?.setItem(schluessel, wert);
|
||||
} catch {
|
||||
/* Nur im Arbeitsspeicher - besser als ein Absturz. */
|
||||
}
|
||||
}
|
||||
|
||||
async loeschen(schluessel: string): Promise<void> {
|
||||
this.#speicher.delete(schluessel);
|
||||
try {
|
||||
this.#ls()?.removeItem(schluessel);
|
||||
} catch {
|
||||
/* siehe oben */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Wird beim App-Start durch die native Umsetzung ersetzt (Keychain unter
|
||||
iOS, Keystore unter Android). Bis dahin gilt die Browser-Variante. */
|
||||
let aktiveAblage: Ablage = new BrowserAblage();
|
||||
|
||||
export function ablageSetzen(ablage: Ablage): void {
|
||||
aktiveAblage = ablage;
|
||||
}
|
||||
|
||||
export function ablage(): Ablage {
|
||||
return aktiveAblage;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Zugangsdaten */
|
||||
|
||||
const SCHLUESSEL_BASIS = "dm360.basis_url";
|
||||
const SCHLUESSEL_TOKEN = "dm360.token";
|
||||
|
||||
export interface Zugang {
|
||||
/** Basis-URL ohne abschließenden Schrägstrich, z. B. https://audi.datametric360.app */
|
||||
basisUrl: string;
|
||||
/** Long-Lived Access Token aus dem HA-Benutzerprofil. */
|
||||
token: string;
|
||||
}
|
||||
|
||||
export async function zugangLesen(): Promise<Zugang | null> {
|
||||
const a = ablage();
|
||||
const [basisUrl, token] = await Promise.all([a.lesen(SCHLUESSEL_BASIS), a.lesen(SCHLUESSEL_TOKEN)]);
|
||||
if (!basisUrl || !token) return null;
|
||||
return { basisUrl, token };
|
||||
}
|
||||
|
||||
export async function zugangSpeichern(zugang: Zugang): Promise<void> {
|
||||
const a = ablage();
|
||||
await Promise.all([
|
||||
a.schreiben(SCHLUESSEL_BASIS, basisUrlNormalisieren(zugang.basisUrl)),
|
||||
a.schreiben(SCHLUESSEL_TOKEN, zugang.token),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function zugangVerwerfen(): Promise<void> {
|
||||
const a = ablage();
|
||||
await Promise.all([a.loeschen(SCHLUESSEL_BASIS), a.loeschen(SCHLUESSEL_TOKEN)]);
|
||||
}
|
||||
|
||||
/** Schneidet abschließende Schrägstriche ab und ergänzt fehlendes Schema.
|
||||
Ohne das entstehen sonst Adressen wie "https://host//api/states". */
|
||||
export function basisUrlNormalisieren(eingabe: string): string {
|
||||
let url = eingabe.trim();
|
||||
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** WebSocket-Adresse zur Basis-URL: http->ws, https->wss. */
|
||||
export function websocketUrl(basisUrl: string): string {
|
||||
return `${basisUrl.replace(/^http/i, "ws")}/api/websocket`;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/* ================================================================
|
||||
Warteschlange für Änderungen ohne Netz
|
||||
================================================================
|
||||
Entscheidung aus COMPANION_APP_ARCHITECTURE.md §3: Offline gemachte
|
||||
Änderungen werden nicht abgewiesen, sondern gesammelt und nachgeliefert,
|
||||
sobald wieder eine Verbindung besteht. Weil es genau einen Benutzer gibt,
|
||||
reicht "der letzte Schreibvorgang gewinnt" - es gibt keinen zweiten
|
||||
Bearbeiter, gegen den abgeglichen werden müsste.
|
||||
|
||||
Die Warteschlange liegt in derselben Ablage wie das Token, überlebt also
|
||||
einen App-Neustart: ein Beleg, der im Funkloch fotografiert wurde, geht
|
||||
nicht verloren, nur weil die App zwischendurch geschlossen wurde. */
|
||||
|
||||
import { ablage } from "./umgebung.ts";
|
||||
import { ApiFehler, type HassRest } from "./rest.ts";
|
||||
|
||||
const SCHLUESSEL = "dm360.warteschlange";
|
||||
|
||||
export interface WartenderAuftrag {
|
||||
id: string;
|
||||
/** Zeitpunkt der Erstellung - die Oberfläche zeigt "wartet seit ...". */
|
||||
erstellt: string;
|
||||
bereich: string;
|
||||
dienst: string;
|
||||
daten: Record<string, unknown>;
|
||||
/** Kurzer Klartext für die Anzeige, z. B. "Beleg vom 3.8. hochladen". */
|
||||
beschreibung: string;
|
||||
fehlversuche: number;
|
||||
letzterFehler?: string;
|
||||
}
|
||||
|
||||
type WarteschlangenHorcher = (auftraege: WartenderAuftrag[]) => void;
|
||||
|
||||
/** Nach so vielen vergeblichen Versuchen gilt ein Auftrag als kaputt und wird
|
||||
nicht weiter blind wiederholt - sonst blockiert ein einziger fehlerhafter
|
||||
Auftrag für immer alle nachfolgenden. */
|
||||
const MAX_FEHLVERSUCHE = 5;
|
||||
|
||||
export class Warteschlange {
|
||||
#auftraege: WartenderAuftrag[] = [];
|
||||
#geladen = false;
|
||||
#laeuft = false;
|
||||
#horcher = new Set<WarteschlangenHorcher>();
|
||||
|
||||
constructor(private readonly rest: HassRest) {}
|
||||
|
||||
async laden(): Promise<void> {
|
||||
if (this.#geladen) return;
|
||||
const roh = await ablage().lesen(SCHLUESSEL);
|
||||
if (roh) {
|
||||
try {
|
||||
const gelesen: unknown = JSON.parse(roh);
|
||||
if (Array.isArray(gelesen)) this.#auftraege = gelesen as WartenderAuftrag[];
|
||||
} catch {
|
||||
/* Beschädigter Eintrag: lieber leer starten als beim Start abstürzen. */
|
||||
this.#auftraege = [];
|
||||
}
|
||||
}
|
||||
this.#geladen = true;
|
||||
this.#melden();
|
||||
}
|
||||
|
||||
get auftraege(): readonly WartenderAuftrag[] {
|
||||
return this.#auftraege;
|
||||
}
|
||||
|
||||
get anzahl(): number {
|
||||
return this.#auftraege.length;
|
||||
}
|
||||
|
||||
aufAenderung(horcher: WarteschlangenHorcher): () => void {
|
||||
this.#horcher.add(horcher);
|
||||
horcher([...this.#auftraege]);
|
||||
return () => this.#horcher.delete(horcher);
|
||||
}
|
||||
|
||||
#melden(): void {
|
||||
const kopie = [...this.#auftraege];
|
||||
for (const h of this.#horcher) h(kopie);
|
||||
}
|
||||
|
||||
async #sichern(): Promise<void> {
|
||||
await ablage().schreiben(SCHLUESSEL, JSON.stringify(this.#auftraege));
|
||||
this.#melden();
|
||||
}
|
||||
|
||||
/** Reiht einen Dienstaufruf ein. Wird direkt danach abgearbeitet, falls
|
||||
Netz da ist - im Normalfall merkt der Benutzer die Warteschlange also
|
||||
gar nicht. */
|
||||
async einreihen(
|
||||
bereich: string,
|
||||
dienst: string,
|
||||
daten: Record<string, unknown>,
|
||||
beschreibung: string,
|
||||
): Promise<WartenderAuftrag> {
|
||||
await this.laden();
|
||||
const auftrag: WartenderAuftrag = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
erstellt: new Date().toISOString(),
|
||||
bereich,
|
||||
dienst,
|
||||
daten,
|
||||
beschreibung,
|
||||
fehlversuche: 0,
|
||||
};
|
||||
this.#auftraege.push(auftrag);
|
||||
await this.#sichern();
|
||||
void this.abarbeiten();
|
||||
return auftrag;
|
||||
}
|
||||
|
||||
async entfernen(id: string): Promise<void> {
|
||||
await this.laden();
|
||||
this.#auftraege = this.#auftraege.filter((a) => a.id !== id);
|
||||
await this.#sichern();
|
||||
}
|
||||
|
||||
/** Arbeitet die Warteschlange der Reihe nach ab. Reihenfolge ist wichtig:
|
||||
wer erst das Profil ändert und dann einen Beleg hochlädt, erwartet
|
||||
genau diese Abfolge auf dem Server. Deshalb wird beim ersten
|
||||
Netzproblem abgebrochen statt weitergesprungen. */
|
||||
async abarbeiten(): Promise<{ erledigt: number; verblieben: number }> {
|
||||
await this.laden();
|
||||
if (this.#laeuft) return { erledigt: 0, verblieben: this.#auftraege.length };
|
||||
this.#laeuft = true;
|
||||
|
||||
let erledigt = 0;
|
||||
try {
|
||||
while (this.#auftraege.length > 0) {
|
||||
const auftrag = this.#auftraege[0];
|
||||
if (!auftrag) break;
|
||||
|
||||
try {
|
||||
await this.rest.dienstAufrufen(auftrag.bereich, auftrag.dienst, auftrag.daten);
|
||||
this.#auftraege.shift();
|
||||
erledigt += 1;
|
||||
await this.#sichern();
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ApiFehler && fehler.istNetzproblem) {
|
||||
/* Weiterhin offline - später erneut versuchen, nichts verwerfen. */
|
||||
break;
|
||||
}
|
||||
|
||||
auftrag.fehlversuche += 1;
|
||||
auftrag.letzterFehler = fehler instanceof Error ? fehler.message : String(fehler);
|
||||
|
||||
if (auftrag.fehlversuche >= MAX_FEHLVERSUCHE) {
|
||||
/* Dauerhaft kaputt: aus dem Weg räumen, damit die übrigen
|
||||
Aufträge nicht ewig dahinter feststecken. Die Oberfläche kann
|
||||
den Fehler über aufAenderung() sichtbar machen. */
|
||||
this.#auftraege.shift();
|
||||
}
|
||||
await this.#sichern();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.#laeuft = false;
|
||||
}
|
||||
|
||||
return { erledigt, verblieben: this.#auftraege.length };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user