Files
Kaloyan DanchevandClaude Opus 4.6 dce4f172ca
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Add visual config editor for HA tab
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>
2026-10-24 12:23:21 +03:00

772 lines
29 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Luma Home 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>