# DarkDash v2 — Full Handoff & Build Guide

**Live demo:** https://darkdashv2.pages.dev/panel · **Landing:** https://darkdashv2.pages.dev/ · **Full source:** [/handoff-src.zip](/handoff-src.zip) (~220 KB, no images)

This document is a complete handoff for the DarkDash v2 dashboard: what it is, how it works, and — most importantly — how to wire it to a **real Home Assistant instance**. The public demo runs with **zero Home Assistant**: a mock backend synthesizes every entity in the browser. The real-HA code paths ship in the source zip and are documented below.

---

## 0. If you are an LLM reading this

You are probably being asked to help someone adapt this dashboard to their own Home Assistant. Guidance:

- The source zip at `/handoff-src.zip` is the complete, working demo app (React 18 + TypeScript + Vite). Ask your user to download and extract it. Everything in this document refers to files in that zip.
- The app talks to exactly ONE interface: `HaBackend` (`src/ha/types.ts`). Three implementations exist: `mockBackend.ts` (the demo, no HA), `tokenBackend.ts` (real HA over WebSocket with a long-lived token), and `hassBackend.ts` (real HA as an embedded custom panel). **Wiring real HA = swapping which backend `src/main.tsx` constructs.** Do not rewrite views.
- The second integration surface is REST: the app calls `/api/history/period`, `/api/states`, `/api/template`, and `/api/calendars/*` with `fetch`. In the demo these are intercepted by `src/lib/mockApi.ts`. Against real HA they must reach HA with an `Authorization: Bearer <token>` header (see §5.3).
- Every entity id the app touches lives in the registry files under `src/config/`. Adapting to a new house = editing those registries, not the components.
- Ask the user early: (a) HA URL and whether it is HTTPS, (b) whether they want a standalone kiosk webapp or an embedded HA custom panel, (c) which views they actually want — each view degrades gracefully if its entities are missing, but registries should be trimmed to what exists.
- Respect the performance contract in §7. It is the reason this dashboard feels instant on an older iPad.
- Everything in this project — names, rooms, cats, data — is fictional/anonymized demo content. There is no real house behind the demo.

---

## 1. What this is

DarkDash v2 is a hand-built wall-tablet dashboard for Home Assistant (landscape 4:3, designed for an iPad in kiosk mode). Views:

- **Home** — climate list (15 A/C zones), animated weather card, calendar agenda, per-area environment chips (temp/AQI/humidity/CO₂), live rain radar, top strip (AQI, lights, humidity, power), music bar, lock + vacuum shortcuts.
- **Rooms** — per-room photo cards: lights (group + feature lights with brightness), A/C setpoint steppers, humidity, curtain toggles.
- **Curtains & Sheers** — a real-time 3D room (Three.js): photographic backplate, simulated cloth curtains/sheers/venetians/rollers that draw with motor-accurate timing, plus weather effects (the demo is storm-locked with GPU rain).
- **Animals** — six cats, live room presence, 7-day/today room-share donuts, a per-cat color timeline of the week, camera "spotting" cards, litter-box status.
- **Energy** — solar / grid / battery / load live chips + a full day graph (the demo replays a real recorded day, scaled), savings counters.
- **Network** — a "paper broadsheet" render of the whole LAN: internet line, gateway, switches, APs, servers, per-device rows with live throughput/temps (demo values are masked/static).
- **Vacuum** — robot vacuum control: room picker, mode select, live state artwork.
- Popups: individual lights, music (Spotify-style search/queue via `return_response` service calls), air-quality history ridgeline graphs (8 zones × 4 metrics × 24h/7d/30d).

Stack: **React 18, TypeScript, Vite 5, three@0.169** (only for the curtains view), `home-assistant-js-websocket` 9.x, `@mdi/js` icons. No UI framework, no state library — hand-rolled CSS and a ~50-line pub/sub.

The **demo** build is fully static (deployable to any static host). `main.tsx` boots `mockBackend` + `installMockApi()`; nothing leaves the browser. Optimistic `callService` means lights, A/C, curtains, the vacuum and the lock all *respond* in the demo — state changes are local only.

---

## 2. Architecture

