HA Widgets — Eigene Home-Assistant-Apps mit Vue 3 (Teil 5: HA-Infrastruktur — Custom Component und KI-Flow)

- Veröffentlicht unter Makerspace von

Hinweis: Der Beitrag wurde mit KI-Unterstützung (Claude Code) verfasst und manuell nachbearbeitet.

HA Widgets — alle Teile: Teil 1: Überblick · Teil 2: Wie fängt man an? · Teil 3: Architektur · Teil 4: Architektur-Checks · Teil 5: HA-Infrastruktur · Teil 6: LCARS-Design · Teil 7: Phone und Desktop · Teil 8: Claude Code richtig briefen

Teil 4 hat die Qualitätssicherung beschrieben — Checks die sicherstellen dass Code-Qualität und Architektur-Regeln eingehalten werden. Jetzt kommt was darunter liegt: wie die Apps überhaupt an ihre eigenen Daten kommen.

In Teil 2 war die Verbindung zu HA noch simpel: subscribeEntities zum Lesen, callService zum Schalten. Das reicht für Sensoren und Schalter — aber nicht für eigene Daten. Wenn die App ein Rezept speichern will, braucht sie eine API. Und die wird von einer HA Custom Component bereitgestellt.


Warum eine Custom Component?

Der naheliegende Ansatz für eigene Daten: Webhooks. HA kann HTTP-Webhooks empfangen, die App schickt einen POST-Request. Einfach, funktioniert.

Aber: Webhooks sind Einbahnstraßen. Die App schickt, HA empfängt — eine Antwort kommt nicht zurück. Für Schreiboperationen reicht das. Für Leseoperationen nicht: die App will nicht nur speichern, sie will auch Daten abrufen.

Die Alternative: eine eigene HA Custom Component, die WebSocket-Befehle registriert. Die App sendet wsCommand('ha_widgets/katalog/get') und bekommt die Antwort direkt zurück — synchron, über die bestehende WebSocket-Verbindung.

Kein extra HTTP-Endpunkt, kein CORS-Problem. Die App kommuniziert mit HA sowieso schon über WebSocket — sie nutzt denselben Kanal jetzt auch für eigene Daten.


wsCommand: die Brücke

Auf der App-Seite ist wsCommand in useHA.ts eine einzige Funktion:

async function wsCommand<T = unknown>(
  type: string,
  data: Record<string, unknown> = {}
): Promise<T> {
  if (!_conn) throw new Error('Not connected')
  return _conn.sendMessagePromise<T>({ type, ...data } as MessageBase)
}

sendMessagePromise schickt eine Nachricht über den WebSocket und gibt eine Promise zurück, die aufgelöst wird wenn HA antwortet. Die type-Property gibt an, welchen Befehl die Custom Component ausführen soll.

Rezeptkatalog laden:

const { wsCommand } = useHA()
const data = await wsCommand<RezeptDaten>('ha_widgets/rezepte/get')

Rezept speichern:

await wsCommand('ha_widgets/rezepte/create', { rezept: neuesRezept })

In den Composables (useRezeptDaten, useVorratKatalog, ...) steht nur das — kein fetch, kein axios, keine REST-URL. wsCommand ist der einzige Ausgang nach außen.


Die API

Die Custom Component registriert 12 WebSocket-Befehle:

Katalog — Vorratskatalog (alle Artikel, Kategorien, Nährwerte):

  • ha_widgets/katalog/get — Katalog laden
  • ha_widgets/katalog/save — Katalog komplett speichern

Vorrat — aktueller Vorratsstand (welche Artikel gerade vorhanden sind):

  • ha_widgets/vorrat/get — Vorratsstand laden
  • ha_widgets/vorrat/save — Vorratsstand komplett speichern

Rezepte — Rezeptkatalog, CRUD:

  • ha_widgets/rezepte/get — Katalog laden (Rezepte, Kategorien, Tags)
  • ha_widgets/rezepte/create — Rezept anlegen (Upsert per Slug)
  • ha_widgets/rezepte/update — Rezept aktualisieren
  • ha_widgets/rezepte/delete — Rezept löschen
  • ha_widgets/rezepte/save_kategorien — Kategorieliste ersetzen
  • ha_widgets/rezepte/save_tags — Tagliste ersetzen
  • ha_widgets/rezepte/rename_tag — Tag umbenennen (in allen Rezepten)

KI:

  • ha_widgets/ki/anfrage — Prompt an Claude schicken, Antwort zurückbekommen

Wie eine Custom Component gebaut wird

Eine HA Custom Component ist ein Python-Paket unter custom_components/<domain>/. Das Minimum:

  • manifest.json — Metadaten: Name, Version, Domain
  • __init__.py — Logik: WebSocket-Befehle registrieren

