Self-contained HTML page that connects to HA WebSocket for entity discovery, provides visual editing of rooms/cards/weather/vacuum config, and exports config.json. Hosted from HA's /local/ directory. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
772 lines
29 KiB
HTML
772 lines
29 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 Config Editor</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0f1117;
|
||
--surface: #1a1d26;
|
||
--card: #22262f;
|
||
--border: rgba(255,255,255,0.08);
|
||
--border-focus: rgba(126,184,218,0.5);
|
||
--text: #e8e8ea;
|
||
--text-dim: #888a90;
|
||
--accent: #7eb8da;
|
||
--accent-bg: rgba(126,184,218,0.12);
|
||
--danger: #e06060;
|
||
--danger-bg: rgba(224,96,96,0.12);
|
||
--success: #60c080;
|
||
--radius: 12px;
|
||
--radius-sm: 8px;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
color-scheme: dark;
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { background: var(--bg); color: var(--text); min-height: 100vh; padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||
|
||
h1 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
|
||
h2 { font-size: 18px; font-weight: 600; margin-bottom: 12px; color: var(--accent); }
|
||
h3 { font-size: 15px; font-weight: 500; margin-bottom: 8px; }
|
||
.subtitle { color: var(--text-dim); font-size: 14px; margin-bottom: 24px; }
|
||
|
||
.section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; }
|
||
|
||
label { display: block; font-size: 13px; color: var(--text-dim); margin-bottom: 4px; }
|
||
input[type="text"], input[type="url"], input[type="number"], select {
|
||
width: 100%; padding: 8px 12px; background: var(--card); border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm); color: var(--text); font-size: 14px; outline: none;
|
||
transition: border-color .15s;
|
||
}
|
||
input:focus, select:focus { border-color: var(--border-focus); }
|
||
input[type="number"] { width: 80px; }
|
||
|
||
.row { display: flex; gap: 12px; align-items: end; margin-bottom: 12px; }
|
||
.row > * { flex: 1; min-width: 0; }
|
||
.row > .shrink { flex: 0 0 auto; }
|
||
|
||
button {
|
||
padding: 8px 16px; border-radius: var(--radius-sm); border: 1px solid var(--border);
|
||
background: var(--card); color: var(--text); font-size: 13px; cursor: pointer;
|
||
transition: all .15s; white-space: nowrap;
|
||
}
|
||
button:hover { border-color: var(--border-focus); background: var(--accent-bg); }
|
||
button.primary { background: var(--accent); color: #111; border-color: var(--accent); font-weight: 600; }
|
||
button.primary:hover { opacity: .85; }
|
||
button.danger { color: var(--danger); border-color: var(--danger); }
|
||
button.danger:hover { background: var(--danger-bg); }
|
||
button.small { padding: 4px 10px; font-size: 12px; }
|
||
|
||
.pill {
|
||
display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px;
|
||
background: var(--accent-bg); color: var(--accent); margin-right: 4px;
|
||
}
|
||
|
||
.entity-picker { position: relative; }
|
||
.entity-picker .dropdown {
|
||
position: absolute; top: 100%; left: 0; right: 0; max-height: 260px; overflow-y: auto;
|
||
background: var(--card); border: 1px solid var(--border-focus); border-radius: var(--radius-sm);
|
||
z-index: 100; display: none;
|
||
}
|
||
.entity-picker .dropdown.open { display: block; }
|
||
.entity-picker .dropdown .item {
|
||
padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid var(--border);
|
||
display: flex; justify-content: space-between;
|
||
}
|
||
.entity-picker .dropdown .item:hover { background: var(--accent-bg); }
|
||
.entity-picker .dropdown .item .domain { color: var(--text-dim); font-size: 11px; }
|
||
.entity-picker .dropdown .item .name { color: var(--text); }
|
||
|
||
.card-item {
|
||
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||
padding: 12px; margin-bottom: 8px; display: flex; gap: 12px; align-items: center;
|
||
}
|
||
.card-item .handle { cursor: grab; color: var(--text-dim); font-size: 18px; user-select: none; }
|
||
.card-item .info { flex: 1; min-width: 0; }
|
||
.card-item .info .eid { font-size: 12px; color: var(--text-dim); }
|
||
.card-item .actions { display: flex; gap: 4px; }
|
||
|
||
.room-tab {
|
||
display: inline-block; padding: 8px 16px; border-radius: 20px; cursor: pointer;
|
||
font-size: 14px; margin-right: 6px; margin-bottom: 8px; border: 1px solid var(--border);
|
||
background: var(--card); transition: all .15s;
|
||
}
|
||
.room-tab.active { background: var(--accent); color: #111; border-color: var(--accent); font-weight: 600; }
|
||
.room-tab:hover:not(.active) { border-color: var(--border-focus); }
|
||
|
||
.toast {
|
||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||
padding: 10px 20px; border-radius: var(--radius); background: var(--success);
|
||
color: #111; font-weight: 600; font-size: 14px; z-index: 999; opacity: 0;
|
||
transition: opacity .3s; pointer-events: none;
|
||
}
|
||
.toast.show { opacity: 1; }
|
||
|
||
.status { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 16px; }
|
||
.status .dot { width: 8px; height: 8px; border-radius: 50%; }
|
||
.status .dot.on { background: var(--success); }
|
||
.status .dot.off { background: var(--danger); }
|
||
|
||
.empty { text-align: center; padding: 32px; color: var(--text-dim); }
|
||
|
||
.vacuum-room-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
|
||
.vacuum-room-item { display: flex; gap: 8px; align-items: center; }
|
||
.vacuum-room-item input[type="number"] { width: 60px; }
|
||
.vacuum-room-item input[type="text"] { flex: 1; }
|
||
|
||
#json-preview {
|
||
width: 100%; min-height: 300px; background: var(--card); border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm); color: var(--text); font-family: "SF Mono", Menlo, monospace;
|
||
font-size: 13px; padding: 12px; resize: vertical; tab-size: 2;
|
||
}
|
||
|
||
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
|
||
.tab-btn {
|
||
padding: 6px 14px; border: none; background: none; color: var(--text-dim);
|
||
font-size: 14px; cursor: pointer; border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||
}
|
||
.tab-btn.active { color: var(--accent); background: var(--accent-bg); font-weight: 600; }
|
||
.tab-content { display: none; }
|
||
.tab-content.active { display: block; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<h1>Luma Home Config</h1>
|
||
<p class="subtitle">Visual editor for the Home Assistant tab on your LG TV</p>
|
||
|
||
<div class="tabs">
|
||
<button class="tab-btn active" onclick="switchTab('connection')">Connection</button>
|
||
<button class="tab-btn" onclick="switchTab('weather')">Weather</button>
|
||
<button class="tab-btn" onclick="switchTab('rooms')">Rooms</button>
|
||
<button class="tab-btn" onclick="switchTab('vacuum')">Vacuum</button>
|
||
<button class="tab-btn" onclick="switchTab('export')">Export</button>
|
||
</div>
|
||
|
||
<!-- Connection Tab -->
|
||
<div id="tab-connection" class="tab-content active">
|
||
<div class="section">
|
||
<h2>Home Assistant Connection</h2>
|
||
<div class="row">
|
||
<div>
|
||
<label>WebSocket URL</label>
|
||
<input type="url" id="ha-url" placeholder="wss://ha.example.org/api/websocket">
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<div>
|
||
<label>Long-Lived Access Token</label>
|
||
<input type="text" id="ha-token" placeholder="eyJ...">
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="shrink">
|
||
<button class="primary" onclick="connect()">Connect</button>
|
||
</div>
|
||
<div class="shrink">
|
||
<button onclick="disconnect()">Disconnect</button>
|
||
</div>
|
||
</div>
|
||
<div class="status">
|
||
<div class="dot" id="conn-dot"></div>
|
||
<span id="conn-status">Not connected</span>
|
||
<span style="margin-left:auto;color:var(--text-dim)" id="entity-count"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Import Existing Config</h2>
|
||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">Paste a config.json or load from file to pre-populate the editor.</p>
|
||
<div class="row">
|
||
<div class="shrink"><button onclick="document.getElementById('file-input').click()">Load File</button></div>
|
||
<div class="shrink"><button onclick="importFromTextarea()">Paste JSON Below</button></div>
|
||
</div>
|
||
<input type="file" id="file-input" accept=".json" style="display:none" onchange="loadFile(event)">
|
||
<textarea id="import-json" rows="4" placeholder='Paste config.json here...' style="width:100%;margin-top:8px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:monospace;font-size:13px;padding:8px;resize:vertical"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Weather Tab -->
|
||
<div id="tab-weather" class="tab-content">
|
||
<div class="section">
|
||
<h2>Weather Card</h2>
|
||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">Hero card shown at the top of every room. Leave empty to hide.</p>
|
||
<div class="row">
|
||
<div class="entity-picker">
|
||
<label>Weather Entity</label>
|
||
<input type="text" id="weather-entity" placeholder="weather.forecast_home" oninput="filterEntities(this,'weather')">
|
||
<div class="dropdown" id="weather-entity-dropdown"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rooms Tab -->
|
||
<div id="tab-rooms" class="tab-content">
|
||
<div class="section">
|
||
<h2>Rooms</h2>
|
||
<div id="room-tabs"></div>
|
||
<button onclick="addRoom()" style="margin-bottom:16px">+ Add Room</button>
|
||
<div id="room-editor"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Vacuum Tab -->
|
||
<div id="tab-vacuum" class="tab-content">
|
||
<div class="section">
|
||
<h2>Vacuum Configuration</h2>
|
||
<div class="row">
|
||
<div class="entity-picker">
|
||
<label>Vacuum Entity</label>
|
||
<input type="text" id="vacuum-entity" placeholder="vacuum.roborock" oninput="filterEntities(this,'vacuum')">
|
||
<div class="dropdown" id="vacuum-entity-dropdown"></div>
|
||
</div>
|
||
<div class="entity-picker">
|
||
<label>Cleaning Mode Entity</label>
|
||
<input type="text" id="vacuum-mode-entity" placeholder="select.roborock_cleaning_mode" oninput="filterEntities(this,'select')">
|
||
<div class="dropdown" id="vacuum-mode-entity-dropdown"></div>
|
||
</div>
|
||
</div>
|
||
<h3>Room Segments</h3>
|
||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">Map Roborock segment IDs to room names.</p>
|
||
<div id="vacuum-rooms"></div>
|
||
<button onclick="addVacuumRoom()" class="small">+ Add Segment</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Export Tab -->
|
||
<div id="tab-export" class="tab-content">
|
||
<div class="section">
|
||
<h2>Generated config.json</h2>
|
||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">
|
||
Copy this to <code>ha-home/config.json</code> in your Luma installation, then restart Luma.
|
||
</p>
|
||
<textarea id="json-preview" readonly></textarea>
|
||
<div class="row" style="margin-top:12px">
|
||
<div class="shrink"><button class="primary" onclick="copyJson()">Copy to Clipboard</button></div>
|
||
<div class="shrink"><button onclick="downloadJson()">Download config.json</button></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
// ── State ────────────────────────────────────────────────────────
|
||
let ws = null;
|
||
let msgId = 0;
|
||
let entities = {};
|
||
let config = {
|
||
version: 1,
|
||
ha: { url: '', token: '' },
|
||
weather: { entity_id: '' },
|
||
vacuum: { entity_id: '', cleaning_mode_entity: '', rooms: {} },
|
||
rooms: []
|
||
};
|
||
let activeRoom = 0;
|
||
|
||
// ── Init ─────────────────────────────────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const saved = localStorage.getItem('luma-editor-config');
|
||
if (saved) {
|
||
try { Object.assign(config, JSON.parse(saved)); } catch {}
|
||
}
|
||
const url = localStorage.getItem('luma-ha-url');
|
||
const token = localStorage.getItem('luma-ha-token');
|
||
if (url) document.getElementById('ha-url').value = url;
|
||
if (token) document.getElementById('ha-token').value = token;
|
||
if (config.ha.url) document.getElementById('ha-url').value = config.ha.url;
|
||
if (config.weather?.entity_id) document.getElementById('weather-entity').value = config.weather.entity_id;
|
||
if (config.vacuum?.entity_id) document.getElementById('vacuum-entity').value = config.vacuum.entity_id;
|
||
if (config.vacuum?.cleaning_mode_entity) document.getElementById('vacuum-mode-entity').value = config.vacuum.cleaning_mode_entity;
|
||
renderRoomTabs();
|
||
renderVacuumRooms();
|
||
renderExport();
|
||
|
||
document.addEventListener('click', (e) => {
|
||
document.querySelectorAll('.dropdown.open').forEach(d => {
|
||
if (!d.parentElement.contains(e.target)) d.classList.remove('open');
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── Tabs ─────────────────────────────────────────────────────────
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
event.target.classList.add('active');
|
||
document.getElementById('tab-' + name).classList.add('active');
|
||
if (name === 'export') renderExport();
|
||
}
|
||
|
||
// ── HA WebSocket ─────────────────────────────────────────────────
|
||
function connect() {
|
||
const url = document.getElementById('ha-url').value.trim();
|
||
const token = document.getElementById('ha-token').value.trim();
|
||
if (!url || !token) return toast('Enter URL and token');
|
||
|
||
config.ha.url = url;
|
||
config.ha.token = ''; // never persist token in config
|
||
localStorage.setItem('luma-ha-url', url);
|
||
localStorage.setItem('luma-ha-token', token);
|
||
saveState();
|
||
|
||
if (ws) ws.close();
|
||
setStatus('connecting');
|
||
|
||
try {
|
||
ws = new WebSocket(url);
|
||
} catch (e) {
|
||
setStatus('error', e.message);
|
||
return;
|
||
}
|
||
|
||
ws.onopen = () => {};
|
||
ws.onclose = () => setStatus('disconnected');
|
||
ws.onerror = () => setStatus('error');
|
||
|
||
ws.onmessage = (event) => {
|
||
const msg = JSON.parse(event.data);
|
||
if (msg.type === 'auth_required') {
|
||
ws.send(JSON.stringify({ type: 'auth', access_token: token }));
|
||
} else if (msg.type === 'auth_ok') {
|
||
setStatus('connected');
|
||
fetchEntities();
|
||
} else if (msg.type === 'auth_invalid') {
|
||
setStatus('error', 'Invalid token');
|
||
ws.close();
|
||
} else if (msg.type === 'result' && msg.success && Array.isArray(msg.result)) {
|
||
processEntities(msg.result);
|
||
}
|
||
};
|
||
}
|
||
|
||
function disconnect() {
|
||
if (ws) ws.close();
|
||
ws = null;
|
||
entities = {};
|
||
setStatus('disconnected');
|
||
document.getElementById('entity-count').textContent = '';
|
||
}
|
||
|
||
function fetchEntities() {
|
||
msgId++;
|
||
ws.send(JSON.stringify({ id: msgId, type: 'get_states' }));
|
||
}
|
||
|
||
function processEntities(states) {
|
||
entities = {};
|
||
for (const s of states) {
|
||
entities[s.entity_id] = {
|
||
entity_id: s.entity_id,
|
||
state: s.state,
|
||
friendly_name: s.attributes?.friendly_name || s.entity_id,
|
||
domain: s.entity_id.split('.')[0],
|
||
attributes: s.attributes || {}
|
||
};
|
||
}
|
||
document.getElementById('entity-count').textContent = `${Object.keys(entities).length} entities`;
|
||
}
|
||
|
||
function setStatus(state, detail) {
|
||
const dot = document.getElementById('conn-dot');
|
||
const label = document.getElementById('conn-status');
|
||
dot.className = 'dot';
|
||
if (state === 'connected') {
|
||
dot.classList.add('on');
|
||
label.textContent = 'Connected';
|
||
} else if (state === 'connecting') {
|
||
label.textContent = 'Connecting...';
|
||
} else if (state === 'error') {
|
||
dot.classList.add('off');
|
||
label.textContent = 'Error' + (detail ? ': ' + detail : '');
|
||
} else {
|
||
dot.classList.add('off');
|
||
label.textContent = 'Disconnected';
|
||
}
|
||
}
|
||
|
||
// ── Entity Picker ────────────────────────────────────────────────
|
||
function filterEntities(input, domainFilter) {
|
||
const query = input.value.toLowerCase();
|
||
const dropdown = input.parentElement.querySelector('.dropdown');
|
||
if (query.length < 2) { dropdown.classList.remove('open'); return; }
|
||
|
||
const matches = Object.values(entities)
|
||
.filter(e => {
|
||
if (domainFilter && e.domain !== domainFilter) return false;
|
||
return e.entity_id.includes(query) || e.friendly_name.toLowerCase().includes(query);
|
||
})
|
||
.slice(0, 30);
|
||
|
||
if (matches.length === 0) { dropdown.classList.remove('open'); return; }
|
||
|
||
dropdown.innerHTML = matches.map(e => `
|
||
<div class="item" onclick="pickEntity(this, '${e.entity_id}')">
|
||
<span class="name">${esc(e.friendly_name)}</span>
|
||
<span class="domain">${esc(e.entity_id)}</span>
|
||
</div>
|
||
`).join('');
|
||
dropdown.classList.add('open');
|
||
}
|
||
|
||
function pickEntity(el, entityId) {
|
||
const input = el.closest('.entity-picker').querySelector('input');
|
||
input.value = entityId;
|
||
el.closest('.dropdown').classList.remove('open');
|
||
input.dispatchEvent(new Event('change'));
|
||
}
|
||
|
||
// ── Room Editor ──────────────────────────────────────────────────
|
||
function addRoom() {
|
||
const id = 'room_' + (config.rooms.length + 1);
|
||
config.rooms.push({ id, name: 'New Room', background: '', sensors: [], cards: [] });
|
||
activeRoom = config.rooms.length - 1;
|
||
renderRoomTabs();
|
||
renderRoomEditor();
|
||
saveState();
|
||
}
|
||
|
||
function removeRoom(index) {
|
||
config.rooms.splice(index, 1);
|
||
if (activeRoom >= config.rooms.length) activeRoom = Math.max(0, config.rooms.length - 1);
|
||
renderRoomTabs();
|
||
renderRoomEditor();
|
||
saveState();
|
||
}
|
||
|
||
function selectRoom(index) {
|
||
activeRoom = index;
|
||
renderRoomTabs();
|
||
renderRoomEditor();
|
||
}
|
||
|
||
function renderRoomTabs() {
|
||
const container = document.getElementById('room-tabs');
|
||
container.innerHTML = config.rooms.map((r, i) =>
|
||
`<span class="room-tab${i === activeRoom ? ' active' : ''}" onclick="selectRoom(${i})">${esc(r.name)}</span>`
|
||
).join('');
|
||
renderRoomEditor();
|
||
}
|
||
|
||
function renderRoomEditor() {
|
||
const container = document.getElementById('room-editor');
|
||
if (config.rooms.length === 0) {
|
||
container.innerHTML = '<div class="empty">No rooms. Click "+ Add Room" to start.</div>';
|
||
return;
|
||
}
|
||
const room = config.rooms[activeRoom];
|
||
if (!room) { container.innerHTML = ''; return; }
|
||
|
||
container.innerHTML = `
|
||
<div class="row">
|
||
<div>
|
||
<label>Room Name</label>
|
||
<input type="text" value="${esc(room.name)}" onchange="updateRoom('name', this.value)">
|
||
</div>
|
||
<div>
|
||
<label>Room ID (slug)</label>
|
||
<input type="text" value="${esc(room.id)}" onchange="updateRoom('id', this.value)">
|
||
</div>
|
||
<div class="shrink" style="padding-bottom:1px">
|
||
<button class="danger small" onclick="removeRoom(${activeRoom})">Delete Room</button>
|
||
</div>
|
||
</div>
|
||
<h3>Cards</h3>
|
||
<div id="cards-list">${renderCards(room.cards)}</div>
|
||
<div class="row" style="margin-top:8px">
|
||
<div class="shrink"><button onclick="addCard('toggle')">+ Toggle</button></div>
|
||
<div class="shrink"><button onclick="addCard('range')">+ Range</button></div>
|
||
<div class="shrink"><button onclick="addCard('climate')">+ Climate</button></div>
|
||
<div class="shrink"><button onclick="addCard('action')">+ Action</button></div>
|
||
<div class="shrink"><button onclick="addCard('vacuum')">+ Vacuum</button></div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderCards(cards) {
|
||
if (cards.length === 0) return '<div class="empty" style="padding:16px">No cards in this room.</div>';
|
||
return cards.map((card, i) => `
|
||
<div class="card-item" draggable="true" ondragstart="dragCard(event,${i})" ondragover="event.preventDefault()" ondrop="dropCard(event,${i})">
|
||
<span class="handle">⠿</span>
|
||
<div class="info">
|
||
<div><span class="pill">${card.type}</span> <strong>${esc(card.name || '')}</strong></div>
|
||
<div class="eid">${esc(card.entity_id)}</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="small" onclick="editCard(${i})">Edit</button>
|
||
<button class="small danger" onclick="removeCard(${i})">×</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function updateRoom(field, value) {
|
||
config.rooms[activeRoom][field] = value;
|
||
renderRoomTabs();
|
||
saveState();
|
||
}
|
||
|
||
function addCard(type) {
|
||
const room = config.rooms[activeRoom];
|
||
room.cards.push({ type, entity_id: '', name: '' });
|
||
renderRoomEditor();
|
||
editCard(room.cards.length - 1);
|
||
saveState();
|
||
}
|
||
|
||
function removeCard(index) {
|
||
config.rooms[activeRoom].cards.splice(index, 1);
|
||
renderRoomEditor();
|
||
saveState();
|
||
}
|
||
|
||
let dragIndex = -1;
|
||
function dragCard(e, index) { dragIndex = index; e.dataTransfer.effectAllowed = 'move'; }
|
||
function dropCard(e, index) {
|
||
e.preventDefault();
|
||
if (dragIndex === index) return;
|
||
const cards = config.rooms[activeRoom].cards;
|
||
const [moved] = cards.splice(dragIndex, 1);
|
||
cards.splice(index, 0, moved);
|
||
renderRoomEditor();
|
||
saveState();
|
||
}
|
||
|
||
function editCard(index) {
|
||
const room = config.rooms[activeRoom];
|
||
const card = room.cards[index];
|
||
const overlay = document.createElement('div');
|
||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;display:flex;align-items:center;justify-content:center';
|
||
overlay.innerHTML = `
|
||
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;width:480px;max-width:90vw">
|
||
<h2 style="margin-bottom:16px">Edit Card</h2>
|
||
<div style="margin-bottom:12px">
|
||
<label>Type</label>
|
||
<select id="edit-type">
|
||
${['toggle','range','climate','action','vacuum'].map(t => `<option${t===card.type?' selected':''}>${t}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div style="margin-bottom:12px" class="entity-picker">
|
||
<label>Entity ID</label>
|
||
<input type="text" id="edit-entity" value="${esc(card.entity_id)}" oninput="filterEntities(this, document.getElementById('edit-type').value === 'climate' ? 'climate' : null)">
|
||
<div class="dropdown" id="edit-entity-dropdown"></div>
|
||
</div>
|
||
<div style="margin-bottom:12px">
|
||
<label>Display Name</label>
|
||
<input type="text" id="edit-name" value="${esc(card.name || '')}" placeholder="Leave empty to use HA friendly_name">
|
||
</div>
|
||
<div style="display:flex;gap:8px;justify-content:end">
|
||
<button onclick="this.closest('div[style*=fixed]').remove()">Cancel</button>
|
||
<button class="primary" onclick="saveCard(${index})">Save</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||
}
|
||
|
||
function saveCard(index) {
|
||
const card = config.rooms[activeRoom].cards[index];
|
||
card.type = document.getElementById('edit-type').value;
|
||
card.entity_id = document.getElementById('edit-entity').value.trim();
|
||
card.name = document.getElementById('edit-name').value.trim();
|
||
if (!card.name && entities[card.entity_id]) {
|
||
card.name = entities[card.entity_id].friendly_name;
|
||
}
|
||
document.querySelector('div[style*="fixed"]').remove();
|
||
renderRoomEditor();
|
||
saveState();
|
||
}
|
||
|
||
// ── Vacuum ───────────────────────────────────────────────────────
|
||
function renderVacuumRooms() {
|
||
const container = document.getElementById('vacuum-rooms');
|
||
const rooms = config.vacuum?.rooms || {};
|
||
const entries = Object.entries(rooms).sort((a, b) => Number(a[0]) - Number(b[0]));
|
||
if (entries.length === 0) {
|
||
container.innerHTML = '<div class="empty" style="padding:12px">No segments configured.</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = entries.map(([id, name]) => `
|
||
<div class="vacuum-room-item" style="margin-bottom:6px;display:flex;gap:8px;align-items:center">
|
||
<input type="number" value="${id}" onchange="renameVacuumSegment('${id}', this.value, null)" style="width:70px" placeholder="ID">
|
||
<input type="text" value="${esc(name)}" onchange="renameVacuumSegment('${id}', null, this.value)" placeholder="Room name">
|
||
<button class="small danger" onclick="removeVacuumRoom('${id}')">×</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function addVacuumRoom() {
|
||
if (!config.vacuum) config.vacuum = { entity_id: '', cleaning_mode_entity: '', rooms: {} };
|
||
const nextId = Math.max(0, ...Object.keys(config.vacuum.rooms).map(Number)) + 1;
|
||
config.vacuum.rooms[String(nextId)] = 'Room ' + nextId;
|
||
renderVacuumRooms();
|
||
saveState();
|
||
}
|
||
|
||
function removeVacuumRoom(id) {
|
||
delete config.vacuum.rooms[id];
|
||
renderVacuumRooms();
|
||
saveState();
|
||
}
|
||
|
||
function renameVacuumSegment(oldId, newId, newName) {
|
||
if (newId !== null && newId !== oldId) {
|
||
const name = config.vacuum.rooms[oldId];
|
||
delete config.vacuum.rooms[oldId];
|
||
config.vacuum.rooms[newId] = name;
|
||
}
|
||
if (newName !== null) {
|
||
config.vacuum.rooms[newId || oldId] = newName;
|
||
}
|
||
renderVacuumRooms();
|
||
saveState();
|
||
}
|
||
|
||
// Sync vacuum inputs
|
||
document.addEventListener('change', (e) => {
|
||
if (e.target.id === 'vacuum-entity') {
|
||
if (!config.vacuum) config.vacuum = { entity_id: '', cleaning_mode_entity: '', rooms: {} };
|
||
config.vacuum.entity_id = e.target.value;
|
||
saveState();
|
||
}
|
||
if (e.target.id === 'vacuum-mode-entity') {
|
||
if (!config.vacuum) config.vacuum = { entity_id: '', cleaning_mode_entity: '', rooms: {} };
|
||
config.vacuum.cleaning_mode_entity = e.target.value;
|
||
saveState();
|
||
}
|
||
if (e.target.id === 'weather-entity') {
|
||
config.weather = { entity_id: e.target.value };
|
||
saveState();
|
||
}
|
||
});
|
||
|
||
// ── Import ───────────────────────────────────────────────────────
|
||
function loadFile(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
importConfig(JSON.parse(e.target.result));
|
||
toast('Config loaded');
|
||
} catch (err) {
|
||
toast('Invalid JSON: ' + err.message);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
event.target.value = '';
|
||
}
|
||
|
||
function importFromTextarea() {
|
||
const text = document.getElementById('import-json').value.trim();
|
||
if (!text) return toast('Paste JSON first');
|
||
try {
|
||
importConfig(JSON.parse(text));
|
||
toast('Config imported');
|
||
} catch (err) {
|
||
toast('Invalid JSON: ' + err.message);
|
||
}
|
||
}
|
||
|
||
function importConfig(data) {
|
||
config = {
|
||
version: data.version || 1,
|
||
ha: { url: data.ha?.url || '', token: '' },
|
||
weather: data.weather || { entity_id: '' },
|
||
vacuum: data.vacuum || { entity_id: '', cleaning_mode_entity: '', rooms: {} },
|
||
rooms: data.rooms || []
|
||
};
|
||
if (config.ha.url) document.getElementById('ha-url').value = config.ha.url;
|
||
document.getElementById('weather-entity').value = config.weather?.entity_id || '';
|
||
document.getElementById('vacuum-entity').value = config.vacuum?.entity_id || '';
|
||
document.getElementById('vacuum-mode-entity').value = config.vacuum?.cleaning_mode_entity || '';
|
||
activeRoom = 0;
|
||
renderRoomTabs();
|
||
renderVacuumRooms();
|
||
saveState();
|
||
}
|
||
|
||
// ── Export ────────────────────────────────────────────────────────
|
||
function buildExport() {
|
||
const out = {
|
||
version: 1,
|
||
ha: {
|
||
url: document.getElementById('ha-url').value.trim(),
|
||
token: ''
|
||
}
|
||
};
|
||
const weatherId = document.getElementById('weather-entity').value.trim();
|
||
if (weatherId) out.weather = { entity_id: weatherId };
|
||
const vacId = document.getElementById('vacuum-entity').value.trim();
|
||
if (vacId) {
|
||
out.vacuum = {
|
||
entity_id: vacId,
|
||
cleaning_mode_entity: document.getElementById('vacuum-mode-entity').value.trim(),
|
||
rooms: config.vacuum?.rooms || {}
|
||
};
|
||
}
|
||
out.rooms = config.rooms.map(r => {
|
||
const room = { id: r.id, name: r.name };
|
||
if (r.background) room.background = r.background;
|
||
room.sensors = r.sensors || [];
|
||
room.cards = r.cards.filter(c => c.entity_id).map(c => {
|
||
const card = { type: c.type, entity_id: c.entity_id };
|
||
if (c.name) card.name = c.name;
|
||
return card;
|
||
});
|
||
return room;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function renderExport() {
|
||
document.getElementById('json-preview').value = JSON.stringify(buildExport(), null, 2);
|
||
}
|
||
|
||
function copyJson() {
|
||
renderExport();
|
||
const text = document.getElementById('json-preview').value;
|
||
navigator.clipboard.writeText(text).then(() => toast('Copied!')).catch(() => {
|
||
document.getElementById('json-preview').select();
|
||
toast('Select all + copy manually');
|
||
});
|
||
}
|
||
|
||
function downloadJson() {
|
||
renderExport();
|
||
const blob = new Blob([document.getElementById('json-preview').value], { 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);
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────
|
||
function saveState() {
|
||
const save = { ...config };
|
||
save.ha = { url: save.ha.url, token: '' };
|
||
localStorage.setItem('luma-editor-config', JSON.stringify(save));
|
||
}
|
||
|
||
function esc(s) {
|
||
const d = document.createElement('div');
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function toast(msg) {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.classList.add('show');
|
||
clearTimeout(el._t);
|
||
el._t = setTimeout(() => el.classList.remove('show'), 2500);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|