DarkDash v2 — Full Handoff & Build Guide
Live demo: https://darkdashv2.pages.dev/panel · Landing: https://darkdashv2.pages.dev/ · Full source: /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.zipis 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), andhassBackend.ts(real HA as an embedded custom panel). Wiring real HA = swapping which backendsrc/main.tsxconstructs. Do not rewrite views. - The second integration surface is REST: the app calls
/api/history/period,/api/states,/api/template, and/api/calendars/*withfetch. In the demo these are intercepted bysrc/lib/mockApi.ts. Against real HA they must reach HA with anAuthorization: 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_responseservice 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'ssubscribe_entitiesWS command acceptsentity_idsand 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. callServiceResponsegoes over REST, not WS, in the token backend — largereturn_responseframes get mangled by Vite's dev WS proxy, and REST also lets a 15 sAbortControllertimeout 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
# 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:
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_responseis 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/periodfor the energy/cat/air graphs. - CORS if the app is served from a different origin than HA (
configuration.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 usewss://. 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:
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:
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:
// 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/statesandGET /api/states/<entity_id>— full/one-shot state reads (lights popup enumerateslight.*that areon).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:
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):
// 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:
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 directioninput_select(Open/Close), and HA scripts that actually drive the motor; the UI callsscript.turn_onand flips the flag optimistically. curtains3d.tsadds, 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 thecallServicecalls in the curtains components tocover.open_cover/close_coverand readcurrent_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_textcamera name +input_datetimetimestamp (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:
- Zero data fetches at boot beyond the WS subscription of the visible view. Popups (air history, lights, music) fetch on first open, never preemptively.
- Subscribe per view to explicit entity lists; unsubscribe on view switch. Never subscribe to all entities.
- History requests are bounded:
minimal_response&no_attributes, explicit entity filter, explicit time window; downsample client-side before rendering. - Immutable state objects from the backend (new object per change) so memoized components skip correctly.
- 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.)
- 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
glassrect[u0,v0,u1,v1](v measured from the bottom of the plate) clips rain streaks to the glazing; optionalpool/deckrects 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 inindex.html):html,bodyfixed 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, CSStransform: scale()fitted viaResizeObserver— 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)withs = 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/endand double-tap zoom; keep this if you embed similarly.
10. Going-public checklist (anonymization)
If you fork this into your own public demo:
- Replace every real entity id, room name, person/pet name, street/coordinate in
src/config/*,lib/networkData.ts, and the radar-map coordinates. - Names hide in odd places: inside SVG files (device artwork text), runtime display-name overrides, label maps, calendar event seeds, media-player friendly names.
- Verify by headless leak-grep of the rendered app (each tab's
document.body.innerText) *and* of the built JS bundle — not the source. - Serve only mock data publicly. No tokens, no proxy to your real HA, ever.
- Check images for identifying content (photos of your actual house, camera snapshots with real context).
Appendix A — src/ha/types.ts (the interface)
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)
// 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)
// 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.*