(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, 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) { 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; } 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 = [ '.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) { 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', }); if (!res.ok) { throw new Error(`Request failed: ${res.status}`); } return res.json(); } 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' }, 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; } 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(); 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() { debugLog('start()', { ha_runtime: isHaRuntime(), embed_mode: Boolean(bootstrap?.ui?.embed), mode: bootstrap?.ui?.mode || 'unknown', }); 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; } debugLog('wall-panel-snapshot-updated', snapshotSummary(snapshot)); 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(); debugLog('initial snapshot applied', snapshotSummary(state.snapshot || bootstrap)); render(); connectRealtime(); if (!state.snapshotPollTimer) { startSnapshotPolling(); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } })();