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 ladenha_widgets/katalog/save— Katalog komplett speichern
Vorrat — aktueller Vorratsstand (welche Artikel gerade vorhanden sind):
ha_widgets/vorrat/get— Vorratsstand ladenha_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 aktualisierenha_widgets/rezepte/delete— Rezept löschenha_widgets/rezepte/save_kategorien— Kategorieliste ersetzenha_widgets/rezepte/save_tags— Tagliste ersetzenha_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.

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ährwertevorrat_state.json— Vorratsstand: welche Artikel gerade vorhanden sindrezepte_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.

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:
- App sendet
wsCommand('ha_widgets/ki/anfrage', { prompt: '...' }) - Custom Component empfängt den Befehl, schickt HTTP-POST an den lokalen Proxy
- Proxy (
claude_proxy.py) ruftclaude -p <prompt>als Subprocess auf - Claude CLI antwortet mit Text
- Proxy gibt die Antwort als JSON zurück
- 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.

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 übersendMessagePromise, 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
localStorage—TokenDialog.vueerscheint wenn keiner vorhanden oder ungültig ist; automatischer Retry beiERR_INVALID_AUTH - KI-Flow: App → Custom Component → lokaler Python-Proxy → Claude CLI → Antwort zurück; kein API-Key auf dem Server nötig
