diff --git a/assets/app.css b/assets/app.css index 786893a..d6dd74c 100755 --- a/assets/app.css +++ b/assets/app.css @@ -49,6 +49,11 @@ body.is-embedded { overflow: auto; } +body.is-ha-native #camera-modal, +body.is-ha-native .camera-modal { + display: none !important; +} + button, input, select, @@ -261,7 +266,7 @@ textarea { .room-list__group { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } @@ -293,7 +298,8 @@ textarea { height: 42px; } -.app-shell--embed .room-item__icon i { +.app-shell--embed .room-item__icon i, +.app-shell--embed .room-item__icon ha-icon { font-size: 32px; } @@ -375,6 +381,17 @@ textarea { justify-content: center; } +.room-item__icon ha-icon, +.grid-card__icon ha-icon, +.mushroom-button__icon ha-icon, +.icon-node--ha { + width: 100%; + height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; +} + .icon-node__img { width: 100%; height: 100%; @@ -509,6 +526,10 @@ textarea { padding: 22px 16px 20px; } +.app-shell.is-ha-native .content-top { + margin-top: 0; +} + .content-top { display: none; margin-bottom: 16px; diff --git a/assets/app.js b/assets/app.js index 66cefea..58e0ac4 100755 --- a/assets/app.js +++ b/assets/app.js @@ -3,7 +3,7 @@ const MOBILE_BREAKPOINT = 920; const state = { snapshot: bootstrap, - embedMode: Boolean(bootstrap?.ui?.embed), + embedMode: Boolean(bootstrap?.ui?.embed || window.WALL_PANEL_HA_MODE), selectedRoomId: 'main', isMobileViewport: false, mobileView: 'spaces', @@ -44,10 +44,14 @@ snapshotPollTimer: null, haSnapshotListenerInstalled: false, debugLastRenderSignature: '', + lastRenderSignature: '', + lastSidebarRenderSignature: '', + lastContentRenderSignature: '', }; const els = {}; const client = window.StrikerPanelClient || (window.StrikerPanelClient = {}); + let renderFrame = null; const debugEnabled = (() => { try { if (window.StrikerPanelDebug) return true; @@ -81,14 +85,20 @@ } state.snapshot = snapshot; initRefs(); - state.embedMode = detectEmbeddedContext(); + state.embedMode = detectEmbeddedContext() || isHaRuntime(); syncLayoutState(); - render(); + if (renderFrame) { + return; + } + renderFrame = window.requestAnimationFrame(() => { + renderFrame = null; + render(); + }); }; client.refresh = () => { initRefs(); - state.embedMode = detectEmbeddedContext(); + state.embedMode = detectEmbeddedContext() || isHaRuntime(); syncLayoutState(); render(); }; @@ -118,11 +128,22 @@ function isHaRuntime() { return Boolean( + window.WALL_PANEL_HA_MODE || haBridge() || bootstrap?.ui?.mode === 'ha-native' ); } + async function waitForHaBridge(timeoutMs = 1000) { + const startedAt = Date.now(); + let bridge = haBridge(); + while (!bridge && (Date.now() - startedAt) < timeoutMs) { + await sleep(50); + bridge = haBridge(); + } + return bridge; + } + function sleep(ms) { return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); } @@ -160,6 +181,254 @@ }; } + function snapshotEntityToken(snapshot, entityId) { + if (!snapshot || !entityId) { + return null; + } + + const collections = [ + snapshot.main_entities, + snapshot.selected_space?.entities, + snapshot.selected_room?.entities, + ]; + if (snapshot.entity_index && typeof snapshot.entity_index === 'object') { + collections.push(Object.values(snapshot.entity_index)); + } + if (snapshot.space_index && typeof snapshot.space_index === 'object') { + Object.values(snapshot.space_index).forEach((room) => { + if (room?.entities) { + collections.push(room.entities); + } + }); + } + if (snapshot.space_entities && typeof snapshot.space_entities === 'object') { + Object.values(snapshot.space_entities).forEach((entities) => collections.push(entities)); + } + if (snapshot.battery_room?.entities) { + collections.push(snapshot.battery_room.entities); + } + + for (const collection of collections) { + if (!Array.isArray(collection)) continue; + const found = collection.find((entity) => entity && entity.entity_id === entityId); + if (found) { + const attr = found.attributes || {}; + return [ + String(found.entity_id || ''), + String(found.state ?? ''), + String(found.last_changed || found.last_updated || ''), + String(attr.current_temperature ?? attr.temperature ?? ''), + String(attr.current_position ?? ''), + String(attr.hvac_action ?? ''), + ]; + } + } + return null; + } + + function entityRenderToken(entity) { + const attr = entity?.attributes || {}; + return [ + String(entity?.entity_id || ''), + String(entity?.state ?? ''), + String(entity?.order ?? 9999), + entity?.visible === false ? '0' : '1', + String(entity?.domain || ''), + String(entity?.card_type || ''), + String(entity?.subtitle || ''), + String(entity?.icon || ''), + String(attr.current_temperature ?? attr.temperature ?? ''), + String(attr.current_position ?? ''), + String(attr.hvac_action ?? ''), + ]; + } + + function roomRenderToken(room) { + return [ + String(room?.id || ''), + String(room?.name || ''), + String(room?.icon || ''), + room?.visible === false ? '0' : '1', + String(room?.order ?? 9999), + String(room?.entity_count ?? 0), + String(room?.active_entity_count ?? 0), + String(room?.temperature_badge || ''), + String(room?.battery_summary_text || ''), + room?.virtual ? '1' : '0', + ]; + } + + function selectedRoomRenderToken(snapshot) { + const room = snapshot?.selected_room || snapshot?.selected_space || {}; + if (!room?.id) { + return ['unknown']; + } + + if (room.id === 'main') { + const boiler = snapshot?.settings?.main_boiler || {}; + const printConfig = snapshot?.settings?.main_print || {}; + const weatherActions = Array.isArray(snapshot?.settings?.main_weather_actions) + ? snapshot.settings.main_weather_actions + : []; + return [ + 'main', + room.name || '', + (Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : []).map(entityRenderToken), + snapshotEntityToken(snapshot, boiler.sensor_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.current_stage_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.print_progress_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.start_time_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.end_time_entity_id || ''), + weatherActions.map((action) => [ + String(action?.entity_id || ''), + String(action?.state_entity_id || ''), + String(action?.command || ''), + String(action?.value ?? ''), + String(action?.active_value ?? ''), + String(action?.label_active || ''), + String(action?.label_inactive || ''), + String(mainWeatherActionIsActive(snapshot, action) ? '1' : '0'), + ]), + snapshot?.weather ? [ + String(snapshot.weather.entity_id || ''), + String(snapshot.weather.state ?? ''), + String(snapshot.weather.temperature ?? ''), + String(snapshot.weather.sensor_temperature ?? ''), + String(snapshot.weather.wind_speed ?? ''), + String(snapshot.weather.condition ?? ''), + ] : null, + ]; + } + + if (room.id === 'batteries') { + return [ + 'batteries', + room.name || '', + room.battery_summary_text || '', + Number(room.entity_count ?? 0) || 0, + Number(room.problem_count ?? room.active_entity_count ?? 0) || 0, + (Array.isArray(room.entities) ? room.entities : []).map((item) => [ + String(item?.entity_id || ''), + String(item?.battery_status || ''), + String(item?.battery_percent_text || ''), + String(item?.forecast_minutes_left ?? ''), + String(item?.forecast_text || ''), + String(item?.source_text || ''), + ]), + ]; + } + + return [ + room.id, + room.name || '', + room.icon || '', + room.visible === false ? '0' : '1', + String(room.order ?? 9999), + String(room.temperature_badge || ''), + roomGridEntries(snapshot, room.id).map((entry) => ( + entry.kind === 'layout' + ? ['layout', String(entry.id || ''), String(entry.order ?? 9999), String(entry.payload?.type || 'ghost')] + : ['entity', ...entityRenderToken(entry.payload)] + )), + state.editMode + ? roomEntitiesIncludingHidden(snapshot, room.id) + .filter((entity) => entity.visible === false) + .map(entityRenderToken) + : [], + ]; + } + + function buildVisibleSnapshotSignature(snapshot) { + const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : []; + const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || {}; + const batteryRoom = snapshot?.battery_room || null; + return JSON.stringify([ + String(snapshot?.ui?.mode || 'unknown'), + String(selectedRoom?.id || 'main'), + rooms.map(roomRenderToken), + batteryRoom ? roomRenderToken(batteryRoom) : null, + selectedRoomRenderToken(snapshot), + ]); + } + + function buildSidebarRenderSignature(snapshot) { + const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : []; + const batteryRoom = snapshot?.battery_room || null; + return JSON.stringify([ + String(state.selectedRoomId || 'main'), + String(state.editMode ? '1' : '0'), + String(state.mobileView || 'spaces'), + String(isMobileViewport() ? '1' : '0'), + rooms.map(roomRenderToken), + batteryRoom ? roomRenderToken(batteryRoom) : null, + ]); + } + + function buildContentRenderSignature(snapshot) { + const room = snapshot?.selected_room || snapshot?.selected_space || {}; + return JSON.stringify([ + String(state.selectedRoomId || room.id || 'main'), + String(room.id || 'main'), + String(state.editMode ? '1' : '0'), + String(state.mobileView || 'spaces'), + String(isMobileViewport() ? '1' : '0'), + selectedRoomRenderToken(snapshot), + ]); + } + + function buildRenderSignature(snapshot) { + const history = state.mainBoilerHistory || {}; + return JSON.stringify([ + buildVisibleSnapshotSignature(snapshot), + String(state.selectedRoomId || 'main'), + String(state.editMode ? '1' : '0'), + String(state.mobileView || 'spaces'), + String(isMobileViewport() ? '1' : '0'), + String(history.entityId || ''), + String(history.loadedAt || 0), + String(Array.isArray(history.points) ? history.points.length : 0), + String(history.loading ? '1' : '0'), + String(history.error || ''), + String(state.entityPopup?.active ? '1' : '0'), + String(state.entityPopup?.entityId || ''), + String(state.temperatureSensorPopup?.active ? '1' : '0'), + String(state.temperatureSensorPopup?.roomId || ''), + String(state.lastPopupSignature || ''), + String(state.lastEntityPopupSignature || ''), + String(state.lastTemperatureSensorPopupSignature || ''), + ]); + } + + function renderSidebarSection(snapshot) { + const nextSignature = buildSidebarRenderSignature(snapshot); + if (nextSignature === state.lastSidebarRenderSignature) { + return false; + } + state.lastSidebarRenderSignature = nextSignature; + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); + if (els.roomsCount) { + els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; + } + if (els.editModeToggle) { + els.editModeToggle.classList.toggle('is-active', state.editMode); + els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + } + return true; + } + + function renderContentSection(snapshot) { + const nextSignature = buildContentRenderSignature(snapshot); + if (nextSignature === state.lastContentRenderSignature) { + return false; + } + state.lastContentRenderSignature = nextSignature; + syncLayoutState(); + renderDashboard(snapshot); + renderSelectedRoom(snapshot); + return true; + } + function requestSummary(action, params = {}) { const summary = { action }; ['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => { @@ -309,6 +578,9 @@ } function detectEmbeddedContext() { + if (isHaRuntime()) { + return true; + } if (Boolean(bootstrap?.ui?.embed)) { return true; } @@ -378,20 +650,160 @@ return svg; } - function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { + const iconTemplateCache = new Map(); + const iconTemplatePromiseCache = new Map(); + const customBrandIconsUrl = 'https://home.striker72rus.ru/local/community/custom-brand-icons/custom-brand-icons.js'; + let customBrandIconsPromise = null; + + function templateFromSvgText(svgText) { + const template = document.createElement('template'); + template.innerHTML = String(svgText || '').trim(); + return template; + } + + function iconUrl(source) { + return `https://api.iconify.design/${source}.svg`; + } + + function ensureCustomBrandIconsLoaded() { + if (window.customIcons || window.customIconsets) { + return Promise.resolve(true); + } + if (customBrandIconsPromise) { + return customBrandIconsPromise; + } + + const existing = document.querySelector('script[data-wall-panel-custom-brand-icons="1"]'); + if (existing) { + customBrandIconsPromise = new Promise((resolve, reject) => { + if (window.customIcons || window.customIconsets) { + resolve(true); + return; + } + existing.addEventListener('load', () => resolve(true), { once: true }); + existing.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true }); + }).finally(() => { + customBrandIconsPromise = null; + }); + return customBrandIconsPromise; + } + + const script = document.createElement('script'); + script.src = customBrandIconsUrl; + script.defer = true; + script.async = true; + script.dataset.wallPanelCustomBrandIcons = '1'; + customBrandIconsPromise = new Promise((resolve, reject) => { + script.addEventListener('load', () => resolve(true), { once: true }); + script.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true }); + }).finally(() => { + customBrandIconsPromise = null; + }); + (document.head || document.documentElement).appendChild(script); + return customBrandIconsPromise; + } + + function applyTemplateToNode(target, template) { + if (!target || !template || !target.isConnected) { + return; + } + target.replaceChildren(template.content.cloneNode(true)); + } + + function primeRemoteIconTemplate(source) { + const key = `remote:${source}`; + if (iconTemplateCache.has(key)) { + return Promise.resolve(iconTemplateCache.get(key)); + } + if (iconTemplatePromiseCache.has(key)) { + return iconTemplatePromiseCache.get(key); + } + + const promise = fetch(iconUrl(source), { + cache: 'force-cache', + credentials: 'omit', + headers: { Accept: 'image/svg+xml' }, + }).then((res) => { + if (!res.ok) { + throw new Error(`Icon load failed: ${res.status}`); + } + return res.text(); + }).then((svgText) => { + const template = templateFromSvgText(svgText); + iconTemplateCache.set(key, template); + return template; + }).finally(() => { + iconTemplatePromiseCache.delete(key); + }); + + iconTemplatePromiseCache.set(key, promise); + return promise; + } + + function primeCustomIconTemplate(source) { + const key = `custom:${source}`; + if (iconTemplateCache.has(key)) { + return Promise.resolve(iconTemplateCache.get(key)); + } + if (iconTemplatePromiseCache.has(key)) { + return iconTemplatePromiseCache.get(key); + } + const [prefix, name] = source.split(':', 2); const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix]; const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon; if (typeof getIcon !== 'function') { - return null; + return Promise.resolve(null); + } + + const promise = Promise.resolve(getIcon(name)).then((definition) => { + if (!definition) { + return null; + } + const template = document.createElement('template'); + template.content.appendChild(createSvgIcon(definition)); + iconTemplateCache.set(key, template); + return template; + }).finally(() => { + iconTemplatePromiseCache.delete(key); + }); + + iconTemplatePromiseCache.set(key, promise); + return promise; + } + + function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { + const cached = iconTemplateCache.get(`custom:${source}`); + if (cached) { + return cached.content.cloneNode(true); + } + + const [prefix] = source.split(':', 2); + const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix]; + const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon; + if (typeof getIcon !== 'function') { + if (!isHaRuntime()) { + return null; + } + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + wrap.appendChild(createIconElement(fallback)); + ensureCustomBrandIconsLoaded().then(() => primeCustomIconTemplate(source)).then((template) => { + if (!template) return; + applyTemplateToNode(wrap, template); + }).catch(() => { + if (!wrap.isConnected) return; + wrap.replaceChildren(createIconElement(fallback)); + }); + return wrap; } const wrap = document.createElement('span'); wrap.className = 'icon-node'; wrap.appendChild(createIconElement(fallback)); - Promise.resolve(getIcon(name)).then((definition) => { - if (!definition || !wrap.isConnected) return; - wrap.replaceChildren(createSvgIcon(definition)); + primeCustomIconTemplate(source).then((template) => { + if (!template) return; + applyTemplateToNode(wrap, template); }).catch(() => { if (!wrap.isConnected) return; wrap.replaceChildren(createIconElement(fallback)); @@ -403,77 +815,71 @@ const source = normalizeIconSource(icon) || fallback; if (source.startsWith('mdi:')) { + if (isHaRuntime()) { + const haIcon = document.createElement('ha-icon'); + haIcon.setAttribute('icon', source); + haIcon.className = 'icon-node icon-node--ha'; + return haIcon; + } const i = document.createElement('i'); i.className = iconClass(source); + i.setAttribute('aria-hidden', 'true'); return i; } - if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) { - const custom = createCustomIconElement(source, fallback); - if (custom) { - return custom; - } - const mappedSource = source.startsWith('fas:') - ? source.replace(/^fas:/, 'fa-solid:') - : source.startsWith('far:') - ? source.replace(/^far:/, 'fa-regular:') - : source.replace(/^fab:/, 'fa-brands:'); - const wrap = document.createElement('span'); - wrap.className = 'icon-node'; - wrap.appendChild(createIconElement(fallback)); - const img = document.createElement('img'); - img.className = 'icon-node__img'; - img.alt = ''; - img.decoding = 'async'; - img.loading = 'lazy'; - img.referrerPolicy = 'no-referrer'; - img.src = `https://api.iconify.design/${mappedSource}.svg`; - img.addEventListener('load', () => { - if (!img.isConnected || !wrap.isConnected) return; - wrap.replaceChildren(img); - }); - img.addEventListener('error', () => { - if (img.dataset.fallbackApplied === '1') return; - img.dataset.fallbackApplied = '1'; - wrap.replaceChildren(createIconElement(fallback)); - }); - wrap.appendChild(img); - return wrap; - } - const custom = createCustomIconElement(source, fallback); if (custom) { return custom; } + if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) { + const mappedSource = source.startsWith('fas:') + ? source.replace(/^fas:/, 'fa-solid:') + : source.startsWith('far:') + ? source.replace(/^far:/, 'fa-regular:') + : source.replace(/^fab:/, 'fa-brands:'); + const cached = iconTemplateCache.get(`remote:${mappedSource}`); + if (cached) { + return cached.content.cloneNode(true); + } + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + wrap.appendChild(createIconElement(fallback)); + primeRemoteIconTemplate(mappedSource).then((template) => { + applyTemplateToNode(wrap, template); + }).catch(() => { + if (!wrap.isConnected) return; + wrap.replaceChildren(createIconElement(fallback)); + }); + return wrap; + } + + const remoteCached = iconTemplateCache.get(`remote:${source}`); + if (remoteCached) { + return remoteCached.content.cloneNode(true); + } + const wrap = document.createElement('span'); wrap.className = 'icon-node'; wrap.appendChild(createIconElement(fallback)); if (source.includes(':')) { - const img = document.createElement('img'); - img.className = 'icon-node__img'; - img.alt = ''; - img.decoding = 'async'; - img.loading = 'lazy'; - img.referrerPolicy = 'no-referrer'; - img.src = `https://api.iconify.design/${source}.svg`; - img.addEventListener('load', () => { - if (!img.isConnected || !wrap.isConnected) return; - wrap.replaceChildren(img); - }); - img.addEventListener('error', () => { - if (img.dataset.fallbackApplied === '1') return; - img.dataset.fallbackApplied = '1'; + primeRemoteIconTemplate(source).then((template) => { + applyTemplateToNode(wrap, template); + }).catch(() => { + if (!wrap.isConnected) return; wrap.replaceChildren(createIconElement(fallback)); }); - wrap.appendChild(img); return wrap; } return createIconElement(fallback); } + if (isHaRuntime()) { + void ensureCustomBrandIconsLoaded(); + } + function esc(value) { return String(value ?? ''); } @@ -550,6 +956,9 @@ } function popupTriggerEntities() { + if (isHaRuntime()) { + return new Set(); + } const fromSnapshot = state.snapshot?.settings?.camera?.trigger_entities; const fromBootstrap = bootstrap?.settings?.camera?.trigger_entities; const triggers = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : []; @@ -669,10 +1078,18 @@ async function apiGet(action, params = {}) { const bridge = haBridge(); if (bridge?.request) { - console.log('[Striker Panel]', 'apiGet via bridge', requestSummary(action, params)); + debugLog('apiGet via bridge', requestSummary(action, params)); return bridge.request('GET', action, params); } - console.log('[Striker Panel]', 'apiGet via http', requestSummary(action, params)); + if (isHaRuntime()) { + const waited = await waitForHaBridge(1000); + if (waited?.request) { + debugLog('apiGet via delayed bridge', requestSummary(action, params)); + return waited.request('GET', action, params); + } + throw new Error('HA bridge is not ready'); + } + debugLog('apiGet via http', requestSummary(action, params)); const res = await fetch(buildUrl(action, params), { headers: { Accept: 'application/json' }, cache: 'no-store', @@ -686,10 +1103,18 @@ async function apiPost(action, payload = {}) { const bridge = haBridge(); if (bridge?.request) { - console.log('[Striker Panel]', 'apiPost via bridge', requestSummary(action, payload)); + debugLog('apiPost via bridge', requestSummary(action, payload)); return bridge.request('POST', action, payload); } - console.log('[Striker Panel]', 'apiPost via http', requestSummary(action, payload)); + if (isHaRuntime()) { + const waited = await waitForHaBridge(1000); + if (waited?.request) { + debugLog('apiPost via delayed bridge', requestSummary(action, payload)); + return waited.request('POST', action, payload); + } + throw new Error('HA bridge is not ready'); + } + debugLog('apiPost via http', requestSummary(action, payload)); const res = await fetch(buildUrl(action), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, @@ -1183,12 +1608,16 @@ if (!els.appShell) return; const mobile = isMobileViewport(); - const embedded = Boolean(state.embedMode); + const haNative = isHaRuntime(); + const embedded = Boolean(state.embedMode || haNative); + state.embedMode = embedded; document.body.classList.toggle('is-mobile-ui', mobile); document.body.classList.toggle('is-embedded', embedded); + document.body.classList.toggle('is-ha-native', haNative); els.appShell.classList.toggle('is-mobile', mobile); els.appShell.classList.toggle('is-desktop', !mobile); els.appShell.classList.toggle('app-shell--embed', embedded); + els.appShell.classList.toggle('is-ha-native', haNative); els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room'); els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room'); @@ -1865,6 +2294,9 @@ } function applyPopupState(active, sensorEntityId) { + if (isHaRuntime()) { + return; + } const camera = state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; const popup = state.snapshot?.popup || {}; if (active && Date.now() < Number(state.popupAutoOpenBlockedUntil || 0)) { @@ -1888,6 +2320,9 @@ } function applyPopupSnapshot(popup = {}) { + if (isHaRuntime()) { + return; + } const snapshot = state.snapshot || bootstrap; snapshot.popup = mergePopupWithCamera({ ...(snapshot.popup || {}), @@ -1897,6 +2332,9 @@ } function syncTriggerPopup(entityId, stateValue) { + if (isHaRuntime()) { + return; + } const value = String(stateValue || '').toLowerCase(); if (!['on', 'off'].includes(value)) { return; @@ -2870,13 +3308,13 @@ const minus = document.createElement('button'); minus.type = 'button'; minus.className = 'round-button entity-modal__round-button'; - minus.innerHTML = ''; + minus.appendChild(createIconElement('mdi:minus')); minus.addEventListener('click', () => handleClimateTemperature(entity, -1)); const plus = document.createElement('button'); plus.type = 'button'; plus.className = 'round-button entity-modal__round-button'; - plus.innerHTML = ''; + plus.appendChild(createIconElement('mdi:plus')); plus.addEventListener('click', () => handleClimateTemperature(entity, 1)); controls.append(minus, plus); @@ -3349,13 +3787,15 @@ const upBtn = document.createElement('button'); upBtn.type = 'button'; upBtn.className = 'mushroom-button mushroom-button--small'; - upBtn.innerHTML = ' Вверх'; + upBtn.appendChild(createIconElement('mdi:arrow-up')); + upBtn.appendChild(document.createTextNode(' Вверх')); upBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, -1)); const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.className = 'mushroom-button mushroom-button--small'; - downBtn.innerHTML = ' Вниз'; + downBtn.appendChild(createIconElement('mdi:arrow-down')); + downBtn.appendChild(document.createTextNode(' Вниз')); downBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1)); actions.append(upBtn, downBtn); @@ -3371,7 +3811,7 @@ const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'mini-action mini-action--wide'; - btn.innerHTML = hidden ? '' : ''; + btn.appendChild(createIconElement(hidden ? 'mdi:eye' : 'mdi:eye-off')); btn.title = hidden ? 'Показать' : 'Скрыть'; btn.addEventListener('click', (event) => { event.stopPropagation(); @@ -3484,7 +3924,8 @@ const settingsBtn = document.createElement('button'); settingsBtn.type = 'button'; settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; - settingsBtn.innerHTML = ' Настройки'; + settingsBtn.appendChild(createIconElement('mdi:cog-outline')); + settingsBtn.appendChild(document.createTextNode(' Настройки')); settingsBtn.addEventListener('click', (event) => { event.stopPropagation(); state.layoutItemSettingsOpen = { @@ -3497,7 +3938,8 @@ const upBtn = document.createElement('button'); upBtn.type = 'button'; upBtn.className = 'mushroom-button mushroom-button--small'; - upBtn.innerHTML = ' Вверх'; + upBtn.appendChild(createIconElement('mdi:arrow-up')); + upBtn.appendChild(document.createTextNode(' Вверх')); upBtn.addEventListener('click', (event) => { event.stopPropagation(); reorderRoomGridEntry(room.id, 'layout', item.id, -1); @@ -3506,7 +3948,8 @@ const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.className = 'mushroom-button mushroom-button--small'; - downBtn.innerHTML = ' Вниз'; + downBtn.appendChild(createIconElement('mdi:arrow-down')); + downBtn.appendChild(document.createTextNode(' Вниз')); downBtn.addEventListener('click', (event) => { event.stopPropagation(); reorderRoomGridEntry(room.id, 'layout', item.id, 1); @@ -3515,7 +3958,8 @@ const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; - deleteBtn.innerHTML = ' Удалить'; + deleteBtn.appendChild(createIconElement('mdi:delete-outline')); + deleteBtn.appendChild(document.createTextNode(' Удалить')); deleteBtn.addEventListener('click', (event) => { event.stopPropagation(); deleteRoomLayoutItem(room.id, item.id); @@ -3531,7 +3975,8 @@ const tempBtn = document.createElement('button'); tempBtn.type = 'button'; tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; - tempBtn.innerHTML = ' Выбрать датчик температуры'; + tempBtn.appendChild(createIconElement('mdi:thermometer')); + tempBtn.appendChild(document.createTextNode(' Выбрать датчик температуры')); tempBtn.addEventListener('click', (event) => { event.stopPropagation(); openTemperatureSensorPopup(room.id); @@ -3654,6 +4099,12 @@ } els.roomList.innerHTML = ''; const sortedRooms = [...(rooms || [])].sort((left, right) => { + const leftVisible = left?.visible === false ? 1 : 0; + const rightVisible = right?.visible === false ? 1 : 0; + if (leftVisible !== rightVisible) { + return leftVisible - rightVisible; + } + if (left.id === 'main') return -1; if (right.id === 'main') return 1; @@ -3841,7 +4292,8 @@ const addButton = document.createElement('button'); addButton.type = 'button'; addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; - addButton.innerHTML = 'Пустая карточка'; + addButton.appendChild(createIconElement('mdi:plus')); + addButton.appendChild(document.createElement('span')).textContent = 'Пустая карточка'; addButton.addEventListener('click', () => { createRoomLayoutItem(room.id); }); @@ -3849,7 +4301,8 @@ const temperatureButton = document.createElement('button'); temperatureButton.type = 'button'; temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; - temperatureButton.innerHTML = 'Выбрать датчик температуры'; + temperatureButton.appendChild(createIconElement('mdi:thermometer')); + temperatureButton.appendChild(document.createElement('span')).textContent = 'Выбрать датчик температуры'; temperatureButton.addEventListener('click', () => { openTemperatureSensorPopup(room.id); }); @@ -3991,6 +4444,10 @@ } function renderPopup(snapshot) { + if (isHaRuntime()) { + hidePopup({ preserveSnapshot: true }); + return; + } if (isMobileViewport()) { hidePopup({ preserveSnapshot: true }); return; @@ -4123,6 +4580,9 @@ } async function showDebugPopup() { + if (isHaRuntime()) { + return; + } try { const response = await apiPost('popup', { command: 'open' }); const snapshot = state.snapshot || bootstrap; @@ -4266,53 +4726,25 @@ return; } - const renderSignature = JSON.stringify([ - snapshot?.selected_room?.id || snapshot?.selected_space?.id || 'main', - Array.isArray(snapshot?.rooms) ? snapshot.rooms.length : Array.isArray(snapshot?.spaces) ? snapshot.spaces.length : 0, - Array.isArray(snapshot?.main_entities) ? snapshot.main_entities.length : 0, - Boolean(snapshot?.popup?.active), - Boolean(snapshot?.ui?.mode === 'ha-native'), - ]); + const renderSignature = buildRenderSignature(snapshot); + if (renderSignature === state.lastRenderSignature) { + return; + } + state.lastRenderSignature = renderSignature; if (renderSignature !== state.debugLastRenderSignature) { state.debugLastRenderSignature = renderSignature; debugLog('render()', snapshotSummary(snapshot)); } - syncLayoutState(); - renderDashboard(snapshot); - renderSelectedRoom(snapshot); - renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + renderSidebarSection(snapshot); + renderContentSection(snapshot); renderPopup(snapshot); renderEntityPopup(snapshot); renderTemperatureSensorPopup(snapshot); - - const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - if (els.roomsCount) { - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - } - if (els.editModeToggle) { - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; - } } function renderDashboardOnly() { - const snapshot = state.snapshot || bootstrap; - if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; - syncLayoutState(); - renderSelectedRoom(snapshot); - renderDashboard(snapshot); - renderPopup(snapshot); - renderEntityPopup(snapshot); - renderTemperatureSensorPopup(snapshot); - const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - if (els.roomsCount) { - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - } - if (els.editModeToggle) { - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; - } + renderContentSection(state.snapshot || bootstrap); } function refreshCurrentRoomLayout(entityId) { @@ -4357,24 +4789,11 @@ } function renderSidebarOnly() { - const snapshot = state.snapshot || bootstrap; - if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; - renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); - const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - if (els.roomsCount) { - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - } - if (els.editModeToggle) { - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; - } + renderSidebarSection(state.snapshot || bootstrap); } function renderSelectionOnly() { - const snapshot = state.snapshot || bootstrap; - if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; - syncLayoutState(); - renderSelectedRoom(snapshot); + renderContentSection(state.snapshot || bootstrap); } async function handleEntityAction(entity, command) { @@ -4586,32 +5005,34 @@ renderSelectionOnly(); }); - bind(els.cameraBackdrop, 'click', (event) => { - if (event.target === els.cameraBackdrop) { - apiPost('popup', { command: 'close' }).catch(() => {}); - hidePopup({ suppressAutoOpen: true }); - } - }); + if (!isHaRuntime()) { + bind(els.cameraBackdrop, 'click', (event) => { + if (event.target === els.cameraBackdrop) { + apiPost('popup', { command: 'close' }).catch(() => {}); + hidePopup({ suppressAutoOpen: true }); + } + }); - bind(els.cameraModalPanel, 'click', (event) => { - event.stopPropagation(); - }); - - const closeCameraPopup = async (event) => { - if (event) { - event.preventDefault(); + bind(els.cameraModalPanel, 'click', (event) => { event.stopPropagation(); - } - try { - await apiPost('popup', { command: 'close' }); - } catch (error) { - console.warn(error); - } - hidePopup({ suppressAutoOpen: true }); - }; + }); - bind(els.cameraClose, 'pointerdown', closeCameraPopup); - bind(els.cameraClose, 'click', closeCameraPopup); + const closeCameraPopup = async (event) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + try { + await apiPost('popup', { command: 'close' }); + } catch (error) { + console.warn(error); + } + hidePopup({ suppressAutoOpen: true }); + }; + + bind(els.cameraClose, 'pointerdown', closeCameraPopup); + bind(els.cameraClose, 'click', closeCameraPopup); + } els.entityBackdrop?.addEventListener('click', (event) => { if (event.target === els.entityBackdrop) { @@ -4975,7 +5396,7 @@ mode: bootstrap?.ui?.mode || 'unknown', }); initRefs(); - state.embedMode = detectEmbeddedContext(); + state.embedMode = detectEmbeddedContext() || isHaRuntime(); syncLayoutState(); syncViewportState(); bindPressFeedback(); diff --git a/custom_components/wall_panel/__init__.py b/custom_components/wall_panel/__init__.py index d931c09..0502e5f 100755 --- a/custom_components/wall_panel/__init__.py +++ b/custom_components/wall_panel/__init__.py @@ -6,6 +6,8 @@ import logging from homeassistant.core import HomeAssistant +from .const import DEFAULT_DASHBOARD_URL_PATH +from .const import DEFAULT_FRONTEND_URL_PATH from .const import DOMAIN from .frontend import async_setup_frontend from .helpers import current_entry_config @@ -53,8 +55,16 @@ async def async_unload_entry(hass: HomeAssistant, entry) -> bool: from homeassistant.components.frontend import async_remove_panel state = hass.data.get(DOMAIN, {}) - panel_url_path = str(state.get(entry.entry_id, {}).get("panel_url_path") or entry.options.get("frontend_url_path", "wall-panel") or "wall-panel").strip() + panel_url_path = str(state.get(entry.entry_id, {}).get("panel_url_path") or DEFAULT_FRONTEND_URL_PATH).strip() + dashboard_url_path = str(state.get(entry.entry_id, {}).get("dashboard_url_path") or DEFAULT_DASHBOARD_URL_PATH).strip() async_remove_panel(hass, panel_url_path) + async_remove_panel(hass, dashboard_url_path) + lovelace = hass.data.get("lovelace") + dashboards = getattr(lovelace, "dashboards", None) + if dashboards is None and isinstance(lovelace, dict): + dashboards = lovelace.get("dashboards") + if dashboards is not None: + dashboards.pop(dashboard_url_path, None) state.pop(entry.entry_id, None) return True diff --git a/custom_components/wall_panel/assets/app.css b/custom_components/wall_panel/assets/app.css index 786893a..d6dd74c 100755 --- a/custom_components/wall_panel/assets/app.css +++ b/custom_components/wall_panel/assets/app.css @@ -49,6 +49,11 @@ body.is-embedded { overflow: auto; } +body.is-ha-native #camera-modal, +body.is-ha-native .camera-modal { + display: none !important; +} + button, input, select, @@ -261,7 +266,7 @@ textarea { .room-list__group { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } @@ -293,7 +298,8 @@ textarea { height: 42px; } -.app-shell--embed .room-item__icon i { +.app-shell--embed .room-item__icon i, +.app-shell--embed .room-item__icon ha-icon { font-size: 32px; } @@ -375,6 +381,17 @@ textarea { justify-content: center; } +.room-item__icon ha-icon, +.grid-card__icon ha-icon, +.mushroom-button__icon ha-icon, +.icon-node--ha { + width: 100%; + height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; +} + .icon-node__img { width: 100%; height: 100%; @@ -509,6 +526,10 @@ textarea { padding: 22px 16px 20px; } +.app-shell.is-ha-native .content-top { + margin-top: 0; +} + .content-top { display: none; margin-bottom: 16px; diff --git a/custom_components/wall_panel/assets/app.js b/custom_components/wall_panel/assets/app.js index 01fb85d..58e0ac4 100755 --- a/custom_components/wall_panel/assets/app.js +++ b/custom_components/wall_panel/assets/app.js @@ -3,7 +3,7 @@ const MOBILE_BREAKPOINT = 920; const state = { snapshot: bootstrap, - embedMode: Boolean(bootstrap?.ui?.embed), + embedMode: Boolean(bootstrap?.ui?.embed || window.WALL_PANEL_HA_MODE), selectedRoomId: 'main', isMobileViewport: false, mobileView: 'spaces', @@ -43,10 +43,41 @@ roomSelectionToken: 0, snapshotPollTimer: null, haSnapshotListenerInstalled: false, + debugLastRenderSignature: '', + lastRenderSignature: '', + lastSidebarRenderSignature: '', + lastContentRenderSignature: '', }; const els = {}; const client = window.StrikerPanelClient || (window.StrikerPanelClient = {}); + let renderFrame = null; + const debugEnabled = (() => { + try { + if (window.StrikerPanelDebug) return true; + const search = new URLSearchParams(window.location.search); + if (['1', 'true', 'yes', 'on'].includes(String(search.get('wp_debug') || '').toLowerCase())) { + return true; + } + const stored = window.localStorage?.getItem('striker-panel-debug'); + if (['1', 'true', 'yes', 'on'].includes(String(stored || '').toLowerCase())) { + return true; + } + } catch (error) { + return Boolean(window.StrikerPanelDebug); + } + return false; + })(); + const debugLog = (...args) => { + if (debugEnabled) { + console.log('[Striker Panel]', ...args); + } + }; + const debugWarn = (...args) => { + if (debugEnabled) { + console.warn('[Striker Panel]', ...args); + } + }; client.renderFromSnapshot = (snapshot) => { if (!snapshot || typeof snapshot !== 'object') { @@ -54,14 +85,20 @@ } state.snapshot = snapshot; initRefs(); - state.embedMode = detectEmbeddedContext(); + state.embedMode = detectEmbeddedContext() || isHaRuntime(); syncLayoutState(); - render(); + if (renderFrame) { + return; + } + renderFrame = window.requestAnimationFrame(() => { + renderFrame = null; + render(); + }); }; client.refresh = () => { initRefs(); - state.embedMode = detectEmbeddedContext(); + state.embedMode = detectEmbeddedContext() || isHaRuntime(); syncLayoutState(); render(); }; @@ -91,11 +128,22 @@ function isHaRuntime() { return Boolean( + window.WALL_PANEL_HA_MODE || haBridge() || bootstrap?.ui?.mode === 'ha-native' ); } + async function waitForHaBridge(timeoutMs = 1000) { + const startedAt = Date.now(); + let bridge = haBridge(); + while (!bridge && (Date.now() - startedAt) < timeoutMs) { + await sleep(50); + bridge = haBridge(); + } + return bridge; + } + function sleep(ms) { return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); } @@ -115,9 +163,294 @@ return false; } + function snapshotSummary(snapshot) { + const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || null; + const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : []; + const mainEntities = Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : []; + const selectedEntities = Array.isArray(selectedRoom?.entities) ? selectedRoom.entities : []; + const popup = snapshot?.popup || {}; + return { + mode: snapshot?.ui?.mode || 'unknown', + selected_room_id: selectedRoom?.id || null, + selected_room_name: selectedRoom?.name || null, + rooms: rooms.length, + main_entities: mainEntities.length, + selected_room_entities: selectedEntities.length, + popup_active: Boolean(popup.active), + popup_sensor: popup.sensor_entity_id || null, + }; + } + + function snapshotEntityToken(snapshot, entityId) { + if (!snapshot || !entityId) { + return null; + } + + const collections = [ + snapshot.main_entities, + snapshot.selected_space?.entities, + snapshot.selected_room?.entities, + ]; + if (snapshot.entity_index && typeof snapshot.entity_index === 'object') { + collections.push(Object.values(snapshot.entity_index)); + } + if (snapshot.space_index && typeof snapshot.space_index === 'object') { + Object.values(snapshot.space_index).forEach((room) => { + if (room?.entities) { + collections.push(room.entities); + } + }); + } + if (snapshot.space_entities && typeof snapshot.space_entities === 'object') { + Object.values(snapshot.space_entities).forEach((entities) => collections.push(entities)); + } + if (snapshot.battery_room?.entities) { + collections.push(snapshot.battery_room.entities); + } + + for (const collection of collections) { + if (!Array.isArray(collection)) continue; + const found = collection.find((entity) => entity && entity.entity_id === entityId); + if (found) { + const attr = found.attributes || {}; + return [ + String(found.entity_id || ''), + String(found.state ?? ''), + String(found.last_changed || found.last_updated || ''), + String(attr.current_temperature ?? attr.temperature ?? ''), + String(attr.current_position ?? ''), + String(attr.hvac_action ?? ''), + ]; + } + } + return null; + } + + function entityRenderToken(entity) { + const attr = entity?.attributes || {}; + return [ + String(entity?.entity_id || ''), + String(entity?.state ?? ''), + String(entity?.order ?? 9999), + entity?.visible === false ? '0' : '1', + String(entity?.domain || ''), + String(entity?.card_type || ''), + String(entity?.subtitle || ''), + String(entity?.icon || ''), + String(attr.current_temperature ?? attr.temperature ?? ''), + String(attr.current_position ?? ''), + String(attr.hvac_action ?? ''), + ]; + } + + function roomRenderToken(room) { + return [ + String(room?.id || ''), + String(room?.name || ''), + String(room?.icon || ''), + room?.visible === false ? '0' : '1', + String(room?.order ?? 9999), + String(room?.entity_count ?? 0), + String(room?.active_entity_count ?? 0), + String(room?.temperature_badge || ''), + String(room?.battery_summary_text || ''), + room?.virtual ? '1' : '0', + ]; + } + + function selectedRoomRenderToken(snapshot) { + const room = snapshot?.selected_room || snapshot?.selected_space || {}; + if (!room?.id) { + return ['unknown']; + } + + if (room.id === 'main') { + const boiler = snapshot?.settings?.main_boiler || {}; + const printConfig = snapshot?.settings?.main_print || {}; + const weatherActions = Array.isArray(snapshot?.settings?.main_weather_actions) + ? snapshot.settings.main_weather_actions + : []; + return [ + 'main', + room.name || '', + (Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : []).map(entityRenderToken), + snapshotEntityToken(snapshot, boiler.sensor_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.current_stage_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.print_progress_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.start_time_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.end_time_entity_id || ''), + weatherActions.map((action) => [ + String(action?.entity_id || ''), + String(action?.state_entity_id || ''), + String(action?.command || ''), + String(action?.value ?? ''), + String(action?.active_value ?? ''), + String(action?.label_active || ''), + String(action?.label_inactive || ''), + String(mainWeatherActionIsActive(snapshot, action) ? '1' : '0'), + ]), + snapshot?.weather ? [ + String(snapshot.weather.entity_id || ''), + String(snapshot.weather.state ?? ''), + String(snapshot.weather.temperature ?? ''), + String(snapshot.weather.sensor_temperature ?? ''), + String(snapshot.weather.wind_speed ?? ''), + String(snapshot.weather.condition ?? ''), + ] : null, + ]; + } + + if (room.id === 'batteries') { + return [ + 'batteries', + room.name || '', + room.battery_summary_text || '', + Number(room.entity_count ?? 0) || 0, + Number(room.problem_count ?? room.active_entity_count ?? 0) || 0, + (Array.isArray(room.entities) ? room.entities : []).map((item) => [ + String(item?.entity_id || ''), + String(item?.battery_status || ''), + String(item?.battery_percent_text || ''), + String(item?.forecast_minutes_left ?? ''), + String(item?.forecast_text || ''), + String(item?.source_text || ''), + ]), + ]; + } + + return [ + room.id, + room.name || '', + room.icon || '', + room.visible === false ? '0' : '1', + String(room.order ?? 9999), + String(room.temperature_badge || ''), + roomGridEntries(snapshot, room.id).map((entry) => ( + entry.kind === 'layout' + ? ['layout', String(entry.id || ''), String(entry.order ?? 9999), String(entry.payload?.type || 'ghost')] + : ['entity', ...entityRenderToken(entry.payload)] + )), + state.editMode + ? roomEntitiesIncludingHidden(snapshot, room.id) + .filter((entity) => entity.visible === false) + .map(entityRenderToken) + : [], + ]; + } + + function buildVisibleSnapshotSignature(snapshot) { + const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : []; + const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || {}; + const batteryRoom = snapshot?.battery_room || null; + return JSON.stringify([ + String(snapshot?.ui?.mode || 'unknown'), + String(selectedRoom?.id || 'main'), + rooms.map(roomRenderToken), + batteryRoom ? roomRenderToken(batteryRoom) : null, + selectedRoomRenderToken(snapshot), + ]); + } + + function buildSidebarRenderSignature(snapshot) { + const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : []; + const batteryRoom = snapshot?.battery_room || null; + return JSON.stringify([ + String(state.selectedRoomId || 'main'), + String(state.editMode ? '1' : '0'), + String(state.mobileView || 'spaces'), + String(isMobileViewport() ? '1' : '0'), + rooms.map(roomRenderToken), + batteryRoom ? roomRenderToken(batteryRoom) : null, + ]); + } + + function buildContentRenderSignature(snapshot) { + const room = snapshot?.selected_room || snapshot?.selected_space || {}; + return JSON.stringify([ + String(state.selectedRoomId || room.id || 'main'), + String(room.id || 'main'), + String(state.editMode ? '1' : '0'), + String(state.mobileView || 'spaces'), + String(isMobileViewport() ? '1' : '0'), + selectedRoomRenderToken(snapshot), + ]); + } + + function buildRenderSignature(snapshot) { + const history = state.mainBoilerHistory || {}; + return JSON.stringify([ + buildVisibleSnapshotSignature(snapshot), + String(state.selectedRoomId || 'main'), + String(state.editMode ? '1' : '0'), + String(state.mobileView || 'spaces'), + String(isMobileViewport() ? '1' : '0'), + String(history.entityId || ''), + String(history.loadedAt || 0), + String(Array.isArray(history.points) ? history.points.length : 0), + String(history.loading ? '1' : '0'), + String(history.error || ''), + String(state.entityPopup?.active ? '1' : '0'), + String(state.entityPopup?.entityId || ''), + String(state.temperatureSensorPopup?.active ? '1' : '0'), + String(state.temperatureSensorPopup?.roomId || ''), + String(state.lastPopupSignature || ''), + String(state.lastEntityPopupSignature || ''), + String(state.lastTemperatureSensorPopupSignature || ''), + ]); + } + + function renderSidebarSection(snapshot) { + const nextSignature = buildSidebarRenderSignature(snapshot); + if (nextSignature === state.lastSidebarRenderSignature) { + return false; + } + state.lastSidebarRenderSignature = nextSignature; + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); + if (els.roomsCount) { + els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; + } + if (els.editModeToggle) { + els.editModeToggle.classList.toggle('is-active', state.editMode); + els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + } + return true; + } + + function renderContentSection(snapshot) { + const nextSignature = buildContentRenderSignature(snapshot); + if (nextSignature === state.lastContentRenderSignature) { + return false; + } + state.lastContentRenderSignature = nextSignature; + syncLayoutState(); + renderDashboard(snapshot); + renderSelectedRoom(snapshot); + return true; + } + + function requestSummary(action, params = {}) { + const summary = { action }; + ['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => { + if (params[key] !== undefined && params[key] !== null && params[key] !== '') { + summary[key] = params[key]; + } + }); + if (params.payload && typeof params.payload === 'object') { + summary.payload_keys = Object.keys(params.payload); + } + return summary; + } + async function resolveInitialSnapshot() { const bootstrapSnapshot = window.APP_BOOTSTRAP || bootstrap || {}; + debugLog('resolveInitialSnapshot()', { + ha_runtime: isHaRuntime(), + bridge_ready: Boolean(haBridge()), + bootstrap_ready: snapshotLooksReady(bootstrapSnapshot), + }); if (!isHaRuntime()) { + debugLog('resolveInitialSnapshot -> bootstrap (standalone)', snapshotSummary(bootstrapSnapshot)); return bootstrapSnapshot; } @@ -134,30 +467,35 @@ const tryBridgeSnapshot = async () => { const bridge = await waitForHaBridge(); if (!bridge) { + debugLog('HA bridge not ready yet'); return null; } const roomId = state.selectedRoomId || 'main'; try { if (typeof bridge.getSnapshot === 'function') { + debugLog('request initial snapshot via bridge.getSnapshot()', { room_id: roomId }); const snapshot = await bridge.getSnapshot(roomId); if (snapshotLooksReady(snapshot)) { + debugLog('initial snapshot received via bridge.getSnapshot()', snapshotSummary(snapshot)); return snapshot; } } } catch (error) { - console.warn(error); + debugWarn('initial snapshot bridge.getSnapshot() failed', error); } try { if (typeof bridge.request === 'function') { + debugLog('request initial snapshot via bridge.request(GET snapshot)', { room_id: roomId }); const snapshot = await bridge.request('GET', 'snapshot', { space_id: roomId }); if (snapshotLooksReady(snapshot)) { + debugLog('initial snapshot received via bridge.request(GET snapshot)', snapshotSummary(snapshot)); return snapshot; } } } catch (error) { - console.warn(error); + debugWarn('initial snapshot bridge.request(GET snapshot) failed', error); } return null; @@ -175,6 +513,7 @@ return secondPass; } + debugLog('resolveInitialSnapshot -> fallback bootstrap', snapshotSummary(bootstrapSnapshot)); return snapshotLooksReady(bootstrapSnapshot) ? bootstrapSnapshot : bootstrapSnapshot; } @@ -239,6 +578,9 @@ } function detectEmbeddedContext() { + if (isHaRuntime()) { + return true; + } if (Boolean(bootstrap?.ui?.embed)) { return true; } @@ -308,20 +650,160 @@ return svg; } - function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { + const iconTemplateCache = new Map(); + const iconTemplatePromiseCache = new Map(); + const customBrandIconsUrl = 'https://home.striker72rus.ru/local/community/custom-brand-icons/custom-brand-icons.js'; + let customBrandIconsPromise = null; + + function templateFromSvgText(svgText) { + const template = document.createElement('template'); + template.innerHTML = String(svgText || '').trim(); + return template; + } + + function iconUrl(source) { + return `https://api.iconify.design/${source}.svg`; + } + + function ensureCustomBrandIconsLoaded() { + if (window.customIcons || window.customIconsets) { + return Promise.resolve(true); + } + if (customBrandIconsPromise) { + return customBrandIconsPromise; + } + + const existing = document.querySelector('script[data-wall-panel-custom-brand-icons="1"]'); + if (existing) { + customBrandIconsPromise = new Promise((resolve, reject) => { + if (window.customIcons || window.customIconsets) { + resolve(true); + return; + } + existing.addEventListener('load', () => resolve(true), { once: true }); + existing.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true }); + }).finally(() => { + customBrandIconsPromise = null; + }); + return customBrandIconsPromise; + } + + const script = document.createElement('script'); + script.src = customBrandIconsUrl; + script.defer = true; + script.async = true; + script.dataset.wallPanelCustomBrandIcons = '1'; + customBrandIconsPromise = new Promise((resolve, reject) => { + script.addEventListener('load', () => resolve(true), { once: true }); + script.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true }); + }).finally(() => { + customBrandIconsPromise = null; + }); + (document.head || document.documentElement).appendChild(script); + return customBrandIconsPromise; + } + + function applyTemplateToNode(target, template) { + if (!target || !template || !target.isConnected) { + return; + } + target.replaceChildren(template.content.cloneNode(true)); + } + + function primeRemoteIconTemplate(source) { + const key = `remote:${source}`; + if (iconTemplateCache.has(key)) { + return Promise.resolve(iconTemplateCache.get(key)); + } + if (iconTemplatePromiseCache.has(key)) { + return iconTemplatePromiseCache.get(key); + } + + const promise = fetch(iconUrl(source), { + cache: 'force-cache', + credentials: 'omit', + headers: { Accept: 'image/svg+xml' }, + }).then((res) => { + if (!res.ok) { + throw new Error(`Icon load failed: ${res.status}`); + } + return res.text(); + }).then((svgText) => { + const template = templateFromSvgText(svgText); + iconTemplateCache.set(key, template); + return template; + }).finally(() => { + iconTemplatePromiseCache.delete(key); + }); + + iconTemplatePromiseCache.set(key, promise); + return promise; + } + + function primeCustomIconTemplate(source) { + const key = `custom:${source}`; + if (iconTemplateCache.has(key)) { + return Promise.resolve(iconTemplateCache.get(key)); + } + if (iconTemplatePromiseCache.has(key)) { + return iconTemplatePromiseCache.get(key); + } + const [prefix, name] = source.split(':', 2); const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix]; const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon; if (typeof getIcon !== 'function') { - return null; + return Promise.resolve(null); + } + + const promise = Promise.resolve(getIcon(name)).then((definition) => { + if (!definition) { + return null; + } + const template = document.createElement('template'); + template.content.appendChild(createSvgIcon(definition)); + iconTemplateCache.set(key, template); + return template; + }).finally(() => { + iconTemplatePromiseCache.delete(key); + }); + + iconTemplatePromiseCache.set(key, promise); + return promise; + } + + function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { + const cached = iconTemplateCache.get(`custom:${source}`); + if (cached) { + return cached.content.cloneNode(true); + } + + const [prefix] = source.split(':', 2); + const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix]; + const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon; + if (typeof getIcon !== 'function') { + if (!isHaRuntime()) { + return null; + } + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + wrap.appendChild(createIconElement(fallback)); + ensureCustomBrandIconsLoaded().then(() => primeCustomIconTemplate(source)).then((template) => { + if (!template) return; + applyTemplateToNode(wrap, template); + }).catch(() => { + if (!wrap.isConnected) return; + wrap.replaceChildren(createIconElement(fallback)); + }); + return wrap; } const wrap = document.createElement('span'); wrap.className = 'icon-node'; wrap.appendChild(createIconElement(fallback)); - Promise.resolve(getIcon(name)).then((definition) => { - if (!definition || !wrap.isConnected) return; - wrap.replaceChildren(createSvgIcon(definition)); + primeCustomIconTemplate(source).then((template) => { + if (!template) return; + applyTemplateToNode(wrap, template); }).catch(() => { if (!wrap.isConnected) return; wrap.replaceChildren(createIconElement(fallback)); @@ -333,77 +815,71 @@ const source = normalizeIconSource(icon) || fallback; if (source.startsWith('mdi:')) { + if (isHaRuntime()) { + const haIcon = document.createElement('ha-icon'); + haIcon.setAttribute('icon', source); + haIcon.className = 'icon-node icon-node--ha'; + return haIcon; + } const i = document.createElement('i'); i.className = iconClass(source); + i.setAttribute('aria-hidden', 'true'); return i; } - if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) { - const custom = createCustomIconElement(source, fallback); - if (custom) { - return custom; - } - const mappedSource = source.startsWith('fas:') - ? source.replace(/^fas:/, 'fa-solid:') - : source.startsWith('far:') - ? source.replace(/^far:/, 'fa-regular:') - : source.replace(/^fab:/, 'fa-brands:'); - const wrap = document.createElement('span'); - wrap.className = 'icon-node'; - wrap.appendChild(createIconElement(fallback)); - const img = document.createElement('img'); - img.className = 'icon-node__img'; - img.alt = ''; - img.decoding = 'async'; - img.loading = 'lazy'; - img.referrerPolicy = 'no-referrer'; - img.src = `https://api.iconify.design/${mappedSource}.svg`; - img.addEventListener('load', () => { - if (!img.isConnected || !wrap.isConnected) return; - wrap.replaceChildren(img); - }); - img.addEventListener('error', () => { - if (img.dataset.fallbackApplied === '1') return; - img.dataset.fallbackApplied = '1'; - wrap.replaceChildren(createIconElement(fallback)); - }); - wrap.appendChild(img); - return wrap; - } - const custom = createCustomIconElement(source, fallback); if (custom) { return custom; } + if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) { + const mappedSource = source.startsWith('fas:') + ? source.replace(/^fas:/, 'fa-solid:') + : source.startsWith('far:') + ? source.replace(/^far:/, 'fa-regular:') + : source.replace(/^fab:/, 'fa-brands:'); + const cached = iconTemplateCache.get(`remote:${mappedSource}`); + if (cached) { + return cached.content.cloneNode(true); + } + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + wrap.appendChild(createIconElement(fallback)); + primeRemoteIconTemplate(mappedSource).then((template) => { + applyTemplateToNode(wrap, template); + }).catch(() => { + if (!wrap.isConnected) return; + wrap.replaceChildren(createIconElement(fallback)); + }); + return wrap; + } + + const remoteCached = iconTemplateCache.get(`remote:${source}`); + if (remoteCached) { + return remoteCached.content.cloneNode(true); + } + const wrap = document.createElement('span'); wrap.className = 'icon-node'; wrap.appendChild(createIconElement(fallback)); if (source.includes(':')) { - const img = document.createElement('img'); - img.className = 'icon-node__img'; - img.alt = ''; - img.decoding = 'async'; - img.loading = 'lazy'; - img.referrerPolicy = 'no-referrer'; - img.src = `https://api.iconify.design/${source}.svg`; - img.addEventListener('load', () => { - if (!img.isConnected || !wrap.isConnected) return; - wrap.replaceChildren(img); - }); - img.addEventListener('error', () => { - if (img.dataset.fallbackApplied === '1') return; - img.dataset.fallbackApplied = '1'; + primeRemoteIconTemplate(source).then((template) => { + applyTemplateToNode(wrap, template); + }).catch(() => { + if (!wrap.isConnected) return; wrap.replaceChildren(createIconElement(fallback)); }); - wrap.appendChild(img); return wrap; } return createIconElement(fallback); } + if (isHaRuntime()) { + void ensureCustomBrandIconsLoaded(); + } + function esc(value) { return String(value ?? ''); } @@ -480,6 +956,9 @@ } function popupTriggerEntities() { + if (isHaRuntime()) { + return new Set(); + } const fromSnapshot = state.snapshot?.settings?.camera?.trigger_entities; const fromBootstrap = bootstrap?.settings?.camera?.trigger_entities; const triggers = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : []; @@ -599,8 +1078,18 @@ async function apiGet(action, params = {}) { const bridge = haBridge(); if (bridge?.request) { + debugLog('apiGet via bridge', requestSummary(action, params)); return bridge.request('GET', action, params); } + if (isHaRuntime()) { + const waited = await waitForHaBridge(1000); + if (waited?.request) { + debugLog('apiGet via delayed bridge', requestSummary(action, params)); + return waited.request('GET', action, params); + } + throw new Error('HA bridge is not ready'); + } + debugLog('apiGet via http', requestSummary(action, params)); const res = await fetch(buildUrl(action, params), { headers: { Accept: 'application/json' }, cache: 'no-store', @@ -614,8 +1103,18 @@ async function apiPost(action, payload = {}) { const bridge = haBridge(); if (bridge?.request) { + debugLog('apiPost via bridge', requestSummary(action, payload)); return bridge.request('POST', action, payload); } + if (isHaRuntime()) { + const waited = await waitForHaBridge(1000); + if (waited?.request) { + debugLog('apiPost via delayed bridge', requestSummary(action, payload)); + return waited.request('POST', action, payload); + } + throw new Error('HA bridge is not ready'); + } + debugLog('apiPost via http', requestSummary(action, payload)); const res = await fetch(buildUrl(action), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, @@ -1109,12 +1608,16 @@ if (!els.appShell) return; const mobile = isMobileViewport(); - const embedded = Boolean(state.embedMode); + const haNative = isHaRuntime(); + const embedded = Boolean(state.embedMode || haNative); + state.embedMode = embedded; document.body.classList.toggle('is-mobile-ui', mobile); document.body.classList.toggle('is-embedded', embedded); + document.body.classList.toggle('is-ha-native', haNative); els.appShell.classList.toggle('is-mobile', mobile); els.appShell.classList.toggle('is-desktop', !mobile); els.appShell.classList.toggle('app-shell--embed', embedded); + els.appShell.classList.toggle('is-ha-native', haNative); els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room'); els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room'); @@ -1791,6 +2294,9 @@ } function applyPopupState(active, sensorEntityId) { + if (isHaRuntime()) { + return; + } const camera = state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; const popup = state.snapshot?.popup || {}; if (active && Date.now() < Number(state.popupAutoOpenBlockedUntil || 0)) { @@ -1814,6 +2320,9 @@ } function applyPopupSnapshot(popup = {}) { + if (isHaRuntime()) { + return; + } const snapshot = state.snapshot || bootstrap; snapshot.popup = mergePopupWithCamera({ ...(snapshot.popup || {}), @@ -1823,6 +2332,9 @@ } function syncTriggerPopup(entityId, stateValue) { + if (isHaRuntime()) { + return; + } const value = String(stateValue || '').toLowerCase(); if (!['on', 'off'].includes(value)) { return; @@ -2796,13 +3308,13 @@ const minus = document.createElement('button'); minus.type = 'button'; minus.className = 'round-button entity-modal__round-button'; - minus.innerHTML = ''; + minus.appendChild(createIconElement('mdi:minus')); minus.addEventListener('click', () => handleClimateTemperature(entity, -1)); const plus = document.createElement('button'); plus.type = 'button'; plus.className = 'round-button entity-modal__round-button'; - plus.innerHTML = ''; + plus.appendChild(createIconElement('mdi:plus')); plus.addEventListener('click', () => handleClimateTemperature(entity, 1)); controls.append(minus, plus); @@ -3275,13 +3787,15 @@ const upBtn = document.createElement('button'); upBtn.type = 'button'; upBtn.className = 'mushroom-button mushroom-button--small'; - upBtn.innerHTML = ' Вверх'; + upBtn.appendChild(createIconElement('mdi:arrow-up')); + upBtn.appendChild(document.createTextNode(' Вверх')); upBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, -1)); const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.className = 'mushroom-button mushroom-button--small'; - downBtn.innerHTML = ' Вниз'; + downBtn.appendChild(createIconElement('mdi:arrow-down')); + downBtn.appendChild(document.createTextNode(' Вниз')); downBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1)); actions.append(upBtn, downBtn); @@ -3297,7 +3811,7 @@ const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'mini-action mini-action--wide'; - btn.innerHTML = hidden ? '' : ''; + btn.appendChild(createIconElement(hidden ? 'mdi:eye' : 'mdi:eye-off')); btn.title = hidden ? 'Показать' : 'Скрыть'; btn.addEventListener('click', (event) => { event.stopPropagation(); @@ -3410,7 +3924,8 @@ const settingsBtn = document.createElement('button'); settingsBtn.type = 'button'; settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; - settingsBtn.innerHTML = ' Настройки'; + settingsBtn.appendChild(createIconElement('mdi:cog-outline')); + settingsBtn.appendChild(document.createTextNode(' Настройки')); settingsBtn.addEventListener('click', (event) => { event.stopPropagation(); state.layoutItemSettingsOpen = { @@ -3423,7 +3938,8 @@ const upBtn = document.createElement('button'); upBtn.type = 'button'; upBtn.className = 'mushroom-button mushroom-button--small'; - upBtn.innerHTML = ' Вверх'; + upBtn.appendChild(createIconElement('mdi:arrow-up')); + upBtn.appendChild(document.createTextNode(' Вверх')); upBtn.addEventListener('click', (event) => { event.stopPropagation(); reorderRoomGridEntry(room.id, 'layout', item.id, -1); @@ -3432,7 +3948,8 @@ const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.className = 'mushroom-button mushroom-button--small'; - downBtn.innerHTML = ' Вниз'; + downBtn.appendChild(createIconElement('mdi:arrow-down')); + downBtn.appendChild(document.createTextNode(' Вниз')); downBtn.addEventListener('click', (event) => { event.stopPropagation(); reorderRoomGridEntry(room.id, 'layout', item.id, 1); @@ -3441,7 +3958,8 @@ const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; - deleteBtn.innerHTML = ' Удалить'; + deleteBtn.appendChild(createIconElement('mdi:delete-outline')); + deleteBtn.appendChild(document.createTextNode(' Удалить')); deleteBtn.addEventListener('click', (event) => { event.stopPropagation(); deleteRoomLayoutItem(room.id, item.id); @@ -3457,7 +3975,8 @@ const tempBtn = document.createElement('button'); tempBtn.type = 'button'; tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; - tempBtn.innerHTML = ' Выбрать датчик температуры'; + tempBtn.appendChild(createIconElement('mdi:thermometer')); + tempBtn.appendChild(document.createTextNode(' Выбрать датчик температуры')); tempBtn.addEventListener('click', (event) => { event.stopPropagation(); openTemperatureSensorPopup(room.id); @@ -3580,6 +4099,12 @@ } els.roomList.innerHTML = ''; const sortedRooms = [...(rooms || [])].sort((left, right) => { + const leftVisible = left?.visible === false ? 1 : 0; + const rightVisible = right?.visible === false ? 1 : 0; + if (leftVisible !== rightVisible) { + return leftVisible - rightVisible; + } + if (left.id === 'main') return -1; if (right.id === 'main') return 1; @@ -3767,7 +4292,8 @@ const addButton = document.createElement('button'); addButton.type = 'button'; addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; - addButton.innerHTML = 'Пустая карточка'; + addButton.appendChild(createIconElement('mdi:plus')); + addButton.appendChild(document.createElement('span')).textContent = 'Пустая карточка'; addButton.addEventListener('click', () => { createRoomLayoutItem(room.id); }); @@ -3775,7 +4301,8 @@ const temperatureButton = document.createElement('button'); temperatureButton.type = 'button'; temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; - temperatureButton.innerHTML = 'Выбрать датчик температуры'; + temperatureButton.appendChild(createIconElement('mdi:thermometer')); + temperatureButton.appendChild(document.createElement('span')).textContent = 'Выбрать датчик температуры'; temperatureButton.addEventListener('click', () => { openTemperatureSensorPopup(room.id); }); @@ -3917,6 +4444,10 @@ } function renderPopup(snapshot) { + if (isHaRuntime()) { + hidePopup({ preserveSnapshot: true }); + return; + } if (isMobileViewport()) { hidePopup({ preserveSnapshot: true }); return; @@ -4049,6 +4580,9 @@ } async function showDebugPopup() { + if (isHaRuntime()) { + return; + } try { const response = await apiPost('popup', { command: 'open' }); const snapshot = state.snapshot || bootstrap; @@ -4192,41 +4726,25 @@ return; } - syncLayoutState(); - renderDashboard(snapshot); - renderSelectedRoom(snapshot); - renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + const renderSignature = buildRenderSignature(snapshot); + if (renderSignature === state.lastRenderSignature) { + return; + } + state.lastRenderSignature = renderSignature; + if (renderSignature !== state.debugLastRenderSignature) { + state.debugLastRenderSignature = renderSignature; + debugLog('render()', snapshotSummary(snapshot)); + } + + renderSidebarSection(snapshot); + renderContentSection(snapshot); renderPopup(snapshot); renderEntityPopup(snapshot); renderTemperatureSensorPopup(snapshot); - - const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - if (els.roomsCount) { - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - } - if (els.editModeToggle) { - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; - } } function renderDashboardOnly() { - const snapshot = state.snapshot || bootstrap; - if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; - syncLayoutState(); - renderSelectedRoom(snapshot); - renderDashboard(snapshot); - renderPopup(snapshot); - renderEntityPopup(snapshot); - renderTemperatureSensorPopup(snapshot); - const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - if (els.roomsCount) { - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - } - if (els.editModeToggle) { - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; - } + renderContentSection(state.snapshot || bootstrap); } function refreshCurrentRoomLayout(entityId) { @@ -4271,24 +4789,11 @@ } function renderSidebarOnly() { - const snapshot = state.snapshot || bootstrap; - if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; - renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); - const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - if (els.roomsCount) { - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - } - if (els.editModeToggle) { - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; - } + renderSidebarSection(state.snapshot || bootstrap); } function renderSelectionOnly() { - const snapshot = state.snapshot || bootstrap; - if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; - syncLayoutState(); - renderSelectedRoom(snapshot); + renderContentSection(state.snapshot || bootstrap); } async function handleEntityAction(entity, command) { @@ -4500,32 +5005,34 @@ renderSelectionOnly(); }); - bind(els.cameraBackdrop, 'click', (event) => { - if (event.target === els.cameraBackdrop) { - apiPost('popup', { command: 'close' }).catch(() => {}); - hidePopup({ suppressAutoOpen: true }); - } - }); + if (!isHaRuntime()) { + bind(els.cameraBackdrop, 'click', (event) => { + if (event.target === els.cameraBackdrop) { + apiPost('popup', { command: 'close' }).catch(() => {}); + hidePopup({ suppressAutoOpen: true }); + } + }); - bind(els.cameraModalPanel, 'click', (event) => { - event.stopPropagation(); - }); - - const closeCameraPopup = async (event) => { - if (event) { - event.preventDefault(); + bind(els.cameraModalPanel, 'click', (event) => { event.stopPropagation(); - } - try { - await apiPost('popup', { command: 'close' }); - } catch (error) { - console.warn(error); - } - hidePopup({ suppressAutoOpen: true }); - }; + }); - bind(els.cameraClose, 'pointerdown', closeCameraPopup); - bind(els.cameraClose, 'click', closeCameraPopup); + const closeCameraPopup = async (event) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + try { + await apiPost('popup', { command: 'close' }); + } catch (error) { + console.warn(error); + } + hidePopup({ suppressAutoOpen: true }); + }; + + bind(els.cameraClose, 'pointerdown', closeCameraPopup); + bind(els.cameraClose, 'click', closeCameraPopup); + } els.entityBackdrop?.addEventListener('click', (event) => { if (event.target === els.entityBackdrop) { @@ -4883,8 +5390,13 @@ } async function start() { + debugLog('start()', { + ha_runtime: isHaRuntime(), + embed_mode: Boolean(bootstrap?.ui?.embed), + mode: bootstrap?.ui?.mode || 'unknown', + }); initRefs(); - state.embedMode = detectEmbeddedContext(); + state.embedMode = detectEmbeddedContext() || isHaRuntime(); syncLayoutState(); syncViewportState(); bindPressFeedback(); @@ -4900,6 +5412,7 @@ if (!snapshot || typeof snapshot !== 'object') { return; } + debugLog('wall-panel-snapshot-updated', snapshotSummary(snapshot)); state.snapshot = snapshot; render(); }); @@ -4917,6 +5430,7 @@ } state.snapshot = await resolveInitialSnapshot(); + debugLog('initial snapshot applied', snapshotSummary(state.snapshot || bootstrap)); render(); connectRealtime(); if (!state.snapshotPollTimer) { diff --git a/custom_components/wall_panel/config_flow.py b/custom_components/wall_panel/config_flow.py index 71d292c..a4d2f56 100755 --- a/custom_components/wall_panel/config_flow.py +++ b/custom_components/wall_panel/config_flow.py @@ -13,12 +13,10 @@ from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, Tex from .const import ( CONF_CONFIG, - CONF_FRONTEND_URL_PATH, CONF_REQUIRE_ADMIN, CONF_SIDEBAR_ICON, CONF_SIDEBAR_TITLE, CONF_SYNC_TOKEN, - DEFAULT_FRONTEND_URL_PATH, DEFAULT_SIDEBAR_ICON, DEFAULT_SIDEBAR_TITLE, DOMAIN, @@ -31,7 +29,6 @@ def _schema(defaults: dict[str, Any]) -> vol.Schema: vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, "Striker Panel")): str, vol.Optional(CONF_SIDEBAR_TITLE, default=defaults.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE)): str, vol.Optional(CONF_SIDEBAR_ICON, default=defaults.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON)): str, - vol.Optional(CONF_FRONTEND_URL_PATH, default=defaults.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH)): str, vol.Optional(CONF_REQUIRE_ADMIN, default=bool(defaults.get(CONF_REQUIRE_ADMIN, False))): bool, vol.Optional( CONF_CONFIG, @@ -60,7 +57,6 @@ class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_NAME: user_input.get(CONF_NAME, "Striker Panel"), CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE), CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON), - CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH), CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)), CONF_SYNC_TOKEN: secrets.token_urlsafe(24), CONF_CONFIG: config, @@ -73,7 +69,6 @@ class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_NAME: "Striker Panel", CONF_SIDEBAR_TITLE: DEFAULT_SIDEBAR_TITLE, CONF_SIDEBAR_ICON: DEFAULT_SIDEBAR_ICON, - CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH, CONF_REQUIRE_ADMIN: False, CONF_CONFIG: config_to_json(normalize_config({})), } @@ -102,7 +97,6 @@ class WallPanelOptionsFlow(config_entries.OptionsFlow): CONF_NAME: user_input.get(CONF_NAME, data.get(CONF_NAME, "Striker Panel")), CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE), CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON), - CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH), CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)), CONF_SYNC_TOKEN: data.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)), CONF_CONFIG: config, @@ -113,7 +107,6 @@ class WallPanelOptionsFlow(config_entries.OptionsFlow): CONF_NAME: self.config_entry.options.get(CONF_NAME, "Striker Panel"), CONF_SIDEBAR_TITLE: self.config_entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE), CONF_SIDEBAR_ICON: self.config_entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON), - CONF_FRONTEND_URL_PATH: self.config_entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH), CONF_REQUIRE_ADMIN: self.config_entry.options.get(CONF_REQUIRE_ADMIN, False), CONF_CONFIG: config_to_json(current_entry_config(self.config_entry)), } diff --git a/custom_components/wall_panel/const.py b/custom_components/wall_panel/const.py index f0b8082..1995724 100755 --- a/custom_components/wall_panel/const.py +++ b/custom_components/wall_panel/const.py @@ -15,3 +15,4 @@ DEFAULT_PANEL_URL = "" DEFAULT_SIDEBAR_TITLE = "Striker Panel" DEFAULT_SIDEBAR_ICON = "mdi:view-dashboard" DEFAULT_FRONTEND_URL_PATH = "striker-panel" +DEFAULT_DASHBOARD_URL_PATH = "wall-panel" diff --git a/custom_components/wall_panel/frontend.py b/custom_components/wall_panel/frontend.py index d0d7f82..0b15fcc 100755 --- a/custom_components/wall_panel/frontend.py +++ b/custom_components/wall_panel/frontend.py @@ -5,13 +5,14 @@ from __future__ import annotations import time from pathlib import Path +from homeassistant.components.lovelace import _register_panel +from homeassistant.components.lovelace.dashboard import LovelaceYAML from homeassistant.components.frontend import async_register_built_in_panel from homeassistant.components.http import StaticPathConfig from homeassistant.core import HomeAssistant from .const import ( - CONF_FRONTEND_URL_PATH, - CONF_PANEL_URL, + DEFAULT_DASHBOARD_URL_PATH, CONF_REQUIRE_ADMIN, CONF_SIDEBAR_ICON, CONF_SIDEBAR_TITLE, @@ -25,10 +26,38 @@ from .helpers import current_entry_config def _panel_url_path(entry) -> str: - raw = str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH).strip() - if raw in {"", "wall-panel"}: - return DEFAULT_FRONTEND_URL_PATH - return raw + # Keep the HA panel identity stable so Home Assistant can always + # discover this panel in the default-panel picker. + return DEFAULT_FRONTEND_URL_PATH + + +def _dashboard_url_path(entry) -> str: + # Keep the Lovelace dashboard identity stable and separate from the + # built-in HA panel path. + return DEFAULT_DASHBOARD_URL_PATH + + +def _dashboard_config(sidebar_title: str, sidebar_icon: str, require_admin: bool) -> dict[str, object]: + return { + "mode": "yaml", + "title": sidebar_title, + "icon": sidebar_icon, + "show_in_sidebar": False, + "require_admin": require_admin, + "filename": "custom_components/wall_panel/lovelace/ui-lovelace.yaml", + } + + +def _register_lovelace_dashboard(hass: HomeAssistant, dashboard_url_path: str, dashboard_config: dict[str, object]) -> None: + lovelace = hass.data.setdefault("lovelace", {}) + dashboards = getattr(lovelace, "dashboards", None) + if dashboards is None and isinstance(lovelace, dict): + dashboards = lovelace.setdefault("dashboards", {}) + if dashboards is None: + return + + dashboards[dashboard_url_path] = LovelaceYAML(hass, dashboard_url_path, dashboard_config) + _register_panel(hass, dashboard_url_path, "yaml", dashboard_config, False) async def async_setup_frontend(hass: HomeAssistant, entry) -> str: @@ -55,12 +84,16 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str: state["_static_paths_registered"] = True panel_url_path = _panel_url_path(entry) + dashboard_url_path = _dashboard_url_path(entry) sidebar_title = str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE).strip() sidebar_icon = str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON).strip() require_admin = bool(entry.options.get(CONF_REQUIRE_ADMIN, False)) sync_token = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip() asset_version = str(int(time.time())) runtime_config = current_entry_config(entry) + dashboard_config = _dashboard_config(sidebar_title, sidebar_icon, require_admin) + + _register_lovelace_dashboard(hass, dashboard_url_path, dashboard_config) async_register_built_in_panel( hass, @@ -85,4 +118,11 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str: update=True, ) + state = hass.data.setdefault(DOMAIN, {}) + state[entry.entry_id] = { + **state.get(entry.entry_id, {}), + "dashboard_url_path": dashboard_url_path, + "panel_url_path": panel_url_path, + } + return panel_url_path diff --git a/custom_components/wall_panel/frontend/panel.js b/custom_components/wall_panel/frontend/panel.js index ae8fc81..67448c4 100755 --- a/custom_components/wall_panel/frontend/panel.js +++ b/custom_components/wall_panel/frontend/panel.js @@ -33,6 +33,19 @@ function panelWarn(...args) { } } +function shouldTraceEntity(entityId) { + return PANEL_DEBUG; +} + +function traceEntity(entityId, label, payload) { + if (!shouldTraceEntity(entityId)) { + return; + } + console.groupCollapsed(`[Striker Panel] ${label} ${entityId}`); + console.log(payload); + console.groupEnd(); +} + function normalizeConfig(value) { const base = { app: { @@ -155,6 +168,186 @@ function previewEntityIds(items, limit = 8) { .filter(Boolean); } +function snapshotEntityLookup(snapshot, entityId) { + if (!snapshot || !entityId) { + return null; + } + + const collections = [ + snapshot.main_entities, + snapshot.selected_room?.entities, + snapshot.selected_space?.entities, + ]; + + if (snapshot.entity_index && typeof snapshot.entity_index === 'object') { + collections.push(Object.values(snapshot.entity_index)); + } + if (snapshot.space_index && typeof snapshot.space_index === 'object') { + Object.values(snapshot.space_index).forEach((room) => { + if (room?.entities) { + collections.push(room.entities); + } + }); + } + if (snapshot.space_entities && typeof snapshot.space_entities === 'object') { + Object.values(snapshot.space_entities).forEach((entities) => collections.push(entities)); + } + if (snapshot.battery_room?.entities) { + collections.push(snapshot.battery_room.entities); + } + + for (const collection of collections) { + if (!Array.isArray(collection)) { + continue; + } + const found = collection.find((entity) => entity && entity.entity_id === entityId); + if (found) { + return found; + } + } + + return null; +} + +function snapshotEntitySignature(snapshot, entityId) { + const entity = snapshotEntityLookup(snapshot, entityId); + if (!entity) { + return null; + } + const attr = entity.attributes || {}; + return [ + String(entity.entity_id || ''), + String(entity.state ?? ''), + String(entity.last_changed || entity.last_updated || ''), + String(attr.current_temperature ?? attr.temperature ?? ''), + String(attr.current_position ?? ''), + String(attr.hvac_action ?? ''), + ]; +} + +function roomRenderSignature(room) { + return [ + String(room?.id || ''), + String(room?.name || ''), + String(room?.icon || ''), + room?.visible === false ? '0' : '1', + String(room?.order ?? 9999), + String(room?.entity_count ?? 0), + String(room?.active_entity_count ?? 0), + String(room?.temperature_badge || ''), + String(room?.battery_summary_text || ''), + room?.virtual ? '1' : '0', + ]; +} + +function entityRenderSignature(entity) { + const attr = entity?.attributes || {}; + return [ + String(entity?.entity_id || ''), + String(entity?.state ?? ''), + String(entity?.order ?? 9999), + entity?.visible === false ? '0' : '1', + String(entity?.domain || ''), + String(entity?.card_type || ''), + String(entity?.subtitle || ''), + String(entity?.icon || ''), + String(attr.current_temperature ?? attr.temperature ?? ''), + String(attr.current_position ?? ''), + String(attr.hvac_action ?? ''), + ]; +} + +function selectedRoomSignature(snapshot) { + const room = snapshot?.selected_room || snapshot?.selected_space || {}; + if (!room?.id) { + return ['unknown']; + } + + if (room.id === 'main') { + const boilerConfig = snapshot?.settings?.main_boiler || {}; + const printConfig = snapshot?.settings?.main_print || {}; + const mainWeatherActions = Array.isArray(snapshot?.settings?.main_weather_actions) + ? snapshot.settings.main_weather_actions + : []; + return [ + 'main', + room.name || '', + (Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : []).map(entityRenderSignature), + snapshotEntitySignature(snapshot, boilerConfig.sensor_entity_id || ''), + snapshotEntitySignature(snapshot, printConfig.current_stage_entity_id || ''), + snapshotEntitySignature(snapshot, printConfig.print_progress_entity_id || ''), + snapshotEntitySignature(snapshot, printConfig.start_time_entity_id || ''), + snapshotEntitySignature(snapshot, printConfig.end_time_entity_id || ''), + mainWeatherActions.map((action) => [ + String(action?.entity_id || ''), + String(action?.state_entity_id || ''), + String(action?.command || ''), + String(action?.value ?? ''), + String(action?.active_value ?? ''), + String(action?.label_active || ''), + String(action?.label_inactive || ''), + ]), + snapshot?.weather ? [ + String(snapshot.weather.entity_id || ''), + String(snapshot.weather.state ?? ''), + String(snapshot.weather.temperature ?? ''), + String(snapshot.weather.sensor_temperature ?? ''), + String(snapshot.weather.wind_speed ?? ''), + String(snapshot.weather.condition ?? ''), + ] : null, + ]; + } + + if (room.id === 'batteries') { + return [ + 'batteries', + room.name || '', + room.battery_summary_text || '', + Number(room.entity_count ?? 0) || 0, + Number(room.problem_count ?? room.active_entity_count ?? 0) || 0, + (Array.isArray(room.entities) ? room.entities : []).map((item) => [ + String(item?.entity_id || ''), + String(item?.battery_status || ''), + String(item?.battery_percent_text || ''), + String(item?.forecast_minutes_left ?? ''), + String(item?.forecast_text || ''), + String(item?.source_text || ''), + ]), + ]; + } + + return [ + room.id, + room.name || '', + room.icon || '', + room.visible === false ? '0' : '1', + room.order ?? 9999, + room.temperature_badge || '', + (roomGridEntries(snapshot, room.id) || []).map((entry) => ( + entry.kind === 'layout' + ? ['layout', String(entry.id || ''), String(entry.order ?? 9999), String(entry.payload?.type || 'ghost')] + : ['entity', ...entityRenderSignature(entry.payload)] + )), + (snapshot?.edit_mode || snapshot?.settings?.edit_mode || false) + ? roomEntitiesIncludingHidden(snapshot, room.id).filter((entity) => entity.visible === false).map(entityRenderSignature) + : [], + ]; +} + +function buildSnapshotRenderSignature(snapshot) { + const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : []; + const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || {}; + const batteryRoom = snapshot?.battery_room || null; + return JSON.stringify([ + String(snapshot?.ui?.mode || 'unknown'), + String(selectedRoom?.id || 'main'), + String(snapshot?.settings?.edit_mode ? '1' : '0'), + rooms.map(roomRenderSignature), + batteryRoom ? roomRenderSignature(batteryRoom) : null, + selectedRoomSignature(snapshot), + ]); +} + function appEntityDomain(entityId) { return String(entityId || '').split('.', 2)[0] || 'unknown'; } @@ -199,35 +392,44 @@ function flattenLabelSource(source) { if (source == null) return []; if (typeof source === 'string' || typeof source === 'number') return [String(source)]; if (!Array.isArray(source)) return []; - const labels = []; - for (const item of source) { - if (typeof item === 'string' || typeof item === 'number') { - labels.push(String(item)); - continue; - } - if (item && typeof item === 'object') { - ['name', 'label', 'id', 'label_id', 'entity_id'].forEach((key) => { - if (item[key]) { - labels.push(String(item[key])); - } - }); - } - } - return labels; + return source + .filter((item) => typeof item === 'string' || typeof item === 'number') + .map((item) => String(item)); } function labelsFromEntity(entity, registryEntry = {}) { const labels = []; [ - entity?.attributes?.labels, - entity?.attributes?.label_ids, - entity?.labels, registryEntry?.labels, registryEntry?.label_ids, ].forEach((source) => labels.push(...flattenLabelSource(source))); return Array.from(new Set(labels.map(String))); } +function entityHasAutoLabel(labels, autoLabel) { + const needle = lower(autoLabel); + if (!needle) { + return false; + } + return labels.some((label) => { + const candidate = lower(label); + return candidate === needle || candidate.includes(needle); + }); +} + +function batteryLabelMatches(label) { + const candidate = lower(label); + return [ + 'Батарейка', + 'Battery', + 'batareika', + 'batareyka', + ].some((needle) => { + const normalized = lower(needle); + return candidate === normalized || candidate.includes(normalized); + }); +} + function entityName(entity, registryEntry = {}, override = {}) { if (override?.title) { return String(override.title); @@ -394,6 +596,201 @@ function registryMap(source, keyCandidates = ['entity_id']) { return map; } +function normalizeRegistryLabels(labels) { + if (labels == null) { + return []; + } + if (typeof labels === 'string' || typeof labels === 'number') { + return [String(labels)]; + } + if (!Array.isArray(labels)) { + return []; + } + return Array.from(new Set( + labels + .filter((item) => typeof item === 'string' || typeof item === 'number') + .map((item) => String(item)) + )); +} + +function registryPayloadItems(payload, key) { + if (Array.isArray(payload)) { + return payload; + } + if (payload && typeof payload === 'object') { + if (Array.isArray(payload[key])) { + return payload[key]; + } + if (Array.isArray(payload.result?.[key])) { + return payload.result[key]; + } + } + return []; +} + +function registryPayloadEntities(payload) { + if (Array.isArray(payload)) { + return payload; + } + if (payload && typeof payload === 'object') { + if (Array.isArray(payload.entities)) { + return payload.entities; + } + if (Array.isArray(payload.result?.entities)) { + return payload.result.entities; + } + } + return []; +} + +function normalizeHaEntityRegistry(entries) { + return registryPayloadEntities(entries).map((entry) => { + if (!entry || typeof entry !== 'object') { + return null; + } + return { + entity_id: String(entry.ei || entry.entity_id || '').trim(), + area_id: String(entry.ai || entry.area_id || '').trim(), + labels: normalizeRegistryLabels(entry.lb ?? entry.labels ?? null), + label_ids: normalizeRegistryLabels(entry.lb ?? entry.label_ids ?? null), + device_id: String(entry.di || entry.device_id || '').trim(), + icon: entry.ic || entry.icon || null, + translation_key: entry.tk || entry.translation_key || null, + entity_category: entry.ec ?? entry.entity_category ?? null, + hidden_by: entry.hb || entry.hidden_by || null, + name: entry.en || entry.name || null, + has_entity_name: Boolean(entry.hn ?? entry.has_entity_name ?? false), + platform: entry.pl || entry.platform || null, + }; + }).filter((entry) => Boolean(entry && entry.entity_id)); +} + +function registryHasLabelsForEntity(registry, entityId) { + if (!registry || !entityId) { + return false; + } + const entry = registry[String(entityId)]; + if (!entry || typeof entry !== 'object') { + return false; + } + const labels = Array.isArray(entry.labels) ? entry.labels : []; + return labels.length > 0 || Boolean(entry.label_ids && entry.label_ids.length); +} + +function registryHasAnyLabels(registry) { + if (!registry || typeof registry !== 'object') { + return false; + } + return Object.values(registry).some((entry) => { + if (!entry || typeof entry !== 'object') { + return false; + } + const labels = Array.isArray(entry.labels) ? entry.labels : []; + const labelIds = Array.isArray(entry.label_ids) ? entry.label_ids : []; + return labels.length > 0 || labelIds.length > 0; + }); +} + +function normalizeHaAreaRegistry(entries) { + return registryPayloadItems(entries, 'areas').map((entry) => { + if (!entry || typeof entry !== 'object') { + return null; + } + return { + area_id: String(entry.area_id || entry.id || entry.ai || '').trim(), + name: String(entry.name || entry.en || '').trim(), + icon: entry.icon || entry.ic || null, + picture: entry.picture || entry.pi || null, + floor_id: String(entry.floor_id || entry.fi || entry.floor || '').trim(), + }; + }).filter((entry) => Boolean(entry && entry.area_id)); +} + +function normalizeHaFloorRegistry(entries) { + return registryPayloadItems(entries, 'floors').map((entry) => { + if (!entry || typeof entry !== 'object') { + return null; + } + return { + floor_id: String(entry.floor_id || entry.id || entry.fi || '').trim(), + name: String(entry.name || entry.en || '').trim(), + icon: entry.icon || entry.ic || null, + level: entry.level ?? entry.lv ?? null, + }; + }).filter((entry) => Boolean(entry && entry.floor_id)); +} + +function normalizeHaDeviceRegistry(entries) { + return registryPayloadItems(entries, 'devices').map((entry) => { + if (!entry || typeof entry !== 'object') { + return null; + } + return { + device_id: String(entry.device_id || entry.id || entry.di || '').trim(), + area_id: String(entry.area_id || entry.ai || '').trim(), + labels: normalizeRegistryLabels(entry.labels ?? null), + label_ids: normalizeRegistryLabels(entry.label_ids ?? entry.labels ?? null), + name: String(entry.name || entry.en || '').trim(), + manufacturer: entry.manufacturer || entry.mf || null, + model: entry.model || entry.md || null, + name_by_user: String(entry.name_by_user || entry.nbu || '').trim(), + original_name: String(entry.original_name || entry.on || '').trim(), + }; + }).filter((entry) => Boolean(entry && entry.device_id)); +} + +function registryEntityId(entry) { + if (!entry || typeof entry !== 'object') { + return ''; + } + return String( + entry.entity_id + || entry.ei + || entry.id + || entry.entityId + || '' + ).trim(); +} + +function registryDeviceId(entry) { + if (!entry || typeof entry !== 'object') { + return ''; + } + return String( + entry.device_id + || entry.di + || entry.id + || entry.deviceId + || '' + ).trim(); +} + +function registryAreaId(entry) { + if (!entry || typeof entry !== 'object') { + return ''; + } + return String( + entry.area_id + || entry.ai + || entry.id + || entry.areaId + || '' + ).trim(); +} + +function registryFloorId(entry) { + if (!entry || typeof entry !== 'object') { + return ''; + } + return String( + entry.floor_id + || entry.fi + || entry.id + || entry.floorId + || '' + ).trim(); +} + function roomLayoutItems(room) { const items = Array.isArray(room?.layout_items) ? room.layout_items : []; return items @@ -420,7 +817,7 @@ function registryAreaMaps(areas) { const byName = {}; for (const area of toList(areas)) { if (!area || typeof area !== 'object') continue; - const areaId = String(area.area_id || area.id || '').trim(); + const areaId = registryAreaId(area); const areaIdKey = normalizeLookupKey(areaId); const areaNameKey = normalizeLookupKey(area.name || area.alias || areaId); if (areaIdKey) { @@ -446,8 +843,8 @@ function roomDefinitions(config, haData) { const area = areasById[areaId] || roomKeys.map((key) => areasByName[key]).find(Boolean) || {}; - const resolvedAreaId = String(area.area_id || area.id || areaId || ''); - const floorId = String(room.floor_id || area.floor_id || area.fi || area.floor || ''); + const resolvedAreaId = registryAreaId(area) || areaId || ''; + const floorId = String(room.floor_id || registryFloorId(area) || area.fi || area.floor || ''); const floor = floorsById[floorId] || {}; rooms.push({ id: String(room.id), @@ -511,10 +908,10 @@ function roomEntities(room, haData, includeHidden = false) { const entityId = String(entity?.entity_id || ''); if (!entityId) continue; const registryEntry = entityRegistry[entityId] || {}; - const entityAreaId = String(registryEntry.area_id || ''); - const deviceId = String(registryEntry.device_id || registryEntry.id || registryEntry.di || ''); + const entityAreaId = registryAreaId(registryEntry); + const deviceId = registryDeviceId(registryEntry); const device = deviceId && deviceRegistry[deviceId] ? deviceRegistry[deviceId] : null; - const deviceAreaId = String(device?.area_id || ''); + const deviceAreaId = registryAreaId(device); const entityAreaName = normalizeLookupKey(areasById[entityAreaId]?.name || areasByName[normalizeLookupKey(entityAreaId)]?.name || entityAreaId); const deviceAreaName = normalizeLookupKey(areasById[deviceAreaId]?.name || areasByName[normalizeLookupKey(deviceAreaId)]?.name || deviceAreaId); const matchesArea = [entityAreaId, deviceAreaId].some((candidate) => candidate && roomAreaIds.has(candidate)) @@ -631,6 +1028,22 @@ function entityIndex(config, haData) { const registryEntry = entityRegistry[entityId] || {}; const labels = labelsFromEntity(entity, registryEntry); const isHidden = Boolean(registryEntry.hidden_by || registryEntry.disabled_by); + if (shouldTraceEntity(entityId)) { + traceEntity(entityId, 'entityIndex', { + entity: { + entity_id: entityId, + state: entity.state ?? 'unknown', + attributes: entity.attributes || {}, + labels: entity.labels || entity.attributes?.labels || entity.attributes?.label_ids || null, + }, + registryEntry, + labels, + auto_label: autoLabel, + manual_auto: manualAuto.has(entityId), + is_auto: manualAuto.has(entityId) || entityHasAutoLabel(labels, autoLabel), + is_hidden: isHidden, + }); + } items[entityId] = { entity_id: entityId, domain: appEntityDomain(entityId), @@ -639,11 +1052,11 @@ function entityIndex(config, haData) { state: entity.state ?? 'unknown', attributes: entity.attributes || {}, labels, - area_id: String(registryEntry.area_id || ''), - device_id: String(registryEntry.device_id || registryEntry.di || ''), + area_id: registryAreaId(registryEntry), + device_id: registryDeviceId(registryEntry), card_type: defaultCardType(entityId), is_door_contact: appEntityDomain(entityId) === 'binary_sensor' && (lower(entity?.attributes?.device_class) === 'door' || /door|двер/i.test(String(entity?.attributes?.friendly_name || registryEntry.name || ''))), - is_auto: manualAuto.has(entityId) || labels.some((label) => lower(label).includes(lower(autoLabel)) && lower(autoLabel) !== ''), + is_auto: manualAuto.has(entityId) || entityHasAutoLabel(labels, autoLabel), is_hidden: isHidden, last_changed: String(entity.last_changed || entity.last_updated || ''), }; @@ -671,9 +1084,27 @@ function mainEntities(config, haData) { if (!entityId) continue; const registryEntry = entityRegistry[entityId] || {}; const labels = labelsFromEntity(entity, registryEntry); - const isAuto = manualAuto.has(entityId) || labels.some((label) => lower(label).includes(lower(autoLabel)) && lower(autoLabel) !== ''); + const isAuto = manualAuto.has(entityId) || entityHasAutoLabel(labels, autoLabel); const domain = appEntityDomain(entityId); const isDoorContact = domain === 'binary_sensor' && (lower(entity?.attributes?.device_class) === 'door' || /door|двер/i.test(String(entity?.attributes?.friendly_name || registryEntry.name || ''))); + if (shouldTraceEntity(entityId)) { + traceEntity(entityId, 'mainEntities candidate', { + entity: { + entity_id: entityId, + state: entity.state ?? 'unknown', + attributes: entity.attributes || {}, + labels: entity.labels || entity.attributes?.labels || entity.attributes?.label_ids || null, + }, + registryEntry, + labels, + auto_label: autoLabel, + manual_auto: manualAuto.has(entityId), + is_auto: isAuto, + is_active: isActiveEntity(entity), + domain, + is_door_contact: isDoorContact, + }); + } if (isAuto) { debug.auto_matches += 1; if (debug.auto_preview.length < 10) debug.auto_preview.push(entityId); @@ -745,21 +1176,37 @@ function weatherSummary(entity) { }; } -function batteryRoom(config, haData, rooms, selectedRoomId) { - const states = toList(haData.states); +function batteryRoom(config, haData, rooms, selectedRoomId, sourceIndex = null) { const entityRegistry = registryMap(haData.entity_registry, ['entity_id']); const deviceRegistry = registryMap(haData.device_registry, ['device_id', 'id', 'di']); + const indexItems = sourceIndex && typeof sourceIndex === 'object' + ? Object.values(sourceIndex) + : Object.values(entityIndex(config, haData)); const deviceGroups = {}; - for (const entity of states) { - const entityId = String(entity?.entity_id || ''); + for (const item of indexItems) { + const entityId = String(item?.entity_id || ''); if (!entityId) continue; const registryEntry = entityRegistry[entityId] || {}; - const deviceId = String(registryEntry.device_id || registryEntry.id || registryEntry.di || ''); + const deviceId = String(item?.device_id || registryDeviceId(registryEntry) || '').trim(); const device = deviceId && deviceRegistry[deviceId] ? deviceRegistry[deviceId] : {}; - const labels = labelsFromEntity(entity, registryEntry).concat(labelsFromEntity({}, device)); - const hasBatteryLabel = labels.some((label) => /battery|батар/i.test(String(label))); - if (!hasBatteryLabel && !/battery$/i.test(entityId) && !/battery_/i.test(entityId)) { + const labels = labelsFromEntity({}, device); + const hasBatteryLabel = labels.some(batteryLabelMatches); + if (shouldTraceEntity(entityId)) { + traceEntity(entityId, 'battery candidate', { + entity: { + entity_id: entityId, + state: item?.state ?? 'unknown', + attributes: item?.attributes || {}, + }, + registryEntry, + device, + labels, + has_battery_label: hasBatteryLabel, + device_id: deviceId, + }); + } + if (!hasBatteryLabel) { continue; } const key = deviceId ? `device:${deviceId}` : `entity:${entityId}`; @@ -770,7 +1217,7 @@ function batteryRoom(config, haData, rooms, selectedRoomId) { items: [], }; } - deviceGroups[key].items.push({ entity, registry: registryEntry }); + deviceGroups[key].items.push({ entity: item, registry: registryEntry }); } const batteryEntities = []; @@ -783,6 +1230,7 @@ function batteryRoom(config, haData, rooms, selectedRoomId) { if (!entityId) continue; const percent = batteryPercentFromEntity(item); const status = batteryStatusFromPercent(item, percent); + const isBatterySensor = /^sensor\..*_battery$/i.test(entityId); let score = /battery$/i.test(entityId) ? 1000 : 0; if (percent != null) score += 100; if (!['unknown', 'unavailable', 'none'].includes(lower(item.state))) score += 10; @@ -793,11 +1241,12 @@ function batteryRoom(config, haData, rooms, selectedRoomId) { item, status, percent, + is_battery_sensor: isBatterySensor, }; bestScore = score; } } - if (!best) continue; + if (!best || !best.is_battery_sensor) continue; const device = group.device || {}; const percent = best.percent; const status = best.status; @@ -817,7 +1266,7 @@ function batteryRoom(config, haData, rooms, selectedRoomId) { forecast_text: null, forecast_reason: null, last_seen_state: String(best.item?.state ?? 'unknown'), - labels: labelsFromEntity(best.item, entityRegistry[best.entity_id] || {}), + labels: best.item?.labels || [], order: 9999, }); } @@ -908,8 +1357,8 @@ function buildSnapshotFromHa(panelConfig, hass, selectedRoomId = 'main', popupSt spaceEntities[room.id] = entities; } - const battery = batteryRoom(config, haData, rooms, selectedRoomId); const entityIndexMap = entityIndex(config, haData); + const battery = batteryRoom(config, haData, rooms, selectedRoomId, entityIndexMap); const main = mainEntities(config, haData); const weatherEntity = selectWeatherEntity(haData, config); const weather = weatherSummary(weatherEntity); @@ -1054,23 +1503,30 @@ class StrikerPanelPanel extends HTMLElement { this._popupCloseTimer = null; this._assetVersion = String(Date.now()); this._debugLastSnapshotSignature = ''; + this._lastSnapshotSignature = ''; this._registryCache = null; this._registryCachePromise = null; + this._registryCacheReady = false; + this._registryCacheLastAttemptAt = 0; + this._registryRetryTimer = null; + this._emitSnapshotTimer = null; + this._pendingSnapshot = null; + this._syncRuntimeTimer = null; } set hass(hass) { this._hass = hass; - this._syncRuntime(); + this._scheduleSyncRuntime(); } set panel(panel) { this._panel = panel; - this._syncRuntime(); + this._scheduleSyncRuntime(); } set narrow(narrow) { this._narrow = Boolean(narrow); - this._syncRuntime(); + this._scheduleSyncRuntime(); } connectedCallback() { @@ -1081,7 +1537,7 @@ class StrikerPanelPanel extends HTMLElement { this._ensureShell(); this._ensureAssets(); this._ensureBridge(); - this._syncRuntime(); + this._scheduleSyncRuntime(true); } disconnectedCallback() { @@ -1089,6 +1545,14 @@ class StrikerPanelPanel extends HTMLElement { clearTimeout(this._popupCloseTimer); this._popupCloseTimer = null; } + if (this._syncRuntimeTimer) { + clearTimeout(this._syncRuntimeTimer); + this._syncRuntimeTimer = null; + } + if (this._registryRetryTimer) { + clearTimeout(this._registryRetryTimer); + this._registryRetryTimer = null; + } } _panelConfig() { @@ -1109,6 +1573,30 @@ class StrikerPanelPanel extends HTMLElement { return this._selectedRoomId || 'main'; } + _scheduleSyncRuntime(immediate = false) { + if (!this.isConnected) { + return; + } + + if (immediate) { + if (this._syncRuntimeTimer) { + clearTimeout(this._syncRuntimeTimer); + this._syncRuntimeTimer = null; + } + this._syncRuntime(); + return; + } + + if (this._syncRuntimeTimer) { + return; + } + + this._syncRuntimeTimer = window.setTimeout(() => { + this._syncRuntimeTimer = null; + this._syncRuntime(); + }, 50); + } + _currentConfig() { const runtimeConfig = this._runtimeConfig || this._panelConfig().runtime_config || this._panelConfig().config || {}; this._runtimeConfig = normalizeConfig(runtimeConfig); @@ -1121,6 +1609,7 @@ class StrikerPanelPanel extends HTMLElement { } window.StrikerPanelClient = window.StrikerPanelClient || {}; window.StrikerPanelClient.mountRoot = this.shadowRoot || this; + window.WALL_PANEL_HA_MODE = true; this.style.display = 'block'; this.style.width = '100%'; this.style.height = '100%'; @@ -1144,7 +1633,7 @@ class StrikerPanelPanel extends HTMLElement {
0
@@ -1157,7 +1646,7 @@ class StrikerPanelPanel extends HTMLElement {
@@ -1178,13 +1667,13 @@ class StrikerPanelPanel extends HTMLElement {
@@ -1271,14 +1760,31 @@ class StrikerPanelPanel extends HTMLElement { } _emitSnapshot(snapshot) { - panelLog('emitSnapshot()', panelSnapshotSummary(snapshot)); - window.APP_BOOTSTRAP = snapshot; - window.dispatchEvent(new CustomEvent(WALL_PANEL_HA_EVENT, { detail: { snapshot } })); - try { - window.StrikerPanelClient?.renderFromSnapshot?.(snapshot); - } catch (error) { - panelWarn('renderFromSnapshot() failed', error); + this._pendingSnapshot = snapshot; + if (this._emitSnapshotTimer) { + return; } + this._emitSnapshotTimer = window.setTimeout(() => { + const nextSnapshot = this._pendingSnapshot; + this._pendingSnapshot = null; + this._emitSnapshotTimer = null; + if (!nextSnapshot) { + return; + } + const signature = String(nextSnapshot.render_signature || ''); + if (signature && signature === this._lastSnapshotSignature) { + return; + } + this._lastSnapshotSignature = signature; + panelLog('emitSnapshot()', panelSnapshotSummary(nextSnapshot)); + window.APP_BOOTSTRAP = nextSnapshot; + window.dispatchEvent(new CustomEvent(WALL_PANEL_HA_EVENT, { detail: { snapshot: nextSnapshot } })); + try { + window.StrikerPanelClient?.renderFromSnapshot?.(nextSnapshot); + } catch (error) { + panelWarn('renderFromSnapshot() failed', error); + } + }, 50); } _getRegistryMaps() { @@ -1286,16 +1792,25 @@ class StrikerPanelPanel extends HTMLElement { return this._registryCache; } const hass = this._hass || {}; + const entityRegistry = registryMap(normalizeHaEntityRegistry(hass.entities), ['entity_id']); + const deviceRegistry = registryMap(normalizeHaDeviceRegistry(hass.devices), ['device_id']); + const areaRegistry = registryMap(normalizeHaAreaRegistry(hass.areas), ['area_id']); + const floorRegistry = registryMap(normalizeHaFloorRegistry(hass.floors), ['floor_id']); return { - entities: registryMap(hass.entities, ['entity_id']), - devices: registryMap(hass.devices, ['device_id', 'id', 'di']), - areas: registryMap(hass.areas, ['area_id', 'id']), - floors: registryMap(hass.floors, ['floor_id', 'id']), + entity_registry: entityRegistry, + device_registry: deviceRegistry, + area_registry: areaRegistry, + floor_registry: floorRegistry, + entities: entityRegistry, + devices: deviceRegistry, + areas: areaRegistry, + floors: floorRegistry, }; } async _refreshRegistryCache(force = false) { - if (!force && this._registryCache) { + const now = Date.now(); + if (!force && this._registryCacheReady && this._registryCache) { return this._registryCache; } @@ -1303,38 +1818,69 @@ class StrikerPanelPanel extends HTMLElement { return this._registryCachePromise; } + if (!force && this._registryCache && (now - this._registryCacheLastAttemptAt) < 5000) { + return this._registryCache; + } + + this._registryCacheLastAttemptAt = now; const hass = this._hass || {}; if (!hass?.callWS) { - this._registryCache = this._getRegistryMaps(); + if (!this._registryCache) { + this._registryCache = this._getRegistryMaps(); + } + this._registryCacheReady = false; + this._scheduleRegistryRetry(); return this._registryCache; } this._registryCachePromise = (async () => { try { const [entities, devices, areas, floors] = await Promise.all([ - hass.callWS({ type: 'config/entity_registry/list' }), + hass.callWS({ type: 'config/entity_registry/list_for_display' }), hass.callWS({ type: 'config/device_registry/list' }), hass.callWS({ type: 'config/area_registry/list' }), hass.callWS({ type: 'config/floor_registry/list' }), ]); - this._registryCache = { - entities: registryMap(entities, ['entity_id']), - devices: registryMap(devices, ['device_id', 'id', 'di']), - areas: registryMap(areas, ['area_id', 'id']), - floors: registryMap(floors, ['floor_id', 'id']), + const entityRegistry = registryMap(normalizeHaEntityRegistry(entities), ['entity_id']); + const deviceRegistry = registryMap(normalizeHaDeviceRegistry(devices), ['device_id']); + const areaRegistry = registryMap(normalizeHaAreaRegistry(areas), ['area_id']); + const floorRegistry = registryMap(normalizeHaFloorRegistry(floors), ['floor_id']); + let normalized = { + entity_registry: entityRegistry, + device_registry: deviceRegistry, + area_registry: areaRegistry, + floor_registry: floorRegistry, + entities: entityRegistry, + devices: deviceRegistry, + areas: areaRegistry, + floors: floorRegistry, }; + this._registryCache = normalized; + this._registryCacheReady = registryHasAnyLabels(this._registryCache.entity_registry) + || registryHasAnyLabels(this._registryCache.device_registry); + if (this._registryRetryTimer) { + clearTimeout(this._registryRetryTimer); + this._registryRetryTimer = null; + } panelLog('registry cache refreshed', { entities: Array.isArray(entities) ? entities.length : 0, devices: Array.isArray(devices) ? devices.length : 0, areas: Array.isArray(areas) ? areas.length : 0, floors: Array.isArray(floors) ? floors.length : 0, - entity_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => item?.entity_id || item?.id || '') : [], - entity_labels_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => ({ id: item?.entity_id || item?.id || '', labels: item?.labels || item?.label_ids || [] })) : [], + entity_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => registryEntityId(item)) : [], + entity_labels_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => ({ id: registryEntityId(item), labels: normalizeRegistryLabels(item?.lb ?? item?.labels ?? []) })) : [], + trace_target: registryEntityId((Array.isArray(entities) ? entities : []).find((item) => registryEntityId(item) === 'light.0x54ef4410011cd300' || registryEntityId(item) === 'light.sarai_svet')) || null, + trace_target_normalized: this._registryCache.entity_registry['light.0x54ef4410011cd300'] || this._registryCache.entity_registry['light.sarai_svet'] || null, + ready: this._registryCacheReady, }); return this._registryCache; } catch (error) { panelWarn('registry cache refresh failed, falling back to hass.* maps', error); - this._registryCache = this._getRegistryMaps(); + if (!this._registryCache) { + this._registryCache = this._getRegistryMaps(); + } + this._registryCacheReady = false; + this._scheduleRegistryRetry(); return this._registryCache; } finally { this._registryCachePromise = null; @@ -1344,6 +1890,18 @@ class StrikerPanelPanel extends HTMLElement { return this._registryCachePromise; } + _scheduleRegistryRetry(delayMs = 2000) { + if (this._registryRetryTimer) { + return; + } + this._registryRetryTimer = window.setTimeout(() => { + this._registryRetryTimer = null; + if (this.isConnected) { + this._syncRuntime(true); + } + }, delayMs); + } + _buildSnapshot(roomId = this._selectedRoom()) { const config = this._currentConfig(); const hass = this._hass || {}; @@ -1352,6 +1910,10 @@ class StrikerPanelPanel extends HTMLElement { states: Object.values(states), ...this._getRegistryMaps(), }; + haData.entity_registry = haData.entity_registry || haData.entities || {}; + haData.device_registry = haData.device_registry || haData.devices || {}; + haData.area_registry = haData.area_registry || haData.areas || {}; + haData.floor_registry = haData.floor_registry || haData.floors || {}; const editMode = Boolean(config?.app?.edit_mode); const rooms = roomDefinitions(config, haData); @@ -1395,9 +1957,9 @@ class StrikerPanelPanel extends HTMLElement { weather.sensor_temperature = weatherSensor.state ?? null; } - const main = mainEntities(config, haData); - const battery = batteryRoom(config, haData, rooms, roomId); const entityIndexMap = entityIndex(config, haData); + const main = mainEntities(config, haData); + const battery = batteryRoom(config, haData, rooms, roomId, entityIndexMap); let selectedRoom; if (roomId === 'main') { @@ -1431,13 +1993,16 @@ class StrikerPanelPanel extends HTMLElement { const camera = config.camera || {}; const popup = clone(this._popupState); - if (popup.active && popup.expires_at && Date.now() >= Number(popup.expires_at) * 1000) { - popup.active = false; - popup.expires_at = null; - this._popupState = popup; - } + popup.active = false; + popup.sensor_entity_id = null; + popup.expires_at = null; + popup.opened_at = null; + popup.poster_url = String(camera.poster_url || ''); + popup.stream_url = String(camera.stream_url || ''); + popup.stream_mode = String(camera.stream_mode || 'hls'); + popup.title = 'Камера'; - return { + const snapshot = { ok: true, demo: false, server_time: Math.floor(Date.now() / 1000), @@ -1488,6 +2053,9 @@ class StrikerPanelPanel extends HTMLElement { }, runtime_config: config, }; + + snapshot.render_signature = buildSnapshotRenderSignature(snapshot); + return snapshot; } _schedulePopupClose(delayMs) { @@ -1503,39 +2071,16 @@ class StrikerPanelPanel extends HTMLElement { } _updateTriggerPopup(nextStates) { - const config = this._currentConfig(); - const triggerEntities = new Set((config.camera?.trigger_entities || []).map(String)); - if (!triggerEntities.size) return; - const previous = this._triggerSnapshot; this._triggerSnapshot = nextStates; - const timeoutMinutes = Math.max(1, Number(config.camera?.popup_timeout_minutes || 3)); - const closeDelaySeconds = 30; - for (const entityId of triggerEntities) { - const current = String(nextStates[entityId] || '').toLowerCase(); - const prev = String(previous[entityId] || '').toLowerCase(); - if (current === 'on' && prev !== 'on') { - this._popupState = { - active: true, - sensor_entity_id: entityId, - opened_at: Math.floor(Date.now() / 1000), - expires_at: null, - }; - this._emitSnapshot(this._buildSnapshot(this._selectedRoom())); - return; - } - if (current === 'off' && prev === 'on' && this._popupState.active && this._popupState.sensor_entity_id === entityId) { - this._popupState.active = true; - this._popupState.expires_at = Math.floor(Date.now() / 1000) + closeDelaySeconds; - this._schedulePopupClose(closeDelaySeconds * 1000); - this._emitSnapshot(this._buildSnapshot(this._selectedRoom())); - return; - } - if (this._popupState.active && this._popupState.expires_at && Math.floor(Date.now() / 1000) >= Number(this._popupState.expires_at)) { - this._popupState.active = false; - this._popupState.expires_at = null; - this._emitSnapshot(this._buildSnapshot(this._selectedRoom())); - } + return; + } + + _snapshotChanged(snapshot) { + const signature = String(snapshot?.render_signature || ''); + if (!signature) { + return true; } + return signature !== this._lastSnapshotSignature; } _refreshFromHass() { @@ -1675,44 +2220,7 @@ class StrikerPanelPanel extends HTMLElement { } if (method === 'POST' && normalizedAction === 'popup') { - const timeoutMinutes = Math.max(1, Number(config.camera?.popup_timeout_minutes || 3)); - if (String(payload.command || '').toLowerCase() === 'close') { - this._popupState = { - active: false, - sensor_entity_id: null, - opened_at: this._popupState.opened_at || null, - expires_at: null, - }; - } else if (String(payload.command || '').toLowerCase() === 'open') { - this._popupState = { - active: true, - sensor_entity_id: String(payload.sensor_entity_id || 'debug'), - opened_at: Math.floor(Date.now() / 1000), - expires_at: Math.floor(Date.now() / 1000) + timeoutMinutes * 60, - }; - this._schedulePopupClose(timeoutMinutes * 60 * 1000); - } else if (payload.sensor_entity_id) { - const sensor = String(payload.sensor_entity_id || ''); - const stateValue = String(payload.state || payload.to || '').toLowerCase(); - if (stateValue === 'on') { - this._popupState = { - active: true, - sensor_entity_id: sensor, - opened_at: Math.floor(Date.now() / 1000), - expires_at: null, - }; - } else if (stateValue === 'off' && this._popupState.active && this._popupState.sensor_entity_id === sensor) { - this._popupState.expires_at = Math.floor(Date.now() / 1000) + 30; - this._schedulePopupClose(30 * 1000); - } - } - panelLog('bridge popup updated', { - command: String(payload.command || '').toLowerCase() || 'update', - sensor_entity_id: payload.sensor_entity_id || null, - active: Boolean(this._popupState.active), - }); const snapshot = this._buildSnapshot(this._selectedRoom()); - this._emitSnapshot(snapshot); return { ok: true, popup: snapshot.popup }; } @@ -1758,7 +2266,7 @@ class StrikerPanelPanel extends HTMLElement { throw new Error(`Unsupported action: ${action}`); } - _syncRuntime() { + _syncRuntime(forceRegistryRefresh = false) { if (!this.isConnected) { return; } @@ -1767,24 +2275,24 @@ class StrikerPanelPanel extends HTMLElement { this._ensureBridge(); this._runtimeConfig = normalizeConfig(this._panelConfig().runtime_config || this._panelConfig().config || this._runtimeConfig || {}); const currentRoom = this._selectedRoom(); - this._refreshRegistryCache().then(() => { + this._refreshRegistryCache(forceRegistryRefresh).then(() => { const snapshot = this._buildSnapshot(currentRoom); const signature = JSON.stringify([ - snapshot?.selected_room?.id || snapshot?.selected_space?.id || 'main', - Array.isArray(snapshot?.rooms) ? snapshot.rooms.length : Array.isArray(snapshot?.spaces) ? snapshot.spaces.length : 0, - Array.isArray(snapshot?.main_entities) ? snapshot.main_entities.length : 0, - Boolean(snapshot?.popup?.active), - Boolean(snapshot?.ui?.mode === 'ha-native' || snapshot?.ui?.mode === 'ha'), + snapshot?.render_signature || '', ]); if (signature !== this._debugLastSnapshotSignature) { this._debugLastSnapshotSignature = signature; panelLog('syncRuntime()', panelSnapshotSummary(snapshot)); } this._emitSnapshot(snapshot); + if (!this._registryCacheReady) { + this._scheduleRegistryRetry(); + } }).catch((error) => { panelWarn('syncRuntime registry refresh failed', error); const snapshot = this._buildSnapshot(currentRoom); this._emitSnapshot(snapshot); + this._scheduleRegistryRetry(); }); } } diff --git a/custom_components/wall_panel/helpers.py b/custom_components/wall_panel/helpers.py index 8d3161f..6519d13 100755 --- a/custom_components/wall_panel/helpers.py +++ b/custom_components/wall_panel/helpers.py @@ -169,7 +169,8 @@ def current_entry_panel(entry) -> dict[str, Any]: CONF_SYNC_TOKEN: str(entry.options.get(CONF_SYNC_TOKEN, "") or ""), CONF_SIDEBAR_TITLE: str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE), CONF_SIDEBAR_ICON: str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON), - CONF_FRONTEND_URL_PATH: str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH), + # The HA panel path must remain canonical and independent from the PHP proxy URL. + CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH, CONF_REQUIRE_ADMIN: bool(entry.options.get(CONF_REQUIRE_ADMIN, False)), } diff --git a/custom_components/wall_panel/lovelace/ui-lovelace.yaml b/custom_components/wall_panel/lovelace/ui-lovelace.yaml new file mode 100755 index 0000000..77a46f7 --- /dev/null +++ b/custom_components/wall_panel/lovelace/ui-lovelace.yaml @@ -0,0 +1,9 @@ +title: Home Panel +views: + - title: Home Panel + path: home-panel + panel: true + cards: + - type: iframe + url: /striker-panel + aspect_ratio: 100% diff --git a/storage/battery_cache.json b/storage/battery_cache.json index 81ec26c..2ebfe17 100755 --- a/storage/battery_cache.json +++ b/storage/battery_cache.json @@ -1,11 +1,11 @@ { "items": { "sensor.garage_motion_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 100 }, { @@ -20,11 +20,11 @@ "percent": 100 }, "sensor.garage_light_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 100 }, { @@ -39,11 +39,11 @@ "percent": 100 }, "sensor.garage_door_motion_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 100 }, { @@ -58,11 +58,11 @@ "percent": 100 }, "sensor.stair_up_motion_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 100 }, { @@ -77,11 +77,11 @@ "percent": 100 }, "sensor.stair_down_motion_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 100 }, { @@ -96,11 +96,11 @@ "percent": 100 }, "sensor.stair_light_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 100 }, { @@ -115,11 +115,11 @@ "percent": 100 }, "sensor.door_sensor_2_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 90 }, { @@ -134,11 +134,11 @@ "percent": 90 }, "sensor.wleak_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 100 }, { @@ -153,11 +153,11 @@ "percent": 100 }, "sensor.0xa4c138433d675809_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 1 }, { @@ -172,11 +172,11 @@ "percent": 1 }, "sensor.0xa4c138997cb4fdd1_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 100 }, { @@ -191,11 +191,11 @@ "percent": 100 }, "sensor.printer_knopka_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 74 }, { @@ -210,11 +210,11 @@ "percent": 74 }, "sensor.lestnitsa_dvizhenie_2_etazh_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 100 }, { @@ -229,11 +229,11 @@ "percent": 100 }, "sensor.spalnia_knopka_girliand_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 29 }, { @@ -248,11 +248,11 @@ "percent": 29 }, "sensor.ulitsa_temperatura_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 63 }, { @@ -267,11 +267,11 @@ "percent": 62 }, "sensor.0x44e2f8fffeb65d8e_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 50 }, { @@ -289,40 +289,24 @@ { "timestamp": 1773925068, "value": 55 + }, + { + "timestamp": 1773944510, + "value": 50 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0.2435, + "forecast_slope_per_hour": 0.054, "forecast_reason": "Заряд не падает", "percent": 40 }, "sensor.0x54ef4410009a6a11_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, - "value": 92 - }, - { - "timestamp": 1773852462, - "value": 93 - }, - { - "timestamp": 1773855575, - "value": 91 - }, - { - "timestamp": 1773858820, - "value": 92 - }, - { - "timestamp": 1773862156, - "value": 91 - }, - { - "timestamp": 1773865222, + "timestamp": 1773865262, "value": 92 }, { @@ -388,20 +372,32 @@ { "timestamp": 1773936397, "value": 93 + }, + { + "timestamp": 1773939621, + "value": 92 + }, + { + "timestamp": 1773942985, + "value": 94 + }, + { + "timestamp": 1773946246, + "value": 93 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0.015, - "forecast_reason": "Заряд не падает", - "percent": 92 + "forecast_slope_per_hour": 0.0057, + "forecast_reason": "Нет заметного разряда", + "percent": 89 }, "sensor.0x00124b0035558456_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 82 }, { @@ -416,11 +412,11 @@ "percent": 73 }, "sensor.0xa4c13874f5fdfd2a_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 92 }, { @@ -435,11 +431,11 @@ "percent": 93 }, "sensor.0x54ef44100119db20_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 100 }, { @@ -464,11 +460,11 @@ "percent": 100 }, "sensor.0x0ceff6fffe6cffc4_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 45 }, { @@ -510,20 +506,52 @@ { "timestamp": 1773925068, "value": 50 + }, + { + "timestamp": 1773944491, + "value": 55 + }, + { + "timestamp": 1773944511, + "value": 50 + }, + { + "timestamp": 1773946215, + "value": 45 + }, + { + "timestamp": 1773946247, + "value": 50 + }, + { + "timestamp": 1773946532, + "value": 45 + }, + { + "timestamp": 1773946537, + "value": 55 + }, + { + "timestamp": 1773946544, + "value": 45 + }, + { + "timestamp": 1773946566, + "value": 50 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0.2618, + "forecast_slope_per_hour": 0.1586, "forecast_reason": "Заряд не падает", - "percent": 55 + "percent": 50 }, "sensor.0x0ceff6fffe6cdee0_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 0 }, { @@ -557,13 +585,21 @@ { "timestamp": 1773925068, "value": 60 + }, + { + "timestamp": 1773944490, + "value": 65 + }, + { + "timestamp": 1773944518, + "value": 55 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 3.1712, + "forecast_slope_per_hour": 2.7826, "forecast_reason": "Заряд не падает", - "percent": 55 + "percent": 50 }, "sensor.0x705464fffe43dee0_battery": { "loaded_at": 1774456345, @@ -605,11 +641,11 @@ "percent": 25 }, "sensor.0xa4c138259d164c22_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 88.5 }, { @@ -647,11 +683,11 @@ "percent": 88 }, "sensor.spalnya_temp_battery": { - "loaded_at": 1774456464, + "loaded_at": 1774470062, "history_hours": 4320, "points": [ { - "timestamp": 1773851664, + "timestamp": 1773865262, "value": 3 }, { diff --git a/storage/popup_state.json b/storage/popup_state.json index 4c4983c..e306fc3 100755 --- a/storage/popup_state.json +++ b/storage/popup_state.json @@ -1,6 +1,6 @@ { "active": false, "sensor_entity_id": "binary_sensor.doorbell_all_occupancy", - "opened_at": 1774456141, + "opened_at": 1774457850, "expires_at": null } diff --git a/wall_panel/assets/app.css b/wall_panel/assets/app.css index 786893a..d6dd74c 100755 --- a/wall_panel/assets/app.css +++ b/wall_panel/assets/app.css @@ -49,6 +49,11 @@ body.is-embedded { overflow: auto; } +body.is-ha-native #camera-modal, +body.is-ha-native .camera-modal { + display: none !important; +} + button, input, select, @@ -261,7 +266,7 @@ textarea { .room-list__group { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } @@ -293,7 +298,8 @@ textarea { height: 42px; } -.app-shell--embed .room-item__icon i { +.app-shell--embed .room-item__icon i, +.app-shell--embed .room-item__icon ha-icon { font-size: 32px; } @@ -375,6 +381,17 @@ textarea { justify-content: center; } +.room-item__icon ha-icon, +.grid-card__icon ha-icon, +.mushroom-button__icon ha-icon, +.icon-node--ha { + width: 100%; + height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; +} + .icon-node__img { width: 100%; height: 100%; @@ -509,6 +526,10 @@ textarea { padding: 22px 16px 20px; } +.app-shell.is-ha-native .content-top { + margin-top: 0; +} + .content-top { display: none; margin-bottom: 16px; diff --git a/wall_panel/assets/app.js b/wall_panel/assets/app.js index d8b5602..58e0ac4 100755 --- a/wall_panel/assets/app.js +++ b/wall_panel/assets/app.js @@ -3,7 +3,7 @@ const MOBILE_BREAKPOINT = 920; const state = { snapshot: bootstrap, - embedMode: Boolean(bootstrap?.ui?.embed), + embedMode: Boolean(bootstrap?.ui?.embed || window.WALL_PANEL_HA_MODE), selectedRoomId: 'main', isMobileViewport: false, mobileView: 'spaces', @@ -44,10 +44,14 @@ snapshotPollTimer: null, haSnapshotListenerInstalled: false, debugLastRenderSignature: '', + lastRenderSignature: '', + lastSidebarRenderSignature: '', + lastContentRenderSignature: '', }; const els = {}; const client = window.StrikerPanelClient || (window.StrikerPanelClient = {}); + let renderFrame = null; const debugEnabled = (() => { try { if (window.StrikerPanelDebug) return true; @@ -81,14 +85,20 @@ } state.snapshot = snapshot; initRefs(); - state.embedMode = detectEmbeddedContext(); + state.embedMode = detectEmbeddedContext() || isHaRuntime(); syncLayoutState(); - render(); + if (renderFrame) { + return; + } + renderFrame = window.requestAnimationFrame(() => { + renderFrame = null; + render(); + }); }; client.refresh = () => { initRefs(); - state.embedMode = detectEmbeddedContext(); + state.embedMode = detectEmbeddedContext() || isHaRuntime(); syncLayoutState(); render(); }; @@ -118,11 +128,22 @@ function isHaRuntime() { return Boolean( + window.WALL_PANEL_HA_MODE || haBridge() || bootstrap?.ui?.mode === 'ha-native' ); } + async function waitForHaBridge(timeoutMs = 1000) { + const startedAt = Date.now(); + let bridge = haBridge(); + while (!bridge && (Date.now() - startedAt) < timeoutMs) { + await sleep(50); + bridge = haBridge(); + } + return bridge; + } + function sleep(ms) { return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); } @@ -160,6 +181,254 @@ }; } + function snapshotEntityToken(snapshot, entityId) { + if (!snapshot || !entityId) { + return null; + } + + const collections = [ + snapshot.main_entities, + snapshot.selected_space?.entities, + snapshot.selected_room?.entities, + ]; + if (snapshot.entity_index && typeof snapshot.entity_index === 'object') { + collections.push(Object.values(snapshot.entity_index)); + } + if (snapshot.space_index && typeof snapshot.space_index === 'object') { + Object.values(snapshot.space_index).forEach((room) => { + if (room?.entities) { + collections.push(room.entities); + } + }); + } + if (snapshot.space_entities && typeof snapshot.space_entities === 'object') { + Object.values(snapshot.space_entities).forEach((entities) => collections.push(entities)); + } + if (snapshot.battery_room?.entities) { + collections.push(snapshot.battery_room.entities); + } + + for (const collection of collections) { + if (!Array.isArray(collection)) continue; + const found = collection.find((entity) => entity && entity.entity_id === entityId); + if (found) { + const attr = found.attributes || {}; + return [ + String(found.entity_id || ''), + String(found.state ?? ''), + String(found.last_changed || found.last_updated || ''), + String(attr.current_temperature ?? attr.temperature ?? ''), + String(attr.current_position ?? ''), + String(attr.hvac_action ?? ''), + ]; + } + } + return null; + } + + function entityRenderToken(entity) { + const attr = entity?.attributes || {}; + return [ + String(entity?.entity_id || ''), + String(entity?.state ?? ''), + String(entity?.order ?? 9999), + entity?.visible === false ? '0' : '1', + String(entity?.domain || ''), + String(entity?.card_type || ''), + String(entity?.subtitle || ''), + String(entity?.icon || ''), + String(attr.current_temperature ?? attr.temperature ?? ''), + String(attr.current_position ?? ''), + String(attr.hvac_action ?? ''), + ]; + } + + function roomRenderToken(room) { + return [ + String(room?.id || ''), + String(room?.name || ''), + String(room?.icon || ''), + room?.visible === false ? '0' : '1', + String(room?.order ?? 9999), + String(room?.entity_count ?? 0), + String(room?.active_entity_count ?? 0), + String(room?.temperature_badge || ''), + String(room?.battery_summary_text || ''), + room?.virtual ? '1' : '0', + ]; + } + + function selectedRoomRenderToken(snapshot) { + const room = snapshot?.selected_room || snapshot?.selected_space || {}; + if (!room?.id) { + return ['unknown']; + } + + if (room.id === 'main') { + const boiler = snapshot?.settings?.main_boiler || {}; + const printConfig = snapshot?.settings?.main_print || {}; + const weatherActions = Array.isArray(snapshot?.settings?.main_weather_actions) + ? snapshot.settings.main_weather_actions + : []; + return [ + 'main', + room.name || '', + (Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : []).map(entityRenderToken), + snapshotEntityToken(snapshot, boiler.sensor_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.current_stage_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.print_progress_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.start_time_entity_id || ''), + snapshotEntityToken(snapshot, printConfig.end_time_entity_id || ''), + weatherActions.map((action) => [ + String(action?.entity_id || ''), + String(action?.state_entity_id || ''), + String(action?.command || ''), + String(action?.value ?? ''), + String(action?.active_value ?? ''), + String(action?.label_active || ''), + String(action?.label_inactive || ''), + String(mainWeatherActionIsActive(snapshot, action) ? '1' : '0'), + ]), + snapshot?.weather ? [ + String(snapshot.weather.entity_id || ''), + String(snapshot.weather.state ?? ''), + String(snapshot.weather.temperature ?? ''), + String(snapshot.weather.sensor_temperature ?? ''), + String(snapshot.weather.wind_speed ?? ''), + String(snapshot.weather.condition ?? ''), + ] : null, + ]; + } + + if (room.id === 'batteries') { + return [ + 'batteries', + room.name || '', + room.battery_summary_text || '', + Number(room.entity_count ?? 0) || 0, + Number(room.problem_count ?? room.active_entity_count ?? 0) || 0, + (Array.isArray(room.entities) ? room.entities : []).map((item) => [ + String(item?.entity_id || ''), + String(item?.battery_status || ''), + String(item?.battery_percent_text || ''), + String(item?.forecast_minutes_left ?? ''), + String(item?.forecast_text || ''), + String(item?.source_text || ''), + ]), + ]; + } + + return [ + room.id, + room.name || '', + room.icon || '', + room.visible === false ? '0' : '1', + String(room.order ?? 9999), + String(room.temperature_badge || ''), + roomGridEntries(snapshot, room.id).map((entry) => ( + entry.kind === 'layout' + ? ['layout', String(entry.id || ''), String(entry.order ?? 9999), String(entry.payload?.type || 'ghost')] + : ['entity', ...entityRenderToken(entry.payload)] + )), + state.editMode + ? roomEntitiesIncludingHidden(snapshot, room.id) + .filter((entity) => entity.visible === false) + .map(entityRenderToken) + : [], + ]; + } + + function buildVisibleSnapshotSignature(snapshot) { + const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : []; + const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || {}; + const batteryRoom = snapshot?.battery_room || null; + return JSON.stringify([ + String(snapshot?.ui?.mode || 'unknown'), + String(selectedRoom?.id || 'main'), + rooms.map(roomRenderToken), + batteryRoom ? roomRenderToken(batteryRoom) : null, + selectedRoomRenderToken(snapshot), + ]); + } + + function buildSidebarRenderSignature(snapshot) { + const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : []; + const batteryRoom = snapshot?.battery_room || null; + return JSON.stringify([ + String(state.selectedRoomId || 'main'), + String(state.editMode ? '1' : '0'), + String(state.mobileView || 'spaces'), + String(isMobileViewport() ? '1' : '0'), + rooms.map(roomRenderToken), + batteryRoom ? roomRenderToken(batteryRoom) : null, + ]); + } + + function buildContentRenderSignature(snapshot) { + const room = snapshot?.selected_room || snapshot?.selected_space || {}; + return JSON.stringify([ + String(state.selectedRoomId || room.id || 'main'), + String(room.id || 'main'), + String(state.editMode ? '1' : '0'), + String(state.mobileView || 'spaces'), + String(isMobileViewport() ? '1' : '0'), + selectedRoomRenderToken(snapshot), + ]); + } + + function buildRenderSignature(snapshot) { + const history = state.mainBoilerHistory || {}; + return JSON.stringify([ + buildVisibleSnapshotSignature(snapshot), + String(state.selectedRoomId || 'main'), + String(state.editMode ? '1' : '0'), + String(state.mobileView || 'spaces'), + String(isMobileViewport() ? '1' : '0'), + String(history.entityId || ''), + String(history.loadedAt || 0), + String(Array.isArray(history.points) ? history.points.length : 0), + String(history.loading ? '1' : '0'), + String(history.error || ''), + String(state.entityPopup?.active ? '1' : '0'), + String(state.entityPopup?.entityId || ''), + String(state.temperatureSensorPopup?.active ? '1' : '0'), + String(state.temperatureSensorPopup?.roomId || ''), + String(state.lastPopupSignature || ''), + String(state.lastEntityPopupSignature || ''), + String(state.lastTemperatureSensorPopupSignature || ''), + ]); + } + + function renderSidebarSection(snapshot) { + const nextSignature = buildSidebarRenderSignature(snapshot); + if (nextSignature === state.lastSidebarRenderSignature) { + return false; + } + state.lastSidebarRenderSignature = nextSignature; + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); + if (els.roomsCount) { + els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; + } + if (els.editModeToggle) { + els.editModeToggle.classList.toggle('is-active', state.editMode); + els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + } + return true; + } + + function renderContentSection(snapshot) { + const nextSignature = buildContentRenderSignature(snapshot); + if (nextSignature === state.lastContentRenderSignature) { + return false; + } + state.lastContentRenderSignature = nextSignature; + syncLayoutState(); + renderDashboard(snapshot); + renderSelectedRoom(snapshot); + return true; + } + function requestSummary(action, params = {}) { const summary = { action }; ['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => { @@ -309,6 +578,9 @@ } function detectEmbeddedContext() { + if (isHaRuntime()) { + return true; + } if (Boolean(bootstrap?.ui?.embed)) { return true; } @@ -378,20 +650,160 @@ return svg; } - function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { + const iconTemplateCache = new Map(); + const iconTemplatePromiseCache = new Map(); + const customBrandIconsUrl = 'https://home.striker72rus.ru/local/community/custom-brand-icons/custom-brand-icons.js'; + let customBrandIconsPromise = null; + + function templateFromSvgText(svgText) { + const template = document.createElement('template'); + template.innerHTML = String(svgText || '').trim(); + return template; + } + + function iconUrl(source) { + return `https://api.iconify.design/${source}.svg`; + } + + function ensureCustomBrandIconsLoaded() { + if (window.customIcons || window.customIconsets) { + return Promise.resolve(true); + } + if (customBrandIconsPromise) { + return customBrandIconsPromise; + } + + const existing = document.querySelector('script[data-wall-panel-custom-brand-icons="1"]'); + if (existing) { + customBrandIconsPromise = new Promise((resolve, reject) => { + if (window.customIcons || window.customIconsets) { + resolve(true); + return; + } + existing.addEventListener('load', () => resolve(true), { once: true }); + existing.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true }); + }).finally(() => { + customBrandIconsPromise = null; + }); + return customBrandIconsPromise; + } + + const script = document.createElement('script'); + script.src = customBrandIconsUrl; + script.defer = true; + script.async = true; + script.dataset.wallPanelCustomBrandIcons = '1'; + customBrandIconsPromise = new Promise((resolve, reject) => { + script.addEventListener('load', () => resolve(true), { once: true }); + script.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true }); + }).finally(() => { + customBrandIconsPromise = null; + }); + (document.head || document.documentElement).appendChild(script); + return customBrandIconsPromise; + } + + function applyTemplateToNode(target, template) { + if (!target || !template || !target.isConnected) { + return; + } + target.replaceChildren(template.content.cloneNode(true)); + } + + function primeRemoteIconTemplate(source) { + const key = `remote:${source}`; + if (iconTemplateCache.has(key)) { + return Promise.resolve(iconTemplateCache.get(key)); + } + if (iconTemplatePromiseCache.has(key)) { + return iconTemplatePromiseCache.get(key); + } + + const promise = fetch(iconUrl(source), { + cache: 'force-cache', + credentials: 'omit', + headers: { Accept: 'image/svg+xml' }, + }).then((res) => { + if (!res.ok) { + throw new Error(`Icon load failed: ${res.status}`); + } + return res.text(); + }).then((svgText) => { + const template = templateFromSvgText(svgText); + iconTemplateCache.set(key, template); + return template; + }).finally(() => { + iconTemplatePromiseCache.delete(key); + }); + + iconTemplatePromiseCache.set(key, promise); + return promise; + } + + function primeCustomIconTemplate(source) { + const key = `custom:${source}`; + if (iconTemplateCache.has(key)) { + return Promise.resolve(iconTemplateCache.get(key)); + } + if (iconTemplatePromiseCache.has(key)) { + return iconTemplatePromiseCache.get(key); + } + const [prefix, name] = source.split(':', 2); const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix]; const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon; if (typeof getIcon !== 'function') { - return null; + return Promise.resolve(null); + } + + const promise = Promise.resolve(getIcon(name)).then((definition) => { + if (!definition) { + return null; + } + const template = document.createElement('template'); + template.content.appendChild(createSvgIcon(definition)); + iconTemplateCache.set(key, template); + return template; + }).finally(() => { + iconTemplatePromiseCache.delete(key); + }); + + iconTemplatePromiseCache.set(key, promise); + return promise; + } + + function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { + const cached = iconTemplateCache.get(`custom:${source}`); + if (cached) { + return cached.content.cloneNode(true); + } + + const [prefix] = source.split(':', 2); + const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix]; + const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon; + if (typeof getIcon !== 'function') { + if (!isHaRuntime()) { + return null; + } + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + wrap.appendChild(createIconElement(fallback)); + ensureCustomBrandIconsLoaded().then(() => primeCustomIconTemplate(source)).then((template) => { + if (!template) return; + applyTemplateToNode(wrap, template); + }).catch(() => { + if (!wrap.isConnected) return; + wrap.replaceChildren(createIconElement(fallback)); + }); + return wrap; } const wrap = document.createElement('span'); wrap.className = 'icon-node'; wrap.appendChild(createIconElement(fallback)); - Promise.resolve(getIcon(name)).then((definition) => { - if (!definition || !wrap.isConnected) return; - wrap.replaceChildren(createSvgIcon(definition)); + primeCustomIconTemplate(source).then((template) => { + if (!template) return; + applyTemplateToNode(wrap, template); }).catch(() => { if (!wrap.isConnected) return; wrap.replaceChildren(createIconElement(fallback)); @@ -403,77 +815,71 @@ const source = normalizeIconSource(icon) || fallback; if (source.startsWith('mdi:')) { + if (isHaRuntime()) { + const haIcon = document.createElement('ha-icon'); + haIcon.setAttribute('icon', source); + haIcon.className = 'icon-node icon-node--ha'; + return haIcon; + } const i = document.createElement('i'); i.className = iconClass(source); + i.setAttribute('aria-hidden', 'true'); return i; } - if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) { - const custom = createCustomIconElement(source, fallback); - if (custom) { - return custom; - } - const mappedSource = source.startsWith('fas:') - ? source.replace(/^fas:/, 'fa-solid:') - : source.startsWith('far:') - ? source.replace(/^far:/, 'fa-regular:') - : source.replace(/^fab:/, 'fa-brands:'); - const wrap = document.createElement('span'); - wrap.className = 'icon-node'; - wrap.appendChild(createIconElement(fallback)); - const img = document.createElement('img'); - img.className = 'icon-node__img'; - img.alt = ''; - img.decoding = 'async'; - img.loading = 'lazy'; - img.referrerPolicy = 'no-referrer'; - img.src = `https://api.iconify.design/${mappedSource}.svg`; - img.addEventListener('load', () => { - if (!img.isConnected || !wrap.isConnected) return; - wrap.replaceChildren(img); - }); - img.addEventListener('error', () => { - if (img.dataset.fallbackApplied === '1') return; - img.dataset.fallbackApplied = '1'; - wrap.replaceChildren(createIconElement(fallback)); - }); - wrap.appendChild(img); - return wrap; - } - const custom = createCustomIconElement(source, fallback); if (custom) { return custom; } + if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) { + const mappedSource = source.startsWith('fas:') + ? source.replace(/^fas:/, 'fa-solid:') + : source.startsWith('far:') + ? source.replace(/^far:/, 'fa-regular:') + : source.replace(/^fab:/, 'fa-brands:'); + const cached = iconTemplateCache.get(`remote:${mappedSource}`); + if (cached) { + return cached.content.cloneNode(true); + } + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + wrap.appendChild(createIconElement(fallback)); + primeRemoteIconTemplate(mappedSource).then((template) => { + applyTemplateToNode(wrap, template); + }).catch(() => { + if (!wrap.isConnected) return; + wrap.replaceChildren(createIconElement(fallback)); + }); + return wrap; + } + + const remoteCached = iconTemplateCache.get(`remote:${source}`); + if (remoteCached) { + return remoteCached.content.cloneNode(true); + } + const wrap = document.createElement('span'); wrap.className = 'icon-node'; wrap.appendChild(createIconElement(fallback)); if (source.includes(':')) { - const img = document.createElement('img'); - img.className = 'icon-node__img'; - img.alt = ''; - img.decoding = 'async'; - img.loading = 'lazy'; - img.referrerPolicy = 'no-referrer'; - img.src = `https://api.iconify.design/${source}.svg`; - img.addEventListener('load', () => { - if (!img.isConnected || !wrap.isConnected) return; - wrap.replaceChildren(img); - }); - img.addEventListener('error', () => { - if (img.dataset.fallbackApplied === '1') return; - img.dataset.fallbackApplied = '1'; + primeRemoteIconTemplate(source).then((template) => { + applyTemplateToNode(wrap, template); + }).catch(() => { + if (!wrap.isConnected) return; wrap.replaceChildren(createIconElement(fallback)); }); - wrap.appendChild(img); return wrap; } return createIconElement(fallback); } + if (isHaRuntime()) { + void ensureCustomBrandIconsLoaded(); + } + function esc(value) { return String(value ?? ''); } @@ -550,6 +956,9 @@ } function popupTriggerEntities() { + if (isHaRuntime()) { + return new Set(); + } const fromSnapshot = state.snapshot?.settings?.camera?.trigger_entities; const fromBootstrap = bootstrap?.settings?.camera?.trigger_entities; const triggers = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : []; @@ -672,6 +1081,14 @@ debugLog('apiGet via bridge', requestSummary(action, params)); return bridge.request('GET', action, params); } + if (isHaRuntime()) { + const waited = await waitForHaBridge(1000); + if (waited?.request) { + debugLog('apiGet via delayed bridge', requestSummary(action, params)); + return waited.request('GET', action, params); + } + throw new Error('HA bridge is not ready'); + } debugLog('apiGet via http', requestSummary(action, params)); const res = await fetch(buildUrl(action, params), { headers: { Accept: 'application/json' }, @@ -689,6 +1106,14 @@ debugLog('apiPost via bridge', requestSummary(action, payload)); return bridge.request('POST', action, payload); } + if (isHaRuntime()) { + const waited = await waitForHaBridge(1000); + if (waited?.request) { + debugLog('apiPost via delayed bridge', requestSummary(action, payload)); + return waited.request('POST', action, payload); + } + throw new Error('HA bridge is not ready'); + } debugLog('apiPost via http', requestSummary(action, payload)); const res = await fetch(buildUrl(action), { method: 'POST', @@ -1183,12 +1608,16 @@ if (!els.appShell) return; const mobile = isMobileViewport(); - const embedded = Boolean(state.embedMode); + const haNative = isHaRuntime(); + const embedded = Boolean(state.embedMode || haNative); + state.embedMode = embedded; document.body.classList.toggle('is-mobile-ui', mobile); document.body.classList.toggle('is-embedded', embedded); + document.body.classList.toggle('is-ha-native', haNative); els.appShell.classList.toggle('is-mobile', mobile); els.appShell.classList.toggle('is-desktop', !mobile); els.appShell.classList.toggle('app-shell--embed', embedded); + els.appShell.classList.toggle('is-ha-native', haNative); els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room'); els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room'); @@ -1865,6 +2294,9 @@ } function applyPopupState(active, sensorEntityId) { + if (isHaRuntime()) { + return; + } const camera = state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; const popup = state.snapshot?.popup || {}; if (active && Date.now() < Number(state.popupAutoOpenBlockedUntil || 0)) { @@ -1888,6 +2320,9 @@ } function applyPopupSnapshot(popup = {}) { + if (isHaRuntime()) { + return; + } const snapshot = state.snapshot || bootstrap; snapshot.popup = mergePopupWithCamera({ ...(snapshot.popup || {}), @@ -1897,6 +2332,9 @@ } function syncTriggerPopup(entityId, stateValue) { + if (isHaRuntime()) { + return; + } const value = String(stateValue || '').toLowerCase(); if (!['on', 'off'].includes(value)) { return; @@ -2870,13 +3308,13 @@ const minus = document.createElement('button'); minus.type = 'button'; minus.className = 'round-button entity-modal__round-button'; - minus.innerHTML = ''; + minus.appendChild(createIconElement('mdi:minus')); minus.addEventListener('click', () => handleClimateTemperature(entity, -1)); const plus = document.createElement('button'); plus.type = 'button'; plus.className = 'round-button entity-modal__round-button'; - plus.innerHTML = ''; + plus.appendChild(createIconElement('mdi:plus')); plus.addEventListener('click', () => handleClimateTemperature(entity, 1)); controls.append(minus, plus); @@ -3349,13 +3787,15 @@ const upBtn = document.createElement('button'); upBtn.type = 'button'; upBtn.className = 'mushroom-button mushroom-button--small'; - upBtn.innerHTML = ' Вверх'; + upBtn.appendChild(createIconElement('mdi:arrow-up')); + upBtn.appendChild(document.createTextNode(' Вверх')); upBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, -1)); const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.className = 'mushroom-button mushroom-button--small'; - downBtn.innerHTML = ' Вниз'; + downBtn.appendChild(createIconElement('mdi:arrow-down')); + downBtn.appendChild(document.createTextNode(' Вниз')); downBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1)); actions.append(upBtn, downBtn); @@ -3371,7 +3811,7 @@ const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'mini-action mini-action--wide'; - btn.innerHTML = hidden ? '' : ''; + btn.appendChild(createIconElement(hidden ? 'mdi:eye' : 'mdi:eye-off')); btn.title = hidden ? 'Показать' : 'Скрыть'; btn.addEventListener('click', (event) => { event.stopPropagation(); @@ -3484,7 +3924,8 @@ const settingsBtn = document.createElement('button'); settingsBtn.type = 'button'; settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; - settingsBtn.innerHTML = ' Настройки'; + settingsBtn.appendChild(createIconElement('mdi:cog-outline')); + settingsBtn.appendChild(document.createTextNode(' Настройки')); settingsBtn.addEventListener('click', (event) => { event.stopPropagation(); state.layoutItemSettingsOpen = { @@ -3497,7 +3938,8 @@ const upBtn = document.createElement('button'); upBtn.type = 'button'; upBtn.className = 'mushroom-button mushroom-button--small'; - upBtn.innerHTML = ' Вверх'; + upBtn.appendChild(createIconElement('mdi:arrow-up')); + upBtn.appendChild(document.createTextNode(' Вверх')); upBtn.addEventListener('click', (event) => { event.stopPropagation(); reorderRoomGridEntry(room.id, 'layout', item.id, -1); @@ -3506,7 +3948,8 @@ const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.className = 'mushroom-button mushroom-button--small'; - downBtn.innerHTML = ' Вниз'; + downBtn.appendChild(createIconElement('mdi:arrow-down')); + downBtn.appendChild(document.createTextNode(' Вниз')); downBtn.addEventListener('click', (event) => { event.stopPropagation(); reorderRoomGridEntry(room.id, 'layout', item.id, 1); @@ -3515,7 +3958,8 @@ const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; - deleteBtn.innerHTML = ' Удалить'; + deleteBtn.appendChild(createIconElement('mdi:delete-outline')); + deleteBtn.appendChild(document.createTextNode(' Удалить')); deleteBtn.addEventListener('click', (event) => { event.stopPropagation(); deleteRoomLayoutItem(room.id, item.id); @@ -3531,7 +3975,8 @@ const tempBtn = document.createElement('button'); tempBtn.type = 'button'; tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; - tempBtn.innerHTML = ' Выбрать датчик температуры'; + tempBtn.appendChild(createIconElement('mdi:thermometer')); + tempBtn.appendChild(document.createTextNode(' Выбрать датчик температуры')); tempBtn.addEventListener('click', (event) => { event.stopPropagation(); openTemperatureSensorPopup(room.id); @@ -3654,6 +4099,12 @@ } els.roomList.innerHTML = ''; const sortedRooms = [...(rooms || [])].sort((left, right) => { + const leftVisible = left?.visible === false ? 1 : 0; + const rightVisible = right?.visible === false ? 1 : 0; + if (leftVisible !== rightVisible) { + return leftVisible - rightVisible; + } + if (left.id === 'main') return -1; if (right.id === 'main') return 1; @@ -3841,7 +4292,8 @@ const addButton = document.createElement('button'); addButton.type = 'button'; addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; - addButton.innerHTML = 'Пустая карточка'; + addButton.appendChild(createIconElement('mdi:plus')); + addButton.appendChild(document.createElement('span')).textContent = 'Пустая карточка'; addButton.addEventListener('click', () => { createRoomLayoutItem(room.id); }); @@ -3849,7 +4301,8 @@ const temperatureButton = document.createElement('button'); temperatureButton.type = 'button'; temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; - temperatureButton.innerHTML = 'Выбрать датчик температуры'; + temperatureButton.appendChild(createIconElement('mdi:thermometer')); + temperatureButton.appendChild(document.createElement('span')).textContent = 'Выбрать датчик температуры'; temperatureButton.addEventListener('click', () => { openTemperatureSensorPopup(room.id); }); @@ -3991,6 +4444,10 @@ } function renderPopup(snapshot) { + if (isHaRuntime()) { + hidePopup({ preserveSnapshot: true }); + return; + } if (isMobileViewport()) { hidePopup({ preserveSnapshot: true }); return; @@ -4123,6 +4580,9 @@ } async function showDebugPopup() { + if (isHaRuntime()) { + return; + } try { const response = await apiPost('popup', { command: 'open' }); const snapshot = state.snapshot || bootstrap; @@ -4266,53 +4726,25 @@ return; } - const renderSignature = JSON.stringify([ - snapshot?.selected_room?.id || snapshot?.selected_space?.id || 'main', - Array.isArray(snapshot?.rooms) ? snapshot.rooms.length : Array.isArray(snapshot?.spaces) ? snapshot.spaces.length : 0, - Array.isArray(snapshot?.main_entities) ? snapshot.main_entities.length : 0, - Boolean(snapshot?.popup?.active), - Boolean(snapshot?.ui?.mode === 'ha-native'), - ]); + const renderSignature = buildRenderSignature(snapshot); + if (renderSignature === state.lastRenderSignature) { + return; + } + state.lastRenderSignature = renderSignature; if (renderSignature !== state.debugLastRenderSignature) { state.debugLastRenderSignature = renderSignature; debugLog('render()', snapshotSummary(snapshot)); } - syncLayoutState(); - renderDashboard(snapshot); - renderSelectedRoom(snapshot); - renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + renderSidebarSection(snapshot); + renderContentSection(snapshot); renderPopup(snapshot); renderEntityPopup(snapshot); renderTemperatureSensorPopup(snapshot); - - const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - if (els.roomsCount) { - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - } - if (els.editModeToggle) { - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; - } } function renderDashboardOnly() { - const snapshot = state.snapshot || bootstrap; - if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; - syncLayoutState(); - renderSelectedRoom(snapshot); - renderDashboard(snapshot); - renderPopup(snapshot); - renderEntityPopup(snapshot); - renderTemperatureSensorPopup(snapshot); - const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - if (els.roomsCount) { - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - } - if (els.editModeToggle) { - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; - } + renderContentSection(state.snapshot || bootstrap); } function refreshCurrentRoomLayout(entityId) { @@ -4357,24 +4789,11 @@ } function renderSidebarOnly() { - const snapshot = state.snapshot || bootstrap; - if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; - renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); - const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - if (els.roomsCount) { - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - } - if (els.editModeToggle) { - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; - } + renderSidebarSection(state.snapshot || bootstrap); } function renderSelectionOnly() { - const snapshot = state.snapshot || bootstrap; - if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; - syncLayoutState(); - renderSelectedRoom(snapshot); + renderContentSection(state.snapshot || bootstrap); } async function handleEntityAction(entity, command) { @@ -4586,32 +5005,34 @@ renderSelectionOnly(); }); - bind(els.cameraBackdrop, 'click', (event) => { - if (event.target === els.cameraBackdrop) { - apiPost('popup', { command: 'close' }).catch(() => {}); - hidePopup({ suppressAutoOpen: true }); - } - }); + if (!isHaRuntime()) { + bind(els.cameraBackdrop, 'click', (event) => { + if (event.target === els.cameraBackdrop) { + apiPost('popup', { command: 'close' }).catch(() => {}); + hidePopup({ suppressAutoOpen: true }); + } + }); - bind(els.cameraModalPanel, 'click', (event) => { - event.stopPropagation(); - }); - - const closeCameraPopup = async (event) => { - if (event) { - event.preventDefault(); + bind(els.cameraModalPanel, 'click', (event) => { event.stopPropagation(); - } - try { - await apiPost('popup', { command: 'close' }); - } catch (error) { - console.warn(error); - } - hidePopup({ suppressAutoOpen: true }); - }; + }); - bind(els.cameraClose, 'pointerdown', closeCameraPopup); - bind(els.cameraClose, 'click', closeCameraPopup); + const closeCameraPopup = async (event) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + try { + await apiPost('popup', { command: 'close' }); + } catch (error) { + console.warn(error); + } + hidePopup({ suppressAutoOpen: true }); + }; + + bind(els.cameraClose, 'pointerdown', closeCameraPopup); + bind(els.cameraClose, 'click', closeCameraPopup); + } els.entityBackdrop?.addEventListener('click', (event) => { if (event.target === els.entityBackdrop) { @@ -4975,7 +5396,7 @@ mode: bootstrap?.ui?.mode || 'unknown', }); initRefs(); - state.embedMode = detectEmbeddedContext(); + state.embedMode = detectEmbeddedContext() || isHaRuntime(); syncLayoutState(); syncViewportState(); bindPressFeedback();