Files
audi-app/companion-app/src/api/umgebung.ts
T
tobias e1d570992e 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>
2026-08-11 00:20:05 +02:00

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`;
}