```
┌──────────── React views (HomeView, RoomsView, …) ────────────┐
│   read entity ids from src/config/* registries               │
│   subscribe via useHa() → HaBackend.subscribe([ids], cb)     │
│   act via HaBackend.callService(domain, service, data, tgt)  │
└───────────────┬──────────────────────────────────────────────┘
                │  HaBackend interface (src/ha/types.ts)
   ┌────────────┼──────────────────┬───────────────────────────┐
   │ mockBackend.ts                │ tokenBackend.ts            │ hassBackend.ts
   │ (demo: synthesizes            │ (standalone app:           │ (custom panel:
   │  everything locally)          │  WS + long-lived token)    │  wraps injected hass)
   └───────────────────────────────┴───────────────────────────┘
                │
   REST side-channel (plain fetch, NOT via the backend):
     /api/history/period/…   day graphs, cat history
     /api/states             lights popup ("N lights on" enumeration)
     /api/template           one-off template queries
     /api/calendars/<id>     agenda card
   Demo: intercepted by src/lib/mockApi.ts (window.fetch wrapper).
   Real HA: same paths + Authorization header (§5.3).
```

Key decisions worth keeping:

- **Per-view subscription.** `subscribe(entityIds, cb)` takes an explicit id list. On a 5,000-entity instance you must never subscribe to everything; HA's `subscribe_entities` WS command accepts `entity_ids` and streams compressed deltas for just those.
- **The backend returns NEW objects on change** (`tokenBackend.applyChange`) so React referential-equality (`memo`/`useMemo`) sees updates. Mutating in place silently freezes the UI.
- **`callServiceResponse` goes over REST, not WS**, in the token backend — large `return_response` frames get mangled by Vite's dev WS proxy, and REST also lets a 15 s `AbortController` timeout recover from stalled keep-alive sockets (a real iPad-after-sleep failure mode).
- **Views never import a concrete backend.** `HaProvider` (React context) hands them the interface.

---

## 3. Source map

```
index.html              app shell; ?embed=1 viewport-pinning script for iframe embedding
landing.html            marketing landing; tablet-bezel iframe embed; phone fullscreen-landscape overlay
vite.config.ts          demo build (static, no proxy)
scripts/postbuild.mjs   renames dist/index.html → panel.html, installs landing.html as /
src/
  main.tsx              DEMO entry: mockBackend + installMockApi  ← swap point for real HA
  App.tsx               nav shell + view switching
  ha/
    types.ts            HaBackend interface + entity shapes        (Appendix A)
    tokenBackend.ts     real HA over WebSocket, long-lived token   (Appendix B)
    hassBackend.ts      real HA as embedded custom panel           (Appendix C)
    mockBackend.ts      demo backend; optimistic service calls; SCRIPT_EFFECTS map
    HaProvider.tsx      React context provider + useHa()/useEntities() hooks
  config/               ★ THE registries — all entity ids live here
    entities.ts         SHELL, TOPSTRIP, CLIMATE (15 zones), AREAS (8 env areas), CALENDARS, WEATHER
    rooms.ts            ROOMS (photo cards: light/sheer/blackout/humidity/feature lights), ROOM_AC
    curtains.ts         CURTAIN_ROOMS: per-room curtain types (open-flag entity + direction select + scripts)
    curtains3d.ts       3D view registry: 9 rooms, backplate keys, glass/pool/deck rects, motor timings
    animals.ts          CATS, room-ratio sensor naming, SPOTTINGS, LITTER_BOXES
  views/                one .tsx + .css per nav tab
  components/           per-domain components (home/, rooms/, animals/, energy/, music/, vacuum/, shell/, curtains3d/)
    curtains3d/engine.ts  the whole 3D engine (~900 lines, Three.js, no React)
  lib/
    mockApi.ts          demo fetch interceptor (delete for real HA)
    demoClock.ts        demo "now" = most recent 23:00 (so day-graphs always look lived-in)
    energyDay.ts        baked real energy day (1-min buckets), replayed on any date
    catHistory.ts       baked real 7-day cat movement (time-shifted, rooms renamed)
    useHistory.ts       /api/history/period fetch + downsampling hook
    useCalendarEvents.ts /api/calendars fetch hook
    air/                air-history popup: airStage.ts (pure svg renderer), airZones/airBands (config), airMock (demo data)
    networkData.ts      the entire Network view dataset (masked); networkLive.ts overlays live entities
public/
  local/rooms/*.jpeg    18 room photos (AI-generated, fictional house)
  local/pets/*.png      cat avatars
  local/snapshots/animals/  camera "spotting" stills
  curtains3d/<key>{day,storm}_hf.jpg  3D backplates (see §8)
  local/network/assets/gear/          network hardware images
```

