HA Widgets — Eigene Home-Assistant-Apps mit Vue 3 (Teil 3: Architektur — wenn eine App nicht reicht)

- 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 2 hat mit einem Cliffhanger geendet: eine App läuft, die WebSocket-Verbindung steht, Sensor lesen und Schalter toggeln funktioniert. Und dann merkt man: man braucht eine zweite App. Und eine dritte. Und alle drei brauchen dieselbe Einkaufslisten-Logik, dieselben TypeScript-Typen, denselben Code zum Laden der Rezepte.

Copy-Paste ist keine Antwort darauf. Das wäre die Architektur-Frage, die am Ende von Teil 2 angekündigt wurde.


Eine gemeinsame Library

Die Lösung ist ein viertes Paket neben den drei Apps: shared/. Ein npm-Paket mit allem was alle Apps brauchen — Composables, TypeScript-Interfaces, Konfiguration, Utility-Funktionen.

npm Workspaces machen das ohne Publish-Overhead möglich. Die package.json im Root:

{
  "name": "ha-web-widgets",
  "private": true,
  "workspaces": ["shared", "tablet/app", "phone/app", "desktop/app"]
}

Ein npm install im Root — alle Pakete teilen sich node_modules. Kein Publish, kein Symlink-Gefummel.

Vite braucht noch einen Alias, weil npm-Workspace-Symlinks unter WSL nicht zuverlässig funktionieren:

resolve: {
  alias: {
    '@ha-widgets/shared': fileURLToPath(
      new URL('../../shared/index.ts', import.meta.url)
    ),
  }
}

Danach importieren alle drei Apps aus @ha-widgets/shared — und meinen damit denselben Quellcode.

Ordnerstruktur


Composables statt Logik in Views

Eine der Kern-Regeln aus der CLAUDE.md: kein fetch() in Views, kein direktes callAction(), keine Geschäftslogik im Template. Alles gehört in Composables.

Warum? Views ändern sich ständig — Layout, Interaktion, Darstellung. Geschäftslogik sollte davon unberührt bleiben. Und wenn Tablet und Phone beide die Einkaufsliste laden, soll das dieselbe Funktion sein.

Ein Beispiel. So würde es nicht gemacht:

// ❌ Logik direkt in der View
const katalog = ref(null)
onMounted(async () => {
  katalog.value = await wsCommand('ha_widgets/katalog/get')
})

So sieht es tatsächlich aus:

// ✅ View ruft nur den Composable auf
const { loadKatalog } = useVorratKatalog()

useViewInit(async () => {
  katalog.value = await loadKatalog()
})

Die View weiß nicht wie Daten geladen werden, woher sie kommen oder wie der Cache funktioniert. Sie bekommt ein Ergebnis.


Konfiguration zentral

Drei Dateien ersetzen alle hardcodierten Strings im Code:

entityConfig.ts — alle Home-Assistant-Entity-IDs als E-Objekt:

export const E = {
  lights: {
    wohnzimmer: 'light.licht_wohnzimmer',
    bad:        'light.licht_bad',
  },
  climate: {
    wohnzimmer: 'climate.heizung_wohnzimmer_geregelt',
  },
  todo: 'todo.einkaufsliste',
  // ...
}

Im Template steht dann E.lights.wohnzimmer statt dem String direkt. Ändert sich eine Entity-ID in HA, eine Stelle im Code — fertig.

apiConfig.ts — externe URLs:

export const NOMINATIM_REVERSE_URL    = 'https://nominatim.openstreetmap.org/reverse'
export const OPENFOODFACTS_SEARCH_URL = 'https://world.openfoodfacts.org/cgi/search.pl'
export const HA_CAMERA_PROXY_PATH     = '/api/camera_proxy'

types.ts — alle TypeScript-Interfaces an einem Ort:

export interface KatalogItem  { kcal: number; einheit: string; ... }
export interface Rezept        { titel: string; zutaten: string[]; ... }
export interface EinkaufItem   { id: string; name: string; status: string }

Nie lokal ein Interface definieren das auch woanders gebraucht wird.


Cache-Strategie

Drei Apps mit je einer WebSocket-Verbindung zu HA. Jeder Datenabruf kostet einen Round-Trip. Wenn beim App-Start alle Views gleichzeitig den Rezeptkatalog anfordern, sollen nicht drei identische Requests rausgehen.

Die Lösung: modul-globaler Cache mit 20 Sekunden TTL — und Promise-Caching für parallele Aufrufe:

let _cache: VorratKatalog | null = null
let _cacheTime = 0
let _promise: Promise<VorratKatalog | null> | null = null
const TTL = 20_000

async function loadKatalog(): Promise<VorratKatalog | null> {
  if (_cache && Date.now() - _cacheTime < TTL) return _cache
  if (_promise) return _promise   // zweiter Aufruf wartet auf laufenden Request
  _promise = wsCommand('ha_widgets/katalog/get')
    .then(data => {
      _cache = data
      _cacheTime = Date.now()
      _promise = null
      return data
    })
  return _promise
}

Der Cache lebt im Modul-Scope — nicht in einer Vue-Instanz. Alle Komponenten die useVorratKatalog() aufrufen teilen sich denselben Cache.


useViewInit — das Connection-Watch-Pattern

Jede View muss warten bis HA verbunden ist, bevor sie Daten lädt. Und beim Unmount muss sie aufräumen. Das ist in jeder View identisch — also einmal geschrieben:

useViewInit(async () => {
  // wird einmalig aufgerufen sobald HA verbunden ist
  katalog.value = await loadKatalog()
  vorrat.value  = await loadVorratState()
})

