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.
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.ha-live.js (the Home Assistant client) and sw.js (a service worker).window.XXX data object.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
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:
if (HA.live) branch run.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();
})();
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:
.token
next to proxy.mjs.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.)npm i ws then node proxy.mjs.https://YOUR_SERVER_IP:5276/ and trust the cert once.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 likelight.living_roomare used throughout this guide for clarity.
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.
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.
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.
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:
sensor/device_tracker per pet whose state is the room name.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.
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();
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).
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.
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.
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):
generate_image with your prompt (put the model name inside params).job_status(sync:true) until done.curl the result's rawUrl to a file.Image‑to‑image (restyle your own photos — e.g. turn real room photos into the cartoon style):
media_upload(files:[…]) → get a media id + an upload URL.curl -X PUT the raw bytes to that URL.media_confirm(media_id).generate_image with medias:[{ value: media_id, role: 'image' }] plus a restyle prompt.Post‑processing (ImageMagick):
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.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.
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:
<label id="__hapt" aria-hidden="true" style="position:fixed;top:-40px;left:-40px;opacity:0">
<input type="checkbox" switch tabindex="-1"></label>
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.
sw.js)sw.js sits at the site root so its scope covers everything. Strategy:
/api/* is never cached (live data must never be served stale).CACHE constant string to force clients onto a new version.cloudflared quick tunnel for a
temporary *.trycloudflare.com link. No backend, no NAS required.proxy.mjs on your LAN (never public) and Add‑to‑Home‑Screen the
https://…:5276/ URL on the phone.Everything shipped to a browser is view‑source readable; there are no client‑side secrets, but real strings leak. Before making anything public:
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.myname.jpg leaks a name just as much as the code does)..bak files, half‑finished variant pages, and any
*-data.js that still holds real data are all fetchable by URL.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.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).