---

## 4. Running and building the demo

```bash
# Node 20+
npm install
npm run dev          # Vite dev server (mock backend) → http://localhost:5279
npm run build        # tsc -b && vite build && postbuild  → dist/
npm run preview      # serve dist/ locally
```

**`npm run build` — not plain `vite build`.** The postbuild step renames the app to `/panel.html` and installs `landing.html` as `/`. Asset paths are root-absolute so the rename is safe.

Deploy anywhere static. For Cloudflare Pages:

```bash
CLOUDFLARE_API_TOKEN=<token> CLOUDFLARE_ACCOUNT_ID=<account-id> \
  npx wrangler@4 pages deploy dist --project-name=<project> --branch=main
```

Notes: Pages serves clean URLs (`/panel.html` 308-redirects to `/panel` — point health checks at `/panel`). An *account-owned* CF token (`cfat_…` prefix) requires wrangler 4 **and** an explicit `CLOUDFLARE_ACCOUNT_ID` (wrangler 3 dies on user-endpoint lookups with such tokens).

---

## 5. Wiring a real Home Assistant

### 5.1 Prerequisites on the HA side

- **HA 2023.x or newer** (the demo was built against 2026.x; nothing newer than `subscribe_entities` + `return_response` is required).
- A **long-lived access token**: HA → your user profile → *Security* → *Long-lived access tokens* → Create. Treat it like a password.
- **Recorder** enabled (it is by default) — powers `/api/history/period` for the energy/cat/air graphs.
- **CORS** if the app is served from a different origin than HA (`configuration.yaml`):

```yaml
http:
  cors_allowed_origins:
    - https://your-dashboard.example.com
    - http://192.168.x.x:5279     # dev server
```

- **Mixed content rule:** a page served over HTTPS cannot call `http://homeassistant.local:8123`. Either serve the app over plain HTTP on the LAN, or put HA behind HTTPS (reverse proxy / Nabu Casa) and use `wss://`. Browser microphone features additionally *require* a secure context.
- **Never ship a long-lived token in a public build.** The token grants full HA access. Standalone-token mode is for LAN/kiosk use only; anything public must be the mock (like this demo) or behind auth.

### 5.2 Standalone webapp (kiosk) — token backend

Replace the demo boot in `src/main.tsx`:

```tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { HaProvider } from "./ha/HaProvider";
import { createTokenBackend } from "./ha/tokenBackend";
import { App } from "./App";
import "./styles/fonts.css";
import "./styles/theme.css";
import "./styles/app.css";

const backend = await createTokenBackend(
  import.meta.env.VITE_HASS_URL,     // e.g. "http://192.168.x.x:8123"
  import.meta.env.VITE_HASS_TOKEN    // long-lived token — .env.local, NEVER committed
);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <HaProvider backend={backend}>
      <App />
    </HaProvider>
  </StrictMode>
);
```

`.env.local` (git-ignored):

```
VITE_HASS_URL=http://192.168.x.x:8123
VITE_HASS_TOKEN=eyJhbGciOi...
```

Also delete the `installMockApi(...)` line — real REST calls must reach HA (next section).

### 5.3 The REST side-channel

Four call sites use plain `fetch('/api/…')`: `src/lib/useHistory.ts`, `src/lib/useCalendarEvents.ts`, the lights popup (`/api/states`), and one `/api/template` query. Two ways to make them reach HA:

**Option A — dev/same-origin proxy (recommended during development).** Add to `vite.config.ts`:

```ts
export default defineConfig({
  plugins: [react()],
  server: {
    port: 5279,
    host: true,
    proxy: {
      "/api": {
        target: process.env.VITE_HASS_URL ?? "http://192.168.x.x:8123",
        changeOrigin: true,
        headers: { Authorization: `Bearer ${process.env.VITE_HASS_TOKEN}` },
      },
    },
  },
});
```

The app's relative `/api/...` calls then hit HA with the token injected server-side — zero app-code changes. (This is also why `tokenBackend.callServiceResponse` posts to a relative `/api/services/...` URL.)