Optional: auf Änderungen der Einkaufsliste reagieren (HA Todo-Entity):

useViewInit(
  async () => { katalog.value = await loadKatalog() },
  async () => { liste.value   = await loadList() },
)

Intern stoppt useViewInit den Watch nach dem ersten erfolgreichen Connect — kein erneutes Laden bei kurzen Verbindungsunterbrechungen.

Eine subtile Falle dabei: der Watch-Stopper muss als let deklariert sein, nicht const. Mit { immediate: true } und const gibt es einen TDZ-Fehler weil der Callback ausgeführt wird bevor die Variable initialisiert ist. Kleines Detail, aber es hat tatsächlich einen Bug produziert.


IPC zwischen Apps

Drei Apps, ein HA WebSocket. Wenn der Desktop ein Rezept speichert, sollen Tablet und Phone das sofort mitbekommen — ohne Polling, ohne extra Server-Roundtrip.

Lösung: HA Custom Events als IPC-Bus. HA broadcastet Events an alle verbundenen WebSocket-Clients — also an alle Apps gleichzeitig.

// Senden — z.B. nach Rezept-Speichern im Desktop
const { send } = useIPC()
await send('rezepte_updated')

// Empfangen — in jeder App via useAppIPC()
// → Cache wird invalidiert, Views re-rendern automatisch

useAppIPC() richtet die Standard-Subscriptions ein. Jede App ruft es einmal in App.vue auf:

// App.vue (alle drei Apps)
useAppIPC()

Das abonniert: rezepte_updated (Rezept-Cache invalidieren), vorrat_updated (Katalog + Vorrat-Cache leeren), reload_all (Seite neu laden für Updates).

Apps können zusätzliche Events registrieren. Das Tablet hört auf navigate_rezept — der Desktop kann damit direkt ein Rezept auf dem Tablet aufschlagen:

// App.vue im Tablet
useAppIPC(async (on) => [
  await on('navigate_rezept', ({ slug }) => {
    router.push('/rezepte/' + slug)
  })
])

Das ist Inter-App-Kommunikation ohne eigenen Server, ohne WebRTC, ohne Polling — einfach weil alle sowieso am selben HA-WebSocket hängen.

Wer HA Developer Tools offen hat wenn eine App ein Event sendet, sieht es live: unter Events erscheint spy_ha_widgets_ipc mit dem kompletten Payload. Gutes Debugging-Werkzeug wenn man nicht sicher ist ob IPC-Events ankommen.

HA Developer Tools → Events


Architektur-Regeln maschinell prüfen

Regeln in einer CLAUDE.md aufschreiben ist gut. Aber irgendwann ist die Codebasis groß genug dass man nicht mehr jeden Commit im Kopf behalten kann — und Claude Code auch nicht. Deshalb laufen nach jeder größeren Änderung automatische Checks: tsc --noEmit für Typen, und ESLint mit Custom Rules für Architektur-Verstöße — kein fetch() in Views, kein any, keine ungenutzten Variablen.

Dass das mehr als Theorie ist, hat ein konkreter Fund bewiesen. Der no-unused-vars-Check hat in TokenDialog.vue gemeldet: cameraAvailable sei definiert aber nie verwendet. Stimmt — aber die Variable stand im Template per v-if="cameraAvailable". Wie kann das sein?

Der Fehler: TokenDialog nutzt die Options-API mit setup(). Variablen die dort definiert werden müssen explizit zurückgegeben werden — sie sind sonst im Template nicht verfügbar. cameraAvailable fehlte im return {}. Der Button zum QR-Code-Scannen war damit von Anfang an versteckt, obwohl er existiert.

Das war kein Fehler den TypeScript oder der Browser jemals gemeldet hätte — v-if mit undefined ist einfach falsy und fertig. Erst der Lint-Check hat es aufgedeckt.

Wie die ESLint-Konfiguration im Detail aussieht, welche blinden Flecken trotzdem bleiben, und wie man mit jscpd Copy-Paste-Klone findet bevor sie zum Problem werden — das ist Thema von Teil 4.


Wie geht es weiter?

Teil 4 schaut genauer auf die Werkzeuge hin: ESLint-Konfiguration im Detail, ein selbstgeschriebenes Python-Script für einen blinden Fleck den weder tsc noch ESLint abdecken, und jscpd zum Aufspüren von Template-Klonen — inklusive konkretem Refactoring mit echten Bugs die dabei aufgetaucht sind.

Danach geht es in Teil 5 um die andere Hälfte der Infrastruktur: die HA Custom Component hinter wsCommand, die Datenhaltung in JSON-Dateien, und der KI-Flow von der App bis zu Claude.


Fazit

  • npm Workspaces mit vier Paketen: shared/, tablet/app, phone/app, desktop/app — ein npm install, ein node_modules
  • Vite-Alias statt npm-Symlink (zuverlässiger unter WSL)
  • Composables kapseln alle Daten und Logik — Views bekommen nur Ergebnisse
  • entityConfig.ts für Entity-IDs, apiConfig.ts für externe URLs, types.ts für alle Interfaces
  • Cache mit 20s TTL + Promise-Caching verhindert doppelte Requests
  • useViewInit standardisiert das Connection-Watch-Pattern in allen Views
  • IPC über HA Custom Events — alle Apps teilen sich den WebSocket und kommunizieren darüber direkt
  • tsc --noEmit + ESLint prüfen Typen und Architektur-Regeln automatisch — und haben einen echten Bug gefunden der sonst nie aufgefallen wäre (Details zur Konfiguration und weiteren Tools in Teil 4)