manifest.json:

{
  "domain": "ha_widgets",
  "name": "HA Widgets",
  "version": "1.0.0",
  "config_flow": true,
  "iot_class": "local_push"
}

In __init__.py werden die Befehle in async_setup registriert. Ein Befehl sieht so aus:

@websocket_api.websocket_command({
    vol.Required("type"): "ha_widgets/katalog/get",
})
@websocket_api.async_response
async def ws_katalog_get(hass, connection, msg):
    data = await hass.async_add_executor_job(
        _read_json, "vorrat_katalog.json", {}
    )
    connection.send_result(msg["id"], data)

websocket_api.async_register_command(hass, ws_katalog_get)

@websocket_api.websocket_command definiert das Schema — welche Parameter der Befehl erwartet. @websocket_api.async_response signalisiert dass der Handler die Antwort selbst sendet. hass.async_add_executor_job führt blockierende Operationen (Dateizugriff) im Thread-Pool aus, nicht im Event-Loop.

Für Befehle mit Parametern kommt das Schema dazu:

@websocket_api.websocket_command({
    vol.Required("type"): "ha_widgets/rezepte/create",
    vol.Required("rezept"): dict,
})

Das Paket wird nach /config/custom_components/ha_widgets/ kopiert, HA neu gestartet, dann in Einstellungen → Integrationen → Integration hinzufügen gesucht.

HA-Integrationsseite mit ha_widgets und Konfigurationsformular


Datenhaltung: JSON-Dateien

Alle Daten liegen als JSON-Dateien unter /config/ha_widgets/ auf dem HA-Server:

  • vorrat_katalog.json — Katalog: alle Artikel, Kategorien, Nährwerte
  • vorrat_state.json — Vorratsstand: welche Artikel gerade vorhanden sind
  • rezepte_katalog.json — Rezepte, Kategorien, Tags

Beim ersten Start prüft die Custom Component ob die Dateien existieren — falls nicht, werden Seed-Dateien aus dem Paket kopiert:

seed_dir = os.path.join(os.path.dirname(__file__), "data")
for filename in ("vorrat_katalog.json", "vorrat_state.json", "rezepte_katalog.json"):
    target = os.path.join(DATA_DIR, filename)
    if not os.path.exists(target):
        shutil.copy(os.path.join(seed_dir, filename), target)

Kein Datenbank-Setup, kein Migration-Script, kein Schema. Die Dateien kann man bei Bedarf direkt im Editor öffnen — praktisch beim Debuggen.


CRUD vs. Full-Write

Rezepte nutzen CRUD (create/update/delete), Vorratsstammdaten und Vorratsstand werden immer komplett geschrieben. Der Unterschied ist nicht zufällig.

Rezepte: Der Rezeptkatalog kann groß werden. Die Desktop-App bearbeitet einzelne Rezepte — sie hat nicht zwingend immer den gesamten Datensatz im Speicher. CRUD macht Operationen atomarer: zwei Apps die gleichzeitig verschiedene Rezepte bearbeiten, überschreiben sich nicht gegenseitig.

Vorratsstammdaten und Vorratsstand: Die Vorratsstammdaten (vorrat_katalog.json) — alle Artikel mit Kategorien und Nährwerten — werden ausschließlich über die Katalog-View im Desktop verwaltet. Den aktuellen Vorratsstand (vorrat_state.json) — welche Artikel gerade vorhanden sind — ändern Tablet und Handy. In beiden Fällen hat die App den kompletten State sowieso im Speicher, und es gibt nur eine schreibende Stelle gleichzeitig. Full-Write ist hier einfacher und sicher genug.

vorrat_state.json ist ein einfaches Array von Strings. Beim Tippen auf „vorhanden" in der App wird der neue Gesamt-Array gesendet:

await wsCommand('ha_widgets/vorrat/save', { data: neuerVorratsstand })

Kein add_item/remove_item — der Client kennt den Zustand und schreibt ihn komplett.


Token-Management

Die WebSocket-Verbindung zu HA braucht einen Long-Lived Access Token. Hardcodiert wäre ein Problem — der Token würde im Build landen, im Repo stehen, überall zu finden sein. Also: localStorage.

Beim Verbindungsaufbau in useHA.ts:

async function getToken(): Promise<string> {
  const stored = localStorage.getItem('ha_token')
  if (stored) return stored
  return new Promise<string>((resolve) => {
    _tokenResolve = resolve
    tokenDialogVisible.value = true
  })
}

Ist kein Token gespeichert, wird ein reaktives Flag gesetzt und eine Promise erzeugt die wartet bis der Nutzer etwas eingibt. TokenDialog.vue in shared/components/ beobachtet das Flag und rendert sich darüber — in allen drei Apps gleichzeitig, denn er liegt in shared/.

Der Dialog bietet zwei Eingabewege: Token manuell einfügen oder per QR-Code scannen (die HA-Profil-Seite kann den Token als QR anzeigen). Der Scan-Weg setzt HTTPS voraus — auf HTTP wird er ausgeblendet.

Was wenn der Token ungültig ist — zum Beispiel nach einem HA-Neustart mit gelöschten Tokens? HA schließt die WebSocket-Verbindung mit Fehlercode 2 (ERR_INVALID_AUTH). useHA.ts fängt das ab:

if (code === 2) {
  localStorage.removeItem('ha_token')
  _conn = null
  await connect()   // → TokenDialog erscheint wieder
}

Token löschen, _conn zurücksetzen, connect() erneut aufrufen. getToken() zeigt dann wieder den Dialog. Das passiert transparent: der Dialog erscheint, neuer Token rein, Verbindung steht wieder.

TokenDialog — »Home Assistant verbinden« mit Token-Eingabefeld und QR-Code-Button


KI-Flow

Der Replikator auf dem Tablet schlägt Gerichte aus vorhandenen Zutaten vor. Die Antwort kommt von Claude — was einen leicht seltsamen Loop ergibt: Claude Code hat den Code geschrieben, der Claude aufruft.

Der Weg einer KI-Anfrage von Anfang bis Ende:

  1. App sendet wsCommand('ha_widgets/ki/anfrage', { prompt: '...' })
  2. Custom Component empfängt den Befehl, schickt HTTP-POST an den lokalen Proxy
  3. Proxy (claude_proxy.py) ruft claude -p <prompt> als Subprocess auf
  4. Claude CLI antwortet mit Text
  5. Proxy gibt die Antwort als JSON zurück
  6. Custom Component leitet sie an die App weiter — die Promise aus Schritt 1 löst sich auf

Die Custom Component als Mittelsmann ist kurz:

async with aiohttp.ClientSession() as session:
    async with session.post(
        url,
        json={"prompt": msg["prompt"]},
        headers={"X-Token": token},
        timeout=aiohttp.ClientTimeout(total=120),
    ) as resp:
        result = await resp.json()
connection.send_result(msg["id"], result)

Der Proxy selbst ist ein einfacher Python-HTTP-Server ohne Framework:

result = subprocess.run(
    ['claude', '-p', prompt],
    capture_output=True, text=True,
    timeout=TIMEOUT, env=os.environ.copy()
)
self.wfile.write(json.dumps({'text': result.stdout}).encode())

Er läuft als systemd-Service im Heimnetz und akzeptiert nur Anfragen aus dem lokalen Subnet — plus einen statischen Token im X-Token-Header als zweite Absicherung.

Warum nicht die Anthropic-API direkt aufrufen? Zwei Gründe: kein API-Key muss auf dem HA-Server gespeichert werden, und die CLI nutzt die lokal bereits konfigurierte Authentifizierung. Außerdem lässt sich über den Proxy zentral steuern welches Modell, welcher Kontext, welche Parameter verwendet werden — ohne die App anfassen zu müssen.

Proxy-Host, Port und Token können nach der Installation in den HA-Integrationseinstellungen angepasst werden.

Replikator — Zutatenauswahl und KI-Antwort mit Rezeptvorschlag


Wie geht es weiter?

Die Infrastruktur steht. Was bisher noch gefehlt hat: wie die Apps aussehen und was sie konkret können. Teil 6 schaut auf die Tablet-App — LCARS-Design, CSS-Tricks für Bögen und Notch-SVGs, das Replikator-Interface im Star-Trek-Stil.


Fazit

  • Custom Component statt Webhooks — bidirektional, über den bestehenden WebSocket, ohne CORS oder extra Endpunkte
  • wsCommand(type, data) — ein generischer Aufruf über sendMessagePromise, den alle Composables nutzen
  • 12 WebSocket-Commands für Katalog, Vorrat, Rezepte und KI
  • JSON-Dateien unter /config/ha_widgets/ als einfache, direkt editierbare Datenhaltung, mit Seed-Dateien beim ersten Start
  • CRUD für Rezepte, Full-Write für Katalog und Vorratsstand — je nach Semantik der Operation
  • Token aus localStorageTokenDialog.vue erscheint wenn keiner vorhanden oder ungültig ist; automatischer Retry bei ERR_INVALID_AUTH
  • KI-Flow: App → Custom Component → lokaler Python-Proxy → Claude CLI → Antwort zurück; kein API-Key auf dem Server nötig