Build & handoff guide raw .md view the demo
Anonymised reference build · open source · framework-free
0. TL;DR — what you're looking at 1. The mock‑first pattern (the key idea) 2. ha-live.js — the whole HA client (lightly condensed) 3. Wiring to real Home Assistant — the token‑injecting proxy 4. How a control maps to an entity 5. The tabs — what each needs, and how to repurpose one 6. The look — how the artwork was made (and how to redo it) 7. Design‑system notes (nice‑to‑haves) 8. Service worker (sw.js) 9. Deploying 10. If you fork from a live build — scrub before you publish 11. License

Phone‑first Home Assistant dashboard — build & handoff guide

A single‑file, framework‑free, mock‑first mobile dashboard for Home Assistant. It runs as a pure static demo on fake data, and wires to a real HA instance through a tiny token‑injecting proxy. This folder is the anonymised reference build — every room, pet, entity id, device and location in it is a fictional decoy (the theme is a fictional "Japanese Home" in Tokyo). Nothing here maps to a real person, home or network.

You are free to copy, fork, gut and repurpose all of it. This is open source. This guide is written for another engineer (human or LLM) picking the project up cold.


0. TL;DR — what you're looking at

tokyo/
├─ index.html                     # redirect → home/  (clean root URL)
├─ home/index.html                # marketing wrapper (hero + phone mockup iframing the panel)
├─ sw.js                          # service worker (offline / instant load)
├─ v5/r5-01-classic-edge/
│  ├─ index.html                  # ⭐ THE APP (all views, all logic, all CSS)
│  ├─ ha-live.js                  # thin HA websocket/REST client (no-op unless on the proxy)
│  ├─ manifest.json               # PWA / Add-to-Home-Screen
│  └─ assets/{sketches,rooms}/    # the app's own line-art room sketches
├─ animals/assets/pets/           # pet avatars
├─ garden/assets/cams/            # camera scene stills (mock)
├─ curtains/assets/scenes/        # per-room curtain backdrops (day + _night)
└─ network/assets/gear/           # device icons for the (optional) network popup

1. The mock‑first pattern (the key idea)

Everything is built and demoed on fake data first. ha-live.js exposes window.HA whose methods are no‑ops unless the page is served by the live proxy:

var LIVE = (location.port === '5276');   // served by the LAN live proxy?
// HA.get(id)  -> {state, attributes} | null   (null in mock)
// HA.call(domain, service, data)     -> Promise (resolves null in mock)
// HA.on(id, cb) / HA.off(id, cb)     -> subscribe to state_changed
// HA.ready     -> Promise<bool>       (false in mock)

Each view seeds itself with mock data, then, only if live, overwrites from HA:

(function(){
  var E = window.ENERGY;                 // seeded mock numbers
  render(E);                             // draw immediately (works offline / in the demo)
  if (window.HA && window.HA.live) {     // LIVE only: replace mock with real sensor values
    ['solar','grid','batt'].forEach(function(k){
      var v = HA.get(E.entities[k]);     // e.g. sensor.solar_power
      if (v) E.now[k] = parseFloat(v.state);
    });
    render(E);
  }
})();

Consequences you get for free:


2. ha-live.js — the whole HA client (lightly condensed)

This is the entire client. It connects through the proxy's same‑origin websocket (/api/websocket); the browser sends a dummy token and the proxy swaps in the real one. It self‑heals with backoff.

