e1d570992e
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>
142 lines
4.7 KiB
TypeScript
142 lines
4.7 KiB
TypeScript
/* ================================================================
|
|
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`;
|
|
}
|