(function () { const bootstrap = window.APP_BOOTSTRAP || {}; const MOBILE_BREAKPOINT = 920; const state = { snapshot: bootstrap, 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, }, lastPopupSignature: '', lastEntityPopupSignature: '', roomDrag: null, confirmResolver: null, haSocket: null, haSocketState: 'disconnected', haReconnectTimer: null, haReconnectDelay: 1000, haSubscribeId: 1, roomSelectionToken: 0, }; const els = {}; function $(id) { return document.getElementById(id); } function q(sel, root = document) { return root.querySelector(sel); } function qa(sel, root = document) { return Array.from(root.querySelectorAll(sel)); } function mobileViewportQuery() { return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); } function isMobileViewport() { return Boolean(state.isMobileViewport); } function isMobileRoomView() { return isMobileViewport() && state.mobileView === 'room'; } 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(); } 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'; 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'; 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('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'; 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('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 = {}) { 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); } }); return url.toString(); } async function apiGet(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 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') { return apiGet('snapshot', { space_id: roomId || 'main' }); } async function loadSnapshot(roomId = state.selectedRoomId || 'main') { const snapshot = await fetchSnapshot(roomId); state.snapshot = snapshot; return snapshot; } function currentRoom() { const snapshot = state.snapshot || {}; 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 || [] } : 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 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 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 syncLayoutState() { if (!els.appShell) return; const mobile = isMobileViewport(); document.body.classList.toggle('is-mobile-ui', mobile); els.appShell.classList.toggle('is-mobile', mobile); els.appShell.classList.toggle('is-desktop', !mobile); 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 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); } const container = els.dashboardSurface; const existing = q(`[data-entity-id="${CSS.escape(entityId)}"]`, container); const entity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); const shouldShow = entity && entity.visible !== false; if (existing && !shouldShow) { existing.remove(); return true; } if (!existing || !shouldShow) return false; const nextCard = renderEntityCard(entity); existing.replaceWith(nextCard); 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.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)); } 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.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 || [], } : 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; } 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() { return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {}; } function haWsUrl(baseUrl) { 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(); 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 currentPosition = Number(entity.attributes?.current_position); const initialValue = Number.isFinite(currentPosition) ? Math.max(0, Math.min(100, currentPosition)) : (String(entity.state || '').toLowerCase() === 'open' ? 100 : 0); 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 progress = document.createElement('div'); progress.className = 'entity-modal__cover-track'; const fill = document.createElement('div'); fill.className = 'entity-modal__cover-fill'; fill.style.height = `${initialValue}%`; fill.style.top = '0'; fill.style.width = '100%'; progress.appendChild(fill); const handle = document.createElement('div'); handle.className = 'entity-modal__cover-handle'; progress.appendChild(handle); const syncValue = (nextValue) => { fill.style.height = `${nextValue}%`; handle.style.top = nextValue <= 0 ? 'calc(100% - 10px)' : `calc(${nextValue}% - 10px)`; value.textContent = `${nextValue}%`; }; 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); handleCoverPosition(entity, 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); }; 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 }); }); rail.append(valueRow, progress); const buttons = document.createElement('div'); buttons.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', () => handleEntityService(entity, 'open')); const stopBtn = createButton('Стоп', null, 'mdi:stop', 'mushroom-button mushroom-button--small mushroom-button--square'); stopBtn.addEventListener('click', () => handleEntityService(entity, 'stop')); const closeBtn = createButton('Закрыть', null, 'mdi:arrow-down', 'mushroom-button mushroom-button--small mushroom-button--square'); closeBtn.addEventListener('click', () => handleEntityService(entity, 'close')); buttons.append(openBtn, stopBtn, closeBtn); wrap.append(rail, buttons); 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 = `