From 03f21e2de87a866d257ab3ba8e9c4bef53de78da Mon Sep 17 00:00:00 2001 From: Striker72rus Date: Wed, 25 Mar 2026 19:34:45 +0300 Subject: [PATCH] - --- assets/app.css | 5 + assets/app.js | 541 +- custom_components/wall_panel/assets/app.css | 2574 +++++++++ custom_components/wall_panel/assets/app.js | 4932 +++++++++++++++++ custom_components/wall_panel/config_flow.py | 18 +- custom_components/wall_panel/frontend.py | 27 +- .../wall_panel/frontend/panel.js | 1887 ++++++- custom_components/wall_panel/helpers.py | 89 +- custom_components/wall_panel/views.py | 39 +- storage/battery_cache.json | 788 +-- storage/popup_state.json | 4 +- wall_panel/assets/app.css | 5 + wall_panel/assets/app.js | 541 +- 13 files changed, 10658 insertions(+), 792 deletions(-) create mode 100755 custom_components/wall_panel/assets/app.css create mode 100755 custom_components/wall_panel/assets/app.js diff --git a/assets/app.css b/assets/app.css index 25bfd91..786893a 100755 --- a/assets/app.css +++ b/assets/app.css @@ -2115,6 +2115,11 @@ body.is-mobile-ui #camera-modal { max-width: none; } +.main-dashboard__cards .main-dashboard__room-grid { + grid-column: 1 / -1; + width: 100%; +} + .room-entities-section { display: grid; gap: 14px; diff --git a/assets/app.js b/assets/app.js index 4f96f47..66cefea 100755 --- a/assets/app.js +++ b/assets/app.js @@ -42,20 +42,210 @@ haSubscribeId: 1, roomSelectionToken: 0, snapshotPollTimer: null, + haSnapshotListenerInstalled: false, + debugLastRenderSignature: '', }; const els = {}; + const client = window.StrikerPanelClient || (window.StrikerPanelClient = {}); + 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') { + return; + } + state.snapshot = snapshot; + initRefs(); + state.embedMode = detectEmbeddedContext(); + syncLayoutState(); + render(); + }; + + client.refresh = () => { + initRefs(); + state.embedMode = detectEmbeddedContext(); + syncLayoutState(); + render(); + }; function $(id) { - return document.getElementById(id); + const root = client.mountRoot || document; + if (!root) return null; + if (typeof root.getElementById === 'function') { + return root.getElementById(id); + } + return root.querySelector?.(`#${CSS.escape(id)}`) || null; } - function q(sel, root = document) { - return root.querySelector(sel); + function q(sel, root) { + const actualRoot = root || client.mountRoot || document; + return actualRoot.querySelector(sel); } - function qa(sel, root = document) { - return Array.from(root.querySelectorAll(sel)); + function qa(sel, root) { + const actualRoot = root || client.mountRoot || document; + return Array.from(actualRoot.querySelectorAll(sel)); + } + + function haBridge() { + return window.WALL_PANEL_HA_BRIDGE || null; + } + + function isHaRuntime() { + return Boolean( + haBridge() + || bootstrap?.ui?.mode === 'ha-native' + ); + } + + function sleep(ms) { + return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); + } + + function snapshotLooksReady(snapshot) { + if (!snapshot || typeof snapshot !== 'object') { + return false; + } + const rooms = Array.isArray(snapshot.rooms) ? snapshot.rooms : []; + const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : []; + if (rooms.length > 0 || spaces.length > 0) { + return true; + } + if (snapshot.selected_room?.id || snapshot.selected_space?.id) { + return true; + } + 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 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; + } + + const waitForHaBridge = async (timeoutMs = 1000) => { + const startedAt = Date.now(); + let bridge = haBridge(); + while (!bridge && (Date.now() - startedAt) < timeoutMs) { + await sleep(50); + bridge = haBridge(); + } + return bridge; + }; + + 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) { + 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) { + debugWarn('initial snapshot bridge.request(GET snapshot) failed', error); + } + + return null; + }; + + const firstPass = await tryBridgeSnapshot(); + if (firstPass) { + return firstPass; + } + + await sleep(150); + + const secondPass = await tryBridgeSnapshot(); + if (secondPass) { + return secondPass; + } + + debugLog('resolveInitialSnapshot -> fallback bootstrap', snapshotSummary(bootstrapSnapshot)); + return snapshotLooksReady(bootstrapSnapshot) ? bootstrapSnapshot : bootstrapSnapshot; } const PRESSABLE_SELECTOR = [ @@ -198,6 +388,7 @@ 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)); @@ -229,6 +420,7 @@ : 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 = ''; @@ -236,6 +428,10 @@ 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'; @@ -252,6 +448,7 @@ const wrap = document.createElement('span'); wrap.className = 'icon-node'; + wrap.appendChild(createIconElement(fallback)); if (source.includes(':')) { const img = document.createElement('img'); @@ -261,6 +458,10 @@ 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'; @@ -448,6 +649,9 @@ } function buildUrl(action, params = {}) { + if (isHaRuntime()) { + return ''; + } const url = new URL('api.php', window.location.href); url.searchParams.set('action', action); Object.entries(params).forEach(([key, value]) => { @@ -463,6 +667,12 @@ } async function apiGet(action, params = {}) { + const bridge = haBridge(); + if (bridge?.request) { + console.log('[Striker Panel]', 'apiGet via bridge', requestSummary(action, params)); + return bridge.request('GET', action, params); + } + console.log('[Striker Panel]', 'apiGet via http', requestSummary(action, params)); const res = await fetch(buildUrl(action, params), { headers: { Accept: 'application/json' }, cache: 'no-store', @@ -474,6 +684,12 @@ } async function apiPost(action, payload = {}) { + const bridge = haBridge(); + if (bridge?.request) { + console.log('[Striker Panel]', 'apiPost via bridge', requestSummary(action, payload)); + return bridge.request('POST', action, payload); + } + console.log('[Striker Panel]', 'apiPost via http', requestSummary(action, payload)); const res = await fetch(buildUrl(action), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, @@ -487,7 +703,11 @@ } async function fetchSnapshot(roomId = state.selectedRoomId || 'main') { - return apiGet('snapshot', { space_id: roomId || 'main' }); + const response = await apiGet('snapshot', { space_id: roomId || 'main' }); + if (response && response.ok === true && response.selected_room) { + return response; + } + return response; } async function loadSnapshot(roomId = state.selectedRoomId || 'main') { @@ -1004,6 +1224,87 @@ return q('.main-dashboard__cards', els.dashboardSurface); } + function renderMainRoomSummaryGrid(snapshot) { + const rooms = Array.isArray(snapshot?.spaces) ? snapshot.spaces : Array.isArray(snapshot?.rooms) ? snapshot.rooms : []; + const batteryRoom = snapshot?.battery_room || null; + const cards = document.createElement('div'); + cards.className = 'room-list__group main-dashboard__room-grid'; + + const roomCard = (room, options = {}) => { + if (!room) return null; + const card = document.createElement('div'); + card.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${options.hidden ? 'is-hidden-room' : ''}`.trim(); + card.dataset.roomId = room.id; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + card.addEventListener('click', () => setSelectedRoom(room.id)); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedRoom(room.id); + } + }); + + const content = document.createElement('div'); + content.className = 'room-item__content'; + + const icon = document.createElement('div'); + icon.className = 'room-item__icon'; + icon.appendChild(createIconElement(room.icon || 'mdi:home-variant')); + + const body = document.createElement('div'); + body.className = 'room-item__body'; + const activeCount = room.id === 'batteries' + ? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0 + : Number(room.active_entity_count ?? room.entity_count ?? 0) || 0; + const metaText = room.id === 'main' + ? 'Главный экран' + : room.id === 'batteries' + ? (room.battery_summary_text || `${room.entity_count || 0} батареек`) + : activeCount > 0 + ? `${activeCount} ${pluralizeActiveEntities(activeCount)}` + : 'Нет активных'; + body.innerHTML = ` +
${esc(room.name || '')}
+
${esc(metaText)}
+ `; + content.append(icon, body); + + const tempBadge = roomTemperatureBadge(snapshot, room); + if (tempBadge) { + card.classList.add('has-temp'); + const temp = document.createElement('div'); + temp.className = 'room-item__temp'; + temp.textContent = tempBadge; + card.appendChild(temp); + } + + card.append(content); + return card; + }; + + const orderedRooms = [...rooms] + .filter((room) => room && room.id !== 'main' && room.visible !== false && room.id !== 'batteries') + .sort((left, right) => { + const leftOrder = Number(left.order ?? 9999); + const rightOrder = Number(right.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left.name || '').localeCompare(String(right.name || ''), 'ru'); + }); + + orderedRooms.forEach((room) => { + const card = roomCard(room); + if (card) cards.appendChild(card); + }); + + if (batteryRoom && !isMobileViewport()) { + const card = roomCard(batteryRoom); + if (card) cards.appendChild(card); + } + + return cards; + } + function currentDashboardCardsContainer() { const snapshot = state.snapshot || bootstrap; const room = snapshot.selected_space || snapshot.selected_room || {}; @@ -1613,10 +1914,16 @@ } function haConnection() { + if (isHaRuntime()) { + return null; + } return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {}; } function haWsUrl(baseUrl) { + if (isHaRuntime()) { + return ''; + } if (!baseUrl) return ''; try { const url = new URL(baseUrl); @@ -3342,6 +3649,9 @@ } function renderRoomButtons(snapshot, rooms, batteryRoom = null) { + if (!els.roomList) { + return; + } els.roomList.innerHTML = ''; const sortedRooms = [...(rooms || [])].sort((left, right) => { if (left.id === 'main') return -1; @@ -3466,6 +3776,11 @@ function renderSelectedRoom(snapshot) { const room = snapshot.selected_space || snapshot.selected_room || {}; + const setText = (el, value) => { + if (el) { + el.textContent = value; + } + }; if (els.contentTop) { els.contentTop.classList.toggle('is-main', room.id === 'main'); } @@ -3474,8 +3789,8 @@ } updateMainPrintStrip(snapshot); if (room.id === 'batteries') { - els.selectedRoomEyebrow.textContent = 'Псевдо-комната'; - els.selectedRoomTitle.textContent = room.name || 'Батарейки'; + setText(els.selectedRoomEyebrow, 'Псевдо-комната'); + setText(els.selectedRoomTitle, room.name || 'Батарейки'); const total = Number(room.entity_count ?? 0) || 0; const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0; const unavailable = Number(room.unavailable_count ?? 0) || 0; @@ -3490,26 +3805,26 @@ if (unknown > 0) { summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`); } - els.selectedRoomMeta.textContent = summaryParts.length + setText(els.selectedRoomMeta, summaryParts.length ? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}` - : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`; + : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`); renderSelectedRoomActions(snapshot); return; } if (room.id !== 'main') { - els.selectedRoomEyebrow.textContent = 'Пространство'; - els.selectedRoomTitle.textContent = room.name || 'Панель'; + setText(els.selectedRoomEyebrow, 'Пространство'); + setText(els.selectedRoomTitle, room.name || 'Панель'); const entities = roomEntities(snapshot, room.id || 'main'); const activeCount = Number(room.active_entity_count ?? entities.length) || 0; - els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`; + setText(els.selectedRoomMeta, `${activeCount} ${pluralizeActiveEntities(activeCount)}`); renderSelectedRoomActions(snapshot); return; } const entities = roomEntities(snapshot, room.id || 'main'); - els.selectedRoomEyebrow.textContent = ''; - els.selectedRoomTitle.textContent = room.name || 'Панель'; - els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`; + setText(els.selectedRoomEyebrow, ''); + setText(els.selectedRoomTitle, room.name || 'Панель'); + setText(els.selectedRoomMeta, `${entities.length} ${pluralizeIncludedEntities(entities.length)}`); renderSelectedRoomActions(snapshot); } @@ -3545,23 +3860,32 @@ function renderDashboard(snapshot) { const room = snapshot.selected_space || snapshot.selected_room || {}; const grid = els.dashboardSurface; + if (!grid) { + return; + } grid.innerHTML = ''; if (room.id === 'main') { const layout = document.createElement('div'); layout.className = 'main-dashboard'; - const hero = renderMainHero(snapshot); - + const mainEntities = roomEntities(snapshot, 'main'); const cards = document.createElement('div'); cards.className = 'grid-surface main-dashboard__cards'; - const mainEntities = roomEntities(snapshot, 'main'); - mainEntities.forEach((entity) => { - cards.appendChild(renderEntityCard(entity, { isMain: true })); - }); + if (mainEntities.length) { + mainEntities.forEach((entity) => { + cards.appendChild(renderEntityCard(entity, { isMain: true })); + }); + } + + const hero = renderMainHero(snapshot); + + layout.append(hero); + if (mainEntities.length) { + layout.append(cards); + } - layout.append(hero, cards); grid.appendChild(layout); return; } @@ -3683,8 +4007,10 @@ ]); if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) { - els.cameraBackdrop.classList.add('is-open'); - els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + if (els.cameraBackdrop) { + els.cameraBackdrop.classList.add('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + } return; } @@ -3695,11 +4021,17 @@ return; } - els.cameraPoster.src = popup.poster_url || ''; - els.cameraPoster.alt = popup.sensor_entity_id || 'camera'; - els.cameraBackdrop.classList.add('is-open'); - els.cameraBackdrop.setAttribute('aria-hidden', 'false'); - els.cameraPlaceholder.classList.add('is-visible'); + if (els.cameraPoster) { + els.cameraPoster.src = popup.poster_url || ''; + els.cameraPoster.alt = popup.sensor_entity_id || 'camera'; + } + if (els.cameraBackdrop) { + els.cameraBackdrop.classList.add('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + } + if (els.cameraPlaceholder) { + els.cameraPlaceholder.classList.add('is-visible'); + } const expiresAt = Number(popup.expires_at || 0); if (expiresAt > 0) { @@ -3709,11 +4041,15 @@ const mins = Math.floor(remaining / 60); const secs = remaining % 60; if (remaining > 0) { - els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`; + if (els.cameraCountdown) { + els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`; + } return; } - els.cameraCountdown.textContent = 'Закрытие...'; + if (els.cameraCountdown) { + els.cameraCountdown.textContent = 'Закрытие...'; + } if (closeRequested) { return; } @@ -3736,7 +4072,9 @@ clearInterval(state.popupDismissTimer); state.popupDismissTimer = setInterval(updateCountdown, 1000); } else { - els.cameraCountdown.textContent = ''; + if (els.cameraCountdown) { + els.cameraCountdown.textContent = ''; + } clearInterval(state.popupDismissTimer); state.popupDismissTimer = null; } @@ -3759,14 +4097,26 @@ active: false, }; } - els.cameraBackdrop.classList.remove('is-open'); - els.cameraBackdrop.setAttribute('aria-hidden', 'true'); - els.cameraStage.innerHTML = ''; - els.cameraStage.appendChild(els.cameraPoster); - els.cameraStage.appendChild(els.cameraPlaceholder); - els.cameraPlaceholder.classList.add('is-visible'); - els.cameraPoster.removeAttribute('src'); - els.cameraCountdown.textContent = ''; + if (els.cameraBackdrop) { + els.cameraBackdrop.classList.remove('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'true'); + } + if (els.cameraStage) { + els.cameraStage.innerHTML = ''; + if (els.cameraPoster) { + els.cameraStage.appendChild(els.cameraPoster); + } + if (els.cameraPlaceholder) { + els.cameraStage.appendChild(els.cameraPlaceholder); + els.cameraPlaceholder.classList.add('is-visible'); + } + } + if (els.cameraPoster) { + els.cameraPoster.removeAttribute('src'); + } + if (els.cameraCountdown) { + els.cameraCountdown.textContent = ''; + } clearInterval(state.popupDismissTimer); state.popupDismissTimer = null; destroyStream(); @@ -3916,18 +4266,34 @@ 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'), + ]); + if (renderSignature !== state.debugLastRenderSignature) { + state.debugLastRenderSignature = renderSignature; + debugLog('render()', snapshotSummary(snapshot)); + } + syncLayoutState(); - renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); - renderSelectedRoom(snapshot); renderDashboard(snapshot); + renderSelectedRoom(snapshot); + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); renderPopup(snapshot); renderEntityPopup(snapshot); renderTemperatureSensorPopup(snapshot); const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + 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() { @@ -3939,6 +4305,14 @@ 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 refreshCurrentRoomLayout(entityId) { @@ -3968,6 +4342,9 @@ } const container = els.dashboardSurface; + if (!container) { + return; + } const order = new Map(roomEntities(snapshot, room.id).map((entity) => [entity.entity_id, Number(entity.order ?? 9999)])); const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]')); cards.sort((left, right) => { @@ -3984,9 +4361,13 @@ 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); - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + 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 renderSelectionOnly() { @@ -4191,6 +4572,11 @@ } function wireEvents() { + const bind = (el, type, handler, options) => { + if (!el) return; + el.addEventListener(type, handler, options); + }; + els.selectedRoomBack?.addEventListener('click', () => { if (!isMobileViewport()) return; closeEntityPopup(); @@ -4200,14 +4586,14 @@ renderSelectionOnly(); }); - els.cameraBackdrop.addEventListener('click', (event) => { + bind(els.cameraBackdrop, 'click', (event) => { if (event.target === els.cameraBackdrop) { apiPost('popup', { command: 'close' }).catch(() => {}); hidePopup({ suppressAutoOpen: true }); } }); - els.cameraModalPanel.addEventListener('click', (event) => { + bind(els.cameraModalPanel, 'click', (event) => { event.stopPropagation(); }); @@ -4224,8 +4610,8 @@ hidePopup({ suppressAutoOpen: true }); }; - els.cameraClose.addEventListener('pointerdown', closeCameraPopup); - els.cameraClose.addEventListener('click', closeCameraPopup); + bind(els.cameraClose, 'pointerdown', closeCameraPopup); + bind(els.cameraClose, 'click', closeCameraPopup); els.entityBackdrop?.addEventListener('click', (event) => { if (event.target === els.entityBackdrop) { @@ -4233,7 +4619,7 @@ } }); - els.entityModalPanel?.addEventListener('click', (event) => { + bind(els.entityModalPanel, 'click', (event) => { event.stopPropagation(); }); @@ -4247,19 +4633,19 @@ } }); - els.temperatureSensorModalPanel?.addEventListener('click', (event) => { + bind(els.temperatureSensorModalPanel, 'click', (event) => { event.stopPropagation(); }); - els.temperatureSensorClose?.addEventListener('click', () => { + bind(els.temperatureSensorClose, 'click', () => { closeTemperatureSensorPopup(); }); - els.popupDebugButton?.addEventListener('click', () => { + bind(els.popupDebugButton, 'click', () => { showDebugPopup(); }); - els.editModeToggle.addEventListener('click', async () => { + bind(els.editModeToggle, 'click', async () => { state.editMode = !state.editMode; try { await apiPost('save-settings', { @@ -4380,6 +4766,9 @@ } function startSnapshotPolling() { + if (isHaRuntime()) { + return; + } const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000)); if (state.snapshotPollTimer) { clearInterval(state.snapshotPollTimer); @@ -4396,6 +4785,9 @@ } function handleHaMessage(message) { + if (isHaRuntime()) { + return; + } if (!message || typeof message !== 'object') { return; } @@ -4519,6 +4911,11 @@ } function connectRealtime() { + if (isHaRuntime()) { + setStatus('HA native mode', 'online'); + stopSnapshotPolling(); + return; + } const connection = haConnection(); const baseUrl = connection.base_url || ''; const token = connection.token || ''; @@ -4572,6 +4969,11 @@ } async function start() { + debugLog('start()', { + ha_runtime: isHaRuntime(), + embed_mode: Boolean(bootstrap?.ui?.embed), + mode: bootstrap?.ui?.mode || 'unknown', + }); initRefs(); state.embedMode = detectEmbeddedContext(); syncLayoutState(); @@ -4582,6 +4984,19 @@ state.clockTimer = setInterval(updateClock, 1000); wireEvents(); + if (!state.haSnapshotListenerInstalled) { + state.haSnapshotListenerInstalled = true; + window.addEventListener('wall-panel-snapshot-updated', (event) => { + const snapshot = event?.detail?.snapshot || event?.detail || null; + if (!snapshot || typeof snapshot !== 'object') { + return; + } + debugLog('wall-panel-snapshot-updated', snapshotSummary(snapshot)); + state.snapshot = snapshot; + render(); + }); + } + const viewportQuery = mobileViewportQuery(); const handleViewportChange = () => { syncViewportState(); @@ -4593,8 +5008,8 @@ viewportQuery.addListener(handleViewportChange); } - const initial = window.APP_BOOTSTRAP || {}; - state.snapshot = initial; + state.snapshot = await resolveInitialSnapshot(); + debugLog('initial snapshot applied', snapshotSummary(state.snapshot || bootstrap)); render(); connectRealtime(); if (!state.snapshotPollTimer) { @@ -4602,5 +5017,9 @@ } } - document.addEventListener('DOMContentLoaded', start); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } })(); diff --git a/custom_components/wall_panel/assets/app.css b/custom_components/wall_panel/assets/app.css new file mode 100755 index 0000000..786893a --- /dev/null +++ b/custom_components/wall_panel/assets/app.css @@ -0,0 +1,2574 @@ +:root { + color-scheme: dark; + --bg: #090b10; + --bg-elevated: #11141b; + --surface: rgba(22, 25, 33, 0.92); + --surface-strong: #1c2028; + --surface-soft: rgba(32, 36, 46, 0.82); + --surface-bright: #eef1f7; + --text: #edf0f6; + --text-muted: rgba(237, 240, 246, 0.62); + --text-subtle: rgba(237, 240, 246, 0.42); + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.16); + --accent: #67d6ff; + --accent-2: #88f0c7; + --warning: #ffcf6d; + --danger: #ff7d7d; + --shadow: 0 16px 48px rgba(0, 0, 0, 0.34); + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 18px; + --radius-sm: 14px; + --sidebar-width: 400px; + --content-pad: 32px; + --font-sans: "Manrope", "Segoe UI", sans-serif; + --font-display: "Space Grotesk", "Manrope", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); + background: + radial-gradient(circle at top left, rgba(77, 110, 138, 0.15), transparent 28%), + radial-gradient(circle at bottom right, rgba(63, 168, 140, 0.12), transparent 24%), + linear-gradient(180deg, #080a0f 0%, #0d1118 100%); + color: var(--text); + overflow: hidden; +} + +body.is-embedded { + overflow: auto; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.app-shell { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + min-height: 100vh; + height: 100vh; +} + +.app-shell--embed { + grid-template-columns: clamp(250px, 22vw, 320px) minmax(0, 1fr); + height: auto; + min-height: 100vh; +} + +.sidebar { + position: relative; + padding: 24px 20px 20px 20px; + background: linear-gradient(180deg, rgba(10, 12, 17, 0.98), rgba(12, 14, 20, 0.88)); + border-right: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.app-shell--embed .sidebar { + padding: 18px 16px 16px; + border-right: 1px solid rgba(255, 255, 255, 0.05); +} + +.sidebar::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(180deg, rgba(255,255,255,0.03), transparent 12%), + linear-gradient(90deg, rgba(102, 214, 255, 0.04), transparent 24%); + mix-blend-mode: screen; +} + +.app-shell--embed .sidebar::after { + display: none; +} + +.clock-panel { + padding: 12px 8px 12px 8px; +} + +.app-shell--embed .clock-panel { + padding: 0 0 8px; +} + +.clock-panel__time { + font-family: var(--font-display); + font-size: clamp(56px, 6.4vw, 82px); + line-height: 0.94; + letter-spacing: -0.06em; + font-weight: 700; +} + +.clock-panel__date { + margin-top: 8px; + color: var(--text-muted); + font-size: 18px; + line-height: 1.1; + white-space: nowrap; +} + +.app-shell--embed .clock-panel__time { + font-size: clamp(34px, 3.8vw, 56px); +} + +.app-shell--embed .clock-panel__date { + font-size: 14px; + white-space: normal; +} + +.rooms-panel { + margin-top: 12px; + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.app-shell--embed .rooms-panel { + margin-top: 8px; +} + +.panel-header, +.content-header, +.camera-modal__header, +.camera-modal__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panel-header { + margin-bottom: 10px; +} + +.panel-header__label { + font-size: 19px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.panel-header__sub { + margin-top: 6px; + color: var(--text-subtle); + font-size: 14px; +} + +#rooms-count:empty { + display: none; +} + +.content-header { + justify-content: space-between; + align-items: flex-start; +} + +.content-header__back { + display: none !important; + flex: 0 0 auto; +} + +.content-header > div { + min-width: 0; +} + +.content-header__actions { + display: flex; + align-items: center; + gap: 10px; + margin-left: 16px; + flex-wrap: wrap; + justify-content: flex-end; + margin-left: auto; +} + +.content-header__ghost-button { + min-height: 40px; + padding-inline: 12px; + border-radius: 12px; +} + +.content-header__ghost-button span { + font-size: 13px; + font-weight: 700; +} + +.icon-button { + appearance: none; + border: 1px solid var(--border); + background: rgba(255,255,255,0.03); + color: var(--text); + width: 40px; + height: 40px; + border-radius: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; +} + +.icon-button:hover { + transform: translateY(-1px); + background: rgba(255,255,255,0.08); + border-color: var(--border-strong); +} + +.icon-button.is-active { + background: rgba(103, 214, 255, 0.14); + border-color: rgba(103, 214, 255, 0.32); +} + +.icon-button--ghost { + width: 44px; + height: 44px; + border-radius: 16px; +} + +.room-list { + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; + min-height: 0; + padding-right: 6px; + padding-bottom: 6px; +} + +.app-shell--embed .room-list { + gap: 10px; + overflow-y: auto; + padding-right: 4px; + padding-bottom: 4px; +} + +.room-list__group { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.room-item { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; + padding: 8px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, rgba(28, 31, 39, 0.92), rgba(20, 23, 30, 0.92)); + color: var(--text); + cursor: pointer; + text-align: left; + min-height: 132px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + transition: border-color 180ms ease, transform 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +.app-shell--embed .room-item { + min-height: 94px; + padding: 7px 8px; +} + +.app-shell--embed .room-item__icon { + width: 42px; + height: 42px; +} + +.app-shell--embed .room-item__icon i { + font-size: 32px; +} + +.app-shell--embed .room-item__name { + font-size: 14px; +} + +.room-item.has-temp { + padding-right: 0px; +} + +.room-item.has-temp .room-item__body { + padding-right: 0px; +} + +.room-item:hover { + transform: translateY(-1px); + border-color: rgba(103, 214, 255, 0.24); +} + +.room-item.is-selected { + background: linear-gradient(180deg, rgba(103, 214, 255, 0.16), rgba(32, 35, 45, 0.98)); + border-color: rgba(103, 214, 255, 0.3); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255,255,255,0.05); +} + +.room-item.is-hidden-room { + opacity: 0.72; +} + +.room-item.is-main { + min-height: 132px; +} + +.room-item.is-battery-room { + background: + radial-gradient(circle at top right, rgba(255, 193, 7, 0.12), transparent 40%), + linear-gradient(180deg, rgba(32, 28, 20, 0.94), rgba(21, 20, 18, 0.95)); + border-color: rgba(255, 193, 7, 0.18); +} + +.room-item.is-battery-room .room-item__icon { + color: #ffc94d; + --icon-node-img-filter: brightness(0) saturate(100%) invert(79%) sepia(41%) saturate(909%) hue-rotate(357deg) brightness(103%) contrast(101%); +} + +.room-item.is-battery-room.is-selected { + background: + radial-gradient(circle at top right, rgba(255, 193, 7, 0.16), transparent 40%), + linear-gradient(180deg, rgba(51, 43, 20, 0.96), rgba(24, 22, 18, 0.98)); + border-color: rgba(255, 193, 7, 0.38); +} + +.room-item__icon { + width: 50px; + height: 50px; + border-radius: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.06); + color: var(--accent); + --icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%); +} + +.room-item__icon i { + font-size: 40px; + line-height: 1; +} + +.room-item__icon .icon-node, +.grid-card__icon .icon-node { + width: 40px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.icon-node__img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + filter: var(--icon-node-img-filter, none); +} + +.icon-node__svg { + width: 100%; + height: 100%; + display: block; + fill: currentColor; +} + +.room-item.is-selected .room-item__icon { + background: rgba(255,255,255,0.9); + color: #101318; + --icon-node-img-filter: brightness(0) saturate(100%); +} + +.room-item__name { + font-size: 16px; + font-weight: 700; + line-height: 1.08; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} + +.room-item__body { + min-width: 0; + width: 100%; +} + +.room-item__meta { + margin-top: 5px; + font-size: 12px; + color: var(--text-muted); +} + +.room-item__temp { + position: absolute; + top: 10px; + right: 3px; + padding: 3px 6px; + border-radius: 999px; + background: rgba(103, 214, 255, 0.12); + color: var(--accent); + border: 1px solid rgba(103, 214, 255, 0.22); + font-size: 20px; + font-weight: 700; + line-height: 1; + z-index: 1; + pointer-events: none; + white-space: nowrap; +} + +.room-item__content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + width: 100%; +} + +.room-item__mini-actions { + display: flex; + align-items: center; + gap: 6px; + justify-content: flex-start; + width: 100%; + margin-top: 0; + flex-wrap: wrap; +} + +.room-item.is-editing .room-item__mini-actions { + display: flex; +} + +.mini-action { + width: 30px; + height: 30px; + border-radius: 11px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.05); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.mini-action--wide { + width: 32px; + height: 32px; +} + +.room-list__divider { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 2px 2px; + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 11px; + grid-column: 1 / -1; +} + +.room-list__divider::before, +.room-list__divider::after { + content: ""; + height: 1px; + background: rgba(255,255,255,0.08); + flex: 1; +} + +.room-list__divider-label { + flex: 0 0 auto; +} + +.content { + position: relative; + padding: var(--content-pad); + overflow: auto; +} + +.app-shell--embed .content { + padding: 22px 16px 20px; +} + +.content-top { + display: none; + margin-bottom: 16px; + margin-top: -30px; +} + +.app-shell--embed .content-top { + margin-top: 0; +} + +.content-top.is-main { + display: block; +} + +.content-header { + min-height: 72px; + margin-bottom: 16px; + justify-content: flex-start; +} + +.app-shell--embed .content-header { + min-height: auto; + margin-bottom: 12px; +} + +.content-header.is-main .content-header__eyebrow, +.content-header.is-main .content-header__meta { + display: none; +} + +.content-header.is-main .content-header__title { + margin-top: 0; +} + +.content-header__eyebrow { + color: var(--text-muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.16em; +} + +.content-header__title { + margin: 6px 0 0; + font-family: var(--font-display); + font-size: clamp(30px, 3.4vw, 48px); + line-height: 1.02; + letter-spacing: -0.05em; +} + +.app-shell--embed .content-header__title { + font-size: clamp(24px, 2.5vw, 40px); +} + +.content-header__meta { + margin-top: 10px; + color: var(--text-muted); + font-size: 15px; +} + +.dashboard-grid { + position: relative; + width: 100%; +} + +.grid-surface { + display: grid; + gap: 18px; + width: 100%; +} + +.grid-card { + position: relative; + overflow: hidden; + border-radius: var(--radius-xl); + border: 1px solid var(--border); + background: linear-gradient(180deg, rgba(29, 33, 41, 0.96), rgba(18, 20, 27, 0.94)); + box-shadow: var(--shadow); + min-height: 120px; + transition: transform 120ms ease, box-shadow 180ms ease, border-color 180ms ease, background 180ms ease, filter 180ms ease; + will-change: transform; +} + +.grid-card.is-hidden { + opacity: 0.52; + border-style: dashed; + filter: saturate(0.8); +} + +.grid-card--ghost { + background: transparent; + border-color: transparent; + box-shadow: none; + min-height: 120px; +} + +.grid-card--ghost .grid-card__inner { + justify-content: space-between; + gap: 10px; +} + +.grid-card--ghost .grid-card__header { + gap: 8px; +} + +.grid-card--ghost .grid-card__icon { + background: rgba(255,255,255,0.02); + border-color: rgba(255,255,255,0.05); + color: rgba(237, 240, 246, 0.42); +} + +.grid-card--ghost .grid-card__title { + font-size: 18px; +} + +.grid-card--ghost .grid-card__subtitle { + margin-top: 2px; + font-size: 13px; +} + +.grid-card--ghost .grid-card__footer--edit { + margin-top: auto; +} + +.grid-card--ghost.is-editing { + background: + radial-gradient(circle at top left, rgba(103, 214, 255, 0.08), transparent 35%), + rgba(255, 255, 255, 0.01); + border-color: rgba(103, 214, 255, 0.18); + border-style: dashed; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +} + +.grid-card--ghost.is-editing .grid-card__title-line, +.grid-card--ghost.is-editing .grid-card__subtitle { + color: var(--text-muted); +} + +.grid-card--ghost.is-editing .grid-card__icon { + color: var(--accent); +} + +.main-dashboard__cards .grid-card--door { + transition: background 220ms ease, border-color 220ms ease, box-shadow 220ms ease, transform 220ms ease, filter 220ms ease; +} + +.main-dashboard__cards .grid-card--door.is-active { + background: + radial-gradient(circle at top right, rgba(160, 221, 255, 0.16), transparent 42%), + linear-gradient(180deg, rgba(44, 50, 61, 0.98), rgba(22, 26, 34, 0.98)); + border-color: rgba(160, 221, 255, 0.3); + box-shadow: + 0 18px 36px rgba(0, 0, 0, 0.26), + inset 0 1px 0 rgba(255,255,255,0.05); + animation: door-card-pulse 3.2s ease-in-out infinite; +} + +.main-dashboard__cards .grid-card--door.is-active::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(circle at 50% 0%, rgba(160, 221, 255, 0.14), transparent 58%); + opacity: 0.35; + animation: door-card-glow 3.2s ease-in-out infinite; +} + +@keyframes door-card-pulse { + 0%, 100% { + transform: translateY(0); + filter: brightness(1); + } + 50% { + transform: translateY(-1px); + filter: brightness(1.03); + } +} + +@keyframes door-card-glow { + 0%, 100% { + opacity: 0.24; + } + 50% { + opacity: 0.48; + } +} + +.main-dashboard__cards .grid-card--door .grid-card__icon { + transform-style: preserve-3d; +} + +.main-dashboard__cards .grid-card--door.is-active .grid-card__icon > * { + display: inline-flex; + transform-origin: 50% 60%; + animation: door-icon-swing 2.8s ease-in-out infinite; + will-change: transform; +} + +@keyframes door-icon-swing { + 0%, 100% { + transform: perspective(90px) rotateY(0deg) rotateZ(0deg) scale(1); + } + 22% { + transform: perspective(90px) rotateY(-26deg) rotateZ(-2deg) scale(1.04); + } + 44% { + transform: perspective(90px) rotateY(0deg) rotateZ(0deg) scale(1); + } + 68% { + transform: perspective(90px) rotateY(22deg) rotateZ(2deg) scale(0.98); + } + 84% { + transform: perspective(90px) rotateY(0deg) rotateZ(0deg) scale(1); + } +} + +.room-entities-section__grid .grid-card.is-active { + background: linear-gradient(180deg, rgb(146 154 170 / 98%), rgb(44 47 53 / 96%)); + border-color: rgba(160, 221, 255, 0.28); + box-shadow: + 0 16px 32px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.room-entities-section__grid .grid-card.is-active .grid-card__title, +.room-entities-section__grid .grid-card.is-active .grid-card__title-line { + color: var(--text); +} + +.room-entities-section__grid .grid-card.is-active .grid-card__subtitle, +.room-entities-section__grid .grid-card.is-active .grid-card__kind { + color: rgba(255, 255, 255, 0.68); +} + +.room-entities-section__grid .grid-card.is-active .grid-card__icon { + background: rgba(255,255,255,0.08); + border-color: rgba(160, 221, 255, 0.18); + color: var(--accent); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +} + +.grid-card--weather { + grid-column: 1 / span 3; +} + +.grid-card--auto { + grid-column: span 3; +} + +.grid-card--entity { + grid-column: span 3; +} + +.grid-card--entity-wide { + grid-column: span 3; +} + +.grid-card--cover { + grid-column: span 3; +} + +.grid-card--climate { + grid-column: span 3; +} + +.grid-card--full { + grid-column: 1 / -1; +} + +.grid-card__inner { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; + padding: 14px; +} + +.grid-card__top { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 12px; +} + +.grid-card__icon { + width: 42px; + height: 42px; + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.06); + color: var(--accent); + font-size: 22px; + flex: 0 0 auto; + --icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%); +} + +.grid-card__icon--active { + background: rgba(255,255,255,0.92); + color: #0f1115; + --icon-node-img-filter: brightness(0) saturate(100%); +} + +.grid-card__title { + font-size: 22px; + font-weight: 700; + line-height: 1.08; + display: flex; + flex-direction: column; + gap: 2px; +} + +.grid-card__title_weather{ + font-size: 40px; + font-weight: 700; + line-height: 1.08; + display: flex; + flex-direction: column; + gap: 2px; +} + +.grid-card__title-line { + display: block; +} + +.grid-card__subtitle { + margin-top: 6px; + color: var(--text-muted); + font-size: 20px; +} + +.grid-card__kind { + margin-top: 2px; + color: var(--text-subtle); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.grid-card__header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + min-width: 0; + flex: 1 1 auto; + width: 100%; +} + +.grid-card--tap { + cursor: pointer; + touch-action: manipulation; +} + +.grid-card--tap:active, +.grid-card.is-pressed, +.mushroom-button:active, +.mushroom-button.is-pressed, +.round-button:active, +.round-button.is-pressed, +.icon-button:active, +.icon-button.is-pressed, +.main-quick-action:active, +.main-quick-action.is-pressed, +.entity-chip:active, +.entity-chip.is-pressed, +.temperature-sensor-modal__option:active, +.temperature-sensor-modal__option.is-pressed, +.room-item:active, +.room-item.is-pressed { + transform: translateY(1px) scale(0.985); + filter: brightness(1.06); +} + +.grid-card--tap:focus-visible, +.room-item:focus-visible, +.icon-button:focus-visible, +.mushroom-button:focus-visible, +.round-button:focus-visible, +.mini-action:focus-visible { + outline: 2px solid rgba(103, 214, 255, 0.6); + outline-offset: 2px; +} + +.room-item.is-dragging { + opacity: 0.55; + transform: scale(0.985); + cursor: grabbing; +} + +.room-item.is-editing { + cursor: grab; + user-select: none; +} + +.grid-card__footer { + display: grid; + gap: 8px; + margin-top: auto; +} + +.grid-card__footer-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.grid-card__footer--edit { + padding-top: 6px; +} + +.grid-card__footer--edit .mushroom-button { + width: 100%; + min-height: 42px; + padding: 8px 10px; + border-radius: 12px; + font-size: 12px; + gap: 6px; +} + +.grid-card__footer--edit .mushroom-button--wide { + grid-column: 1 / -1; +} + +.grid-card__footer--edit .mushroom-button--small { + min-height: 42px; +} + +.grid-card__layout-settings { + display: grid; + gap: 10px; + margin-top: 2px; +} + +.grid-card__layout-settings .mushroom-button { + width: 100%; +} + +.mushroom-button { + min-height: 48px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.04); + color: var(--text); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, color 180ms ease; + will-change: transform; +} + +.mushroom-button:hover { + transform: translateY(-1px); + border-color: rgba(103, 214, 255, 0.24); +} + +.mushroom-button.is-on { + background: var(--surface-bright); + color: #101318; + border-color: rgba(255,255,255,0.55); +} + +.mushroom-button.is-disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.mushroom-button__icon { + font-size: 20px; + line-height: 1; +} + +.mushroom-button__body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + text-align: left; +} + +.mushroom-button--square .mushroom-button__body { + align-items: center; + text-align: center; +} + +.mushroom-button__title { + font-size: 14px; + font-weight: 700; +} + +.mushroom-button__subtitle { + color: inherit; + opacity: 0.7; + font-size: 12px; +} + +.mushroom-button--small { + min-height: 40px; + padding: 8px 10px; + border-radius: 12px; +} + +.mushroom-button--wide { + width: 100%; +} + +.mushroom-button--square { + width: 80px; + min-height: 80px; + padding: 10px 8px; + flex-direction: column; + gap: 6px; + text-align: center; +} + +.weather-card { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.weather-card--compact { + height: 100%; + justify-content: space-between; +} + +.weather-card__rows { + display: flex; + flex-direction: column; + gap: 8px; +} + +.weather-card__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.06); +} + +.weather-card__row-label { + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.weather-card__row-value { + font-weight: 800; + font-size: 22px; + color: var(--text); +} + +.stat-row { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 12px 14px; + border-radius: 16px; + background: rgba(255,255,255,0.04); +} + +.stat-row__label { + color: var(--text-muted); +} + +.stat-row__value { + font-weight: 700; +} + +.climate-card { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; +} + +.climate-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + width: 100%; +} + +.climate-card__meta { + display: grid; + justify-items: end; + text-align: right; + gap: 2px; + flex: 0 0 auto; + min-width: 72px; +} + +.climate-card__meta-target { + font-family: var(--font-display); + font-size: clamp(24px, 2.8vw, 32px); + line-height: 1; + letter-spacing: -0.06em; +} + +.climate-card__meta-current { + color: var(--text-muted); + font-size: 11px; + line-height: 1.1; +} + +.climate-card .grid-card__header { + flex: 1 1 auto; +} + +.climate-card__buttons { + display: flex; + gap: 10px; + justify-content: center; +} + +.round-button { + width: 44px; + height: 44px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.04); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, color 180ms ease; + will-change: transform; +} + +.cover-card { + display: flex; + flex-direction: column; + gap: 10px; +} + +.cover-card__rail { + display: grid; + gap: 8px; +} + +.cover-progress { + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: rgba(255,255,255,0.08); +} + +.cover-progress__value { + height: 100%; + background: linear-gradient(90deg, #66d6ff, #88f0c7); + border-radius: inherit; +} + +.cover-card__buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255,255,255,0.05); + color: var(--text-muted); + font-size: 12px; +} + +.loading-card { + grid-column: 1 / -1; + padding: 28px; + border-radius: var(--radius-xl); + background: rgba(255,255,255,0.04); + border: 1px solid var(--border); + color: var(--text-muted); +} + +.modal-backdrop { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 22px; + background: rgba(4, 6, 10, 0.72); + backdrop-filter: blur(12px); + z-index: 40; +} + +.modal-backdrop.is-open { + display: flex; +} + +body.is-mobile-ui #camera-modal { + display: none !important; +} + +.camera-modal { + position: relative; + width: calc(100vw - 24px); + height: calc(100vh - 24px); + max-width: 1600px; + max-height: none; + border-radius: 22px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(18, 20, 27, 0.98), rgba(10, 12, 17, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.camera-modal__close { + position: fixed; + top: 18px; + right: 18px; + z-index: 9999; + background: rgba(15, 18, 24, 0.88); + border-color: rgba(255,255,255,0.12); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35); + color: var(--text); + backdrop-filter: blur(10px); + pointer-events: auto; + touch-action: manipulation; +} + +.camera-modal__body { + flex: 1 1 auto; + min-height: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.camera-stage { + position: relative; + flex: 1 1 auto; + min-height: 0; + border-radius: 0; + overflow: hidden; + background: #0b0d11; + border: 1px solid rgba(255,255,255,0.06); +} + +.camera-stage__poster, +.camera-stage iframe, +.camera-stage video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + border: 0; + background: #0b0d11; +} + +.camera-stage__poster { + z-index: 1; +} + +.camera-stage iframe, +.camera-stage video { + z-index: 2; + opacity: 0; + transition: opacity 180ms ease; +} + +.camera-stage iframe.is-ready, +.camera-stage video.is-ready { + opacity: 1; +} + +.camera-stage__placeholder { + position: absolute; + inset: 0; + display: none; + place-items: center; + text-align: center; + padding: 24px; + background: linear-gradient(180deg, rgba(13,16,22,0.08), rgba(13,16,22,0.38)); + backdrop-filter: blur(2px); + pointer-events: none; + z-index: 3; +} + +.camera-stage__placeholder.is-visible { + display: grid; +} + +.camera-stage__placeholder-icon { + width: 86px; + height: 86px; + border-radius: 30px; + display: grid; + place-items: center; + background: rgba(255,255,255,0.06); + font-size: 40px; + color: var(--accent); +} + +.camera-stage__placeholder-title { + margin-top: 16px; + font-size: 22px; + font-weight: 700; +} + +.camera-stage__placeholder-subtitle { + margin-top: 8px; + color: var(--text-muted); + max-width: 420px; +} + +.camera-modal__footer { + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 8px; + color: var(--text-muted); + padding: 12px 18px 16px; + min-height: 56px; +} + +.camera-modal__countdown { + font-weight: 700; + color: var(--text); + min-height: 1.2em; + line-height: 1.2; + display: block; + white-space: nowrap; +} + +.entity-modal { + width: min(78vw, 1080px); + max-height: 90vh; + border-radius: 30px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(20, 22, 29, 0.98), rgba(12, 14, 18, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.temperature-sensor-modal { + width: min(62vw, 760px); + max-height: 84vh; + border-radius: 30px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(20, 22, 29, 0.98), rgba(12, 14, 18, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.temperature-sensor-modal__header { + padding: 18px 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.temperature-sensor-modal__eyebrow { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; + min-height: 14px; +} + +.temperature-sensor-modal__title { + margin-top: 5px; + font-size: 18px; + font-weight: 700; +} + +.temperature-sensor-modal__body { + padding: 18px; + display: grid; + gap: 14px; + overflow: auto; +} + +.temperature-sensor-modal__current { + display: grid; + gap: 8px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); +} + +.temperature-sensor-modal__current-label { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 11px; +} + +.temperature-sensor-modal__current-value { + font-size: 16px; + font-weight: 700; +} + +.temperature-sensor-modal__empty { + color: var(--text-muted); + padding: 20px 0; +} + +.temperature-sensor-modal__list { + display: grid; + gap: 10px; +} + +.temperature-sensor-modal__option { + width: 100%; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.04); + color: var(--text); + border-radius: 18px; + padding: 12px 14px; + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; + cursor: pointer; + text-align: left; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; + will-change: transform; +} + +.temperature-sensor-modal__option:hover { + transform: translateY(-1px); + background: rgba(255,255,255,0.06); + border-color: var(--border-strong); +} + +.temperature-sensor-modal__option.is-active { + background: rgba(103, 214, 255, 0.14); + border-color: rgba(103, 214, 255, 0.28); +} + +.temperature-sensor-modal__option-main { + display: grid; + gap: 4px; + min-width: 0; +} + +.temperature-sensor-modal__option-name { + font-weight: 700; + line-height: 1.15; +} + +.temperature-sensor-modal__option-meta { + color: var(--text-muted); + font-size: 13px; + line-height: 1.2; +} + +.temperature-sensor-modal__option-value { + color: var(--accent); + font-weight: 800; + white-space: nowrap; +} + +.entity-modal__header { + padding: 18px 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.entity-modal__eyebrow { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; + min-height: 14px; +} + +.entity-modal__title { + margin-top: 5px; + font-size: 18px; + font-weight: 700; +} + +.entity-modal__body { + padding: 18px; + display: grid; + gap: 18px; + overflow: auto; +} + +.entity-modal__fallback { + color: var(--text-muted); + padding: 20px 0; +} + +.entity-modal__cover, +.entity-modal__climate { + display: grid; + gap: 16px; +} + +.entity-modal__cover { + grid-template-columns: minmax(0, 1fr) 118px; + align-items: stretch; +} + +.entity-modal__rail { + display: grid; + gap: 14px; + min-width: 0; +} + +.entity-modal__rail--cover { + grid-template-rows: auto minmax(0, 1fr) auto; + justify-items: center; + align-content: stretch; + gap: 12px; +} + +.entity-modal__cover-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.entity-modal__cover-label, +.entity-modal__current-label, +.entity-modal__options-title { + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.entity-modal__cover-value, +.entity-modal__current-value { + font-size: 18px; + font-weight: 800; + color: var(--text); +} + +.entity-modal__cover-track { + position: relative; + justify-self: center; + width: 56px; + min-height: 280px; + border-radius: 999px; + background: + radial-gradient(circle at 50% 6%, rgba(255,255,255,0.10), transparent 28%), + linear-gradient(180deg, rgba(18, 20, 27, 0.96), rgba(10, 12, 17, 0.98)); + overflow: hidden; + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.06), + inset 0 -18px 28px rgba(0, 0, 0, 0.26); + touch-action: none; + user-select: none; + cursor: ns-resize; +} + +.entity-modal__cover-fill { + position: absolute; + left: 0; + right: 0; + bottom: 0; + border-radius: inherit; + background: + linear-gradient(180deg, rgba(103, 214, 255, 0.96) 0%, rgba(126, 236, 220, 0.94) 58%, rgba(188, 255, 242, 0.96) 100%); + box-shadow: + 0 10px 26px rgba(103, 214, 255, 0.14), + inset 0 1px 0 rgba(255,255,255,0.20); + width: 100%; + height: 0%; +} + +.entity-modal__cover-handle { + position: absolute; + left: 50%; + bottom: -10px; + width: 36px; + height: 20px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.18); + background: + linear-gradient(180deg, rgba(255,255,255,0.98), rgba(228,236,244,0.9)); + box-shadow: + 0 10px 22px rgba(0, 0, 0, 0.26), + inset 0 1px 0 rgba(255,255,255,0.7); + transform: translateX(-50%); + z-index: 2; + pointer-events: none; +} + +.entity-modal__actions--vertical { + display: grid; + grid-auto-rows: minmax(52px, auto); + gap: 10px; + align-content: center; + justify-self: stretch; +} + +.entity-modal__actions--vertical .mushroom-button--square { + width: 100%; + min-height: 72px; +} + +.entity-modal__actions--vertical .mushroom-button__body { + gap: 2px; +} + +.entity-modal__actions--vertical .mushroom-button__icon { + font-size: 18px; +} + +.entity-modal__actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.entity-modal__actions .mushroom-button--square { + width: 100%; + min-height: 96px; +} + +.entity-modal__actions .mushroom-button__title { + line-height: 1.05; +} + +.entity-modal__actions.entity-modal__actions--vertical { + grid-template-columns: 1fr; + grid-auto-rows: minmax(52px, auto); + align-content: center; +} + +.entity-modal__actions.entity-modal__actions--vertical .mushroom-button--square { + min-height: 72px; +} + +.entity-modal__climate-summary { + display: grid; + gap: 8px; + justify-items: center; + text-align: center; + padding-top: 8px; +} + +.entity-modal__target-row { + display: flex; + align-items: baseline; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + +.entity-modal__target-state { + color: var(--text-muted); + font-size: 17px; + font-weight: 700; +} + +.entity-modal__target-temp { + font-family: var(--font-display); + font-size: clamp(44px, 6vw, 72px); + line-height: 1; + letter-spacing: -0.06em; +} + +.entity-modal__target-temp span { + font-size: 0.36em; + vertical-align: top; +} + +.entity-modal__temperature-controls { + display: flex; + justify-content: center; + gap: 14px; +} + +.entity-modal__round-button { + width: 54px; + height: 54px; +} + +.entity-modal__modes { + display: grid; + gap: 12px; +} + +.entity-modal__options-block { + display: grid; + gap: 8px; +} + +.entity-modal__chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.entity-chip { + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.04); + color: var(--text); + border-radius: 999px; + padding: 8px 12px; + min-height: 36px; + cursor: pointer; + text-align: center; + line-height: 1.05; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, color 180ms ease; + will-change: transform; +} + +.entity-chip.is-active { + background: rgba(103, 214, 255, 0.16); + border-color: rgba(103, 214, 255, 0.28); + color: var(--text); +} + +.confirm-modal { + width: min(420px, calc(100vw - 36px)); + border-radius: 28px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(20, 23, 30, 0.98), rgba(12, 14, 18, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + padding: 22px; +} + +.confirm-modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.confirm-modal__eyebrow { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; +} + +.confirm-modal__title { + margin-top: 6px; + font-size: 20px; + font-weight: 800; + line-height: 1.08; +} + +.confirm-modal__body { + margin-top: 14px; + color: var(--text-muted); + font-size: 15px; + line-height: 1.4; +} + +.confirm-modal__footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +.confirm-modal .mushroom-button { + min-width: 100px; +} + +.hidden { + display: none !important; +} + +.main-dashboard { + display: grid; + gap: 18px; + grid-column: 1 / -1; + width: 100%; +} + +.main-dashboard__hero { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 18px; + align-items: stretch; +} + +.main-dashboard__weather-slot { + min-width: 0; + display: flex; + align-self: stretch; +} + +.main-dashboard__weather-slot .grid-card--weather { + width: 100%; + max-width: none; + grid-column: auto; + height: 100%; + min-height: 100%; +} + +.main-dashboard__actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + align-items: start; + min-width: 0; + align-self: start; +} + +.main-dashboard__hero-stack { + display: grid; + gap: 14px; + min-width: 0; + align-self: start; + align-content: start; +} + +.main-print-strip { + width: 100%; + min-height: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.main-print-strip__inner { + display: grid; + gap: 6px; + padding: 0 2px 0; +} + +.main-print-strip__header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + min-height: 16px; +} + +.main-print-strip__badge { + flex: 0 0 auto; + padding: 2px 8px; + border-radius: 999px; + background: rgba(103, 214, 255, 0.14); + border: 1px solid rgba(103, 214, 255, 0.22); + color: var(--accent); + font-size: 11px; + font-weight: 800; + line-height: 1; +} + +.main-print-strip__progress { + height: 6px; + border-radius: 999px; + overflow: hidden; + background: rgba(255,255,255,0.08); +} + +.main-print-strip__progress-fill { + width: 0%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #67d6ff, #88f0c7); +} + +.main-print-strip__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + min-height: 14px; +} + +.main-print-strip__remaining { + color: var(--text-muted); + font-size: 12px; + font-weight: 700; + line-height: 1; + white-space: nowrap; + letter-spacing: 0.02em; +} + +.main-quick-action { + min-height: 0; + padding: 12px 10px 11px; + border-radius: 20px; + border: 1px solid rgba(0, 0, 0, 0.04); + background: var(--quick-action-bg, rgba(255, 255, 255, 0.08)); + color: var(--quick-action-color, var(--text)); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.14); + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 6px; + text-align: center; + cursor: pointer; + transition: transform 180ms ease, box-shadow 180ms ease, filter 180ms ease; + position: relative; + overflow: hidden; + will-change: transform; +} + +.main-quick-action:hover { + transform: translateY(-1px); + filter: saturate(1.03); +} + +.main-quick-action.is-active { + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.22); +} + +.main-quick-action__icon { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--quick-action-icon-color, currentColor); + flex: 0 0 auto; +} + +.main-quick-action__icon i { + font-size: 20px; + line-height: 1; +} + +.main-quick-action__icon .icon-node, +.main-quick-action__icon .icon-node__img, +.main-quick-action__icon .icon-node__svg { + width: 100%; + height: 100%; +} + +.main-quick-action__label { + font-size: 13px; + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.01em; + word-break: break-word; +} + +.main-dashboard__hero-spacer { + flex: 1 1 auto; + min-height: 1px; + border-radius: var(--radius-xl); +} + +.main-boiler-card { + min-height: 112px; + border-color: rgba(255, 186, 92, 0.18); + background: + radial-gradient(circle at top right, rgba(255, 186, 92, 0.14), transparent 42%), + linear-gradient(180deg, rgba(37, 31, 24, 0.98), rgba(19, 20, 27, 0.96)); +} + +.main-boiler-card__inner { + display: grid; + gap: 6px; + padding: 10px 12px 10px; +} + +.main-boiler-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.main-boiler-card__text { + display: grid; + gap: 2px; +} + +.main-boiler-card__eyebrow { + color: rgba(255, 214, 160, 0.72); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +.main-boiler-card__body { + display: grid; + grid-template-columns: minmax(86px, 96px) minmax(0, 1fr); + gap: 8px; + align-items: center; + min-width: 0; +} + +.main-boiler-card__value-column { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + min-width: 0; +} + +.main-boiler-card__range { + flex: 0 0 auto; + padding: 4px 8px; + border-radius: 999px; + background: rgba(255, 186, 92, 0.11); + color: rgba(255, 214, 160, 0.9); + font-size: 11px; + font-weight: 700; +} + +.main-boiler-card__value-row { + display: flex; + align-items: baseline; + gap: 6px; +} + +.main-boiler-card__value-label { + color: rgba(255, 214, 160, 0.7); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.main-boiler-card__value { + color: var(--text); + font-family: var(--font-display); + font-size: clamp(22px, 2.4vw, 28px); + line-height: 0.98; + letter-spacing: -0.06em; +} + +.main-boiler-card__unit { + color: rgba(255, 255, 255, 0.72); + font-size: 12px; + font-weight: 700; + transform: translateY(-2px); +} + +.main-boiler-card__chart-wrap { + position: relative; + min-height: 60px; + border-radius: 14px; + overflow: hidden; + background: linear-gradient(180deg, rgba(255, 186, 92, 0.04), rgba(255, 186, 92, 0.01)); +} + +.main-boiler-card__chart { + width: 100%; + height: 100%; + display: block; +} + +.main-boiler-card__loading { + position: absolute; + left: 10px; + bottom: 6px; + color: rgba(255, 214, 160, 0.72); + font-size: 12px; + font-weight: 600; +} + +.main-dashboard__cards { + width: 100%; + grid-template-columns: repeat(15, minmax(0, 1fr)); +} + +.main-dashboard__cards .grid-card { + grid-column: span 3; + width: 100%; + aspect-ratio: 1 / 1; + min-height: 0; +} + +.main-dashboard__cards .grid-card__inner { + padding: 12px; +} + +.main-dashboard__cards .grid-card--cover { + justify-self: stretch; + max-width: none; +} + +.main-dashboard__cards .main-dashboard__room-grid { + grid-column: 1 / -1; + width: 100%; +} + +.room-entities-section { + display: grid; + gap: 14px; + width: 100%; + grid-column: 1 / -1; +} + +.room-entities-section + .room-entities-section { + margin-top: 16px; +} + +.room-entities-section__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + padding: 0 4px; +} + +.room-entities-section__title { + color: var(--text-subtle); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.room-entities-section__meta { + color: var(--text-subtle); + font-size: 12px; +} + +.room-entities-section--hidden { + padding-top: 16px; + border-top: 1px solid rgba(255,255,255,0.07); +} + +.room-entities-section--hidden .room-entities-section__title { + color: var(--warning); +} + +.battery-room { + display: grid; + gap: 14px; +} + +.battery-room__header { + align-items: flex-start; +} + +.battery-room__list { + display: grid; + gap: 12px; +} + +.battery-room__empty { + grid-column: 1 / -1; +} + +.battery-card { + min-height: 96px; + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(255,255,255,0.04), transparent 34%), + linear-gradient(180deg, rgba(27, 30, 37, 0.98), rgba(18, 20, 26, 0.98)); + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.battery-card--critical, +.battery-card--empty { + border-color: rgba(255, 125, 125, 0.3); +} + +.battery-card--low { + border-color: rgba(255, 191, 84, 0.28); +} + +.battery-card--unavailable { + border-color: rgba(237, 240, 246, 0.14); +} + +.battery-card--unknown { + border-color: rgba(103, 214, 255, 0.18); +} + +.battery-card--ok { + border-color: rgba(136, 240, 199, 0.14); +} + +.battery-card__inner { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 16px 18px; +} + +.battery-card__main { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.battery-card__icon { + width: 46px; + height: 46px; + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.05); + color: #ffc94d; + flex: 0 0 auto; +} + +.battery-card--critical .battery-card__icon, +.battery-card--empty .battery-card__icon { + color: #ff7d7d; +} + +.battery-card--low .battery-card__icon { + color: #ffbf54; +} + +.battery-card--unavailable .battery-card__icon { + color: rgba(237, 240, 246, 0.52); +} + +.battery-card--unknown .battery-card__icon { + color: var(--accent); +} + +.battery-card--ok .battery-card__icon { + color: var(--accent-2); +} + +.battery-card__text { + min-width: 0; +} + +.battery-card__title { + font-size: 18px; + font-weight: 800; + line-height: 1.1; + overflow-wrap: anywhere; +} + +.battery-card__source { + margin-top: 5px; + color: var(--text-muted); + font-size: 13px; +} + +.battery-card__side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + justify-content: center; + text-align: right; +} + +.battery-card__percent { + font-size: 26px; + line-height: 1; + font-weight: 800; + color: var(--text); +} + +.battery-card--critical .battery-card__percent, +.battery-card--empty .battery-card__percent { + color: #ff7d7d; +} + +.battery-card--low .battery-card__percent { + color: #ffbf54; +} + +.battery-card--unavailable .battery-card__percent, +.battery-card--unknown .battery-card__percent { + color: var(--text-muted); +} + +.battery-card--ok .battery-card__percent { + color: var(--accent-2); +} + +.battery-card__status { + color: var(--text-subtle); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.battery-card__footer { + grid-column: 1 / -1; + margin-top: -2px; + padding-left: 60px; + color: var(--text-muted); + font-size: 13px; +} + +.room-entities-section__grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); + width: 100%; + align-items: stretch; +} + +.room-entities-section__grid .grid-card--entity, +.room-entities-section__grid .grid-card--entity-wide, +.room-entities-section__grid .grid-card--cover, +.room-entities-section__grid .grid-card--climate, +.room-entities-section__grid .grid-card--auto, +.room-entities-section__grid .grid-card--ghost { + grid-column: span 1; +} + +.room-entities-section__grid--hidden .grid-card { + opacity: 0.86; + border-style: dashed; +} + +.room-entities-section__grid--hidden .grid-card__footer--edit .mushroom-button { + background: rgba(255,255,255,0.03); +} + +.dashboard-grid > .grid-surface { + grid-template-columns: repeat(15, minmax(0, 1fr)); + width: 100%; +} + +@media (max-width: 1200px) { + .app-shell { + grid-template-columns: 230px 1fr; + } + + .main-dashboard__hero { + grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); + } + + .main-boiler-card { + min-height: 108px; + } + + .main-quick-action { + padding: 10px 10px 9px; + border-radius: 18px; + } + + .main-quick-action__label { + font-size: 12px; + } + + .grid-card--weather { + grid-column: 1 / -1; + } + + .grid-card--auto, + .grid-card--entity, + .grid-card--entity-wide, + .grid-card--cover, + .grid-card--climate { + grid-column: span 3; + } +} + +@media (max-width: 920px) { + body { + overflow: hidden; + } + + .app-shell { + grid-template-columns: 1fr; + height: 100dvh; + min-height: 100dvh; + } + + .app-shell.is-mobile { + overflow: hidden; + } + + .app-shell.is-mobile .sidebar, + .app-shell.is-mobile .content { + min-height: 0; + height: 100%; + } + + .app-shell.is-mobile .sidebar { + overflow: auto; + border-right: 0; + border-bottom: 1px solid rgba(255,255,255,0.05); + padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); + } + + .app-shell.is-mobile .content { + overflow: auto; + padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); + } + + .app-shell.is-mobile.mobile-view-spaces .content { + display: none; + } + + .app-shell.is-mobile.mobile-view-room .sidebar { + display: none; + } + + .app-shell.is-mobile .content-header { + align-items: center; + gap: 12px; + min-height: 0; + margin-bottom: 14px; + } + + .app-shell.is-mobile .content-header__back { + display: inline-flex !important; + } + + .app-shell.is-mobile .content-header__title { + margin-top: 0; + font-size: clamp(26px, 7vw, 36px); + } + + .app-shell.is-mobile .content-header__meta { + font-size: 13px; + } + + .app-shell.is-mobile .room-item.is-selected { + background: linear-gradient(180deg, rgba(28, 31, 39, 0.92), rgba(20, 23, 30, 0.92)); + border-color: var(--border); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + } + + .app-shell.is-mobile .room-item.is-selected .room-item__icon { + background: rgba(255,255,255,0.04); + color: var(--accent); + --icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%); + } + + .app-shell.is-mobile .content-top { + margin-top: -12px; + } + + .main-dashboard__hero { + grid-template-columns: 1fr; + } + + .main-dashboard__hero-stack { + gap: 12px; + } + + .main-dashboard__actions { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + } + + .main-quick-action { + min-height: 76px; + } + + .grid-card--weather { + grid-column: 1 / -1; + } + + .main-dashboard__cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .main-dashboard__cards .grid-card { + grid-column: span 1; + } + + .room-entities-section__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .room-entities-section__grid .grid-card--entity, + .room-entities-section__grid .grid-card--entity-wide, + .room-entities-section__grid .grid-card--cover, + .room-entities-section__grid .grid-card--climate, + .room-entities-section__grid .grid-card--auto, + .room-entities-section__grid .grid-card--ghost { + grid-column: span 1; + } + + .grid-card--ghost { + display: none; + } + + .main-quick-action { + min-height: 72px; + } + + .main-boiler-card { + min-height: 106px; + } + + .main-boiler-card__body { + grid-template-columns: minmax(82px, 92px) minmax(0, 1fr); + gap: 6px; + } + + .camera-modal { + width: calc(100vw - 16px); + height: calc(100vh - 16px); + border-radius: 18px; + } + + .entity-modal { + width: calc(100vw - 20px); + max-height: calc(100dvh - 20px); + border-radius: 24px; + } + + .temperature-sensor-modal { + width: calc(100vw - 20px); + max-height: calc(100dvh - 20px); + border-radius: 24px; + } + + .entity-modal__body { + padding: 16px; + gap: 14px; + } + + .entity-modal__cover { + grid-template-columns: minmax(0, 1fr) 88px; + gap: 12px; + } + + .entity-modal__cover-track { + width: 44px; + min-height: 240px; + border-radius: 999px; + } + + .entity-modal__cover-handle { + width: 30px; + height: 18px; + } + + .entity-modal__actions--vertical { + grid-auto-rows: minmax(48px, auto); + gap: 8px; + } +} diff --git a/custom_components/wall_panel/assets/app.js b/custom_components/wall_panel/assets/app.js new file mode 100755 index 0000000..01fb85d --- /dev/null +++ b/custom_components/wall_panel/assets/app.js @@ -0,0 +1,4932 @@ +(function () { + const bootstrap = window.APP_BOOTSTRAP || {}; + const MOBILE_BREAKPOINT = 920; + const state = { + snapshot: bootstrap, + embedMode: Boolean(bootstrap?.ui?.embed), + selectedRoomId: 'main', + isMobileViewport: false, + mobileView: 'spaces', + editMode: Boolean(bootstrap?.settings?.edit_mode), + clockTimer: null, + hlsInstance: null, + popupDismissTimer: null, + popupAutoOpenBlockedUntil: 0, + roomAutoReturnTimer: null, + mainBoilerHistory: { + entityId: null, + points: [], + loadedAt: 0, + loading: false, + error: null, + promise: null, + }, + entityPopup: { + active: false, + entityId: null, + }, + temperatureSensorPopup: { + active: false, + roomId: null, + }, + lastPopupSignature: '', + lastEntityPopupSignature: '', + lastTemperatureSensorPopupSignature: '', + roomDrag: null, + layoutItemSettingsOpen: {}, + confirmResolver: null, + haSocket: null, + haSocketState: 'disconnected', + haReconnectTimer: null, + haReconnectDelay: 1000, + haSubscribeId: 1, + roomSelectionToken: 0, + snapshotPollTimer: null, + haSnapshotListenerInstalled: false, + }; + + const els = {}; + const client = window.StrikerPanelClient || (window.StrikerPanelClient = {}); + + client.renderFromSnapshot = (snapshot) => { + if (!snapshot || typeof snapshot !== 'object') { + return; + } + state.snapshot = snapshot; + initRefs(); + state.embedMode = detectEmbeddedContext(); + syncLayoutState(); + render(); + }; + + client.refresh = () => { + initRefs(); + state.embedMode = detectEmbeddedContext(); + syncLayoutState(); + render(); + }; + + function $(id) { + const root = client.mountRoot || document; + if (!root) return null; + if (typeof root.getElementById === 'function') { + return root.getElementById(id); + } + return root.querySelector?.(`#${CSS.escape(id)}`) || null; + } + + function q(sel, root) { + const actualRoot = root || client.mountRoot || document; + return actualRoot.querySelector(sel); + } + + function qa(sel, root) { + const actualRoot = root || client.mountRoot || document; + return Array.from(actualRoot.querySelectorAll(sel)); + } + + function haBridge() { + return window.WALL_PANEL_HA_BRIDGE || null; + } + + function isHaRuntime() { + return Boolean( + haBridge() + || bootstrap?.ui?.mode === 'ha-native' + ); + } + + function sleep(ms) { + return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); + } + + function snapshotLooksReady(snapshot) { + if (!snapshot || typeof snapshot !== 'object') { + return false; + } + const rooms = Array.isArray(snapshot.rooms) ? snapshot.rooms : []; + const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : []; + if (rooms.length > 0 || spaces.length > 0) { + return true; + } + if (snapshot.selected_room?.id || snapshot.selected_space?.id) { + return true; + } + return false; + } + + async function resolveInitialSnapshot() { + const bootstrapSnapshot = window.APP_BOOTSTRAP || bootstrap || {}; + if (!isHaRuntime()) { + return bootstrapSnapshot; + } + + const waitForHaBridge = async (timeoutMs = 1000) => { + const startedAt = Date.now(); + let bridge = haBridge(); + while (!bridge && (Date.now() - startedAt) < timeoutMs) { + await sleep(50); + bridge = haBridge(); + } + return bridge; + }; + + const tryBridgeSnapshot = async () => { + const bridge = await waitForHaBridge(); + if (!bridge) { + return null; + } + + const roomId = state.selectedRoomId || 'main'; + try { + if (typeof bridge.getSnapshot === 'function') { + const snapshot = await bridge.getSnapshot(roomId); + if (snapshotLooksReady(snapshot)) { + return snapshot; + } + } + } catch (error) { + console.warn(error); + } + + try { + if (typeof bridge.request === 'function') { + const snapshot = await bridge.request('GET', 'snapshot', { space_id: roomId }); + if (snapshotLooksReady(snapshot)) { + return snapshot; + } + } + } catch (error) { + console.warn(error); + } + + return null; + }; + + const firstPass = await tryBridgeSnapshot(); + if (firstPass) { + return firstPass; + } + + await sleep(150); + + const secondPass = await tryBridgeSnapshot(); + if (secondPass) { + return secondPass; + } + + return snapshotLooksReady(bootstrapSnapshot) ? bootstrapSnapshot : bootstrapSnapshot; + } + + const PRESSABLE_SELECTOR = [ + '.grid-card--tap', + '.mushroom-button', + '.round-button', + '.icon-button', + '.main-quick-action', + '.entity-chip', + '.temperature-sensor-modal__option', + '.room-item', + ].join(', '); + + function bindPressFeedback() { + let pressedEl = null; + let releaseTimer = null; + + const clearPressed = () => { + if (releaseTimer !== null) { + window.clearTimeout(releaseTimer); + releaseTimer = null; + } + if (pressedEl) { + pressedEl.classList.remove('is-pressed'); + pressedEl = null; + } + }; + + const setPressed = (el) => { + if (!el || el.classList.contains('is-disabled')) { + return; + } + clearPressed(); + pressedEl = el; + el.classList.add('is-pressed'); + releaseTimer = window.setTimeout(clearPressed, 160); + }; + + document.addEventListener('pointerdown', (event) => { + if (event.button !== undefined && event.button !== 0) return; + const target = event.target instanceof Element ? event.target.closest(PRESSABLE_SELECTOR) : null; + if (!target) return; + setPressed(target); + }, { passive: true }); + + document.addEventListener('pointerup', clearPressed, { passive: true }); + document.addEventListener('pointercancel', clearPressed, { passive: true }); + window.addEventListener('blur', clearPressed); + } + + function mobileViewportQuery() { + return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); + } + + function isMobileViewport() { + return Boolean(state.isMobileViewport); + } + + function isMobileRoomView() { + return isMobileViewport() && state.mobileView === 'room'; + } + + function detectEmbeddedContext() { + if (Boolean(bootstrap?.ui?.embed)) { + return true; + } + + try { + return window.self !== window.top; + } catch (error) { + return true; + } + } + + function setMobileView(nextView) { + if (!isMobileViewport()) { + state.mobileView = 'room'; + return; + } + + state.mobileView = nextView === 'room' ? 'room' : 'spaces'; + } + + function syncViewportState() { + const query = mobileViewportQuery(); + const nextIsMobile = Boolean(query.matches); + const changed = nextIsMobile !== state.isMobileViewport; + + state.isMobileViewport = nextIsMobile; + if (nextIsMobile) { + state.mobileView = changed ? 'spaces' : (state.mobileView || 'spaces'); + } else { + state.mobileView = 'room'; + clearRoomAutoReturnTimer(); + scheduleRoomAutoReturn(state.selectedRoomId || 'main'); + } + + if (nextIsMobile) { + clearRoomAutoReturnTimer(); + if (state.selectedRoomId === 'batteries' && state.snapshot) { + state.selectedRoomId = 'main'; + patchSnapshotSelection('main'); + } + } + + return nextIsMobile; + } + + function iconClass(icon) { + if (!icon) return 'mdi mdi-help-circle-outline'; + return icon.startsWith('mdi:') ? `mdi ${icon.replace('mdi:', 'mdi-')}` : icon; + } + + function normalizeIconSource(source) { + const value = String(source ?? '').trim(); + if (!value) return ''; + return value.replace(/\.svg$/i, ''); + } + + function createSvgIcon(definition) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', definition?.viewBox || '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + svg.classList.add('icon-node__svg'); + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', definition?.path || ''); + svg.appendChild(path); + return svg; + } + + function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { + 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; + } + + 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)); + }).catch(() => { + if (!wrap.isConnected) return; + wrap.replaceChildren(createIconElement(fallback)); + }); + return wrap; + } + + function createIconElement(icon, fallback = 'mdi:help-circle-outline') { + const source = normalizeIconSource(icon) || fallback; + + if (source.startsWith('mdi:')) { + const i = document.createElement('i'); + i.className = iconClass(source); + 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; + } + + 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'; + wrap.replaceChildren(createIconElement(fallback)); + }); + wrap.appendChild(img); + return wrap; + } + + return createIconElement(fallback); + } + + function esc(value) { + return String(value ?? ''); + } + + function entitySortTime(entity) { + const value = entity?.last_changed || entity?.last_updated || entity?.attributes?.last_changed || ''; + const time = Date.parse(value); + return Number.isFinite(time) ? time : 0; + } + + function splitEntityName(name) { + const value = String(name ?? ''); + const parts = value.split('|').map((part) => part.trim()).filter(Boolean); + if (parts.length >= 2) { + return [parts[0], parts.slice(1).join(' | ')]; + } + return [value.trim()]; + } + + function buildEntityTitle(name) { + const lines = splitEntityName(name); + const title = document.createElement('div'); + title.className = 'grid-card__title'; + const mainLine = document.createElement('span'); + mainLine.className = 'grid-card__title-line'; + mainLine.textContent = lines[0] || ''; + title.appendChild(mainLine); + + if (lines.length > 1) { + const subtitle = document.createElement('span'); + subtitle.className = 'grid-card__subtitle'; + subtitle.textContent = lines.slice(1).join(' | '); + title.appendChild(subtitle); + } + return title; + } + + function isMainDisplayEntity(entity) { + const domain = String(entity?.domain || '').toLowerCase(); + const isDoorContact = Boolean(entity?.is_door_contact); + const state = String(entity?.state || '').toLowerCase(); + if (domain === 'cover') { + return ['open', 'opening', 'closing'].includes(state); + } + if (domain === 'binary_sensor' && isDoorContact) { + return ['on', 'open'].includes(state); + } + return ['on', 'cool', 'heat', 'heating', 'cooling'].includes(state) || (domain === 'fan' && state === 'on'); + } + + function optimisticStateForCommand(entity, command) { + const current = String(entity?.state || '').toLowerCase(); + const active = isMainDisplayEntity(entity); + + switch (command) { + case 'turn_on': + return 'on'; + case 'turn_off': + return 'off'; + case 'open': + return 'open'; + case 'close': + return 'closed'; + case 'toggle': + if (String(entity?.domain || '').toLowerCase() === 'cover') { + return active ? 'closed' : 'open'; + } + return active ? 'off' : 'on'; + case 'stop': + return current || null; + default: + return null; + } + } + + function popupTriggerEntities() { + 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 : []; + return new Set(triggers.map((value) => String(value))); + } + + function cameraConfig() { + return state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; + } + + function resolvePopupStreamMode(streamUrl, explicitMode = '') { + const normalizedUrl = String(streamUrl || '').trim(); + const urlMode = inferStreamMode(normalizedUrl); + const mode = String(explicitMode || '').trim().toLowerCase(); + + if (!normalizedUrl) { + return mode || 'poster'; + } + + if (urlMode === 'iframe') { + return 'iframe'; + } + + if (urlMode === 'hls') { + return 'hls'; + } + + if (urlMode === 'video') { + return 'video'; + } + + if (mode && !['poster', 'hls'].includes(mode)) { + return mode; + } + + return 'poster'; + } + + function mergePopupWithCamera(popup = {}) { + const camera = cameraConfig(); + const streamUrl = popup.stream_url || camera.stream_url || ''; + const streamMode = resolvePopupStreamMode(streamUrl, popup.stream_mode || camera.stream_mode || ''); + return { + ...popup, + poster_url: popup.poster_url || camera.poster_url || '', + stream_url: streamUrl, + stream_mode: streamMode, + title: popup.title || 'Камера', + }; + } + + function pluralizeRooms(count) { + const n = Math.abs(Number(count) || 0) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return 'пространств'; + if (n1 > 1 && n1 < 5) return 'пространства'; + if (n1 === 1) return 'пространство'; + return 'пространств'; + } + + function pluralizeRu(count, one, few, many) { + const n = Math.abs(Number(count) || 0) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return many; + if (n1 > 1 && n1 < 5) return few; + if (n1 === 1) return one; + return many; + } + + function pluralizeEntities(count) { + return pluralizeRu(count, 'объект', 'объекта', 'объектов'); + } + + function pluralizeActiveEntities(count) { + return pluralizeRu(count, 'активный', 'активных', 'активных'); + } + + function pluralizeIncludedEntities(count) { + return pluralizeRu(count, 'включенный объект', 'включенных объекта', 'включенных объектов'); + } + + function formatTime(date = new Date()) { + return new Intl.DateTimeFormat('ru-RU', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); + } + + function formatDate(date = new Date()) { + const weekday = new Intl.DateTimeFormat('ru-RU', { weekday: 'long' }).format(date); + const dayMonth = new Intl.DateTimeFormat('ru-RU', { + day: 'numeric', + month: 'long', + }).format(date); + return `${weekday}, ${dayMonth}`; + } + + function buildUrl(action, params = {}) { + if (isHaRuntime()) { + return ''; + } + const url = new URL('api.php', window.location.href); + url.searchParams.set('action', action); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, value); + } + }); + const proxyToken = String(window.APP_BOOTSTRAP?.ui?.proxy_token || '').trim(); + if (proxyToken && !url.searchParams.has('token')) { + url.searchParams.set('token', proxyToken); + } + return url.toString(); + } + + async function apiGet(action, params = {}) { + const bridge = haBridge(); + if (bridge?.request) { + return bridge.request('GET', action, params); + } + const res = await fetch(buildUrl(action, params), { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + return res.json(); + } + + async function apiPost(action, payload = {}) { + const bridge = haBridge(); + if (bridge?.request) { + return bridge.request('POST', action, payload); + } + const res = await fetch(buildUrl(action), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), + }); + const json = await res.json(); + if (!res.ok || json.ok === false) { + throw new Error(json.error || `Request failed: ${res.status}`); + } + return json; + } + + async function fetchSnapshot(roomId = state.selectedRoomId || 'main') { + const response = await apiGet('snapshot', { space_id: roomId || 'main' }); + if (response && response.ok === true && response.selected_room) { + return response; + } + return response; + } + + async function loadSnapshot(roomId = state.selectedRoomId || 'main') { + const snapshot = await fetchSnapshot(roomId); + state.snapshot = snapshot; + return snapshot; + } + + function currentRoom() { + const snapshot = state.snapshot || {}; + if (state.selectedRoomId === 'batteries') { + return snapshot.battery_room || snapshot.selected_room || snapshot.selected_space || null; + } + const spaces = snapshot.spaces || snapshot.rooms || []; + return spaces.find((space) => space.id === state.selectedRoomId) || snapshot.selected_space || snapshot.selected_room || null; + } + + function roomEntityCollection(snapshot, roomId) { + const room = roomId === 'main' + ? { entities: snapshot.main_entities || [] } + : roomId === 'batteries' + ? (snapshot.battery_room || snapshot.selected_room || snapshot.selected_space || { entities: [] }) + : snapshot.space_index?.[roomId] + || snapshot.space_entities?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null); + return room?.entities || []; + } + + function sortRoomEntities(entities) { + return (Array.isArray(entities) ? entities : []) + .slice() + .sort((left, right) => { + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left?.name || '').localeCompare(String(right?.name || ''), 'ru'); + }); + } + + function sortMainEntities(entities) { + return (Array.isArray(entities) ? entities : []) + .slice() + .sort((left, right) => { + const leftTime = entitySortTime(left); + const rightTime = entitySortTime(right); + if (leftTime !== rightTime) return leftTime - rightTime; + + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + + return String(left?.name || '').localeCompare(String(right?.name || ''), 'ru'); + }); + } + + function roomEntities(snapshot, roomId) { + const collection = roomEntityCollection(snapshot, roomId).filter((entity) => entity.visible !== false); + return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection); + } + + function roomEntitiesIncludingHidden(snapshot, roomId) { + const collection = roomEntityCollection(snapshot, roomId); + return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection); + } + + function roomLayoutItemCollection(snapshot, roomId) { + if (!snapshot || !roomId || roomId === 'main') { + return []; + } + + const room = snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null); + const items = Array.isArray(room?.layout_items) ? room.layout_items : []; + return items.filter((item) => item && typeof item === 'object' && String(item.type || 'ghost') === 'ghost'); + } + + function roomLayoutItems(snapshot, roomId) { + return roomLayoutItemCollection(snapshot, roomId) + .slice() + .sort((left, right) => { + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left?.id || '').localeCompare(String(right?.id || ''), 'ru'); + }); + } + + function roomGridEntries(snapshot, roomId) { + const entries = []; + + roomEntities(snapshot, roomId).forEach((entity) => { + entries.push({ + kind: 'entity', + id: entity.entity_id, + order: Number(entity.order ?? 9999) || 9999, + sortLabel: String(entity.name || entity.entity_id || ''), + payload: entity, + }); + }); + + if (!isMobileViewport() && roomId !== 'main') { + roomLayoutItems(snapshot, roomId).forEach((item) => { + entries.push({ + kind: 'layout', + id: item.id, + order: Number(item.order ?? 9999) || 9999, + sortLabel: String(item.id || ''), + payload: item, + }); + }); + } + + entries.sort((left, right) => { + if (left.order !== right.order) { + return left.order - right.order; + } + if (left.kind !== right.kind) { + return left.kind === 'entity' ? -1 : 1; + } + return left.sortLabel.localeCompare(right.sortLabel, 'ru'); + }); + + return entries; + } + + function isTemperatureSensorEntity(entity) { + if (!entity || String(entity.domain || '').toLowerCase() !== 'sensor') { + return false; + } + + const entityId = String(entity.entity_id || ''); + const attributes = entity.attributes || {}; + const deviceClass = String(attributes.device_class || '').toLowerCase(); + const unit = String(attributes.unit_of_measurement || '').trim().toLowerCase(); + + return deviceClass === 'temperature' + || unit === '°c' + || unit === 'c' + || entityId.endsWith('_temperature'); + } + + function roomTemperatureSensorLabel(entity) { + const name = String(entity?.name || entity?.attributes?.friendly_name || entity?.entity_id || 'Датчик'); + const value = entity?.attributes?.current_temperature ?? entity?.attributes?.temperature ?? entity?.state ?? null; + const numeric = Number(value); + const valueText = Number.isFinite(numeric) ? `${Math.round(numeric)}°` : '—'; + + return { + name, + valueText, + meta: String(entity?.entity_id || ''), + }; + } + + function roomTemperatureSensorCandidates(snapshot, roomId) { + const room = snapshot?.space_index?.[roomId] + || (snapshot?.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot?.selected_room?.id === roomId ? snapshot.selected_room : null); + const entities = Array.isArray(room?.entities) ? room.entities : []; + const selectedId = String(room?.temperature_sensor_entity_id || '').trim(); + const candidates = entities.filter((entity) => entity && isTemperatureSensorEntity(entity)); + + candidates.sort((left, right) => { + const leftSelected = String(left.entity_id || '') === selectedId ? 0 : 1; + const rightSelected = String(right.entity_id || '') === selectedId ? 0 : 1; + if (leftSelected !== rightSelected) return leftSelected - rightSelected; + + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + + return String(left.name || left.entity_id || '').localeCompare(String(right.name || right.entity_id || ''), 'ru'); + }); + + return candidates; + } + + function roomTemperatureBadge(snapshot, room) { + const roomId = String(room?.id || ''); + if (!roomId || roomId === 'main') { + return null; + } + + const roomIndex = snapshot?.space_index?.[roomId] || room || {}; + const selectedId = String(roomIndex.temperature_sensor_entity_id || room.temperature_sensor_entity_id || '').trim(); + const entities = Array.isArray(roomIndex.entities) ? roomIndex.entities : []; + + if (selectedId) { + const selected = entities.find((entity) => entity && String(entity.entity_id || '') === selectedId); + if (selected) { + const value = selected.attributes?.current_temperature ?? selected.attributes?.temperature ?? selected.state ?? null; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return `${Math.round(numeric)}°`; + } + } + + if (room.temperature_badge) { + return room.temperature_badge; + } + } + + const firstCandidate = entities.find((entity) => entity && isTemperatureSensorEntity(entity)); + if (firstCandidate) { + const value = firstCandidate.attributes?.current_temperature ?? firstCandidate.attributes?.temperature ?? firstCandidate.state ?? null; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return `${Math.round(numeric)}°`; + } + } + + if (room.temperature_badge) { + return room.temperature_badge; + } + + return null; + } + + function entityKindLabel(entity) { + return String(entity?.domain || entity?.entity_id?.split('.')?.[0] || '').toLowerCase() || 'entity'; + } + + function renderEntityTypeLabel(entity) { + const kind = document.createElement('div'); + kind.className = 'grid-card__kind'; + kind.textContent = entityKindLabel(entity); + return kind; + } + + function entityFromSnapshot(snapshot, entityId) { + return getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); + } + + function entityPopupEntity() { + const snapshot = state.snapshot || bootstrap; + const entityId = state.entityPopup?.entityId; + return entityFromSnapshot(snapshot, entityId); + } + + function openEntityPopup(entityId) { + const snapshot = state.snapshot || bootstrap; + const entity = entityFromSnapshot(snapshot, entityId); + if (!entity) return; + state.entityPopup = { + active: true, + entityId, + kind: entityKindLabel(entity), + }; + renderEntityPopup(snapshot); + } + + function closeEntityPopup() { + state.entityPopup = { + active: false, + entityId: null, + }; + const backdrop = els.entityBackdrop; + if (backdrop) { + backdrop.classList.remove('is-open'); + backdrop.setAttribute('aria-hidden', 'true'); + } + if (els.entityBody) { + els.entityBody.innerHTML = ''; + } + if (els.entityTitle) { + els.entityTitle.textContent = 'Устройство'; + } + if (els.entityEyebrow) { + els.entityEyebrow.textContent = ''; + } + } + + function closeTemperatureSensorPopup() { + state.temperatureSensorPopup = { + active: false, + roomId: null, + }; + state.lastTemperatureSensorPopupSignature = ''; + const backdrop = els.temperatureSensorBackdrop; + if (backdrop) { + backdrop.classList.remove('is-open'); + backdrop.setAttribute('aria-hidden', 'true'); + } + if (els.temperatureSensorBody) { + els.temperatureSensorBody.innerHTML = ''; + } + } + + function openTemperatureSensorPopup(roomId) { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null) + || null; + if (!room || roomId === 'main' || roomId === 'batteries') return; + + state.temperatureSensorPopup = { + active: true, + roomId, + }; + renderTemperatureSensorPopup(snapshot); + } + + function renderEntityPopup(snapshot) { + const backdrop = els.entityBackdrop; + if (!backdrop) return false; + + const popupState = state.entityPopup || {}; + const entity = popupState.active ? entityFromSnapshot(snapshot, popupState.entityId) : null; + if (!popupState.active || !entity) { + closeEntityPopup(); + return false; + } + + const signature = JSON.stringify([ + entity.entity_id || '', + entity.state || '', + entity.attributes?.current_position ?? '', + entity.attributes?.temperature ?? '', + entity.attributes?.current_temperature ?? '', + entity.attributes?.hvac_mode ?? '', + entity.attributes?.fan_mode ?? '', + entity.attributes?.swing_mode ?? '', + entity.attributes?.preset_mode ?? '', + entity.attributes?.hvac_action ?? '', + state.editMode ? '1' : '0', + ]); + + if (signature === state.lastEntityPopupSignature && backdrop.classList.contains('is-open')) { + return true; + } + + state.lastEntityPopupSignature = signature; + backdrop.classList.add('is-open'); + backdrop.setAttribute('aria-hidden', 'false'); + + if (els.entityTitle) { + els.entityTitle.textContent = entity.name || 'Устройство'; + } + if (els.entityEyebrow) { + els.entityEyebrow.textContent = entityKindLabel(entity); + } + if (els.entityBody) { + els.entityBody.replaceChildren(); + if (entity.domain === 'cover') { + els.entityBody.appendChild(renderCoverPopup(entity)); + } else if (entity.domain === 'climate') { + els.entityBody.appendChild(renderClimatePopup(entity)); + } else { + const fallback = document.createElement('div'); + fallback.className = 'entity-modal__fallback'; + fallback.textContent = 'Для этого типа пока нет popup.'; + els.entityBody.appendChild(fallback); + } + } + + return true; + } + + function renderTemperatureSensorPopup(snapshot) { + const backdrop = els.temperatureSensorBackdrop; + if (!backdrop) return false; + + if (isMobileViewport() || !state.editMode) { + closeTemperatureSensorPopup(); + return false; + } + + const popupState = state.temperatureSensorPopup || {}; + const roomId = popupState.active ? popupState.roomId : null; + const room = roomId + ? (snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null)) + : null; + + if (!popupState.active || !room || roomId === 'main' || roomId === 'batteries') { + closeTemperatureSensorPopup(); + return false; + } + + const candidates = roomTemperatureSensorCandidates(snapshot, roomId); + const selectedId = String(room.temperature_sensor_entity_id || room?.temperature_sensor_entity_id || '').trim(); + const signature = JSON.stringify([ + roomId, + selectedId, + candidates.map((entity) => `${entity.entity_id}:${entity.state}:${entity.attributes?.current_temperature ?? entity.attributes?.temperature ?? ''}`).join('|'), + state.editMode ? '1' : '0', + ]); + + if (signature === state.lastTemperatureSensorPopupSignature && backdrop.classList.contains('is-open')) { + return true; + } + + state.lastTemperatureSensorPopupSignature = signature; + backdrop.classList.add('is-open'); + backdrop.setAttribute('aria-hidden', 'false'); + + if (els.temperatureSensorTitle) { + els.temperatureSensorTitle.textContent = room.name ? `Выбрать датчик температуры · ${room.name}` : 'Выбрать датчик температуры'; + } + + if (!els.temperatureSensorBody) { + return true; + } + + els.temperatureSensorBody.replaceChildren(); + + const current = document.createElement('div'); + current.className = 'temperature-sensor-modal__current'; + const selectedEntity = selectedId + ? candidates.find((entity) => String(entity.entity_id || '') === selectedId) + : null; + current.innerHTML = ` +
Текущий выбор
+
${selectedEntity ? esc(selectedEntity.name || selectedEntity.entity_id) : 'Автоматически'}
+ `; + els.temperatureSensorBody.appendChild(current); + + const resetButton = document.createElement('button'); + resetButton.type = 'button'; + resetButton.className = `temperature-sensor-modal__option ${!selectedId ? 'is-active' : ''}`; + resetButton.innerHTML = ` + + Автоматически + Использовать первый подходящий датчик в комнате + + ${!selectedId ? 'Выбрано' : 'Сбросить'} + `; + resetButton.addEventListener('click', async () => { + await saveSpacePatch(room, { temperature_sensor_entity_id: '' }); + closeTemperatureSensorPopup(); + }); + els.temperatureSensorBody.appendChild(resetButton); + + if (!candidates.length) { + const empty = document.createElement('div'); + empty.className = 'temperature-sensor-modal__empty'; + empty.textContent = 'В этой комнате не найдено температурных датчиков.'; + els.temperatureSensorBody.appendChild(empty); + return true; + } + + const list = document.createElement('div'); + list.className = 'temperature-sensor-modal__list'; + + candidates.forEach((entity) => { + const label = roomTemperatureSensorLabel(entity); + const option = document.createElement('button'); + option.type = 'button'; + option.className = `temperature-sensor-modal__option ${String(entity.entity_id || '') === selectedId ? 'is-active' : ''}`; + option.innerHTML = ` + + ${esc(label.name)} + ${esc(label.meta)} + + ${esc(label.valueText)} + `; + option.addEventListener('click', async () => { + await saveSpacePatch(room, { temperature_sensor_entity_id: entity.entity_id }); + closeTemperatureSensorPopup(); + }); + list.appendChild(option); + }); + + els.temperatureSensorBody.appendChild(list); + return true; + } + + function syncLayoutState() { + if (!els.appShell) return; + + const mobile = isMobileViewport(); + const embedded = Boolean(state.embedMode); + document.body.classList.toggle('is-mobile-ui', mobile); + document.body.classList.toggle('is-embedded', embedded); + 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('mobile-view-spaces', mobile && state.mobileView !== 'room'); + els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room'); + + if (els.selectedRoomBack) { + els.selectedRoomBack.hidden = !isMobileRoomView(); + } + } + + function normalizePositionValue(value) { + const next = Number(value); + if (!Number.isFinite(next)) return null; + return Math.max(0, Math.min(100, Math.round(next))); + } + + function shouldShowMainEntity(entity) { + if (!entity) return false; + const domain = String(entity.domain || entity.entity_id?.split('.')?.[0] || '').toLowerCase(); + const state = String(entity.state || '').toLowerCase(); + const isAuto = Boolean(entity.is_auto); + const isHidden = Boolean(entity.is_hidden); + const isDoorContact = Boolean(entity.is_door_contact); + if (!isAuto || isHidden) return false; + if (!['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain)) return false; + if (domain === 'binary_sensor' && !isDoorContact) return false; + return domain === 'cover' + ? ['open', 'opening', 'closing'].includes(state) + : domain === 'binary_sensor' + ? ['on', 'open'].includes(state) + : ['on', 'cool', 'heat', 'heating', 'cooling'].includes(state); + } + + function mainCardsContainer() { + return q('.main-dashboard__cards', els.dashboardSurface); + } + + function renderMainRoomSummaryGrid(snapshot) { + const rooms = Array.isArray(snapshot?.spaces) ? snapshot.spaces : Array.isArray(snapshot?.rooms) ? snapshot.rooms : []; + const batteryRoom = snapshot?.battery_room || null; + const cards = document.createElement('div'); + cards.className = 'room-list__group main-dashboard__room-grid'; + + const roomCard = (room, options = {}) => { + if (!room) return null; + const card = document.createElement('div'); + card.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${options.hidden ? 'is-hidden-room' : ''}`.trim(); + card.dataset.roomId = room.id; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + card.addEventListener('click', () => setSelectedRoom(room.id)); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedRoom(room.id); + } + }); + + const content = document.createElement('div'); + content.className = 'room-item__content'; + + const icon = document.createElement('div'); + icon.className = 'room-item__icon'; + icon.appendChild(createIconElement(room.icon || 'mdi:home-variant')); + + const body = document.createElement('div'); + body.className = 'room-item__body'; + const activeCount = room.id === 'batteries' + ? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0 + : Number(room.active_entity_count ?? room.entity_count ?? 0) || 0; + const metaText = room.id === 'main' + ? 'Главный экран' + : room.id === 'batteries' + ? (room.battery_summary_text || `${room.entity_count || 0} батареек`) + : activeCount > 0 + ? `${activeCount} ${pluralizeActiveEntities(activeCount)}` + : 'Нет активных'; + body.innerHTML = ` +
${esc(room.name || '')}
+
${esc(metaText)}
+ `; + content.append(icon, body); + + const tempBadge = roomTemperatureBadge(snapshot, room); + if (tempBadge) { + card.classList.add('has-temp'); + const temp = document.createElement('div'); + temp.className = 'room-item__temp'; + temp.textContent = tempBadge; + card.appendChild(temp); + } + + card.append(content); + return card; + }; + + const orderedRooms = [...rooms] + .filter((room) => room && room.id !== 'main' && room.visible !== false && room.id !== 'batteries') + .sort((left, right) => { + const leftOrder = Number(left.order ?? 9999); + const rightOrder = Number(right.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left.name || '').localeCompare(String(right.name || ''), 'ru'); + }); + + orderedRooms.forEach((room) => { + const card = roomCard(room); + if (card) cards.appendChild(card); + }); + + if (batteryRoom && !isMobileViewport()) { + const card = roomCard(batteryRoom); + if (card) cards.appendChild(card); + } + + return cards; + } + + function currentDashboardCardsContainer() { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + return mainCardsContainer(); + } + return els.dashboardSurface; + } + + function findRenderedCard(entityId) { + if (!entityId) return null; + return q(`[data-entity-id="${CSS.escape(entityId)}"]`, els.dashboardSurface); + } + + function sortMainCardsBySnapshot(container) { + const snapshot = state.snapshot || {}; + const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((entity) => entity.entity_id); + const order = new Map(orderedMainIds.map((entityId, index) => [entityId, index])); + const cards = Array.from(container?.querySelectorAll('.grid-card[data-entity-id]') || []); + cards.sort((left, right) => { + const leftId = left.dataset.entityId || ''; + const rightId = right.dataset.entityId || ''; + const leftOrder = order.has(leftId) ? order.get(leftId) : Number.MAX_SAFE_INTEGER; + const rightOrder = order.has(rightId) ? order.get(rightId) : Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return leftId.localeCompare(rightId, 'ru'); + }); + cards.forEach((card) => container.appendChild(card)); + } + + function updateMainWeatherCard() { + const snapshot = state.snapshot || {}; + const hero = q('.main-dashboard__hero', els.dashboardSurface); + if (!hero) return false; + const next = renderMainHero(snapshot); + hero.replaceWith(next); + return true; + } + + function updateMainEntityCard(entityId) { + const snapshot = state.snapshot || {}; + const container = mainCardsContainer(); + if (!container) return false; + + const entity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); + const existing = q(`[data-entity-id="${CSS.escape(entityId)}"]`, container); + const shouldShow = shouldShowMainEntity(entity); + if (existing && !shouldShow) { + existing.remove(); + return true; + } + if (!shouldShow) { + return false; + } + + const nextCard = renderEntityCard(entity, { isMain: true }); + if (existing) { + existing.replaceWith(nextCard); + return true; + } + + const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((item) => item.entity_id); + const nextIndex = orderedMainIds.indexOf(entityId); + const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]')); + for (const card of cards) { + const cardId = card.dataset.entityId; + const cardIndex = orderedMainIds.indexOf(cardId); + if (cardIndex > nextIndex && nextIndex !== -1) { + card.before(nextCard); + return true; + } + } + + container.appendChild(nextCard); + return true; + } + + function updateRoomEntityCard(entityId) { + const snapshot = state.snapshot || {}; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + return updateMainEntityCard(entityId); + } + if (room.id === 'batteries') { + renderDashboardOnly(); + return true; + } + renderDashboardOnly(); + return true; + } + + function setCardInteractionLock(entityId) { + state.roomDrag = state.roomDrag || {}; + state.roomDrag.suppressClickUntil = Date.now() + 180; + state.roomDrag.entityId = entityId; + } + + function openConfirm(options = {}) { + const backdrop = els.confirmBackdrop; + if (!backdrop) return Promise.resolve(false); + els.confirmTitle.textContent = options.title || 'Хотите закрыть?'; + els.confirmMessage.textContent = options.message || 'Это действие отправит команду закрытия.'; + backdrop.classList.add('is-open'); + backdrop.setAttribute('aria-hidden', 'false'); + + return new Promise((resolve) => { + state.confirmResolver = resolve; + const finish = (result) => { + if (state.confirmResolver) { + const resolver = state.confirmResolver; + state.confirmResolver = null; + resolver(result); + } + backdrop.classList.remove('is-open'); + backdrop.setAttribute('aria-hidden', 'true'); + }; + + const onYes = () => finish(true); + const onNo = () => finish(false); + const onBackdrop = (event) => { + if (event.target === backdrop) onNo(); + }; + + const cleanup = () => { + els.confirmYes.removeEventListener('click', onYes); + els.confirmNo.removeEventListener('click', onNo); + backdrop.removeEventListener('click', onBackdrop); + }; + + els.confirmYes.addEventListener('click', () => { + cleanup(); + onYes(); + }, { once: true }); + els.confirmNo.addEventListener('click', () => { + cleanup(); + onNo(); + }, { once: true }); + backdrop.addEventListener('click', (event) => { + if (event.target === backdrop) { + cleanup(); + onNo(); + } + }, { once: true }); + }); + } + + function roomCollections() { + const snapshot = state.snapshot || {}; + const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : Array.isArray(snapshot.rooms) ? snapshot.rooms : []; + const visible = spaces.filter((room) => room.id !== 'main' && room.visible !== false); + const hidden = spaces.filter((room) => room.id !== 'main' && room.visible === false); + return { visible, hidden }; + } + + function orderedRoomIdsFromGroup(groupEl) { + return qa('.room-item', groupEl) + .map((item) => item.dataset.roomId) + .filter((roomId) => roomId && roomId !== 'main'); + } + + function roomById(roomId) { + const snapshot = state.snapshot || {}; + const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : Array.isArray(snapshot.rooms) ? snapshot.rooms : []; + return spaces.find((room) => room.id === roomId) || null; + } + + async function persistRoomOrderForGroup(groupEl, hidden = false) { + const snapshot = state.snapshot || {}; + const roomIds = orderedRoomIdsFromGroup(groupEl); + if (!roomIds.length) return; + + const baseOrder = hidden ? 1000 : 0; + const roomsById = new Map((snapshot.spaces || snapshot.rooms || []).map((room) => [room.id, room])); + const changes = roomIds.map((roomId, index) => { + const room = roomsById.get(roomId); + const nextOrder = baseOrder + (index * 10); + if (!room || Number(room.order ?? 9999) === nextOrder) { + return null; + } + return { roomId, nextOrder }; + }).filter(Boolean); + + if (!changes.length) return; + + await Promise.all(changes.map(({ roomId, nextOrder }) => apiPost('save-space-override', { + room_id: roomId, + order: nextOrder, + }))); + + changes.forEach(({ roomId, nextOrder }) => { + patchSnapshotSpace(roomId, { order: nextOrder }); + }); + renderSidebarOnly(); + } + + function clearRoomDragState() { + const drag = state.roomDrag; + if (!drag) return; + if (drag.itemEl) { + drag.itemEl.classList.remove('is-dragging'); + drag.itemEl.removeAttribute('aria-grabbed'); + } + state.roomDrag = null; + } + + function startRoomDrag(room, itemEl, groupEl, hidden, event) { + if (!state.editMode || room.id === 'main') return; + if (event.target.closest('button')) return; + + const pointerId = event.pointerId; + const drag = { + roomId: room.id, + itemEl, + groupEl, + hidden, + pointerId, + startX: event.clientX, + startY: event.clientY, + moved: false, + suppressClickUntil: 0, + }; + state.roomDrag = drag; + itemEl.classList.add('is-dragging'); + itemEl.setAttribute('aria-grabbed', 'true'); + if (itemEl.setPointerCapture) { + try { + itemEl.setPointerCapture(pointerId); + } catch (error) { + console.warn(error); + } + } + event.preventDefault(); + } + + function roomDropTargetAtPoint(x, y, groupEl, draggedId) { + const node = document.elementFromPoint(x, y); + const item = node?.closest?.('.room-item'); + if (!item || item.dataset.roomId === draggedId || item.dataset.roomGroup !== (state.roomDrag?.hidden ? 'hidden' : 'visible')) { + return null; + } + if (!groupEl.contains(item)) { + return null; + } + return item; + } + + function moveRoomDrag(clientX, clientY) { + const drag = state.roomDrag; + if (!drag) return; + + const dx = Math.abs(clientX - drag.startX); + const dy = Math.abs(clientY - drag.startY); + if (!drag.moved && Math.max(dx, dy) < 6) { + return; + } + + drag.moved = true; + const target = roomDropTargetAtPoint(clientX, clientY, drag.groupEl, drag.roomId); + if (!target) return; + + const targetRect = target.getBoundingClientRect(); + const before = clientY < (targetRect.top + targetRect.height / 2); + if (before) { + drag.groupEl.insertBefore(drag.itemEl, target); + } else { + drag.groupEl.insertBefore(drag.itemEl, target.nextSibling); + } + } + + async function finishRoomDrag() { + const drag = state.roomDrag; + if (!drag) return; + + const itemEl = drag.itemEl; + const groupEl = drag.groupEl; + const hidden = drag.hidden; + const moved = drag.moved; + itemEl.classList.remove('is-dragging'); + itemEl.removeAttribute('aria-grabbed'); + + if (drag.itemEl.releasePointerCapture && drag.pointerId !== null) { + try { + drag.itemEl.releasePointerCapture(drag.pointerId); + } catch (error) { + console.warn(error); + } + } + + state.roomDrag = { + ...drag, + suppressClickUntil: Date.now() + 200, + }; + + if (moved) { + await persistRoomOrderForGroup(groupEl, hidden); + } + + window.setTimeout(() => { + if (state.roomDrag && Date.now() >= (state.roomDrag.suppressClickUntil || 0)) { + state.roomDrag = null; + } + }, 220); + } + + function updateEntityInCollection(collection, entityId, updater) { + if (!Array.isArray(collection)) return false; + let changed = false; + collection.forEach((entity) => { + if (!entity || entity.entity_id !== entityId) return; + updater(entity); + changed = true; + }); + return changed; + } + + function updateEntityInMap(map, entityId, updater) { + if (!map || typeof map !== 'object') return false; + const entity = map[entityId]; + if (!entity || typeof entity !== 'object') return false; + updater(entity); + return true; + } + + function patchSnapshotEntity(entityId, patch = {}) { + const snapshot = state.snapshot || {}; + let changed = false; + + const applyPatch = (entity) => { + Object.assign(entity, patch); + changed = true; + }; + + updateEntityInCollection(snapshot.main_entities, entityId, applyPatch); + updateEntityInMap(snapshot.entity_index, entityId, applyPatch); + updateEntityInMap(snapshot.space_index, entityId, applyPatch); + + if (snapshot.space_entities && typeof snapshot.space_entities === 'object') { + Object.values(snapshot.space_entities).forEach((collection) => { + updateEntityInCollection(collection, entityId, applyPatch); + }); + } + + if (snapshot.battery_room?.entities) { + updateEntityInCollection(snapshot.battery_room.entities, entityId, applyPatch); + } + + if (snapshot.selected_space?.entities) { + updateEntityInCollection(snapshot.selected_space.entities, entityId, applyPatch); + } + + if (snapshot.selected_room?.entities) { + updateEntityInCollection(snapshot.selected_room.entities, entityId, applyPatch); + } + + return changed; + } + + function syncMainEntities(entityId, sourceEntity, patch = {}) { + const snapshot = state.snapshot || {}; + const list = Array.isArray(snapshot.main_entities) ? snapshot.main_entities : []; + const index = list.findIndex((entity) => entity && entity.entity_id === entityId); + const entity = index >= 0 ? list[index] : null; + const nextState = String(patch.state ?? sourceEntity?.state ?? '').toLowerCase(); + const definition = getEntityDefinition(snapshot, entityId) || sourceEntity || entity || { entity_id: entityId }; + const domain = String(definition?.domain || entity?.domain || entityId.split('.')[0] || '').toLowerCase(); + const isDoorContact = Boolean(definition?.is_door_contact || entity?.is_door_contact); + const shouldDisplay = domain === 'cover' + ? ['open', 'opening', 'closing'].includes(nextState) + : domain === 'binary_sensor' + ? isDoorContact && ['on', 'open'].includes(nextState) + : ['on', 'cool', 'heat', 'heating', 'cooling'].includes(nextState) || (domain === 'fan' && nextState === 'on'); + const isAllowedDomain = ['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain); + const isAuto = Boolean(definition?.is_auto); + + if (definition?.is_hidden || !isAuto) { + if (index >= 0) { + list.splice(index, 1); + if (snapshot.selected_space?.id === 'main') { + snapshot.selected_space.entities = list; + snapshot.selected_room = snapshot.selected_space; + } + return true; + } + return false; + } + + if (!isAllowedDomain || (domain === 'binary_sensor' && !isDoorContact)) { + if (index >= 0) { + list.splice(index, 1); + if (snapshot.selected_space?.id === 'main') { + snapshot.selected_space.entities = list; + snapshot.selected_room = snapshot.selected_space; + } + return true; + } + return false; + } + + if (!shouldDisplay) { + if (index >= 0) { + list.splice(index, 1); + } else { + return false; + } + } else if (index >= 0) { + Object.assign(entity, { + ...definition, + ...patch, + }); + } else { + list.push({ + ...definition, + ...patch, + last_changed: patch.last_changed || sourceEntity?.last_changed || sourceEntity?.last_updated || new Date().toISOString(), + }); + } + + list.sort((a, b) => { + const timeA = entitySortTime(a); + const timeB = entitySortTime(b); + if (timeA !== timeB) return timeA - timeB; + return String(a.name || '').localeCompare(String(b.name || ''), 'ru'); + }); + + if (snapshot.selected_space?.id === 'main') { + snapshot.selected_space.entities = list; + snapshot.selected_room = snapshot.selected_space; + } + + return true; + } + + function getEntityFromSnapshot(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) return found; + } + + return null; + } + + function getEntityDefinition(snapshot, entityId) { + if (!snapshot || !entityId) return null; + if (snapshot.entity_index && typeof snapshot.entity_index === 'object' && snapshot.entity_index[entityId]) { + return snapshot.entity_index[entityId]; + } + return getEntityFromSnapshot(snapshot, entityId); + } + + function statePayloadChanged(existing, incoming) { + if (!existing || !incoming) return true; + if (String(existing.state ?? '') !== String(incoming.state ?? '')) return true; + return JSON.stringify(existing.attributes || {}) !== JSON.stringify(incoming.attributes || {}); + } + + function patchSnapshotSpace(roomId, patch = {}) { + const snapshot = state.snapshot || {}; + const collections = [snapshot.spaces, snapshot.rooms]; + let changed = false; + + collections.forEach((collection) => { + if (!Array.isArray(collection)) return; + collection.forEach((room) => { + if (!room || room.id !== roomId) return; + Object.assign(room, patch); + changed = true; + }); + }); + + if (snapshot.space_index && snapshot.space_index[roomId]) { + Object.assign(snapshot.space_index[roomId], patch); + changed = true; + } + + if (snapshot.selected_space?.id === roomId) { + Object.assign(snapshot.selected_space, patch); + changed = true; + } + + if (snapshot.selected_room?.id === roomId) { + Object.assign(snapshot.selected_room, patch); + changed = true; + } + + return changed; + } + + function patchSnapshotSelection(roomId) { + const snapshot = state.snapshot || {}; + const spaces = snapshot.spaces || snapshot.rooms || []; + const room = roomId === 'main' + ? { + id: 'main', + name: snapshot.settings?.main_room_name || 'Главная', + icon: snapshot.settings?.main_room_icon || 'mdi:home', + visible: true, + entities: snapshot.main_entities || [], + } + : roomId === 'batteries' + ? snapshot.battery_room + : snapshot.space_index?.[roomId] || spaces.find((space) => space.id === roomId); + + if (!room) return; + + state.selectedRoomId = roomId; + if (roomId === 'main') { + clearRoomAutoReturnTimer(); + snapshot.selected_space = { + id: 'main', + name: snapshot.settings?.main_room_name || 'Главная', + icon: snapshot.settings?.main_room_icon || 'mdi:home', + visible: true, + entities: snapshot.main_entities || [], + }; + snapshot.selected_room = snapshot.selected_space; + return; + } + + if (roomId === 'batteries') { + snapshot.selected_space = { + ...room, + entities: Array.isArray(room.entities) ? room.entities : [], + }; + snapshot.selected_room = snapshot.selected_space; + return; + } + + const entities = snapshot.space_index?.[roomId]?.entities || snapshot.space_entities?.[roomId] || room.entities || []; + snapshot.selected_space = { + ...room, + entities, + }; + snapshot.selected_room = snapshot.selected_space; + } + + function applyPopupState(active, sensorEntityId) { + const camera = state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; + const popup = state.snapshot?.popup || {}; + if (active && Date.now() < Number(state.popupAutoOpenBlockedUntil || 0)) { + return; + } + const next = { + ...popup, + active, + sensor_entity_id: sensorEntityId || null, + opened_at: active ? Math.floor(Date.now() / 1000) : popup.opened_at || null, + expires_at: active ? Math.floor(Date.now() / 1000) + (Number(camera.popup_timeout_minutes || 3) * 60) : null, + poster_url: camera.poster_url || popup.poster_url || '', + stream_url: camera.stream_url || popup.stream_url || '', + stream_mode: camera.stream_mode || popup.stream_mode || 'hls', + title: popup.title || 'Камера', + }; + + state.snapshot = state.snapshot || bootstrap; + state.snapshot.popup = next; + renderPopup(state.snapshot); + } + + function applyPopupSnapshot(popup = {}) { + const snapshot = state.snapshot || bootstrap; + snapshot.popup = mergePopupWithCamera({ + ...(snapshot.popup || {}), + ...popup, + }); + renderPopup(snapshot); + } + + function syncTriggerPopup(entityId, stateValue) { + const value = String(stateValue || '').toLowerCase(); + if (!['on', 'off'].includes(value)) { + return; + } + + apiPost('popup', { sensor_entity_id: entityId, state: value }) + .then((response) => { + if (response?.popup) { + applyPopupSnapshot(response.popup); + } + }) + .catch((error) => { + console.warn(error); + }); + } + + function haConnection() { + if (isHaRuntime()) { + return null; + } + return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {}; + } + + function haWsUrl(baseUrl) { + if (isHaRuntime()) { + return ''; + } + if (!baseUrl) return ''; + try { + const url = new URL(baseUrl); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.pathname = '/api/websocket'; + url.search = ''; + url.hash = ''; + return url.toString(); + } catch (error) { + return ''; + } + } + + function setStatus(text, tone = '') { + if (!els.connectionStatus) return; + els.connectionStatus.textContent = text; + els.connectionStatus.dataset.tone = tone; + } + + function clearRoomAutoReturnTimer() { + if (state.roomAutoReturnTimer) { + clearTimeout(state.roomAutoReturnTimer); + state.roomAutoReturnTimer = null; + } + } + + function scheduleRoomAutoReturn(roomId) { + const nextRoomId = roomId || 'main'; + clearRoomAutoReturnTimer(); + if (nextRoomId === 'main' || isMobileViewport()) { + return; + } + + state.roomAutoReturnTimer = window.setTimeout(() => { + state.roomAutoReturnTimer = null; + if ((state.selectedRoomId || 'main') === nextRoomId) { + setSelectedRoom('main'); + } + }, 120000); + } + + async function setSelectedRoom(roomId) { + const nextRoomId = roomId || 'main'; + const token = ++state.roomSelectionToken; + clearRoomAutoReturnTimer(); + closeTemperatureSensorPopup(); + patchSnapshotSelection(nextRoomId); + if (isMobileViewport()) { + setMobileView('room'); + } + render(); + scheduleRoomAutoReturn(nextRoomId); + try { + const snapshot = await fetchSnapshot(nextRoomId); + if (token !== state.roomSelectionToken) { + return; + } + state.snapshot = snapshot; + patchSnapshotSelection(nextRoomId); + render(); + } catch (error) { + if (token !== state.roomSelectionToken) { + return; + } + console.warn(error); + } + } + + function createButton(label, subtitle, icon, className = '', attrs = {}) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = className; + Object.entries(attrs).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + btn.dataset[key] = value; + } + }); + + const iconEl = document.createElement('i'); + iconEl.className = iconClass(icon); + + const body = document.createElement('div'); + body.className = 'mushroom-button__body'; + + const titleEl = document.createElement('div'); + titleEl.className = 'mushroom-button__title'; + titleEl.textContent = label; + + body.appendChild(titleEl); + + if (subtitle) { + const subEl = document.createElement('div'); + subEl.className = 'mushroom-button__subtitle'; + subEl.textContent = subtitle; + body.appendChild(subEl); + } + + btn.appendChild(iconEl); + btn.appendChild(body); + return btn; + } + + function mainWeatherCard(weather) { + const card = document.createElement('article'); + card.className = 'grid-card grid-card--weather grid-card--weather-compact'; + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner weather-card weather-card--compact'; + + const title = document.createElement('div'); + title.className = 'grid-card__title_weather'; + title.textContent = 'Погода'; + + const rows = document.createElement('div'); + rows.className = 'weather-card__rows'; + [ + ['Интернет', weather?.temperature != null ? `${Number(weather.temperature).toFixed(0)}°C` : '—'], + ['Датчик', weather?.sensor_temperature != null ? `${weather.sensor_temperature}°C` : '—'], + ['Ветер', weather?.wind_speed != null ? `${Math.round(Number(weather.wind_speed))} км/ч` : '—'], + ].forEach(([label, value]) => { + const row = document.createElement('div'); + row.className = 'weather-card__row'; + row.innerHTML = `${label}${value}`; + rows.appendChild(row); + }); + + inner.appendChild(title); + inner.appendChild(rows); + card.appendChild(inner); + return card; + } + + function mainWeatherActions(snapshot = state.snapshot || bootstrap) { + const fromSnapshot = snapshot?.settings?.main_weather_actions; + const fromBootstrap = bootstrap?.settings?.main_weather_actions; + const actions = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : []; + return actions.filter((action) => action && String(action.entity_id || '').trim() !== ''); + } + + function mainWeatherActionEntity(snapshot, action) { + const entityId = String(action?.state_entity_id || action?.entity_id || '').trim(); + if (!entityId) return null; + return getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId) || null; + } + + function mainWeatherActionIsActive(snapshot, action) { + const entity = mainWeatherActionEntity(snapshot, action); + if (!entity) return false; + const current = String(entity.state ?? '').trim().toLowerCase(); + const compareValue = action?.active_value ?? action?.value; + if (compareValue === null || compareValue === undefined || String(compareValue).trim() === '') { + return !['off', 'false', '0', 'unknown', 'unavailable', 'idle'].includes(current); + } + return current === String(compareValue).trim().toLowerCase(); + } + + function mainWeatherActionAffectsEntity(entityId) { + const nextEntityId = String(entityId || '').trim(); + if (!nextEntityId) return false; + return mainWeatherActions().some((action) => { + const stateEntityId = String(action.state_entity_id || action.entity_id || '').trim(); + return stateEntityId === nextEntityId || String(action.entity_id || '').trim() === nextEntityId; + }); + } + + function mainPrintAffectsEntity(entityId) { + const nextEntityId = String(entityId || '').trim(); + if (!nextEntityId) return false; + const config = mainPrintConfig(); + if (!config) return false; + return [ + config.current_stage_entity_id, + config.print_progress_entity_id, + config.start_time_entity_id, + config.end_time_entity_id, + ].includes(nextEntityId); + } + + function mainWeatherActionLabel(action, active) { + const value = action?.value; + const label = active + ? (action?.label_active ?? action?.active_label ?? '') + : (action?.label_inactive ?? action?.inactive_label ?? ''); + if (String(label || '').trim() !== '') { + return String(label); + } + if (value !== null && value !== undefined && String(value).trim() !== '') { + return active ? `${value}°` : `Установить ${value}°`; + } + return active ? 'Активно' : 'Включить'; + } + + function renderMainWeatherActions(snapshot) { + const actions = mainWeatherActions(snapshot); + if (!actions.length) return null; + + const wrap = document.createElement('div'); + wrap.className = 'main-dashboard__actions'; + + actions.forEach((action) => { + const active = mainWeatherActionIsActive(snapshot, action); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `main-quick-action ${active ? 'is-active' : ''}`; + btn.dataset.entityId = String(action.entity_id || ''); + btn.dataset.stateEntityId = String(action.state_entity_id || action.entity_id || ''); + btn.dataset.command = String(action.command || 'set_temperature'); + btn.dataset.value = action.value !== undefined && action.value !== null ? String(action.value) : ''; + btn.style.setProperty('--quick-action-bg', active ? (action.active_color || '#4caf50') : (action.inactive_color || '#c8e6c9')); + btn.style.setProperty('--quick-action-color', active ? (action.active_text_color || 'white') : (action.inactive_text_color || 'black')); + btn.style.setProperty('--quick-action-icon-color', active ? (action.active_icon_color || 'white') : (action.inactive_icon_color || 'gray')); + btn.style.setProperty('--icon-node-img-filter', active + ? 'brightness(0) saturate(100%) invert(100%)' + : 'brightness(0) saturate(100%) invert(42%)'); + + const icon = document.createElement('div'); + icon.className = 'main-quick-action__icon'; + icon.appendChild(createIconElement(action.icon || 'mdi:thermometer')); + + const label = document.createElement('div'); + label.className = 'main-quick-action__label'; + label.textContent = mainWeatherActionLabel(action, active); + + btn.append(icon, label); + btn.addEventListener('click', () => { + handleMainWeatherAction(action); + }); + btn.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMainWeatherAction(action); + } + }); + wrap.appendChild(btn); + }); + + return wrap; + } + + function renderMainPrintCard(snapshot = state.snapshot || bootstrap) { + const info = mainPrintState(snapshot); + if (!info) return null; + + const card = document.createElement('article'); + card.className = 'main-print-strip'; + + const inner = document.createElement('div'); + inner.className = 'main-print-strip__inner'; + + const header = document.createElement('div'); + header.className = 'main-print-strip__header'; + + const badge = document.createElement('div'); + badge.className = 'main-print-strip__badge'; + badge.textContent = `${Math.max(0, Math.round(info.progress ?? 0))}%`; + + header.appendChild(badge); + + const progress = document.createElement('div'); + progress.className = 'main-print-strip__progress'; + const fill = document.createElement('div'); + fill.className = 'main-print-strip__progress-fill'; + fill.style.width = `${Math.max(0, Math.min(100, Number(info.progress ?? 0) || 0))}%`; + progress.appendChild(fill); + + const footer = document.createElement('div'); + footer.className = 'main-print-strip__footer'; + + const remaining = document.createElement('div'); + remaining.className = 'main-print-strip__remaining'; + remaining.textContent = info.remainingSeconds !== null + ? formatDurationText(info.remainingSeconds) + : '—'; + + footer.appendChild(remaining); + inner.append(header, progress, footer); + card.appendChild(inner); + return card; + } + + function mainBoilerConfig(snapshot = state.snapshot || bootstrap) { + const fromSnapshot = snapshot?.settings?.main_boiler; + const fromBootstrap = bootstrap?.settings?.main_boiler; + const config = (fromSnapshot && typeof fromSnapshot === 'object') + ? fromSnapshot + : ((fromBootstrap && typeof fromBootstrap === 'object') ? fromBootstrap : null); + if (!config) return null; + + const sensorEntityId = String(config.sensor_entity_id || '').trim(); + if (!sensorEntityId) return null; + + return { + title: String(config.title || 'Бойлер'), + sensor_entity_id: sensorEntityId, + history_hours: Math.max(1, Number(config.history_hours || 24) || 24), + }; + } + + function mainPrintConfig(snapshot = state.snapshot || bootstrap) { + const fromSnapshot = snapshot?.settings?.main_print; + const fromBootstrap = bootstrap?.settings?.main_print; + const config = (fromSnapshot && typeof fromSnapshot === 'object') + ? fromSnapshot + : ((fromBootstrap && typeof fromBootstrap === 'object') ? fromBootstrap : null); + if (!config) return null; + + const currentStageEntityId = String(config.current_stage_entity_id || '').trim(); + const printProgressEntityId = String(config.print_progress_entity_id || '').trim(); + const startTimeEntityId = String(config.start_time_entity_id || '').trim(); + const endTimeEntityId = String(config.end_time_entity_id || '').trim(); + if (!currentStageEntityId || !printProgressEntityId || !startTimeEntityId || !endTimeEntityId) { + return null; + } + + return { + title: String(config.title || '').trim(), + current_stage_entity_id: currentStageEntityId, + print_progress_entity_id: printProgressEntityId, + start_time_entity_id: startTimeEntityId, + end_time_entity_id: endTimeEntityId, + }; + } + + function parseDateValue(value) { + const text = String(value ?? '').trim(); + if (!text) return null; + + const numeric = Number(text); + if (Number.isFinite(numeric) && String(Math.trunc(numeric)) === text.replace(/\.0+$/, '')) { + return numeric > 1e12 ? numeric : numeric * 1000; + } + + const parsed = Date.parse(text); + return Number.isFinite(parsed) ? parsed : null; + } + + function formatDurationText(seconds) { + const total = Math.max(0, Math.round(Number(seconds) || 0)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + + if (hours > 0) { + return secs > 0 ? `${hours}ч ${minutes}м ${secs}с` : `${hours}ч ${minutes}м`; + } + if (minutes > 0) { + return secs > 0 ? `${minutes}м ${secs}с` : `${minutes}м`; + } + return `${secs}с`; + } + + function mainPrintState(snapshot = state.snapshot || bootstrap) { + const config = mainPrintConfig(snapshot); + if (!config) return null; + + const stage = getEntityFromSnapshot(snapshot, config.current_stage_entity_id) + || getEntityDefinition(snapshot, config.current_stage_entity_id); + if (!stage || String(stage.state || '').toLowerCase() !== 'printing') { + return null; + } + + const progressEntity = getEntityFromSnapshot(snapshot, config.print_progress_entity_id) + || getEntityDefinition(snapshot, config.print_progress_entity_id); + const startEntity = getEntityFromSnapshot(snapshot, config.start_time_entity_id) + || getEntityDefinition(snapshot, config.start_time_entity_id); + const endEntity = getEntityFromSnapshot(snapshot, config.end_time_entity_id) + || getEntityDefinition(snapshot, config.end_time_entity_id); + + const progressValueRaw = Number(String(progressEntity?.state ?? '').replace(',', '.')); + const progress = Number.isFinite(progressValueRaw) + ? Math.max(0, Math.min(100, progressValueRaw)) + : null; + + const startTs = parseDateValue(startEntity?.state ?? startEntity?.attributes?.value ?? startEntity?.attributes?.timestamp); + const endTs = parseDateValue(endEntity?.state ?? endEntity?.attributes?.value ?? endEntity?.attributes?.timestamp); + const nowTs = Date.now(); + let remainingSeconds = null; + + if (startTs !== null && endTs !== null && endTs > startTs) { + remainingSeconds = Math.max(0, (endTs - nowTs) / 1000); + } else if (endTs !== null) { + remainingSeconds = Math.max(0, (endTs - nowTs) / 1000); + } + + return { + title: String(config.title || '').trim(), + stage: String(stage.state || 'printing'), + progress, + remainingSeconds, + }; + } + + function updateMainPrintStrip(snapshot = state.snapshot || bootstrap) { + if (!els.mainPrintStripSlot) return; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id !== 'main') { + els.mainPrintStripSlot.innerHTML = ''; + return; + } + + els.mainPrintStripSlot.innerHTML = ''; + const printStrip = renderMainPrintCard(snapshot); + if (printStrip) { + els.mainPrintStripSlot.appendChild(printStrip); + } + } + + function formatTemperatureValue(value) { + const next = Number(String(value ?? '').replace(',', '.')); + if (!Number.isFinite(next)) { + return null; + } + + const digits = Math.abs(next % 1) > 0.05 ? 1 : 0; + return new Intl.NumberFormat('ru-RU', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(next); + } + + function normalizeHistoryPoints(payload, fallbackValue = null) { + const raw = Array.isArray(payload?.history) ? payload.history : payload; + const groups = Array.isArray(raw) && raw.length > 0 && Array.isArray(raw[0]) ? raw : [raw]; + const points = []; + + groups.forEach((group) => { + if (!Array.isArray(group)) return; + group.forEach((entry) => { + if (!entry || typeof entry !== 'object') return; + const rawValue = entry.state ?? entry.value ?? null; + const numericValue = Number(String(rawValue ?? '').replace(',', '.')); + if (!Number.isFinite(numericValue)) return; + const timestamp = Date.parse(entry.last_changed || entry.last_updated || ''); + if (!Number.isFinite(timestamp)) return; + points.push({ + timestamp, + value: numericValue, + }); + }); + }); + + points.sort((left, right) => left.timestamp - right.timestamp); + + const deduped = []; + for (const point of points) { + const last = deduped[deduped.length - 1]; + if (last && last.timestamp === point.timestamp) { + deduped[deduped.length - 1] = point; + continue; + } + deduped.push(point); + } + + if (!deduped.length) { + const fallbackNumeric = Number(String(fallbackValue ?? '').replace(',', '.')); + if (Number.isFinite(fallbackNumeric)) { + const now = Date.now(); + return [ + { timestamp: now - 60_000, value: fallbackNumeric }, + { timestamp: now, value: fallbackNumeric }, + ]; + } + } + + return deduped; + } + + function boilerHistoryState(entityId) { + const history = state.mainBoilerHistory || {}; + if (history.entityId !== entityId) { + return []; + } + return Array.isArray(history.points) ? history.points : []; + } + + function renderBoilerSparkline(points) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 240 72'); + svg.setAttribute('preserveAspectRatio', 'none'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('main-boiler-card__chart'); + + const ns = 'http://www.w3.org/2000/svg'; + const defs = document.createElementNS(ns, 'defs'); + const gradient = document.createElementNS(ns, 'linearGradient'); + gradient.setAttribute('id', 'boiler-chart-fill'); + gradient.setAttribute('x1', '0%'); + gradient.setAttribute('x2', '0%'); + gradient.setAttribute('y1', '0%'); + gradient.setAttribute('y2', '100%'); + + const stopTop = document.createElementNS(ns, 'stop'); + stopTop.setAttribute('offset', '0%'); + stopTop.setAttribute('stop-color', 'rgba(255, 186, 92, 0.38)'); + const stopBottom = document.createElementNS(ns, 'stop'); + stopBottom.setAttribute('offset', '100%'); + stopBottom.setAttribute('stop-color', 'rgba(255, 186, 92, 0.02)'); + gradient.append(stopTop, stopBottom); + defs.appendChild(gradient); + svg.appendChild(defs); + + if (!Array.isArray(points) || points.length === 0) { + const line = document.createElementNS(ns, 'path'); + line.setAttribute('d', 'M 0 48 L 240 48'); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', 'rgba(255, 186, 92, 0.28)'); + line.setAttribute('stroke-width', '2'); + svg.appendChild(line); + return svg; + } + + const values = points.map((point) => Number(point.value)).filter((value) => Number.isFinite(value)); + if (!values.length) { + return svg; + } + + let min = Math.min(...values); + let max = Math.max(...values); + if (min === max) { + min -= 0.5; + max += 0.5; + } else { + const padding = Math.max((max - min) * 0.2, 0.3); + min -= padding; + max += padding; + } + + const width = 240; + const height = 72; + const chartTop = 8; + const chartBottom = 60; + const chartHeight = chartBottom - chartTop; + const span = max - min || 1; + const linePoints = points.map((point, index) => { + const ratioX = points.length === 1 ? 0.5 : index / (points.length - 1); + const ratioY = (Number(point.value) - min) / span; + return { + x: ratioX * width, + y: chartBottom - (ratioY * chartHeight), + }; + }); + + const linePath = linePoints + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`) + .join(' '); + + const areaPath = [ + `M 0 ${chartBottom}`, + `L ${linePoints[0].x.toFixed(2)} ${linePoints[0].y.toFixed(2)}`, + ...linePoints.slice(1).map((point) => `L ${point.x.toFixed(2)} ${point.y.toFixed(2)}`), + `L ${width} ${chartBottom}`, + 'Z', + ].join(' '); + + const area = document.createElementNS(ns, 'path'); + area.setAttribute('d', areaPath); + area.setAttribute('fill', 'url(#boiler-chart-fill)'); + area.setAttribute('stroke', 'none'); + svg.appendChild(area); + + const line = document.createElementNS(ns, 'path'); + line.setAttribute('d', linePath); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', '#ffba5c'); + line.setAttribute('stroke-width', '2.4'); + line.setAttribute('stroke-linecap', 'round'); + line.setAttribute('stroke-linejoin', 'round'); + svg.appendChild(line); + + const lastPoint = linePoints[linePoints.length - 1]; + const dot = document.createElementNS(ns, 'circle'); + dot.setAttribute('cx', lastPoint.x.toFixed(2)); + dot.setAttribute('cy', lastPoint.y.toFixed(2)); + dot.setAttribute('r', '3.2'); + dot.setAttribute('fill', '#ffba5c'); + dot.setAttribute('stroke', 'rgba(24, 25, 29, 0.95)'); + dot.setAttribute('stroke-width', '2'); + svg.appendChild(dot); + + return svg; + } + + function scheduleMainBoilerHistoryLoad(snapshot = state.snapshot || bootstrap, force = false) { + const config = mainBoilerConfig(snapshot); + if (!config) { + return Promise.resolve([]); + } + + const cache = state.mainBoilerHistory || {}; + const isSameEntity = cache.entityId === config.sensor_entity_id; + const isFresh = isSameEntity && Array.isArray(cache.points) && cache.points.length > 0 && !force && (Date.now() - Number(cache.loadedAt || 0) < 5 * 60 * 1000); + if (isFresh) { + return Promise.resolve(cache.points); + } + + if (cache.promise && isSameEntity && !force) { + return cache.promise; + } + + const currentEntity = getEntityFromSnapshot(snapshot, config.sensor_entity_id) || getEntityDefinition(snapshot, config.sensor_entity_id); + const currentValue = currentEntity?.state ?? null; + + state.mainBoilerHistory = { + ...cache, + entityId: config.sensor_entity_id, + loading: true, + error: null, + }; + + const promise = apiGet('history', { + entity_id: config.sensor_entity_id, + hours: config.history_hours || 24, + }).then((response) => { + const points = normalizeHistoryPoints(response?.history ?? response, currentValue); + state.mainBoilerHistory = { + entityId: config.sensor_entity_id, + points, + loadedAt: Date.now(), + loading: false, + error: null, + promise: null, + }; + if ((state.selectedRoomId || 'main') === 'main') { + updateMainWeatherCard(); + } + return points; + }).catch((error) => { + state.mainBoilerHistory = { + entityId: config.sensor_entity_id, + points: Array.isArray(cache.points) ? cache.points : normalizeHistoryPoints([], currentValue), + loadedAt: Number(cache.loadedAt || 0), + loading: false, + error: error?.message || String(error), + promise: null, + }; + if ((state.selectedRoomId || 'main') === 'main') { + updateMainWeatherCard(); + } + return state.mainBoilerHistory.points; + }); + + state.mainBoilerHistory.promise = promise; + return promise; + } + + function renderMainBoilerCard(snapshot) { + const config = mainBoilerConfig(snapshot); + if (!config) { + return null; + } + + const entity = getEntityFromSnapshot(snapshot, config.sensor_entity_id) || getEntityDefinition(snapshot, config.sensor_entity_id); + const currentValue = formatTemperatureValue(entity?.state); + const historyPoints = boilerHistoryState(config.sensor_entity_id); + const history = state.mainBoilerHistory || {}; + + const card = document.createElement('article'); + card.className = 'grid-card main-boiler-card'; + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner main-boiler-card__inner'; + + const header = document.createElement('div'); + header.className = 'main-boiler-card__header'; + + const text = document.createElement('div'); + text.className = 'main-boiler-card__text'; + + const eyebrow = document.createElement('div'); + eyebrow.className = 'main-boiler-card__eyebrow'; + eyebrow.textContent = 'Температура бойлера'; + text.appendChild(eyebrow); + + const range = document.createElement('div'); + range.className = 'main-boiler-card__range'; + range.textContent = '24 часа'; + header.append(text, range); + + const body = document.createElement('div'); + body.className = 'main-boiler-card__body'; + + const valueColumn = document.createElement('div'); + valueColumn.className = 'main-boiler-card__value-column'; + + const valueLabel = document.createElement('div'); + valueLabel.className = 'main-boiler-card__value-label'; + valueLabel.textContent = 'Сейчас'; + + const valueRow = document.createElement('div'); + valueRow.className = 'main-boiler-card__value-row'; + + const value = document.createElement('div'); + value.className = 'main-boiler-card__value'; + value.textContent = currentValue || '—'; + + const unit = document.createElement('div'); + unit.className = 'main-boiler-card__unit'; + unit.textContent = '°C'; + + valueRow.append(value, unit); + valueColumn.append(valueLabel, valueRow); + + const chartWrap = document.createElement('div'); + chartWrap.className = 'main-boiler-card__chart-wrap'; + if (history.loading && (!historyPoints || !historyPoints.length)) { + chartWrap.classList.add('is-loading'); + } + chartWrap.appendChild(renderBoilerSparkline(historyPoints)); + + if (history.loading && (!historyPoints || !historyPoints.length)) { + const loading = document.createElement('div'); + loading.className = 'main-boiler-card__loading'; + loading.textContent = 'Загружаем график...'; + chartWrap.appendChild(loading); + } + + body.append(valueColumn, chartWrap); + inner.append(header, body); + card.appendChild(inner); + scheduleMainBoilerHistoryLoad(snapshot); + return card; + } + + function renderMainHero(snapshot) { + const hero = document.createElement('div'); + hero.className = 'main-dashboard__hero'; + + const weatherSlot = document.createElement('div'); + weatherSlot.className = 'main-dashboard__weather-slot'; + if (snapshot.weather) { + weatherSlot.appendChild(mainWeatherCard(snapshot.weather)); + } + hero.appendChild(weatherSlot); + + const stack = document.createElement('div'); + stack.className = 'main-dashboard__hero-stack'; + + const actions = renderMainWeatherActions(snapshot); + if (actions) { + stack.appendChild(actions); + } + + const boiler = renderMainBoilerCard(snapshot); + if (boiler) { + stack.appendChild(boiler); + } + + if (!stack.childNodes.length) { + const spacer = document.createElement('div'); + spacer.className = 'main-dashboard__hero-spacer'; + stack.appendChild(spacer); + } + + hero.appendChild(stack); + + return hero; + } + + function serviceValueForCommand(command, value) { + if (value === null || value === undefined || value === '') { + return null; + } + if (command === 'set_position' || command === 'set_temperature') { + return Number(value); + } + return value; + } + + function climateOptionButtons(entity, attrName, command, title) { + const values = Array.isArray(entity.attributes?.[attrName]) ? entity.attributes[attrName] : []; + if (!values.length) return null; + + const section = document.createElement('div'); + section.className = 'entity-modal__options-block'; + + const heading = document.createElement('div'); + heading.className = 'entity-modal__options-title'; + heading.textContent = climateGroupTitle(attrName) || title; + section.appendChild(heading); + + const list = document.createElement('div'); + list.className = 'entity-modal__chips'; + const current = String(entity.attributes?.[command.replace('set_', '')] || entity.attributes?.hvac_mode || entity.attributes?.fan_mode || entity.attributes?.swing_mode || entity.attributes?.preset_mode || '').toLowerCase(); + + values.forEach((value) => { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = `entity-chip ${String(value).toLowerCase() === current ? 'is-active' : ''}`; + chip.textContent = climateOptionLabel(attrName, value); + chip.title = String(value); + chip.addEventListener('click', () => { + handleClimateCommand(entity, command, value); + }); + list.appendChild(chip); + }); + + section.appendChild(list); + return section; + } + + function renderCoverPopup(entity) { + const wrap = document.createElement('div'); + wrap.className = 'entity-modal__cover'; + + const rail = document.createElement('div'); + rail.className = 'entity-modal__rail entity-modal__rail--cover'; + + const initialValue = coverPositionValue(entity); + + const valueRow = document.createElement('div'); + valueRow.className = 'entity-modal__cover-meta'; + + const label = document.createElement('div'); + label.className = 'entity-modal__cover-label'; + label.textContent = 'Открыт на'; + + const value = document.createElement('div'); + value.className = 'entity-modal__cover-value'; + value.textContent = `${initialValue}%`; + valueRow.append(label, value); + + const actions = document.createElement('div'); + actions.className = 'entity-modal__actions entity-modal__actions--vertical'; + + const openBtn = createButton('Открыть', null, 'mdi:arrow-up', 'mushroom-button mushroom-button--small mushroom-button--square'); + openBtn.addEventListener('click', () => handleEntityAction(entity, 'open')); + + const stopBtn = createButton('Стоп', null, 'mdi:stop', 'mushroom-button mushroom-button--small mushroom-button--square'); + stopBtn.addEventListener('click', () => handleEntityAction(entity, 'stop')); + + const closeBtn = createButton('Закрыть', null, 'mdi:arrow-down', 'mushroom-button mushroom-button--small mushroom-button--square'); + closeBtn.addEventListener('click', () => handleEntityAction(entity, 'close')); + + actions.append(openBtn, stopBtn, closeBtn); + + const progress = document.createElement('div'); + progress.className = 'entity-modal__cover-track'; + progress.tabIndex = 0; + progress.setAttribute('role', 'slider'); + progress.setAttribute('aria-label', 'Позиция жалюзи'); + progress.setAttribute('aria-valuemin', '0'); + progress.setAttribute('aria-valuemax', '100'); + + const fill = document.createElement('div'); + fill.className = 'entity-modal__cover-fill'; + fill.style.height = `${initialValue}%`; + fill.style.bottom = '0'; + fill.style.width = '100%'; + progress.appendChild(fill); + + const handle = document.createElement('div'); + handle.className = 'entity-modal__cover-handle'; + progress.appendChild(handle); + + let currentValue = initialValue; + const syncValue = (nextValue) => { + currentValue = Math.max(0, Math.min(100, Math.round(nextValue))); + fill.style.height = `${currentValue}%`; + handle.style.bottom = `calc(${currentValue}% - 10px)`; + value.textContent = `${currentValue}%`; + progress.setAttribute('aria-valuenow', String(currentValue)); + progress.setAttribute('aria-valuetext', `${currentValue}%`); + }; + syncValue(initialValue); + + const updateFromPointer = (clientY) => { + const rect = progress.getBoundingClientRect(); + const ratio = 1 - ((clientY - rect.top) / rect.height); + const nextValue = Math.max(0, Math.min(100, Math.round(ratio * 100))); + syncValue(nextValue); + }; + + let dragPointerId = null; + const onPointerMove = (event) => { + if (dragPointerId !== event.pointerId) return; + event.preventDefault(); + updateFromPointer(event.clientY); + }; + const onPointerUp = (event) => { + if (dragPointerId !== event.pointerId) return; + event.preventDefault(); + progress.releasePointerCapture?.(dragPointerId); + dragPointerId = null; + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerUp); + handleCoverPosition(entity, currentValue); + }; + + progress.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + dragPointerId = event.pointerId; + progress.setPointerCapture?.(dragPointerId); + updateFromPointer(event.clientY); + window.addEventListener('pointermove', onPointerMove, { passive: false }); + window.addEventListener('pointerup', onPointerUp, { passive: false }); + window.addEventListener('pointercancel', onPointerUp, { passive: false }); + }); + + progress.addEventListener('keydown', (event) => { + const step = event.shiftKey ? 10 : 5; + if (event.key === 'ArrowUp' || event.key === 'ArrowRight') { + event.preventDefault(); + syncValue(currentValue + step); + handleCoverPosition(entity, currentValue); + } else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') { + event.preventDefault(); + syncValue(currentValue - step); + handleCoverPosition(entity, currentValue); + } else if (event.key === 'Home') { + event.preventDefault(); + syncValue(0); + handleCoverPosition(entity, 0); + } else if (event.key === 'End') { + event.preventDefault(); + syncValue(100); + handleCoverPosition(entity, 100); + } + }); + + rail.append(valueRow, progress); + wrap.append(rail, actions); + return wrap; + } + + function renderClimatePopup(entity) { + const wrap = document.createElement('div'); + wrap.className = 'entity-modal__climate'; + + const tempBlock = document.createElement('div'); + tempBlock.className = 'entity-modal__climate-summary'; + tempBlock.innerHTML = ` +
Текущая температура
+
${esc(entity.attributes?.current_temperature ?? '—')}°C
+
+
${esc(climateStateLabel(entity.attributes?.hvac_action || entity.state || '—'))}
+
${esc(entity.attributes?.temperature ?? '—')}°C
+
+ `; + + const controls = document.createElement('div'); + controls.className = 'entity-modal__temperature-controls'; + + const minus = document.createElement('button'); + minus.type = 'button'; + minus.className = 'round-button entity-modal__round-button'; + minus.innerHTML = ''; + 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.addEventListener('click', () => handleClimateTemperature(entity, 1)); + + controls.append(minus, plus); + + const modes = document.createElement('div'); + modes.className = 'entity-modal__modes'; + [ + ['hvac_modes', 'Режим', 'set_hvac_mode'], + ['fan_modes', 'Вентилятор', 'set_fan_mode'], + ['swing_modes', 'Качание', 'set_swing_mode'], + ['preset_modes', 'Предустановки', 'set_preset_mode'], + ].forEach(([attrName, title, command]) => { + const block = climateOptionButtons(entity, attrName, command, title); + if (block) { + modes.appendChild(block); + } + }); + + wrap.append(tempBlock, controls, modes); + return wrap; + } + + function climateGroupTitle(attrName) { + const key = String(attrName || '').toLowerCase(); + switch (key) { + case 'hvac_modes': + return 'Режим'; + case 'fan_modes': + return 'Вентилятор'; + case 'swing_modes': + return 'Качание'; + case 'preset_modes': + return 'Предустановки'; + default: + return 'Режим'; + } + } + + function climateOptionLabel(attrName, value) { + const key = String(value ?? '').trim().toLowerCase(); + const normalized = key.replace(/\s+/g, '_'); + + const maps = { + hvac_modes: { + off: 'Выключено', + auto: 'Авто', + cool: 'Охлаждение', + heat: 'Обогрев', + dry: 'Осушение', + fan_only: 'Только вентилятор', + heat_cool: 'Авто', + eco: 'Эко', + away: 'Вне дома', + sleep: 'Сон', + }, + fan_modes: { + auto: 'Авто', + low: 'Низкая', + low_mid: 'Ниже средней', + low_medium: 'Ниже средней', + medium: 'Средняя', + mid: 'Средняя', + mid_high: 'Выше средней', + high: 'Высокая', + turbo: 'Турбо', + diffuse: 'Рассеянный', + }, + swing_modes: { + off: 'Выкл', + top: 'Верх', + middletop1: 'Верх 1', + middletop2: 'Верх 2', + middlebottom2: 'Низ 2', + middlebottom1: 'Низ 1', + bottom: 'Низ', + swing: 'Авто', + auto: 'Авто', + }, + preset_modes: { + none: 'Нет', + sleep: 'Сон', + boost: 'Турбо', + eco: 'Эко', + away: 'Вне дома', + home: 'Дома', + comfort: 'Комфорт', + quiet: 'Тихо', + }, + }; + + const map = maps[String(attrName || '').toLowerCase()] || {}; + if (map[normalized]) { + return map[normalized]; + } + + return String(value ?? '').replace(/_/g, ' '); + } + + function climateStateLabel(value) { + const key = String(value ?? '').trim().toLowerCase(); + const labels = { + off: 'Выключено', + idle: 'Ожидание', + auto: 'Авто', + cool: 'Охлаждение', + heating: 'Нагрев', + heat: 'Обогрев', + cooling: 'Охлаждение', + dry: 'Осушение', + fan: 'Вентиляция', + heat_cool: 'Авто', + on: 'Включено', + }; + return labels[key] || String(value ?? '—').replace(/_/g, ' '); + } + + function coverPositionValue(entity) { + const currentPosition = Number(entity?.attributes?.current_position); + if (Number.isFinite(currentPosition)) { + return Math.max(0, Math.min(100, Math.round(currentPosition))); + } + + const state = String(entity?.state || '').toLowerCase(); + return state === 'open' || state === 'opening' ? 100 : 0; + } + + async function handleEntityService(entity, command, value = null, patch = null) { + try { + if (patch) { + patchSnapshotEntity(entity.entity_id, patch); + } + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + refreshCurrentRoomLayout(entity.entity_id); + await apiPost('service', { + entity_id: entity.entity_id, + command, + ...(value !== null && value !== undefined ? { value } : {}), + }); + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + } catch (error) { + console.error(error); + setStatus('Ошибка команды', 'error'); + } + } + + function handleCoverPosition(entity, value) { + const next = normalizePositionValue(value); + if (next === null) return; + const currentPosition = Number(entity.attributes?.current_position); + let stateValue = undefined; + if (next === 0) { + stateValue = 'closed'; + } else if (next === 100) { + stateValue = 'open'; + } else if (Number.isFinite(currentPosition)) { + stateValue = next >= currentPosition ? 'opening' : 'closing'; + } + const patch = { + attributes: { + ...(entity.attributes || {}), + current_position: next, + }, + }; + if (stateValue) { + patch.state = stateValue; + } + handleEntityService(entity, 'set_position', next, patch); + } + + async function handleMainWeatherAction(action) { + const snapshot = state.snapshot || bootstrap; + const entityId = String(action?.entity_id || '').trim(); + if (!entityId) return; + + const command = String(action?.command || 'set_temperature').trim() || 'set_temperature'; + const value = action?.value; + const stateEntityId = String(action?.state_entity_id || entityId).trim() || entityId; + const stateEntity = getEntityFromSnapshot(snapshot, stateEntityId) || getEntityDefinition(snapshot, stateEntityId); + const targetEntity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); + const nextValue = value !== null && value !== undefined && value !== '' ? String(value) : ''; + + if (stateEntity) { + const nextAttributes = { ...(stateEntity.attributes || {}) }; + if (command === 'set_temperature' && value !== null && value !== undefined && value !== '') { + nextAttributes.temperature = Number(value); + } + const patch = { + attributes: nextAttributes, + }; + if (stateEntityId !== entityId || command !== 'set_temperature') { + patch.state = nextValue || stateEntity.state; + } + patchSnapshotEntity(stateEntityId, patch); + } + + if (targetEntity && targetEntity.entity_id !== stateEntityId) { + const nextAttributes = { ...(targetEntity.attributes || {}) }; + if (command === 'set_temperature' && value !== null && value !== undefined && value !== '') { + nextAttributes.temperature = Number(value); + } + patchSnapshotEntity(targetEntity.entity_id, { + attributes: nextAttributes, + }); + } + + renderDashboardOnly(); + + try { + await apiPost('service', { + entity_id: entityId, + command, + ...(value !== null && value !== undefined ? { value } : {}), + }); + } catch (error) { + console.error(error); + setStatus('Ошибка команды', 'error'); + } + } + + function handleClimateCommand(entity, command, value) { + const patch = {}; + if (command === 'set_hvac_mode') { + patch.attributes = { + ...(entity.attributes || {}), + hvac_mode: value, + }; + } else if (command === 'set_fan_mode') { + patch.attributes = { + ...(entity.attributes || {}), + fan_mode: value, + }; + } else if (command === 'set_swing_mode') { + patch.attributes = { + ...(entity.attributes || {}), + swing_mode: value, + }; + } else if (command === 'set_preset_mode') { + patch.attributes = { + ...(entity.attributes || {}), + preset_mode: value, + }; + } + handleEntityService(entity, command, value, Object.keys(patch).length ? patch : null); + } + + function renderToggleCard(entity, { isMain = false } = {}) { + const card = document.createElement('article'); + const active = ['on', 'open', 'cool', 'heat', 'heating', 'cooling'].includes(String(entity.state).toLowerCase()); + const isDoorContact = Boolean(entity.is_door_contact); + card.className = `grid-card ${!isDoorContact ? 'grid-card--tap ' : ''}${isMain ? 'grid-card--auto' : 'grid-card--entity'} ${isDoorContact ? 'grid-card--door' : ''} ${active ? 'is-active' : ''}`; + card.dataset.entityId = entity.entity_id; + if (!isDoorContact) { + card.dataset.clickToggle = 'true'; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + } + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner'; + + const icon = document.createElement('div'); + icon.className = `grid-card__icon${isMain && active && !isDoorContact ? ' grid-card__icon--active' : ''}${isDoorContact ? ' grid-card__icon--door' : ''}`; + icon.appendChild(createIconElement(entity.icon)); + + const text = buildEntityTitle(entity.name); + + const left = document.createElement('div'); + left.className = 'grid-card__header'; + left.append(icon, text); + if (state.editMode) { + left.appendChild(renderEntityTypeLabel(entity)); + } + inner.appendChild(left); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + card.appendChild(inner); + if (!isDoorContact) { + card.addEventListener('click', (event) => { + if (event.target.closest('button')) return; + handleEntityAction(entity, 'toggle'); + }); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleEntityAction(entity, 'toggle'); + } + }); + } + return card; + } + + function renderCoverCard(entity, options = {}) { + const card = document.createElement('article'); + const currentPosition = coverPositionValue(entity); + const coverState = String(entity.state).toLowerCase(); + const isOpen = ['open', 'opening'].includes(coverState); + const hasVisiblePosition = currentPosition > 0 || isOpen; + card.className = `grid-card grid-card--cover ${!options.isMain && isOpen ? 'is-active' : ''}`; + card.dataset.entityId = entity.entity_id; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner cover-card'; + + const icon = document.createElement('div'); + icon.className = `grid-card__icon${options.isMain && isOpen ? ' grid-card__icon--active' : ''}`; + icon.appendChild(createIconElement(entity.icon)); + + const text = buildEntityTitle(entity.name); + + const left = document.createElement('div'); + left.className = 'grid-card__header'; + left.append(icon, text); + if (state.editMode) { + left.appendChild(renderEntityTypeLabel(entity)); + } + + const rail = document.createElement('div'); + rail.className = 'cover-card__rail'; + + if (hasVisiblePosition) { + const progress = document.createElement('div'); + progress.className = 'cover-progress'; + const bar = document.createElement('div'); + bar.className = 'cover-progress__value'; + const pos = currentPosition > 0 ? currentPosition : 100; + bar.style.width = `${Math.max(0, Math.min(100, pos))}%`; + progress.appendChild(bar); + + if (!options.isMain) { + rail.append(progress); + inner.append(left, rail); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + } else { + inner.append(left, progress); + } + } else { + inner.append(left); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + } + + card.appendChild(inner); + card.addEventListener('click', async (event) => { + if (event.target.closest('button')) return; + if (options.isMain) { + const confirmed = await openConfirm({ + title: 'Хотите закрыть?', + message: `${entity.name} будет закрыт.`, + }); + if (confirmed) { + handleEntityAction(entity, 'close'); + } + return; + } + openEntityPopup(entity.entity_id); + }); + card.addEventListener('keydown', async (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + if (options.isMain) { + const confirmed = await openConfirm({ + title: 'Хотите закрыть?', + message: `${entity.name} будет закрыт.`, + }); + if (confirmed) { + handleEntityAction(entity, 'close'); + } + return; + } + openEntityPopup(entity.entity_id); + }); + return card; + } + + function renderClimateCard(entity, options = {}) { + const card = document.createElement('article'); + const active = !['off', 'unavailable', 'unknown'].includes(String(entity.state).toLowerCase()); + card.className = `grid-card grid-card--climate grid-card--tap ${!options.isMain && active ? 'is-active' : ''}`; + card.dataset.entityId = entity.entity_id; + card.dataset.clickToggle = 'true'; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner climate-card'; + + const icon = document.createElement('div'); + icon.className = `grid-card__icon${options.isMain && active ? ' grid-card__icon--active' : ''}`; + icon.appendChild(createIconElement(entity.icon)); + + const text = buildEntityTitle(entity.name); + + const left = document.createElement('div'); + left.className = 'grid-card__header'; + left.append(icon, text); + if (state.editMode) { + left.appendChild(renderEntityTypeLabel(entity)); + } + + const tempMeta = document.createElement('div'); + tempMeta.className = 'climate-card__meta'; + tempMeta.innerHTML = ` +
${esc(entity.attributes?.temperature ?? '—')}°
+
Сейчас ${esc(entity.attributes?.current_temperature ?? '—')}°
+ `; + + const topRow = document.createElement('div'); + topRow.className = 'climate-card__top'; + topRow.append(left, tempMeta); + + inner.append(topRow); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + card.appendChild(inner); + card.addEventListener('click', (event) => { + if (event.target.closest('button')) return; + if (options.isMain) { + handleEntityAction(entity, active ? 'turn_off' : 'turn_on'); + return; + } + openEntityPopup(entity.entity_id); + }); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (options.isMain) { + handleEntityAction(entity, active ? 'turn_off' : 'turn_on'); + return; + } + openEntityPopup(entity.entity_id); + } + }); + return card; + } + + function renderEditActions(entity) { + const wrap = document.createElement('div'); + wrap.className = 'grid-card__footer grid-card__footer--edit'; + + const hidden = entity.visible === false || entity.override?.visible === false; + + const hideBtn = document.createElement('button'); + hideBtn.type = 'button'; + hideBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + hideBtn.textContent = hidden ? 'Показать' : 'Скрыть'; + hideBtn.addEventListener('click', (event) => { + event.stopPropagation(); + saveOverridePatch(entity, { visible: hidden ? true : false }); + }); + + if (hidden) { + wrap.append(hideBtn); + return wrap; + } + + const actions = document.createElement('div'); + actions.className = 'grid-card__footer-actions'; + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'mushroom-button mushroom-button--small'; + upBtn.innerHTML = ' Вверх'; + 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.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1)); + + actions.append(upBtn, downBtn); + wrap.append(hideBtn, actions); + return wrap; + } + + function renderRoomEditActions(room) { + const wrap = document.createElement('div'); + wrap.className = 'room-item__mini-actions'; + + const hidden = room.visible === false; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'mini-action mini-action--wide'; + btn.innerHTML = hidden ? '' : ''; + btn.title = hidden ? 'Показать' : 'Скрыть'; + btn.addEventListener('click', (event) => { + event.stopPropagation(); + saveSpacePatch(room, { visible: hidden ? true : false }); + }); + + wrap.appendChild(btn); + return wrap; + } + + function wireRoomItemDragEvents(item, room, groupEl, hidden) { + if (!state.editMode || room.id === 'main' || room.virtual || room.id === 'batteries') return; + + item.draggable = true; + item.addEventListener('pointerdown', (event) => { + if (event.target.closest('button')) return; + startRoomDrag(room, item, groupEl, hidden, event); + }); + item.addEventListener('pointermove', (event) => { + if (!state.roomDrag || state.roomDrag.roomId !== room.id) return; + moveRoomDrag(event.clientX, event.clientY); + }); + item.addEventListener('pointerup', async (event) => { + if (!state.roomDrag || state.roomDrag.roomId !== room.id) return; + try { + await finishRoomDrag(); + } catch (error) { + console.warn(error); + } + event.preventDefault(); + }); + item.addEventListener('pointercancel', async () => { + if (!state.roomDrag || state.roomDrag.roomId !== room.id) return; + try { + await finishRoomDrag(); + } catch (error) { + console.warn(error); + } + }); + item.addEventListener('dragstart', (event) => { + event.preventDefault(); + }); + } + + function renderEntityCard(entity, options = {}) { + const type = entity.card_type || entity.domain; + const card = type === 'cover' + ? renderCoverCard(entity, options) + : type === 'climate' + ? renderClimateCard(entity, options) + : renderToggleCard(entity, options); + + if (entity.visible === false) { + card.classList.add('is-hidden'); + } + if (state.editMode) { + card.classList.add('is-editing'); + } + + return card; + } + + function renderLayoutCard(item, room) { + const card = document.createElement('article'); + card.className = 'grid-card grid-card--ghost'; + card.dataset.layoutItemId = item.id; + card.tabIndex = state.editMode ? 0 : -1; + + if (state.editMode) { + card.classList.add('is-editing'); + } + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner grid-card__ghost-inner'; + + if (state.editMode) { + const header = document.createElement('div'); + header.className = 'grid-card__header'; + + const icon = document.createElement('div'); + icon.className = 'grid-card__icon grid-card__icon--ghost'; + icon.appendChild(createIconElement('mdi:checkbox-blank-outline')); + + const title = document.createElement('div'); + title.className = 'grid-card__title'; + title.innerHTML = 'Пустая карточка'; + + const subtitle = document.createElement('div'); + subtitle.className = 'grid-card__subtitle'; + subtitle.textContent = 'Свободное место для раскладки плиток'; + + header.append(icon, title, subtitle); + inner.appendChild(header); + inner.appendChild(renderLayoutItemEditActions(room, item)); + } + + card.appendChild(inner); + return card; + } + + function renderLayoutItemEditActions(room, item) { + const wrap = document.createElement('div'); + wrap.className = 'grid-card__footer grid-card__footer--edit'; + + const actions = document.createElement('div'); + actions.className = 'grid-card__footer-actions'; + + const isSettingsOpen = Boolean(state.layoutItemSettingsOpen?.[item.id]); + + const settingsBtn = document.createElement('button'); + settingsBtn.type = 'button'; + settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + settingsBtn.innerHTML = ' Настройки'; + settingsBtn.addEventListener('click', (event) => { + event.stopPropagation(); + state.layoutItemSettingsOpen = { + ...(state.layoutItemSettingsOpen || {}), + [item.id]: !isSettingsOpen, + }; + renderDashboardOnly(); + }); + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'mushroom-button mushroom-button--small'; + upBtn.innerHTML = ' Вверх'; + upBtn.addEventListener('click', (event) => { + event.stopPropagation(); + reorderRoomGridEntry(room.id, 'layout', item.id, -1); + }); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'mushroom-button mushroom-button--small'; + downBtn.innerHTML = ' Вниз'; + downBtn.addEventListener('click', (event) => { + event.stopPropagation(); + reorderRoomGridEntry(room.id, 'layout', item.id, 1); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + deleteBtn.innerHTML = ' Удалить'; + deleteBtn.addEventListener('click', (event) => { + event.stopPropagation(); + deleteRoomLayoutItem(room.id, item.id); + }); + + actions.append(upBtn, downBtn, settingsBtn); + wrap.append(actions, deleteBtn); + + if (isSettingsOpen) { + const settings = document.createElement('div'); + settings.className = 'grid-card__layout-settings'; + + const tempBtn = document.createElement('button'); + tempBtn.type = 'button'; + tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + tempBtn.innerHTML = ' Выбрать датчик температуры'; + tempBtn.addEventListener('click', (event) => { + event.stopPropagation(); + openTemperatureSensorPopup(room.id); + }); + + settings.appendChild(tempBtn); + wrap.appendChild(settings); + } + + return wrap; + } + + async function reorderRoomGridEntry(roomId, kind, entryId, direction) { + const nextRoomId = roomId || currentRoom()?.id || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !entryId || !direction || isMobileViewport()) { + return null; + } + + try { + const snapshot = state.snapshot || bootstrap; + const entries = roomGridEntries(snapshot, nextRoomId); + const currentIndex = entries.findIndex((entry) => entry.kind === kind && entry.id === entryId); + if (currentIndex < 0) { + return null; + } + + const targetIndex = currentIndex + direction; + if (targetIndex < 0 || targetIndex >= entries.length) { + return null; + } + + const reordered = entries.slice(); + const [moved] = reordered.splice(currentIndex, 1); + reordered.splice(targetIndex, 0, moved); + + await apiPost('reorder-room-grid', { + room_id: nextRoomId, + entries: reordered.map((entry) => ({ + kind: entry.kind, + id: entry.id, + })), + }); + + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + + return null; + } + + function renderBatteryCard(item) { + const status = String(item.battery_status || 'unknown'); + const card = document.createElement('article'); + card.className = `grid-card battery-card battery-card--${status}`; + card.dataset.entityId = item.entity_id; + card.dataset.batteryStatus = status; + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner battery-card__inner'; + + const main = document.createElement('div'); + main.className = 'battery-card__main'; + + const icon = document.createElement('div'); + icon.className = 'battery-card__icon'; + icon.appendChild(createIconElement(item.battery_icon || 'mdi:battery-outline')); + + const text = document.createElement('div'); + text.className = 'battery-card__text'; + + const title = document.createElement('div'); + title.className = 'battery-card__title'; + title.textContent = item.name || item.entity_id; + + const source = document.createElement('div'); + source.className = 'battery-card__source'; + source.textContent = item.source_text || [item.source_room_name, item.source_device_name].filter(Boolean).join(' | ') || 'Без комнаты'; + + text.append(title, source); + main.append(icon, text); + + const side = document.createElement('div'); + side.className = 'battery-card__side'; + + const percent = document.createElement('div'); + percent.className = 'battery-card__percent'; + percent.textContent = item.battery_percent_text || item.battery_status_label || '—'; + + const statusLabel = document.createElement('div'); + statusLabel.className = 'battery-card__status'; + statusLabel.textContent = item.battery_status_label || 'Неизвестно'; + + side.append(percent, statusLabel); + + const footer = document.createElement('div'); + footer.className = 'battery-card__footer'; + footer.textContent = item.forecast_text + || item.forecast_reason + || (status === 'ok' ? 'Прогноз недоступен' : item.battery_status_label || ''); + const hasFooter = Boolean(footer.textContent); + + inner.append(main, side); + if (hasFooter) { + inner.appendChild(footer); + } + card.appendChild(inner); + return card; + } + + function renderRoomButtons(snapshot, rooms, batteryRoom = null) { + if (!els.roomList) { + return; + } + els.roomList.innerHTML = ''; + const sortedRooms = [...(rooms || [])].sort((left, right) => { + if (left.id === 'main') return -1; + if (right.id === 'main') return 1; + + const leftOrder = Number(left.order ?? 9999); + const rightOrder = Number(right.order ?? 9999); + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + const leftFloor = Number(left.floor_level ?? 0); + const rightFloor = Number(right.floor_level ?? 0); + if (leftFloor !== rightFloor) { + return leftFloor - rightFloor; + } + + return String(left.name || '').localeCompare(String(right.name || ''), 'ru'); + }); + + const visibleRooms = sortedRooms.filter((room) => room.id === 'main' || room.visible !== false); + const hiddenRooms = sortedRooms.filter((room) => room.id !== 'main' && room.visible === false); + + const visibleGroup = document.createElement('div'); + visibleGroup.className = 'room-list__group room-list__group--visible'; + let hiddenGroup = null; + + const renderItem = (room, hidden = false) => { + const item = document.createElement('div'); + item.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${hidden ? 'is-hidden-room' : ''} ${state.editMode && !room.virtual ? 'is-editing' : ''}`; + item.dataset.roomId = room.id; + item.dataset.roomGroup = hidden ? 'hidden' : 'visible'; + item.tabIndex = 0; + item.setAttribute('role', 'button'); + item.addEventListener('click', (event) => { + if (event.target.closest('button')) return; + if (state.roomDrag?.moved && state.roomDrag?.roomId === room.id && Date.now() < (state.roomDrag?.suppressClickUntil || 0)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + setSelectedRoom(room.id); + }); + item.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedRoom(room.id); + } + }); + + const content = document.createElement('div'); + content.className = 'room-item__content'; + + const icon = document.createElement('div'); + icon.className = 'room-item__icon'; + icon.appendChild(createIconElement(room.icon || 'mdi:home-variant')); + + const body = document.createElement('div'); + body.className = 'room-item__body'; + const activeCount = room.id === 'batteries' + ? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0 + : Number(room.active_entity_count ?? room.entity_count ?? 0) || 0; + const metaText = room.id === 'main' + ? 'Главный экран' + : room.id === 'batteries' + ? (room.battery_summary_text || `${room.entity_count || 0} батареек`) + : activeCount > 0 + ? `${activeCount} ${pluralizeActiveEntities(activeCount)}` + : 'Нет активных'; + body.innerHTML = ` +
${esc(room.name)}
+
${metaText}
+ `; + content.append(icon, body); + + const tempBadge = roomTemperatureBadge(snapshot, room); + if (tempBadge) { + item.classList.add('has-temp'); + const temp = document.createElement('div'); + temp.className = 'room-item__temp'; + temp.textContent = tempBadge; + item.appendChild(temp); + } + + item.append(content); + if (state.editMode && room.id !== 'main' && !room.virtual && room.id !== 'batteries') { + item.appendChild(renderRoomEditActions(room)); + } + wireRoomItemDragEvents(item, room, hidden ? hiddenGroup : visibleGroup, hidden); + return item; + }; + + visibleRooms.forEach((room) => { + visibleGroup.appendChild(renderItem(room, false)); + }); + + if (batteryRoom && !isMobileViewport()) { + visibleGroup.appendChild(renderItem(batteryRoom, false)); + } + + els.roomList.appendChild(visibleGroup); + + if (state.editMode && hiddenRooms.length) { + const divider = document.createElement('div'); + divider.className = 'room-list__divider'; + + const label = document.createElement('div'); + label.className = 'room-list__divider-label'; + label.textContent = 'Скрытые'; + + divider.append(label); + els.roomList.appendChild(divider); + + hiddenGroup = document.createElement('div'); + hiddenGroup.className = 'room-list__group room-list__group--hidden'; + hiddenRooms.forEach((room) => { + hiddenGroup.appendChild(renderItem(room, true)); + }); + els.roomList.appendChild(hiddenGroup); + } + } + + function renderSelectedRoom(snapshot) { + const room = snapshot.selected_space || snapshot.selected_room || {}; + const setText = (el, value) => { + if (el) { + el.textContent = value; + } + }; + if (els.contentTop) { + els.contentTop.classList.toggle('is-main', room.id === 'main'); + } + if (els.contentHeader) { + els.contentHeader.classList.toggle('hidden', room.id === 'main' && !isMobileRoomView()); + } + updateMainPrintStrip(snapshot); + if (room.id === 'batteries') { + setText(els.selectedRoomEyebrow, 'Псевдо-комната'); + setText(els.selectedRoomTitle, room.name || 'Батарейки'); + const total = Number(room.entity_count ?? 0) || 0; + const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0; + const unavailable = Number(room.unavailable_count ?? 0) || 0; + const unknown = Number(room.unknown_count ?? 0) || 0; + const summaryParts = []; + if (critical > 0) { + summaryParts.push(`${critical} ${pluralizeRu(critical, 'проблемная', 'проблемных', 'проблемных')}`); + } + if (unavailable > 0) { + summaryParts.push(`${unavailable} ${pluralizeRu(unavailable, 'недоступная', 'недоступных', 'недоступных')}`); + } + if (unknown > 0) { + summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`); + } + setText(els.selectedRoomMeta, summaryParts.length + ? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}` + : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`); + renderSelectedRoomActions(snapshot); + return; + } + if (room.id !== 'main') { + setText(els.selectedRoomEyebrow, 'Пространство'); + setText(els.selectedRoomTitle, room.name || 'Панель'); + const entities = roomEntities(snapshot, room.id || 'main'); + const activeCount = Number(room.active_entity_count ?? entities.length) || 0; + setText(els.selectedRoomMeta, `${activeCount} ${pluralizeActiveEntities(activeCount)}`); + renderSelectedRoomActions(snapshot); + return; + } + + const entities = roomEntities(snapshot, room.id || 'main'); + setText(els.selectedRoomEyebrow, ''); + setText(els.selectedRoomTitle, room.name || 'Панель'); + setText(els.selectedRoomMeta, `${entities.length} ${pluralizeIncludedEntities(entities.length)}`); + renderSelectedRoomActions(snapshot); + } + + function renderSelectedRoomActions(snapshot) { + if (!els.selectedRoomActions) return; + + const room = snapshot.selected_space || snapshot.selected_room || {}; + els.selectedRoomActions.replaceChildren(); + + if (isMobileViewport() || !state.editMode || room.id === 'main' || room.id === 'batteries') { + return; + } + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; + addButton.innerHTML = 'Пустая карточка'; + addButton.addEventListener('click', () => { + createRoomLayoutItem(room.id); + }); + + const temperatureButton = document.createElement('button'); + temperatureButton.type = 'button'; + temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; + temperatureButton.innerHTML = 'Выбрать датчик температуры'; + temperatureButton.addEventListener('click', () => { + openTemperatureSensorPopup(room.id); + }); + + els.selectedRoomActions.append(addButton, temperatureButton); + } + + function renderDashboard(snapshot) { + const room = snapshot.selected_space || snapshot.selected_room || {}; + const grid = els.dashboardSurface; + if (!grid) { + return; + } + grid.innerHTML = ''; + + if (room.id === 'main') { + const layout = document.createElement('div'); + layout.className = 'main-dashboard'; + + const mainEntities = roomEntities(snapshot, 'main'); + const cards = document.createElement('div'); + cards.className = 'grid-surface main-dashboard__cards'; + + if (mainEntities.length) { + mainEntities.forEach((entity) => { + cards.appendChild(renderEntityCard(entity, { isMain: true })); + }); + } + + const hero = renderMainHero(snapshot); + + layout.append(hero); + if (mainEntities.length) { + layout.append(cards); + } + + grid.appendChild(layout); + return; + } + + if (room.id === 'batteries') { + const section = document.createElement('section'); + section.className = 'room-entities-section battery-room'; + + const header = document.createElement('div'); + header.className = 'room-entities-section__header battery-room__header'; + + const title = document.createElement('div'); + title.className = 'room-entities-section__title'; + title.textContent = room.battery_summary_text || `${Number(room.entity_count ?? 0) || 0} батареек`; + header.appendChild(title); + section.appendChild(header); + + const list = document.createElement('div'); + list.className = 'battery-room__list'; + const items = Array.isArray(room.entities) ? room.entities : []; + + items.forEach((item) => { + list.appendChild(renderBatteryCard(item)); + }); + + if (!items.length) { + const empty = document.createElement('article'); + empty.className = 'loading-card battery-room__empty'; + empty.textContent = 'Батарейки с ярлыком «Батарейка» не найдены.'; + list.appendChild(empty); + } + + section.appendChild(list); + grid.appendChild(section); + return; + } + + const visibleEntries = roomGridEntries(snapshot, room.id); + const hiddenEntitiesList = state.editMode && room.id !== 'main' + ? roomEntitiesIncludingHidden(snapshot, room.id).filter((entity) => entity.visible === false) + : []; + + const visibleSection = document.createElement('section'); + visibleSection.className = 'room-entities-section'; + + if (state.editMode) { + const visibleHeader = document.createElement('div'); + visibleHeader.className = 'room-entities-section__header'; + + const visibleTitle = document.createElement('div'); + visibleTitle.className = 'room-entities-section__title'; + visibleTitle.textContent = 'Объекты'; + visibleHeader.appendChild(visibleTitle); + visibleSection.appendChild(visibleHeader); + } + + const visibleGrid = document.createElement('div'); + visibleGrid.className = 'grid-surface room-entities-section__grid'; + visibleEntries.forEach((entry) => { + if (entry.kind === 'layout') { + visibleGrid.appendChild(renderLayoutCard(entry.payload, room)); + } else { + visibleGrid.appendChild(renderEntityCard(entry.payload)); + } + }); + + if (!visibleEntries.length) { + const empty = document.createElement('article'); + empty.className = 'loading-card grid-card--full'; + empty.textContent = 'В этой комнате нет доступных объектов.'; + visibleGrid.appendChild(empty); + } + + visibleSection.appendChild(visibleGrid); + grid.appendChild(visibleSection); + + if (state.editMode && hiddenEntitiesList.length) { + const hiddenSection = document.createElement('section'); + hiddenSection.className = 'room-entities-section room-entities-section--hidden'; + + const hiddenHeader = document.createElement('div'); + hiddenHeader.className = 'room-entities-section__header'; + + const hiddenTitle = document.createElement('div'); + hiddenTitle.className = 'room-entities-section__title'; + hiddenTitle.textContent = 'Скрытые объекты'; + + const hiddenMeta = document.createElement('div'); + hiddenMeta.className = 'room-entities-section__meta'; + hiddenMeta.textContent = `${hiddenEntitiesList.length} ${pluralizeEntities(hiddenEntitiesList.length)}`; + + hiddenHeader.append(hiddenTitle, hiddenMeta); + hiddenSection.appendChild(hiddenHeader); + + const hiddenGrid = document.createElement('div'); + hiddenGrid.className = 'grid-surface room-entities-section__grid room-entities-section__grid--hidden'; + hiddenEntitiesList.forEach((entity) => { + hiddenGrid.appendChild(renderEntityCard(entity)); + }); + hiddenSection.appendChild(hiddenGrid); + grid.appendChild(hiddenSection); + } + } + + function renderPopup(snapshot) { + if (isMobileViewport()) { + hidePopup({ preserveSnapshot: true }); + return; + } + + const popup = mergePopupWithCamera(snapshot.popup || {}); + const signature = JSON.stringify([ + popup.active, + popup.sensor_entity_id || '', + popup.expires_at || '', + popup.stream_url || '', + popup.poster_url || '', + popup.stream_mode || '', + ]); + + if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) { + if (els.cameraBackdrop) { + els.cameraBackdrop.classList.add('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + } + return; + } + + state.lastPopupSignature = signature; + + if (!popup.active) { + hidePopup(); + return; + } + + if (els.cameraPoster) { + els.cameraPoster.src = popup.poster_url || ''; + els.cameraPoster.alt = popup.sensor_entity_id || 'camera'; + } + if (els.cameraBackdrop) { + els.cameraBackdrop.classList.add('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + } + if (els.cameraPlaceholder) { + els.cameraPlaceholder.classList.add('is-visible'); + } + + const expiresAt = Number(popup.expires_at || 0); + if (expiresAt > 0) { + let closeRequested = false; + const updateCountdown = () => { + const remaining = Math.max(0, expiresAt - Math.floor(Date.now() / 1000)); + const mins = Math.floor(remaining / 60); + const secs = remaining % 60; + if (remaining > 0) { + if (els.cameraCountdown) { + els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`; + } + return; + } + + if (els.cameraCountdown) { + els.cameraCountdown.textContent = 'Закрытие...'; + } + if (closeRequested) { + return; + } + closeRequested = true; + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = null; + apiPost('popup', { command: 'close' }) + .then((response) => { + if (response?.popup) { + applyPopupSnapshot(response.popup); + } else { + hidePopup(); + } + }) + .catch(() => { + hidePopup(); + }); + }; + updateCountdown(); + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = setInterval(updateCountdown, 1000); + } else { + if (els.cameraCountdown) { + els.cameraCountdown.textContent = ''; + } + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = null; + } + + const streamUrl = popup.stream_url || ''; + const resolvedMode = resolvePopupStreamMode(streamUrl, popup.stream_mode || ''); + renderStream(streamUrl, resolvedMode, popup.poster_url || ''); + } + + function hidePopup(options = {}) { + const { suppressAutoOpen = false, preserveSnapshot = false } = options; + if (suppressAutoOpen) { + state.popupAutoOpenBlockedUntil = Date.now() + 60000; + } + state.lastPopupSignature = ''; + state.snapshot = state.snapshot || bootstrap; + if (!preserveSnapshot && state.snapshot.popup) { + state.snapshot.popup = { + ...state.snapshot.popup, + active: false, + }; + } + if (els.cameraBackdrop) { + els.cameraBackdrop.classList.remove('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'true'); + } + if (els.cameraStage) { + els.cameraStage.innerHTML = ''; + if (els.cameraPoster) { + els.cameraStage.appendChild(els.cameraPoster); + } + if (els.cameraPlaceholder) { + els.cameraStage.appendChild(els.cameraPlaceholder); + els.cameraPlaceholder.classList.add('is-visible'); + } + } + if (els.cameraPoster) { + els.cameraPoster.removeAttribute('src'); + } + if (els.cameraCountdown) { + els.cameraCountdown.textContent = ''; + } + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = null; + destroyStream(); + } + + async function showDebugPopup() { + try { + const response = await apiPost('popup', { command: 'open' }); + const snapshot = state.snapshot || bootstrap; + state.snapshot = snapshot; + applyPopupSnapshot(response.popup || {}); + } catch (error) { + console.error(error); + setStatus('Ошибка popup', 'error'); + } + } + + function destroyStream() { + if (state.hlsInstance) { + try { + state.hlsInstance.destroy(); + } catch (error) { + console.warn(error); + } + state.hlsInstance = null; + } + } + + function inferStreamMode(url) { + if (!url) return 'poster'; + if (url.includes('.m3u8')) return 'hls'; + if (url.includes('.mp4')) return 'video'; + if (url.includes('stream.html')) return 'iframe'; + if (url.startsWith('http')) return 'iframe'; + return 'iframe'; + } + + function mutedStreamUrl(url) { + if (!url) return ''; + try { + const parsed = new URL(url, window.location.href); + if (parsed.pathname.includes('webrtc.html')) { + parsed.searchParams.set('media', 'video'); + } + parsed.searchParams.set('mute', '1'); + parsed.searchParams.set('volume', '0'); + parsed.searchParams.set('autoplay', '1'); + return parsed.toString(); + } catch (error) { + return url; + } + } + + async function loadHlsScript() { + if (window.Hls) return; + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js'; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + async function renderStream(url, mode, posterUrl) { + destroyStream(); + els.cameraStage.innerHTML = ''; + els.cameraStage.appendChild(els.cameraPoster); + els.cameraStage.appendChild(els.cameraPlaceholder); + + els.cameraPlaceholder.classList.add('is-visible'); + els.cameraPoster.src = posterUrl || ''; + + if (!url) { + return; + } + + if (mode === 'iframe') { + const iframe = document.createElement('iframe'); + iframe.classList.add('is-loading'); + iframe.src = mutedStreamUrl(url); + iframe.allow = 'autoplay; fullscreen; picture-in-picture'; + iframe.referrerPolicy = 'no-referrer'; + iframe.addEventListener('load', () => { + iframe.classList.add('is-ready'); + els.cameraPlaceholder.classList.remove('is-visible'); + }); + iframe.addEventListener('error', () => els.cameraPlaceholder.classList.add('is-visible')); + els.cameraStage.appendChild(iframe); + return; + } + + const video = document.createElement('video'); + video.classList.add('is-loading'); + video.autoplay = true; + video.muted = true; + video.defaultMuted = true; + video.volume = 0; + video.playsInline = true; + video.setAttribute('muted', ''); + video.setAttribute('playsinline', ''); + video.setAttribute('webkit-playsinline', ''); + video.controls = false; + video.poster = posterUrl || ''; + video.preload = 'metadata'; + video.addEventListener('loadeddata', () => { + video.classList.add('is-ready'); + els.cameraPlaceholder.classList.remove('is-visible'); + }); + video.addEventListener('canplay', () => { + video.classList.add('is-ready'); + els.cameraPlaceholder.classList.remove('is-visible'); + }); + video.addEventListener('error', () => els.cameraPlaceholder.classList.add('is-visible')); + els.cameraStage.appendChild(video); + + if (mode === 'hls') { + if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = url; + return; + } + + try { + await loadHlsScript(); + if (window.Hls) { + const hls = new window.Hls({ + lowLatencyMode: true, + }); + hls.loadSource(url); + hls.attachMedia(video); + state.hlsInstance = hls; + hls.on(window.Hls.Events.MANIFEST_PARSED, () => els.cameraPlaceholder.classList.remove('is-visible')); + hls.on(window.Hls.Events.ERROR, () => els.cameraPlaceholder.classList.add('is-visible')); + return; + } + } catch (error) { + console.warn('HLS load failed', error); + } + } + + video.src = url; + } + + function render() { + const snapshot = state.snapshot || bootstrap; + if (!snapshot || !(snapshot.spaces || snapshot.rooms)) { + return; + } + + syncLayoutState(); + renderDashboard(snapshot); + renderSelectedRoom(snapshot); + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + 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'; + } + } + + function refreshCurrentRoomLayout(entityId) { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + updateMainEntityCard(entityId); + renderSelectedRoom(snapshot); + return; + } + + if (room.id === 'batteries') { + renderDashboardOnly(); + return; + } + + renderDashboardOnly(); + } + + function refreshCurrentRoomOrder() { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + const container = mainCardsContainer(); + if (container) sortMainCardsBySnapshot(container); + return; + } + + const container = els.dashboardSurface; + if (!container) { + return; + } + const order = new Map(roomEntities(snapshot, room.id).map((entity) => [entity.entity_id, Number(entity.order ?? 9999)])); + const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]')); + cards.sort((left, right) => { + const leftOrder = order.get(left.dataset.entityId) ?? Number.MAX_SAFE_INTEGER; + const rightOrder = order.get(right.dataset.entityId) ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left.dataset.entityId || '').localeCompare(String(right.dataset.entityId || ''), 'ru'); + }); + cards.forEach((card) => container.appendChild(card)); + } + + 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'; + } + } + + function renderSelectionOnly() { + const snapshot = state.snapshot || bootstrap; + if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; + syncLayoutState(); + renderSelectedRoom(snapshot); + } + + async function handleEntityAction(entity, command) { + try { + const snapshot = state.snapshot || bootstrap; + const nextState = optimisticStateForCommand(entity, command); + const isCurrentRoomEntity = state.selectedRoomId !== 'main' + && roomEntities(snapshot, state.selectedRoomId).some((item) => item.entity_id === entity.entity_id); + + if (nextState !== null) { + if (state.selectedRoomId === 'main' || isMainDisplayEntity(entity)) { + patchSnapshotEntity(entity.entity_id, { + state: nextState, + attributes: entity.attributes || {}, + last_changed: entity.last_changed || entity.last_updated || new Date().toISOString(), + }); + syncMainEntities(entity.entity_id, entity, { + state: nextState, + attributes: entity.attributes || {}, + last_changed: entity.last_changed || entity.last_updated || new Date().toISOString(), + }); + refreshCurrentRoomLayout(entity.entity_id); + } else if (isCurrentRoomEntity) { + patchSnapshotEntity(entity.entity_id, { + state: nextState, + attributes: entity.attributes || {}, + }); + refreshCurrentRoomLayout(entity.entity_id); + } + } + + await apiPost('service', { + entity_id: entity.entity_id, + command, + }); + } catch (error) { + console.error(error); + setStatus('Ошибка команды', 'error'); + } + } + + async function handleClimateTemperature(entity, delta) { + const current = Number(entity.attributes?.temperature); + if (!Number.isFinite(current)) return; + const target = Math.round((current + delta) * 2) / 2; + try { + patchSnapshotEntity(entity.entity_id, { + attributes: { + ...(entity.attributes || {}), + temperature: target, + }, + }); + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + refreshCurrentRoomLayout(entity.entity_id); + await apiPost('service', { + entity_id: entity.entity_id, + command: 'set_temperature', + value: target, + }); + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + } catch (error) { + console.error(error); + setStatus('Ошибка температуры', 'error'); + } + } + + async function saveOverridePatch(entity, patch) { + const room = currentRoom(); + if (!room || room.id === 'main') return; + + try { + await apiPost('save-entity-override', { + room_id: room.id, + entity_id: entity.entity_id, + ...patch, + }); + patchSnapshotEntity(entity.entity_id, { + visible: patch.visible !== undefined ? Boolean(patch.visible) : undefined, + order: patch.order !== undefined ? patch.order : undefined, + }); + try { + await loadSnapshot(state.selectedRoomId || room.id || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + async function saveSpacePatch(room, patch) { + try { + const nextPatch = {}; + if (patch.visible !== undefined) nextPatch.visible = Boolean(patch.visible); + if (patch.order !== undefined) nextPatch.order = patch.order; + if (patch.name !== undefined) nextPatch.name = patch.name; + if (patch.icon !== undefined) nextPatch.icon = patch.icon; + if (patch.temperature_sensor_entity_id !== undefined) { + nextPatch.temperature_sensor_entity_id = String(patch.temperature_sensor_entity_id || ''); + } + + await apiPost('save-space-override', { + room_id: room.id, + ...patch, + }); + patchSnapshotSpace(room.id, nextPatch); + try { + await loadSnapshot(state.selectedRoomId || room.id || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + async function createRoomLayoutItem(roomId) { + const room = currentRoom(); + const nextRoomId = roomId || room?.id || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || isMobileViewport()) { + return null; + } + + try { + const snapshot = state.snapshot || bootstrap; + const items = roomGridEntries(snapshot, nextRoomId); + const maxOrder = items.reduce((max, item) => Math.max(max, Number(item.order ?? 9999) || 9999), 0); + await apiPost('create-room-layout-item', { + room_id: nextRoomId, + order: maxOrder + 10, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + + return null; + } + + async function saveRoomLayoutItem(roomId, layoutItemId, patch) { + const nextRoomId = roomId || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !layoutItemId) return; + + try { + await apiPost('save-room-layout-item', { + room_id: nextRoomId, + layout_item_id: layoutItemId, + ...patch, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + async function deleteRoomLayoutItem(roomId, layoutItemId) { + const nextRoomId = roomId || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !layoutItemId) return; + + try { + await apiPost('delete-room-layout-item', { + room_id: nextRoomId, + layout_item_id: layoutItemId, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + function wireEvents() { + const bind = (el, type, handler, options) => { + if (!el) return; + el.addEventListener(type, handler, options); + }; + + els.selectedRoomBack?.addEventListener('click', () => { + if (!isMobileViewport()) return; + closeEntityPopup(); + setMobileView('spaces'); + syncLayoutState(); + renderSidebarOnly(); + renderSelectionOnly(); + }); + + 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(); + 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) { + closeEntityPopup(); + } + }); + + bind(els.entityModalPanel, 'click', (event) => { + event.stopPropagation(); + }); + + els.entityClose?.addEventListener('click', () => { + closeEntityPopup(); + }); + + els.temperatureSensorBackdrop?.addEventListener('click', (event) => { + if (event.target === els.temperatureSensorBackdrop) { + closeTemperatureSensorPopup(); + } + }); + + bind(els.temperatureSensorModalPanel, 'click', (event) => { + event.stopPropagation(); + }); + + bind(els.temperatureSensorClose, 'click', () => { + closeTemperatureSensorPopup(); + }); + + bind(els.popupDebugButton, 'click', () => { + showDebugPopup(); + }); + + bind(els.editModeToggle, 'click', async () => { + state.editMode = !state.editMode; + try { + await apiPost('save-settings', { + edit_mode: state.editMode, + }); + } catch (error) { + console.warn(error); + } + state.snapshot = state.snapshot || bootstrap; + if (state.snapshot?.settings) { + state.snapshot.settings.edit_mode = state.editMode; + } + if (!state.editMode && currentRoom()?.visible === false) { + patchSnapshotSelection('main'); + } + try { + await loadSnapshot(state.selectedRoomId || 'main'); + } catch (error) { + console.warn(error); + } + render(); + }); + } + + function initRefs() { + els.appShell = q('.app-shell'); + els.clockTime = $('clock-time'); + els.clockDate = $('clock-date'); + els.roomsCount = $('rooms-count'); + els.roomList = $('room-list'); + els.editModeToggle = $('edit-mode-toggle'); + els.selectedRoomBack = $('selected-room-back'); + els.contentTop = q('.content-top'); + els.mainPrintStripSlot = $('main-print-strip-slot'); + els.contentHeader = q('.content-header'); + els.selectedRoomActions = $('selected-room-actions'); + els.selectedRoomEyebrow = $('selected-room-eyebrow'); + els.selectedRoomTitle = $('selected-room-title'); + els.selectedRoomMeta = $('selected-room-meta'); + els.dashboardSurface = $('dashboard-surface'); + els.cameraBackdrop = $('camera-modal'); + els.cameraModalPanel = $('camera-modal-panel'); + els.cameraClose = $('camera-modal-close'); + els.popupDebugButton = $('popup-debug-button'); + els.cameraStage = $('camera-stage'); + els.cameraPoster = $('camera-poster'); + els.cameraPlaceholder = $('camera-placeholder'); + els.cameraCountdown = $('camera-countdown'); + els.confirmBackdrop = $('confirm-modal'); + els.confirmTitle = $('confirm-modal-title'); + els.confirmMessage = $('confirm-modal-message'); + els.confirmYes = $('confirm-modal-yes'); + els.confirmNo = $('confirm-modal-no'); + els.entityBackdrop = $('entity-modal'); + els.entityModalPanel = $('entity-modal-panel'); + els.entityClose = $('entity-modal-close'); + els.entityEyebrow = $('entity-modal-eyebrow'); + els.entityTitle = $('entity-modal-title'); + els.entityBody = $('entity-modal-body'); + els.temperatureSensorBackdrop = $('temperature-sensor-modal'); + els.temperatureSensorModalPanel = $('temperature-sensor-modal-panel'); + els.temperatureSensorClose = $('temperature-sensor-modal-close'); + els.temperatureSensorTitle = $('temperature-sensor-modal-title'); + els.temperatureSensorBody = $('temperature-sensor-modal-body'); + + if (els.cameraPoster && !els.cameraPoster.dataset.boundErrorHandler) { + els.cameraPoster.dataset.boundErrorHandler = '1'; + els.cameraPoster.addEventListener('error', () => { + els.cameraPoster.removeAttribute('src'); + els.cameraPlaceholder.classList.add('is-visible'); + }); + els.cameraPoster.addEventListener('load', () => { + els.cameraPlaceholder.classList.remove('is-visible'); + }); + } + } + + function updateClock() { + if (els.clockTime) { + els.clockTime.textContent = formatTime(new Date()); + } + if (els.clockDate) { + els.clockDate.textContent = formatDate(new Date()); + } + updateMainPrintStrip(); + } + + function stopRealtime() { + if (state.haSocket) { + try { + state.haSocket.close(); + } catch (error) { + console.warn(error); + } + state.haSocket = null; + } + clearTimeout(state.haReconnectTimer); + state.haReconnectTimer = null; + state.haSocketState = 'disconnected'; + stopSnapshotPolling(); + } + + function scheduleReconnect() { + clearTimeout(state.haReconnectTimer); + state.haSocketState = 'reconnecting'; + const delay = Math.min(state.haReconnectDelay, 30000); + state.haReconnectDelay = Math.min(state.haReconnectDelay * 2, 30000); + state.haReconnectTimer = window.setTimeout(() => { + connectRealtime(); + }, delay); + } + + function stopSnapshotPolling() { + if (state.snapshotPollTimer) { + clearInterval(state.snapshotPollTimer); + state.snapshotPollTimer = null; + } + } + + function startSnapshotPolling() { + if (isHaRuntime()) { + return; + } + const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000)); + if (state.snapshotPollTimer) { + clearInterval(state.snapshotPollTimer); + } + + state.snapshotPollTimer = window.setInterval(async () => { + try { + await loadSnapshot(state.selectedRoomId || 'main'); + render(); + } catch (error) { + console.warn(error); + } + }, interval); + } + + function handleHaMessage(message) { + if (isHaRuntime()) { + return; + } + if (!message || typeof message !== 'object') { + return; + } + + if (message.type === 'auth_required') { + const connection = haConnection(); + if (!connection.token) return; + state.haSocket?.send(JSON.stringify({ + type: 'auth', + access_token: connection.token, + })); + return; + } + + if (message.type === 'auth_ok') { + state.haSocketState = 'auth_ok'; + state.haReconnectDelay = 1000; + state.haSocket?.send(JSON.stringify({ + id: state.haSubscribeId++, + type: 'subscribe_events', + event_type: 'state_changed', + })); + return; + } + + if (message.type === 'result' && message.success) { + if (state.haSocketState !== 'connected') { + state.haSocketState = 'connected'; + } + return; + } + + if (message.type === 'event' && message.event?.event_type === 'state_changed') { + const event = message.event; + const entityId = event?.data?.entity_id; + const newState = event?.data?.new_state; + if (entityId && newState) { + const snapshot = state.snapshot || bootstrap; + const currentRoomId = state.selectedRoomId || 'main'; + const existingEntity = getEntityFromSnapshot(snapshot, entityId); + const entityDefinition = getEntityDefinition(snapshot, entityId); + const entityRecord = existingEntity || entityDefinition; + if (entityRecord?.is_hidden) { + patchSnapshotEntity(entityId, { + state: newState.state, + attributes: newState.attributes || {}, + last_changed: newState.last_changed || newState.last_updated || entityRecord.last_changed, + last_updated: newState.last_updated || entityRecord.last_updated, + }); + return; + } + const affectsWeather = snapshot.weather?.entity_id === entityId || entityId === 'sensor.weather_temperature'; + const affectsRoom = currentRoomId !== 'main' && existingEntity !== null; + const affectsTemperatureBadge = isTemperatureSensorEntity(entityRecord); + + if (entityRecord && !statePayloadChanged(entityRecord, newState)) { + const triggerEntities = popupTriggerEntities(); + if (triggerEntities.has(entityId)) { + syncTriggerPopup(entityId, newState.state); + } + return; + } + + patchSnapshotEntity(entityId, { + state: newState.state, + attributes: newState.attributes || {}, + last_changed: newState.last_changed || newState.last_updated || entityRecord?.last_changed, + last_updated: newState.last_updated || entityRecord?.last_updated, + }); + + const mainChanged = syncMainEntities(entityId, entityRecord, { + state: newState.state, + attributes: newState.attributes || {}, + last_changed: newState.last_changed || newState.last_updated || entityRecord?.last_changed, + last_updated: newState.last_updated || entityRecord?.last_updated, + }); + + if (snapshot.weather?.entity_id === entityId) { + snapshot.weather.state = newState.state; + snapshot.weather.temperature = newState.attributes?.temperature ?? snapshot.weather.temperature; + snapshot.weather.wind_speed = newState.attributes?.wind_speed ?? snapshot.weather.wind_speed; + snapshot.weather.condition = newState.attributes?.condition ?? newState.state; + } + + if (snapshot.weather && entityId === 'sensor.weather_temperature') { + snapshot.weather.sensor_temperature = newState.state; + } + + const triggerEntities = popupTriggerEntities(); + if (triggerEntities.has(entityId)) { + syncTriggerPopup(entityId, newState.state); + } + + const affectsBoiler = mainBoilerConfig(snapshot)?.sensor_entity_id === entityId; + const affectsPrint = mainPrintAffectsEntity(entityId); + + if (currentRoomId === 'main' && (mainChanged || affectsWeather || mainWeatherActionAffectsEntity(entityId) || affectsBoiler || affectsPrint)) { + if (affectsWeather || mainWeatherActionAffectsEntity(entityId) || affectsBoiler || affectsPrint) { + updateMainWeatherCard(); + } + if (mainChanged) { + updateMainEntityCard(entityId); + } + renderSelectedRoom(snapshot); + renderSidebarOnly(); + } else if (affectsRoom) { + updateRoomEntityCard(entityId); + renderSelectedRoom(snapshot); + renderSidebarOnly(); + } + + if (affectsTemperatureBadge) { + renderSidebarOnly(); + } + + if (state.entityPopup?.active) { + renderEntityPopup(snapshot); + } + } + } + } + + function connectRealtime() { + if (isHaRuntime()) { + setStatus('HA native mode', 'online'); + stopSnapshotPolling(); + return; + } + const connection = haConnection(); + const baseUrl = connection.base_url || ''; + const token = connection.token || ''; + const wsUrl = haWsUrl(baseUrl); + if (!wsUrl || !token) { + state.haSocketState = 'unavailable'; + setStatus('Online', 'online'); + startSnapshotPolling(); + return; + } + + if (window.location.protocol === 'https:' && wsUrl.startsWith('ws://')) { + state.haSocketState = 'unavailable'; + setStatus('Polling mode', 'online'); + startSnapshotPolling(); + return; + } + + stopSnapshotPolling(); + stopRealtime(); + state.haSocketState = 'connecting'; + setStatus('Connecting WS...', 'loading'); + + try { + const socket = new WebSocket(wsUrl); + state.haSocket = socket; + socket.onopen = () => { + state.haSocketState = 'open'; + }; + socket.onmessage = (event) => { + try { + handleHaMessage(JSON.parse(event.data)); + } catch (error) { + console.warn(error); + } + }; + socket.onerror = () => { + setStatus('WS error', 'error'); + startSnapshotPolling(); + }; + socket.onclose = () => { + state.haSocket = null; + if (state.haSocketState !== 'disconnected') { + scheduleReconnect(); + } + }; + } catch (error) { + console.error(error); + scheduleReconnect(); + } + } + + async function start() { + initRefs(); + state.embedMode = detectEmbeddedContext(); + syncLayoutState(); + syncViewportState(); + bindPressFeedback(); + updateClock(); + clearInterval(state.clockTimer); + state.clockTimer = setInterval(updateClock, 1000); + wireEvents(); + + if (!state.haSnapshotListenerInstalled) { + state.haSnapshotListenerInstalled = true; + window.addEventListener('wall-panel-snapshot-updated', (event) => { + const snapshot = event?.detail?.snapshot || event?.detail || null; + if (!snapshot || typeof snapshot !== 'object') { + return; + } + state.snapshot = snapshot; + render(); + }); + } + + const viewportQuery = mobileViewportQuery(); + const handleViewportChange = () => { + syncViewportState(); + render(); + }; + if (typeof viewportQuery.addEventListener === 'function') { + viewportQuery.addEventListener('change', handleViewportChange); + } else if (typeof viewportQuery.addListener === 'function') { + viewportQuery.addListener(handleViewportChange); + } + + state.snapshot = await resolveInitialSnapshot(); + render(); + connectRealtime(); + if (!state.snapshotPollTimer) { + startSnapshotPolling(); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } +})(); diff --git a/custom_components/wall_panel/config_flow.py b/custom_components/wall_panel/config_flow.py index 9d99066..71d292c 100755 --- a/custom_components/wall_panel/config_flow.py +++ b/custom_components/wall_panel/config_flow.py @@ -14,29 +14,25 @@ from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, Tex from .const import ( CONF_CONFIG, CONF_FRONTEND_URL_PATH, - CONF_PANEL_URL, CONF_REQUIRE_ADMIN, CONF_SIDEBAR_ICON, CONF_SIDEBAR_TITLE, CONF_SYNC_TOKEN, DEFAULT_FRONTEND_URL_PATH, - DEFAULT_PANEL_URL, DEFAULT_SIDEBAR_ICON, DEFAULT_SIDEBAR_TITLE, DOMAIN, ) -from .helpers import config_to_json, normalize_config, parse_config_json +from .helpers import config_to_json, current_entry_config, normalize_config, parse_config_json def _schema(defaults: dict[str, Any]) -> vol.Schema: return vol.Schema({ vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, "Striker Panel")): str, - vol.Optional(CONF_PANEL_URL, default=defaults.get(CONF_PANEL_URL, DEFAULT_PANEL_URL)): 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_SYNC_TOKEN, default=defaults.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24))): str, vol.Optional( CONF_CONFIG, default=defaults.get(CONF_CONFIG, config_to_json(normalize_config({}))), @@ -62,12 +58,11 @@ class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: data = { CONF_NAME: user_input.get(CONF_NAME, "Striker Panel"), - CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""), 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: str(user_input.get(CONF_SYNC_TOKEN, "") or ""), + CONF_SYNC_TOKEN: secrets.token_urlsafe(24), CONF_CONFIG: config, } await self.async_set_unique_id(DOMAIN) @@ -76,12 +71,10 @@ class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): defaults = { CONF_NAME: "Striker Panel", - CONF_PANEL_URL: DEFAULT_PANEL_URL, 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_SYNC_TOKEN: secrets.token_urlsafe(24), CONF_CONFIG: config_to_json(normalize_config({})), } return self.async_show_form(step_id="user", data_schema=_schema(defaults), errors=errors) @@ -107,25 +100,22 @@ class WallPanelOptionsFlow(config_entries.OptionsFlow): data = dict(self.config_entry.options) data.update({ CONF_NAME: user_input.get(CONF_NAME, data.get(CONF_NAME, "Striker Panel")), - CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""), 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: str(user_input.get(CONF_SYNC_TOKEN, "") or ""), + CONF_SYNC_TOKEN: data.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)), CONF_CONFIG: config, }) return self.async_create_entry(title="", data=data) defaults = { CONF_NAME: self.config_entry.options.get(CONF_NAME, "Striker Panel"), - CONF_PANEL_URL: self.config_entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL), 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_SYNC_TOKEN: self.config_entry.options.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)), - CONF_CONFIG: config_to_json(normalize_config(self.config_entry.options.get(CONF_CONFIG, {}))), + CONF_CONFIG: config_to_json(current_entry_config(self.config_entry)), } return self.async_show_form(step_id="init", data_schema=_schema(defaults), errors=errors) diff --git a/custom_components/wall_panel/frontend.py b/custom_components/wall_panel/frontend.py index 1468948..d0d7f82 100755 --- a/custom_components/wall_panel/frontend.py +++ b/custom_components/wall_panel/frontend.py @@ -21,12 +21,23 @@ from .const import ( DEFAULT_SIDEBAR_TITLE, DOMAIN, ) +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 async def async_setup_frontend(hass: HomeAssistant, entry) -> str: """Register the custom panel and static frontend assets.""" frontend_dir = Path(__file__).parent / "frontend" + # Bundle the HA-native assets inside the component so the integration + # does not depend on files living in the root of /config. + assets_dir = Path(__file__).parent / "assets" state = hass.data.setdefault(DOMAIN, {}) if not state.get("_static_paths_registered"): await hass.http.async_register_static_paths([ @@ -35,16 +46,21 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str: str(frontend_dir), cache_headers=False, ), + StaticPathConfig( + f"/api/{DOMAIN}/assets", + str(assets_dir), + cache_headers=False, + ), ]) state["_static_paths_registered"] = True - panel_url_path = str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH).strip() + panel_url_path = _panel_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)) - panel_url = str(entry.options.get(CONF_PANEL_URL, "") or "").strip() sync_token = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip() asset_version = str(int(time.time())) + runtime_config = current_entry_config(entry) async_register_built_in_panel( hass, @@ -56,13 +72,12 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str: "_panel_custom": { "name": "striker-panel-panel", "module_url": f"/api/{DOMAIN}/frontend/panel.js?v={asset_version}", - "embed_iframe": False, - "trust_external": False, "config": { - "panel_url": panel_url, - "panel_url_path": panel_url_path, + "runtime_config": runtime_config, + "ui_mode": "ha-native", "entry_id": entry.entry_id, "sync_token": sync_token, + "config_url": f"/api/{DOMAIN}/config/{entry.entry_id}", }, } }, diff --git a/custom_components/wall_panel/frontend/panel.js b/custom_components/wall_panel/frontend/panel.js index eb63e9f..ae8fc81 100755 --- a/custom_components/wall_panel/frontend/panel.js +++ b/custom_components/wall_panel/frontend/panel.js @@ -1,57 +1,1093 @@ -class WallPanelPanel extends HTMLElement { +const WALL_PANEL_HA_EVENT = 'wall-panel-snapshot-updated'; + +function isDebugEnabled() { + 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 PANEL_DEBUG = isDebugEnabled(); + +function panelLog(...args) { + if (PANEL_DEBUG) { + console.log('[Striker Panel]', ...args); + } +} + +function panelWarn(...args) { + if (PANEL_DEBUG) { + console.warn('[Striker Panel]', ...args); + } +} + +function normalizeConfig(value) { + const base = { + app: { + title: 'Striker Panel', + poll_interval_ms: 5000, + main_room_name: 'Главная', + main_room_icon: 'mdi:home', + edit_mode: false, + battery_history_hours: 4320, + }, + home_assistant: { + base_url: '', + token: '', + verify_ssl: true, + sync_url: '', + sync_token: '', + sync_timeout: 10, + sync_verify_ssl: true, + sync_cache_seconds: 30, + weather_entity_id: '', + auto_label: 'auto', + auto_entity_ids: [], + }, + camera: { + rtsp_url: '', + stream_url: '', + stream_mode: 'hls', + poster_url: '', + popup_timeout_minutes: 3, + trigger_entities: [], + }, + rooms: [], + }; + + const merge = (target, source) => { + Object.entries(source || {}).forEach(([key, value]) => { + if (value && typeof value === 'object' && !Array.isArray(value) && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { + merge(target[key], value); + } else { + target[key] = typeof structuredClone === 'function' + ? structuredClone(value) + : JSON.parse(JSON.stringify(value)); + } + }); + }; + + if (value && typeof value === 'object') { + merge(base, value); + } + if (!Array.isArray(base.rooms)) { + base.rooms = []; + } + return base; +} + +function toList(source) { + if (!source) return []; + if (Array.isArray(source)) return source.filter(Boolean); + if (typeof source === 'object') return Object.values(source).filter(Boolean); + return []; +} + +function lower(value) { + return String(value ?? '').trim().toLowerCase(); +} + +function normalizeLookupKey(value) { + return lower(value) + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9а-яё]+/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function clone(value) { + return typeof structuredClone === 'function' + ? structuredClone(value) + : JSON.parse(JSON.stringify(value)); +} + +function panelSnapshotSummary(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 panelRequestSummary(method, action, payload = {}) { + const summary = { + method: String(method || '').toUpperCase(), + action: String(action || ''), + }; + ['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => { + if (payload[key] !== undefined && payload[key] !== null && payload[key] !== '') { + summary[key] = payload[key]; + } + }); + if (payload.payload && typeof payload.payload === 'object') { + summary.payload_keys = Object.keys(payload.payload); + } + return summary; +} + +function previewEntityIds(items, limit = 8) { + return toList(items) + .slice(0, Math.max(0, Number(limit) || 0)) + .map((item) => String(item?.entity_id || item?.id || item?.name || '')) + .filter(Boolean); +} + +function appEntityDomain(entityId) { + return String(entityId || '').split('.', 2)[0] || 'unknown'; +} + +function defaultCardType(entityId) { + const domain = appEntityDomain(entityId); + switch (domain) { + case 'cover': + return 'cover'; + case 'climate': + return 'climate'; + case 'light': + case 'switch': + return 'toggle'; + default: + return 'toggle'; + } +} + +function isActiveEntity(entity) { + const state = lower(entity?.state); + const domain = appEntityDomain(entity?.entity_id); + const deviceClass = lower(entity?.attributes?.device_class); + if (['unavailable', 'unknown', 'none'].includes(state)) { + return false; + } + if (domain === 'binary_sensor') { + return deviceClass === 'door' + ? ['on', 'open'].includes(state) + : !['off', 'false', '0', 'idle'].includes(state); + } + if (domain === 'cover') { + return ['open', 'opening', 'closing'].includes(state); + } + if (domain === 'climate') { + return !['off', 'unavailable', 'unknown'].includes(state); + } + return !['off', 'false', '0', 'idle'].includes(state); +} + +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; +} + +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 entityName(entity, registryEntry = {}, override = {}) { + if (override?.title) { + return String(override.title); + } + const domain = appEntityDomain(entity?.entity_id); + if (domain === 'fan' && registryEntry?.name) { + return String(registryEntry.name); + } + if (entity?.attributes?.friendly_name) { + return String(entity.attributes.friendly_name); + } + if (registryEntry?.name) { + return String(registryEntry.name); + } + return String(entity?.entity_id || 'entity'); +} + +function entityIcon(entity, override = {}) { + if (override?.icon) { + return String(override.icon); + } + if (entity?.attributes?.icon) { + return String(entity.attributes.icon); + } + switch (appEntityDomain(entity?.entity_id)) { + case 'light': + return 'mdi:lightbulb'; + case 'switch': + return 'mdi:toggle-switch'; + case 'cover': + return 'mdi:curtains'; + case 'climate': + return 'mdi:air-conditioner'; + case 'weather': + return 'mdi:weather-partly-cloudy'; + case 'binary_sensor': + return 'mdi:motion-sensor'; + default: + return 'mdi:devices'; + } +} + +function entitySubtitle(entity, cardType) { + const state = String(entity?.state ?? ''); + const attr = entity?.attributes || {}; + if (cardType === 'cover') { + if (Number.isFinite(Number(attr.current_position))) { + return `Позиция ${Math.round(Number(attr.current_position))}%`; + } + if (state === 'open') return 'Открыто'; + if (state === 'closed') return 'Закрыто'; + return state ? state : 'Нет данных'; + } + if (cardType === 'climate') { + const pieces = []; + if (Number.isFinite(Number(attr.current_temperature))) { + pieces.push(`${String(attr.current_temperature).replace(/\.0+$/, '')}°`); + } + if (Number.isFinite(Number(attr.temperature))) { + pieces.push(`Цель ${String(attr.temperature).replace(/\.0+$/, '')}°`); + } + if (attr.hvac_action) { + pieces.push(String(attr.hvac_action)); + } + return pieces.length ? pieces.join(' · ') : 'Климат'; + } + if (state === '') return 'Нет данных'; + if (Number.isFinite(Number(attr.current_temperature))) { + return `${state} · ${String(attr.current_temperature).replace(/\.0+$/, '')}°`; + } + switch (state) { + case 'on': + return 'Включено'; + case 'off': + return 'Выключено'; + case 'open': + return 'Открыто'; + case 'closed': + return 'Закрыто'; + default: + return state; + } +} + +function batteryStatusRank(status) { + switch (status) { + case 'empty': + return 0; + case 'critical': + return 1; + case 'low': + return 2; + case 'unavailable': + return 3; + case 'unknown': + return 4; + default: + return 5; + } +} + +function batteryStatusFromPercent(entity, percent) { + const state = lower(entity?.state); + if (['unavailable'].includes(state)) return 'unavailable'; + if (['unknown', 'none', ''].includes(state) && percent == null) return 'unknown'; + if (percent != null && percent <= 5) return 'empty'; + if (percent != null && percent <= 15) return 'critical'; + if (percent != null && percent <= 30) return 'low'; + return 'ok'; +} + +function batteryPercentFromEntity(entity) { + const candidates = [ + entity?.state, + entity?.attributes?.battery_level, + entity?.attributes?.battery_percentage, + entity?.attributes?.battery, + entity?.attributes?.percentage, + ]; + for (const candidate of candidates) { + if (candidate == null) continue; + const text = String(candidate).trim().replace(',', '.').replace(/%$/, ''); + if (!text || !Number.isFinite(Number(text))) continue; + const value = Number(text); + if (value < 0 || value > 1000) continue; + return value > 100 ? value / 10 : value; + } + return null; +} + +function batterySummaryText(counts, total) { + const empty = Number(counts.empty || 0); + const critical = Number(counts.critical || 0); + const low = Number(counts.low || 0); + const unavailable = Number(counts.unavailable || 0); + const unknown = Number(counts.unknown || 0); + const problems = empty + critical + low + unavailable + unknown; + if (!total) return 'Нет батареек'; + if (!problems) return `Все ${total} батареек в норме`; + const parts = []; + if (empty) parts.push(`${empty} разряжены`); + if (critical) parts.push(`${critical} скоро разрядится`); + if (low) parts.push(`${low} низкий заряд`); + if (unavailable) parts.push(`${unavailable} недоступны`); + if (unknown) parts.push(`${unknown} неизвестно`); + return `${total} батареек, ${parts.join(', ')}`; +} + +function registryMap(source, keyCandidates = ['entity_id']) { + const map = {}; + for (const item of toList(source)) { + if (!item || typeof item !== 'object') continue; + let id = ''; + for (const key of keyCandidates) { + if (item[key]) { + id = String(item[key]); + break; + } + } + if (id) { + map[id] = item; + } + } + return map; +} + +function roomLayoutItems(room) { + const items = Array.isArray(room?.layout_items) ? room.layout_items : []; + return items + .filter((item) => item && typeof item === 'object' && lower(item.type || 'ghost') === 'ghost') + .map((item) => ({ + id: String(item.id || item.layout_item_id || ''), + type: 'ghost', + order: Number.isFinite(Number(item.order)) ? Number(item.order) : 9999, + })) + .filter((item) => item.id) + .sort((left, right) => (left.order - right.order) || left.id.localeCompare(right.id, 'ru')); +} + +function roomMatchKeys(room) { + return Array.from(new Set([ + room?.area_id, + room?.id, + room?.name, + ].map(normalizeLookupKey).filter(Boolean))); +} + +function registryAreaMaps(areas) { + const byId = registryMap(areas, ['area_id', 'id']); + const byName = {}; + for (const area of toList(areas)) { + if (!area || typeof area !== 'object') continue; + const areaId = String(area.area_id || area.id || '').trim(); + const areaIdKey = normalizeLookupKey(areaId); + const areaNameKey = normalizeLookupKey(area.name || area.alias || areaId); + if (areaIdKey) { + byName[areaIdKey] = area; + } + if (areaNameKey) { + byName[areaNameKey] = area; + } + } + return { byId, byName }; +} + +function roomDefinitions(config, haData) { + const { byId: areasById, byName: areasByName } = registryAreaMaps(haData.areas); + const floorsById = registryMap(haData.floors, ['floor_id', 'id']); + const rooms = []; + const configured = Array.isArray(config.rooms) ? config.rooms : []; + + for (const room of configured) { + if (!room || typeof room !== 'object' || !room.id) continue; + const roomKeys = roomMatchKeys(room); + const areaId = String(room.area_id || room.id || ''); + 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 floor = floorsById[floorId] || {}; + rooms.push({ + id: String(room.id), + name: String(room.name || area.name || room.id), + icon: String(room.icon || area.icon || 'mdi:home-variant'), + area_id: resolvedAreaId, + floor_id: floorId || null, + floor_name: String(room.floor_name || floor.name || floorId || ''), + floor_level: Number.isFinite(Number(room.floor_level)) ? Number(room.floor_level) : Number.isFinite(Number(floor.level)) ? Number(floor.level) : 0, + visible: room.visible !== false, + order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999, + entity_ids: Array.isArray(room.entity_ids) ? room.entity_ids.map(String) : [], + entity_overrides: room.entity_overrides && typeof room.entity_overrides === 'object' ? room.entity_overrides : {}, + layout_items: roomLayoutItems(room), + temperature_sensor_entity_id: String(room.temperature_sensor_entity_id || ''), + match_keys: roomKeys, + }); + } + + rooms.sort((a, b) => { + if (Boolean(a.visible) !== Boolean(b.visible)) return a.visible ? -1 : 1; + if (a.order !== b.order) return a.order - b.order; + if (a.floor_level !== b.floor_level) return a.floor_level - b.floor_level; + return a.name.localeCompare(b.name, 'ru'); + }); + return rooms; +} + +function roomEntities(room, haData, includeHidden = false) { + const states = toList(haData.states); + const entityRegistry = registryMap(haData.entity_registry, ['entity_id']); + const deviceRegistry = registryMap(haData.device_registry, ['device_id', 'id', 'di']); + const { byId: areasById, byName: areasByName } = registryAreaMaps(haData.areas); + const explicitIds = [ + ...(Array.isArray(room?.entity_ids) ? room.entity_ids.map(String) : []), + ...Object.keys(room?.entity_overrides || {}), + ].map(String).filter(Boolean); + const areaId = String(room?.area_id || room?.id || ''); + const roomKeys = Array.isArray(room?.match_keys) && room.match_keys.length ? room.match_keys : roomMatchKeys(room); + const entityOverrides = room?.entity_overrides && typeof room.entity_overrides === 'object' ? room.entity_overrides : {}; + const candidates = []; + const roomAreaIds = new Set(); + const roomAreaNames = new Set(); + if (areaId) { + roomAreaIds.add(areaId); + roomAreaNames.add(normalizeLookupKey(areaId)); + } + for (const key of roomKeys) { + const matchedArea = areasByName[key]; + if (!matchedArea) continue; + const matchedAreaId = String(matchedArea.area_id || matchedArea.id || ''); + if (matchedAreaId) { + roomAreaIds.add(matchedAreaId); + roomAreaNames.add(normalizeLookupKey(matchedAreaId)); + } + roomAreaNames.add(normalizeLookupKey(matchedArea.name || matchedAreaId)); + roomAreaNames.add(normalizeLookupKey(matchedArea.alias || matchedArea.name || matchedAreaId)); + } + + for (const entity of states) { + 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 device = deviceId && deviceRegistry[deviceId] ? deviceRegistry[deviceId] : null; + const deviceAreaId = String(device?.area_id || ''); + 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)) + || [entityAreaName, deviceAreaName].some((candidate) => candidate && roomAreaNames.has(candidate)); + const matchesExplicit = explicitIds.includes(entityId); + if (!matchesArea && !matchesExplicit) continue; + if (registryEntry.hidden_by || registryEntry.disabled_by) continue; + const override = entityOverrides[entityId] && typeof entityOverrides[entityId] === 'object' ? entityOverrides[entityId] : {}; + const visible = override.visible !== false; + if (!visible && !includeHidden) continue; + const cardType = String(override.card_type || defaultCardType(entityId)); + candidates.push({ + entity_id: entityId, + domain: appEntityDomain(entityId), + name: entityName(entity, registryEntry, override), + icon: entityIcon(entity, override), + state: entity.state ?? 'unknown', + attributes: entity.attributes || {}, + labels: labelsFromEntity(entity, registryEntry), + area_id: entityAreaId || null, + card_type: cardType, + order: Number.isFinite(Number(override.order)) ? Number(override.order) : 9999, + subtitle: entitySubtitle(entity, cardType), + override, + visible, + }); + } + + candidates.sort((a, b) => (a.order - b.order) || a.name.localeCompare(b.name, 'ru')); + return candidates; +} + +function roomActiveCount(items) { + return items.reduce((count, item) => count + (isActiveEntity(item) ? 1 : 0), 0); +} + +function roomTemperatureBadge(items) { + for (const item of items) { + const attr = item?.attributes || {}; + const candidate = attr.current_temperature ?? attr.temperature ?? null; + if (candidate != null && Number.isFinite(Number(candidate))) { + return `${Math.round(Number(candidate))}°`; + } + } + return null; +} + +function roomTemperatureCandidate(room, items) { + const selectedSensorId = String(room?.temperature_sensor_entity_id || '').trim(); + if (!selectedSensorId) return null; + const entity = items.find((item) => item.entity_id === selectedSensorId); + if (!entity) return null; + const attr = entity.attributes || {}; + const candidate = attr.current_temperature ?? attr.temperature ?? entity.state ?? null; + if (candidate == null || !Number.isFinite(Number(candidate))) return null; + const domain = appEntityDomain(entity.entity_id); + const deviceClass = lower(attr.device_class); + const unit = lower(attr.unit_of_measurement); + if (domain !== 'sensor') return null; + if (domain === 'sensor' && deviceClass !== 'temperature' && unit !== '°c' && unit !== 'c' && !String(entity.entity_id).endsWith('_temperature')) { + return null; + } + return `${Math.round(Number(candidate))}°`; +} + +function roomSummary(room, items, haData) { + const allItems = roomEntities(room, haData, true); + let temperatureBadge = roomTemperatureCandidate(room, allItems) || roomTemperatureBadge(items); + if (!temperatureBadge) { + const explicitIds = []; + for (const [entityId, override] of Object.entries(room?.entity_overrides || {})) { + if (!override || typeof override !== 'object') continue; + if (String(entityId).endsWith('_temperature') && override.visible !== false) { + explicitIds.push(entityId); + } + } + for (const temperatureEntityId of explicitIds) { + const entity = toList(haData.states).find((item) => String(item?.entity_id || '') === temperatureEntityId); + if (!entity) continue; + const candidate = entity.attributes?.current_temperature ?? entity.attributes?.temperature ?? entity.state ?? null; + if (candidate != null && Number.isFinite(Number(candidate))) { + temperatureBadge = `${Math.round(Number(candidate))}°`; + break; + } + } + } + const activeCount = roomActiveCount(items); + return { + id: room.id, + name: room.name, + icon: room.icon, + floor_id: room.floor_id, + floor_name: room.floor_name, + floor_level: room.floor_level, + visible: Boolean(room.visible), + order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999, + temperature_sensor_entity_id: room.temperature_sensor_entity_id || null, + entity_count: activeCount, + active_entity_count: activeCount, + temperature_badge: temperatureBadge, + }; +} + +function entityIndex(config, haData) { + const states = toList(haData.states); + const entityRegistry = registryMap(haData.entity_registry, ['entity_id']); + const autoLabel = String(config?.home_assistant?.auto_label || 'auto'); + const manualAuto = new Set((config?.home_assistant?.auto_entity_ids || []).map(String)); + const items = {}; + + for (const entity of states) { + const entityId = String(entity?.entity_id || ''); + if (!entityId) continue; + const registryEntry = entityRegistry[entityId] || {}; + const labels = labelsFromEntity(entity, registryEntry); + const isHidden = Boolean(registryEntry.hidden_by || registryEntry.disabled_by); + items[entityId] = { + entity_id: entityId, + domain: appEntityDomain(entityId), + name: entityName(entity, registryEntry, {}), + icon: entityIcon(entity, {}), + state: entity.state ?? 'unknown', + attributes: entity.attributes || {}, + labels, + area_id: String(registryEntry.area_id || ''), + device_id: String(registryEntry.device_id || registryEntry.di || ''), + 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_hidden: isHidden, + last_changed: String(entity.last_changed || entity.last_updated || ''), + }; + } + + return items; +} + +function mainEntities(config, haData) { + const states = toList(haData.states); + const entityRegistry = registryMap(haData.entity_registry, ['entity_id']); + const autoLabel = String(config?.home_assistant?.auto_label || 'auto'); + const manualAuto = new Set((config?.home_assistant?.auto_entity_ids || []).map(String)); + const items = []; + const debug = { + total_states: states.length, + auto_matches: 0, + auto_preview: [], + active_allowed_preview: [], + domain_rejected: 0, + }; + + for (const entity of states) { + const entityId = String(entity?.entity_id || ''); + 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 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 (isAuto) { + debug.auto_matches += 1; + if (debug.auto_preview.length < 10) debug.auto_preview.push(entityId); + } + if (!isAuto || !isActiveEntity(entity)) continue; + if (debug.active_allowed_preview.length < 10) debug.active_allowed_preview.push(entityId); + if (!['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain)) { + debug.domain_rejected += 1; + continue; + } + if (domain === 'binary_sensor' && !isDoorContact) continue; + const cardType = defaultCardType(entityId); + items.push({ + entity_id: entityId, + domain, + name: entityName(entity, registryEntry, {}), + icon: entityIcon(entity, {}), + state: entity.state ?? 'unknown', + attributes: entity.attributes || {}, + labels, + card_type: cardType, + is_door_contact: isDoorContact, + subtitle: entitySubtitle(entity, cardType), + last_changed: String(entity.last_changed || entity.last_updated || ''), + }); + } + + items.sort((a, b) => { + const timeA = Date.parse(a.last_changed || '') || 0; + const timeB = Date.parse(b.last_changed || '') || 0; + if (timeA !== timeB) return timeA - timeB; + return a.name.localeCompare(b.name, 'ru'); + }); + panelLog('mainEntities()', { + total_states: debug.total_states, + auto_matches: debug.auto_matches, + auto_preview: debug.auto_preview, + active_allowed_preview: debug.active_allowed_preview, + domain_rejected: debug.domain_rejected, + final_count: items.length, + final_preview: previewEntityIds(items), + }); + return items; +} + +function selectWeatherEntity(haData, config) { + const preferred = String(config?.home_assistant?.weather_entity_id || '').trim(); + const states = toList(haData.states); + if (preferred) { + const match = states.find((entity) => String(entity?.entity_id || '') === preferred); + if (match) return match; + } + const direct = states.find((entity) => String(entity?.entity_id || '') === 'weather.yandex_weather'); + if (direct) return direct; + return states.find((entity) => appEntityDomain(entity?.entity_id) === 'weather') || null; +} + +function weatherSummary(entity) { + if (!entity) return null; + const attr = entity.attributes || {}; + return { + entity_id: entity.entity_id || null, + name: attr.friendly_name || 'Погода', + state: entity.state ?? null, + temperature: attr.temperature ?? null, + sensor_temperature: null, + wind_speed: attr.wind_speed ?? null, + condition: attr.condition ?? entity.state ?? null, + }; +} + +function batteryRoom(config, haData, rooms, selectedRoomId) { + const states = toList(haData.states); + const entityRegistry = registryMap(haData.entity_registry, ['entity_id']); + const deviceRegistry = registryMap(haData.device_registry, ['device_id', 'id', 'di']); + const deviceGroups = {}; + + for (const entity of states) { + const entityId = String(entity?.entity_id || ''); + if (!entityId) continue; + const registryEntry = entityRegistry[entityId] || {}; + const deviceId = String(registryEntry.device_id || registryEntry.id || registryEntry.di || ''); + 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)) { + continue; + } + const key = deviceId ? `device:${deviceId}` : `entity:${entityId}`; + if (!deviceGroups[key]) { + deviceGroups[key] = { + device_id: deviceId, + device, + items: [], + }; + } + deviceGroups[key].items.push({ entity, registry: registryEntry }); + } + + const batteryEntities = []; + for (const group of Object.values(deviceGroups)) { + let best = null; + let bestScore = Number.NEGATIVE_INFINITY; + for (const candidate of group.items) { + const item = candidate.entity || {}; + const entityId = String(item.entity_id || ''); + if (!entityId) continue; + const percent = batteryPercentFromEntity(item); + const status = batteryStatusFromPercent(item, percent); + let score = /battery$/i.test(entityId) ? 1000 : 0; + if (percent != null) score += 100; + if (!['unknown', 'unavailable', 'none'].includes(lower(item.state))) score += 10; + if (appEntityDomain(entityId) === 'sensor') score += 5; + if (score > bestScore || (score === bestScore && entityId.localeCompare(String(best?.entity_id || ''), 'ru') < 0)) { + best = { + entity_id: entityId, + item, + status, + percent, + }; + bestScore = score; + } + } + if (!best) continue; + const device = group.device || {}; + const percent = best.percent; + const status = best.status; + batteryEntities.push({ + entity_id: best.entity_id, + name: String(best.item?.attributes?.friendly_name || best.entity_id), + source_room_name: String(device.name_by_user || device.name || best.entity_id), + source_device_name: String(device.name_by_user || device.name || ''), + source_text: String(device.name_by_user || device.name || best.entity_id), + domain: appEntityDomain(best.entity_id), + battery_percent: percent, + battery_percent_text: percent == null ? null : `${String(Math.round(percent))}%`, + battery_status: status, + battery_status_label: status, + battery_icon: status === 'low' ? 'mdi:battery-30' : status === 'critical' ? 'mdi:battery-alert' : status === 'empty' ? 'mdi:battery-outline' : 'mdi:battery', + forecast_minutes_left: null, + forecast_text: null, + forecast_reason: null, + last_seen_state: String(best.item?.state ?? 'unknown'), + labels: labelsFromEntity(best.item, entityRegistry[best.entity_id] || {}), + order: 9999, + }); + } + + batteryEntities.sort((a, b) => { + const rankA = batteryStatusRank(String(a.battery_status || 'ok')); + const rankB = batteryStatusRank(String(b.battery_status || 'ok')); + if (rankA !== rankB) return rankA - rankB; + const percentA = a.battery_percent; + const percentB = b.battery_percent; + if (percentA != null && percentB != null && percentA !== percentB) return percentA - percentB; + if (percentA != null && percentB == null) return -1; + if (percentA == null && percentB != null) return 1; + return String(a.name || '').localeCompare(String(b.name || ''), 'ru'); + }); + + const counts = batteryEntities.reduce((acc, item) => { + acc[item.battery_status] = (acc[item.battery_status] || 0) + 1; + return acc; + }, {}); + const total = batteryEntities.length; + const problem = ['empty', 'critical', 'low', 'unavailable', 'unknown'].reduce((sum, key) => sum + Number(counts[key] || 0), 0); + + return { + id: 'batteries', + name: 'Батарейки', + icon: 'mdi:battery-outline', + visible: true, + virtual: true, + layout_items: [], + entities: batteryEntities, + entity_count: total, + active_entity_count: problem, + problem_count: problem, + battery_counts: counts, + battery_summary_text: batterySummaryText(counts, total), + critical_count: Number(counts.empty || 0) + Number(counts.critical || 0) + Number(counts.low || 0), + low_count: Number(counts.low || 0), + unavailable_count: Number(counts.unavailable || 0), + unknown_count: Number(counts.unknown || 0), + order: -1000, + }; +} + +function buildSnapshotFromHa(panelConfig, hass, selectedRoomId = 'main', popupState = null) { + const config = normalizeConfig(panelConfig.runtime_config || panelConfig.config || panelConfig || {}); + const states = hass?.states || {}; + const haData = { + states: Object.values(states), + areas: hass?.areas || {}, + floors: hass?.floors || {}, + entity_registry: hass?.entities || {}, + device_registry: hass?.devices || {}, + }; + const editMode = Boolean(config?.app?.edit_mode); + const rooms = roomDefinitions(config, haData); + const mainRoom = { + id: 'main', + name: String(config?.app?.main_room_name || 'Главная'), + icon: String(config?.app?.main_room_icon || 'mdi:home'), + visible: true, + entity_count: 0, + }; + + const roomSummaries = [mainRoom]; + const roomViews = {}; + const spaceIndex = {}; + const spaceEntities = {}; + + for (const room of rooms) { + const entities = roomEntities(room, haData, editMode); + const summary = roomSummary(room, entities, haData); + roomSummaries.push(summary); + roomViews[room.id] = { + id: room.id, + name: room.name, + icon: room.icon, + visible: Boolean(room.visible), + order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999, + entities, + layout_items: roomLayoutItems(room), + entity_count: summary.entity_count, + active_entity_count: summary.active_entity_count, + temperature_badge: summary.temperature_badge, + temperature_sensor_entity_id: room.temperature_sensor_entity_id, + }; + spaceIndex[room.id] = roomViews[room.id]; + spaceEntities[room.id] = entities; + } + + const battery = batteryRoom(config, haData, rooms, selectedRoomId); + const entityIndexMap = entityIndex(config, haData); + const main = mainEntities(config, haData); + const weatherEntity = selectWeatherEntity(haData, config); + const weather = weatherSummary(weatherEntity); + const weatherSensor = states['sensor.weather_temperature']; + if (weather && weatherSensor) { + weather.sensor_temperature = weatherSensor.state ?? null; + } + + const nextRoomId = selectedRoomId || 'main'; + let selectedRoom; + if (nextRoomId === 'main') { + selectedRoom = { + id: 'main', + name: mainRoom.name, + icon: mainRoom.icon, + visible: true, + entities: main, + layout_items: [], + entity_count: main.length, + active_entity_count: main.length, + temperature_badge: null, + }; + selectedRoom.weather = weather; + } else if (nextRoomId === 'batteries') { + selectedRoom = battery; + } else { + selectedRoom = spaceIndex[nextRoomId] || roomViews[nextRoomId] || { + id: nextRoomId, + name: nextRoomId, + icon: 'mdi:home-variant', + visible: true, + entities: [], + layout_items: [], + entity_count: 0, + active_entity_count: 0, + temperature_badge: null, + }; + } + + const camera = config.camera || {}; + const selectedPopup = popupState || null; + const popup = { + active: Boolean(selectedPopup?.active), + sensor_entity_id: selectedPopup?.sensor_entity_id ?? null, + opened_at: selectedPopup?.opened_at ?? null, + expires_at: selectedPopup?.expires_at ?? null, + poster_url: String(camera.poster_url || ''), + stream_url: String(camera.stream_url || ''), + stream_mode: String(camera.stream_mode || 'hls'), + title: 'Камера', + }; + + panelLog('buildSnapshot()', { + selected_room_id: nextRoomId, + rooms_total: rooms.length, + rooms_preview: rooms.slice(0, 8).map((room) => ({ + id: room.id, + name: room.name, + visible: Boolean(room.visible), + entity_ids: previewEntityIds(room.entity_ids, 6), + })), + room_entity_counts: Object.fromEntries( + Object.entries(spaceEntities).slice(0, 8).map(([roomId, entities]) => [roomId, { + count: Array.isArray(entities) ? entities.length : 0, + preview: previewEntityIds(entities, 6), + }]) + ), + main_entities: main.length, + main_preview: previewEntityIds(main, 10), + selected_room_entities: Array.isArray(selectedRoom?.entities) ? selectedRoom.entities.length : 0, + selected_room_preview: previewEntityIds(selectedRoom?.entities, 10), + weather_entity_id: weather?.entity_id || null, + popup_active: Boolean(popup.active), + }); + + return { + ok: true, + demo: false, + server_time: Math.floor(Date.now() / 1000), + settings: { + title: String(config.app?.title || 'Striker Panel'), + poll_interval_ms: Number(config.app?.poll_interval_ms || 5000), + edit_mode: Boolean(config.app?.edit_mode), + main_room_name: mainRoom.name, + ha_connection: { + base_url: '', + token: '', + verify_ssl: true, + }, + camera: { + poster_url: String(camera.poster_url || ''), + stream_url: String(camera.stream_url || ''), + stream_mode: String(camera.stream_mode || 'hls'), + popup_timeout_minutes: Number(camera.popup_timeout_minutes || 3), + trigger_entities: Array.isArray(camera.trigger_entities) ? camera.trigger_entities.map(String) : [], + }, + main_weather_actions: Array.isArray(config.app?.main_weather_actions) ? config.app.main_weather_actions : [], + main_boiler: config.app?.main_boiler || {}, + main_print: config.app?.main_print || {}, + }, + spaces: roomSummaries, + selected_space: selectedRoom, + space_index: spaceIndex, + space_entities: spaceEntities, + entity_index: entityIndexMap, + weather, + main_entities: main, + battery_room: battery, + temperature_sensor_entity_id: selectedRoom?.temperature_sensor_entity_id ?? null, + popup, + rooms: roomSummaries, + selected_room: selectedRoom, + ui: { + embed: true, + mode: 'ha', + shell: 'ha-native', + config_source: 'ha-local', + proxy_token: '', + }, + runtime_config: config, + }; +} + +class StrikerPanelPanel extends HTMLElement { constructor() { super(); this._hass = null; this._panel = null; this._narrow = false; - this._pollTimer = null; - this._activePanelUrl = ''; - this.attachShadow({ mode: 'open' }); + this._initialized = false; + this._assetsLoaded = false; + this._bridge = null; + this._runtimeConfig = null; + this._selectedRoomId = 'main'; + this._popupState = { + active: false, + sensor_entity_id: null, + opened_at: null, + expires_at: null, + }; + this._triggerSnapshot = {}; + this._popupCloseTimer = null; + this._assetVersion = String(Date.now()); + this._debugLastSnapshotSignature = ''; + this._registryCache = null; + this._registryCachePromise = null; } set hass(hass) { this._hass = hass; - this._render(); + this._syncRuntime(); } set panel(panel) { this._panel = panel; - this._render(); + this._syncRuntime(); } set narrow(narrow) { this._narrow = Boolean(narrow); - this._render(); + this._syncRuntime(); } connectedCallback() { - this._render(); + panelLog('connectedCallback()', { + entry_id: this._entryId(), + selected_room_id: this._selectedRoom(), + }); + this._ensureShell(); + this._ensureAssets(); + this._ensureBridge(); + this._syncRuntime(); } disconnectedCallback() { - if (this._pollTimer) { - window.clearInterval(this._pollTimer); - this._pollTimer = null; - } - } - - _resolveUrl(rawUrl) { - const value = String(rawUrl || '').trim(); - if (!value) { - return ''; - } - - try { - const url = new URL(value, window.location.href); - if (!url.searchParams.has('embed')) { - url.searchParams.set('embed', '1'); - } - if (!url.searchParams.has('mode')) { - url.searchParams.set('mode', 'ha'); - } - return url.toString(); - } catch (error) { - return value; + if (this._popupCloseTimer) { + clearTimeout(this._popupCloseTimer); + this._popupCloseTimer = null; } } @@ -61,169 +1097,698 @@ class WallPanelPanel extends HTMLElement { return customConfig.config || customConfig || config; } - _configUrl() { - return '/api/wall_panel/panel'; + _entryId() { + return String(this._panelConfig().entry_id || '').trim(); } - _legacyConfigUrl() { - const payload = this._panelConfig(); - const entryId = String(payload.entry_id || '').trim(); - if (!entryId) { - return ''; - } - return `/api/wall_panel/config/${encodeURIComponent(entryId)}`; + _syncToken() { + return String(this._panelConfig().sync_token || '').trim(); } - _proxyUrl() { - const payload = this._panelConfig(); - const entryId = String(payload.entry_id || '').trim(); - if (!entryId) { - return ''; - } - - const token = String(payload.sync_token || '').trim(); - const url = `/api/wall_panel/proxy/${encodeURIComponent(entryId)}/`; - if (!token) { - return url; - } - - const proxyUrl = new URL(url, window.location.href); - proxyUrl.searchParams.set('token', token); - return proxyUrl.pathname + proxyUrl.search + proxyUrl.hash; + _selectedRoom() { + return this._selectedRoomId || 'main'; } - _hasPanelUrl() { - const payload = this._panelConfig(); - const panelUrl = String(payload.panel_url || payload.ingress_url || '').trim(); - return panelUrl !== ''; + _currentConfig() { + const runtimeConfig = this._runtimeConfig || this._panelConfig().runtime_config || this._panelConfig().config || {}; + this._runtimeConfig = normalizeConfig(runtimeConfig); + return this._runtimeConfig; } - _renderMessage(title, body, extra = '') { - if (!this.shadowRoot) { + _ensureShell() { + if (this._initialized) { return; } - - this.shadowRoot.innerHTML = ` - -
-
-
- ${title} -
${body}
- ${extra} +
+ + +
+
+
+
+
+ +
+
+

+
+
+
+
+ +
+
+ +
+
+
+
+ + + + + + + + `; + this._initialized = true; } - _renderIframe(panelUrl) { - if (!this.shadowRoot) { + _ensureAssets() { + if (this._assetsLoaded) { return; } + const jsSrc = `/api/wall_panel/assets/app.js?v=${this._assetVersion}`; - const wrap = this.shadowRoot.querySelector('.wrap'); - if (!wrap) { - return; + if (!document.querySelector(`link[data-striker-panel-mdi="1"]`)) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css'; + link.dataset.strikerPanelMdi = '1'; + document.head.appendChild(link); } - const iframe = document.createElement('iframe'); - iframe.src = panelUrl; - iframe.loading = 'eager'; - iframe.referrerPolicy = 'no-referrer'; - iframe.allow = 'autoplay; fullscreen; picture-in-picture'; - iframe.style.width = '100%'; - iframe.style.height = '100%'; - iframe.style.border = '0'; - iframe.style.display = 'block'; - iframe.style.background = 'transparent'; - iframe.addEventListener('load', () => { - this._activePanelUrl = panelUrl; + if (!document.querySelector(`script[data-striker-panel-app="1"][src="${jsSrc}"]`)) { + const script = document.createElement('script'); + script.src = jsSrc; + script.defer = true; + script.dataset.strikerPanelApp = '1'; + document.head.appendChild(script); + } + + this._assetsLoaded = true; + } + + _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); + } + } + + _getRegistryMaps() { + if (this._registryCache) { + return this._registryCache; + } + const hass = this._hass || {}; + 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']), + }; + } + + async _refreshRegistryCache(force = false) { + if (!force && this._registryCache) { + return this._registryCache; + } + + if (!force && this._registryCachePromise) { + return this._registryCachePromise; + } + + const hass = this._hass || {}; + if (!hass?.callWS) { + this._registryCache = this._getRegistryMaps(); + 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/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']), + }; + 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 || [] })) : [], + }); + return this._registryCache; + } catch (error) { + panelWarn('registry cache refresh failed, falling back to hass.* maps', error); + this._registryCache = this._getRegistryMaps(); + return this._registryCache; + } finally { + this._registryCachePromise = null; + } + })(); + + return this._registryCachePromise; + } + + _buildSnapshot(roomId = this._selectedRoom()) { + const config = this._currentConfig(); + const hass = this._hass || {}; + const states = hass.states || {}; + const haData = { + states: Object.values(states), + ...this._getRegistryMaps(), + }; + + const editMode = Boolean(config?.app?.edit_mode); + const rooms = roomDefinitions(config, haData); + const mainRoom = { + id: 'main', + name: String(config?.app?.main_room_name || 'Главная'), + icon: String(config?.app?.main_room_icon || 'mdi:home'), + visible: true, + entity_count: 0, + }; + const roomSummaries = [mainRoom]; + const roomViews = {}; + const spaceIndex = {}; + const spaceEntities = {}; + + for (const room of rooms) { + const entities = roomEntities(room, haData, editMode); + const summary = roomSummary(room, entities, haData); + roomSummaries.push(summary); + roomViews[room.id] = { + id: room.id, + name: room.name, + icon: room.icon, + visible: Boolean(room.visible), + order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999, + entities, + layout_items: roomLayoutItems(room), + entity_count: summary.entity_count, + active_entity_count: summary.active_entity_count, + temperature_badge: summary.temperature_badge, + temperature_sensor_entity_id: room.temperature_sensor_entity_id, + }; + spaceIndex[room.id] = roomViews[room.id]; + spaceEntities[room.id] = entities; + } + + const weatherEntity = selectWeatherEntity(haData, config); + const weather = weatherSummary(weatherEntity); + const weatherSensor = states['sensor.weather_temperature']; + if (weather && weatherSensor) { + weather.sensor_temperature = weatherSensor.state ?? null; + } + + const main = mainEntities(config, haData); + const battery = batteryRoom(config, haData, rooms, roomId); + const entityIndexMap = entityIndex(config, haData); + + let selectedRoom; + if (roomId === 'main') { + selectedRoom = { + id: 'main', + name: mainRoom.name, + icon: mainRoom.icon, + visible: true, + entities: main, + layout_items: [], + entity_count: main.length, + active_entity_count: main.length, + temperature_badge: null, + }; + selectedRoom.weather = weather; + } else if (roomId === 'batteries') { + selectedRoom = battery; + } else { + selectedRoom = spaceIndex[roomId] || roomViews[roomId] || { + id: roomId, + name: roomId, + icon: 'mdi:home-variant', + visible: true, + entities: [], + layout_items: [], + entity_count: 0, + active_entity_count: 0, + temperature_badge: null, + }; + } + + 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; + } + + return { + ok: true, + demo: false, + server_time: Math.floor(Date.now() / 1000), + settings: { + title: String(config.app?.title || 'Striker Panel'), + poll_interval_ms: Number(config.app?.poll_interval_ms || 5000), + edit_mode: Boolean(config.app?.edit_mode), + main_room_name: mainRoom.name, + ha_connection: null, + camera: { + poster_url: String(camera.poster_url || ''), + stream_url: String(camera.stream_url || ''), + stream_mode: String(camera.stream_mode || 'hls'), + popup_timeout_minutes: Number(camera.popup_timeout_minutes || 3), + trigger_entities: Array.isArray(camera.trigger_entities) ? camera.trigger_entities.map(String) : [], + }, + main_weather_actions: Array.isArray(config.app?.main_weather_actions) ? config.app.main_weather_actions : [], + main_boiler: config.app?.main_boiler || {}, + main_print: config.app?.main_print || {}, + }, + spaces: roomSummaries, + selected_space: selectedRoom, + space_index: spaceIndex, + space_entities: spaceEntities, + entity_index: entityIndexMap, + weather, + main_entities: main, + battery_room: battery, + temperature_sensor_entity_id: selectedRoom?.temperature_sensor_entity_id ?? null, + popup: { + active: Boolean(popup.active), + sensor_entity_id: popup.sensor_entity_id ?? null, + opened_at: popup.opened_at ?? null, + expires_at: popup.expires_at ?? null, + poster_url: String(camera.poster_url || ''), + stream_url: String(camera.stream_url || ''), + stream_mode: String(camera.stream_mode || 'hls'), + title: 'Камера', + }, + rooms: roomSummaries, + selected_room: selectedRoom, + ui: { + embed: true, + mode: 'ha-native', + shell: 'ha-native', + config_source: 'ha-local', + proxy_token: '', + }, + runtime_config: config, + }; + } + + _schedulePopupClose(delayMs) { + if (this._popupCloseTimer) { + clearTimeout(this._popupCloseTimer); + } + this._popupCloseTimer = setTimeout(() => { + this._popupCloseTimer = null; + this._popupState.active = false; + this._popupState.expires_at = null; + this._emitSnapshot(this._buildSnapshot(this._selectedRoom())); + }, delayMs); + } + + _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())); + } + } + } + + _refreshFromHass() { + if (!this._hass) { + return; + } + const nextStates = {}; + Object.entries(this._hass.states || {}).forEach(([entityId, entity]) => { + nextStates[entityId] = entity?.state; }); - wrap.replaceChildren(iframe); + this._updateTriggerPopup(nextStates); + const snapshot = this._buildSnapshot(this._selectedRoom()); + this._emitSnapshot(snapshot); } - async _tryAttachPanel() { - const payload = this._panelConfig(); - const proxyUrl = this._proxyUrl(); - if (proxyUrl && proxyUrl === this._activePanelUrl) { + _ensureBridge() { + if (this._bridge) { return; } - - if (proxyUrl && this._hasPanelUrl()) { - this._renderIframe(proxyUrl); - return; - } - - const panelUrl = this._resolveUrl(payload.panel_url || payload.ingress_url || ''); - const configUrl = this._configUrl(); - this._renderMessage( - 'Waiting for Striker Panel', - panelUrl - ? 'Open this panel through Home Assistant after the add-on URL is configured.' - : 'Set the PHP panel URL in the integration options.', - configUrl ? `${configUrl}` : '' - ); + panelLog('bridge init'); + this._bridge = { + mode: 'ha', + request: (method, action, payload = {}) => this._request(method, action, payload), + getSnapshot: (roomId = this._selectedRoom()) => Promise.resolve(this._buildSnapshot(roomId || this._selectedRoom())), + setSelectedRoomId: (roomId) => { + this._selectedRoomId = String(roomId || 'main'); + }, + getConfig: () => clone(this._currentConfig()), + }; + window.WALL_PANEL_HA_BRIDGE = this._bridge; } - _render() { - if (!this.shadowRoot) { + async _request(method, action, payload = {}) { + const normalizedAction = String(action || '').trim().toLowerCase(); + const config = this._currentConfig(); + const entryId = this._entryId(); + const token = this._syncToken(); + panelLog('bridge request', panelRequestSummary(method, action, payload)); + + if (method === 'GET' && normalizedAction === 'snapshot') { + const roomId = String(payload.space_id || payload.room_id || this._selectedRoom() || 'main'); + this._selectedRoomId = roomId; + panelLog('bridge snapshot -> build', { room_id: roomId }); + return this._buildSnapshot(roomId); + } + + if (method === 'GET' && normalizedAction === 'history') { + const entityId = String(payload.entity_id || '').trim(); + const hours = Math.max(1, Math.min(168, Number(payload.hours || 24) || 24)); + if (!entityId) { + return []; + } + if (this._hass?.callWS) { + const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); + panelLog('bridge history via callWS', { entity_id: entityId, hours }); + const history = await this._hass.callWS({ + type: 'history/history_during_period', + start_time: start, + end_time: new Date().toISOString(), + entity_ids: [entityId], + minimal_response: true, + no_attributes: true, + significant_changes_only: false, + }); + return { history }; + } + const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); + panelLog('bridge history via fetch fallback', { entity_id: entityId, hours }); + const url = new URL(`/api/history/period/${encodeURIComponent(start)}`, window.location.origin); + url.searchParams.set('filter_entity_id', entityId); + url.searchParams.set('minimal_response', '1'); + url.searchParams.set('no_attributes', '1'); + const res = await fetch(url.toString(), { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + throw new Error(`History request failed: ${res.status}`); + } + const history = await res.json(); + return { history }; + } + + if (method === 'POST' && normalizedAction === 'service') { + const entityId = String(payload.entity_id || '').trim(); + const command = String(payload.command || '').trim(); + const value = payload.value; + if (!entityId || !command) { + throw new Error('entity_id and command are required'); + } + + const domain = appEntityDomain(entityId); + const serviceMap = { + toggle: [domain, 'toggle', { entity_id: entityId }], + turn_on: [domain, 'turn_on', { entity_id: entityId }], + turn_off: [domain, 'turn_off', { entity_id: entityId }], + open: ['cover', 'open_cover', { entity_id: entityId }], + close: ['cover', 'close_cover', { entity_id: entityId }], + stop: ['cover', 'stop_cover', { entity_id: entityId }], + set_position: ['cover', 'set_cover_position', { entity_id: entityId, position: value }], + set_temperature: ['climate', 'set_temperature', { entity_id: entityId, temperature: value }], + set_hvac_mode: ['climate', 'set_hvac_mode', { entity_id: entityId, hvac_mode: value }], + set_fan_mode: ['climate', 'set_fan_mode', { entity_id: entityId, fan_mode: value }], + set_swing_mode: ['climate', 'set_swing_mode', { entity_id: entityId, swing_mode: value }], + set_preset_mode: ['climate', 'set_preset_mode', { entity_id: entityId, preset_mode: value }], + }; + const [serviceDomain, serviceName, data] = serviceMap[command] || [domain, command, { entity_id: entityId, ...(value !== undefined ? { value } : {}) }]; + if (this._hass?.callService) { + panelLog('bridge service via callService', { + entity_id: entityId, + command, + service: `${serviceDomain}.${serviceName}`, + }); + await this._hass.callService(serviceDomain, serviceName, data); + } else { + panelLog('bridge service via fetch fallback', { + entity_id: entityId, + command, + service: `${serviceDomain}.${serviceName}`, + }); + const url = new URL(`/api/services/${encodeURIComponent(serviceDomain)}/${encodeURIComponent(serviceName)}`, window.location.origin); + const res = await fetch(url.toString(), { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(data), + }); + if (!res.ok) { + throw new Error(`Home Assistant returned HTTP ${res.status}`); + } + } + this._refreshFromHass(); + return { ok: true }; + } + + 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 }; + } + + if (method === 'POST' && ['save-settings', 'save-entity-override', 'save-space-override', 'create-room-layout-item', 'save-room-layout-item', 'delete-room-layout-item', 'reorder-room-grid'].includes(normalizedAction)) { + if (!entryId) { + throw new Error('entry_id is required'); + } + if (!token) { + throw new Error('sync token is required'); + } + + panelLog('bridge config write', { + action: normalizedAction, + entry_id: entryId, + payload_keys: Object.keys(payload || {}), + }); + const url = new URL(`/api/wall_panel/config/${encodeURIComponent(entryId)}`, window.location.origin); + const res = await fetch(url.toString(), { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Wall-Panel-Token': token, + }, + body: JSON.stringify({ + action: normalizedAction, + payload, + }), + }); + const json = await res.json(); + if (!res.ok || json.ok === false) { + throw new Error(json.error || `Request failed: ${res.status}`); + } + if (json.config) { + this._runtimeConfig = normalizeConfig(json.config); + } + const snapshot = this._buildSnapshot(this._selectedRoom()); + this._emitSnapshot(snapshot); + return json; + } + + throw new Error(`Unsupported action: ${action}`); + } + + _syncRuntime() { + if (!this.isConnected) { return; } - - if (this._pollTimer) { - window.clearInterval(this._pollTimer); - this._pollTimer = null; - } - - this._tryAttachPanel(); - this._pollTimer = window.setInterval(() => { - this._tryAttachPanel(); - }, 2000); + this._ensureShell(); + this._ensureAssets(); + this._ensureBridge(); + this._runtimeConfig = normalizeConfig(this._panelConfig().runtime_config || this._panelConfig().config || this._runtimeConfig || {}); + const currentRoom = this._selectedRoom(); + this._refreshRegistryCache().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'), + ]); + if (signature !== this._debugLastSnapshotSignature) { + this._debugLastSnapshotSignature = signature; + panelLog('syncRuntime()', panelSnapshotSummary(snapshot)); + } + this._emitSnapshot(snapshot); + }).catch((error) => { + panelWarn('syncRuntime registry refresh failed', error); + const snapshot = this._buildSnapshot(currentRoom); + this._emitSnapshot(snapshot); + }); } } if (!customElements.get('striker-panel-panel')) { - customElements.define('striker-panel-panel', WallPanelPanel); + customElements.define('striker-panel-panel', StrikerPanelPanel); } diff --git a/custom_components/wall_panel/helpers.py b/custom_components/wall_panel/helpers.py index 56b6a23..8d3161f 100755 --- a/custom_components/wall_panel/helpers.py +++ b/custom_components/wall_panel/helpers.py @@ -3,7 +3,9 @@ from __future__ import annotations import json +import os from copy import deepcopy +from pathlib import Path from typing import Any from .const import ( @@ -21,6 +23,13 @@ from .const import ( ) +def shared_config_path() -> Path: + override = os.getenv("WALL_PANEL_SHARED_CONFIG_PATH", "").strip() + if override: + return Path(override) + return Path("/config/wall_panel/wall_panel_config.json") + + def default_config() -> dict[str, Any]: return { "app": { @@ -79,7 +88,79 @@ def parse_config_json(raw: str) -> dict[str, Any]: def current_entry_config(entry) -> dict[str, Any]: - return normalize_config(entry.options.get(CONF_CONFIG)) + path = shared_config_path() + options_config = normalize_config(entry.options.get(CONF_CONFIG)) + + def _config_score(config: dict[str, Any]) -> int: + score = 0 + rooms = config.get("rooms", []) + if isinstance(rooms, list): + score += len(rooms) * 25 + for room in rooms: + if not isinstance(room, dict): + continue + score += len([value for value in ( + room.get("name"), + room.get("icon"), + room.get("area_id"), + room.get("floor_id"), + room.get("temperature_sensor_entity_id"), + ) if isinstance(value, str) and value.strip()]) + entity_ids = room.get("entity_ids", []) + if isinstance(entity_ids, list): + score += len([item for item in entity_ids if str(item).strip()]) + overrides = room.get("entity_overrides", {}) + if isinstance(overrides, dict): + score += len(overrides) * 3 + layout_items = room.get("layout_items", []) + if isinstance(layout_items, list): + score += len(layout_items) * 5 + + app = config.get("app", {}) + if isinstance(app, dict): + for key in ("title", "main_room_name", "main_room_icon"): + value = app.get(key) + if isinstance(value, str) and value.strip(): + score += 3 + for key in ("main_boiler", "main_print"): + value = app.get(key) + if isinstance(value, dict) and value: + score += len(value) * 2 + actions = app.get("main_weather_actions", []) + if isinstance(actions, list): + score += len(actions) * 2 + + camera = config.get("camera", {}) + if isinstance(camera, dict): + for key in ("rtsp_url", "stream_url", "poster_url"): + value = camera.get(key) + if isinstance(value, str) and value.strip(): + score += 5 + trigger_entities = camera.get("trigger_entities", []) + if isinstance(trigger_entities, list): + score += len([item for item in trigger_entities if str(item).strip()]) + + return score + + chosen_config = options_config + try: + if path.is_file(): + raw = path.read_text(encoding="utf-8") + if raw.strip(): + data = json.loads(raw) + if isinstance(data, dict): + file_config = normalize_config(data) + if _config_score(file_config) >= _config_score(options_config): + chosen_config = file_config + except (OSError, json.JSONDecodeError, ValueError): + pass + + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(config_to_json(chosen_config) + "\n", encoding="utf-8") + except OSError: + pass + return chosen_config def current_entry_panel(entry) -> dict[str, Any]: @@ -238,6 +319,12 @@ def build_patch_payload(payload: dict[str, Any], keys: list[str]) -> dict[str, A return result +def save_shared_config(config: dict[str, Any]) -> None: + path = shared_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(config_to_json(normalize_config(config)) + "\n", encoding="utf-8") + + def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None: for key, value in source.items(): if isinstance(value, dict) and isinstance(target.get(key), dict): diff --git a/custom_components/wall_panel/views.py b/custom_components/wall_panel/views.py index 89d1e7a..854c4fb 100755 --- a/custom_components/wall_panel/views.py +++ b/custom_components/wall_panel/views.py @@ -24,6 +24,7 @@ from .helpers import ( normalize_config, reorder_room_grid, save_settings, + save_shared_config, update_entity_override, update_room_layout_item, update_room_override, @@ -66,6 +67,11 @@ def _request_token(request: web.Request) -> str: return request.query.get("token", "").strip() +def _proxy_cookie_name(entry_id: str) -> str: + safe_entry_id = "".join(ch for ch in entry_id if ch.isalnum() or ch in {"-", "_"}) + return f"wall_panel_proxy_{safe_entry_id}" + + def _authorized(entry, request: web.Request) -> bool: expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip() if not expected: @@ -74,6 +80,19 @@ def _authorized(entry, request: web.Request) -> bool: return secrets.compare_digest(_request_token(request), expected) +def _proxy_authorized(entry, request: web.Request, entry_id: str) -> bool: + expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip() + if not expected: + return False + + token = _request_token(request) + if token and secrets.compare_digest(token, expected): + return True + + cookie = request.cookies.get(_proxy_cookie_name(entry_id), "") + return bool(cookie) and secrets.compare_digest(cookie, expected) + + def _response(data: Any, status: int = 200) -> web.Response: if isinstance(data, str): return web.Response(text=data, status=status, content_type="text/plain; charset=utf-8") @@ -235,7 +254,7 @@ async def _handle_proxy_request(request: web.Request, entry_id: str, path: str, entry = _entry_from_hass(hass, entry_id) if entry is None: return _response({"ok": False, "error": "Unknown entry"}, 404) - if not _authorized(entry, request): + if not _proxy_authorized(entry, request, entry_id): _LOGGER.warning("Wall Panel proxy denied for %s: unauthorized", entry_id) return _response({"ok": False, "error": "Unauthorized"}, 401) @@ -286,11 +305,26 @@ async def _handle_proxy_request(request: web.Request, entry_id: str, path: str, response_body = await upstream.read() _LOGGER.warning("Wall Panel upstream %s %s", upstream.status, upstream_url) - return web.Response( + response = web.Response( body=response_body, status=upstream.status, headers=response_headers, ) + expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip() + if expected: + # The iframe's HTML may authenticate with ?token=..., but its relative + # CSS/JS/API requests will not inherit that query string. A scoped cookie + # keeps the whole proxied panel session authorized. + response.set_cookie( + _proxy_cookie_name(entry_id), + expected, + path=_proxy_root(entry_id), + secure=True, + httponly=True, + samesite="Lax", + max_age=60 * 60 * 24, + ) + return response async def handle_proxy_request(request: web.Request) -> web.StreamResponse: @@ -303,6 +337,7 @@ async def handle_proxy_request(request: web.Request) -> web.StreamResponse: def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> None: + save_shared_config(config) options = dict(entry.options) options[CONF_CONFIG] = config hass.config_entries.async_update_entry(entry, options=options) diff --git a/storage/battery_cache.json b/storage/battery_cache.json index 0458b7f..81ec26c 100755 --- a/storage/battery_cache.json +++ b/storage/battery_cache.json @@ -1,628 +1,456 @@ { "items": { "sensor.garage_motion_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 100 }, { - "timestamp": 1773668185, - "value": 100 - }, - { - "timestamp": 1773668401, - "value": 100 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 100 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 100 }, "sensor.garage_light_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 100 }, { - "timestamp": 1773668185, - "value": 100 - }, - { - "timestamp": 1773668401, - "value": 100 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 100 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 100 }, "sensor.garage_door_motion_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 100 }, { - "timestamp": 1773668185, - "value": 100 - }, - { - "timestamp": 1773668401, - "value": 100 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925032, "value": 100 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 100 }, "sensor.stair_up_motion_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 100 }, { - "timestamp": 1773668185, - "value": 100 - }, - { - "timestamp": 1773668367, - "value": 100 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 100 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 100 }, "sensor.stair_down_motion_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 100 }, { - "timestamp": 1773668185, - "value": 100 - }, - { - "timestamp": 1773668401, - "value": 100 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 100 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 100 }, "sensor.stair_light_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 100 }, { - "timestamp": 1773668185, - "value": 100 - }, - { - "timestamp": 1773668401, - "value": 100 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 100 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 100 }, "sensor.door_sensor_2_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 90 }, { - "timestamp": 1773668166, - "value": 90 - }, - { - "timestamp": 1773668401, - "value": 90 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 90 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 90 }, "sensor.wleak_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 100 }, { - "timestamp": 1773668185, - "value": 100 - }, - { - "timestamp": 1773668401, - "value": 100 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 100 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 100 }, "sensor.0xa4c138433d675809_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 1 }, { - "timestamp": 1773668185, - "value": 1 - }, - { - "timestamp": 1773668401, - "value": 1 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 1 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 1 }, "sensor.0xa4c138997cb4fdd1_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 100 }, { - "timestamp": 1773668158, - "value": 100 - }, - { - "timestamp": 1773668379, - "value": 100 - }, - { - "timestamp": 1773669193, + "timestamp": 1773925068, "value": 100 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 100 }, "sensor.printer_knopka_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 74 }, { - "timestamp": 1773668185, - "value": 74 - }, - { - "timestamp": 1773668401, - "value": 74 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 74 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 74 }, "sensor.lestnitsa_dvizhenie_2_etazh_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 100 }, { - "timestamp": 1773668185, - "value": 100 - }, - { - "timestamp": 1773668401, - "value": 100 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 100 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 100 }, "sensor.spalnia_knopka_girliand_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 29 }, { - "timestamp": 1773668185, - "value": 29 - }, - { - "timestamp": 1773668401, - "value": 29 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 29 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 29 }, "sensor.ulitsa_temperatura_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 63 }, { - "timestamp": 1773668185, - "value": 63 - }, - { - "timestamp": 1773668401, - "value": 63 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 63 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 62 }, "sensor.0x44e2f8fffeb65d8e_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, + "value": 50 + }, + { + "timestamp": 1773903513, "value": 55 }, { - "timestamp": 1773664955, - "value": 60 - }, - { - "timestamp": 1773664975, + "timestamp": 1773903534, "value": 50 }, { - "timestamp": 1773668185, - "value": 50 - }, - { - "timestamp": 1773668401, - "value": 50 - }, - { - "timestamp": 1773669214, - "value": 50 - }, - { - "timestamp": 1773680612, - "value": 60 - }, - { - "timestamp": 1773680632, + "timestamp": 1773905684, "value": 55 }, { - "timestamp": 1773726922, - "value": 60 - }, - { - "timestamp": 1773726944, - "value": 50 - }, - { - "timestamp": 1773730991, - "value": 60 - }, - { - "timestamp": 1773739180, + "timestamp": 1773925068, "value": 55 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0.1639, + "forecast_slope_per_hour": 0.2435, "forecast_reason": "Заряд не падает", - "percent": 45 + "percent": 40 }, "sensor.0x54ef4410009a6a11_battery": { - "loaded_at": 1774247175, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773642375, - "value": 95 - }, - { - "timestamp": 1773646522, - "value": 95 - }, - { - "timestamp": 1773647796, - "value": 95 - }, - { - "timestamp": 1773648329, - "value": 93 - }, - { - "timestamp": 1773651401, - "value": 94 - }, - { - "timestamp": 1773654742, - "value": 95 - }, - { - "timestamp": 1773663946, - "value": 94 - }, - { - "timestamp": 1773668185, - "value": 94 - }, - { - "timestamp": 1773668401, - "value": 94 - }, - { - "timestamp": 1773669214, - "value": 94 - }, - { - "timestamp": 1773673180, - "value": 95 - }, - { - "timestamp": 1773680014, - "value": 93 - }, - { - "timestamp": 1773683229, - "value": 95 - }, - { - "timestamp": 1773686431, - "value": 96 - }, - { - "timestamp": 1773689837, - "value": 93 - }, - { - "timestamp": 1773692920, - "value": 91 - }, - { - "timestamp": 1773696266, - "value": 93 - }, - { - "timestamp": 1773699456, - "value": 94 - }, - { - "timestamp": 1773702777, - "value": 96 - }, - { - "timestamp": 1773706218, - "value": 95 - }, - { - "timestamp": 1773709493, - "value": 91 - }, - { - "timestamp": 1773712864, + "timestamp": 1773851664, "value": 92 }, { - "timestamp": 1773716073, + "timestamp": 1773852462, + "value": 93 + }, + { + "timestamp": 1773855575, + "value": 91 + }, + { + "timestamp": 1773858820, + "value": 92 + }, + { + "timestamp": 1773862156, + "value": 91 + }, + { + "timestamp": 1773865222, + "value": 92 + }, + { + "timestamp": 1773868448, + "value": 91 + }, + { + "timestamp": 1773871808, "value": 95 }, { - "timestamp": 1773719369, + "timestamp": 1773875247, + "value": 92 + }, + { + "timestamp": 1773878606, + "value": 94 + }, + { + "timestamp": 1773885141, + "value": 92 + }, + { + "timestamp": 1773891488, + "value": 91 + }, + { + "timestamp": 1773897540, + "value": 93 + }, + { + "timestamp": 1773900649, + "value": 92 + }, + { + "timestamp": 1773907117, + "value": 93 + }, + { + "timestamp": 1773910581, + "value": 92 + }, + { + "timestamp": 1773913641, + "value": 91 + }, + { + "timestamp": 1773917027, + "value": 93 + }, + { + "timestamp": 1773925068, + "value": 93 + }, + { + "timestamp": 1773926597, + "value": 92 + }, + { + "timestamp": 1773933391, + "value": 91 + }, + { + "timestamp": 1773936397, "value": 93 } ], - "forecast_minutes_left": 85442, - "forecast_text": "≈ 59д 8ч до разряда", - "forecast_slope_per_hour": -0.0646, - "forecast_reason": null, + "forecast_minutes_left": null, + "forecast_text": null, + "forecast_slope_per_hour": 0.015, + "forecast_reason": "Заряд не падает", "percent": 92 }, "sensor.0x00124b0035558456_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 82 }, { - "timestamp": 1773668185, - "value": 82 - }, - { - "timestamp": 1773668401, - "value": 82 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 82 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", - "percent": 82 + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", + "percent": 73 }, "sensor.0xa4c13874f5fdfd2a_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, - "value": 91.5 + "timestamp": 1773851664, + "value": 92 }, { - "timestamp": 1773668185, - "value": 91.5 - }, - { - "timestamp": 1773668401, - "value": 91.5 - }, - { - "timestamp": 1773669204, - "value": 91.5 + "timestamp": 1773925068, + "value": 92 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", - "percent": 93.5 + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", + "percent": 93 }, "sensor.0x54ef44100119db20_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 100 }, { - "timestamp": 1773668185, - "value": 100 - }, - { - "timestamp": 1773668401, - "value": 100 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 100 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", "percent": 100 }, "sensor.door_sensor_spalnya_battery": { @@ -636,214 +464,206 @@ "percent": 100 }, "sensor.0x0ceff6fffe6cffc4_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, + "value": 45 + }, + { + "timestamp": 1773903513, "value": 50 }, { - "timestamp": 1773664975, + "timestamp": 1773903535, "value": 45 }, { - "timestamp": 1773668185, - "value": 45 - }, - { - "timestamp": 1773668401, - "value": 45 - }, - { - "timestamp": 1773669214, - "value": 45 - }, - { - "timestamp": 1773680611, + "timestamp": 1773905683, "value": 50 }, { - "timestamp": 1773680632, + "timestamp": 1773916260, "value": 45 }, { - "timestamp": 1773730991, + "timestamp": 1773918195, "value": 50 }, { - "timestamp": 1773731013, + "timestamp": 1773918217, "value": 45 }, { - "timestamp": 1773735636, + "timestamp": 1773920870, "value": 50 }, { - "timestamp": 1773735657, - "value": 45 + "timestamp": 1773923525, + "value": 55 }, { - "timestamp": 1773739159, + "timestamp": 1773923547, + "value": 50 + }, + { + "timestamp": 1773925068, "value": 50 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0.0887, + "forecast_slope_per_hour": 0.2618, "forecast_reason": "Заряд не падает", "percent": 55 }, "sensor.0x0ceff6fffe6cdee0_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 0 }, { - "timestamp": 1773668185, - "value": 0 + "timestamp": 1773916238, + "value": 60 }, { - "timestamp": 1773668401, - "value": 0 + "timestamp": 1773918189, + "value": 65 }, { - "timestamp": 1773669214, - "value": 0 + "timestamp": 1773918211, + "value": 60 + }, + { + "timestamp": 1773920877, + "value": 65 + }, + { + "timestamp": 1773920891, + "value": 60 + }, + { + "timestamp": 1773923528, + "value": 65 + }, + { + "timestamp": 1773923546, + "value": 60 + }, + { + "timestamp": 1773925068, + "value": 60 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", - "percent": 60 + "forecast_slope_per_hour": 3.1712, + "forecast_reason": "Заряд не падает", + "percent": 55 }, "sensor.0x705464fffe43dee0_battery": { - "loaded_at": 1774247175, + "loaded_at": 1774456345, "history_hours": 4320, "points": [ { - "timestamp": 1773642375, - "value": 45 - }, - { - "timestamp": 1773646522, - "value": 45 - }, - { - "timestamp": 1773647796, - "value": 45 - }, - { - "timestamp": 1773668185, - "value": 45 - }, - { - "timestamp": 1773668401, - "value": 45 - }, - { - "timestamp": 1773669214, - "value": 45 - }, - { - "timestamp": 1773726943, + "timestamp": 1773851545, "value": 40 + }, + { + "timestamp": 1773903534, + "value": 35 + }, + { + "timestamp": 1773905684, + "value": 40 + }, + { + "timestamp": 1773911127, + "value": 35 + }, + { + "timestamp": 1773920927, + "value": 40 + }, + { + "timestamp": 1773925068, + "value": 40 + }, + { + "timestamp": 1773932330, + "value": 35 } ], - "forecast_minutes_left": 9734, - "forecast_text": "≈ 6д 18ч до разряда", - "forecast_slope_per_hour": -0.2157, + "forecast_minutes_left": 13964, + "forecast_text": "≈ 9д 16ч до разряда", + "forecast_slope_per_hour": -0.1074, "forecast_reason": null, - "percent": 35 + "percent": 25 }, "sensor.0xa4c138259d164c22_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 88.5 }, { - "timestamp": 1773668171, - "value": 88.5 - }, - { - "timestamp": 1773668401, - "value": 88.5 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925068, "value": 88.5 } ], "forecast_minutes_left": null, "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", - "percent": 87 + "forecast_slope_per_hour": null, + "forecast_reason": "Недостаточно истории", + "percent": 88.5 }, "sensor.0xa4c138fe1cdd2a21_battery": { - "loaded_at": 1774247175, + "loaded_at": 1774456345, "history_hours": 4320, "points": [ { - "timestamp": 1773642375, - "value": 87.5 - }, - { - "timestamp": 1773646492, - "value": 87.5 - }, - { - "timestamp": 1773647796, - "value": 87.5 - }, - { - "timestamp": 1773668172, - "value": 87.5 - }, - { - "timestamp": 1773668401, - "value": 87.5 - }, - { - "timestamp": 1773669214, - "value": 87.5 - }, - { - "timestamp": 1773711258, + "timestamp": 1773851545, "value": 86 + }, + { + "timestamp": 1773906929, + "value": 85.5 + }, + { + "timestamp": 1773925068, + "value": 85.5 } ], - "forecast_minutes_left": 67706, - "forecast_text": "≈ 47д до разряда", - "forecast_slope_per_hour": -0.0753, + "forecast_minutes_left": 200295, + "forecast_text": "≈ 139д 2ч до разряда", + "forecast_slope_per_hour": -0.0264, "forecast_reason": null, - "percent": 85 + "percent": 88 }, "sensor.spalnya_temp_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456464, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851664, "value": 3 }, { - "timestamp": 1773668185, + "timestamp": 1773910195, + "value": 0 + }, + { + "timestamp": 1773916438, "value": 3 }, { - "timestamp": 1773668401, - "value": 3 - }, - { - "timestamp": 1773669214, + "timestamp": 1773925010, "value": 3 } ], @@ -854,30 +674,30 @@ "percent": 0 }, "sensor.kukhnia_temperatura_battery": { - "loaded_at": 1774264856, + "loaded_at": 1774456345, "history_hours": 4320, "points": [ { - "timestamp": 1773660056, + "timestamp": 1773851545, "value": 90 }, { - "timestamp": 1773668185, - "value": 90 + "timestamp": 1773919131, + "value": 83 }, { - "timestamp": 1773668401, - "value": 90 + "timestamp": 1773925068, + "value": 83 }, { - "timestamp": 1773669214, + "timestamp": 1773926056, "value": 90 } ], - "forecast_minutes_left": null, - "forecast_text": null, - "forecast_slope_per_hour": 0, - "forecast_reason": "Нет заметного разряда", + "forecast_minutes_left": 25113, + "forecast_text": "≈ 17д 10ч до разряда", + "forecast_slope_per_hour": -0.215, + "forecast_reason": null, "percent": 90 } } diff --git a/storage/popup_state.json b/storage/popup_state.json index b50645a..4c4983c 100755 --- a/storage/popup_state.json +++ b/storage/popup_state.json @@ -1,6 +1,6 @@ { "active": false, - "sensor_entity_id": "binary_sensor.barn_all_occupancy", - "opened_at": 1774445418, + "sensor_entity_id": "binary_sensor.doorbell_all_occupancy", + "opened_at": 1774456141, "expires_at": null } diff --git a/wall_panel/assets/app.css b/wall_panel/assets/app.css index 25bfd91..786893a 100755 --- a/wall_panel/assets/app.css +++ b/wall_panel/assets/app.css @@ -2115,6 +2115,11 @@ body.is-mobile-ui #camera-modal { max-width: none; } +.main-dashboard__cards .main-dashboard__room-grid { + grid-column: 1 / -1; + width: 100%; +} + .room-entities-section { display: grid; gap: 14px; diff --git a/wall_panel/assets/app.js b/wall_panel/assets/app.js index 4f96f47..d8b5602 100755 --- a/wall_panel/assets/app.js +++ b/wall_panel/assets/app.js @@ -42,20 +42,210 @@ haSubscribeId: 1, roomSelectionToken: 0, snapshotPollTimer: null, + haSnapshotListenerInstalled: false, + debugLastRenderSignature: '', }; const els = {}; + const client = window.StrikerPanelClient || (window.StrikerPanelClient = {}); + 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') { + return; + } + state.snapshot = snapshot; + initRefs(); + state.embedMode = detectEmbeddedContext(); + syncLayoutState(); + render(); + }; + + client.refresh = () => { + initRefs(); + state.embedMode = detectEmbeddedContext(); + syncLayoutState(); + render(); + }; function $(id) { - return document.getElementById(id); + const root = client.mountRoot || document; + if (!root) return null; + if (typeof root.getElementById === 'function') { + return root.getElementById(id); + } + return root.querySelector?.(`#${CSS.escape(id)}`) || null; } - function q(sel, root = document) { - return root.querySelector(sel); + function q(sel, root) { + const actualRoot = root || client.mountRoot || document; + return actualRoot.querySelector(sel); } - function qa(sel, root = document) { - return Array.from(root.querySelectorAll(sel)); + function qa(sel, root) { + const actualRoot = root || client.mountRoot || document; + return Array.from(actualRoot.querySelectorAll(sel)); + } + + function haBridge() { + return window.WALL_PANEL_HA_BRIDGE || null; + } + + function isHaRuntime() { + return Boolean( + haBridge() + || bootstrap?.ui?.mode === 'ha-native' + ); + } + + function sleep(ms) { + return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); + } + + function snapshotLooksReady(snapshot) { + if (!snapshot || typeof snapshot !== 'object') { + return false; + } + const rooms = Array.isArray(snapshot.rooms) ? snapshot.rooms : []; + const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : []; + if (rooms.length > 0 || spaces.length > 0) { + return true; + } + if (snapshot.selected_room?.id || snapshot.selected_space?.id) { + return true; + } + 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 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; + } + + const waitForHaBridge = async (timeoutMs = 1000) => { + const startedAt = Date.now(); + let bridge = haBridge(); + while (!bridge && (Date.now() - startedAt) < timeoutMs) { + await sleep(50); + bridge = haBridge(); + } + return bridge; + }; + + 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) { + 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) { + debugWarn('initial snapshot bridge.request(GET snapshot) failed', error); + } + + return null; + }; + + const firstPass = await tryBridgeSnapshot(); + if (firstPass) { + return firstPass; + } + + await sleep(150); + + const secondPass = await tryBridgeSnapshot(); + if (secondPass) { + return secondPass; + } + + debugLog('resolveInitialSnapshot -> fallback bootstrap', snapshotSummary(bootstrapSnapshot)); + return snapshotLooksReady(bootstrapSnapshot) ? bootstrapSnapshot : bootstrapSnapshot; } const PRESSABLE_SELECTOR = [ @@ -198,6 +388,7 @@ 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)); @@ -229,6 +420,7 @@ : 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 = ''; @@ -236,6 +428,10 @@ 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'; @@ -252,6 +448,7 @@ const wrap = document.createElement('span'); wrap.className = 'icon-node'; + wrap.appendChild(createIconElement(fallback)); if (source.includes(':')) { const img = document.createElement('img'); @@ -261,6 +458,10 @@ 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'; @@ -448,6 +649,9 @@ } function buildUrl(action, params = {}) { + if (isHaRuntime()) { + return ''; + } const url = new URL('api.php', window.location.href); url.searchParams.set('action', action); Object.entries(params).forEach(([key, value]) => { @@ -463,6 +667,12 @@ } async function apiGet(action, params = {}) { + const bridge = haBridge(); + if (bridge?.request) { + debugLog('apiGet via bridge', requestSummary(action, params)); + return bridge.request('GET', action, params); + } + debugLog('apiGet via http', requestSummary(action, params)); const res = await fetch(buildUrl(action, params), { headers: { Accept: 'application/json' }, cache: 'no-store', @@ -474,6 +684,12 @@ } async function apiPost(action, payload = {}) { + const bridge = haBridge(); + if (bridge?.request) { + debugLog('apiPost via bridge', requestSummary(action, payload)); + return bridge.request('POST', action, payload); + } + debugLog('apiPost via http', requestSummary(action, payload)); const res = await fetch(buildUrl(action), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, @@ -487,7 +703,11 @@ } async function fetchSnapshot(roomId = state.selectedRoomId || 'main') { - return apiGet('snapshot', { space_id: roomId || 'main' }); + const response = await apiGet('snapshot', { space_id: roomId || 'main' }); + if (response && response.ok === true && response.selected_room) { + return response; + } + return response; } async function loadSnapshot(roomId = state.selectedRoomId || 'main') { @@ -1004,6 +1224,87 @@ return q('.main-dashboard__cards', els.dashboardSurface); } + function renderMainRoomSummaryGrid(snapshot) { + const rooms = Array.isArray(snapshot?.spaces) ? snapshot.spaces : Array.isArray(snapshot?.rooms) ? snapshot.rooms : []; + const batteryRoom = snapshot?.battery_room || null; + const cards = document.createElement('div'); + cards.className = 'room-list__group main-dashboard__room-grid'; + + const roomCard = (room, options = {}) => { + if (!room) return null; + const card = document.createElement('div'); + card.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${options.hidden ? 'is-hidden-room' : ''}`.trim(); + card.dataset.roomId = room.id; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + card.addEventListener('click', () => setSelectedRoom(room.id)); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedRoom(room.id); + } + }); + + const content = document.createElement('div'); + content.className = 'room-item__content'; + + const icon = document.createElement('div'); + icon.className = 'room-item__icon'; + icon.appendChild(createIconElement(room.icon || 'mdi:home-variant')); + + const body = document.createElement('div'); + body.className = 'room-item__body'; + const activeCount = room.id === 'batteries' + ? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0 + : Number(room.active_entity_count ?? room.entity_count ?? 0) || 0; + const metaText = room.id === 'main' + ? 'Главный экран' + : room.id === 'batteries' + ? (room.battery_summary_text || `${room.entity_count || 0} батареек`) + : activeCount > 0 + ? `${activeCount} ${pluralizeActiveEntities(activeCount)}` + : 'Нет активных'; + body.innerHTML = ` +
${esc(room.name || '')}
+
${esc(metaText)}
+ `; + content.append(icon, body); + + const tempBadge = roomTemperatureBadge(snapshot, room); + if (tempBadge) { + card.classList.add('has-temp'); + const temp = document.createElement('div'); + temp.className = 'room-item__temp'; + temp.textContent = tempBadge; + card.appendChild(temp); + } + + card.append(content); + return card; + }; + + const orderedRooms = [...rooms] + .filter((room) => room && room.id !== 'main' && room.visible !== false && room.id !== 'batteries') + .sort((left, right) => { + const leftOrder = Number(left.order ?? 9999); + const rightOrder = Number(right.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left.name || '').localeCompare(String(right.name || ''), 'ru'); + }); + + orderedRooms.forEach((room) => { + const card = roomCard(room); + if (card) cards.appendChild(card); + }); + + if (batteryRoom && !isMobileViewport()) { + const card = roomCard(batteryRoom); + if (card) cards.appendChild(card); + } + + return cards; + } + function currentDashboardCardsContainer() { const snapshot = state.snapshot || bootstrap; const room = snapshot.selected_space || snapshot.selected_room || {}; @@ -1613,10 +1914,16 @@ } function haConnection() { + if (isHaRuntime()) { + return null; + } return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {}; } function haWsUrl(baseUrl) { + if (isHaRuntime()) { + return ''; + } if (!baseUrl) return ''; try { const url = new URL(baseUrl); @@ -3342,6 +3649,9 @@ } function renderRoomButtons(snapshot, rooms, batteryRoom = null) { + if (!els.roomList) { + return; + } els.roomList.innerHTML = ''; const sortedRooms = [...(rooms || [])].sort((left, right) => { if (left.id === 'main') return -1; @@ -3466,6 +3776,11 @@ function renderSelectedRoom(snapshot) { const room = snapshot.selected_space || snapshot.selected_room || {}; + const setText = (el, value) => { + if (el) { + el.textContent = value; + } + }; if (els.contentTop) { els.contentTop.classList.toggle('is-main', room.id === 'main'); } @@ -3474,8 +3789,8 @@ } updateMainPrintStrip(snapshot); if (room.id === 'batteries') { - els.selectedRoomEyebrow.textContent = 'Псевдо-комната'; - els.selectedRoomTitle.textContent = room.name || 'Батарейки'; + setText(els.selectedRoomEyebrow, 'Псевдо-комната'); + setText(els.selectedRoomTitle, room.name || 'Батарейки'); const total = Number(room.entity_count ?? 0) || 0; const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0; const unavailable = Number(room.unavailable_count ?? 0) || 0; @@ -3490,26 +3805,26 @@ if (unknown > 0) { summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`); } - els.selectedRoomMeta.textContent = summaryParts.length + setText(els.selectedRoomMeta, summaryParts.length ? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}` - : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`; + : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`); renderSelectedRoomActions(snapshot); return; } if (room.id !== 'main') { - els.selectedRoomEyebrow.textContent = 'Пространство'; - els.selectedRoomTitle.textContent = room.name || 'Панель'; + setText(els.selectedRoomEyebrow, 'Пространство'); + setText(els.selectedRoomTitle, room.name || 'Панель'); const entities = roomEntities(snapshot, room.id || 'main'); const activeCount = Number(room.active_entity_count ?? entities.length) || 0; - els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`; + setText(els.selectedRoomMeta, `${activeCount} ${pluralizeActiveEntities(activeCount)}`); renderSelectedRoomActions(snapshot); return; } const entities = roomEntities(snapshot, room.id || 'main'); - els.selectedRoomEyebrow.textContent = ''; - els.selectedRoomTitle.textContent = room.name || 'Панель'; - els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`; + setText(els.selectedRoomEyebrow, ''); + setText(els.selectedRoomTitle, room.name || 'Панель'); + setText(els.selectedRoomMeta, `${entities.length} ${pluralizeIncludedEntities(entities.length)}`); renderSelectedRoomActions(snapshot); } @@ -3545,23 +3860,32 @@ function renderDashboard(snapshot) { const room = snapshot.selected_space || snapshot.selected_room || {}; const grid = els.dashboardSurface; + if (!grid) { + return; + } grid.innerHTML = ''; if (room.id === 'main') { const layout = document.createElement('div'); layout.className = 'main-dashboard'; - const hero = renderMainHero(snapshot); - + const mainEntities = roomEntities(snapshot, 'main'); const cards = document.createElement('div'); cards.className = 'grid-surface main-dashboard__cards'; - const mainEntities = roomEntities(snapshot, 'main'); - mainEntities.forEach((entity) => { - cards.appendChild(renderEntityCard(entity, { isMain: true })); - }); + if (mainEntities.length) { + mainEntities.forEach((entity) => { + cards.appendChild(renderEntityCard(entity, { isMain: true })); + }); + } + + const hero = renderMainHero(snapshot); + + layout.append(hero); + if (mainEntities.length) { + layout.append(cards); + } - layout.append(hero, cards); grid.appendChild(layout); return; } @@ -3683,8 +4007,10 @@ ]); if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) { - els.cameraBackdrop.classList.add('is-open'); - els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + if (els.cameraBackdrop) { + els.cameraBackdrop.classList.add('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + } return; } @@ -3695,11 +4021,17 @@ return; } - els.cameraPoster.src = popup.poster_url || ''; - els.cameraPoster.alt = popup.sensor_entity_id || 'camera'; - els.cameraBackdrop.classList.add('is-open'); - els.cameraBackdrop.setAttribute('aria-hidden', 'false'); - els.cameraPlaceholder.classList.add('is-visible'); + if (els.cameraPoster) { + els.cameraPoster.src = popup.poster_url || ''; + els.cameraPoster.alt = popup.sensor_entity_id || 'camera'; + } + if (els.cameraBackdrop) { + els.cameraBackdrop.classList.add('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + } + if (els.cameraPlaceholder) { + els.cameraPlaceholder.classList.add('is-visible'); + } const expiresAt = Number(popup.expires_at || 0); if (expiresAt > 0) { @@ -3709,11 +4041,15 @@ const mins = Math.floor(remaining / 60); const secs = remaining % 60; if (remaining > 0) { - els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`; + if (els.cameraCountdown) { + els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`; + } return; } - els.cameraCountdown.textContent = 'Закрытие...'; + if (els.cameraCountdown) { + els.cameraCountdown.textContent = 'Закрытие...'; + } if (closeRequested) { return; } @@ -3736,7 +4072,9 @@ clearInterval(state.popupDismissTimer); state.popupDismissTimer = setInterval(updateCountdown, 1000); } else { - els.cameraCountdown.textContent = ''; + if (els.cameraCountdown) { + els.cameraCountdown.textContent = ''; + } clearInterval(state.popupDismissTimer); state.popupDismissTimer = null; } @@ -3759,14 +4097,26 @@ active: false, }; } - els.cameraBackdrop.classList.remove('is-open'); - els.cameraBackdrop.setAttribute('aria-hidden', 'true'); - els.cameraStage.innerHTML = ''; - els.cameraStage.appendChild(els.cameraPoster); - els.cameraStage.appendChild(els.cameraPlaceholder); - els.cameraPlaceholder.classList.add('is-visible'); - els.cameraPoster.removeAttribute('src'); - els.cameraCountdown.textContent = ''; + if (els.cameraBackdrop) { + els.cameraBackdrop.classList.remove('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'true'); + } + if (els.cameraStage) { + els.cameraStage.innerHTML = ''; + if (els.cameraPoster) { + els.cameraStage.appendChild(els.cameraPoster); + } + if (els.cameraPlaceholder) { + els.cameraStage.appendChild(els.cameraPlaceholder); + els.cameraPlaceholder.classList.add('is-visible'); + } + } + if (els.cameraPoster) { + els.cameraPoster.removeAttribute('src'); + } + if (els.cameraCountdown) { + els.cameraCountdown.textContent = ''; + } clearInterval(state.popupDismissTimer); state.popupDismissTimer = null; destroyStream(); @@ -3916,18 +4266,34 @@ 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'), + ]); + if (renderSignature !== state.debugLastRenderSignature) { + state.debugLastRenderSignature = renderSignature; + debugLog('render()', snapshotSummary(snapshot)); + } + syncLayoutState(); - renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); - renderSelectedRoom(snapshot); renderDashboard(snapshot); + renderSelectedRoom(snapshot); + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); renderPopup(snapshot); renderEntityPopup(snapshot); renderTemperatureSensorPopup(snapshot); const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + 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() { @@ -3939,6 +4305,14 @@ 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 refreshCurrentRoomLayout(entityId) { @@ -3968,6 +4342,9 @@ } const container = els.dashboardSurface; + if (!container) { + return; + } const order = new Map(roomEntities(snapshot, room.id).map((entity) => [entity.entity_id, Number(entity.order ?? 9999)])); const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]')); cards.sort((left, right) => { @@ -3984,9 +4361,13 @@ 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); - els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; - els.editModeToggle.classList.toggle('is-active', state.editMode); - els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + 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 renderSelectionOnly() { @@ -4191,6 +4572,11 @@ } function wireEvents() { + const bind = (el, type, handler, options) => { + if (!el) return; + el.addEventListener(type, handler, options); + }; + els.selectedRoomBack?.addEventListener('click', () => { if (!isMobileViewport()) return; closeEntityPopup(); @@ -4200,14 +4586,14 @@ renderSelectionOnly(); }); - els.cameraBackdrop.addEventListener('click', (event) => { + bind(els.cameraBackdrop, 'click', (event) => { if (event.target === els.cameraBackdrop) { apiPost('popup', { command: 'close' }).catch(() => {}); hidePopup({ suppressAutoOpen: true }); } }); - els.cameraModalPanel.addEventListener('click', (event) => { + bind(els.cameraModalPanel, 'click', (event) => { event.stopPropagation(); }); @@ -4224,8 +4610,8 @@ hidePopup({ suppressAutoOpen: true }); }; - els.cameraClose.addEventListener('pointerdown', closeCameraPopup); - els.cameraClose.addEventListener('click', closeCameraPopup); + bind(els.cameraClose, 'pointerdown', closeCameraPopup); + bind(els.cameraClose, 'click', closeCameraPopup); els.entityBackdrop?.addEventListener('click', (event) => { if (event.target === els.entityBackdrop) { @@ -4233,7 +4619,7 @@ } }); - els.entityModalPanel?.addEventListener('click', (event) => { + bind(els.entityModalPanel, 'click', (event) => { event.stopPropagation(); }); @@ -4247,19 +4633,19 @@ } }); - els.temperatureSensorModalPanel?.addEventListener('click', (event) => { + bind(els.temperatureSensorModalPanel, 'click', (event) => { event.stopPropagation(); }); - els.temperatureSensorClose?.addEventListener('click', () => { + bind(els.temperatureSensorClose, 'click', () => { closeTemperatureSensorPopup(); }); - els.popupDebugButton?.addEventListener('click', () => { + bind(els.popupDebugButton, 'click', () => { showDebugPopup(); }); - els.editModeToggle.addEventListener('click', async () => { + bind(els.editModeToggle, 'click', async () => { state.editMode = !state.editMode; try { await apiPost('save-settings', { @@ -4380,6 +4766,9 @@ } function startSnapshotPolling() { + if (isHaRuntime()) { + return; + } const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000)); if (state.snapshotPollTimer) { clearInterval(state.snapshotPollTimer); @@ -4396,6 +4785,9 @@ } function handleHaMessage(message) { + if (isHaRuntime()) { + return; + } if (!message || typeof message !== 'object') { return; } @@ -4519,6 +4911,11 @@ } function connectRealtime() { + if (isHaRuntime()) { + setStatus('HA native mode', 'online'); + stopSnapshotPolling(); + return; + } const connection = haConnection(); const baseUrl = connection.base_url || ''; const token = connection.token || ''; @@ -4572,6 +4969,11 @@ } async function start() { + debugLog('start()', { + ha_runtime: isHaRuntime(), + embed_mode: Boolean(bootstrap?.ui?.embed), + mode: bootstrap?.ui?.mode || 'unknown', + }); initRefs(); state.embedMode = detectEmbeddedContext(); syncLayoutState(); @@ -4582,6 +4984,19 @@ state.clockTimer = setInterval(updateClock, 1000); wireEvents(); + if (!state.haSnapshotListenerInstalled) { + state.haSnapshotListenerInstalled = true; + window.addEventListener('wall-panel-snapshot-updated', (event) => { + const snapshot = event?.detail?.snapshot || event?.detail || null; + if (!snapshot || typeof snapshot !== 'object') { + return; + } + debugLog('wall-panel-snapshot-updated', snapshotSummary(snapshot)); + state.snapshot = snapshot; + render(); + }); + } + const viewportQuery = mobileViewportQuery(); const handleViewportChange = () => { syncViewportState(); @@ -4593,8 +5008,8 @@ viewportQuery.addListener(handleViewportChange); } - const initial = window.APP_BOOTSTRAP || {}; - state.snapshot = initial; + state.snapshot = await resolveInitialSnapshot(); + debugLog('initial snapshot applied', snapshotSummary(state.snapshot || bootstrap)); render(); connectRealtime(); if (!state.snapshotPollTimer) { @@ -4602,5 +5017,9 @@ } } - document.addEventListener('DOMContentLoaded', start); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } })();