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 <[email protected]>
This commit is contained in:
2026-08-11 00:20:05 +02:00
co-authored by Claude Opus 5
commit e1d570992e
151 changed files with 18162 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.local
.DS_Store
+81
View File
@@ -0,0 +1,81 @@
# DataMetric360 — companion app
Private vehicle app for one car. Ships three ways from one codebase: native iOS and Android via
Capacitor, and embedded as a plain iframe in the Home Assistant dashboard. Architecture and the
decisions behind it: [`../COMPANION_APP_ARCHITECTURE.md`](../COMPANION_APP_ARCHITECTURE.md).
## Status
**Data layer only.** No UI yet — the screens come from the Claude Design draft
([`../DESIGN_BRIEF_DATAMETRIC360.md`](../DESIGN_BRIEF_DATAMETRIC360.md)), and get implemented on top
of `design-system/`'s React components once that draft settles. This package was written first on
purpose: how the app talks to Home Assistant doesn't depend on what the screens look like, so it
survives every design iteration untouched.
Not yet added (deliberately, they'd be guesses today): React/Vite, Capacitor, the native secure-storage
adapter, and the Audi brand assets (fonts/rings/badges — those come from `homeassistant/www/` at
implementation time, never into `design-system/`, see the licence note in the architecture doc).
## Layout
```
src/api/
├── types.ts HA state shapes + domain types (Fahrt, Tankvorgang, …) + the entity-ID table
├── umgebung.ts runtime detection (capacitor/iframe/browser), credential storage, URL helpers
├── rest.ts REST client — replaces hass.states / hass.callService
├── live.ts WebSocket client — push updates, auto-reconnect with backoff
├── warteschlange.ts offline queue for writes made without a connection
└── index.ts DataMetricApi — ties the three together, exposes the domain operations
scripts/smoke.ts verification against a running HA instance
```
Comments are in German, matching the rest of the project.
## What replaces what
The old panel got a `hass` object injected by `panel_custom`. That object only exists inside the HA
frontend, which is exactly why the old panel can't run as a standalone app. The mapping:
| Old panel | Here |
|---|---|
| `hass.states[id].attributes.daten` | `rest.datenLesen(id)` / `DataMetricApi.profilLesen()` etc. |
| `hass.callService(...)` | `warteschlange.einreihen(...)` via the `DataMetricApi` methods |
| automatic re-render on state push | `live.aufZustand(...)` |
Same entities, same pyscript services, same backend files written — only the transport changes.
Every **write** goes through the queue rather than straight to REST. That's what makes offline edits
behave the same as online ones, just delayed: a receipt photographed in a dead zone is persisted and
sent when the connection returns, surviving an app restart in between.
## Commands
Node is installed at `C:\Program Files\nodejs` but is **not on PATH** — prefix it:
```bash
$env:Path = "C:\Program Files\nodejs;" + $env:Path
```
```bash
npm run typecheck
```
```bash
npm run smoke
```
`smoke` targets `http://localhost:18123` (the `audi_ha_test` Docker container) by default; pass a
different base URL as the first argument.
## Verification status (2026-08-10)
`tsc --noEmit` clean under `strict` plus `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`.
All 7 smoke checks pass against the running test container, covering URL normalisation, the
http→ws/https→wss mapping, that `GET /api/` exists and rejects an unauthenticated request with 401,
and that the WebSocket opens with `auth_required` — which is the message `live.ts`'s whole auth flow
is built around.
**Not yet verified — needs a token:** authenticated reads, service calls, and the queue's real
round-trip. Those need a Long-Lived Access Token, which is a deliberate manual step (see the LLAT
provisioning decision in the architecture doc). To do it: create a token in the HA profile page, then
extend `scripts/smoke.ts` with authenticated cases.
+47
View File
@@ -0,0 +1,47 @@
{
"name": "datametric360",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "datametric360",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^24.13.3",
"typescript": "^5.7.2"
}
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "datametric360",
"version": "0.1.0",
"private": true,
"description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Dieses Paket enthält vorerst nur die Datenschicht; die Oberfläche folgt aus dem Claude-Design-Entwurf.",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"smoke": "node --experimental-strip-types scripts/smoke.ts"
},
"devDependencies": {
"@types/node": "^24.13.3",
"typescript": "^5.7.2"
}
}
+79
View File
@@ -0,0 +1,79 @@
/* Rauchtest gegen die laufende HA-Testinstanz (Docker, Port 18123).
Prüft bewusst nur das, was ohne Zugangsdaten prüfbar ist:
die Adressbildung, die Existenz der Endpunkte und den Beginn des
WebSocket-Handshakes. Damit ist belegt, dass rest.ts und live.ts die
richtigen Pfade und das richtige Protokoll ansprechen - ohne dass dafür
irgendwo ein Token eingesammelt werden müsste.
Aufruf: node scripts/smoke.ts (Node >= 23, Typen werden entfernt)
node scripts/smoke.ts http://localhost:18123 */
import { basisUrlNormalisieren, websocketUrl } from "../src/api/umgebung.ts";
const BASIS = basisUrlNormalisieren(process.argv[2] ?? "http://localhost:18123");
let fehler = 0;
function pruefe(bezeichnung: string, bedingung: boolean, zusatz = ""): void {
console.log(`${bedingung ? "PASS" : "FAIL"} ${bezeichnung}${zusatz ? ` (${zusatz})` : ""}`);
if (!bedingung) fehler += 1;
}
/* ------------------------------------------------- reine Funktionen */
pruefe("basisUrlNormalisieren schneidet Schrägstriche ab",
basisUrlNormalisieren("https://host/") === "https://host");
pruefe("basisUrlNormalisieren ergänzt fehlendes Schema",
basisUrlNormalisieren("host.example") === "https://host.example");
pruefe("basisUrlNormalisieren lässt http stehen",
basisUrlNormalisieren("http://localhost:18123") === "http://localhost:18123");
pruefe("websocketUrl macht aus https wss",
websocketUrl("https://h") === "wss://h/api/websocket");
pruefe("websocketUrl macht aus http ws",
websocketUrl("http://h") === "ws://h/api/websocket");
/* --------------------------------------------------- REST-Endpunkt */
try {
const antwort = await fetch(`${BASIS}/api/`, { headers: { "Content-Type": "application/json" } });
/* Ohne Token muss HA mit 401 antworten. Genau das beweist zweierlei:
der Pfad /api/ existiert, und er ist nicht offen zugänglich. */
pruefe("GET /api/ ohne Token antwortet 401", antwort.status === 401, `Status ${antwort.status}`);
} catch (e) {
pruefe("GET /api/ erreichbar", false, e instanceof Error ? e.message : String(e));
}
/* ---------------------------------------------- WebSocket-Handshake */
const wsErgebnis = await new Promise<string>((fertig) => {
const frist = setTimeout(() => fertig("Zeitüberschreitung"), 8000);
try {
const socket = new WebSocket(websocketUrl(BASIS));
socket.addEventListener("message", (e: MessageEvent) => {
clearTimeout(frist);
try {
fertig(String(JSON.parse(String(e.data)).type));
} catch {
fertig("kein JSON");
}
socket.close();
});
socket.addEventListener("error", () => {
clearTimeout(frist);
fertig("Verbindungsfehler");
});
} catch (e) {
clearTimeout(frist);
fertig(e instanceof Error ? e.message : String(e));
}
});
/* live.ts wartet als Erstes auf genau diese Nachricht - stimmt sie nicht,
liefe der ganze Anmeldeablauf ins Leere. */
pruefe("WebSocket meldet sich mit auth_required", wsErgebnis === "auth_required", `erhalten: ${wsErgebnis}`);
console.log(fehler === 0 ? "\nAlle Prüfungen bestanden." : `\n${fehler} Prüfung(en) fehlgeschlagen.`);
/* Nur den Rückgabewert setzen, nicht process.exit() aufrufen: unter Windows
stürzt libuv sonst mit einer Assertion ab, weil der eben geschlossene
WebSocket noch nicht vollständig abgebaut ist. So läuft Node normal aus. */
process.exitCode = fehler === 0 ? 0 : 1;
+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 };
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src/**/*.ts", "scripts/**/*.ts"]
}