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:
2026-08-11 00:10:53 +02:00
commit e1d570992e
151 changed files with 18162 additions and 0 deletions
+103
View File
@@ -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");
}
}
+167
View File
@@ -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);
}
}
+137
View File
@@ -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,
);
}
}
+136
View File
@@ -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;
+141
View File
@@ -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`;
}
+163
View File
@@ -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 };
}
}