DarkDash
SMART HOME · V2

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:


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:

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:


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

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

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:

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):

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"],
});

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.tsCLIMATE

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):

6.6 Animals — config/animals.ts

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


9. Landing page & embedding mechanics


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)

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.*