Add visual config editor for HA tab
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

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>
This commit is contained in:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-24 12:23:21 +03:00
1 parent 764af371b9
commit dce4f172ca
1 file changed
+771
+771
View File
@@ -0,0 +1,771 @@
<!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>