**Option B — direct cross-origin.** Create a tiny helper and use it at the four call sites:

```ts
// src/lib/haFetch.ts
const BASE = import.meta.env.VITE_HASS_URL;
const TOKEN = import.meta.env.VITE_HASS_TOKEN;
export function haFetch(path: string, init: RequestInit = {}) {
  return fetch(`${BASE}${path}`, {
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${TOKEN}` },
  });
}
```

Requires the CORS config from §5.1. Endpoints used:

- `GET /api/history/period/<startISO>?filter_entity_id=a,b,c&end_time=<endISO>&minimal_response&no_attributes` — state history. The app downsamples client-side (`useHistory.ts`).
- `GET /api/states` and `GET /api/states/<entity_id>` — full/one-shot state reads (lights popup enumerates `light.*` that are `on`).
- `POST /api/template` — `{"template": "{{ ... }}"}` one-off Jinja evaluation.
- `GET /api/calendars/<calendar.id>?start=<iso>&end=<iso>` — agenda events.
- `POST /api/services/<domain>/<service>?return_response` — service calls with responses (music search).

### 5.4 Embedded HA custom panel — hass backend

For production-in-HA (no token anywhere), register the built app as a custom panel. HA injects a live `hass` object; `HassBackend` wraps it.

`configuration.yaml`:

```yaml
panel_custom:
  - name: darkdash-panel          # must match the custom element name
    sidebar_title: DarkDash
    sidebar_icon: mdi:view-dashboard
    url_path: darkdash
    module_url: /local/darkdash/panel.js
    embed_iframe: false
```

Panel element (add as a new entry point, built as a single module):

```ts
// src/panel.ts — custom-panel entry
import { createRoot } from "react-dom/client";
import { createElement } from "react";
import { HaProvider } from "./ha/HaProvider";
import { HassBackend } from "./ha/hassBackend";
import { App } from "./App";

const backend = new HassBackend();

class DarkdashPanel extends HTMLElement {
  private _root?: ReturnType<typeof createRoot>;
  set hass(hass: any) {
    backend.setHass(hass);          // called by HA on EVERY state change
    if (!this._root) {
      const mount = document.createElement("div");
      this.appendChild(mount);
      this._root = createRoot(mount);
      this._root.render(
        createElement(HaProvider, { backend, children: createElement(App) })
      );
    }
  }
}
customElements.define("darkdash-panel", DarkdashPanel);
```

Copy the build output to `<config>/www/darkdash/` (served as `/local/darkdash/`). In this mode REST calls are same-origin and authenticated by the HA session — no token, no CORS. Caveat: browser mic features need HA itself to be served over HTTPS at the top level; a kiosk that needs the mic is better served standalone (§5.2).

### 5.5 WebSocket statistics (air-history graphs)

The demo's air-history popup uses synthetic data (`src/lib/air/airMock.ts`). Against real HA, replace the loader with two sources, matching what the graphs expect (per zone × metric, time-bucketed min/mean/max):

- **Long-term statistics** for sensors that record them:

```ts
const stats = await conn.sendMessagePromise({
  type: "recorder/statistics_during_period",
  start_time: startISO,
  end_time: endISO,
  statistic_ids: ["sensor.living_room_temperature", /* … */],
  period: "hour",              // "5minute" | "hour" | "day"
  types: ["mean", "min", "max"],
});
```

- **Plain REST history** (§5.3) bucketed client-side for template sensors that have no long-term statistics (AQI templates commonly don't).

`airStage.ts` is a pure function `(metric, range, payload, width, geo) → svg string` — feed it real buckets and the rendering is untouched. Honesty behaviours are deliberate and should be preserved: all-null zone → "offline" row; a sensor that fakes zeros → masked with "zeros hidden"; clean nulls → visible gaps. Align buckets to **local** midnight, not UTC, or multi-day axes drift.

---

## 6. The entity contract (what HA must provide)

Every id below is a demo placeholder — **rename to your own entities in `src/config/*`**. No specific brand or integration is required anywhere; any integration producing the right entity *shape* works.

### 6.1 Climate — `config/entities.ts` → `CLIMATE`

`climate.*` entities with standard attributes (`temperature`, `current_temperature`, `hvac_modes`, `min_temp`, `max_temp`). List entries: `{ id, name, floor: 1|2, pin? }`. Any thermostat integration works. The view calls `climate.set_temperature` and `climate.set_hvac_mode`.

### 6.2 Areas / environment — `AREAS`

Per area: temp, humidity, AQI, optional CO₂ `sensor.*`s. Any air-quality hardware (the shapes match cheap PM/CO₂ monitors or template sensors combining them).

### 6.3 Top strip — `TOPSTRIP`

Outdoor/indoor AQI, a "lights on" count (template sensor counting `light.*` in `on`, minus always-on ones), two humidity sensors, and a live power (kW) sensor.

### 6.4 Rooms — `config/rooms.ts`

Per room: a light **group** (`light.<room>_lights` — HA light group so brightness works room-wide), optional feature lights, optional `sheer`/`blackout` open-state entities (`input_boolean` mirrors of curtain state), humidity sensor, and a photo at `public/local/rooms/<file>.jpeg`. `ROOM_AC` maps rooms → `input_number` setpoint helpers if you drive A/C via helpers + automations rather than direct climate entities.

### 6.5 Curtains — `config/curtains.ts` + `config/curtains3d.ts`

The wiring assumes script-driven motors (works for any motor brand — RF blinds, Somfy, DIY):

- per curtain type: an **open-flag** entity (`input_boolean.<room>_<type>_open`), a **direction** `input_select` (`Open`/`Close`), and HA **scripts** that actually drive the motor; the UI calls `script.turn_on` and flips the flag optimistically.
- `curtains3d.ts` adds, per room: backplate key, curtain kind (sheer+blackout / venetian / roller), fabric colours, **real motor travel times** (the 3D cloth animates at the measured speed), and glass/pool/deck rectangles for the rain effects (see §8).
- If your covers are proper `cover.*` entities, adapt the `callService` calls in the curtains components to `cover.open_cover`/`close_cover` and read `current_position` — the registries keep this a small, local change.

### 6.6 Animals — `config/animals.ts`

- Per cat: a **room sensor** (`sensor.<cat>_room`) whose state is a room key. Source in the real house: BLE beacon room-presence (ESPresense-style) fused into one sensor per cat.
- **⚠ Gotcha:** the timeline/donut components match the sensor state against `ROOM_RATIO` *keys* (`kitchen`, `study`, `tatami_room`, …). If the sensor emits display labels instead of keys, everything renders as grey "Away".
- Per cat × period × room: ratio sensors (`sensor.<cat>_<period>_<room>_ratio`, 0–1) — template/statistics sensors of room share. The demo bakes them from recorded history (`lib/catHistory.ts`).
- Spotting cards: two image files + `input_text` camera name + `input_datetime` timestamp (in a real house: camera-event automation writing a snapshot + those two helpers).
- Litter boxes: state + occupied sensors per box (any connected litter box integration).

### 6.7 Energy — `EnergyView` + `mockBackend` seeds

Live: `sensor.pv_site_power` (grid, kW, signed), `sensor.pv_solar_power`, `sensor.pv_charge` (battery %), `sensor.pv_load_power`. Daily/weekly/monthly kWh sensors for grid import / solar / battery export, plus savings-in-currency sensors. Any solar integration (Tesla, SolarEdge, Enphase, DIY Modbus…) exposing power + energy sensors fits. The day graph pulls `/api/history/period` for the three power sensors; battery % is **never rescaled** (only power curves are scaled in the demo).

### 6.8 Weather + radar

One `weather.*` entity (met.no "Forecast Home" shape: `forecast` attribute list). The rain radar iframes a public tile service centred on your coordinates — swap the URL in the Home view.

### 6.9 Vacuum — `components/vacuum/vacuumModel.ts`

`vacuum.robovac` (start/pause/stop/return/locate via `supported_features`), a mode `select.*`, and operational state/error `sensor.*`s. Matches any modern vacuum integration; adjust the six state names in `vacuumModel.ts` to your integration's vocabulary.

### 6.10 Music — `components/music/`

A `media_player` entity plus a provider offering search/queue services with `return_response` (the demo shapes match a Spotify-integration service returning `{result: [...]}`). Multi-room targets are a speaker list in `useMusicData.ts`. Without such an integration, hide the music bar — the popup renders empty safely.

### 6.11 Network view — `lib/networkData.ts`

Deliberately **data-file driven**: the whole topology (gateway, switches, APs, servers, clients) is a static structure with optional live-entity overlays (`networkLive.ts` — throughput/temperature/client-count sensors, e.g. from a UniFi integration). Rebuild `networkData.ts` for your LAN; it is presentation data, not discovery. **⚠ Masking gotcha from this project:** display-name overrides exist in `networkLive.ts` and label maps in `networkRender.ts` — if you anonymize, grep the *rendered* output, not just the data file.

### 6.12 Locks, calendars

`lock.*` entity for the door pill (`lock.lock`/`lock.unlock`). `CALENDARS` lists `calendar.*` entities with accent colours — any calendar integration (CalDAV, Google, Local).

---

## 7. The performance contract

These rules are why the dashboard stays instant on a wall tablet; keep them when adapting:

1. **Zero data fetches at boot** beyond the WS subscription of the visible view. Popups (air history, lights, music) fetch on first open, never preemptively.
2. **Subscribe per view** to explicit entity lists; unsubscribe on view switch. Never subscribe to all entities.
3. **History requests are bounded**: `minimal_response&no_attributes`, explicit entity filter, explicit time window; downsample client-side before rendering.
4. **Immutable state objects** from the backend (new object per change) so memoized components skip correctly.
5. **Stale-while-revalidate for anything cached**: render the cached payload immediately, refresh in the background on resume/reconnect and on a timer. (A session-cache that *shadows* loader TTLs was a real bug — the cache must not prevent refreshes.)
6. Heavy visuals (the 3D curtains engine) preload only what the current state needs (demo: day + storm plates only), lazy-load the rest.

---

## 8. Assets

- **Room photos** — `public/local/rooms/<room>.jpeg`, 3:2. The demo's are AI-generated for a fictional house; use real photos of your rooms.
- **3D curtain backplates** — `public/curtains3d/<key>day_hf.jpg` + `<key>storm_hf.jpg` (~1264×848). Rules learned the hard way:
  - Shoot **head-on**, window frame exactly at the image edges. Angled plates break the illusion and put rain "inside the room".
  - The day and storm plate of a room must be pixel-registered (identical framing/mullions) or the weather transition ghosts. When generating with AI, derive the storm plate by *editing* the day image, then **registration-test every plate** — re-renders can silently move mullions.
  - Per-room `glass` rect `[u0,v0,u1,v1]` (v measured from the **bottom** of the plate) clips rain streaks to the glazing; optional `pool`/`deck` rects add splash/ping effects and are unioned into a ground-clip for splash/mist quads. Keep pool rects **inset** from the visible water — splash quads extend beyond their spawn rect.
- **Cat avatars** — `public/local/pets/<cat>.png`, square.
- **Landing poster** — `public/panel-poster.jpg`: a 1560×1170 screenshot of `/panel.html?embed=1` (regenerate after UI changes; used as the landing's loading skin and the phone tap-target).
- **Network gear images** — `public/local/network/assets/gear/`.

---

## 9. Landing page & embedding mechanics

- The app pins its viewport when loaded with `?embed=1` (inline script in `index.html`): `html,body` fixed at the design size with `-webkit-text-size-adjust:100%`. This defeats iOS Safari's iframe content-sizing and font inflation. The design size (1560×1170) is deliberately larger than the iPad's nominal 1376×1032 — probed until every row fit scroll-free.
- The landing (`landing.html`) renders the app in a tablet-bezel mockup: iframe at native size, CSS `transform: scale()` fitted via `ResizeObserver` — miniature but fully interactive (browsers scale touch coordinates through transforms).
- **Phones**: a poster + "tap for the live demo" (the live iframe loads only on tap). Tapping any demo entry point opens a **full-viewport overlay**: `requestFullscreen()` + `screen.orientation.lock('landscape')` where supported (Android); on iOS (neither API allowed) the app is CSS-rotated 90° while the phone is portrait: `translate((vw+1170s)/2, (vh−1560s)/2) rotate(90deg) scale(s)` with `s = min(vh/1560, vw/1170)`; a resize listener swaps to flat centred scaling in real landscape.
- **iOS crash guard:** pinch-zooming a large scaled iframe can crash Safari's renderer (WebKit OOM re-rasterizing under zoom). The demo area blocks `gesturestart/change/end` and double-tap zoom; keep this if you embed similarly.

---

## 10. Going-public checklist (anonymization)

If you fork this into your own public demo:

1. Replace every real entity id, room name, person/pet name, street/coordinate in `src/config/*`, `lib/networkData.ts`, and the radar-map coordinates.
2. Names hide in odd places: inside SVG files (device artwork text), runtime display-name overrides, label maps, calendar event seeds, media-player friendly names.
3. Verify by **headless leak-grep of the rendered app** (each tab's `document.body.innerText`) *and* of the built JS bundle — not the source.
4. Serve only mock data publicly. No tokens, no proxy to your real HA, ever.
5. Check images for identifying content (photos of your actual house, camera snapshots with real context).

---

## Appendix A — `src/ha/types.ts` (the interface)

```ts
export interface HassEntity {
  entity_id: string;
  state: string;
  attributes: Record<string, any>;
  last_changed?: string;
  last_updated?: string;
}

export type EntityMap = Record<string, HassEntity>;

export interface ServiceTarget {
  entity_id?: string | string[];
  device_id?: string | string[];
  area_id?: string | string[];
}

export interface HaBackend {
  /** Snapshot of currently-known states for the subscribed entities. */
  getStates(): EntityMap;
  /** Subscribe to a specific set of entities. Returns an unsubscribe fn.
   *  At 5k+ entities we subscribe per-view, never to everything. */
  subscribe(entityIds: string[], onChange: (states: EntityMap) => void): () => void;
  /** Fire a service call, e.g. callService('vacuum','start',{},{entity_id}). */
  callService(domain: string, service: string, data?: Record<string, any>, target?: ServiceTarget): Promise<unknown>;
  /** Service call that returns a response (return_response). */
  callServiceResponse(domain: string, service: string, data?: Record<string, any>, target?: ServiceTarget): Promise<any>;
  /** Browse a media_player source tree (media_player/browse_media). */
  browseMedia(entityId: string, mediaContentType?: string, mediaContentId?: string): Promise<any>;
  /** True once the initial connection/handshake is ready. */
  ready(): boolean;
}
```

## Appendix B — `src/ha/tokenBackend.ts` (standalone WebSocket backend)

```ts
// DEV backend: connect to a real HA instance with a long-lived token.
// Uses HA's `subscribe_entities` (compressed) so we only stream the entities
// a view actually needs — critical on a 5k-entity instance.
import {
  createConnection,
  createLongLivedTokenAuth,
  type Connection,
} from "home-assistant-js-websocket";
import type { EntityMap, HaBackend, HassEntity, ServiceTarget } from "./types";

