# 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

- **One file does almost everything:** `v5/r5-01-classic-edge/index.html` (~7k lines of HTML + CSS +
  vanilla JS, **no build step, no framework, no npm**). Open it in a browser and it works.
- **Two tiny companions:** `ha-live.js` (the Home Assistant client) and `sw.js` (a service worker).
- **Views** behind a bottom nav: **Home** (floors → rooms), **Energy**, **Animals** (pet presence),
  **Garden** (cameras), **Curtains**, **A/C**. Each view is a self‑contained IIFE that reads a
  `window.XXX` data object.
- **Mock vs Live is one line:** the app talks to real HA **only** when it is served by the LAN proxy
  on port **5276**. Anywhere else (a static host, a CDN, a `file://`) every HA call is a safe no‑op
  and the UI runs on seeded mock data. That is why the public demo can ship with zero risk.

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

```js
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:

```js
(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:
- Design/iterate with **no hardware and no HA** at all.
- The **public demo cannot touch a real home** — no token is present and nothing connects.
- "Going live" is additive: you never rewrite a view, you just let the `if (HA.live)` branch run.

---

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

```js
(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`):

```js
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 Tokens** → *Create*. 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:

```js
// 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:

```js
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](https://espresense.com/)**:
- Flash cheap **ESP32** boards with ESPresense and place one per room/zone.
- Put a **BLE beacon/tag** on each pet's collar (a tiny iBeacon, or a Tile/keyfinder ESPresense can see).
- ESPresense + the HA ESPresense integration then publish, per tag, the **nearest room** → you get a
  `sensor`/`device_tracker` per pet whose state is the room name.

The view reads `window.ANIMALS`:
```js
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):

```js
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):**
- Line‑art sketch → transparent PNG: `convert in.png -fuzz 28% -transparent white -trim out.png`,
  then tint per‑room in CSS (`filter` / a coloured overlay) so one sketch reskins to any accent colour.
- Night variant of a scene: `convert day.jpg -modulate 62,80,100 -fill '#141d3a' -colorize 20 night.jpg`.

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

- **No framework.** Everything is hand‑rolled DOM + CSS. State lives in plain JS objects; re‑render is
  a function that rebuilds a section. This keeps the whole app in one grabbable file.
- **Dark mode:** a `.dark` class on `<html>`, persisted in `localStorage`, applied before first paint to
  avoid a flash.
- **iOS haptics on toggles (Safari 17.4+):** iOS only fires Taptic for a *genuine finger tap on a native
  `<input switch>`*. The trick is a hidden native switch plus a **capture‑phase** click listener over a
  whitelist of "state‑change" controls (toggles, steppers, covers) — **not** sliders or navigation:

  ```html
  <label id="__hapt" aria-hidden="true" style="position:fixed;top:-40px;left:-40px;opacity:0">
    <input type="checkbox" switch tabindex="-1"></label>
  ```
  ```js
  var lbl = document.getElementById('__hapt');
  window.__haptic = function(){ try { lbl.click(); } catch(e){} };
  var SEL = '.tg, .pow, .fan-tog, .tset-tog, .sbtn, .lpop-cell, [data-haptic]';  // things that SHOULD buzz
  document.addEventListener('click', function(e){
    if (e.target.closest('input[type=range], .slider')) return;   // never buzz dimmers/sliders
    if (e.target.closest(SEL)) window.__haptic();
  }, true);   // capture phase → still fires even if the control stopPropagation()s
  ```
  Android has no native‑switch Taptic but supports `navigator.vibrate(…)` as a fallback.
- **Optional i18n:** a gettext‑style DOM walker translates visible text on the fly (the demo toggles
  EN⇄Japanese) via a `MutationObserver` + a `{english: translation}` map. Purely optional.
- **PWA:** `manifest.json` + `apple-touch-icon` give you the full‑screen Add‑to‑Home‑Screen kiosk.

---

## 8. Service worker (`sw.js`)

`sw.js` sits at the site root so its scope covers everything. Strategy:
- **HTML navigations → network‑first with a ~2.5 s timeout** (freshest page when online; cached page
  when the host is slow/unreachable so it still opens instantly and works offline).
- **Same‑origin assets → cache‑first** (images/sketches rarely change).
- **`/api/*` is never cached** (live data must never be served stale).
- HTTPS only. Bump the `CACHE` constant string to force clients onto a new version.

---

## 9. Deploying

- **As a static demo (mock):** it's just files. Drag the folder onto **Netlify Drop**, or upload to
  **Cloudflare Pages** / **GitHub Pages**, or run any static server + a `cloudflared` quick tunnel for a
  temporary `*.trycloudflare.com` link. No backend, no NAS required.
- **As a live panel:** run `proxy.mjs` on your LAN (never public) and Add‑to‑Home‑Screen the
  `https://…:5276/` URL on the phone.

---

## 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:
- **Anonymise every entity id** (`sensor.solar_daily` → `sensor.s_1`), consistently (same id → same
  dummy everywhere) so string‑keyed logic still lines up. Don't forget the `group.` / `automation.`
  domains.
- **Rename real names in code *and* in file names** — people, pets, rooms, devices (e.g. an image
  called `myname.jpg` leaks a name just as much as the code does).
- **Strip developer comments** that mention real sensors, dashboards, config file names, or hardware
  models.
- **Remove dev clutter from the served tree** — `.bak` files, half‑finished variant pages, and any
  `*-data.js` that still holds real data are all fetchable by URL.
- **Verify:** `grep` the *entire* served tree for real markers until it's zero, then load it headless and
  confirm **0 JS errors and 0 broken assets**.

---

## 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).
