Compare commits
3
Commits
14db4f6e71
...
dce4f172ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dce4f172ca | ||
|
|
764af371b9 | ||
|
|
98dd75ab0e |
No files matched your search
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M2 4h20"></path><path d="M2 4v16"></path><path d="M22 4v16"></path><path d="M5 4c0 6 3 8 7 8"></path><path d="M19 4c0 6-3 8-7 8"></path><path d="M5 4c0 10 3 16 7 16"></path><path d="M19 4c0 10-3 16-7 16"></path></svg>
|
||||
|
After Width: | Height: | Size: 426 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z"></path></svg>
|
||||
|
After Width: | Height: | Size: 325 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path><path d="M9 18h6"></path><path d="M10 22h4"></path></svg>
|
||||
|
After Width: | Height: | Size: 375 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h.01"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>
|
||||
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><rect x="2" y="6" width="20" height="12" rx="6"></rect><circle cx="16" cy="12" r="2"></circle></svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><circle cx="12" cy="12" r="9"></circle><circle cx="12" cy="12" r="3"></circle><path d="M12 9V4"></path><path d="m14.6 10.5 4.3-2.5"></path><path d="m14.6 13.5 4.3 2.5"></path><path d="M12 15v5"></path><path d="m9.4 13.5-4.3 2.5"></path><path d="m9.4 10.5-4.3-2.5"></path></svg>
|
||||
|
After Width: | Height: | Size: 477 B |
@@ -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>
|
||||
@@ -0,0 +1,41 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const APPS = [
|
||||
'com.palm.app.settings',
|
||||
'com.webos.app.notificationcenter',
|
||||
'com.webos.app.gameoptimizer',
|
||||
];
|
||||
|
||||
const outDir = path.join(__dirname, 'dist');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
for (const app of APPS) {
|
||||
const srcPath = path.join(__dirname, app + '.js');
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.log('SKIP:', app, '(source not found)');
|
||||
continue;
|
||||
}
|
||||
|
||||
const code = fs.readFileSync(srcPath, 'utf8');
|
||||
|
||||
let minified = code
|
||||
.replace(/\/\/[^\n]*/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\n\s*\n/g, '\n')
|
||||
.replace(/^\s+/gm, '')
|
||||
.replace(/\n/g, '')
|
||||
.replace(/ +/g, ' ');
|
||||
|
||||
console.log(app + ':');
|
||||
console.log(' Original:', code.length, 'bytes');
|
||||
console.log(' Minified:', minified.length, 'bytes');
|
||||
|
||||
const minPath = path.join(outDir, app + '.min.js');
|
||||
fs.writeFileSync(minPath, minified);
|
||||
|
||||
const b64 = Buffer.from(minified).toString('base64');
|
||||
const b64Path = path.join(outDir, app + '.b64');
|
||||
fs.writeFileSync(b64Path, b64);
|
||||
console.log(' Base64: ', b64.length, 'bytes');
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const src = path.join(__dirname, 'com.palm.app.settings.js');
|
||||
const code = fs.readFileSync(src, 'utf8');
|
||||
|
||||
let minified = code
|
||||
.replace(/\/\/[^\n]*/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\n\s*\n/g, '\n')
|
||||
.replace(/^\s+/gm, '')
|
||||
.replace(/\n/g, '')
|
||||
.replace(/ +/g, ' ');
|
||||
|
||||
console.log('Original size:', code.length, 'bytes');
|
||||
console.log('Minified size:', minified.length, 'bytes');
|
||||
|
||||
const outDir = path.join(__dirname, 'dist');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const outPath = path.join(outDir, 'com.palm.app.settings.min.js');
|
||||
fs.writeFileSync(outPath, minified);
|
||||
console.log('Written to:', outPath);
|
||||
|
||||
const b64 = Buffer.from(minified).toString('base64');
|
||||
const b64Path = path.join(outDir, 'com.palm.app.settings.b64');
|
||||
fs.writeFileSync(b64Path, b64);
|
||||
console.log('Base64 size:', b64.length, 'bytes');
|
||||
console.log('Base64 written to:', b64Path);
|
||||
@@ -0,0 +1,282 @@
|
||||
// LG webOS Game Optimizer — Luma-style Dark Theme V1.0
|
||||
// Same visual language as Settings theme: transparent compositor,
|
||||
// nuclear background strip, dark pills, white focus.
|
||||
// CSS class hashes from webOS 6.5.3 (kisscurl-koli).
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STYLE_ID = 'luma-go-v10';
|
||||
|
||||
// === COMPOSITOR TRANSPARENCY ===
|
||||
try {
|
||||
if (window.PalmSystem && PalmSystem.setWindowProperties) {
|
||||
PalmSystem.setWindowProperties(JSON.stringify({ transparent: true }));
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (window.webOSSystem && webOSSystem.setWindowProperty) {
|
||||
webOSSystem.setWindowProperty('transparent', 'true');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
var CSS = [
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 1. NUCLEAR — strip all backgrounds
|
||||
// -------------------------------------------------------
|
||||
'*, *::before, *::after {',
|
||||
' background-color: transparent !important;',
|
||||
' background-image: none !important;',
|
||||
' box-shadow: none !important;',
|
||||
' transition: none !important;',
|
||||
' animation: none !important;',
|
||||
'}',
|
||||
'html, body {',
|
||||
' background: transparent !important;',
|
||||
' background-color: transparent !important;',
|
||||
' background-image: none !important;',
|
||||
'}',
|
||||
'body { color: rgba(255,255,255,0.85) !important; }',
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 2. DARK PILLS on items
|
||||
// -------------------------------------------------------
|
||||
// Standard Enact Item bg
|
||||
'.Item_bg__166C- {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: rgba(0,0,0,0.65) !important;',
|
||||
' background-image: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
' filter: none !important;',
|
||||
'}',
|
||||
|
||||
// Game-specific Item bg
|
||||
'.GameItem_bg__1Px-3 {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: rgba(0,0,0,0.65) !important;',
|
||||
' background-image: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
' filter: none !important;',
|
||||
'}',
|
||||
|
||||
// Other bg elements: pill radius
|
||||
'[class*="_bg__"]:not(.Item_bg__166C-):not(.GameItem_bg__1Px-3) {',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 3. FOCUS — white pill + dark text
|
||||
// -------------------------------------------------------
|
||||
|
||||
// Standard Item focus
|
||||
'.Item_item__1Q0oR.spottable:focus,',
|
||||
'.Item_item__2eTQF.spottable:focus {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
'.Item_item__1Q0oR.spottable:focus .Item_bg__166C-,',
|
||||
'.Item_item__2eTQF.spottable:focus .Item_bg__166C- {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: #fff !important;',
|
||||
' background-image: none !important;',
|
||||
' filter: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
'.Item_item__1Q0oR.spottable:focus .Item_label__yk-Xa,',
|
||||
'.Item_item__2eTQF.spottable:focus .Item_label__yk-Xa,',
|
||||
'.Item_item__1Q0oR.spottable:focus .Item_itemContent__372MG,',
|
||||
'.Item_item__2eTQF.spottable:focus .Item_itemContent__372MG,',
|
||||
'.Item_item__1Q0oR.spottable:focus .Item_slotAfter__2fc4G,',
|
||||
'.Item_item__2eTQF.spottable:focus .Item_slotAfter__2fc4G,',
|
||||
'.Item_item__1Q0oR.spottable:focus .Item_slotBefore__37DXJ,',
|
||||
'.Item_item__2eTQF.spottable:focus .Item_slotBefore__37DXJ,',
|
||||
'.Item_item__1Q0oR.spottable:focus .Icon_icon__1UDrL,',
|
||||
'.Item_item__1Q0oR.spottable:focus .Icon_icon__2ch_u,',
|
||||
'.Item_item__2eTQF.spottable:focus .Icon_icon__1UDrL,',
|
||||
'.Item_item__2eTQF.spottable:focus .Icon_icon__2ch_u {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
|
||||
// GameItem focus
|
||||
'.GameItem_item__1e3fq.spottable:focus {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
'.GameItem_item__1e3fq.spottable:focus .GameItem_bg__1Px-3 {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: #fff !important;',
|
||||
' background-image: none !important;',
|
||||
' filter: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
'.GameItem_item__1e3fq.spottable:focus .GameItem_label__2HOiY,',
|
||||
'.GameItem_item__1e3fq.spottable:focus .Icon_icon__1UDrL,',
|
||||
'.GameItem_item__1e3fq.spottable:focus .Icon_icon__2ch_u {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
|
||||
// Disabled items
|
||||
'[disabled] .Item_itemContent__372MG,',
|
||||
'[aria-disabled="true"] .Item_itemContent__372MG,',
|
||||
'[disabled] .Item_label__yk-Xa,',
|
||||
'[aria-disabled="true"] .Item_label__yk-Xa,',
|
||||
'[disabled] .Item_slotAfter__2fc4G,',
|
||||
'[aria-disabled="true"] .Item_slotAfter__2fc4G,',
|
||||
'[disabled] .Item_slotBefore__37DXJ,',
|
||||
'[aria-disabled="true"] .Item_slotBefore__37DXJ,',
|
||||
'[disabled] .Icon_icon__1UDrL,',
|
||||
'[disabled] .Icon_icon__2ch_u,',
|
||||
'[aria-disabled="true"] .Icon_icon__1UDrL,',
|
||||
'[aria-disabled="true"] .Icon_icon__2ch_u {',
|
||||
' opacity: 1 !important;',
|
||||
' color: rgba(255,255,255,0.5) !important;',
|
||||
'}',
|
||||
|
||||
// Button focus (standard Enact)
|
||||
'.Button_button__2TM80.spottable:focus,',
|
||||
'.Button_button__2eYtX.spottable:focus {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
'.Button_button__2TM80.spottable:focus .Button_bg__2zdeb,',
|
||||
'.Button_button__2TM80.spottable:focus .Button_bg__2zkI-,',
|
||||
'.Button_button__2eYtX.spottable:focus .Button_bg__2zdeb,',
|
||||
'.Button_button__2eYtX.spottable:focus .Button_bg__2zkI- {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: #fff !important;',
|
||||
' background-image: none !important;',
|
||||
' filter: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
'.Button_button__2TM80.spottable:focus .Button_client__1-mQb,',
|
||||
'.Button_button__2TM80.spottable:focus .Button_client__2pFZm,',
|
||||
'.Button_button__2eYtX.spottable:focus .Button_client__1-mQb,',
|
||||
'.Button_button__2eYtX.spottable:focus .Button_client__2pFZm,',
|
||||
'.Button_button__2TM80.spottable:focus .Icon_icon__1UDrL,',
|
||||
'.Button_button__2TM80.spottable:focus .Icon_icon__2ch_u,',
|
||||
'.Button_button__2eYtX.spottable:focus .Icon_icon__1UDrL,',
|
||||
'.Button_button__2eYtX.spottable:focus .Icon_icon__2ch_u {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
|
||||
// GameButton focus
|
||||
'.GameButton_button__2_0__.spottable:focus {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
'.GameButton_button__2_0__.spottable:focus .GameButton_bg__10LOj {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: #fff !important;',
|
||||
' background-image: none !important;',
|
||||
' filter: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
|
||||
// GameIconButton focus
|
||||
'.GameIconButton_button__2mofS.spottable:focus .GameIconButton_bg__121PO {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: #fff !important;',
|
||||
' background-image: none !important;',
|
||||
' filter: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
|
||||
// GameSwitch focus
|
||||
'.GameSwitch_switch__OWz1z.spottable:focus .GameSwitch_bg__1mRa0 {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: #fff !important;',
|
||||
' background-image: none !important;',
|
||||
' filter: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
|
||||
// GameRadioItem focus
|
||||
'.GameRadioItem_radioItem__2GUcI.spottable:focus {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
|
||||
'.spottable:focus { outline: none !important; }',
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 4. TEXT & MISC
|
||||
// -------------------------------------------------------
|
||||
'.Item_item__1Q0oR, .Item_item__2eTQF {',
|
||||
' color: rgba(255,255,255,0.85) !important;',
|
||||
'}',
|
||||
'.GameItem_item__1e3fq {',
|
||||
' color: rgba(255,255,255,0.85) !important;',
|
||||
'}',
|
||||
|
||||
'.Button_button__2TM80, .Button_button__2eYtX {',
|
||||
' color: rgba(255,255,255,0.85) !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
|
||||
'.Header_header__2Xmak {',
|
||||
' color: #fff !important;',
|
||||
' border-bottom: 1px solid rgba(255,255,255,0.06) !important;',
|
||||
'}',
|
||||
|
||||
'h1, h2, h3 { color: #fff !important; }',
|
||||
'.Heading_heading__Y4gPw { color: #fff !important; }',
|
||||
'.GameHeading_gameHeading__2dHP1 { color: #fff !important; }',
|
||||
|
||||
// GamePanel backdrop — semi-transparent dark instead of opaque
|
||||
'.GamePanel_panelBackdrop__1nX3U {',
|
||||
' background-color: rgba(0,0,0,0.75) !important;',
|
||||
' border-radius: 24px !important;',
|
||||
'}',
|
||||
'.GamePanel_panel__2770_ { color: rgba(255,255,255,0.85) !important; }',
|
||||
|
||||
// AI Indicator
|
||||
'.AIIndicatorUnit_title__1EVRE { color: rgba(255,255,255,0.7) !important; }',
|
||||
'.AIIndicatorUnit_value__pC9jL { color: #fff !important; }',
|
||||
|
||||
// Alert popup
|
||||
'.Alert_alert__gVwa9,',
|
||||
'.Alert_popup__1b3Xc {',
|
||||
' background-color: rgba(0,0,0,0.75) !important;',
|
||||
' border-radius: 24px !important;',
|
||||
'}',
|
||||
'.Alert_title__1HfX1 { color: #fff !important; }',
|
||||
'.Alert_body__3g69H { color: rgba(255,255,255,0.85) !important; }',
|
||||
|
||||
// FixedPopupPanels
|
||||
'.FixedPopupPanels_fixedPopupPanels__wpe6K,',
|
||||
'.FixedPopupPanels_body__2ARIw,',
|
||||
'.FixedPopupPanels_panel__38yei {',
|
||||
' background-color: rgba(0,0,0,0.75) !important;',
|
||||
' border-radius: 24px !important;',
|
||||
'}',
|
||||
|
||||
// Popup container
|
||||
'.Popup_popup__3sv6e {',
|
||||
' background-color: rgba(0,0,0,0.75) !important;',
|
||||
' border-radius: 24px !important;',
|
||||
'}',
|
||||
'.Popup_body__2J-hw { color: rgba(255,255,255,0.85) !important; }',
|
||||
|
||||
// Picker styling
|
||||
'.Picker_picker__fngq1 { color: rgba(255,255,255,0.85) !important; }',
|
||||
|
||||
'* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; }',
|
||||
|
||||
''
|
||||
].join('\n');
|
||||
|
||||
function injectStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
var style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
if (document.body) {
|
||||
injectStyle();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', injectStyle);
|
||||
}
|
||||
|
||||
new MutationObserver(function () {
|
||||
if (!document.getElementById(STYLE_ID)) injectStyle();
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,237 @@
|
||||
// LG webOS Notification Center — Luma-style Dark Theme V1.0
|
||||
// Same visual language as Settings theme: transparent compositor,
|
||||
// nuclear background strip, dark pills, white focus.
|
||||
// CSS class hashes from webOS 6.5.3 (kisscurl-koli).
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STYLE_ID = 'luma-nc-v10';
|
||||
|
||||
// === COMPOSITOR TRANSPARENCY ===
|
||||
try {
|
||||
if (window.PalmSystem && PalmSystem.setWindowProperties) {
|
||||
PalmSystem.setWindowProperties(JSON.stringify({ transparent: true }));
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (window.webOSSystem && webOSSystem.setWindowProperty) {
|
||||
webOSSystem.setWindowProperty('transparent', 'true');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
var CSS = [
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 1. NUCLEAR — strip all backgrounds
|
||||
// -------------------------------------------------------
|
||||
'*, *::before, *::after {',
|
||||
' background-color: transparent !important;',
|
||||
' background-image: none !important;',
|
||||
' box-shadow: none !important;',
|
||||
' transition: none !important;',
|
||||
' animation: none !important;',
|
||||
'}',
|
||||
'html, body {',
|
||||
' background: transparent !important;',
|
||||
' background-color: transparent !important;',
|
||||
' background-image: none !important;',
|
||||
'}',
|
||||
'body { color: rgba(255,255,255,0.85) !important; }',
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 2. DARK PILLS on items
|
||||
// -------------------------------------------------------
|
||||
'.Item_bg__2MM1D {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: rgba(0,0,0,0.65) !important;',
|
||||
' background-image: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
' filter: none !important;',
|
||||
'}',
|
||||
|
||||
// Other bg elements stay hidden until focus
|
||||
'[class*="_bg__"]:not(.Item_bg__2MM1D) {',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
|
||||
// FormCheckboxItem bg
|
||||
'.FormCheckboxItem_bg__3GHsc {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: rgba(0,0,0,0.65) !important;',
|
||||
' background-image: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
' filter: none !important;',
|
||||
'}',
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 3. FOCUS — white pill + dark text
|
||||
// -------------------------------------------------------
|
||||
|
||||
// Item focus
|
||||
'.Item_item__3OZOO.spottable:focus,',
|
||||
'.Item_item__3gT3d.spottable:focus {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
'.Item_item__3OZOO.spottable:focus .Item_bg__2MM1D,',
|
||||
'.Item_item__3gT3d.spottable:focus .Item_bg__2MM1D {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: #fff !important;',
|
||||
' background-image: none !important;',
|
||||
' filter: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
'.Item_item__3OZOO.spottable:focus .Item_label__2Wgl2,',
|
||||
'.Item_item__3gT3d.spottable:focus .Item_label__2Wgl2,',
|
||||
'.Item_item__3OZOO.spottable:focus .Item_itemContent__19m1V,',
|
||||
'.Item_item__3gT3d.spottable:focus .Item_itemContent__19m1V,',
|
||||
'.Item_item__3OZOO.spottable:focus .Item_slotAfter__hf946,',
|
||||
'.Item_item__3gT3d.spottable:focus .Item_slotAfter__hf946,',
|
||||
'.Item_item__3OZOO.spottable:focus .Item_slotBefore__39lzy,',
|
||||
'.Item_item__3gT3d.spottable:focus .Item_slotBefore__39lzy,',
|
||||
'.Item_item__3OZOO.spottable:focus .Icon_icon__3xbFH,',
|
||||
'.Item_item__3OZOO.spottable:focus .Icon_icon__uDPTY,',
|
||||
'.Item_item__3gT3d.spottable:focus .Icon_icon__3xbFH,',
|
||||
'.Item_item__3gT3d.spottable:focus .Icon_icon__uDPTY {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
|
||||
// FormCheckboxItem focus
|
||||
'.FormCheckboxItem_item__s8bcU.spottable:focus {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
'.FormCheckboxItem_item__s8bcU.spottable:focus .FormCheckboxItem_bg__3GHsc {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: #fff !important;',
|
||||
' background-image: none !important;',
|
||||
' filter: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
|
||||
// Disabled items: force content children visible
|
||||
'[disabled] .Item_itemContent__19m1V,',
|
||||
'[aria-disabled="true"] .Item_itemContent__19m1V,',
|
||||
'[disabled] .Item_label__2Wgl2,',
|
||||
'[aria-disabled="true"] .Item_label__2Wgl2,',
|
||||
'[disabled] .Item_slotAfter__hf946,',
|
||||
'[aria-disabled="true"] .Item_slotAfter__hf946,',
|
||||
'[disabled] .Item_slotBefore__39lzy,',
|
||||
'[aria-disabled="true"] .Item_slotBefore__39lzy,',
|
||||
'[disabled] .Icon_icon__3xbFH,',
|
||||
'[disabled] .Icon_icon__uDPTY,',
|
||||
'[aria-disabled="true"] .Icon_icon__3xbFH,',
|
||||
'[aria-disabled="true"] .Icon_icon__uDPTY {',
|
||||
' opacity: 1 !important;',
|
||||
' color: rgba(255,255,255,0.5) !important;',
|
||||
'}',
|
||||
|
||||
// Button focus
|
||||
'.Button_button__2U0Zv.spottable:focus,',
|
||||
'.Button_button__msIzD.spottable:focus {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
'.Button_button__2U0Zv.spottable:focus .Button_bg__1Dcsq,',
|
||||
'.Button_button__2U0Zv.spottable:focus .Button_bg__AoDJy,',
|
||||
'.Button_button__msIzD.spottable:focus .Button_bg__1Dcsq,',
|
||||
'.Button_button__msIzD.spottable:focus .Button_bg__AoDJy {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: #fff !important;',
|
||||
' background-image: none !important;',
|
||||
' filter: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
'.Button_button__2U0Zv.spottable:focus .Button_client__2HSGm,',
|
||||
'.Button_button__2U0Zv.spottable:focus .Button_client__3Hkj3,',
|
||||
'.Button_button__msIzD.spottable:focus .Button_client__2HSGm,',
|
||||
'.Button_button__msIzD.spottable:focus .Button_client__3Hkj3,',
|
||||
'.Button_button__2U0Zv.spottable:focus .Icon_icon__3xbFH,',
|
||||
'.Button_button__2U0Zv.spottable:focus .Icon_icon__uDPTY,',
|
||||
'.Button_button__msIzD.spottable:focus .Icon_icon__3xbFH,',
|
||||
'.Button_button__msIzD.spottable:focus .Icon_icon__uDPTY {',
|
||||
' color: #1a1a1a !important;',
|
||||
'}',
|
||||
|
||||
// Checkbox focus
|
||||
'.Checkbox_checkbox__1mTpP.spottable:focus .Checkbox_bg__1-1hH {',
|
||||
' opacity: 1 !important;',
|
||||
' background-color: #fff !important;',
|
||||
' background-image: none !important;',
|
||||
' filter: none !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
|
||||
'.spottable:focus { outline: none !important; }',
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 4. TEXT & MISC
|
||||
// -------------------------------------------------------
|
||||
'.Item_item__3OZOO, .Item_item__3gT3d {',
|
||||
' color: rgba(255,255,255,0.85) !important;',
|
||||
'}',
|
||||
|
||||
'.Button_button__2U0Zv, .Button_button__msIzD {',
|
||||
' color: rgba(255,255,255,0.85) !important;',
|
||||
' border-radius: 9999px !important;',
|
||||
'}',
|
||||
|
||||
'.Header_header__1wT5v {',
|
||||
' color: #fff !important;',
|
||||
' border-bottom: 1px solid rgba(255,255,255,0.06) !important;',
|
||||
'}',
|
||||
|
||||
'h1, h2, h3 { color: #fff !important; }',
|
||||
'.Heading_heading__Exc3p { color: #fff !important; }',
|
||||
|
||||
// Notification-specific: NotiHeader
|
||||
'.NotiHeader_children__5zAyB { color: rgba(255,255,255,0.85) !important; }',
|
||||
'.NotiHeader_headerButton__Y2scK { color: rgba(255,255,255,0.7) !important; }',
|
||||
'.NotiIcon_icon__3m2-f { color: rgba(255,255,255,0.85) !important; }',
|
||||
|
||||
// Alert popup styling
|
||||
'.Alert_alert__1hTPw,',
|
||||
'.Alert_popup__2t0iD {',
|
||||
' background-color: rgba(0,0,0,0.75) !important;',
|
||||
' border-radius: 24px !important;',
|
||||
'}',
|
||||
'.Alert_title__1Qf_f { color: #fff !important; }',
|
||||
'.Alert_body__3frtV { color: rgba(255,255,255,0.85) !important; }',
|
||||
|
||||
// FixedPopupPanels (detail views)
|
||||
'.FixedPopupPanels_fixedPopupPanels__12XNO,',
|
||||
'.FixedPopupPanels_body__jZC_5,',
|
||||
'.FixedPopupPanels_panel__3qAUZ {',
|
||||
' background-color: rgba(0,0,0,0.75) !important;',
|
||||
' border-radius: 24px !important;',
|
||||
'}',
|
||||
|
||||
// Popup container
|
||||
'.Popup_popup__K1Ygj {',
|
||||
' background-color: rgba(0,0,0,0.75) !important;',
|
||||
' border-radius: 24px !important;',
|
||||
'}',
|
||||
'.Popup_body__1N5b9 { color: rgba(255,255,255,0.85) !important; }',
|
||||
|
||||
'* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; }',
|
||||
|
||||
''
|
||||
].join('\n');
|
||||
|
||||
function injectStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
var style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
if (document.body) {
|
||||
injectStyle();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', injectStyle);
|
||||
}
|
||||
|
||||
new MutationObserver(function () {
|
||||
if (!document.getElementById(STYLE_ID)) injectStyle();
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
})();
|
||||
+105
@@ -178,6 +178,7 @@ fn effects(
|
||||
platform: &Platform,
|
||||
preferences: &std::path::Path,
|
||||
weather: &weather_service::Service,
|
||||
ha: Option<&ha_client::HaService>,
|
||||
) -> bool {
|
||||
for effect in effects {
|
||||
match effect {
|
||||
@@ -225,11 +226,110 @@ fn effects(
|
||||
}
|
||||
Effect::Quit => return false,
|
||||
Effect::Weather(command) => weather.send(command),
|
||||
Effect::Ha(ha_effect) => {
|
||||
if let Some(service) = ha {
|
||||
ha_dispatch(service, ha_effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
|
||||
use state::HaEffect;
|
||||
static CALL_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1000);
|
||||
let next_id = || CALL_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
match effect {
|
||||
HaEffect::Toggle(entity_id) => {
|
||||
let (domain, service_name) = if entity_id.starts_with("light.") {
|
||||
("light", "toggle")
|
||||
} else if entity_id.starts_with("switch.") {
|
||||
("switch", "toggle")
|
||||
} else if entity_id.starts_with("cover.") {
|
||||
("cover", "toggle")
|
||||
} else {
|
||||
("homeassistant", "toggle")
|
||||
};
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: domain.into(),
|
||||
service: service_name.into(),
|
||||
target: serde_json::json!({ "entity_id": entity_id }),
|
||||
data: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
HaEffect::SetBrightness(entity_id, brightness) => {
|
||||
if brightness <= 0. {
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: "light".into(),
|
||||
service: "turn_off".into(),
|
||||
target: serde_json::json!({ "entity_id": entity_id }),
|
||||
data: serde_json::json!({}),
|
||||
});
|
||||
} else {
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
target: serde_json::json!({ "entity_id": entity_id }),
|
||||
data: serde_json::json!({ "brightness": brightness as u8 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
HaEffect::SetClimateTemp(entity_id, temp) => {
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: "climate".into(),
|
||||
service: "set_temperature".into(),
|
||||
target: serde_json::json!({ "entity_id": entity_id }),
|
||||
data: serde_json::json!({ "temperature": temp }),
|
||||
});
|
||||
}
|
||||
HaEffect::ActivateScene(entity_id) => {
|
||||
let (domain, service_name) = if entity_id.starts_with("script.") {
|
||||
("script", "turn_on")
|
||||
} else {
|
||||
("scene", "turn_on")
|
||||
};
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: domain.into(),
|
||||
service: service_name.into(),
|
||||
target: serde_json::json!({ "entity_id": entity_id }),
|
||||
data: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
HaEffect::VacuumClean { entity_id, segments, mode } => {
|
||||
if let Some(mode_name) = &mode {
|
||||
let cleaning_mode_entity = format!(
|
||||
"select.{}_cleaning_mode",
|
||||
entity_id.strip_prefix("vacuum.").unwrap_or(&entity_id)
|
||||
);
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: "select".into(),
|
||||
service: "select_option".into(),
|
||||
target: serde_json::json!({ "entity_id": cleaning_mode_entity }),
|
||||
data: serde_json::json!({ "option": mode_name }),
|
||||
});
|
||||
}
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: "vacuum".into(),
|
||||
service: "send_command".into(),
|
||||
target: serde_json::json!({ "entity_id": entity_id }),
|
||||
data: serde_json::json!({
|
||||
"command": "app_segment_clean",
|
||||
"params": { "segments": segments, "repeat": 1 }
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn script(
|
||||
action: &str,
|
||||
state: &mut State,
|
||||
@@ -524,6 +624,7 @@ fn run() -> Result<(), String> {
|
||||
};
|
||||
let ha_service = if let Ok(bytes) = std::fs::read(&ha_config_path) {
|
||||
if let Ok(config_value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
state.ha.config = state::HaTabConfig::parse(&config_value);
|
||||
let url = config_value.get("ha").and_then(|h| h.get("url")).and_then(|v| v.as_str()).unwrap_or("").to_owned();
|
||||
let token = config_value.get("ha").and_then(|h| h.get("token")).and_then(|v| v.as_str()).unwrap_or("").to_owned();
|
||||
if !url.is_empty() && !token.is_empty() {
|
||||
@@ -555,6 +656,7 @@ fn run() -> Result<(), String> {
|
||||
&platform,
|
||||
&preferences_path,
|
||||
&weather_service,
|
||||
ha_service.as_ref(),
|
||||
);
|
||||
platform.sync_splash(&state.preferences.background);
|
||||
startup_stage("preferences and workers");
|
||||
@@ -965,6 +1067,7 @@ fn run() -> Result<(), String> {
|
||||
&platform,
|
||||
&preferences_path,
|
||||
&weather_service,
|
||||
ha_service.as_ref(),
|
||||
);
|
||||
if press
|
||||
.as_ref()
|
||||
@@ -1008,6 +1111,7 @@ fn run() -> Result<(), String> {
|
||||
&platform,
|
||||
&preferences_path,
|
||||
&weather_service,
|
||||
ha_service.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1029,6 +1133,7 @@ fn run() -> Result<(), String> {
|
||||
&platform,
|
||||
&preferences_path,
|
||||
&weather_service,
|
||||
ha_service.as_ref(),
|
||||
);
|
||||
}
|
||||
let keyboard = state.text_input_active();
|
||||
|
||||
+406
-1
@@ -62,6 +62,14 @@ pub enum Target {
|
||||
WeatherSearch,
|
||||
WeatherClear,
|
||||
WeatherResult(usize),
|
||||
HaRoom(usize),
|
||||
HaCard(usize, usize),
|
||||
HaWeather,
|
||||
HaVacuumOpen,
|
||||
HaVacuumRoom(usize),
|
||||
HaVacuumMode,
|
||||
HaVacuumStart,
|
||||
HaVacuumClose,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -139,6 +147,20 @@ pub enum Effect {
|
||||
Launch(String),
|
||||
Quit,
|
||||
Weather(weather::Command),
|
||||
Ha(HaEffect),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum HaEffect {
|
||||
Toggle(String),
|
||||
SetBrightness(String, f32),
|
||||
SetClimateTemp(String, f32),
|
||||
ActivateScene(String),
|
||||
VacuumClean {
|
||||
entity_id: String,
|
||||
segments: Vec<u32>,
|
||||
mode: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub const KEYS: [&str; 40] = [
|
||||
@@ -147,11 +169,64 @@ pub const KEYS: [&str; 40] = [
|
||||
"Delete", "Clear", "Done",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum HaCardType {
|
||||
Toggle,
|
||||
Range,
|
||||
Climate,
|
||||
Action,
|
||||
Vacuum,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HaCardConfig {
|
||||
pub card_type: HaCardType,
|
||||
pub entity_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HaRoomConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub cards: Vec<HaCardConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct HaWeatherConfig {
|
||||
pub entity_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HaVacuumRoom {
|
||||
pub segment_id: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct HaVacuumConfig {
|
||||
pub entity_id: String,
|
||||
pub cleaning_mode_entity: String,
|
||||
pub rooms: Vec<HaVacuumRoom>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct HaTabConfig {
|
||||
pub rooms: Vec<HaRoomConfig>,
|
||||
pub weather: Option<HaWeatherConfig>,
|
||||
pub vacuum: Option<HaVacuumConfig>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct HaState {
|
||||
pub connected: bool,
|
||||
pub entities: std::collections::HashMap<String, HaEntity>,
|
||||
pub history: std::collections::HashMap<String, Vec<(f64, f64)>>,
|
||||
pub config: HaTabConfig,
|
||||
pub room: usize,
|
||||
pub vacuum_selected: Vec<bool>,
|
||||
pub vacuum_mode: usize,
|
||||
pub vacuum_open: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -160,6 +235,74 @@ pub struct HaEntity {
|
||||
pub attributes: serde_json::Value,
|
||||
}
|
||||
|
||||
impl HaTabConfig {
|
||||
pub fn parse(value: &serde_json::Value) -> Self {
|
||||
let mut config = HaTabConfig::default();
|
||||
|
||||
if let Some(w) = value.get("weather") {
|
||||
if let Some(eid) = w.get("entity_id").and_then(|v| v.as_str()) {
|
||||
config.weather = Some(HaWeatherConfig {
|
||||
entity_id: eid.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(v) = value.get("vacuum") {
|
||||
let mut vc = HaVacuumConfig::default();
|
||||
if let Some(eid) = v.get("entity_id").and_then(|v| v.as_str()) {
|
||||
vc.entity_id = eid.to_owned();
|
||||
}
|
||||
if let Some(cme) = v.get("cleaning_mode_entity").and_then(|v| v.as_str()) {
|
||||
vc.cleaning_mode_entity = cme.to_owned();
|
||||
}
|
||||
if let Some(rooms) = v.get("rooms").and_then(|v| v.as_object()) {
|
||||
for (id, name) in rooms {
|
||||
if let (Ok(seg), Some(n)) = (id.parse::<u32>(), name.as_str()) {
|
||||
vc.rooms.push(HaVacuumRoom {
|
||||
segment_id: seg,
|
||||
name: n.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
vc.rooms.sort_by_key(|r| r.segment_id);
|
||||
}
|
||||
if !vc.entity_id.is_empty() {
|
||||
config.vacuum = Some(vc);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rooms) = value.get("rooms").and_then(|v| v.as_array()) {
|
||||
for room in rooms {
|
||||
let id = room.get("id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
|
||||
let name = room.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
|
||||
let mut cards = Vec::new();
|
||||
if let Some(card_arr) = room.get("cards").and_then(|v| v.as_array()) {
|
||||
for card in card_arr {
|
||||
let ct = match card.get("type").and_then(|v| v.as_str()) {
|
||||
Some("toggle") => HaCardType::Toggle,
|
||||
Some("range") => HaCardType::Range,
|
||||
Some("climate") => HaCardType::Climate,
|
||||
Some("action") => HaCardType::Action,
|
||||
Some("vacuum") => HaCardType::Vacuum,
|
||||
_ => continue,
|
||||
};
|
||||
let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
|
||||
let n = card.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
|
||||
cards.push(HaCardConfig {
|
||||
card_type: ct,
|
||||
entity_id: eid,
|
||||
name: n,
|
||||
});
|
||||
}
|
||||
}
|
||||
config.rooms.push(HaRoomConfig { id, name, cards });
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub page: Page,
|
||||
pub focus: Target,
|
||||
@@ -410,7 +553,13 @@ impl State {
|
||||
.first()
|
||||
.map(|app| Target::App(app.id.clone()))
|
||||
.unwrap_or(Target::Filter(1)),
|
||||
Page::Ha => Target::Nav(2),
|
||||
Page::Ha => {
|
||||
if self.ha.config.rooms.is_empty() {
|
||||
Target::Nav(2)
|
||||
} else {
|
||||
Target::HaRoom(self.ha.room)
|
||||
}
|
||||
}
|
||||
Page::Settings => Target::Sidebar(0),
|
||||
}
|
||||
}
|
||||
@@ -636,6 +785,99 @@ impl State {
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
// ── HA page navigation ────────────────────────────────
|
||||
if self.page == Page::Ha && !self.ha.config.rooms.is_empty() {
|
||||
if self.ha.vacuum_open {
|
||||
return self.ha_vacuum_direction(direction, controls);
|
||||
}
|
||||
if let Target::HaRoom(room) = self.focus {
|
||||
match direction {
|
||||
Direction::Left if room > 0 => {
|
||||
self.ha.room = room - 1;
|
||||
self.focus(Target::HaRoom(room - 1), controls);
|
||||
self.scroll = 0.;
|
||||
}
|
||||
Direction::Right if room + 1 < self.ha.config.rooms.len() => {
|
||||
self.ha.room = room + 1;
|
||||
self.focus(Target::HaRoom(room + 1), controls);
|
||||
self.scroll = 0.;
|
||||
}
|
||||
Direction::Up => self.focus(Target::Nav(2), controls),
|
||||
Direction::Down => {
|
||||
if let Some(weather) = &self.ha.config.weather
|
||||
&& self.ha.entities.contains_key(&weather.entity_id)
|
||||
{
|
||||
self.focus(Target::HaWeather, controls);
|
||||
} else if !self.ha.config.rooms[self.ha.room].cards.is_empty() {
|
||||
self.focus(Target::HaCard(self.ha.room, 0), controls);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
if self.focus == Target::HaWeather {
|
||||
match direction {
|
||||
Direction::Up => self.focus(Target::HaRoom(self.ha.room), controls),
|
||||
Direction::Down => {
|
||||
if !self.ha.config.rooms[self.ha.room].cards.is_empty() {
|
||||
self.focus(Target::HaCard(self.ha.room, 0), controls);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
if let Target::HaCard(room, index) = self.focus {
|
||||
let room_cfg = &self.ha.config.rooms[room];
|
||||
let card = &room_cfg.cards[index];
|
||||
let cols = self.ha_card_columns();
|
||||
match (&card.card_type, direction) {
|
||||
(HaCardType::Range, Direction::Left) => {
|
||||
let brightness = self.ha_entity_brightness(&card.entity_id);
|
||||
let new = (brightness - 25.5).max(0.);
|
||||
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
|
||||
}
|
||||
(HaCardType::Range, Direction::Right) => {
|
||||
let brightness = self.ha_entity_brightness(&card.entity_id);
|
||||
let new = (brightness + 25.5).min(255.);
|
||||
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
|
||||
}
|
||||
(HaCardType::Climate, Direction::Left) => {
|
||||
let temp = self.ha_entity_target_temp(&card.entity_id);
|
||||
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp - 0.5))];
|
||||
}
|
||||
(HaCardType::Climate, Direction::Right) => {
|
||||
let temp = self.ha_entity_target_temp(&card.entity_id);
|
||||
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp + 0.5))];
|
||||
}
|
||||
(_, Direction::Left) if index % cols > 0 => {
|
||||
self.focus(Target::HaCard(room, index - 1), controls);
|
||||
}
|
||||
(_, Direction::Right) if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => {
|
||||
self.focus(Target::HaCard(room, index + 1), controls);
|
||||
}
|
||||
(_, Direction::Up) => {
|
||||
if index >= cols {
|
||||
self.focus(Target::HaCard(room, index - cols), controls);
|
||||
} else if self.ha.config.weather.is_some() {
|
||||
self.focus(Target::HaWeather, controls);
|
||||
} else {
|
||||
self.focus(Target::HaRoom(self.ha.room), controls);
|
||||
}
|
||||
}
|
||||
(_, Direction::Down) => {
|
||||
let next = index + cols;
|
||||
if next < room_cfg.cards.len() {
|
||||
self.focus(Target::HaCard(room, next), controls);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
|
||||
let setting_rows = match self.subpage {
|
||||
Some(Subpage::Greeting) => Some([Target::GreetingName, Target::GreetingNameClear]),
|
||||
Some(Subpage::AppNames) => Some([Target::AppNamesHome, Target::AppNamesApps]),
|
||||
@@ -982,6 +1224,80 @@ impl State {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn ha_card_columns(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn ha_entity_brightness(&self, entity_id: &str) -> f32 {
|
||||
self.ha.entities.get(entity_id)
|
||||
.and_then(|e| e.attributes.get("brightness"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.) as f32
|
||||
}
|
||||
|
||||
fn ha_entity_target_temp(&self, entity_id: &str) -> f32 {
|
||||
self.ha.entities.get(entity_id)
|
||||
.and_then(|e| e.attributes.get("temperature"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(20.) as f32
|
||||
}
|
||||
|
||||
fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
|
||||
let vac = match &self.ha.config.vacuum {
|
||||
Some(v) => v,
|
||||
None => return vec![],
|
||||
};
|
||||
let room_count = vac.rooms.len();
|
||||
let cols = 3usize;
|
||||
match &self.focus {
|
||||
Target::HaVacuumRoom(index) => {
|
||||
let index = *index;
|
||||
match direction {
|
||||
Direction::Left if index % cols > 0 => {
|
||||
self.focus(Target::HaVacuumRoom(index - 1), controls);
|
||||
}
|
||||
Direction::Right if index % cols + 1 < cols && index + 1 < room_count => {
|
||||
self.focus(Target::HaVacuumRoom(index + 1), controls);
|
||||
}
|
||||
Direction::Up if index >= cols => {
|
||||
self.focus(Target::HaVacuumRoom(index - cols), controls);
|
||||
}
|
||||
Direction::Up => {
|
||||
self.focus(Target::HaVacuumClose, controls);
|
||||
}
|
||||
Direction::Down => {
|
||||
let next = index + cols;
|
||||
if next < room_count {
|
||||
self.focus(Target::HaVacuumRoom(next), controls);
|
||||
} else {
|
||||
self.focus(Target::HaVacuumMode, controls);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Target::HaVacuumMode => match direction {
|
||||
Direction::Up => {
|
||||
let last_row_start = (room_count.saturating_sub(1)) / cols * cols;
|
||||
let idx = (last_row_start).min(room_count.saturating_sub(1));
|
||||
self.focus(Target::HaVacuumRoom(idx), controls);
|
||||
}
|
||||
Direction::Down => self.focus(Target::HaVacuumStart, controls),
|
||||
_ => {}
|
||||
},
|
||||
Target::HaVacuumStart => match direction {
|
||||
Direction::Up => self.focus(Target::HaVacuumMode, controls),
|
||||
_ => {}
|
||||
},
|
||||
Target::HaVacuumClose => match direction {
|
||||
Direction::Down => self.focus(Target::HaVacuumRoom(0), controls),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub fn options(&mut self) {
|
||||
if self.overlay.is_some() || self.moving.is_some() {
|
||||
return;
|
||||
@@ -1330,6 +1646,82 @@ impl State {
|
||||
return vec![Effect::Save];
|
||||
}
|
||||
}
|
||||
Target::HaRoom(index) => {
|
||||
self.ha.room = index;
|
||||
self.scroll = 0.;
|
||||
if !self.ha.config.rooms[index].cards.is_empty() {
|
||||
self.focus = Target::HaCard(index, 0);
|
||||
}
|
||||
}
|
||||
Target::HaCard(room, index) => {
|
||||
let card = &self.ha.config.rooms[room].cards[index];
|
||||
match card.card_type {
|
||||
HaCardType::Toggle => {
|
||||
return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))];
|
||||
}
|
||||
HaCardType::Action => {
|
||||
return vec![Effect::Ha(HaEffect::ActivateScene(card.entity_id.clone()))];
|
||||
}
|
||||
HaCardType::Vacuum => {
|
||||
if let Some(vac) = &self.ha.config.vacuum {
|
||||
self.ha.vacuum_open = true;
|
||||
self.ha.vacuum_selected = vec![false; vac.rooms.len()];
|
||||
self.ha.vacuum_mode = 0;
|
||||
self.focus = Target::HaVacuumRoom(0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Target::HaVacuumRoom(index) => {
|
||||
if index < self.ha.vacuum_selected.len() {
|
||||
self.ha.vacuum_selected[index] = !self.ha.vacuum_selected[index];
|
||||
}
|
||||
}
|
||||
Target::HaVacuumMode => {
|
||||
if let Some(vac) = &self.ha.config.vacuum {
|
||||
if let Some(entity) = self.ha.entities.get(&vac.cleaning_mode_entity) {
|
||||
if let Some(options) = entity.attributes.get("options").and_then(|o| o.as_array()) {
|
||||
self.ha.vacuum_mode = (self.ha.vacuum_mode + 1) % options.len().max(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::HaVacuumStart => {
|
||||
if let Some(vac) = &self.ha.config.vacuum {
|
||||
let segments: Vec<u32> = vac.rooms.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| self.ha.vacuum_selected.get(*i).copied().unwrap_or(false))
|
||||
.map(|(_, r)| r.segment_id)
|
||||
.collect();
|
||||
if !segments.is_empty() {
|
||||
let mode = self.ha.entities.get(&vac.cleaning_mode_entity)
|
||||
.and_then(|e| e.attributes.get("options"))
|
||||
.and_then(|o| o.as_array())
|
||||
.and_then(|opts| opts.get(self.ha.vacuum_mode))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_owned());
|
||||
self.ha.vacuum_open = false;
|
||||
self.focus = Target::HaCard(self.ha.room,
|
||||
self.ha.config.rooms[self.ha.room].cards.iter()
|
||||
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
|
||||
.unwrap_or(0));
|
||||
return vec![Effect::Ha(HaEffect::VacuumClean {
|
||||
entity_id: vac.entity_id.clone(),
|
||||
segments,
|
||||
mode,
|
||||
})];
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::HaVacuumClose => {
|
||||
self.ha.vacuum_open = false;
|
||||
self.focus = Target::HaCard(self.ha.room,
|
||||
self.ha.config.rooms[self.ha.room].cards.iter()
|
||||
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
|
||||
.unwrap_or(0));
|
||||
}
|
||||
Target::HaVacuumOpen | Target::HaWeather => {}
|
||||
Target::Sidebar(index) => {
|
||||
self.section = index;
|
||||
self.scroll = 0.;
|
||||
@@ -1560,6 +1952,19 @@ impl State {
|
||||
self.scroll = 0.;
|
||||
return vec![];
|
||||
}
|
||||
if self.page == Page::Ha && self.ha.vacuum_open {
|
||||
self.ha.vacuum_open = false;
|
||||
self.focus = Target::HaCard(self.ha.room,
|
||||
self.ha.config.rooms[self.ha.room].cards.iter()
|
||||
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
|
||||
.unwrap_or(0));
|
||||
return vec![];
|
||||
}
|
||||
if self.page == Page::Ha && matches!(self.focus, Target::HaCard(..)) {
|
||||
self.focus = Target::HaRoom(self.ha.room);
|
||||
self.scroll = 0.;
|
||||
return vec![];
|
||||
}
|
||||
if self.page == Page::Apps && matches!(self.focus, Target::Search | Target::ClearSearch) {
|
||||
self.query.clear();
|
||||
self.focus = Target::Nav(1);
|
||||
|
||||
+675
-9
@@ -1608,20 +1608,686 @@ fn location_settings(out: &mut Scene, state: &State, assets: &Assets) {
|
||||
}
|
||||
|
||||
fn ha(out: &mut Scene, state: &State, assets: &Assets) {
|
||||
use crate::state::HaCardType;
|
||||
|
||||
let language = state.preferences.language;
|
||||
let rect = Rect::new(84., 164.8, 1752., 780.);
|
||||
empty(
|
||||
out,
|
||||
state,
|
||||
let config = &state.ha.config;
|
||||
let s = state.scroll;
|
||||
|
||||
if config.rooms.is_empty() {
|
||||
let rect = Rect::new(84., 164.8, 1752., 780.);
|
||||
empty(
|
||||
out,
|
||||
state,
|
||||
assets,
|
||||
EmptyState {
|
||||
rect,
|
||||
icon: "settings",
|
||||
title: language.t("ha"),
|
||||
hint: "Home Assistant",
|
||||
action: None,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Room tabs (horizontal pill strip) ────────────────────────
|
||||
let mut tab_x = 84.;
|
||||
for (index, room) in config.rooms.iter().enumerate() {
|
||||
let width = (assets.width(&room.name, text(16., 500, 0.)) + 36.).max(88.);
|
||||
let rect = Rect::new(tab_x, 164.8, width, 42.8);
|
||||
let focused = state.focus == Target::HaRoom(index);
|
||||
let selected = state.ha.room == index;
|
||||
out.pill(rect, focused, selected);
|
||||
let color = if focused {
|
||||
c(0x2c2c2c, 1.)
|
||||
} else if selected {
|
||||
WHITE
|
||||
} else {
|
||||
c(0xeeeeee, 0.55)
|
||||
};
|
||||
out.text(
|
||||
&room.name,
|
||||
Rect::new(rect.x + 18., rect.y + 12., rect.w - 36., 20.),
|
||||
text(16., 500, 0.),
|
||||
color,
|
||||
true,
|
||||
);
|
||||
out.control(Target::HaRoom(index), rect, false);
|
||||
tab_x += width + 8.;
|
||||
}
|
||||
|
||||
// ── Connection indicator ─────────────────────────────────────
|
||||
if !state.ha.connected {
|
||||
let dot = Rect::new(tab_x + 12., 178., 12., 12.);
|
||||
out.shape(dot, 6., c(0xff6b6b, 0.8));
|
||||
}
|
||||
|
||||
let room = &config.rooms[state.ha.room];
|
||||
let mut y = 230. - s;
|
||||
|
||||
// ── Weather hero card (if configured) ────────────────────────
|
||||
if let Some(weather_cfg) = &config.weather {
|
||||
if let Some(entity) = state.ha.entities.get(&weather_cfg.entity_id) {
|
||||
let rect = Rect::new(84., y, 1752., 100.);
|
||||
ha_weather_card(out, state, assets, rect, entity);
|
||||
out.control(Target::HaWeather, rect, true);
|
||||
y += 116.;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card grid ────────────────────────────────────────────────
|
||||
let cols = 3usize;
|
||||
let card_w = 564.;
|
||||
let card_gap = 16.;
|
||||
let card_h = 88.;
|
||||
|
||||
for (index, card) in room.cards.iter().enumerate() {
|
||||
let col = index % cols;
|
||||
let row = index / cols;
|
||||
let cx = 84. + col as f32 * (card_w + card_gap);
|
||||
let cy = y + row as f32 * (card_h + card_gap);
|
||||
let rect = Rect::new(cx, cy, card_w, card_h);
|
||||
let focused = state.focus == Target::HaCard(state.ha.room, index);
|
||||
|
||||
let entity = state.ha.entities.get(&card.entity_id);
|
||||
match card.card_type {
|
||||
HaCardType::Toggle => {
|
||||
ha_toggle_card(out, state, assets, rect, card, entity, focused);
|
||||
}
|
||||
HaCardType::Range => {
|
||||
ha_range_card(out, state, assets, rect, card, entity, focused);
|
||||
}
|
||||
HaCardType::Climate => {
|
||||
ha_climate_card(out, state, assets, rect, card, entity, focused);
|
||||
}
|
||||
HaCardType::Action => {
|
||||
ha_action_card(out, state, assets, rect, card, focused);
|
||||
}
|
||||
HaCardType::Vacuum => {
|
||||
ha_vacuum_card(out, state, assets, rect, card, entity, focused);
|
||||
}
|
||||
}
|
||||
out.control(Target::HaCard(state.ha.room, index), rect, true);
|
||||
}
|
||||
|
||||
// ── Vacuum modal (overlays everything) ───────────────────────
|
||||
if state.ha.vacuum_open {
|
||||
if let Some(vac_cfg) = &config.vacuum {
|
||||
ha_vacuum_modal(out, state, assets, vac_cfg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Glass card base ──────────────────────────────────────────────
|
||||
|
||||
fn ha_glass(out: &mut Scene, rect: Rect, focused: bool) {
|
||||
out.shadow(
|
||||
rect,
|
||||
22.,
|
||||
if focused { 28. } else { 6. },
|
||||
if focused { 8. } else { 2. },
|
||||
if focused { 0.28 } else { 0.06 },
|
||||
);
|
||||
out.shape(rect, 22., c(0xffffff, if focused { 0.16 } else { 0.12 }));
|
||||
out.border(rect, 22., 1., c(0xffffff, if focused { 0.28 } else { 0.15 }));
|
||||
if focused {
|
||||
out.border(rect.inset(-4.), 26., 2., c(0xffffff, 0.12));
|
||||
}
|
||||
}
|
||||
|
||||
fn ha_entity_icon(card_type: &crate::state::HaCardType, entity_id: &str) -> &'static str {
|
||||
use crate::state::HaCardType;
|
||||
match card_type {
|
||||
HaCardType::Toggle => {
|
||||
if entity_id.starts_with("light.") {
|
||||
"light"
|
||||
} else if entity_id.starts_with("cover.") {
|
||||
"curtain"
|
||||
} else {
|
||||
"switch"
|
||||
}
|
||||
}
|
||||
HaCardType::Range => "light",
|
||||
HaCardType::Climate => "thermometer",
|
||||
HaCardType::Action => "scene",
|
||||
HaCardType::Vacuum => "vacuum",
|
||||
}
|
||||
}
|
||||
|
||||
fn ha_state_color(entity: Option<&crate::state::HaEntity>) -> Color {
|
||||
match entity.map(|e| e.state.as_str()) {
|
||||
Some("on") => c(0x7EB8DA, 1.),
|
||||
Some("heat" | "heat_cool" | "auto") => c(0xf0a060, 1.),
|
||||
Some("cool") => c(0x60b0f0, 1.),
|
||||
_ => c(0xbbbbbb, 0.5),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle card ──────────────────────────────────────────────────
|
||||
|
||||
fn ha_toggle_card(
|
||||
out: &mut Scene,
|
||||
_state: &State,
|
||||
assets: &Assets,
|
||||
rect: Rect,
|
||||
card: &crate::state::HaCardConfig,
|
||||
entity: Option<&crate::state::HaEntity>,
|
||||
focused: bool,
|
||||
) {
|
||||
ha_glass(out, rect, focused);
|
||||
let icon_name = ha_entity_icon(&card.card_type, &card.entity_id);
|
||||
let accent = ha_state_color(entity);
|
||||
let is_on = entity.is_some_and(|e| e.state == "on");
|
||||
|
||||
out.icon(
|
||||
assets,
|
||||
EmptyState {
|
||||
icon_name,
|
||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
||||
if is_on { accent } else { c(0xbbbbbb, 0.5) },
|
||||
);
|
||||
out.text(
|
||||
&card.name,
|
||||
Rect::new(rect.x + 80., rect.y + 22., rect.w - 180., 24.),
|
||||
text(20., 500, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
let status = entity.map_or("unavailable", |e| e.state.as_str());
|
||||
out.text(
|
||||
status,
|
||||
Rect::new(rect.x + 80., rect.y + 52., rect.w - 180., 16.),
|
||||
text(13.12, 400, 0.),
|
||||
c(0xe8e8e8, 0.48),
|
||||
false,
|
||||
);
|
||||
// On/off indicator dot
|
||||
let dot_x = rect.x + rect.w - 36.;
|
||||
let dot_y = rect.y + rect.h / 2. - 8.;
|
||||
out.shape(
|
||||
Rect::new(dot_x, dot_y, 16., 16.),
|
||||
8.,
|
||||
if is_on { accent } else { c(0x555555, 0.6) },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Range card (dimmable lights, cover position) ─────────────────
|
||||
|
||||
fn ha_range_card(
|
||||
out: &mut Scene,
|
||||
_state: &State,
|
||||
assets: &Assets,
|
||||
rect: Rect,
|
||||
card: &crate::state::HaCardConfig,
|
||||
entity: Option<&crate::state::HaEntity>,
|
||||
focused: bool,
|
||||
) {
|
||||
ha_glass(out, rect, focused);
|
||||
let is_on = entity.is_some_and(|e| e.state == "on");
|
||||
let brightness = entity
|
||||
.and_then(|e| e.attributes.get("brightness"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.);
|
||||
let pct = (brightness / 255. * 100.).round() as u8;
|
||||
let accent = if is_on { c(0x7EB8DA, 1.) } else { c(0xbbbbbb, 0.5) };
|
||||
|
||||
out.icon(
|
||||
assets,
|
||||
"light",
|
||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
||||
accent,
|
||||
);
|
||||
out.text(
|
||||
&card.name,
|
||||
Rect::new(rect.x + 80., rect.y + 16., rect.w - 180., 24.),
|
||||
text(20., 500, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
// Brightness bar
|
||||
let bar_x = rect.x + 80.;
|
||||
let bar_y = rect.y + 52.;
|
||||
let bar_w = rect.w - 160.;
|
||||
let bar_h = 8.;
|
||||
out.shape(Rect::new(bar_x, bar_y, bar_w, bar_h), 4., c(0xffffff, 0.08));
|
||||
if is_on && pct > 0 {
|
||||
let fill_w = bar_w * (pct as f32 / 100.).min(1.);
|
||||
out.shape(Rect::new(bar_x, bar_y, fill_w, bar_h), 4., accent);
|
||||
}
|
||||
// Percentage label
|
||||
let label = if is_on { format!("{}%", pct) } else { "Off".into() };
|
||||
out.text(
|
||||
&label,
|
||||
Rect::new(rect.x + rect.w - 72., rect.y + 34., 52., 20.),
|
||||
text(15.2, 500, 0.).right(),
|
||||
if is_on { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||
false,
|
||||
);
|
||||
if focused {
|
||||
// D-pad hint arrows
|
||||
out.icon(assets, "left", Rect::new(rect.x + 80., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4));
|
||||
out.icon(assets, "right", Rect::new(rect.x + 96., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Climate card ─────────────────────────────────────────────────
|
||||
|
||||
fn ha_climate_card(
|
||||
out: &mut Scene,
|
||||
state: &State,
|
||||
assets: &Assets,
|
||||
rect: Rect,
|
||||
card: &crate::state::HaCardConfig,
|
||||
entity: Option<&crate::state::HaEntity>,
|
||||
focused: bool,
|
||||
) {
|
||||
ha_glass(out, rect, focused);
|
||||
let accent = ha_state_color(entity);
|
||||
let current_temp = entity
|
||||
.and_then(|e| e.attributes.get("current_temperature"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.);
|
||||
let target_temp = entity
|
||||
.and_then(|e| e.attributes.get("temperature"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.);
|
||||
let hvac_mode = entity.map_or("off", |e| e.state.as_str());
|
||||
|
||||
out.icon(
|
||||
assets,
|
||||
"thermometer",
|
||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
||||
accent,
|
||||
);
|
||||
out.text(
|
||||
&card.name,
|
||||
Rect::new(rect.x + 80., rect.y + 16., 280., 24.),
|
||||
text(20., 500, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
out.text(
|
||||
hvac_mode,
|
||||
Rect::new(rect.x + 80., rect.y + 46., 120., 16.),
|
||||
text(13.12, 400, 0.),
|
||||
c(0xe8e8e8, 0.48),
|
||||
false,
|
||||
);
|
||||
// Current temperature
|
||||
let current_str = format!("{:.1}°", current_temp);
|
||||
out.text(
|
||||
¤t_str,
|
||||
Rect::new(rect.x + rect.w - 200., rect.y + 16., 80., 28.),
|
||||
text(24., 600, -0.03).right(),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
// Target temperature
|
||||
let target_str = format!("→ {:.1}°", target_temp);
|
||||
out.text(
|
||||
&target_str,
|
||||
Rect::new(rect.x + rect.w - 108., rect.y + 18., 88., 24.),
|
||||
text(18., 400, 0.),
|
||||
accent,
|
||||
false,
|
||||
);
|
||||
if focused {
|
||||
out.icon(assets, "left", Rect::new(rect.x + rect.w - 108., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4));
|
||||
out.icon(assets, "right", Rect::new(rect.x + rect.w - 92., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4));
|
||||
}
|
||||
// History sparkline if available
|
||||
if let Some(points) = state.ha.history.get(&card.entity_id) {
|
||||
if points.len() >= 2 {
|
||||
ha_sparkline(out, Rect::new(rect.x + 80., rect.y + 64., 300., 16.), points, accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ha_sparkline(out: &mut Scene, rect: Rect, points: &[(f64, f64)], color: Color) {
|
||||
if points.is_empty() { return; }
|
||||
let min_val = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
|
||||
let max_val = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
|
||||
let range = (max_val - min_val).max(1.);
|
||||
let step = rect.w / (points.len() as f32 - 1.).max(1.);
|
||||
for i in 0..points.len() {
|
||||
let x = rect.x + i as f32 * step;
|
||||
let norm = ((points[i].1 - min_val) / range) as f32;
|
||||
let bar_h = (norm * rect.h).max(2.);
|
||||
let y = rect.y + rect.h - bar_h;
|
||||
out.shape(
|
||||
Rect::new(x, y, step.max(2.), bar_h),
|
||||
0.,
|
||||
[color[0], color[1], color[2], 0.35],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action card (scenes, scripts) ────────────────────────────────
|
||||
|
||||
fn ha_action_card(
|
||||
out: &mut Scene,
|
||||
_state: &State,
|
||||
assets: &Assets,
|
||||
rect: Rect,
|
||||
card: &crate::state::HaCardConfig,
|
||||
focused: bool,
|
||||
) {
|
||||
ha_glass(out, rect, focused);
|
||||
let accent = c(0xc9a0dc, 1.);
|
||||
out.icon(
|
||||
assets,
|
||||
"scene",
|
||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
||||
accent,
|
||||
);
|
||||
out.text(
|
||||
&card.name,
|
||||
Rect::new(rect.x + 80., rect.y + 28., rect.w - 120., 32.),
|
||||
text(20., 500, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
if focused {
|
||||
out.text(
|
||||
"OK to activate",
|
||||
Rect::new(rect.x + 80., rect.y + 60., 200., 16.),
|
||||
text(12., 400, 0.),
|
||||
c(0xe8e8e8, 0.40),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vacuum card (summary; opens modal) ───────────────────────────
|
||||
|
||||
fn ha_vacuum_card(
|
||||
out: &mut Scene,
|
||||
_state: &State,
|
||||
assets: &Assets,
|
||||
rect: Rect,
|
||||
card: &crate::state::HaCardConfig,
|
||||
entity: Option<&crate::state::HaEntity>,
|
||||
focused: bool,
|
||||
) {
|
||||
ha_glass(out, rect, focused);
|
||||
let status = entity.map_or("unavailable", |e| e.state.as_str());
|
||||
let accent = match status {
|
||||
"cleaning" => c(0x7EB8DA, 1.),
|
||||
"returning" => c(0xf0a060, 1.),
|
||||
_ => c(0xbbbbbb, 0.5),
|
||||
};
|
||||
out.icon(
|
||||
assets,
|
||||
"vacuum",
|
||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
||||
accent,
|
||||
);
|
||||
out.text(
|
||||
&card.name,
|
||||
Rect::new(rect.x + 80., rect.y + 22., rect.w - 120., 24.),
|
||||
text(20., 500, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
out.text(
|
||||
status,
|
||||
Rect::new(rect.x + 80., rect.y + 52., rect.w - 120., 16.),
|
||||
text(13.12, 400, 0.),
|
||||
c(0xe8e8e8, 0.48),
|
||||
false,
|
||||
);
|
||||
if focused {
|
||||
out.text(
|
||||
"OK to open",
|
||||
Rect::new(rect.x + rect.w - 120., rect.y + 36., 100., 16.),
|
||||
text(12., 400, 0.).right(),
|
||||
c(0xe8e8e8, 0.40),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weather hero card ────────────────────────────────────────────
|
||||
|
||||
fn ha_weather_card(
|
||||
out: &mut Scene,
|
||||
_state: &State,
|
||||
assets: &Assets,
|
||||
rect: Rect,
|
||||
entity: &crate::state::HaEntity,
|
||||
) {
|
||||
let focused = _state.focus == Target::HaWeather;
|
||||
ha_glass(out, rect, focused);
|
||||
let condition = entity.state.as_str();
|
||||
let temp = entity
|
||||
.attributes
|
||||
.get("temperature")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.);
|
||||
let humidity = entity
|
||||
.attributes
|
||||
.get("humidity")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.);
|
||||
let wind = entity
|
||||
.attributes
|
||||
.get("wind_speed")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.);
|
||||
|
||||
let weather_icon = match condition {
|
||||
"sunny" | "clear-night" => "weather-sun",
|
||||
"cloudy" | "fog" => "weather-cloud",
|
||||
"partlycloudy" => "weather-partly-cloudy",
|
||||
"rainy" | "pouring" => "weather-rain",
|
||||
"snowy" => "weather-snow",
|
||||
"lightning" | "lightning-rainy" => "weather-storm",
|
||||
"hail" => "weather-drizzle",
|
||||
_ => "weather-sun",
|
||||
};
|
||||
out.icon(
|
||||
assets,
|
||||
weather_icon,
|
||||
Rect::new(rect.x + 24., rect.y + 22., 56., 56.),
|
||||
c(0xf0d080, 1.),
|
||||
);
|
||||
out.text(
|
||||
&format!("{:.0}°", temp),
|
||||
Rect::new(rect.x + 100., rect.y + 18., 120., 40.),
|
||||
text(36., 600, -0.04),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
out.text(
|
||||
condition,
|
||||
Rect::new(rect.x + 100., rect.y + 62., 200., 18.),
|
||||
text(14.4, 400, 0.),
|
||||
c(0xe8e8e8, 0.60),
|
||||
false,
|
||||
);
|
||||
out.icon(
|
||||
assets,
|
||||
"humidity",
|
||||
Rect::new(rect.x + 340., rect.y + 34., 20., 20.),
|
||||
c(0x60b0f0, 0.7),
|
||||
);
|
||||
out.text(
|
||||
&format!("{:.0}%", humidity),
|
||||
Rect::new(rect.x + 368., rect.y + 36., 80., 20.),
|
||||
text(15.2, 400, 0.),
|
||||
c(0xe8e8e8, 0.60),
|
||||
false,
|
||||
);
|
||||
out.text(
|
||||
&format!("{:.0} km/h", wind),
|
||||
Rect::new(rect.x + 480., rect.y + 36., 120., 20.),
|
||||
text(15.2, 400, 0.),
|
||||
c(0xe8e8e8, 0.60),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vacuum modal ─────────────────────────────────────────────────
|
||||
|
||||
fn ha_vacuum_modal(
|
||||
out: &mut Scene,
|
||||
state: &State,
|
||||
assets: &Assets,
|
||||
vac: &crate::state::HaVacuumConfig,
|
||||
) {
|
||||
let overlay_rect = Rect::new(0., 0., 1920., 1080.);
|
||||
out.shape(overlay_rect, 0., c(0x080b12, 0.75));
|
||||
out.modal = Some(overlay_rect);
|
||||
|
||||
let modal = Rect::new(320., 120., 1280., 840.);
|
||||
ha_glass(out, modal, false);
|
||||
|
||||
// Title + close
|
||||
out.text(
|
||||
"Room Cleaning",
|
||||
Rect::new(modal.x + 40., modal.y + 28., 400., 32.),
|
||||
text(28., 600, -0.03),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
let close_rect = Rect::new(modal.x + modal.w - 72., modal.y + 24., 40., 40.);
|
||||
out.shape(close_rect, 20., c(0xffffff, 0.08));
|
||||
out.icon(assets, "close", close_rect.inset(10.), c(0xeeeeee, 0.7));
|
||||
let close_focused = state.focus == Target::HaVacuumClose;
|
||||
if close_focused {
|
||||
out.border(close_rect.inset(-3.), 23., 2., WHITE);
|
||||
}
|
||||
out.control(Target::HaVacuumClose, close_rect, false);
|
||||
|
||||
// Room grid
|
||||
let cols = 3usize;
|
||||
let room_w = 380.;
|
||||
let room_h = 72.;
|
||||
let gap = 12.;
|
||||
let grid_x = modal.x + 40.;
|
||||
let grid_y = modal.y + 88.;
|
||||
|
||||
for (index, room) in vac.rooms.iter().enumerate() {
|
||||
let col = index % cols;
|
||||
let row = index / cols;
|
||||
let rx = grid_x + col as f32 * (room_w + gap);
|
||||
let ry = grid_y + row as f32 * (room_h + gap);
|
||||
let rect = Rect::new(rx, ry, room_w, room_h);
|
||||
let selected = state.ha.vacuum_selected.get(index).copied().unwrap_or(false);
|
||||
let focused = state.focus == Target::HaVacuumRoom(index);
|
||||
|
||||
out.shape(
|
||||
rect,
|
||||
icon: "settings",
|
||||
title: language.t("ha"),
|
||||
hint: "Home Assistant",
|
||||
action: None,
|
||||
16.,
|
||||
if selected {
|
||||
c(0x7EB8DA, 0.20)
|
||||
} else {
|
||||
c(0xffffff, 0.06)
|
||||
},
|
||||
);
|
||||
out.border(
|
||||
rect,
|
||||
16.,
|
||||
if selected { 2. } else { 1. },
|
||||
if selected {
|
||||
c(0x7EB8DA, 0.60)
|
||||
} else {
|
||||
c(0xffffff, 0.10)
|
||||
},
|
||||
);
|
||||
if focused {
|
||||
out.border(rect.inset(-4.), 20., 2., WHITE);
|
||||
}
|
||||
if selected {
|
||||
out.icon(
|
||||
assets,
|
||||
"check",
|
||||
Rect::new(rect.x + 16., rect.y + 20., 32., 32.),
|
||||
c(0x7EB8DA, 1.),
|
||||
);
|
||||
}
|
||||
out.text(
|
||||
&room.name,
|
||||
Rect::new(
|
||||
rect.x + if selected { 56. } else { 20. },
|
||||
rect.y + 24.,
|
||||
rect.w - 76.,
|
||||
24.,
|
||||
),
|
||||
text(18., 500, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
out.control(Target::HaVacuumRoom(index), rect, false);
|
||||
}
|
||||
|
||||
// Cleaning mode selector
|
||||
let mode_y = grid_y + ((vac.rooms.len() + cols - 1) / cols) as f32 * (room_h + gap) + 16.;
|
||||
let mode_rect = Rect::new(grid_x, mode_y, 600., 56.);
|
||||
let mode_focused = state.focus == Target::HaVacuumMode;
|
||||
out.shape(mode_rect, 14., c(0xffffff, 0.06));
|
||||
out.border(mode_rect, 14., 1., c(0xffffff, 0.10));
|
||||
if mode_focused {
|
||||
out.border(mode_rect.inset(-4.), 18., 2., WHITE);
|
||||
}
|
||||
let mode_label = state.ha.entities.get(&vac.cleaning_mode_entity)
|
||||
.and_then(|e| e.attributes.get("options"))
|
||||
.and_then(|o| o.as_array())
|
||||
.and_then(|opts| opts.get(state.ha.vacuum_mode))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("default");
|
||||
out.text(
|
||||
&format!("Mode: {}", mode_label),
|
||||
Rect::new(mode_rect.x + 20., mode_rect.y + 16., 540., 24.),
|
||||
text(18., 500, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
out.control(Target::HaVacuumMode, mode_rect, false);
|
||||
|
||||
// Start button
|
||||
let start_y = mode_y + 72.;
|
||||
let selected_count = state.ha.vacuum_selected.iter().filter(|&&s| s).count();
|
||||
let start_rect = Rect::new(grid_x, start_y, 600., 56.);
|
||||
let start_focused = state.focus == Target::HaVacuumStart;
|
||||
let can_start = selected_count > 0;
|
||||
out.shape(
|
||||
start_rect,
|
||||
14.,
|
||||
if can_start {
|
||||
c(0x7EB8DA, 0.25)
|
||||
} else {
|
||||
c(0xffffff, 0.04)
|
||||
},
|
||||
);
|
||||
out.border(
|
||||
start_rect,
|
||||
14.,
|
||||
1.,
|
||||
if can_start {
|
||||
c(0x7EB8DA, 0.50)
|
||||
} else {
|
||||
c(0xffffff, 0.06)
|
||||
},
|
||||
);
|
||||
if start_focused && can_start {
|
||||
out.border(start_rect.inset(-4.), 18., 2., WHITE);
|
||||
}
|
||||
out.text(
|
||||
&format!(
|
||||
"Start Cleaning{}",
|
||||
if selected_count > 0 {
|
||||
format!(" ({} rooms)", selected_count)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
),
|
||||
Rect::new(start_rect.x + 20., start_rect.y + 16., 540., 24.),
|
||||
text(18., 600, -0.02),
|
||||
if can_start { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||
false,
|
||||
);
|
||||
out.control(Target::HaVacuumStart, start_rect, false);
|
||||
}
|
||||
|
||||
fn settings(out: &mut Scene, state: &State, assets: &Assets) {
|
||||
|
||||
Reference in new issue
Block a user