interface CompressedState {
  s?: string;
  a?: Record<string, any>;
  lc?: number;
  lu?: number;
}

export async function createTokenBackend(
  hassUrl: string,
  token: string
): Promise<HaBackend> {
  const auth = createLongLivedTokenAuth(hassUrl, token);
  const conn: Connection = await createConnection({ auth });

  const states: EntityMap = {};
  let isReady = true;

  function applyAdd(id: string, cs: CompressedState) {
    states[id] = {
      entity_id: id,
      state: cs.s ?? "unknown",
      attributes: cs.a ?? {},
    };
  }
  function applyChange(id: string, plus?: CompressedState) {
    const cur = states[id] ?? { entity_id: id, state: "unknown", attributes: {} };
    // Replace with a NEW object (don't mutate) so referential-equality checks
    // (useMemo/memo) detect the change — otherwise live updates are missed.
    states[id] = {
      ...cur,
      state: plus?.s !== undefined ? plus.s : cur.state,
      attributes: plus?.a ? { ...cur.attributes, ...plus.a } : cur.attributes,
    } as HassEntity;
  }

  return {
    getStates: () => states,
    ready: () => isReady,

    subscribe(entityIds, onChange) {
      let unsub: (() => void) | undefined;
      let cancelled = false;

      conn
        .subscribeMessage<any>(
          (msg) => {
            if (msg.a) for (const [id, cs] of Object.entries(msg.a)) applyAdd(id, cs as CompressedState);
            if (msg.c)
              for (const [id, ch] of Object.entries<any>(msg.c)) applyChange(id, ch["+"]);
            if (msg.r) for (const id of msg.r) delete states[id];
            onChange({ ...states });
          },
          { type: "subscribe_entities", entity_ids: entityIds }
        )
        .then((u) => {
          if (cancelled) u();
          else unsub = u;
        });

      return () => {
        cancelled = true;
        unsub?.();
      };
    },

    async callService(domain, service, data = {}, target?: ServiceTarget) {
      return conn.sendMessagePromise({
        type: "call_service",
        domain,
        service,
        service_data: data,
        target,
      });
    },

    // Service responses go over REST (the Vite dev WS proxy mangles large
    // return_response frames). Same-origin /api is proxied to HA + token-injected.
    async callServiceResponse(domain, service, data = {}, target?: ServiceTarget) {
      const body: Record<string, any> = { ...data };
      if (target?.entity_id) body.entity_id = target.entity_id;
      // Time the fetch out so a stalled request (e.g. a stale keep-alive socket
      // after the tab has been idle) can't hang forever.
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), 15000);
      try {
        const r = await fetch(`/api/services/${domain}/${service}?return_response`, {
          method: "POST",
          headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
          body: JSON.stringify(body),
          signal: ctrl.signal,
        });
        if (!r.ok) throw new Error(`service ${domain}.${service} ${r.status}`);
        const j = await r.json();
        return j?.service_response ?? j;
      } finally {
        clearTimeout(timer);
      }
    },

    async browseMedia(entityId, mediaContentType, mediaContentId) {
      return conn.sendMessagePromise({
        type: "media_player/browse_media",
        entity_id: entityId,
        ...(mediaContentType ? { media_content_type: mediaContentType } : {}),
        ...(mediaContentId ? { media_content_id: mediaContentId } : {}),
      });
    },
  };
}
```

## Appendix C — `src/ha/hassBackend.ts` (embedded custom-panel backend)

```ts
// PROD backend: wraps the `hass` object HA injects into a custom panel.
// HA replaces `hass` (immutably) on every state change; the panel element
// forwards each new hass here via setHass(), and we notify only the
// subscribers whose entities actually changed.
import type { EntityMap, HaBackend, HassEntity, ServiceTarget } from "./types";

