(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,
};
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));
}
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';
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);
}
});
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 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 || {};
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 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() {
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();
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) {
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 || {};
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') {
els.selectedRoomEyebrow.textContent = 'Псевдо-комната';
els.selectedRoomTitle.textContent = 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, 'неизвестная', 'неизвестных', 'неизвестных')}`);
}
els.selectedRoomMeta.textContent = summaryParts.length
? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`
: `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`;
renderSelectedRoomActions(snapshot);
return;
}
if (room.id !== 'main') {
els.selectedRoomEyebrow.textContent = 'Пространство';
els.selectedRoomTitle.textContent = room.name || 'Панель';
const entities = roomEntities(snapshot, room.id || 'main');
const activeCount = Number(room.active_entity_count ?? entities.length) || 0;
els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`;
renderSelectedRoomActions(snapshot);
return;
}
const entities = roomEntities(snapshot, room.id || 'main');
els.selectedRoomEyebrow.textContent = '';
els.selectedRoomTitle.textContent = room.name || 'Панель';
els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`;
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;
grid.innerHTML = '';
if (room.id === 'main') {
const layout = document.createElement('div');
layout.className = 'main-dashboard';
const hero = renderMainHero(snapshot);
const cards = document.createElement('div');
cards.className = 'grid-surface main-dashboard__cards';
const mainEntities = roomEntities(snapshot, 'main');
mainEntities.forEach((entity) => {
cards.appendChild(renderEntityCard(entity, { isMain: true }));
});
layout.append(hero, 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) {
els.cameraBackdrop.classList.add('is-open');
els.cameraBackdrop.setAttribute('aria-hidden', 'false');
return;
}
state.lastPopupSignature = signature;
if (!popup.active) {
hidePopup();
return;
}
els.cameraPoster.src = popup.poster_url || '';
els.cameraPoster.alt = popup.sensor_entity_id || 'camera';
els.cameraBackdrop.classList.add('is-open');
els.cameraBackdrop.setAttribute('aria-hidden', 'false');
els.cameraPlaceholder.classList.add('is-visible');
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) {
els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`;
return;
}
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 {
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,
};
}
els.cameraBackdrop.classList.remove('is-open');
els.cameraBackdrop.setAttribute('aria-hidden', 'true');
els.cameraStage.innerHTML = '';
els.cameraStage.appendChild(els.cameraPoster);
els.cameraStage.appendChild(els.cameraPlaceholder);
els.cameraPlaceholder.classList.add('is-visible');
els.cameraPoster.removeAttribute('src');
els.cameraCountdown.textContent = '';
clearInterval(state.popupDismissTimer);
state.popupDismissTimer = null;
destroyStream();
}
async function showDebugPopup() {
try {
const response = await apiPost('popup', { command: 'open' });
const snapshot = state.snapshot || bootstrap;
state.snapshot = snapshot;
applyPopupSnapshot(response.popup || {});
} catch (error) {
console.error(error);
setStatus('Ошибка popup', 'error');
}
}
function destroyStream() {
if (state.hlsInstance) {
try {
state.hlsInstance.destroy();
} catch (error) {
console.warn(error);
}
state.hlsInstance = null;
}
}
function inferStreamMode(url) {
if (!url) return 'poster';
if (url.includes('.m3u8')) return 'hls';
if (url.includes('.mp4')) return 'video';
if (url.includes('stream.html')) return 'iframe';
if (url.startsWith('http')) return 'iframe';
return 'iframe';
}
function mutedStreamUrl(url) {
if (!url) return '';
try {
const parsed = new URL(url, window.location.href);
if (parsed.pathname.includes('webrtc.html')) {
parsed.searchParams.set('media', 'video');
}
parsed.searchParams.set('mute', '1');
parsed.searchParams.set('volume', '0');
parsed.searchParams.set('autoplay', '1');
return parsed.toString();
} catch (error) {
return url;
}
}
async function loadHlsScript() {
if (window.Hls) return;
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
async function renderStream(url, mode, posterUrl) {
destroyStream();
els.cameraStage.innerHTML = '';
els.cameraStage.appendChild(els.cameraPoster);
els.cameraStage.appendChild(els.cameraPlaceholder);
els.cameraPlaceholder.classList.add('is-visible');
els.cameraPoster.src = posterUrl || '';
if (!url) {
return;
}
if (mode === 'iframe') {
const iframe = document.createElement('iframe');
iframe.classList.add('is-loading');
iframe.src = mutedStreamUrl(url);
iframe.allow = 'autoplay; fullscreen; picture-in-picture';
iframe.referrerPolicy = 'no-referrer';
iframe.addEventListener('load', () => {
iframe.classList.add('is-ready');
els.cameraPlaceholder.classList.remove('is-visible');
});
iframe.addEventListener('error', () => els.cameraPlaceholder.classList.add('is-visible'));
els.cameraStage.appendChild(iframe);
return;
}
const video = document.createElement('video');
video.classList.add('is-loading');
video.autoplay = true;
video.muted = true;
video.defaultMuted = true;
video.volume = 0;
video.playsInline = true;
video.setAttribute('muted', '');
video.setAttribute('playsinline', '');
video.setAttribute('webkit-playsinline', '');
video.controls = false;
video.poster = posterUrl || '';
video.preload = 'metadata';
video.addEventListener('loadeddata', () => {
video.classList.add('is-ready');
els.cameraPlaceholder.classList.remove('is-visible');
});
video.addEventListener('canplay', () => {
video.classList.add('is-ready');
els.cameraPlaceholder.classList.remove('is-visible');
});
video.addEventListener('error', () => els.cameraPlaceholder.classList.add('is-visible'));
els.cameraStage.appendChild(video);
if (mode === 'hls') {
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
return;
}
try {
await loadHlsScript();
if (window.Hls) {
const hls = new window.Hls({
lowLatencyMode: true,
});
hls.loadSource(url);
hls.attachMedia(video);
state.hlsInstance = hls;
hls.on(window.Hls.Events.MANIFEST_PARSED, () => els.cameraPlaceholder.classList.remove('is-visible'));
hls.on(window.Hls.Events.ERROR, () => els.cameraPlaceholder.classList.add('is-visible'));
return;
}
} catch (error) {
console.warn('HLS load failed', error);
}
}
video.src = url;
}
function render() {
const snapshot = state.snapshot || bootstrap;
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) {
return;
}
syncLayoutState();
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
renderSelectedRoom(snapshot);
renderDashboard(snapshot);
renderPopup(snapshot);
renderEntityPopup(snapshot);
renderTemperatureSensorPopup(snapshot);
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
els.editModeToggle.classList.toggle('is-active', state.editMode);
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
}
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);
}
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;
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);
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
els.editModeToggle.classList.toggle('is-active', state.editMode);
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
}
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() {
els.selectedRoomBack?.addEventListener('click', () => {
if (!isMobileViewport()) return;
closeEntityPopup();
setMobileView('spaces');
syncLayoutState();
renderSidebarOnly();
renderSelectionOnly();
});
els.cameraBackdrop.addEventListener('click', (event) => {
if (event.target === els.cameraBackdrop) {
apiPost('popup', { command: 'close' }).catch(() => {});
hidePopup({ suppressAutoOpen: true });
}
});
els.cameraModalPanel.addEventListener('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 });
};
els.cameraClose.addEventListener('pointerdown', closeCameraPopup);
els.cameraClose.addEventListener('click', closeCameraPopup);
els.entityBackdrop?.addEventListener('click', (event) => {
if (event.target === els.entityBackdrop) {
closeEntityPopup();
}
});
els.entityModalPanel?.addEventListener('click', (event) => {
event.stopPropagation();
});
els.entityClose?.addEventListener('click', () => {
closeEntityPopup();
});
els.temperatureSensorBackdrop?.addEventListener('click', (event) => {
if (event.target === els.temperatureSensorBackdrop) {
closeTemperatureSensorPopup();
}
});
els.temperatureSensorModalPanel?.addEventListener('click', (event) => {
event.stopPropagation();
});
els.temperatureSensorClose?.addEventListener('click', () => {
closeTemperatureSensorPopup();
});
els.popupDebugButton?.addEventListener('click', () => {
showDebugPopup();
});
els.editModeToggle.addEventListener('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() {
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 (!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() {
const connection = haConnection();
const baseUrl = connection.base_url || '';
const token = connection.token || '';
const wsUrl = haWsUrl(baseUrl);
if (!wsUrl || !token) {
state.haSocketState = 'unavailable';
setStatus('Online', 'online');
startSnapshotPolling();
return;
}
if (window.location.protocol === 'https:' && wsUrl.startsWith('ws://')) {
state.haSocketState = 'unavailable';
setStatus('Polling mode', 'online');
startSnapshotPolling();
return;
}
stopSnapshotPolling();
stopRealtime();
state.haSocketState = 'connecting';
setStatus('Connecting WS...', 'loading');
try {
const socket = new WebSocket(wsUrl);
state.haSocket = socket;
socket.onopen = () => {
state.haSocketState = 'open';
};
socket.onmessage = (event) => {
try {
handleHaMessage(JSON.parse(event.data));
} catch (error) {
console.warn(error);
}
};
socket.onerror = () => {
setStatus('WS error', 'error');
startSnapshotPolling();
};
socket.onclose = () => {
state.haSocket = null;
if (state.haSocketState !== 'disconnected') {
scheduleReconnect();
}
};
} catch (error) {
console.error(error);
scheduleReconnect();
}
}
async function start() {
initRefs();
state.embedMode = detectEmbeddedContext();
syncLayoutState();
syncViewportState();
bindPressFeedback();
updateClock();
clearInterval(state.clockTimer);
state.clockTimer = setInterval(updateClock, 1000);
wireEvents();
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);
}
const initial = window.APP_BOOTSTRAP || {};
state.snapshot = initial;
render();
connectRealtime();
if (!state.snapshotPollTimer) {
startSnapshotPolling();
}
}
document.addEventListener('DOMContentLoaded', start);
})();