(function () {
  var LIVE = (location.port === '5276');
  var HA = { live: false, states: {}, _subs: {}, _rid: 1, _pending: {} };
  window.HA = HA;

  function raw(msg) { ws.send(JSON.stringify(msg)); }
  function send(msg) { msg.id = HA._rid++; return new Promise(function (res) { HA._pending[msg.id] = res; raw(msg); }); }

  HA.get = function (id) { return HA.states[id] || null; };
  HA.on  = function (id, cb) { (HA._subs[id] = HA._subs[id] || []).push(cb); if (HA.live && HA.states[id]) { try { cb(HA.states[id]); } catch (e) {} } };
  HA.off = function (id, cb) { var a = HA._subs[id]; if (a) { var i = a.indexOf(cb); if (i >= 0) a.splice(i, 1); } };
  HA.call  = function (domain, service, data) { if (!HA.live) return Promise.resolve(null); return send({ type:'call_service', domain:domain, service:service, service_data:data||{} }); };
  HA.callR = function (domain, service, data) { if (!HA.live) return Promise.resolve(null); return send({ type:'call_service', domain:domain, service:service, service_data:data||{}, return_response:true }); };

  if (!LIVE) { HA.ready = Promise.resolve(false); return; }   // mock mode → never connect

  var ws, resolveReady, backoff = 500;
  HA.ready = new Promise(function (r) { resolveReady = r; });

  function connect() {
    try { ws = new WebSocket('wss://' + location.host + '/api/websocket'); }
    catch (e) { setTimeout(connect, backoff); backoff = Math.min(backoff*2, 10000); return; }
    ws.onmessage = function (ev) {
      var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
      if (m.type === 'auth_required') raw({ type:'auth', access_token:'proxy-swaps-this' });
      else if (m.type === 'auth_ok') onAuthed();
      else if (m.type === 'event' && m.event && m.event.event_type === 'state_changed') onChanged(m.event.data);
      else if (m.type === 'result') { var p = HA._pending[m.id]; if (p) { p(m); delete HA._pending[m.id]; } }
    };
    ws.onclose = function () { HA.live = false; setTimeout(connect, backoff); backoff = Math.min(backoff*2, 10000); };
  }
  function onAuthed() {
    backoff = 500;
    send({ type:'get_states' }).then(function (r) {
      if (r && r.success) r.result.forEach(function (s) { HA.states[s.entity_id] = { state:s.state, attributes:s.attributes }; });
      raw({ id: HA._rid++, type:'subscribe_events', event_type:'state_changed' });
      HA.live = true; resolveReady(true);
      Object.keys(HA._subs).forEach(function (id) { if (HA.states[id]) HA._subs[id].forEach(function (cb){ try{cb(HA.states[id]);}catch(e){} }); });
      document.dispatchEvent(new CustomEvent('ha-live-ready'));
    });
  }
  function onChanged(d) {
    var id = d.entity_id;
    HA.states[id] = d.new_state ? { state:d.new_state.state, attributes:d.new_state.attributes } : null;
    (HA._subs[id] || []).forEach(function (cb){ try{cb(HA.states[id]);}catch(e){} });
  }
  connect();
})();

3. Wiring to real Home Assistant — the token‑injecting proxy