type Listener = { ids: string[]; cb: (s: EntityMap) => void };

export class HassBackend implements HaBackend {
  private hass: any = null;
  private listeners = new Set<Listener>();

  setHass(hass: any) {
    const prev = this.hass;
    this.hass = hass;
    if (!prev) {
      this.listeners.forEach((l) => l.cb(this.slice(l.ids)));
      return;
    }
    // only notify listeners whose entities changed identity (HA state objects are immutable)
    this.listeners.forEach((l) => {
      const changed = l.ids.some((id) => prev.states?.[id] !== hass.states?.[id]);
      if (changed) l.cb(this.slice(l.ids));
    });
  }

  private slice(ids: string[]): EntityMap {
    const out: EntityMap = {};
    for (const id of ids) {
      const e = this.hass?.states?.[id];
      if (e) out[id] = e as HassEntity;
    }
    return out;
  }

  getStates(): EntityMap {
    return this.hass?.states ?? {};
  }
  ready() {
    return !!this.hass;
  }

  subscribe(entityIds: string[], onChange: (s: EntityMap) => void) {
    const l: Listener = { ids: entityIds, cb: onChange };
    this.listeners.add(l);
    if (this.hass) onChange(this.slice(entityIds));
    return () => this.listeners.delete(l);
  }

  async callService(domain: string, service: string, data: Record<string, any> = {}, target?: ServiceTarget) {
    return this.hass.callService(domain, service, data, target);
  }

  async callServiceResponse(domain: string, service: string, data: Record<string, any> = {}, target?: ServiceTarget) {
    const res: any = await this.hass.callService(domain, service, data, target, false, true);
    return res?.response ?? res;
  }

  async browseMedia(entityId: string, mediaContentType?: string, mediaContentId?: string) {
    return this.hass.connection.sendMessagePromise({
      type: "media_player/browse_media",
      entity_id: entityId,
      ...(mediaContentType ? { media_content_type: mediaContentType } : {}),
      ...(mediaContentId ? { media_content_id: mediaContentId } : {}),
    });
  }
}
```

---

*DarkDash v2 · handoff generated 2026-08-16 · demo home, demo data — nothing here is connected to a real house.*
