4462 lines
152 KiB
JavaScript
Executable File
4462 lines
152 KiB
JavaScript
Executable File
(function () {
|
||
const bootstrap = window.APP_BOOTSTRAP || {};
|
||
const MOBILE_BREAKPOINT = 920;
|
||
const state = {
|
||
snapshot: bootstrap,
|
||
selectedRoomId: 'main',
|
||
isMobileViewport: false,
|
||
mobileView: 'spaces',
|
||
editMode: Boolean(bootstrap?.settings?.edit_mode),
|
||
clockTimer: null,
|
||
hlsInstance: null,
|
||
popupDismissTimer: null,
|
||
popupAutoOpenBlockedUntil: 0,
|
||
roomAutoReturnTimer: null,
|
||
mainBoilerHistory: {
|
||
entityId: null,
|
||
points: [],
|
||
loadedAt: 0,
|
||
loading: false,
|
||
error: null,
|
||
promise: null,
|
||
},
|
||
entityPopup: {
|
||
active: false,
|
||
entityId: null,
|
||
},
|
||
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,
|
||
};
|
||
|
||
const els = {};
|
||
|
||
function $(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function q(sel, root = document) {
|
||
return root.querySelector(sel);
|
||
}
|
||
|
||
function qa(sel, root = document) {
|
||
return Array.from(root.querySelectorAll(sel));
|
||
}
|
||
|
||
function mobileViewportQuery() {
|
||
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
|
||
}
|
||
|
||
function isMobileViewport() {
|
||
return Boolean(state.isMobileViewport);
|
||
}
|
||
|
||
function isMobileRoomView() {
|
||
return isMobileViewport() && state.mobileView === 'room';
|
||
}
|
||
|
||
function setMobileView(nextView) {
|
||
if (!isMobileViewport()) {
|
||
state.mobileView = 'room';
|
||
return;
|
||
}
|
||
|
||
state.mobileView = nextView === 'room' ? 'room' : 'spaces';
|
||
}
|
||
|
||
function syncViewportState() {
|
||
const query = mobileViewportQuery();
|
||
const nextIsMobile = Boolean(query.matches);
|
||
const changed = nextIsMobile !== state.isMobileViewport;
|
||
|
||
state.isMobileViewport = nextIsMobile;
|
||
if (nextIsMobile) {
|
||
state.mobileView = changed ? 'spaces' : (state.mobileView || 'spaces');
|
||
} else {
|
||
state.mobileView = 'room';
|
||
clearRoomAutoReturnTimer();
|
||
scheduleRoomAutoReturn(state.selectedRoomId || 'main');
|
||
}
|
||
|
||
if (nextIsMobile) {
|
||
clearRoomAutoReturnTimer();
|
||
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);
|
||
}
|
||
});
|
||
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 = `
|
||
<div class="temperature-sensor-modal__current-label">Текущий выбор</div>
|
||
<div class="temperature-sensor-modal__current-value">${selectedEntity ? esc(selectedEntity.name || selectedEntity.entity_id) : 'Автоматически'}</div>
|
||
`;
|
||
els.temperatureSensorBody.appendChild(current);
|
||
|
||
const resetButton = document.createElement('button');
|
||
resetButton.type = 'button';
|
||
resetButton.className = `temperature-sensor-modal__option ${!selectedId ? 'is-active' : ''}`;
|
||
resetButton.innerHTML = `
|
||
<span class="temperature-sensor-modal__option-main">
|
||
<span class="temperature-sensor-modal__option-name">Автоматически</span>
|
||
<span class="temperature-sensor-modal__option-meta">Использовать первый подходящий датчик в комнате</span>
|
||
</span>
|
||
<span class="temperature-sensor-modal__option-value">${!selectedId ? 'Выбрано' : 'Сбросить'}</span>
|
||
`;
|
||
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 = `
|
||
<span class="temperature-sensor-modal__option-main">
|
||
<span class="temperature-sensor-modal__option-name">${esc(label.name)}</span>
|
||
<span class="temperature-sensor-modal__option-meta">${esc(label.meta)}</span>
|
||
</span>
|
||
<span class="temperature-sensor-modal__option-value">${esc(label.valueText)}</span>
|
||
`;
|
||
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();
|
||
document.body.classList.toggle('is-mobile-ui', mobile);
|
||
els.appShell.classList.toggle('is-mobile', mobile);
|
||
els.appShell.classList.toggle('is-desktop', !mobile);
|
||
els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room');
|
||
els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room');
|
||
|
||
if (els.selectedRoomBack) {
|
||
els.selectedRoomBack.hidden = !isMobileRoomView();
|
||
}
|
||
}
|
||
|
||
function normalizePositionValue(value) {
|
||
const next = Number(value);
|
||
if (!Number.isFinite(next)) return null;
|
||
return Math.max(0, Math.min(100, Math.round(next)));
|
||
}
|
||
|
||
function shouldShowMainEntity(entity) {
|
||
if (!entity) return false;
|
||
const domain = String(entity.domain || entity.entity_id?.split('.')?.[0] || '').toLowerCase();
|
||
const state = String(entity.state || '').toLowerCase();
|
||
const isAuto = Boolean(entity.is_auto);
|
||
const isHidden = Boolean(entity.is_hidden);
|
||
const isDoorContact = Boolean(entity.is_door_contact);
|
||
if (!isAuto || isHidden) return false;
|
||
if (!['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain)) return false;
|
||
if (domain === 'binary_sensor' && !isDoorContact) return false;
|
||
return domain === 'cover'
|
||
? ['open', 'opening', 'closing'].includes(state)
|
||
: domain === 'binary_sensor'
|
||
? ['on', 'open'].includes(state)
|
||
: ['on', 'cool', 'heat', 'heating', 'cooling'].includes(state);
|
||
}
|
||
|
||
function mainCardsContainer() {
|
||
return q('.main-dashboard__cards', els.dashboardSurface);
|
||
}
|
||
|
||
function currentDashboardCardsContainer() {
|
||
const snapshot = state.snapshot || bootstrap;
|
||
const room = snapshot.selected_space || snapshot.selected_room || {};
|
||
if (room.id === 'main') {
|
||
return mainCardsContainer();
|
||
}
|
||
return els.dashboardSurface;
|
||
}
|
||
|
||
function findRenderedCard(entityId) {
|
||
if (!entityId) return null;
|
||
return q(`[data-entity-id="${CSS.escape(entityId)}"]`, els.dashboardSurface);
|
||
}
|
||
|
||
function sortMainCardsBySnapshot(container) {
|
||
const snapshot = state.snapshot || {};
|
||
const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((entity) => entity.entity_id);
|
||
const order = new Map(orderedMainIds.map((entityId, index) => [entityId, index]));
|
||
const cards = Array.from(container?.querySelectorAll('.grid-card[data-entity-id]') || []);
|
||
cards.sort((left, right) => {
|
||
const leftId = left.dataset.entityId || '';
|
||
const rightId = right.dataset.entityId || '';
|
||
const leftOrder = order.has(leftId) ? order.get(leftId) : Number.MAX_SAFE_INTEGER;
|
||
const rightOrder = order.has(rightId) ? order.get(rightId) : Number.MAX_SAFE_INTEGER;
|
||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||
return leftId.localeCompare(rightId, 'ru');
|
||
});
|
||
cards.forEach((card) => container.appendChild(card));
|
||
}
|
||
|
||
function updateMainWeatherCard() {
|
||
const snapshot = state.snapshot || {};
|
||
const hero = q('.main-dashboard__hero', els.dashboardSurface);
|
||
if (!hero) return false;
|
||
const next = renderMainHero(snapshot);
|
||
hero.replaceWith(next);
|
||
return true;
|
||
}
|
||
|
||
function updateMainEntityCard(entityId) {
|
||
const snapshot = state.snapshot || {};
|
||
const container = mainCardsContainer();
|
||
if (!container) return false;
|
||
|
||
const entity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId);
|
||
const existing = q(`[data-entity-id="${CSS.escape(entityId)}"]`, container);
|
||
const shouldShow = shouldShowMainEntity(entity);
|
||
if (existing && !shouldShow) {
|
||
existing.remove();
|
||
return true;
|
||
}
|
||
if (!shouldShow) {
|
||
return false;
|
||
}
|
||
|
||
const nextCard = renderEntityCard(entity, { isMain: true });
|
||
if (existing) {
|
||
existing.replaceWith(nextCard);
|
||
return true;
|
||
}
|
||
|
||
const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((item) => item.entity_id);
|
||
const nextIndex = orderedMainIds.indexOf(entityId);
|
||
const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]'));
|
||
for (const card of cards) {
|
||
const cardId = card.dataset.entityId;
|
||
const cardIndex = orderedMainIds.indexOf(cardId);
|
||
if (cardIndex > nextIndex && nextIndex !== -1) {
|
||
card.before(nextCard);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
container.appendChild(nextCard);
|
||
return true;
|
||
}
|
||
|
||
function updateRoomEntityCard(entityId) {
|
||
const snapshot = state.snapshot || {};
|
||
const room = snapshot.selected_space || snapshot.selected_room || {};
|
||
if (room.id === 'main') {
|
||
return updateMainEntityCard(entityId);
|
||
}
|
||
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 = `<span class="weather-card__row-label">${label}</span><span class="weather-card__row-value">${value}</span>`;
|
||
rows.appendChild(row);
|
||
});
|
||
|
||
inner.appendChild(title);
|
||
inner.appendChild(rows);
|
||
card.appendChild(inner);
|
||
return card;
|
||
}
|
||
|
||
function mainWeatherActions(snapshot = state.snapshot || bootstrap) {
|
||
const fromSnapshot = snapshot?.settings?.main_weather_actions;
|
||
const fromBootstrap = bootstrap?.settings?.main_weather_actions;
|
||
const actions = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : [];
|
||
return actions.filter((action) => action && String(action.entity_id || '').trim() !== '');
|
||
}
|
||
|
||
function mainWeatherActionEntity(snapshot, action) {
|
||
const entityId = String(action?.state_entity_id || action?.entity_id || '').trim();
|
||
if (!entityId) return null;
|
||
return getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId) || null;
|
||
}
|
||
|
||
function mainWeatherActionIsActive(snapshot, action) {
|
||
const entity = mainWeatherActionEntity(snapshot, action);
|
||
if (!entity) return false;
|
||
const current = String(entity.state ?? '').trim().toLowerCase();
|
||
const compareValue = action?.active_value ?? action?.value;
|
||
if (compareValue === null || compareValue === undefined || String(compareValue).trim() === '') {
|
||
return !['off', 'false', '0', 'unknown', 'unavailable', 'idle'].includes(current);
|
||
}
|
||
return current === String(compareValue).trim().toLowerCase();
|
||
}
|
||
|
||
function mainWeatherActionAffectsEntity(entityId) {
|
||
const nextEntityId = String(entityId || '').trim();
|
||
if (!nextEntityId) return false;
|
||
return mainWeatherActions().some((action) => {
|
||
const stateEntityId = String(action.state_entity_id || action.entity_id || '').trim();
|
||
return stateEntityId === nextEntityId || String(action.entity_id || '').trim() === nextEntityId;
|
||
});
|
||
}
|
||
|
||
function mainPrintAffectsEntity(entityId) {
|
||
const nextEntityId = String(entityId || '').trim();
|
||
if (!nextEntityId) return false;
|
||
const config = mainPrintConfig();
|
||
if (!config) return false;
|
||
return [
|
||
config.current_stage_entity_id,
|
||
config.print_progress_entity_id,
|
||
config.start_time_entity_id,
|
||
config.end_time_entity_id,
|
||
].includes(nextEntityId);
|
||
}
|
||
|
||
function mainWeatherActionLabel(action, active) {
|
||
const value = action?.value;
|
||
const label = active
|
||
? (action?.label_active ?? action?.active_label ?? '')
|
||
: (action?.label_inactive ?? action?.inactive_label ?? '');
|
||
if (String(label || '').trim() !== '') {
|
||
return String(label);
|
||
}
|
||
if (value !== null && value !== undefined && String(value).trim() !== '') {
|
||
return active ? `${value}°` : `Установить ${value}°`;
|
||
}
|
||
return active ? 'Активно' : 'Включить';
|
||
}
|
||
|
||
function renderMainWeatherActions(snapshot) {
|
||
const actions = mainWeatherActions(snapshot);
|
||
if (!actions.length) return null;
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'main-dashboard__actions';
|
||
|
||
actions.forEach((action) => {
|
||
const active = mainWeatherActionIsActive(snapshot, action);
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = `main-quick-action ${active ? 'is-active' : ''}`;
|
||
btn.dataset.entityId = String(action.entity_id || '');
|
||
btn.dataset.stateEntityId = String(action.state_entity_id || action.entity_id || '');
|
||
btn.dataset.command = String(action.command || 'set_temperature');
|
||
btn.dataset.value = action.value !== undefined && action.value !== null ? String(action.value) : '';
|
||
btn.style.setProperty('--quick-action-bg', active ? (action.active_color || '#4caf50') : (action.inactive_color || '#c8e6c9'));
|
||
btn.style.setProperty('--quick-action-color', active ? (action.active_text_color || 'white') : (action.inactive_text_color || 'black'));
|
||
btn.style.setProperty('--quick-action-icon-color', active ? (action.active_icon_color || 'white') : (action.inactive_icon_color || 'gray'));
|
||
btn.style.setProperty('--icon-node-img-filter', active
|
||
? 'brightness(0) saturate(100%) invert(100%)'
|
||
: 'brightness(0) saturate(100%) invert(42%)');
|
||
|
||
const icon = document.createElement('div');
|
||
icon.className = 'main-quick-action__icon';
|
||
icon.appendChild(createIconElement(action.icon || 'mdi:thermometer'));
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'main-quick-action__label';
|
||
label.textContent = mainWeatherActionLabel(action, active);
|
||
|
||
btn.append(icon, label);
|
||
btn.addEventListener('click', () => {
|
||
handleMainWeatherAction(action);
|
||
});
|
||
btn.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
handleMainWeatherAction(action);
|
||
}
|
||
});
|
||
wrap.appendChild(btn);
|
||
});
|
||
|
||
return wrap;
|
||
}
|
||
|
||
function renderMainPrintCard(snapshot = state.snapshot || bootstrap) {
|
||
const info = mainPrintState(snapshot);
|
||
if (!info) return null;
|
||
|
||
const card = document.createElement('article');
|
||
card.className = 'main-print-strip';
|
||
|
||
const inner = document.createElement('div');
|
||
inner.className = 'main-print-strip__inner';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'main-print-strip__header';
|
||
|
||
const badge = document.createElement('div');
|
||
badge.className = 'main-print-strip__badge';
|
||
badge.textContent = `${Math.max(0, Math.round(info.progress ?? 0))}%`;
|
||
|
||
header.appendChild(badge);
|
||
|
||
const progress = document.createElement('div');
|
||
progress.className = 'main-print-strip__progress';
|
||
const fill = document.createElement('div');
|
||
fill.className = 'main-print-strip__progress-fill';
|
||
fill.style.width = `${Math.max(0, Math.min(100, Number(info.progress ?? 0) || 0))}%`;
|
||
progress.appendChild(fill);
|
||
|
||
const footer = document.createElement('div');
|
||
footer.className = 'main-print-strip__footer';
|
||
|
||
const remaining = document.createElement('div');
|
||
remaining.className = 'main-print-strip__remaining';
|
||
remaining.textContent = info.remainingSeconds !== null
|
||
? formatDurationText(info.remainingSeconds)
|
||
: '—';
|
||
|
||
footer.appendChild(remaining);
|
||
inner.append(header, progress, footer);
|
||
card.appendChild(inner);
|
||
return card;
|
||
}
|
||
|
||
function mainBoilerConfig(snapshot = state.snapshot || bootstrap) {
|
||
const fromSnapshot = snapshot?.settings?.main_boiler;
|
||
const fromBootstrap = bootstrap?.settings?.main_boiler;
|
||
const config = (fromSnapshot && typeof fromSnapshot === 'object')
|
||
? fromSnapshot
|
||
: ((fromBootstrap && typeof fromBootstrap === 'object') ? fromBootstrap : null);
|
||
if (!config) return null;
|
||
|
||
const sensorEntityId = String(config.sensor_entity_id || '').trim();
|
||
if (!sensorEntityId) return null;
|
||
|
||
return {
|
||
title: String(config.title || 'Бойлер'),
|
||
sensor_entity_id: sensorEntityId,
|
||
history_hours: Math.max(1, Number(config.history_hours || 24) || 24),
|
||
};
|
||
}
|
||
|
||
function mainPrintConfig(snapshot = state.snapshot || bootstrap) {
|
||
const fromSnapshot = snapshot?.settings?.main_print;
|
||
const fromBootstrap = bootstrap?.settings?.main_print;
|
||
const config = (fromSnapshot && typeof fromSnapshot === 'object')
|
||
? fromSnapshot
|
||
: ((fromBootstrap && typeof fromBootstrap === 'object') ? fromBootstrap : null);
|
||
if (!config) return null;
|
||
|
||
const currentStageEntityId = String(config.current_stage_entity_id || '').trim();
|
||
const printProgressEntityId = String(config.print_progress_entity_id || '').trim();
|
||
const startTimeEntityId = String(config.start_time_entity_id || '').trim();
|
||
const endTimeEntityId = String(config.end_time_entity_id || '').trim();
|
||
if (!currentStageEntityId || !printProgressEntityId || !startTimeEntityId || !endTimeEntityId) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
title: String(config.title || '').trim(),
|
||
current_stage_entity_id: currentStageEntityId,
|
||
print_progress_entity_id: printProgressEntityId,
|
||
start_time_entity_id: startTimeEntityId,
|
||
end_time_entity_id: endTimeEntityId,
|
||
};
|
||
}
|
||
|
||
function parseDateValue(value) {
|
||
const text = String(value ?? '').trim();
|
||
if (!text) return null;
|
||
|
||
const numeric = Number(text);
|
||
if (Number.isFinite(numeric) && String(Math.trunc(numeric)) === text.replace(/\.0+$/, '')) {
|
||
return numeric > 1e12 ? numeric : numeric * 1000;
|
||
}
|
||
|
||
const parsed = Date.parse(text);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function formatDurationText(seconds) {
|
||
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
||
const hours = Math.floor(total / 3600);
|
||
const minutes = Math.floor((total % 3600) / 60);
|
||
const secs = total % 60;
|
||
|
||
if (hours > 0) {
|
||
return secs > 0 ? `${hours}ч ${minutes}м ${secs}с` : `${hours}ч ${minutes}м`;
|
||
}
|
||
if (minutes > 0) {
|
||
return secs > 0 ? `${minutes}м ${secs}с` : `${minutes}м`;
|
||
}
|
||
return `${secs}с`;
|
||
}
|
||
|
||
function mainPrintState(snapshot = state.snapshot || bootstrap) {
|
||
const config = mainPrintConfig(snapshot);
|
||
if (!config) return null;
|
||
|
||
const stage = getEntityFromSnapshot(snapshot, config.current_stage_entity_id)
|
||
|| getEntityDefinition(snapshot, config.current_stage_entity_id);
|
||
if (!stage || String(stage.state || '').toLowerCase() !== 'printing') {
|
||
return null;
|
||
}
|
||
|
||
const progressEntity = getEntityFromSnapshot(snapshot, config.print_progress_entity_id)
|
||
|| getEntityDefinition(snapshot, config.print_progress_entity_id);
|
||
const startEntity = getEntityFromSnapshot(snapshot, config.start_time_entity_id)
|
||
|| getEntityDefinition(snapshot, config.start_time_entity_id);
|
||
const endEntity = getEntityFromSnapshot(snapshot, config.end_time_entity_id)
|
||
|| getEntityDefinition(snapshot, config.end_time_entity_id);
|
||
|
||
const progressValueRaw = Number(String(progressEntity?.state ?? '').replace(',', '.'));
|
||
const progress = Number.isFinite(progressValueRaw)
|
||
? Math.max(0, Math.min(100, progressValueRaw))
|
||
: null;
|
||
|
||
const startTs = parseDateValue(startEntity?.state ?? startEntity?.attributes?.value ?? startEntity?.attributes?.timestamp);
|
||
const endTs = parseDateValue(endEntity?.state ?? endEntity?.attributes?.value ?? endEntity?.attributes?.timestamp);
|
||
const nowTs = Date.now();
|
||
let remainingSeconds = null;
|
||
|
||
if (startTs !== null && endTs !== null && endTs > startTs) {
|
||
remainingSeconds = Math.max(0, (endTs - nowTs) / 1000);
|
||
} else if (endTs !== null) {
|
||
remainingSeconds = Math.max(0, (endTs - nowTs) / 1000);
|
||
}
|
||
|
||
return {
|
||
title: String(config.title || '').trim(),
|
||
stage: String(stage.state || 'printing'),
|
||
progress,
|
||
remainingSeconds,
|
||
};
|
||
}
|
||
|
||
function updateMainPrintStrip(snapshot = state.snapshot || bootstrap) {
|
||
if (!els.mainPrintStripSlot) return;
|
||
const room = snapshot.selected_space || snapshot.selected_room || {};
|
||
if (room.id !== 'main') {
|
||
els.mainPrintStripSlot.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
els.mainPrintStripSlot.innerHTML = '';
|
||
const printStrip = renderMainPrintCard(snapshot);
|
||
if (printStrip) {
|
||
els.mainPrintStripSlot.appendChild(printStrip);
|
||
}
|
||
}
|
||
|
||
function formatTemperatureValue(value) {
|
||
const next = Number(String(value ?? '').replace(',', '.'));
|
||
if (!Number.isFinite(next)) {
|
||
return null;
|
||
}
|
||
|
||
const digits = Math.abs(next % 1) > 0.05 ? 1 : 0;
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
}).format(next);
|
||
}
|
||
|
||
function normalizeHistoryPoints(payload, fallbackValue = null) {
|
||
const raw = Array.isArray(payload?.history) ? payload.history : payload;
|
||
const groups = Array.isArray(raw) && raw.length > 0 && Array.isArray(raw[0]) ? raw : [raw];
|
||
const points = [];
|
||
|
||
groups.forEach((group) => {
|
||
if (!Array.isArray(group)) return;
|
||
group.forEach((entry) => {
|
||
if (!entry || typeof entry !== 'object') return;
|
||
const rawValue = entry.state ?? entry.value ?? null;
|
||
const numericValue = Number(String(rawValue ?? '').replace(',', '.'));
|
||
if (!Number.isFinite(numericValue)) return;
|
||
const timestamp = Date.parse(entry.last_changed || entry.last_updated || '');
|
||
if (!Number.isFinite(timestamp)) return;
|
||
points.push({
|
||
timestamp,
|
||
value: numericValue,
|
||
});
|
||
});
|
||
});
|
||
|
||
points.sort((left, right) => left.timestamp - right.timestamp);
|
||
|
||
const deduped = [];
|
||
for (const point of points) {
|
||
const last = deduped[deduped.length - 1];
|
||
if (last && last.timestamp === point.timestamp) {
|
||
deduped[deduped.length - 1] = point;
|
||
continue;
|
||
}
|
||
deduped.push(point);
|
||
}
|
||
|
||
if (!deduped.length) {
|
||
const fallbackNumeric = Number(String(fallbackValue ?? '').replace(',', '.'));
|
||
if (Number.isFinite(fallbackNumeric)) {
|
||
const now = Date.now();
|
||
return [
|
||
{ timestamp: now - 60_000, value: fallbackNumeric },
|
||
{ timestamp: now, value: fallbackNumeric },
|
||
];
|
||
}
|
||
}
|
||
|
||
return deduped;
|
||
}
|
||
|
||
function boilerHistoryState(entityId) {
|
||
const history = state.mainBoilerHistory || {};
|
||
if (history.entityId !== entityId) {
|
||
return [];
|
||
}
|
||
return Array.isArray(history.points) ? history.points : [];
|
||
}
|
||
|
||
function renderBoilerSparkline(points) {
|
||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.setAttribute('viewBox', '0 0 240 72');
|
||
svg.setAttribute('preserveAspectRatio', 'none');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
svg.classList.add('main-boiler-card__chart');
|
||
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
const defs = document.createElementNS(ns, 'defs');
|
||
const gradient = document.createElementNS(ns, 'linearGradient');
|
||
gradient.setAttribute('id', 'boiler-chart-fill');
|
||
gradient.setAttribute('x1', '0%');
|
||
gradient.setAttribute('x2', '0%');
|
||
gradient.setAttribute('y1', '0%');
|
||
gradient.setAttribute('y2', '100%');
|
||
|
||
const stopTop = document.createElementNS(ns, 'stop');
|
||
stopTop.setAttribute('offset', '0%');
|
||
stopTop.setAttribute('stop-color', 'rgba(255, 186, 92, 0.38)');
|
||
const stopBottom = document.createElementNS(ns, 'stop');
|
||
stopBottom.setAttribute('offset', '100%');
|
||
stopBottom.setAttribute('stop-color', 'rgba(255, 186, 92, 0.02)');
|
||
gradient.append(stopTop, stopBottom);
|
||
defs.appendChild(gradient);
|
||
svg.appendChild(defs);
|
||
|
||
if (!Array.isArray(points) || points.length === 0) {
|
||
const line = document.createElementNS(ns, 'path');
|
||
line.setAttribute('d', 'M 0 48 L 240 48');
|
||
line.setAttribute('fill', 'none');
|
||
line.setAttribute('stroke', 'rgba(255, 186, 92, 0.28)');
|
||
line.setAttribute('stroke-width', '2');
|
||
svg.appendChild(line);
|
||
return svg;
|
||
}
|
||
|
||
const values = points.map((point) => Number(point.value)).filter((value) => Number.isFinite(value));
|
||
if (!values.length) {
|
||
return svg;
|
||
}
|
||
|
||
let min = Math.min(...values);
|
||
let max = Math.max(...values);
|
||
if (min === max) {
|
||
min -= 0.5;
|
||
max += 0.5;
|
||
} else {
|
||
const padding = Math.max((max - min) * 0.2, 0.3);
|
||
min -= padding;
|
||
max += padding;
|
||
}
|
||
|
||
const width = 240;
|
||
const height = 72;
|
||
const chartTop = 8;
|
||
const chartBottom = 60;
|
||
const chartHeight = chartBottom - chartTop;
|
||
const span = max - min || 1;
|
||
const linePoints = points.map((point, index) => {
|
||
const ratioX = points.length === 1 ? 0.5 : index / (points.length - 1);
|
||
const ratioY = (Number(point.value) - min) / span;
|
||
return {
|
||
x: ratioX * width,
|
||
y: chartBottom - (ratioY * chartHeight),
|
||
};
|
||
});
|
||
|
||
const linePath = linePoints
|
||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
|
||
.join(' ');
|
||
|
||
const areaPath = [
|
||
`M 0 ${chartBottom}`,
|
||
`L ${linePoints[0].x.toFixed(2)} ${linePoints[0].y.toFixed(2)}`,
|
||
...linePoints.slice(1).map((point) => `L ${point.x.toFixed(2)} ${point.y.toFixed(2)}`),
|
||
`L ${width} ${chartBottom}`,
|
||
'Z',
|
||
].join(' ');
|
||
|
||
const area = document.createElementNS(ns, 'path');
|
||
area.setAttribute('d', areaPath);
|
||
area.setAttribute('fill', 'url(#boiler-chart-fill)');
|
||
area.setAttribute('stroke', 'none');
|
||
svg.appendChild(area);
|
||
|
||
const line = document.createElementNS(ns, 'path');
|
||
line.setAttribute('d', linePath);
|
||
line.setAttribute('fill', 'none');
|
||
line.setAttribute('stroke', '#ffba5c');
|
||
line.setAttribute('stroke-width', '2.4');
|
||
line.setAttribute('stroke-linecap', 'round');
|
||
line.setAttribute('stroke-linejoin', 'round');
|
||
svg.appendChild(line);
|
||
|
||
const lastPoint = linePoints[linePoints.length - 1];
|
||
const dot = document.createElementNS(ns, 'circle');
|
||
dot.setAttribute('cx', lastPoint.x.toFixed(2));
|
||
dot.setAttribute('cy', lastPoint.y.toFixed(2));
|
||
dot.setAttribute('r', '3.2');
|
||
dot.setAttribute('fill', '#ffba5c');
|
||
dot.setAttribute('stroke', 'rgba(24, 25, 29, 0.95)');
|
||
dot.setAttribute('stroke-width', '2');
|
||
svg.appendChild(dot);
|
||
|
||
return svg;
|
||
}
|
||
|
||
function scheduleMainBoilerHistoryLoad(snapshot = state.snapshot || bootstrap, force = false) {
|
||
const config = mainBoilerConfig(snapshot);
|
||
if (!config) {
|
||
return Promise.resolve([]);
|
||
}
|
||
|
||
const cache = state.mainBoilerHistory || {};
|
||
const isSameEntity = cache.entityId === config.sensor_entity_id;
|
||
const isFresh = isSameEntity && Array.isArray(cache.points) && cache.points.length > 0 && !force && (Date.now() - Number(cache.loadedAt || 0) < 5 * 60 * 1000);
|
||
if (isFresh) {
|
||
return Promise.resolve(cache.points);
|
||
}
|
||
|
||
if (cache.promise && isSameEntity && !force) {
|
||
return cache.promise;
|
||
}
|
||
|
||
const currentEntity = getEntityFromSnapshot(snapshot, config.sensor_entity_id) || getEntityDefinition(snapshot, config.sensor_entity_id);
|
||
const currentValue = currentEntity?.state ?? null;
|
||
|
||
state.mainBoilerHistory = {
|
||
...cache,
|
||
entityId: config.sensor_entity_id,
|
||
loading: true,
|
||
error: null,
|
||
};
|
||
|
||
const promise = apiGet('history', {
|
||
entity_id: config.sensor_entity_id,
|
||
hours: config.history_hours || 24,
|
||
}).then((response) => {
|
||
const points = normalizeHistoryPoints(response?.history ?? response, currentValue);
|
||
state.mainBoilerHistory = {
|
||
entityId: config.sensor_entity_id,
|
||
points,
|
||
loadedAt: Date.now(),
|
||
loading: false,
|
||
error: null,
|
||
promise: null,
|
||
};
|
||
if ((state.selectedRoomId || 'main') === 'main') {
|
||
updateMainWeatherCard();
|
||
}
|
||
return points;
|
||
}).catch((error) => {
|
||
state.mainBoilerHistory = {
|
||
entityId: config.sensor_entity_id,
|
||
points: Array.isArray(cache.points) ? cache.points : normalizeHistoryPoints([], currentValue),
|
||
loadedAt: Number(cache.loadedAt || 0),
|
||
loading: false,
|
||
error: error?.message || String(error),
|
||
promise: null,
|
||
};
|
||
if ((state.selectedRoomId || 'main') === 'main') {
|
||
updateMainWeatherCard();
|
||
}
|
||
return state.mainBoilerHistory.points;
|
||
});
|
||
|
||
state.mainBoilerHistory.promise = promise;
|
||
return promise;
|
||
}
|
||
|
||
function renderMainBoilerCard(snapshot) {
|
||
const config = mainBoilerConfig(snapshot);
|
||
if (!config) {
|
||
return null;
|
||
}
|
||
|
||
const entity = getEntityFromSnapshot(snapshot, config.sensor_entity_id) || getEntityDefinition(snapshot, config.sensor_entity_id);
|
||
const currentValue = formatTemperatureValue(entity?.state);
|
||
const historyPoints = boilerHistoryState(config.sensor_entity_id);
|
||
const history = state.mainBoilerHistory || {};
|
||
|
||
const card = document.createElement('article');
|
||
card.className = 'grid-card main-boiler-card';
|
||
|
||
const inner = document.createElement('div');
|
||
inner.className = 'grid-card__inner main-boiler-card__inner';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'main-boiler-card__header';
|
||
|
||
const text = document.createElement('div');
|
||
text.className = 'main-boiler-card__text';
|
||
|
||
const eyebrow = document.createElement('div');
|
||
eyebrow.className = 'main-boiler-card__eyebrow';
|
||
eyebrow.textContent = 'Температура бойлера';
|
||
text.appendChild(eyebrow);
|
||
|
||
const range = document.createElement('div');
|
||
range.className = 'main-boiler-card__range';
|
||
range.textContent = '24 часа';
|
||
header.append(text, range);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'main-boiler-card__body';
|
||
|
||
const valueColumn = document.createElement('div');
|
||
valueColumn.className = 'main-boiler-card__value-column';
|
||
|
||
const valueLabel = document.createElement('div');
|
||
valueLabel.className = 'main-boiler-card__value-label';
|
||
valueLabel.textContent = 'Сейчас';
|
||
|
||
const valueRow = document.createElement('div');
|
||
valueRow.className = 'main-boiler-card__value-row';
|
||
|
||
const value = document.createElement('div');
|
||
value.className = 'main-boiler-card__value';
|
||
value.textContent = currentValue || '—';
|
||
|
||
const unit = document.createElement('div');
|
||
unit.className = 'main-boiler-card__unit';
|
||
unit.textContent = '°C';
|
||
|
||
valueRow.append(value, unit);
|
||
valueColumn.append(valueLabel, valueRow);
|
||
|
||
const chartWrap = document.createElement('div');
|
||
chartWrap.className = 'main-boiler-card__chart-wrap';
|
||
if (history.loading && (!historyPoints || !historyPoints.length)) {
|
||
chartWrap.classList.add('is-loading');
|
||
}
|
||
chartWrap.appendChild(renderBoilerSparkline(historyPoints));
|
||
|
||
if (history.loading && (!historyPoints || !historyPoints.length)) {
|
||
const loading = document.createElement('div');
|
||
loading.className = 'main-boiler-card__loading';
|
||
loading.textContent = 'Загружаем график...';
|
||
chartWrap.appendChild(loading);
|
||
}
|
||
|
||
body.append(valueColumn, chartWrap);
|
||
inner.append(header, body);
|
||
card.appendChild(inner);
|
||
scheduleMainBoilerHistoryLoad(snapshot);
|
||
return card;
|
||
}
|
||
|
||
function renderMainHero(snapshot) {
|
||
const hero = document.createElement('div');
|
||
hero.className = 'main-dashboard__hero';
|
||
|
||
const weatherSlot = document.createElement('div');
|
||
weatherSlot.className = 'main-dashboard__weather-slot';
|
||
if (snapshot.weather) {
|
||
weatherSlot.appendChild(mainWeatherCard(snapshot.weather));
|
||
}
|
||
hero.appendChild(weatherSlot);
|
||
|
||
const stack = document.createElement('div');
|
||
stack.className = 'main-dashboard__hero-stack';
|
||
|
||
const actions = renderMainWeatherActions(snapshot);
|
||
if (actions) {
|
||
stack.appendChild(actions);
|
||
}
|
||
|
||
const boiler = renderMainBoilerCard(snapshot);
|
||
if (boiler) {
|
||
stack.appendChild(boiler);
|
||
}
|
||
|
||
if (!stack.childNodes.length) {
|
||
const spacer = document.createElement('div');
|
||
spacer.className = 'main-dashboard__hero-spacer';
|
||
stack.appendChild(spacer);
|
||
}
|
||
|
||
hero.appendChild(stack);
|
||
|
||
return hero;
|
||
}
|
||
|
||
function serviceValueForCommand(command, value) {
|
||
if (value === null || value === undefined || value === '') {
|
||
return null;
|
||
}
|
||
if (command === 'set_position' || command === 'set_temperature') {
|
||
return Number(value);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function climateOptionButtons(entity, attrName, command, title) {
|
||
const values = Array.isArray(entity.attributes?.[attrName]) ? entity.attributes[attrName] : [];
|
||
if (!values.length) return null;
|
||
|
||
const section = document.createElement('div');
|
||
section.className = 'entity-modal__options-block';
|
||
|
||
const heading = document.createElement('div');
|
||
heading.className = 'entity-modal__options-title';
|
||
heading.textContent = climateGroupTitle(attrName) || title;
|
||
section.appendChild(heading);
|
||
|
||
const list = document.createElement('div');
|
||
list.className = 'entity-modal__chips';
|
||
const current = String(entity.attributes?.[command.replace('set_', '')] || entity.attributes?.hvac_mode || entity.attributes?.fan_mode || entity.attributes?.swing_mode || entity.attributes?.preset_mode || '').toLowerCase();
|
||
|
||
values.forEach((value) => {
|
||
const chip = document.createElement('button');
|
||
chip.type = 'button';
|
||
chip.className = `entity-chip ${String(value).toLowerCase() === current ? 'is-active' : ''}`;
|
||
chip.textContent = climateOptionLabel(attrName, value);
|
||
chip.title = String(value);
|
||
chip.addEventListener('click', () => {
|
||
handleClimateCommand(entity, command, value);
|
||
});
|
||
list.appendChild(chip);
|
||
});
|
||
|
||
section.appendChild(list);
|
||
return section;
|
||
}
|
||
|
||
function renderCoverPopup(entity) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'entity-modal__cover';
|
||
|
||
const rail = document.createElement('div');
|
||
rail.className = 'entity-modal__rail entity-modal__rail--cover';
|
||
|
||
const currentPosition = Number(entity.attributes?.current_position);
|
||
const initialValue = Number.isFinite(currentPosition)
|
||
? Math.max(0, Math.min(100, currentPosition))
|
||
: (String(entity.state || '').toLowerCase() === 'open' ? 100 : 0);
|
||
|
||
const valueRow = document.createElement('div');
|
||
valueRow.className = 'entity-modal__cover-meta';
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'entity-modal__cover-label';
|
||
label.textContent = 'Открыт на';
|
||
|
||
const value = document.createElement('div');
|
||
value.className = 'entity-modal__cover-value';
|
||
value.textContent = `${initialValue}%`;
|
||
valueRow.append(label, value);
|
||
|
||
const progress = document.createElement('div');
|
||
progress.className = 'entity-modal__cover-track';
|
||
|
||
const fill = document.createElement('div');
|
||
fill.className = 'entity-modal__cover-fill';
|
||
fill.style.height = `${initialValue}%`;
|
||
fill.style.top = '0';
|
||
fill.style.width = '100%';
|
||
progress.appendChild(fill);
|
||
|
||
const handle = document.createElement('div');
|
||
handle.className = 'entity-modal__cover-handle';
|
||
progress.appendChild(handle);
|
||
|
||
const syncValue = (nextValue) => {
|
||
fill.style.height = `${nextValue}%`;
|
||
handle.style.top = nextValue <= 0 ? 'calc(100% - 10px)' : `calc(${nextValue}% - 10px)`;
|
||
value.textContent = `${nextValue}%`;
|
||
};
|
||
syncValue(initialValue);
|
||
|
||
const updateFromPointer = (clientY) => {
|
||
const rect = progress.getBoundingClientRect();
|
||
const ratio = 1 - ((clientY - rect.top) / rect.height);
|
||
const nextValue = Math.max(0, Math.min(100, Math.round(ratio * 100)));
|
||
syncValue(nextValue);
|
||
handleCoverPosition(entity, nextValue);
|
||
};
|
||
|
||
let dragPointerId = null;
|
||
const onPointerMove = (event) => {
|
||
if (dragPointerId !== event.pointerId) return;
|
||
event.preventDefault();
|
||
updateFromPointer(event.clientY);
|
||
};
|
||
const onPointerUp = (event) => {
|
||
if (dragPointerId !== event.pointerId) return;
|
||
event.preventDefault();
|
||
progress.releasePointerCapture?.(dragPointerId);
|
||
dragPointerId = null;
|
||
window.removeEventListener('pointermove', onPointerMove);
|
||
window.removeEventListener('pointerup', onPointerUp);
|
||
window.removeEventListener('pointercancel', onPointerUp);
|
||
};
|
||
|
||
progress.addEventListener('pointerdown', (event) => {
|
||
if (event.button !== 0) return;
|
||
dragPointerId = event.pointerId;
|
||
progress.setPointerCapture?.(dragPointerId);
|
||
updateFromPointer(event.clientY);
|
||
window.addEventListener('pointermove', onPointerMove, { passive: false });
|
||
window.addEventListener('pointerup', onPointerUp, { passive: false });
|
||
window.addEventListener('pointercancel', onPointerUp, { passive: false });
|
||
});
|
||
|
||
rail.append(valueRow, progress);
|
||
|
||
const buttons = document.createElement('div');
|
||
buttons.className = 'entity-modal__actions entity-modal__actions--vertical';
|
||
|
||
const openBtn = createButton('Открыть', null, 'mdi:arrow-up', 'mushroom-button mushroom-button--small mushroom-button--square');
|
||
openBtn.addEventListener('click', () => handleEntityService(entity, 'open'));
|
||
|
||
const stopBtn = createButton('Стоп', null, 'mdi:stop', 'mushroom-button mushroom-button--small mushroom-button--square');
|
||
stopBtn.addEventListener('click', () => handleEntityService(entity, 'stop'));
|
||
|
||
const closeBtn = createButton('Закрыть', null, 'mdi:arrow-down', 'mushroom-button mushroom-button--small mushroom-button--square');
|
||
closeBtn.addEventListener('click', () => handleEntityService(entity, 'close'));
|
||
|
||
buttons.append(openBtn, stopBtn, closeBtn);
|
||
wrap.append(rail, buttons);
|
||
return wrap;
|
||
}
|
||
|
||
function renderClimatePopup(entity) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'entity-modal__climate';
|
||
|
||
const tempBlock = document.createElement('div');
|
||
tempBlock.className = 'entity-modal__climate-summary';
|
||
tempBlock.innerHTML = `
|
||
<div class="entity-modal__current-label">Текущая температура</div>
|
||
<div class="entity-modal__current-value">${esc(entity.attributes?.current_temperature ?? '—')}°C</div>
|
||
<div class="entity-modal__target-row">
|
||
<div class="entity-modal__target-state">${esc(climateStateLabel(entity.attributes?.hvac_action || entity.state || '—'))}</div>
|
||
<div class="entity-modal__target-temp">${esc(entity.attributes?.temperature ?? '—')}<span>°C</span></div>
|
||
</div>
|
||
`;
|
||
|
||
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 = '<i class="mdi mdi-minus"></i>';
|
||
minus.addEventListener('click', () => handleClimateTemperature(entity, -1));
|
||
|
||
const plus = document.createElement('button');
|
||
plus.type = 'button';
|
||
plus.className = 'round-button entity-modal__round-button';
|
||
plus.innerHTML = '<i class="mdi mdi-plus"></i>';
|
||
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, ' ');
|
||
}
|
||
|
||
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 isOpen = ['open', 'opening'].includes(String(entity.state).toLowerCase());
|
||
const currentPosition = Number(entity.attributes?.current_position);
|
||
const hasVisiblePosition = Number.isFinite(currentPosition)
|
||
? 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 = Number.isFinite(currentPosition) ? 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 = `
|
||
<div class="climate-card__meta-target">${esc(entity.attributes?.temperature ?? '—')}°</div>
|
||
<div class="climate-card__meta-current">Сейчас ${esc(entity.attributes?.current_temperature ?? '—')}°</div>
|
||
`;
|
||
|
||
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 = '<i class="mdi mdi-arrow-up"></i> Вверх';
|
||
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 = '<i class="mdi mdi-arrow-down"></i> Вниз';
|
||
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 ? '<i class="mdi mdi-eye"></i>' : '<i class="mdi mdi-eye-off"></i>';
|
||
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 = '<span class="grid-card__title-line">Пустая карточка</span>';
|
||
|
||
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 = '<i class="mdi mdi-cog-outline"></i> Настройки';
|
||
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 = '<i class="mdi mdi-arrow-up"></i> Вверх';
|
||
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 = '<i class="mdi mdi-arrow-down"></i> Вниз';
|
||
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 = '<i class="mdi mdi-delete-outline"></i> Удалить';
|
||
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 = '<i class="mdi mdi-thermometer"></i> Выбрать датчик температуры';
|
||
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 = `
|
||
<div class="room-item__name">${esc(room.name)}</div>
|
||
<div class="room-item__meta">${metaText}</div>
|
||
`;
|
||
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 = '<i class="mdi mdi-plus"></i><span>Пустая карточка</span>';
|
||
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 = '<i class="mdi mdi-thermometer"></i><span>Выбрать датчик температуры</span>';
|
||
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';
|
||
}
|
||
|
||
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 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');
|
||
return;
|
||
}
|
||
|
||
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');
|
||
};
|
||
socket.onclose = () => {
|
||
state.haSocket = null;
|
||
if (state.haSocketState !== 'disconnected') {
|
||
scheduleReconnect();
|
||
}
|
||
};
|
||
} catch (error) {
|
||
console.error(error);
|
||
scheduleReconnect();
|
||
}
|
||
}
|
||
|
||
async function start() {
|
||
initRefs();
|
||
syncViewportState();
|
||
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();
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', start);
|
||
})();
|