The panel is a static site; HA needs a token. Never put the token in the browser. Instead a tiny Node proxy runs on your LAN, serves the app over HTTPS, and bridges /api/* (REST) and /api/websocket (WS) to HA — injecting the token server‑side. The browser only ever talks to the proxy (same origin), so there is no mixed‑content problem and the token never leaves the server.

Security rule: this proxy is LAN‑only. Do not put port 5276 on any public tunnel/funnel. Live control stays on your home network; only the mock demo is ever public.

proxy.mjs (generic — fill in the placeholders; needs npm i ws):

import http from 'http';
import https from 'https';
import { readFileSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { WebSocketServer, WebSocket } from 'ws';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP      = path.resolve(__dirname, 'v5', 'r5-01-classic-edge');  // the app is served at '/'
const ROOTS    = [APP, path.resolve(__dirname, 'v5'), __dirname];      // fall-throughs for ../ and ../../ assets
const HA_HOST  = 'YOUR_HA_IP';        // e.g. 10.0.0.5  — your Home Assistant box
const HA_PORT  = 8123;
const PORT     = 5276;                // the "live" port ha-live.js keys on. Keep it LAN-only.
const TOKEN    = readFileSync(path.join(__dirname, '.token'), 'utf8').trim();   // HA long-lived token
const CERT     = { key: readFileSync('key.pem'), cert: readFileSync('cert.pem') };  // any self-signed cert

const MIME = { '.html':'text/html; charset=utf-8', '.js':'application/javascript', '.json':'application/json',
  '.png':'image/png', '.jpg':'image/jpeg', '.svg':'image/svg+xml', '.webp':'image/webp', '.mp4':'video/mp4',
  '.woff2':'font/woff2', '.ico':'image/x-icon' };

const server = https.createServer(CERT, (req, res) => {
  const u = new URL(req.url, 'https://x');

  // ---- REST bridge: inject Authorization ----
  if (u.pathname.startsWith('/api/')) {
    const headers = { ...req.headers, host: `${HA_HOST}:${HA_PORT}`, authorization: `Bearer ${TOKEN}` };
    const preq = http.request({ host: HA_HOST, port: HA_PORT, path: req.url, method: req.method, headers },
      pres => { res.writeHead(pres.statusCode, pres.headers); pres.pipe(res); });
    preq.on('error', e => { res.writeHead(502); res.end('bridge error: ' + e.message); });
    req.pipe(preq);
    return;
  }
  // ---- static file serve (path-traversal guarded) ----
  let rel = decodeURIComponent(u.pathname);
  if (rel.endsWith('/')) rel += 'index.html';
  for (const base of ROOTS) {
    const fp = path.join(base, rel);
    if (!fp.startsWith(base + path.sep) && fp !== base) continue;   // traversal guard
    try { const data = readFileSync(fp);
      res.writeHead(200, { 'content-type': MIME[path.extname(fp)] || 'application/octet-stream' });
      res.end(data); return;
    } catch {}
  }
  res.writeHead(404); res.end('not found');
});

// ---- WebSocket bridge: swap the dummy token for the real one in the auth handshake ----
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
  if (new URL(req.url, 'https://x').pathname !== '/api/websocket') { socket.destroy(); return; }
  wss.handleUpgrade(req, socket, head, (client) => {
    const upstream = new WebSocket(`ws://${HA_HOST}:${HA_PORT}/api/websocket`);
    const q = [];
    upstream.on('open', () => q.splice(0).forEach(m => upstream.send(m)));
    upstream.on('message', d => { if (client.readyState === WebSocket.OPEN) client.send(d.toString()); });
    client.on('message', d => {
      let txt = d.toString();
      try { const m = JSON.parse(txt); if (m.type === 'auth') txt = JSON.stringify({ type:'auth', access_token: TOKEN }); } catch {}
      if (upstream.readyState === WebSocket.OPEN) upstream.send(txt); else q.push(txt);
    });
    const bye = () => { try{client.close();}catch{} try{upstream.close();}catch{} };
    client.on('close', bye); upstream.on('close', bye); client.on('error', bye); upstream.on('error', bye);
  });
});
server.listen(PORT, '0.0.0.0', () => console.log(`live proxy up on :${PORT}`));

Bring it live in 5 steps:

  1. In HA: your profile → Long‑Lived Access TokensCreate. Save it into a file named .token next to proxy.mjs.
  2. Generate a self‑signed cert (openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 3650). (HTTPS is required for the service worker, mic, and Add‑to‑Home‑Screen.)
  3. npm i ws then node proxy.mjs.
  4. On the phone, open https://YOUR_SERVER_IP:5276/ and trust the cert once.
  5. Add to Home Screen → you now have a full‑screen kiosk driving real HA.

4. How a control maps to an entity

Controls carry the HA entity id they represent. In mock the id is ignored; in live the value comes from HA.get(id).state and taps call HA.call(...). A room light toggle, end to end:

// data:  { name:'Living', entity:'light.living_room', on:false }
// render: <button class="tg" data-ent="light.living_room">…</button>
btn.addEventListener('click', function(){
  room.on = !room.on;                                   // optimistic UI (mock + live)
  paint(btn, room.on);
  window.HA.call('light', room.on ? 'turn_on' : 'turn_off', { entity_id: room.entity });  // no-op in mock
});
// live sync so the UI follows HA when something else changes the light:
window.HA.on('light.living_room', function(s){ room.on = (s.state === 'on'); paint(btn, room.on); });

In this reference build every id is an anonymised dummy like light.l_12 / sensor.s_44 / climate.ac_3. Replace them with your real entity ids. Readable ids like light.living_room are used throughout this guide for clarity.


5. The tabs — what each needs, and how to repurpose one

Every tab is the same shape: a window.XXX data object + one IIFE that renders it + a bottom‑nav button. To repurpose a tab, swap the data object and the render body; keep the shell.

Home (floors → rooms) — no extra hardware

Pure HA. Lights (light.* / switch.*), climate (climate.*), locks (lock.*) grouped by room and floor. Swipe or tap the floor tabs; the chrome stays static and only the grid slides.

Energy — needs solar hardware

Requires a solar/energy setup reporting into HA: an inverter with an HA integration (SolarEdge, Fronius, SMA, Enphase, Tesla/Powerwall, Huawei…) or CT clamps on your mains (e.g. a Shelly EM / emporia Vue) exposing power + daily‑energy sensors. The view reads a window.ENERGY object:

window.ENERGY = {
  entities: { solarPower:'sensor.solar_power', gridPower:'sensor.grid_power', batt:'sensor.battery_soc',
              solarKwh:{today:'sensor.solar_energy_daily', week:'…', month:'…'}, /* …grid, batt… */ },
  now: { solarPower:2.1, gridPower:0.4, battPct:82 },          // present values (kW / %)
  series: { hrs:[…], sv:[…], gv:[…], bv:[…], lv:[…] },          // today's curve for the hero graph
  usage:  { today:{grid:70,solar:70,batt:9.6}, week:{…}, month:{…} },  // kWh
  savings:{ today:1891, week:12287, month:53595, ytd:356420 }   // money saved by self-consuming solar
};

The savings number is just arithmetic, not a sensor: saved ≈ solar_kWh × your_tariff. Pick a blended rate = (retail price you avoid by self‑consuming) mixed with (feed‑in tariff for exported surplus). Example used here: ~¥27/kWh. In live, overwrite now/usage from your HA sensors and recompute savings from solarKwh × tariff.

Animals (pet presence) — needs ESPresense

