Compare commits
7
Commits
14db4f6e71
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29050dc32 | ||
|
|
c8e6fcb659 | ||
|
|
a7dd876c63 | ||
|
|
df271d738c | ||
|
|
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 |
+3
-2
@@ -39,8 +39,9 @@
|
|||||||
{ "type": "range", "entity_id": "light.rgbic_strip_lights_z1", "name": "LED Strip 1" },
|
{ "type": "range", "entity_id": "light.rgbic_strip_lights_z1", "name": "LED Strip 1" },
|
||||||
{ "type": "range", "entity_id": "light.rgbic_strip_lights_z2", "name": "LED Strip 2" },
|
{ "type": "range", "entity_id": "light.rgbic_strip_lights_z2", "name": "LED Strip 2" },
|
||||||
{ "type": "toggle", "entity_id": "light.picture_frame_lamp", "name": "Picture Lamp" },
|
{ "type": "toggle", "entity_id": "light.picture_frame_lamp", "name": "Picture Lamp" },
|
||||||
{ "type": "toggle", "entity_id": "cover.living_room_curtain", "name": "Curtain" },
|
{ "type": "curtain", "entity_id": "cover.living_room_curtain", "name": "Curtain" },
|
||||||
{ "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" }
|
{ "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" },
|
||||||
|
{ "type": "media", "entity_id": "media_player.music_assistant", "name": "Music" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -440,6 +440,10 @@ pub fn entity_ids_from_config(config: &Value) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(media) = config.get("media").and_then(|m| m.get("entity_id")).and_then(Value::as_str) {
|
||||||
|
ids.push(media.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(rooms) = config.get("rooms").and_then(Value::as_array) {
|
if let Some(rooms) = config.get("rooms").and_then(Value::as_array) {
|
||||||
for room in rooms {
|
for room in rooms {
|
||||||
if let Some(sensors) = room.get("sensors").and_then(Value::as_array) {
|
if let Some(sensors) = room.get("sensors").and_then(Value::as_array) {
|
||||||
|
|||||||
+162
@@ -178,6 +178,7 @@ fn effects(
|
|||||||
platform: &Platform,
|
platform: &Platform,
|
||||||
preferences: &std::path::Path,
|
preferences: &std::path::Path,
|
||||||
weather: &weather_service::Service,
|
weather: &weather_service::Service,
|
||||||
|
ha: Option<&ha_client::HaService>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
for effect in effects {
|
for effect in effects {
|
||||||
match effect {
|
match effect {
|
||||||
@@ -225,11 +226,155 @@ fn effects(
|
|||||||
}
|
}
|
||||||
Effect::Quit => return false,
|
Effect::Quit => return false,
|
||||||
Effect::Weather(command) => weather.send(command),
|
Effect::Weather(command) => weather.send(command),
|
||||||
|
Effect::Ha(ha_effect) => {
|
||||||
|
if let Some(service) = ha {
|
||||||
|
ha_dispatch(service, ha_effect);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
true
|
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 }
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
HaEffect::MediaPlayPause(entity_id) => {
|
||||||
|
service.send(ha_client::HaCommand::CallService {
|
||||||
|
id: next_id(),
|
||||||
|
domain: "media_player".into(),
|
||||||
|
service: "media_play_pause".into(),
|
||||||
|
target: serde_json::json!({ "entity_id": entity_id }),
|
||||||
|
data: serde_json::json!({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
HaEffect::MediaNext(entity_id) => {
|
||||||
|
service.send(ha_client::HaCommand::CallService {
|
||||||
|
id: next_id(),
|
||||||
|
domain: "media_player".into(),
|
||||||
|
service: "media_next_track".into(),
|
||||||
|
target: serde_json::json!({ "entity_id": entity_id }),
|
||||||
|
data: serde_json::json!({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
HaEffect::MediaPrev(entity_id) => {
|
||||||
|
service.send(ha_client::HaCommand::CallService {
|
||||||
|
id: next_id(),
|
||||||
|
domain: "media_player".into(),
|
||||||
|
service: "media_previous_track".into(),
|
||||||
|
target: serde_json::json!({ "entity_id": entity_id }),
|
||||||
|
data: serde_json::json!({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
HaEffect::SetVolume(entity_id, volume) => {
|
||||||
|
service.send(ha_client::HaCommand::CallService {
|
||||||
|
id: next_id(),
|
||||||
|
domain: "media_player".into(),
|
||||||
|
service: "volume_set".into(),
|
||||||
|
target: serde_json::json!({ "entity_id": entity_id }),
|
||||||
|
data: serde_json::json!({ "volume_level": volume }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
HaEffect::SetCoverPosition(entity_id, position) => {
|
||||||
|
service.send(ha_client::HaCommand::CallService {
|
||||||
|
id: next_id(),
|
||||||
|
domain: "cover".into(),
|
||||||
|
service: "set_cover_position".into(),
|
||||||
|
target: serde_json::json!({ "entity_id": entity_id }),
|
||||||
|
data: serde_json::json!({ "position": position }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn script(
|
fn script(
|
||||||
action: &str,
|
action: &str,
|
||||||
state: &mut State,
|
state: &mut State,
|
||||||
@@ -524,6 +669,7 @@ fn run() -> Result<(), String> {
|
|||||||
};
|
};
|
||||||
let ha_service = if let Ok(bytes) = std::fs::read(&ha_config_path) {
|
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) {
|
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 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();
|
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() {
|
if !url.is_empty() && !token.is_empty() {
|
||||||
@@ -555,6 +701,7 @@ fn run() -> Result<(), String> {
|
|||||||
&platform,
|
&platform,
|
||||||
&preferences_path,
|
&preferences_path,
|
||||||
&weather_service,
|
&weather_service,
|
||||||
|
ha_service.as_ref(),
|
||||||
);
|
);
|
||||||
platform.sync_splash(&state.preferences.background);
|
platform.sync_splash(&state.preferences.background);
|
||||||
startup_stage("preferences and workers");
|
startup_stage("preferences and workers");
|
||||||
@@ -925,6 +1072,17 @@ fn run() -> Result<(), String> {
|
|||||||
mouse_y,
|
mouse_y,
|
||||||
..
|
..
|
||||||
} if state.overlay.is_none() => {
|
} if state.overlay.is_none() => {
|
||||||
|
// HA card scroll: adjust range/climate/media values
|
||||||
|
let ha_scroll = state.page == state::Page::Ha
|
||||||
|
&& matches!(&state.focus, state::Target::HaCard(room, index)
|
||||||
|
if matches!(
|
||||||
|
state.ha.config.rooms[*room].cards[*index].card_type,
|
||||||
|
state::HaCardType::Range | state::HaCardType::Climate | state::HaCardType::Media
|
||||||
|
))
|
||||||
|
&& y != 0;
|
||||||
|
if ha_scroll {
|
||||||
|
pending = state.scroll_ha(y);
|
||||||
|
} else {
|
||||||
let over_favorites = state.page == state::Page::Home
|
let over_favorites = state.page == state::Page::Home
|
||||||
&& pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| {
|
&& pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| {
|
||||||
(64. ..1856.).contains(&x)
|
(64. ..1856.).contains(&x)
|
||||||
@@ -954,6 +1112,7 @@ fn run() -> Result<(), String> {
|
|||||||
.fold(0., f32::max);
|
.fold(0., f32::max);
|
||||||
state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.));
|
state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
state.pointer = true;
|
state.pointer = true;
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
@@ -965,6 +1124,7 @@ fn run() -> Result<(), String> {
|
|||||||
&platform,
|
&platform,
|
||||||
&preferences_path,
|
&preferences_path,
|
||||||
&weather_service,
|
&weather_service,
|
||||||
|
ha_service.as_ref(),
|
||||||
);
|
);
|
||||||
if press
|
if press
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1008,6 +1168,7 @@ fn run() -> Result<(), String> {
|
|||||||
&platform,
|
&platform,
|
||||||
&preferences_path,
|
&preferences_path,
|
||||||
&weather_service,
|
&weather_service,
|
||||||
|
ha_service.as_ref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1029,6 +1190,7 @@ fn run() -> Result<(), String> {
|
|||||||
&platform,
|
&platform,
|
||||||
&preferences_path,
|
&preferences_path,
|
||||||
&weather_service,
|
&weather_service,
|
||||||
|
ha_service.as_ref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let keyboard = state.text_input_active();
|
let keyboard = state.text_input_active();
|
||||||
|
|||||||
+448
-1
@@ -62,6 +62,14 @@ pub enum Target {
|
|||||||
WeatherSearch,
|
WeatherSearch,
|
||||||
WeatherClear,
|
WeatherClear,
|
||||||
WeatherResult(usize),
|
WeatherResult(usize),
|
||||||
|
HaRoom(usize),
|
||||||
|
HaCard(usize, usize),
|
||||||
|
HaWeather,
|
||||||
|
HaVacuumOpen,
|
||||||
|
HaVacuumRoom(usize),
|
||||||
|
HaVacuumMode,
|
||||||
|
HaVacuumStart,
|
||||||
|
HaVacuumClose,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
@@ -139,6 +147,25 @@ pub enum Effect {
|
|||||||
Launch(String),
|
Launch(String),
|
||||||
Quit,
|
Quit,
|
||||||
Weather(weather::Command),
|
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>,
|
||||||
|
},
|
||||||
|
MediaPlayPause(String),
|
||||||
|
MediaNext(String),
|
||||||
|
MediaPrev(String),
|
||||||
|
SetVolume(String, f32),
|
||||||
|
SetCoverPosition(String, u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const KEYS: [&str; 40] = [
|
pub const KEYS: [&str; 40] = [
|
||||||
@@ -147,11 +174,67 @@ pub const KEYS: [&str; 40] = [
|
|||||||
"Delete", "Clear", "Done",
|
"Delete", "Clear", "Done",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum HaCardType {
|
||||||
|
Toggle,
|
||||||
|
Range,
|
||||||
|
Climate,
|
||||||
|
Action,
|
||||||
|
Vacuum,
|
||||||
|
Media,
|
||||||
|
Curtain,
|
||||||
|
Blinds,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Default)]
|
||||||
pub struct HaState {
|
pub struct HaState {
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
pub entities: std::collections::HashMap<String, HaEntity>,
|
pub entities: std::collections::HashMap<String, HaEntity>,
|
||||||
pub history: std::collections::HashMap<String, Vec<(f64, f64)>>,
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -160,6 +243,77 @@ pub struct HaEntity {
|
|||||||
pub attributes: serde_json::Value,
|
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,
|
||||||
|
Some("media") => HaCardType::Media,
|
||||||
|
Some("curtain") => HaCardType::Curtain,
|
||||||
|
Some("blinds") => HaCardType::Blinds,
|
||||||
|
_ => 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 struct State {
|
||||||
pub page: Page,
|
pub page: Page,
|
||||||
pub focus: Target,
|
pub focus: Target,
|
||||||
@@ -410,7 +564,13 @@ impl State {
|
|||||||
.first()
|
.first()
|
||||||
.map(|app| Target::App(app.id.clone()))
|
.map(|app| Target::App(app.id.clone()))
|
||||||
.unwrap_or(Target::Filter(1)),
|
.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),
|
Page::Settings => Target::Sidebar(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -636,6 +796,80 @@ impl State {
|
|||||||
return vec![];
|
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 cols = self.ha_card_columns();
|
||||||
|
match direction {
|
||||||
|
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 {
|
let setting_rows = match self.subpage {
|
||||||
Some(Subpage::Greeting) => Some([Target::GreetingName, Target::GreetingNameClear]),
|
Some(Subpage::Greeting) => Some([Target::GreetingName, Target::GreetingNameClear]),
|
||||||
Some(Subpage::AppNames) => Some([Target::AppNamesHome, Target::AppNamesApps]),
|
Some(Subpage::AppNames) => Some([Target::AppNamesHome, Target::AppNamesApps]),
|
||||||
@@ -982,6 +1216,124 @@ impl State {
|
|||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ha_card_columns(&self) -> usize {
|
||||||
|
4
|
||||||
|
}
|
||||||
|
|
||||||
|
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_entity_volume(&self, entity_id: &str) -> f32 {
|
||||||
|
self.ha.entities.get(entity_id)
|
||||||
|
.and_then(|e| e.attributes.get("volume_level"))
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(0.) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ha_entity_position(&self, entity_id: &str) -> f32 {
|
||||||
|
self.ha.entities.get(entity_id)
|
||||||
|
.and_then(|e| e.attributes.get("current_position"))
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(0.) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_ha(&mut self, delta: i32) -> Vec<Effect> {
|
||||||
|
if let Target::HaCard(room, index) = &self.focus {
|
||||||
|
let card = &self.ha.config.rooms[*room].cards[*index];
|
||||||
|
match card.card_type {
|
||||||
|
HaCardType::Range => {
|
||||||
|
let brightness = self.ha_entity_brightness(&card.entity_id);
|
||||||
|
let new = (brightness + delta as f32 * 25.5).clamp(0., 255.);
|
||||||
|
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
|
||||||
|
}
|
||||||
|
HaCardType::Climate => {
|
||||||
|
let temp = self.ha_entity_target_temp(&card.entity_id);
|
||||||
|
let new = temp + delta as f32 * 0.5;
|
||||||
|
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), new))];
|
||||||
|
}
|
||||||
|
HaCardType::Media => {
|
||||||
|
let vol = self.ha_entity_volume(&card.entity_id);
|
||||||
|
let new = (vol + delta as f32 * 0.05).clamp(0., 1.);
|
||||||
|
return vec![Effect::Ha(HaEffect::SetVolume(card.entity_id.clone(), new))];
|
||||||
|
}
|
||||||
|
HaCardType::Curtain | HaCardType::Blinds => {
|
||||||
|
let pos = self.ha_entity_position(&card.entity_id);
|
||||||
|
let new = (pos + delta as f32 * 10.).clamp(0., 100.) as u8;
|
||||||
|
return vec![Effect::Ha(HaEffect::SetCoverPosition(card.entity_id.clone(), new))];
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
pub fn options(&mut self) {
|
||||||
if self.overlay.is_some() || self.moving.is_some() {
|
if self.overlay.is_some() || self.moving.is_some() {
|
||||||
return;
|
return;
|
||||||
@@ -1330,6 +1682,88 @@ impl State {
|
|||||||
return vec![Effect::Save];
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HaCardType::Media => {
|
||||||
|
return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))];
|
||||||
|
}
|
||||||
|
HaCardType::Curtain | HaCardType::Blinds => {
|
||||||
|
return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))];
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) => {
|
Target::Sidebar(index) => {
|
||||||
self.section = index;
|
self.section = index;
|
||||||
self.scroll = 0.;
|
self.scroll = 0.;
|
||||||
@@ -1560,6 +1994,19 @@ impl State {
|
|||||||
self.scroll = 0.;
|
self.scroll = 0.;
|
||||||
return vec![];
|
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) {
|
if self.page == Page::Apps && matches!(self.focus, Target::Search | Target::ClearSearch) {
|
||||||
self.query.clear();
|
self.query.clear();
|
||||||
self.focus = Target::Nav(1);
|
self.focus = Target::Nav(1);
|
||||||
|
|||||||
+1061
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
|||||||
|
FROM debian:buster-slim
|
||||||
|
|
||||||
|
# Buster is archived — point sources at archive.debian.org
|
||||||
|
RUN sed -i 's|deb.debian.org|archive.debian.org|g; s|security.debian.org|archive.debian.org|g; /buster-updates/d' /etc/apt/sources.list && \
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl ca-certificates pkg-config cmake git \
|
||||||
|
gcc libc6-dev gcc-arm-linux-gnueabi binutils-arm-linux-gnueabi libc6-dev-armel-cross \
|
||||||
|
python3 \
|
||||||
|
xz-utils && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Node.js 20 binary (supports glibc 2.28+, has import.meta.dirname)
|
||||||
|
RUN curl -fsSL https://nodejs.org/dist/v20.18.1/node-v20.18.1-linux-x64.tar.xz | \
|
||||||
|
tar -xJ --strip-components=1 -C /usr/local && \
|
||||||
|
npm install -g pnpm@9
|
||||||
|
|
||||||
|
# Rust with ARM soft-float target
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||||
|
sh -s -- -y --default-toolchain stable --profile minimal && \
|
||||||
|
. /root/.cargo/env && \
|
||||||
|
rustup target add armv7-unknown-linux-gnueabi
|
||||||
|
|
||||||
|
# Create a fake WEBOS_NDK that wraps the standard cross-compiler
|
||||||
|
RUN mkdir -p /opt/webos-ndk/bin && \
|
||||||
|
ln -s /usr/bin/arm-linux-gnueabi-gcc /opt/webos-ndk/bin/arm-webos-linux-gnueabi-gcc && \
|
||||||
|
ln -s /usr/bin/arm-linux-gnueabi-ar /opt/webos-ndk/bin/arm-webos-linux-gnueabi-ar && \
|
||||||
|
ln -s /usr/bin/arm-linux-gnueabi-strip /opt/webos-ndk/bin/arm-webos-linux-gnueabi-strip && \
|
||||||
|
mkdir -p /opt/webos-ndk/arm-webos-linux-gnueabi/sysroot/usr/include && \
|
||||||
|
cp -a /usr/arm-linux-gnueabi/include/* /opt/webos-ndk/arm-webos-linux-gnueabi/sysroot/usr/include/ && \
|
||||||
|
mkdir -p /opt/webos-ndk/arm-webos-linux-gnueabi/sysroot/usr/lib && \
|
||||||
|
cp -a /usr/arm-linux-gnueabi/lib/* /opt/webos-ndk/arm-webos-linux-gnueabi/sysroot/usr/lib/
|
||||||
|
|
||||||
|
ENV WEBOS_NDK=/opt/webos-ndk
|
||||||
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CC=/usr/bin/arm-linux-gnueabi-gcc
|
||||||
|
STUBDIR=/usr/arm-linux-gnueabi/lib
|
||||||
|
|
||||||
|
echo "=== Creating webOS stub libraries ==="
|
||||||
|
|
||||||
|
cat > /tmp/luna-stub.c << 'STUBEOF'
|
||||||
|
typedef int gint;
|
||||||
|
typedef unsigned int guint;
|
||||||
|
typedef void* gpointer;
|
||||||
|
int LSErrorInit(void *e) { return 1; }
|
||||||
|
void LSErrorFree(void *e) {}
|
||||||
|
int LSRegister(const char *n, void **h, void *e) { return 1; }
|
||||||
|
int LSUnregister(void *h, void *e) { return 1; }
|
||||||
|
int LSGmainContextAttach(void *h, void *c, void *e) { return 1; }
|
||||||
|
int LSCallOneReply(void *h, const char *u, const char *p, void *cb, void *ud, unsigned long *t, void *e) { return 1; }
|
||||||
|
int LSCallCancel(void *h, unsigned long t, void *e) { return 1; }
|
||||||
|
const char* LSMessageGetPayload(void *m) { return "{}"; }
|
||||||
|
STUBEOF
|
||||||
|
$CC -shared -o $STUBDIR/libluna-service2.so /tmp/luna-stub.c
|
||||||
|
echo " Created libluna-service2.so stub"
|
||||||
|
|
||||||
|
cat > /tmp/glib-stub.c << 'STUBEOF'
|
||||||
|
void* g_main_context_new(void) { return (void*)0; }
|
||||||
|
int g_main_context_iteration(void *ctx, int may_block) { return 0; }
|
||||||
|
void g_main_context_unref(void *ctx) {}
|
||||||
|
STUBEOF
|
||||||
|
$CC -shared -o $STUBDIR/libglib-2.0.so /tmp/glib-stub.c
|
||||||
|
echo " Created libglib-2.0.so stub"
|
||||||
|
|
||||||
|
echo "=== Installing npm deps ==="
|
||||||
|
pnpm install --frozen-lockfile 2>&1 | tail -5 || true
|
||||||
|
|
||||||
|
echo "=== Preparing SDL-webOS runtime ==="
|
||||||
|
node tools/webos-runtime.mjs 2>&1 | tail -5
|
||||||
|
|
||||||
|
echo "=== Building for webOS ARM ==="
|
||||||
|
sh tools/build-webos.sh 2>&1 | tail -30
|
||||||
|
|
||||||
|
echo "=== Build result ==="
|
||||||
|
ls -la target/armv7-unknown-linux-gnueabi/release/luma
|
||||||
|
arm-linux-gnueabi-strip target/armv7-unknown-linux-gnueabi/release/luma
|
||||||
|
ls -la target/armv7-unknown-linux-gnueabi/release/luma
|
||||||
|
echo "=== Done ==="
|
||||||
@@ -18,7 +18,7 @@ export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_LINKER="$CC"
|
|||||||
# The target's soft-float *calling convention* is required by webOS, but its
|
# The target's soft-float *calling convention* is required by webOS, but its
|
||||||
# default software FP codegen is not. Match the NDK's ARMv7/VFP baseline while
|
# default software FP codegen is not. Match the NDK's ARMv7/VFP baseline while
|
||||||
# retaining the gnueabi ABI; keep NEON off for portable Rust scalar code.
|
# retaining the gnueabi ABI; keep NEON off for portable Rust scalar code.
|
||||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_RUSTFLAGS="${CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_RUSTFLAGS:-} -C target-cpu=cortex-a9 -C target-feature=-neon,-soft-float -C link-arg=--sysroot=$SYSROOT -C link-arg=-Wl,-rpath,\$ORIGIN/lib"
|
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_RUSTFLAGS="${CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_RUSTFLAGS:-} -C target-cpu=cortex-a9 -C target-feature=-neon,-soft-float -C link-arg=--sysroot=$SYSROOT -C link-arg=-Wl,-rpath,\$ORIGIN/lib -C link-arg=-Wl,--disable-new-dtags"
|
||||||
export CC_armv7_unknown_linux_gnueabi="$CC"
|
export CC_armv7_unknown_linux_gnueabi="$CC"
|
||||||
export AR_armv7_unknown_linux_gnueabi="$WEBOS_NDK/bin/arm-webos-linux-gnueabi-ar"
|
export AR_armv7_unknown_linux_gnueabi="$WEBOS_NDK/bin/arm-webos-linux-gnueabi-ar"
|
||||||
export CFLAGS_armv7_unknown_linux_gnueabi="${CFLAGS_armv7_unknown_linux_gnueabi:-} --sysroot=$SYSROOT"
|
export CFLAGS_armv7_unknown_linux_gnueabi="${CFLAGS_armv7_unknown_linux_gnueabi:-} --sysroot=$SYSROOT"
|
||||||
|
|||||||
Reference in new issue
Block a user