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>
1067 lines
41 KiB
HTML
1067 lines
41 KiB
HTML
<!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()">×</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>
|