This tab shows which room each pet is in, plus a per‑pet timeline and litter‑box activity. Room‑level indoor presence comes from ESPresense:

The view reads window.ANIMALS:

window.ANIMALS = {
  rooms: [{ key:'living', label:'Living', floor:'down', color:'#3ecf6e' }, …],
  cats:  [{ id:'pet1', name:'Mochi', photo:'…/pet1.png',
            now:'living',            // ← the ESPresense room sensor's state, live
            base:'cat_room' }],
  timeline: { today:{ pet1:[{room, f0, f1}, …] }, '7d':{…} },  // room visits as fractions of the window
  litter: [{ name:'Cat Room', state:'clear', usesToday:6, lastUsed:'18 min ago' }]
};

In live: cat.now = HA.get('sensor.pet1_room').state; build the timeline from /api/history/period/… for that sensor. Litter events can come from a smart litter box (e.g. a Catlink/PetKit integration) or a door/vibration sensor.

Garden (cameras) — needs cameras in HA

Any camera.* in HA works. iOS Safari can't play an MJPEG <img> stream, so the panel uses chained double‑buffered snapshot polling of /api/camera_proxy/<entity> (~7 fps, no flicker, visibility‑gated so it pauses off‑screen):

var alive = true, n = 0;
function loop(){
  if (!alive || !document.body.contains(cam)) { alive = false; return; }
  if (feed.offsetParent === null) { setTimeout(loop, 500); return; }   // paused while off-screen
  var url = '/api/camera_proxy/camera.garden?n=' + (n++), im = new Image();
  im.onload  = function(){ if(!alive) return; feed.src = url; setTimeout(loop, 140); };  // swap only after decode
  im.onerror = function(){ if(!alive) return; setTimeout(loop, 1200); };
  im.src = url;
}
loop();

Curtains — needs covers

cover.* entities. Open/close buttons call HA.call('cover','open_cover'|'close_cover',{entity_id}); a slider can call set_cover_position. The demo also renders a stylised curtain/venetian blind over a per‑room backdrop image (scenes/<room>.jpg + a _night variant).

A/C — needs climate entities

climate.*. Temperature +/‑ steppers call climate.set_temperature; mode toggles call climate.set_hvac_mode. Tip: if two UI tiles are the same physical unit, drive both from one entity so they move in tandem.

Repurposing a tab

It's all yours. A tab = window.XXX + an IIFE + a nav button. Want a Media tab? Point it at media_player.*. A Security tab? alarm_control_panel.* + binary_sensor.* door/motion. A Plants tab? soil‑moisture sensors. Delete the ones you don't want; the bottom nav is just buttons.


6. The look — how the artwork was made (and how to redo it)

The cartoon pets, the realistic garden/pool camera stills, the line‑art room sketches, and the "luxury Japanese" curtain backdrops were all generated with an AI image model (this build used the Higgsfield MCP with the nano_banana_pro model, but any generator — DALL·E, Stable Diffusion, Midjourney — or your own photos work just as well). The point is a single, consistent illustration style.

Text‑to‑image (pets, cam scenes, sketches):

  1. generate_image with your prompt (put the model name inside params).
  2. Poll job_status(sync:true) until done.
  3. curl the result's rawUrl to a file.
  4. Post‑process with ImageMagick (see below).

Image‑to‑image (restyle your own photos — e.g. turn real room photos into the cartoon style):

  1. media_upload(files:[…]) → get a media id + an upload URL.
  2. curl -X PUT the raw bytes to that URL.
  3. media_confirm(media_id).
  4. generate_image with medias:[{ value: media_id, role: 'image' }] plus a restyle prompt.

Post‑processing (ImageMagick):

Gotcha — rasterising SVG: some boxes (e.g. a hardened ImageMagick policy, or no Pillow) cannot turn SVG into PNG. Workaround: render the SVG in headless Chromium (puppeteer, element.screenshot({ deviceScaleFactor: 2 })) and screenshot it to PNG. Raster→raster convert still works fine; only SVG rasterisation is the problem.

You do not need any of these specific tools — swap in whatever image pipeline you like. Keep the style consistent and the file names generic.


7. Design‑system notes (nice‑to‑haves)


8. Service worker (sw.js)

sw.js sits at the site root so its scope covers everything. Strategy:


9. Deploying


10. If you fork from a live build — scrub before you publish

Everything shipped to a browser is view‑source readable; there are no client‑side secrets, but real strings leak. Before making anything public:


11. License

Open source — copy, fork, gut and repurpose it, commercial or not. No warranty; you're responsible for your own tokens, certs and exposure. If you publish a fork, scrub your real data first (§10).

This page is the rendered version of BUILD-GUIDE.md, shipped alongside the Home Assistant Demo Dash. Copy, fork, gut and repurpose — see §11.