Files
luma-home/web-dashboard/index.html
T
Kaloyan DanchevandClaude Opus 4.6 d681f20884
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Add HACS integration, 8 Lovelace cards, and pairing page
New HA integration (integration/custom_components/luma_home/) with:
- WS API: luma/get_config (Lovelace→v2 bridge), luma/pair, luma/pair_status
- Config flow with dashboard URL path setting

8 HACS frontend cards (cards/) matching TV app visual spec:
- toggle, range, climate, media, curtain, action, vacuum, weather
- Glass card design: rgba(36,40,48,0.75) bg, 22px radius, accent glows

Pairing page (pairing/luma-pair.html) for TV token provisioning.
Web dashboard (web-dashboard/) — legacy config editor (deprecated).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-25 13:19:26 +02:00

1067 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Luma Home</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<style>
:root {
--bg: #0c0f14;
--surface: #161a22;
--card: rgba(36, 40, 48, 0.78);
--card-border: rgba(255,255,255,0.08);
--card-border-active: rgba(255,255,255,0.18);
--text: #f0f0f0;
--text-muted: rgba(232,232,232,0.50);
--accent: #7EB8DA;
--accent-warm: #f0a060;
--danger: #e05555;
--edit-accent: #a78bfa;
--radius: 18px;
--gap: 14px;
--sidebar-w: 220px;
--col-count: 4;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
display: flex;
}
/* ── Connection overlay ────────────────────────── */
#connect-overlay {
position: fixed; inset: 0; z-index: 1000;
background: var(--bg);
display: flex; align-items: center; justify-content: center;
}
#connect-overlay.hidden { display: none; }
.connect-box {
background: var(--surface);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 32px;
width: 380px;
display: flex; flex-direction: column; gap: 16px;
}
.connect-box h2 { font-size: 20px; font-weight: 600; }
.connect-box label { font-size: 13px; color: var(--text-muted); }
.connect-box input {
background: var(--bg);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 14px;
font-family: inherit;
width: 100%;
}
.connect-box input:focus { outline: none; border-color: var(--accent); }
.connect-box .error { color: var(--danger); font-size: 13px; min-height: 18px; }
/* ── Buttons ───────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 16px;
border: none; border-radius: 10px;
font-family: inherit; font-size: 13px; font-weight: 500;
cursor: pointer; transition: all 0.15s;
}
.btn-primary { background: var(--accent); color: #111; }
.btn-primary:hover { filter: brightness(1.1); }
.btn-ghost {
background: transparent; color: var(--text-muted);
border: 1px solid var(--card-border);
}
.btn-ghost:hover { color: var(--text); border-color: var(--card-border-active); }
.btn-danger { background: var(--danger); color: white; }
.btn-edit { background: var(--edit-accent); color: #111; }
.btn-sm { padding: 5px 10px; font-size: 12px; border-radius: 8px; }
/* ── Sidebar ───────────────────────────────────── */
#sidebar {
width: var(--sidebar-w);
background: var(--surface);
border-right: 1px solid var(--card-border);
display: flex; flex-direction: column;
flex-shrink: 0;
height: 100vh;
}
.sidebar-header {
padding: 20px 16px 12px;
font-size: 18px; font-weight: 700;
letter-spacing: -0.02em;
display: flex; align-items: center; gap: 8px;
}
.sidebar-header svg { width: 22px; height: 22px; opacity: 0.7; }
#room-list {
flex: 1; overflow-y: auto; padding: 4px 8px;
display: flex; flex-direction: column; gap: 2px;
}
.room-tab {
padding: 10px 12px;
border-radius: 10px;
font-size: 14px; font-weight: 500;
cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
transition: background 0.12s;
color: var(--text-muted);
}
.room-tab:hover { background: rgba(255,255,255,0.04); color: var(--text); }
.room-tab.active { background: rgba(126,184,218,0.12); color: var(--accent); }
.room-tab .room-actions { display: none; gap: 4px; }
.room-tab:hover .room-actions,
body.edit-mode .room-tab .room-actions { display: flex; }
.room-action-btn {
background: none; border: none; cursor: pointer;
color: var(--text-muted); padding: 2px;
border-radius: 4px; display: flex;
}
.room-action-btn:hover { color: var(--text); background: rgba(255,255,255,0.08); }
.sidebar-footer { padding: 12px; }
/* ── Main content ──────────────────────────────── */
#main {
flex: 1; display: flex; flex-direction: column;
overflow: hidden;
}
#toolbar {
padding: 16px 24px;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
#toolbar h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
.toolbar-actions { display: flex; gap: 8px; align-items: center; }
#grid-area {
flex: 1; overflow-y: auto; padding: 20px 24px;
}
/* ── Card grid ─────────────────────────────────── */
.card-grid {
display: grid;
grid-template-columns: repeat(var(--col-count), 1fr);
grid-auto-rows: 140px;
gap: var(--gap);
max-width: 1200px;
}
.card {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 16px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.12s;
display: flex; flex-direction: column; justify-content: space-between;
}
.card::before {
content: '';
position: absolute; inset: -1px;
border-radius: var(--radius);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.card:hover {
border-color: var(--card-border-active);
transform: translateY(-1px);
}
.card.on { border-color: rgba(126,184,218,0.25); }
.card.on::before {
background: radial-gradient(ellipse at 50% 0%, var(--card-glow, rgba(126,184,218,0.12)) 0%, transparent 70%);
opacity: 1;
}
.card.heat { border-color: rgba(240,160,96,0.25); }
.card.heat::before {
background: radial-gradient(ellipse at 50% 0%, rgba(240,160,96,0.12) 0%, transparent 70%);
opacity: 1;
}
.card .card-top { display: flex; align-items: center; gap: 10px; position: relative; z-index: 1; }
.card .card-icon {
width: 38px; height: 38px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
background: rgba(255,255,255,0.06);
flex-shrink: 0;
}
.card.on .card-icon { background: rgba(126,184,218,0.18); color: var(--accent); }
.card .card-name { font-size: 13px; font-weight: 500; line-height: 1.3; }
.card .card-state {
font-size: 12px; color: var(--text-muted);
position: relative; z-index: 1;
}
.card .card-state.on-text { color: var(--accent); }
.card .card-value {
font-size: 28px; font-weight: 600;
position: relative; z-index: 1;
letter-spacing: -0.03em;
}
/* Size variants */
.card.size-2x1 { grid-column: span 2; }
.card.size-1x2 { grid-row: span 2; }
.card.size-2x2 { grid-column: span 2; grid-row: span 2; }
/* ── Slider (range cards) ──────────────────────── */
.card-slider {
position: relative; z-index: 1;
width: 100%;
height: 6px;
background: rgba(255,255,255,0.08);
border-radius: 3px;
margin-top: 8px;
cursor: pointer;
}
.card-slider-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.15s;
}
/* ── Edit mode ─────────────────────────────────── */
body.edit-mode .card {
border-style: dashed;
cursor: grab;
}
body.edit-mode .card:active { cursor: grabbing; }
body.edit-mode .card.dragging { opacity: 0.4; }
body.edit-mode .card.drag-over {
border-color: var(--edit-accent);
box-shadow: 0 0 0 2px rgba(167,139,250,0.3);
}
.card-edit-badge {
display: none;
position: absolute; top: 8px; right: 8px;
background: var(--edit-accent);
color: #111; font-size: 10px; font-weight: 600;
padding: 2px 6px; border-radius: 4px; z-index: 2;
}
body.edit-mode .card-edit-badge { display: block; }
.add-card-btn {
border: 2px dashed rgba(255,255,255,0.12);
border-radius: var(--radius);
display: none;
align-items: center; justify-content: center;
cursor: pointer;
color: var(--text-muted);
font-size: 14px;
gap: 6px;
transition: border-color 0.15s, color 0.15s;
min-height: 140px;
}
body.edit-mode .add-card-btn { display: flex; }
.add-card-btn:hover { border-color: var(--edit-accent); color: var(--edit-accent); }
/* ── Edit panel (slide-out) ────────────────────── */
#edit-panel {
position: fixed; top: 0; right: -420px; bottom: 0;
width: 400px;
background: var(--surface);
border-left: 1px solid var(--card-border);
z-index: 100;
display: flex; flex-direction: column;
transition: right 0.25s ease;
box-shadow: -8px 0 32px rgba(0,0,0,0.4);
}
#edit-panel.open { right: 0; }
.panel-header {
padding: 20px;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid var(--card-border);
}
.panel-header h3 { font-size: 16px; font-weight: 600; }
.panel-body {
flex: 1; overflow-y: auto; padding: 20px;
display: flex; flex-direction: column; gap: 20px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.field input, .field select {
background: var(--bg);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 14px;
font-family: inherit;
}
.field input:focus, .field select:focus { outline: none; border-color: var(--accent); }
.field select option { background: var(--bg); color: var(--text); }
.color-row { display: flex; gap: 8px; align-items: center; }
.color-row input[type="color"] {
width: 40px; height: 40px; padding: 2px;
border-radius: 8px; cursor: pointer;
}
.color-row input[type="text"] { flex: 1; }
.panel-footer {
padding: 16px 20px;
display: flex; gap: 8px; justify-content: space-between;
border-top: 1px solid var(--card-border);
}
/* ── Entity picker modal ───────────────────────── */
#entity-modal {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.6);
display: none; align-items: center; justify-content: center;
}
#entity-modal.open { display: flex; }
.entity-picker {
background: var(--surface);
border: 1px solid var(--card-border);
border-radius: 16px;
width: 480px; max-height: 70vh;
display: flex; flex-direction: column;
}
.entity-picker-header { padding: 16px 20px; }
.entity-picker-header input {
width: 100%;
background: var(--bg);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 10px 14px;
color: var(--text);
font-size: 14px;
font-family: inherit;
}
.entity-picker-header input:focus { outline: none; border-color: var(--accent); }
.entity-list {
flex: 1; overflow-y: auto; padding: 0 8px 8px;
}
.entity-item {
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
display: flex; flex-direction: column; gap: 2px;
transition: background 0.1s;
}
.entity-item:hover { background: rgba(255,255,255,0.06); }
.entity-item .eid { font-size: 13px; font-weight: 500; }
.entity-item .ename { font-size: 11px; color: var(--text-muted); }
/* ── Responsive ────────────────────────────────── */
@media (max-width: 900px) {
:root { --col-count: 3; --sidebar-w: 180px; }
}
@media (max-width: 640px) {
:root { --col-count: 2; --sidebar-w: 0px; }
#sidebar { display: none; }
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
</style>
</head>
<body>
<!-- Connection overlay -->
<div id="connect-overlay">
<div class="connect-box">
<h2>Luma Home</h2>
<div class="field">
<label>Home Assistant URL</label>
<input id="ha-url" type="text" placeholder="ws://homeassistant.local:8123/api/websocket" spellcheck="false">
</div>
<div class="field">
<label>Long-Lived Access Token</label>
<input id="ha-token" type="password" placeholder="Paste token from HA profile page">
</div>
<div class="error" id="connect-error"></div>
<button class="btn btn-primary" onclick="doConnect()" style="width:100%">Connect</button>
</div>
</div>
<!-- Main app -->
<div id="sidebar">
<div class="sidebar-header">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3L4 9v12h16V9l-8-6zm0 2.5L18 10v9H6v-9l6-4.5z"/></svg>
Luma Home
</div>
<div id="room-list"></div>
<div class="sidebar-footer">
<button class="btn btn-ghost btn-sm" onclick="addRoom()" style="width:100%">+ Add Room</button>
</div>
</div>
<div id="main">
<div id="toolbar">
<h1 id="room-title">—</h1>
<div class="toolbar-actions">
<button class="btn btn-ghost btn-sm" id="edit-toggle" onclick="toggleEditMode()">Edit</button>
<button class="btn btn-ghost btn-sm" onclick="exportConfig()">Export for TV</button>
<button class="btn btn-ghost btn-sm" onclick="resetToTvConfig()">Reset to TV</button>
</div>
</div>
<div id="grid-area">
<div class="card-grid" id="card-grid"></div>
</div>
</div>
<!-- Edit panel -->
<div id="edit-panel">
<div class="panel-header">
<h3 id="panel-title">Edit Card</h3>
<button class="btn btn-ghost btn-sm" onclick="closeEditPanel()">&times;</button>
</div>
<div class="panel-body">
<div class="field">
<label>Entity</label>
<div style="display:flex;gap:8px">
<input id="ed-entity" type="text" readonly style="flex:1;cursor:pointer" onclick="openEntityPicker()">
<button class="btn btn-ghost btn-sm" onclick="openEntityPicker()">Browse</button>
</div>
</div>
<div class="field">
<label>Display Name</label>
<input id="ed-name" type="text" placeholder="Card name">
</div>
<div class="field">
<label>Card Type</label>
<select id="ed-type">
<option value="toggle">Toggle</option>
<option value="range">Range (Dimmer)</option>
<option value="climate">Climate</option>
<option value="media">Media</option>
<option value="curtain">Curtain</option>
<option value="blinds">Blinds</option>
<option value="action">Action (Scene)</option>
<option value="vacuum">Vacuum</option>
<option value="weather">Weather</option>
</select>
</div>
<div class="field">
<label>Size</label>
<select id="ed-size">
<option value="1x1">Small (1×1)</option>
<option value="2x1">Wide (2×1)</option>
<option value="1x2">Tall (1×2)</option>
<option value="2x2">Large (2×2)</option>
</select>
</div>
<div class="field">
<label>Accent Color</label>
<div class="color-row">
<input type="color" id="ed-accent" value="#7EB8DA">
<input type="text" id="ed-accent-text" value="#7EB8DA" maxlength="7">
</div>
</div>
<div class="field">
<label>Glow Color</label>
<div class="color-row">
<input type="color" id="ed-glow" value="#7EB8DA">
<input type="text" id="ed-glow-text" value="#7EB8DA" maxlength="7">
</div>
</div>
<div class="field">
<label>Icon</label>
<select id="ed-icon">
<option value="auto">Auto (from entity)</option>
<option value="💡">💡 Light</option>
<option value="🌡">🌡 Thermostat</option>
<option value="🎵">🎵 Media</option>
<option value="🪟">🪟 Curtain/Blinds</option>
<option value="🎬">🎬 Scene</option>
<option value="🌤">🌤 Weather</option>
<option value="🔌">🔌 Switch</option>
<option value="🤖">🤖 Vacuum</option>
<option value="❄️">❄️ Climate</option>
</select>
</div>
</div>
<div class="panel-footer">
<button class="btn btn-danger btn-sm" onclick="deleteEditingCard()">Delete</button>
<button class="btn btn-primary btn-sm" onclick="saveEditingCard()">Save</button>
</div>
</div>
<!-- Entity picker modal -->
<div id="entity-modal">
<div class="entity-picker">
<div class="entity-picker-header">
<input type="text" id="entity-search" placeholder="Search entities..." oninput="filterEntities()">
</div>
<div class="entity-list" id="entity-list"></div>
</div>
</div>
<script>
// ── TV config (ha-home/config.json) ───────────────
const TV_CONFIG = {"version":1,"ha":{"url":"wss://ha.xtrm-lab.org/api/websocket","token":""},"weather":{"entity_id":"weather.forecast_home"},"vacuum":{"entity_id":"vacuum.living_room_roborock_s7_pro_ultra","cleaning_mode_entity":"select.living_room_roborock_s7_pro_ultra_cleaning_mode","rooms":{"16":"Kitchen","17":"Bedroom","18":"Dining Room","19":"Corridor","20":"Bathroom","21":"Living Room","22":"Master Bedroom","23":"Corridor 2","24":"Bedroom 2"}},"rooms":[{"id":"home","name":"Home","background":"living-room.jpg","sensors":["sensor.living_room_thermostat_temperature","sensor.living_room_thermostat_humidity"],"cards":[{"type":"weather","entity_id":"weather.forecast_home","name":"Weather"},{"type":"media","entity_id":"media_player.music_assistant","name":"Music"},{"type":"vacuum","entity_id":"vacuum.living_room_roborock_s7_pro_ultra","name":"Roborock"}]},{"id":"living_area","name":"Living Area","background":"living-room.jpg","sensors":["sensor.living_room_thermostat_temperature","sensor.living_room_thermostat_humidity"],"cards":[{"type":"climate","entity_id":"climate.living_room_thermostat","name":"Thermostat"},{"type":"climate","entity_id":"climate.living_room_radiator","name":"Radiator"},{"type":"range","entity_id":"light.living_room_light","name":"Ceiling Light"},{"type":"range","entity_id":"light.tv_lights","name":"TV Lights"},{"type":"range","entity_id":"light.rgbic_strip_lights_z1","name":"LED Strip 1"},{"type":"range","entity_id":"light.rgbic_strip_lights_z2","name":"LED Strip 2"},{"type":"toggle","entity_id":"light.picture_frame_lamp","name":"Picture Lamp"},{"type":"curtain","entity_id":"cover.living_room_curtain","name":"Curtain"},{"type":"range","entity_id":"light.dining_room_light","name":"Dining Light"},{"type":"toggle","entity_id":"light.entrance_light_1","name":"Entrance"},{"type":"toggle","entity_id":"light.entrance_mirror_light","name":"Mirror"},{"type":"action","entity_id":"scene.tv_mode","name":"TV Mode"},{"type":"action","entity_id":"scene.living_room_100","name":"Living Room 100%"},{"type":"action","entity_id":"scene.living_room_lights_10","name":"Living Room 10%"},{"type":"action","entity_id":"script.tv_ambient_on","name":"TV Ambient On"},{"type":"action","entity_id":"script.tv_ambient_off","name":"TV Ambient Off"},{"type":"action","entity_id":"script.living_dining_all_off","name":"All Lights Off"},{"type":"action","entity_id":"scene.curtains_close","name":"Close Curtains"}]},{"id":"kitchen","name":"Kitchen","background":"kitchen.jpg","sensors":["sensor.kitchen_termostat_temperature","sensor.kitchen_termostat_humidity"],"cards":[{"type":"climate","entity_id":"climate.kitchen_termostat","name":"Thermostat"},{"type":"toggle","entity_id":"switch.dishwasher_power","name":"Dishwasher"}]},{"id":"bedroom","name":"Dad's Bedroom","background":"bedroom.jpg","sensors":[],"cards":[{"type":"climate","entity_id":"climate.main_bedroom_radiator","name":"Radiator"},{"type":"toggle","entity_id":"light.bedroom_ceiling_light","name":"Ceiling"},{"type":"toggle","entity_id":"light.bedroom_desk_lights","name":"Desk"},{"type":"range","entity_id":"light.bedroom_led_strip","name":"LED Strip"},{"type":"action","entity_id":"scene.desk_mac","name":"Desk: Mac"},{"type":"action","entity_id":"scene.desk_pc","name":"Desk: PC"}]},{"id":"dari_bedroom","name":"Dari's Bedroom","background":"dari-bedroom.jpg","sensors":[],"cards":[{"type":"climate","entity_id":"climate.girls_bedroom_radiator","name":"Radiator"}]},{"id":"teo_s_bedroom","name":"Teo's Bedroom","background":"teo-bedroom.jpg","sensors":[],"cards":[{"type":"toggle","entity_id":"light.boys_smartlight","name":"Light"}]},{"id":"main_bathroom","name":"Main Bathroom","background":"bathroom.jpg","sensors":[],"cards":[{"type":"toggle","entity_id":"light.bathroom_light_1","name":"Light 1"},{"type":"toggle","entity_id":"light.bathroom_light_2","name":"Light 2"}]},{"id":"kids_bathroom","name":"Kids Bathroom","background":"kids-bathroom.jpg","sensors":[],"cards":[{"type":"toggle","entity_id":"light.kids_bathroom_light_1","name":"Light 1"},{"type":"toggle","entity_id":"light.kids_bathroom_light_2","name":"Light 2"},{"type":"toggle","entity_id":"switch.washing_machine_power","name":"Washing Machine"},{"type":"toggle","entity_id":"switch.dryer_power","name":"Dryer"}]}]};
function tvConfigToDashboard(tv) {
const tallTypes = ['weather', 'media', 'vacuum'];
return {
version: 1,
device: 'web',
rooms: tv.rooms.map(r => ({
id: r.id,
name: r.name,
background: r.background || '',
sensors: r.sensors || [],
cards: r.cards.map(c => ({
entity_id: c.entity_id,
name: c.name,
type: c.type,
size: tallTypes.includes(c.type) ? '1x2' : '1x1',
accent: '#7EB8DA',
glow: '#7EB8DA',
icon: 'auto',
}))
}))
};
}
function dashboardToTvConfig(cfg) {
return {
version: 1,
ha: { url: TV_CONFIG.ha.url, token: '' },
weather: TV_CONFIG.weather,
vacuum: TV_CONFIG.vacuum,
rooms: cfg.rooms.map(r => ({
id: r.id,
name: r.name,
background: r.background || '',
sensors: r.sensors || [],
cards: r.cards.map(c => ({
type: c.type,
entity_id: c.entity_id,
name: c.name,
}))
}))
};
}
const ICONS = {
'light': '💡', 'switch': '🔌', 'climate': '🌡', 'media_player': '🎵',
'cover': '🪟', 'scene': '🎬', 'script': '🎬', 'weather': '🌤',
'vacuum': '🤖', 'sensor': '📊', 'binary_sensor': '⚡', 'fan': '💨',
'automation': '⚙️', 'input_boolean': '🔘', 'select': '📋',
};
function iconFor(entityId, cardIcon) {
if (cardIcon && cardIcon !== 'auto') return cardIcon;
const domain = entityId.split('.')[0];
return ICONS[domain] || '📦';
}
// ── App state ──────────────────────────────────────
const app = {
ws: null,
connected: false,
entities: {},
config: { version: 1, device: 'web', rooms: [] },
activeRoom: 0,
editMode: false,
editingCard: null, // { roomIdx, cardIdx } or null
msgId: 1,
pendingCalls: {},
entityPickerCb: null,
dragFrom: null,
};
// ── HA WebSocket ───────────────────────────────────
function doConnect() {
const urlEl = document.getElementById('ha-url');
const tokenEl = document.getElementById('ha-token');
const errEl = document.getElementById('connect-error');
const url = urlEl.value.trim();
const token = tokenEl.value.trim();
if (!url || !token) { errEl.textContent = 'Both fields are required.'; return; }
errEl.textContent = 'Connecting...';
try { localStorage.setItem('luma-ha-url', url); localStorage.setItem('luma-ha-token', token); } catch {}
connectWS(url, token);
}
function connectWS(url, token) {
if (app.ws) { try { app.ws.close(); } catch {} }
const ws = new WebSocket(url);
app.ws = ws;
ws.onopen = () => {};
ws.onclose = () => {
app.connected = false;
document.getElementById('connect-overlay').classList.remove('hidden');
document.getElementById('connect-error').textContent = 'Connection lost. Reconnect?';
};
ws.onerror = () => {
document.getElementById('connect-error').textContent = 'Connection failed. Check URL.';
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'auth_required') {
ws.send(JSON.stringify({ type: 'auth', access_token: token }));
} else if (msg.type === 'auth_ok') {
app.connected = true;
document.getElementById('connect-overlay').classList.add('hidden');
document.getElementById('connect-error').textContent = '';
fetchStates();
subscribeEvents();
} else if (msg.type === 'auth_invalid') {
document.getElementById('connect-error').textContent = 'Invalid token.';
ws.close();
} else if (msg.type === 'result' && msg.success && app.pendingCalls[msg.id]) {
app.pendingCalls[msg.id](msg.result);
delete app.pendingCalls[msg.id];
} else if (msg.type === 'event' && msg.event?.event_type === 'state_changed') {
const d = msg.event.data;
if (d.new_state) {
app.entities[d.entity_id] = d.new_state;
renderCards();
}
}
};
}
function sendMsg(msg, cb) {
const id = app.msgId++;
msg.id = id;
if (cb) app.pendingCalls[id] = cb;
app.ws.send(JSON.stringify(msg));
return id;
}
function fetchStates() {
sendMsg({ type: 'get_states' }, (states) => {
for (const s of states) app.entities[s.entity_id] = s;
renderCards();
});
}
function subscribeEvents() {
sendMsg({ type: 'subscribe_events', event_type: 'state_changed' });
}
function callService(domain, service, data) {
sendMsg({ type: 'call_service', domain, service, service_data: data || {} });
}
// ── Config persistence ─────────────────────────────
function loadConfig() {
try {
const raw = localStorage.getItem('luma-dashboard-config');
if (raw) {
app.config = JSON.parse(raw);
if (!app.config.rooms) app.config.rooms = [];
}
} catch {}
if (app.config.rooms.length === 0) {
app.config = tvConfigToDashboard(TV_CONFIG);
saveConfig();
}
}
function saveConfig() {
try { localStorage.setItem('luma-dashboard-config', JSON.stringify(app.config)); } catch {}
}
function exportConfig() {
const tvJson = JSON.stringify(dashboardToTvConfig(app.config), null, 2);
try { navigator.clipboard.writeText(tvJson); } catch {}
const blob = new Blob([tvJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'config.json';
a.click();
URL.revokeObjectURL(url);
}
function resetToTvConfig() {
app.config = tvConfigToDashboard(TV_CONFIG);
app.activeRoom = 0;
saveConfig();
renderAll();
}
// ── Room management ────────────────────────────────
function addRoom() {
const name = prompt('Room name:');
if (!name) return;
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '_');
app.config.rooms.push({ id, name, cards: [] });
app.activeRoom = app.config.rooms.length - 1;
saveConfig();
renderAll();
}
function renameRoom(idx) {
const room = app.config.rooms[idx];
const name = prompt('Rename room:', room.name);
if (!name) return;
room.name = name;
room.id = name.toLowerCase().replace(/[^a-z0-9]+/g, '_');
saveConfig();
renderAll();
}
function deleteRoom(idx) {
if (app.config.rooms.length <= 1) return;
if (!confirm(`Delete "${app.config.rooms[idx].name}"?`)) return;
app.config.rooms.splice(idx, 1);
if (app.activeRoom >= app.config.rooms.length) app.activeRoom = app.config.rooms.length - 1;
saveConfig();
renderAll();
}
function selectRoom(idx) {
app.activeRoom = idx;
renderAll();
}
// ── Edit mode ──────────────────────────────────────
function toggleEditMode() {
app.editMode = !app.editMode;
document.body.classList.toggle('edit-mode', app.editMode);
document.getElementById('edit-toggle').textContent = app.editMode ? 'Done' : 'Edit';
if (app.editMode) {
document.getElementById('edit-toggle').classList.replace('btn-ghost', 'btn-edit');
} else {
document.getElementById('edit-toggle').classList.replace('btn-edit', 'btn-ghost');
closeEditPanel();
}
renderCards();
}
function openEditPanel(roomIdx, cardIdx) {
const card = app.config.rooms[roomIdx].cards[cardIdx];
app.editingCard = { roomIdx, cardIdx };
document.getElementById('ed-entity').value = card.entity_id || '';
document.getElementById('ed-name').value = card.name || '';
document.getElementById('ed-type').value = card.type || 'toggle';
document.getElementById('ed-size').value = card.size || '1x1';
document.getElementById('ed-accent').value = card.accent || '#7EB8DA';
document.getElementById('ed-accent-text').value = card.accent || '#7EB8DA';
document.getElementById('ed-glow').value = card.glow || '#7EB8DA';
document.getElementById('ed-glow-text').value = card.glow || '#7EB8DA';
document.getElementById('ed-icon').value = card.icon || 'auto';
document.getElementById('panel-title').textContent = cardIdx === -1 ? 'New Card' : 'Edit Card';
document.getElementById('edit-panel').classList.add('open');
}
function closeEditPanel() {
document.getElementById('edit-panel').classList.remove('open');
app.editingCard = null;
}
function saveEditingCard() {
if (!app.editingCard) return;
const { roomIdx, cardIdx } = app.editingCard;
const card = {
entity_id: document.getElementById('ed-entity').value,
name: document.getElementById('ed-name').value,
type: document.getElementById('ed-type').value,
size: document.getElementById('ed-size').value,
accent: document.getElementById('ed-accent').value,
glow: document.getElementById('ed-glow').value,
icon: document.getElementById('ed-icon').value,
};
if (!card.entity_id) { alert('Select an entity.'); return; }
if (cardIdx === -1) {
app.config.rooms[roomIdx].cards.push(card);
} else {
app.config.rooms[roomIdx].cards[cardIdx] = card;
}
saveConfig();
closeEditPanel();
renderCards();
}
function deleteEditingCard() {
if (!app.editingCard || app.editingCard.cardIdx === -1) { closeEditPanel(); return; }
const { roomIdx, cardIdx } = app.editingCard;
app.config.rooms[roomIdx].cards.splice(cardIdx, 1);
saveConfig();
closeEditPanel();
renderCards();
}
function addCardToRoom() {
app.editingCard = { roomIdx: app.activeRoom, cardIdx: -1 };
document.getElementById('ed-entity').value = '';
document.getElementById('ed-name').value = '';
document.getElementById('ed-type').value = 'toggle';
document.getElementById('ed-size').value = '1x1';
document.getElementById('ed-accent').value = '#7EB8DA';
document.getElementById('ed-accent-text').value = '#7EB8DA';
document.getElementById('ed-glow').value = '#7EB8DA';
document.getElementById('ed-glow-text').value = '#7EB8DA';
document.getElementById('ed-icon').value = 'auto';
document.getElementById('panel-title').textContent = 'New Card';
document.getElementById('edit-panel').classList.add('open');
}
// ── Entity picker ──────────────────────────────────
function openEntityPicker() {
const modal = document.getElementById('entity-modal');
modal.classList.add('open');
const search = document.getElementById('entity-search');
search.value = '';
search.focus();
filterEntities();
app.entityPickerCb = (eid) => {
document.getElementById('ed-entity').value = eid;
const ent = app.entities[eid];
if (ent && !document.getElementById('ed-name').value) {
document.getElementById('ed-name').value = ent.attributes.friendly_name || eid.split('.')[1];
}
const domain = eid.split('.')[0];
const typeMap = { light: 'range', switch: 'toggle', climate: 'climate', media_player: 'media', cover: 'curtain', scene: 'action', script: 'action', weather: 'weather', vacuum: 'vacuum' };
if (typeMap[domain]) document.getElementById('ed-type').value = typeMap[domain];
modal.classList.remove('open');
};
}
function filterEntities() {
const q = document.getElementById('entity-search').value.toLowerCase();
const list = document.getElementById('entity-list');
const sorted = Object.values(app.entities)
.filter(e => {
const s = e.entity_id + ' ' + (e.attributes.friendly_name || '');
return s.toLowerCase().includes(q);
})
.sort((a, b) => a.entity_id.localeCompare(b.entity_id))
.slice(0, 100);
list.innerHTML = sorted.map(e => `
<div class="entity-item" onclick="pickEntity('${e.entity_id}')">
<span class="eid">${iconFor(e.entity_id, null)} ${e.attributes.friendly_name || e.entity_id.split('.')[1]}</span>
<span class="ename">${e.entity_id} — ${e.state}</span>
</div>
`).join('');
}
function pickEntity(eid) {
if (app.entityPickerCb) app.entityPickerCb(eid);
}
document.getElementById('entity-modal').addEventListener('click', (e) => {
if (e.target.id === 'entity-modal') e.target.classList.remove('open');
});
// Color sync
document.getElementById('ed-accent').addEventListener('input', (e) => {
document.getElementById('ed-accent-text').value = e.target.value;
});
document.getElementById('ed-accent-text').addEventListener('input', (e) => {
if (/^#[0-9a-f]{6}$/i.test(e.target.value)) document.getElementById('ed-accent').value = e.target.value;
});
document.getElementById('ed-glow').addEventListener('input', (e) => {
document.getElementById('ed-glow-text').value = e.target.value;
});
document.getElementById('ed-glow-text').addEventListener('input', (e) => {
if (/^#[0-9a-f]{6}$/i.test(e.target.value)) document.getElementById('ed-glow').value = e.target.value;
});
// ── Card interaction ───────────────────────────────
function onCardClick(roomIdx, cardIdx, e) {
if (app.editMode) {
openEditPanel(roomIdx, cardIdx);
return;
}
const card = app.config.rooms[roomIdx].cards[cardIdx];
const ent = app.entities[card.entity_id];
const domain = card.entity_id.split('.')[0];
if (card.type === 'toggle') {
callService('homeassistant', 'toggle', { entity_id: card.entity_id });
// Optimistic update
if (ent) ent.state = ent.state === 'on' ? 'off' : 'on';
renderCards();
} else if (card.type === 'action') {
if (domain === 'scene') callService('scene', 'turn_on', { entity_id: card.entity_id });
else if (domain === 'script') callService('script', 'turn_on', { entity_id: card.entity_id });
else callService('homeassistant', 'turn_on', { entity_id: card.entity_id });
} else if (card.type === 'media') {
callService('media_player', 'media_play_pause', { entity_id: card.entity_id });
} else if (card.type === 'vacuum') {
if (ent?.state === 'cleaning') callService('vacuum', 'return_to_base', { entity_id: card.entity_id });
else callService('vacuum', 'start', { entity_id: card.entity_id });
}
}
function onSliderInput(roomIdx, cardIdx, value, e) {
e.stopPropagation();
const card = app.config.rooms[roomIdx].cards[cardIdx];
const domain = card.entity_id.split('.')[0];
if (card.type === 'range' && domain === 'light') {
const bri = Math.round(value * 2.55);
callService('light', 'turn_on', { entity_id: card.entity_id, brightness: bri });
} else if (card.type === 'climate') {
callService('climate', 'set_temperature', { entity_id: card.entity_id, temperature: value });
} else if (card.type === 'curtain' || card.type === 'blinds') {
callService('cover', 'set_cover_position', { entity_id: card.entity_id, position: value });
}
}
// ── Drag and drop (reorder) ────────────────────────
function onDragStart(e, cardIdx) {
if (!app.editMode) return;
app.dragFrom = cardIdx;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', cardIdx);
}
function onDragEnd(e) {
e.target.classList.remove('dragging');
document.querySelectorAll('.card.drag-over').forEach(el => el.classList.remove('drag-over'));
app.dragFrom = null;
}
function onDragOver(e, cardIdx) {
if (app.dragFrom === null || app.dragFrom === cardIdx) return;
e.preventDefault();
e.currentTarget.classList.add('drag-over');
}
function onDragLeave(e) {
e.currentTarget.classList.remove('drag-over');
}
function onDrop(e, cardIdx) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
if (app.dragFrom === null || app.dragFrom === cardIdx) return;
const room = app.config.rooms[app.activeRoom];
const [moved] = room.cards.splice(app.dragFrom, 1);
room.cards.splice(cardIdx, 0, moved);
app.dragFrom = null;
saveConfig();
renderCards();
}
// ── Rendering ──────────────────────────────────────
function renderAll() {
renderRoomTabs();
renderCards();
}
function renderRoomTabs() {
const list = document.getElementById('room-list');
list.innerHTML = app.config.rooms.map((room, i) => `
<div class="room-tab ${i === app.activeRoom ? 'active' : ''}" onclick="selectRoom(${i})">
<span>${room.name}</span>
<span class="room-actions">
<button class="room-action-btn" onclick="event.stopPropagation();renameRoom(${i})" title="Rename">✏</button>
<button class="room-action-btn" onclick="event.stopPropagation();deleteRoom(${i})" title="Delete">✕</button>
</span>
</div>
`).join('');
const room = app.config.rooms[app.activeRoom];
document.getElementById('room-title').textContent = room ? room.name : '—';
}
function renderCards() {
const room = app.config.rooms[app.activeRoom];
if (!room) return;
const grid = document.getElementById('card-grid');
let html = '';
for (let i = 0; i < room.cards.length; i++) {
html += renderCard(room.cards[i], i);
}
html += `<div class="add-card-btn" onclick="addCardToRoom()">+ Add Card</div>`;
grid.innerHTML = html;
}
function renderCard(card, idx) {
const ent = app.entities[card.entity_id];
const state = ent?.state || 'unavailable';
const isOn = state === 'on' || state === 'playing' || state === 'cleaning';
const isHeat = state === 'heat' || state === 'heat_cool' || state === 'auto';
const accent = card.accent || '#7EB8DA';
const glow = card.glow || accent;
const sizeClass = card.size && card.size !== '1x1' ? `size-${card.size}` : '';
const stateClass = isOn ? 'on' : (isHeat ? 'heat' : '');
const icon = iconFor(card.entity_id, card.icon);
let stateText = state;
let valueHtml = '';
let sliderHtml = '';
if (card.type === 'range') {
const bri = ent?.attributes?.brightness;
const pct = bri != null ? Math.round(bri / 2.55) : 0;
stateText = isOn ? `${pct}%` : 'Off';
if (isOn) {
sliderHtml = `
<div class="card-slider" onclick="event.stopPropagation()" style="--accent:${accent}">
<div class="card-slider-fill" style="width:${pct}%;background:${accent}"></div>
<input type="range" min="0" max="100" value="${pct}"
style="position:absolute;inset:0;width:100%;opacity:0;cursor:pointer;margin:0"
oninput="this.previousElementSibling.style.width=this.value+'%'"
onchange="onSliderInput(${app.activeRoom},${idx},+this.value,event)">
</div>`;
}
} else if (card.type === 'climate') {
const temp = ent?.attributes?.current_temperature;
const target = ent?.attributes?.temperature;
stateText = state === 'off' ? 'Off' : `${state}`;
if (temp != null) valueHtml = `<div class="card-value">${temp}°</div>`;
if (target != null) stateText += ` → ${target}°`;
} else if (card.type === 'media') {
const title = ent?.attributes?.media_title;
const artist = ent?.attributes?.media_artist;
stateText = state === 'playing' ? (artist ? `${artist} — ${title}` : title || 'Playing') : state;
} else if (card.type === 'weather') {
const temp = ent?.attributes?.temperature;
const cond = state;
if (temp != null) valueHtml = `<div class="card-value">${temp}°</div>`;
stateText = cond;
} else if (card.type === 'curtain' || card.type === 'blinds') {
const pos = ent?.attributes?.current_position;
stateText = state === 'closed' ? 'Closed' : (pos != null ? `${pos}%` : state);
} else if (card.type === 'vacuum') {
stateText = state === 'cleaning' ? 'Cleaning' : (state === 'returning' ? 'Returning' : state);
} else if (card.type === 'action') {
stateText = '';
} else {
stateText = isOn ? 'On' : (state === 'off' ? 'Off' : state);
}
const draggable = app.editMode ? 'draggable="true"' : '';
return `
<div class="card ${stateClass} ${sizeClass}" style="--card-glow:${glow}33"
onclick="onCardClick(${app.activeRoom},${idx},event)"
${draggable}
ondragstart="onDragStart(event,${idx})"
ondragend="onDragEnd(event)"
ondragover="onDragOver(event,${idx})"
ondragleave="onDragLeave(event)"
ondrop="onDrop(event,${idx})">
<span class="card-edit-badge">Edit</span>
<div class="card-top">
<div class="card-icon" style="${isOn ? `color:${accent}; background:${accent}22` : ''}">${icon}</div>
<div class="card-name">${card.name || card.entity_id.split('.')[1]}</div>
</div>
${valueHtml}
<div class="card-state ${isOn ? 'on-text' : ''}" ${isOn ? `style="color:${accent}"` : ''}>${stateText}</div>
${sliderHtml}
</div>`;
}
// ── Init ───────────────────────────────────────────
loadConfig();
renderAll();
// Auto-connect from saved credentials
try {
const savedUrl = localStorage.getItem('luma-ha-url');
const savedToken = localStorage.getItem('luma-ha-token');
if (savedUrl && savedToken) {
document.getElementById('ha-url').value = savedUrl;
document.getElementById('ha-token').value = savedToken;
document.getElementById('connect-error').textContent = 'Reconnecting...';
connectWS(savedUrl, savedToken);
}
} catch {}
</script>
</body>
</html>