wallpanell/assets/app.js
2026-03-25 23:55:49 +03:00

5447 lines
184 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
const bootstrap = window.APP_BOOTSTRAP || {};
const MOBILE_BREAKPOINT = 920;
const state = {
snapshot: bootstrap,
embedMode: Boolean(bootstrap?.ui?.embed || window.WALL_PANEL_HA_MODE),
selectedRoomId: 'main',
isMobileViewport: false,
mobileView: 'spaces',
editMode: Boolean(bootstrap?.settings?.edit_mode),
clockTimer: null,
hlsInstance: null,
popupDismissTimer: null,
popupAutoOpenBlockedUntil: 0,
roomAutoReturnTimer: null,
mainBoilerHistory: {
entityId: null,
points: [],
loadedAt: 0,
loading: false,
error: null,
promise: null,
},
entityPopup: {
active: false,
entityId: null,
},
temperatureSensorPopup: {
active: false,
roomId: null,
},
lastPopupSignature: '',
lastEntityPopupSignature: '',
lastTemperatureSensorPopupSignature: '',
roomDrag: null,
layoutItemSettingsOpen: {},
confirmResolver: null,
haSocket: null,
haSocketState: 'disconnected',
haReconnectTimer: null,
haReconnectDelay: 1000,
haSubscribeId: 1,
roomSelectionToken: 0,
snapshotPollTimer: null,
haSnapshotListenerInstalled: false,
debugLastRenderSignature: '',
lastRenderSignature: '',
lastSidebarRenderSignature: '',
lastContentRenderSignature: '',
};
const els = {};
const client = window.StrikerPanelClient || (window.StrikerPanelClient = {});
let renderFrame = null;
const debugEnabled = (() => {
try {
if (window.StrikerPanelDebug) return true;
const search = new URLSearchParams(window.location.search);
if (['1', 'true', 'yes', 'on'].includes(String(search.get('wp_debug') || '').toLowerCase())) {
return true;
}
const stored = window.localStorage?.getItem('striker-panel-debug');
if (['1', 'true', 'yes', 'on'].includes(String(stored || '').toLowerCase())) {
return true;
}
} catch (error) {
return Boolean(window.StrikerPanelDebug);
}
return false;
})();
const debugLog = (...args) => {
if (debugEnabled) {
console.log('[Striker Panel]', ...args);
}
};
const debugWarn = (...args) => {
if (debugEnabled) {
console.warn('[Striker Panel]', ...args);
}
};
client.renderFromSnapshot = (snapshot) => {
if (!snapshot || typeof snapshot !== 'object') {
return;
}
state.snapshot = snapshot;
initRefs();
state.embedMode = detectEmbeddedContext() || isHaRuntime();
syncLayoutState();
if (renderFrame) {
return;
}
renderFrame = window.requestAnimationFrame(() => {
renderFrame = null;
render();
});
};
client.refresh = () => {
initRefs();
state.embedMode = detectEmbeddedContext() || isHaRuntime();
syncLayoutState();
render();
};
function $(id) {
const root = client.mountRoot || document;
if (!root) return null;
if (typeof root.getElementById === 'function') {
return root.getElementById(id);
}
return root.querySelector?.(`#${CSS.escape(id)}`) || null;
}
function q(sel, root) {
const actualRoot = root || client.mountRoot || document;
return actualRoot.querySelector(sel);
}
function qa(sel, root) {
const actualRoot = root || client.mountRoot || document;
return Array.from(actualRoot.querySelectorAll(sel));
}
function haBridge() {
return window.WALL_PANEL_HA_BRIDGE || null;
}
function isHaRuntime() {
return Boolean(
window.WALL_PANEL_HA_MODE ||
haBridge()
|| bootstrap?.ui?.mode === 'ha-native'
);
}
async function waitForHaBridge(timeoutMs = 1000) {
const startedAt = Date.now();
let bridge = haBridge();
while (!bridge && (Date.now() - startedAt) < timeoutMs) {
await sleep(50);
bridge = haBridge();
}
return bridge;
}
function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
}
function snapshotLooksReady(snapshot) {
if (!snapshot || typeof snapshot !== 'object') {
return false;
}
const rooms = Array.isArray(snapshot.rooms) ? snapshot.rooms : [];
const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : [];
if (rooms.length > 0 || spaces.length > 0) {
return true;
}
if (snapshot.selected_room?.id || snapshot.selected_space?.id) {
return true;
}
return false;
}
function snapshotSummary(snapshot) {
const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || null;
const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : [];
const mainEntities = Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : [];
const selectedEntities = Array.isArray(selectedRoom?.entities) ? selectedRoom.entities : [];
const popup = snapshot?.popup || {};
return {
mode: snapshot?.ui?.mode || 'unknown',
selected_room_id: selectedRoom?.id || null,
selected_room_name: selectedRoom?.name || null,
rooms: rooms.length,
main_entities: mainEntities.length,
selected_room_entities: selectedEntities.length,
popup_active: Boolean(popup.active),
popup_sensor: popup.sensor_entity_id || null,
};
}
function snapshotEntityToken(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) {
const attr = found.attributes || {};
return [
String(found.entity_id || ''),
String(found.state ?? ''),
String(found.last_changed || found.last_updated || ''),
String(attr.current_temperature ?? attr.temperature ?? ''),
String(attr.current_position ?? ''),
String(attr.hvac_action ?? ''),
];
}
}
return null;
}
function entityRenderToken(entity) {
const attr = entity?.attributes || {};
return [
String(entity?.entity_id || ''),
String(entity?.state ?? ''),
String(entity?.order ?? 9999),
entity?.visible === false ? '0' : '1',
String(entity?.domain || ''),
String(entity?.card_type || ''),
String(entity?.subtitle || ''),
String(entity?.icon || ''),
String(attr.current_temperature ?? attr.temperature ?? ''),
String(attr.current_position ?? ''),
String(attr.hvac_action ?? ''),
];
}
function roomRenderToken(room) {
return [
String(room?.id || ''),
String(room?.name || ''),
String(room?.icon || ''),
room?.visible === false ? '0' : '1',
String(room?.order ?? 9999),
String(room?.entity_count ?? 0),
String(room?.active_entity_count ?? 0),
String(room?.temperature_badge || ''),
String(room?.battery_summary_text || ''),
room?.virtual ? '1' : '0',
];
}
function selectedRoomRenderToken(snapshot) {
const room = snapshot?.selected_room || snapshot?.selected_space || {};
if (!room?.id) {
return ['unknown'];
}
if (room.id === 'main') {
const boiler = snapshot?.settings?.main_boiler || {};
const printConfig = snapshot?.settings?.main_print || {};
const weatherActions = Array.isArray(snapshot?.settings?.main_weather_actions)
? snapshot.settings.main_weather_actions
: [];
return [
'main',
room.name || '',
(Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : []).map(entityRenderToken),
snapshotEntityToken(snapshot, boiler.sensor_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.current_stage_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.print_progress_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.start_time_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.end_time_entity_id || ''),
weatherActions.map((action) => [
String(action?.entity_id || ''),
String(action?.state_entity_id || ''),
String(action?.command || ''),
String(action?.value ?? ''),
String(action?.active_value ?? ''),
String(action?.label_active || ''),
String(action?.label_inactive || ''),
String(mainWeatherActionIsActive(snapshot, action) ? '1' : '0'),
]),
snapshot?.weather ? [
String(snapshot.weather.entity_id || ''),
String(snapshot.weather.state ?? ''),
String(snapshot.weather.temperature ?? ''),
String(snapshot.weather.sensor_temperature ?? ''),
String(snapshot.weather.wind_speed ?? ''),
String(snapshot.weather.condition ?? ''),
] : null,
];
}
if (room.id === 'batteries') {
return [
'batteries',
room.name || '',
room.battery_summary_text || '',
Number(room.entity_count ?? 0) || 0,
Number(room.problem_count ?? room.active_entity_count ?? 0) || 0,
(Array.isArray(room.entities) ? room.entities : []).map((item) => [
String(item?.entity_id || ''),
String(item?.battery_status || ''),
String(item?.battery_percent_text || ''),
String(item?.forecast_minutes_left ?? ''),
String(item?.forecast_text || ''),
String(item?.source_text || ''),
]),
];
}
return [
room.id,
room.name || '',
room.icon || '',
room.visible === false ? '0' : '1',
String(room.order ?? 9999),
String(room.temperature_badge || ''),
roomGridEntries(snapshot, room.id).map((entry) => (
entry.kind === 'layout'
? ['layout', String(entry.id || ''), String(entry.order ?? 9999), String(entry.payload?.type || 'ghost')]
: ['entity', ...entityRenderToken(entry.payload)]
)),
state.editMode
? roomEntitiesIncludingHidden(snapshot, room.id)
.filter((entity) => entity.visible === false)
.map(entityRenderToken)
: [],
];
}
function buildVisibleSnapshotSignature(snapshot) {
const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : [];
const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || {};
const batteryRoom = snapshot?.battery_room || null;
return JSON.stringify([
String(snapshot?.ui?.mode || 'unknown'),
String(selectedRoom?.id || 'main'),
rooms.map(roomRenderToken),
batteryRoom ? roomRenderToken(batteryRoom) : null,
selectedRoomRenderToken(snapshot),
]);
}
function buildSidebarRenderSignature(snapshot) {
const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : [];
const batteryRoom = snapshot?.battery_room || null;
return JSON.stringify([
String(state.selectedRoomId || 'main'),
String(state.editMode ? '1' : '0'),
String(state.mobileView || 'spaces'),
String(isMobileViewport() ? '1' : '0'),
rooms.map(roomRenderToken),
batteryRoom ? roomRenderToken(batteryRoom) : null,
]);
}
function buildContentRenderSignature(snapshot) {
const room = snapshot?.selected_room || snapshot?.selected_space || {};
return JSON.stringify([
String(state.selectedRoomId || room.id || 'main'),
String(room.id || 'main'),
String(state.editMode ? '1' : '0'),
String(state.mobileView || 'spaces'),
String(isMobileViewport() ? '1' : '0'),
selectedRoomRenderToken(snapshot),
]);
}
function buildRenderSignature(snapshot) {
const history = state.mainBoilerHistory || {};
return JSON.stringify([
buildVisibleSnapshotSignature(snapshot),
String(state.selectedRoomId || 'main'),
String(state.editMode ? '1' : '0'),
String(state.mobileView || 'spaces'),
String(isMobileViewport() ? '1' : '0'),
String(history.entityId || ''),
String(history.loadedAt || 0),
String(Array.isArray(history.points) ? history.points.length : 0),
String(history.loading ? '1' : '0'),
String(history.error || ''),
String(state.entityPopup?.active ? '1' : '0'),
String(state.entityPopup?.entityId || ''),
String(state.temperatureSensorPopup?.active ? '1' : '0'),
String(state.temperatureSensorPopup?.roomId || ''),
String(state.lastPopupSignature || ''),
String(state.lastEntityPopupSignature || ''),
String(state.lastTemperatureSensorPopupSignature || ''),
]);
}
function renderSidebarSection(snapshot) {
const nextSignature = buildSidebarRenderSignature(snapshot);
if (nextSignature === state.lastSidebarRenderSignature) {
return false;
}
state.lastSidebarRenderSignature = nextSignature;
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
if (els.roomsCount) {
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
}
if (els.editModeToggle) {
els.editModeToggle.classList.toggle('is-active', state.editMode);
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
}
return true;
}
function renderContentSection(snapshot) {
const nextSignature = buildContentRenderSignature(snapshot);
if (nextSignature === state.lastContentRenderSignature) {
return false;
}
state.lastContentRenderSignature = nextSignature;
syncLayoutState();
renderDashboard(snapshot);
renderSelectedRoom(snapshot);
return true;
}
function requestSummary(action, params = {}) {
const summary = { action };
['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => {
if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
summary[key] = params[key];
}
});
if (params.payload && typeof params.payload === 'object') {
summary.payload_keys = Object.keys(params.payload);
}
return summary;
}
async function resolveInitialSnapshot() {
const bootstrapSnapshot = window.APP_BOOTSTRAP || bootstrap || {};
debugLog('resolveInitialSnapshot()', {
ha_runtime: isHaRuntime(),
bridge_ready: Boolean(haBridge()),
bootstrap_ready: snapshotLooksReady(bootstrapSnapshot),
});
if (!isHaRuntime()) {
debugLog('resolveInitialSnapshot -> bootstrap (standalone)', snapshotSummary(bootstrapSnapshot));
return bootstrapSnapshot;
}
const waitForHaBridge = async (timeoutMs = 1000) => {
const startedAt = Date.now();
let bridge = haBridge();
while (!bridge && (Date.now() - startedAt) < timeoutMs) {
await sleep(50);
bridge = haBridge();
}
return bridge;
};
const tryBridgeSnapshot = async () => {
const bridge = await waitForHaBridge();
if (!bridge) {
debugLog('HA bridge not ready yet');
return null;
}
const roomId = state.selectedRoomId || 'main';
try {
if (typeof bridge.getSnapshot === 'function') {
debugLog('request initial snapshot via bridge.getSnapshot()', { room_id: roomId });
const snapshot = await bridge.getSnapshot(roomId);
if (snapshotLooksReady(snapshot)) {
debugLog('initial snapshot received via bridge.getSnapshot()', snapshotSummary(snapshot));
return snapshot;
}
}
} catch (error) {
debugWarn('initial snapshot bridge.getSnapshot() failed', error);
}
try {
if (typeof bridge.request === 'function') {
debugLog('request initial snapshot via bridge.request(GET snapshot)', { room_id: roomId });
const snapshot = await bridge.request('GET', 'snapshot', { space_id: roomId });
if (snapshotLooksReady(snapshot)) {
debugLog('initial snapshot received via bridge.request(GET snapshot)', snapshotSummary(snapshot));
return snapshot;
}
}
} catch (error) {
debugWarn('initial snapshot bridge.request(GET snapshot) failed', error);
}
return null;
};
const firstPass = await tryBridgeSnapshot();
if (firstPass) {
return firstPass;
}
await sleep(150);
const secondPass = await tryBridgeSnapshot();
if (secondPass) {
return secondPass;
}
debugLog('resolveInitialSnapshot -> fallback bootstrap', snapshotSummary(bootstrapSnapshot));
return snapshotLooksReady(bootstrapSnapshot) ? bootstrapSnapshot : bootstrapSnapshot;
}
const PRESSABLE_SELECTOR = [
'.grid-card--tap',
'.mushroom-button',
'.round-button',
'.icon-button',
'.main-quick-action',
'.entity-chip',
'.temperature-sensor-modal__option',
'.room-item',
].join(', ');
function bindPressFeedback() {
let pressedEl = null;
let releaseTimer = null;
const clearPressed = () => {
if (releaseTimer !== null) {
window.clearTimeout(releaseTimer);
releaseTimer = null;
}
if (pressedEl) {
pressedEl.classList.remove('is-pressed');
pressedEl = null;
}
};
const setPressed = (el) => {
if (!el || el.classList.contains('is-disabled')) {
return;
}
clearPressed();
pressedEl = el;
el.classList.add('is-pressed');
releaseTimer = window.setTimeout(clearPressed, 160);
};
document.addEventListener('pointerdown', (event) => {
if (event.button !== undefined && event.button !== 0) return;
const target = event.target instanceof Element ? event.target.closest(PRESSABLE_SELECTOR) : null;
if (!target) return;
setPressed(target);
}, { passive: true });
document.addEventListener('pointerup', clearPressed, { passive: true });
document.addEventListener('pointercancel', clearPressed, { passive: true });
window.addEventListener('blur', clearPressed);
}
function mobileViewportQuery() {
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
}
function isMobileViewport() {
return Boolean(state.isMobileViewport);
}
function isMobileRoomView() {
return isMobileViewport() && state.mobileView === 'room';
}
function detectEmbeddedContext() {
if (isHaRuntime()) {
return true;
}
if (Boolean(bootstrap?.ui?.embed)) {
return true;
}
try {
return window.self !== window.top;
} catch (error) {
return true;
}
}
function setMobileView(nextView) {
if (!isMobileViewport()) {
state.mobileView = 'room';
return;
}
state.mobileView = nextView === 'room' ? 'room' : 'spaces';
}
function syncViewportState() {
const query = mobileViewportQuery();
const nextIsMobile = Boolean(query.matches);
const changed = nextIsMobile !== state.isMobileViewport;
state.isMobileViewport = nextIsMobile;
if (nextIsMobile) {
state.mobileView = changed ? 'spaces' : (state.mobileView || 'spaces');
} else {
state.mobileView = 'room';
clearRoomAutoReturnTimer();
scheduleRoomAutoReturn(state.selectedRoomId || 'main');
}
if (nextIsMobile) {
clearRoomAutoReturnTimer();
if (state.selectedRoomId === 'batteries' && state.snapshot) {
state.selectedRoomId = 'main';
patchSnapshotSelection('main');
}
}
return nextIsMobile;
}
function iconClass(icon) {
if (!icon) return 'mdi mdi-help-circle-outline';
return icon.startsWith('mdi:') ? `mdi ${icon.replace('mdi:', 'mdi-')}` : icon;
}
function normalizeIconSource(source) {
const value = String(source ?? '').trim();
if (!value) return '';
return value.replace(/\.svg$/i, '');
}
function createSvgIcon(definition) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', definition?.viewBox || '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
svg.classList.add('icon-node__svg');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', definition?.path || '');
svg.appendChild(path);
return svg;
}
const iconTemplateCache = new Map();
const iconTemplatePromiseCache = new Map();
const customBrandIconsUrl = 'https://home.striker72rus.ru/local/community/custom-brand-icons/custom-brand-icons.js';
let customBrandIconsPromise = null;
function templateFromSvgText(svgText) {
const template = document.createElement('template');
template.innerHTML = String(svgText || '').trim();
return template;
}
function iconUrl(source) {
return `https://api.iconify.design/${source}.svg`;
}
function ensureCustomBrandIconsLoaded() {
if (window.customIcons || window.customIconsets) {
return Promise.resolve(true);
}
if (customBrandIconsPromise) {
return customBrandIconsPromise;
}
const existing = document.querySelector('script[data-wall-panel-custom-brand-icons="1"]');
if (existing) {
customBrandIconsPromise = new Promise((resolve, reject) => {
if (window.customIcons || window.customIconsets) {
resolve(true);
return;
}
existing.addEventListener('load', () => resolve(true), { once: true });
existing.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true });
}).finally(() => {
customBrandIconsPromise = null;
});
return customBrandIconsPromise;
}
const script = document.createElement('script');
script.src = customBrandIconsUrl;
script.defer = true;
script.async = true;
script.dataset.wallPanelCustomBrandIcons = '1';
customBrandIconsPromise = new Promise((resolve, reject) => {
script.addEventListener('load', () => resolve(true), { once: true });
script.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true });
}).finally(() => {
customBrandIconsPromise = null;
});
(document.head || document.documentElement).appendChild(script);
return customBrandIconsPromise;
}
function applyTemplateToNode(target, template) {
if (!target || !template || !target.isConnected) {
return;
}
target.replaceChildren(template.content.cloneNode(true));
}
function primeRemoteIconTemplate(source) {
const key = `remote:${source}`;
if (iconTemplateCache.has(key)) {
return Promise.resolve(iconTemplateCache.get(key));
}
if (iconTemplatePromiseCache.has(key)) {
return iconTemplatePromiseCache.get(key);
}
const promise = fetch(iconUrl(source), {
cache: 'force-cache',
credentials: 'omit',
headers: { Accept: 'image/svg+xml' },
}).then((res) => {
if (!res.ok) {
throw new Error(`Icon load failed: ${res.status}`);
}
return res.text();
}).then((svgText) => {
const template = templateFromSvgText(svgText);
iconTemplateCache.set(key, template);
return template;
}).finally(() => {
iconTemplatePromiseCache.delete(key);
});
iconTemplatePromiseCache.set(key, promise);
return promise;
}
function primeCustomIconTemplate(source) {
const key = `custom:${source}`;
if (iconTemplateCache.has(key)) {
return Promise.resolve(iconTemplateCache.get(key));
}
if (iconTemplatePromiseCache.has(key)) {
return iconTemplatePromiseCache.get(key);
}
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 Promise.resolve(null);
}
const promise = Promise.resolve(getIcon(name)).then((definition) => {
if (!definition) {
return null;
}
const template = document.createElement('template');
template.content.appendChild(createSvgIcon(definition));
iconTemplateCache.set(key, template);
return template;
}).finally(() => {
iconTemplatePromiseCache.delete(key);
});
iconTemplatePromiseCache.set(key, promise);
return promise;
}
function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') {
const cached = iconTemplateCache.get(`custom:${source}`);
if (cached) {
return cached.content.cloneNode(true);
}
const [prefix] = source.split(':', 2);
const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix];
const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon;
if (typeof getIcon !== 'function') {
if (!isHaRuntime()) {
return null;
}
const wrap = document.createElement('span');
wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
ensureCustomBrandIconsLoaded().then(() => primeCustomIconTemplate(source)).then((template) => {
if (!template) return;
applyTemplateToNode(wrap, template);
}).catch(() => {
if (!wrap.isConnected) return;
wrap.replaceChildren(createIconElement(fallback));
});
return wrap;
}
const wrap = document.createElement('span');
wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
primeCustomIconTemplate(source).then((template) => {
if (!template) return;
applyTemplateToNode(wrap, template);
}).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:')) {
if (isHaRuntime()) {
const haIcon = document.createElement('ha-icon');
haIcon.setAttribute('icon', source);
haIcon.className = 'icon-node icon-node--ha';
return haIcon;
}
const i = document.createElement('i');
i.className = iconClass(source);
i.setAttribute('aria-hidden', 'true');
return i;
}
const custom = createCustomIconElement(source, fallback);
if (custom) {
return custom;
}
if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) {
const mappedSource = source.startsWith('fas:')
? source.replace(/^fas:/, 'fa-solid:')
: source.startsWith('far:')
? source.replace(/^far:/, 'fa-regular:')
: source.replace(/^fab:/, 'fa-brands:');
const cached = iconTemplateCache.get(`remote:${mappedSource}`);
if (cached) {
return cached.content.cloneNode(true);
}
const wrap = document.createElement('span');
wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
primeRemoteIconTemplate(mappedSource).then((template) => {
applyTemplateToNode(wrap, template);
}).catch(() => {
if (!wrap.isConnected) return;
wrap.replaceChildren(createIconElement(fallback));
});
return wrap;
}
const remoteCached = iconTemplateCache.get(`remote:${source}`);
if (remoteCached) {
return remoteCached.content.cloneNode(true);
}
const wrap = document.createElement('span');
wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
if (source.includes(':')) {
primeRemoteIconTemplate(source).then((template) => {
applyTemplateToNode(wrap, template);
}).catch(() => {
if (!wrap.isConnected) return;
wrap.replaceChildren(createIconElement(fallback));
});
return wrap;
}
return createIconElement(fallback);
}
if (isHaRuntime()) {
void ensureCustomBrandIconsLoaded();
}
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() {
if (isHaRuntime()) {
return new Set();
}
const fromSnapshot = state.snapshot?.settings?.camera?.trigger_entities;
const fromBootstrap = bootstrap?.settings?.camera?.trigger_entities;
const triggers = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : [];
return new Set(triggers.map((value) => String(value)));
}
function cameraConfig() {
return state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {};
}
function resolvePopupStreamMode(streamUrl, explicitMode = '') {
const normalizedUrl = String(streamUrl || '').trim();
const urlMode = inferStreamMode(normalizedUrl);
const mode = String(explicitMode || '').trim().toLowerCase();
if (!normalizedUrl) {
return mode || 'poster';
}
if (urlMode === 'iframe') {
return 'iframe';
}
if (urlMode === 'hls') {
return 'hls';
}
if (urlMode === 'video') {
return 'video';
}
if (mode && !['poster', 'hls'].includes(mode)) {
return mode;
}
return 'poster';
}
function mergePopupWithCamera(popup = {}) {
const camera = cameraConfig();
const streamUrl = popup.stream_url || camera.stream_url || '';
const streamMode = resolvePopupStreamMode(streamUrl, popup.stream_mode || camera.stream_mode || '');
return {
...popup,
poster_url: popup.poster_url || camera.poster_url || '',
stream_url: streamUrl,
stream_mode: streamMode,
title: popup.title || 'Камера',
};
}
function pluralizeRooms(count) {
const n = Math.abs(Number(count) || 0) % 100;
const n1 = n % 10;
if (n > 10 && n < 20) return 'пространств';
if (n1 > 1 && n1 < 5) return 'пространства';
if (n1 === 1) return 'пространство';
return 'пространств';
}
function pluralizeRu(count, one, few, many) {
const n = Math.abs(Number(count) || 0) % 100;
const n1 = n % 10;
if (n > 10 && n < 20) return many;
if (n1 > 1 && n1 < 5) return few;
if (n1 === 1) return one;
return many;
}
function pluralizeEntities(count) {
return pluralizeRu(count, 'объект', 'объекта', 'объектов');
}
function pluralizeActiveEntities(count) {
return pluralizeRu(count, 'активный', 'активных', 'активных');
}
function pluralizeIncludedEntities(count) {
return pluralizeRu(count, 'включенный объект', 'включенных объекта', 'включенных объектов');
}
function formatTime(date = new Date()) {
return new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function formatDate(date = new Date()) {
const weekday = new Intl.DateTimeFormat('ru-RU', { weekday: 'long' }).format(date);
const dayMonth = new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'long',
}).format(date);
return `${weekday}, ${dayMonth}`;
}
function buildUrl(action, params = {}) {
if (isHaRuntime()) {
return '';
}
const url = new URL('api.php', window.location.href);
url.searchParams.set('action', action);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, value);
}
});
const proxyToken = String(window.APP_BOOTSTRAP?.ui?.proxy_token || '').trim();
if (proxyToken && !url.searchParams.has('token')) {
url.searchParams.set('token', proxyToken);
}
return url.toString();
}
async function apiGet(action, params = {}) {
const bridge = haBridge();
if (bridge?.request) {
debugLog('apiGet via bridge', requestSummary(action, params));
return bridge.request('GET', action, params);
}
if (isHaRuntime()) {
const waited = await waitForHaBridge(1000);
if (waited?.request) {
debugLog('apiGet via delayed bridge', requestSummary(action, params));
return waited.request('GET', action, params);
}
throw new Error('HA bridge is not ready');
}
debugLog('apiGet via http', requestSummary(action, params));
const res = await fetch(buildUrl(action, params), {
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
return res.json();
}
async function apiPost(action, payload = {}) {
const bridge = haBridge();
if (bridge?.request) {
debugLog('apiPost via bridge', requestSummary(action, payload));
return bridge.request('POST', action, payload);
}
if (isHaRuntime()) {
const waited = await waitForHaBridge(1000);
if (waited?.request) {
debugLog('apiPost via delayed bridge', requestSummary(action, payload));
return waited.request('POST', action, payload);
}
throw new Error('HA bridge is not ready');
}
debugLog('apiPost via http', requestSummary(action, payload));
const res = await fetch(buildUrl(action), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || json.ok === false) {
throw new Error(json.error || `Request failed: ${res.status}`);
}
return json;
}
async function fetchSnapshot(roomId = state.selectedRoomId || 'main') {
const response = await apiGet('snapshot', { space_id: roomId || 'main' });
if (response && response.ok === true && response.selected_room) {
return response;
}
return response;
}
async function loadSnapshot(roomId = state.selectedRoomId || 'main') {
const snapshot = await fetchSnapshot(roomId);
state.snapshot = snapshot;
return snapshot;
}
function currentRoom() {
const snapshot = state.snapshot || {};
if (state.selectedRoomId === 'batteries') {
return snapshot.battery_room || snapshot.selected_room || snapshot.selected_space || null;
}
const spaces = snapshot.spaces || snapshot.rooms || [];
return spaces.find((space) => space.id === state.selectedRoomId) || snapshot.selected_space || snapshot.selected_room || null;
}
function roomEntityCollection(snapshot, roomId) {
const room = roomId === 'main'
? { entities: snapshot.main_entities || [] }
: roomId === 'batteries'
? (snapshot.battery_room || snapshot.selected_room || snapshot.selected_space || { entities: [] })
: snapshot.space_index?.[roomId]
|| snapshot.space_entities?.[roomId]
|| (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null)
|| (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null);
return room?.entities || [];
}
function sortRoomEntities(entities) {
return (Array.isArray(entities) ? entities : [])
.slice()
.sort((left, right) => {
const leftOrder = Number(left?.order ?? 9999);
const rightOrder = Number(right?.order ?? 9999);
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(left?.name || '').localeCompare(String(right?.name || ''), 'ru');
});
}
function sortMainEntities(entities) {
return (Array.isArray(entities) ? entities : [])
.slice()
.sort((left, right) => {
const leftTime = entitySortTime(left);
const rightTime = entitySortTime(right);
if (leftTime !== rightTime) return leftTime - rightTime;
const leftOrder = Number(left?.order ?? 9999);
const rightOrder = Number(right?.order ?? 9999);
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(left?.name || '').localeCompare(String(right?.name || ''), 'ru');
});
}
function roomEntities(snapshot, roomId) {
const collection = roomEntityCollection(snapshot, roomId).filter((entity) => entity.visible !== false);
return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection);
}
function roomEntitiesIncludingHidden(snapshot, roomId) {
const collection = roomEntityCollection(snapshot, roomId);
return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection);
}
function roomLayoutItemCollection(snapshot, roomId) {
if (!snapshot || !roomId || roomId === 'main') {
return [];
}
const room = snapshot.space_index?.[roomId]
|| (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null)
|| (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null);
const items = Array.isArray(room?.layout_items) ? room.layout_items : [];
return items.filter((item) => item && typeof item === 'object' && String(item.type || 'ghost') === 'ghost');
}
function roomLayoutItems(snapshot, roomId) {
return roomLayoutItemCollection(snapshot, roomId)
.slice()
.sort((left, right) => {
const leftOrder = Number(left?.order ?? 9999);
const rightOrder = Number(right?.order ?? 9999);
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(left?.id || '').localeCompare(String(right?.id || ''), 'ru');
});
}
function roomGridEntries(snapshot, roomId) {
const entries = [];
roomEntities(snapshot, roomId).forEach((entity) => {
entries.push({
kind: 'entity',
id: entity.entity_id,
order: Number(entity.order ?? 9999) || 9999,
sortLabel: String(entity.name || entity.entity_id || ''),
payload: entity,
});
});
if (!isMobileViewport() && roomId !== 'main') {
roomLayoutItems(snapshot, roomId).forEach((item) => {
entries.push({
kind: 'layout',
id: item.id,
order: Number(item.order ?? 9999) || 9999,
sortLabel: String(item.id || ''),
payload: item,
});
});
}
entries.sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
if (left.kind !== right.kind) {
return left.kind === 'entity' ? -1 : 1;
}
return left.sortLabel.localeCompare(right.sortLabel, 'ru');
});
return entries;
}
function isTemperatureSensorEntity(entity) {
if (!entity || String(entity.domain || '').toLowerCase() !== 'sensor') {
return false;
}
const entityId = String(entity.entity_id || '');
const attributes = entity.attributes || {};
const deviceClass = String(attributes.device_class || '').toLowerCase();
const unit = String(attributes.unit_of_measurement || '').trim().toLowerCase();
return deviceClass === 'temperature'
|| unit === '°c'
|| unit === 'c'
|| entityId.endsWith('_temperature');
}
function roomTemperatureSensorLabel(entity) {
const name = String(entity?.name || entity?.attributes?.friendly_name || entity?.entity_id || 'Датчик');
const value = entity?.attributes?.current_temperature ?? entity?.attributes?.temperature ?? entity?.state ?? null;
const numeric = Number(value);
const valueText = Number.isFinite(numeric) ? `${Math.round(numeric)}°` : '—';
return {
name,
valueText,
meta: String(entity?.entity_id || ''),
};
}
function roomTemperatureSensorCandidates(snapshot, roomId) {
const room = snapshot?.space_index?.[roomId]
|| (snapshot?.selected_space?.id === roomId ? snapshot.selected_space : null)
|| (snapshot?.selected_room?.id === roomId ? snapshot.selected_room : null);
const entities = Array.isArray(room?.entities) ? room.entities : [];
const selectedId = String(room?.temperature_sensor_entity_id || '').trim();
const candidates = entities.filter((entity) => entity && isTemperatureSensorEntity(entity));
candidates.sort((left, right) => {
const leftSelected = String(left.entity_id || '') === selectedId ? 0 : 1;
const rightSelected = String(right.entity_id || '') === selectedId ? 0 : 1;
if (leftSelected !== rightSelected) return leftSelected - rightSelected;
const leftOrder = Number(left?.order ?? 9999);
const rightOrder = Number(right?.order ?? 9999);
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(left.name || left.entity_id || '').localeCompare(String(right.name || right.entity_id || ''), 'ru');
});
return candidates;
}
function roomTemperatureBadge(snapshot, room) {
const roomId = String(room?.id || '');
if (!roomId || roomId === 'main') {
return null;
}
const roomIndex = snapshot?.space_index?.[roomId] || room || {};
const selectedId = String(roomIndex.temperature_sensor_entity_id || room.temperature_sensor_entity_id || '').trim();
const entities = Array.isArray(roomIndex.entities) ? roomIndex.entities : [];
if (selectedId) {
const selected = entities.find((entity) => entity && String(entity.entity_id || '') === selectedId);
if (selected) {
const value = selected.attributes?.current_temperature ?? selected.attributes?.temperature ?? selected.state ?? null;
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return `${Math.round(numeric)}°`;
}
}
if (room.temperature_badge) {
return room.temperature_badge;
}
}
const firstCandidate = entities.find((entity) => entity && isTemperatureSensorEntity(entity));
if (firstCandidate) {
const value = firstCandidate.attributes?.current_temperature ?? firstCandidate.attributes?.temperature ?? firstCandidate.state ?? null;
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return `${Math.round(numeric)}°`;
}
}
if (room.temperature_badge) {
return room.temperature_badge;
}
return null;
}
function entityKindLabel(entity) {
return String(entity?.domain || entity?.entity_id?.split('.')?.[0] || '').toLowerCase() || 'entity';
}
function renderEntityTypeLabel(entity) {
const kind = document.createElement('div');
kind.className = 'grid-card__kind';
kind.textContent = entityKindLabel(entity);
return kind;
}
function entityFromSnapshot(snapshot, entityId) {
return getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId);
}
function entityPopupEntity() {
const snapshot = state.snapshot || bootstrap;
const entityId = state.entityPopup?.entityId;
return entityFromSnapshot(snapshot, entityId);
}
function openEntityPopup(entityId) {
const snapshot = state.snapshot || bootstrap;
const entity = entityFromSnapshot(snapshot, entityId);
if (!entity) return;
state.entityPopup = {
active: true,
entityId,
kind: entityKindLabel(entity),
};
renderEntityPopup(snapshot);
}
function closeEntityPopup() {
state.entityPopup = {
active: false,
entityId: null,
};
const backdrop = els.entityBackdrop;
if (backdrop) {
backdrop.classList.remove('is-open');
backdrop.setAttribute('aria-hidden', 'true');
}
if (els.entityBody) {
els.entityBody.innerHTML = '';
}
if (els.entityTitle) {
els.entityTitle.textContent = 'Устройство';
}
if (els.entityEyebrow) {
els.entityEyebrow.textContent = '';
}
}
function closeTemperatureSensorPopup() {
state.temperatureSensorPopup = {
active: false,
roomId: null,
};
state.lastTemperatureSensorPopupSignature = '';
const backdrop = els.temperatureSensorBackdrop;
if (backdrop) {
backdrop.classList.remove('is-open');
backdrop.setAttribute('aria-hidden', 'true');
}
if (els.temperatureSensorBody) {
els.temperatureSensorBody.innerHTML = '';
}
}
function openTemperatureSensorPopup(roomId) {
const snapshot = state.snapshot || bootstrap;
const room = snapshot.space_index?.[roomId]
|| (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null)
|| (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null)
|| null;
if (!room || roomId === 'main' || roomId === 'batteries') return;
state.temperatureSensorPopup = {
active: true,
roomId,
};
renderTemperatureSensorPopup(snapshot);
}
function renderEntityPopup(snapshot) {
const backdrop = els.entityBackdrop;
if (!backdrop) return false;
const popupState = state.entityPopup || {};
const entity = popupState.active ? entityFromSnapshot(snapshot, popupState.entityId) : null;
if (!popupState.active || !entity) {
closeEntityPopup();
return false;
}
const signature = JSON.stringify([
entity.entity_id || '',
entity.state || '',
entity.attributes?.current_position ?? '',
entity.attributes?.temperature ?? '',
entity.attributes?.current_temperature ?? '',
entity.attributes?.hvac_mode ?? '',
entity.attributes?.fan_mode ?? '',
entity.attributes?.swing_mode ?? '',
entity.attributes?.preset_mode ?? '',
entity.attributes?.hvac_action ?? '',
state.editMode ? '1' : '0',
]);
if (signature === state.lastEntityPopupSignature && backdrop.classList.contains('is-open')) {
return true;
}
state.lastEntityPopupSignature = signature;
backdrop.classList.add('is-open');
backdrop.setAttribute('aria-hidden', 'false');
if (els.entityTitle) {
els.entityTitle.textContent = entity.name || 'Устройство';
}
if (els.entityEyebrow) {
els.entityEyebrow.textContent = entityKindLabel(entity);
}
if (els.entityBody) {
els.entityBody.replaceChildren();
if (entity.domain === 'cover') {
els.entityBody.appendChild(renderCoverPopup(entity));
} else if (entity.domain === 'climate') {
els.entityBody.appendChild(renderClimatePopup(entity));
} else {
const fallback = document.createElement('div');
fallback.className = 'entity-modal__fallback';
fallback.textContent = 'Для этого типа пока нет popup.';
els.entityBody.appendChild(fallback);
}
}
return true;
}
function renderTemperatureSensorPopup(snapshot) {
const backdrop = els.temperatureSensorBackdrop;
if (!backdrop) return false;
if (isMobileViewport() || !state.editMode) {
closeTemperatureSensorPopup();
return false;
}
const popupState = state.temperatureSensorPopup || {};
const roomId = popupState.active ? popupState.roomId : null;
const room = roomId
? (snapshot.space_index?.[roomId]
|| (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null)
|| (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null))
: null;
if (!popupState.active || !room || roomId === 'main' || roomId === 'batteries') {
closeTemperatureSensorPopup();
return false;
}
const candidates = roomTemperatureSensorCandidates(snapshot, roomId);
const selectedId = String(room.temperature_sensor_entity_id || room?.temperature_sensor_entity_id || '').trim();
const signature = JSON.stringify([
roomId,
selectedId,
candidates.map((entity) => `${entity.entity_id}:${entity.state}:${entity.attributes?.current_temperature ?? entity.attributes?.temperature ?? ''}`).join('|'),
state.editMode ? '1' : '0',
]);
if (signature === state.lastTemperatureSensorPopupSignature && backdrop.classList.contains('is-open')) {
return true;
}
state.lastTemperatureSensorPopupSignature = signature;
backdrop.classList.add('is-open');
backdrop.setAttribute('aria-hidden', 'false');
if (els.temperatureSensorTitle) {
els.temperatureSensorTitle.textContent = room.name ? `Выбрать датчик температуры · ${room.name}` : 'Выбрать датчик температуры';
}
if (!els.temperatureSensorBody) {
return true;
}
els.temperatureSensorBody.replaceChildren();
const current = document.createElement('div');
current.className = 'temperature-sensor-modal__current';
const selectedEntity = selectedId
? candidates.find((entity) => String(entity.entity_id || '') === selectedId)
: null;
current.innerHTML = `
<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();
const haNative = isHaRuntime();
const embedded = Boolean(state.embedMode || haNative);
state.embedMode = embedded;
document.body.classList.toggle('is-mobile-ui', mobile);
document.body.classList.toggle('is-embedded', embedded);
document.body.classList.toggle('is-ha-native', haNative);
els.appShell.classList.toggle('is-mobile', mobile);
els.appShell.classList.toggle('is-desktop', !mobile);
els.appShell.classList.toggle('app-shell--embed', embedded);
els.appShell.classList.toggle('is-ha-native', haNative);
els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room');
els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room');
if (els.selectedRoomBack) {
els.selectedRoomBack.hidden = !isMobileRoomView();
}
}
function normalizePositionValue(value) {
const next = Number(value);
if (!Number.isFinite(next)) return null;
return Math.max(0, Math.min(100, Math.round(next)));
}
function shouldShowMainEntity(entity) {
if (!entity) return false;
const domain = String(entity.domain || entity.entity_id?.split('.')?.[0] || '').toLowerCase();
const state = String(entity.state || '').toLowerCase();
const isAuto = Boolean(entity.is_auto);
const isHidden = Boolean(entity.is_hidden);
const isDoorContact = Boolean(entity.is_door_contact);
if (!isAuto || isHidden) return false;
if (!['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain)) return false;
if (domain === 'binary_sensor' && !isDoorContact) return false;
return domain === 'cover'
? ['open', 'opening', 'closing'].includes(state)
: domain === 'binary_sensor'
? ['on', 'open'].includes(state)
: ['on', 'cool', 'heat', 'heating', 'cooling'].includes(state);
}
function mainCardsContainer() {
return q('.main-dashboard__cards', els.dashboardSurface);
}
function renderMainRoomSummaryGrid(snapshot) {
const rooms = Array.isArray(snapshot?.spaces) ? snapshot.spaces : Array.isArray(snapshot?.rooms) ? snapshot.rooms : [];
const batteryRoom = snapshot?.battery_room || null;
const cards = document.createElement('div');
cards.className = 'room-list__group main-dashboard__room-grid';
const roomCard = (room, options = {}) => {
if (!room) return null;
const card = document.createElement('div');
card.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${options.hidden ? 'is-hidden-room' : ''}`.trim();
card.dataset.roomId = room.id;
card.tabIndex = 0;
card.setAttribute('role', 'button');
card.addEventListener('click', () => setSelectedRoom(room.id));
card.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedRoom(room.id);
}
});
const content = document.createElement('div');
content.className = 'room-item__content';
const icon = document.createElement('div');
icon.className = 'room-item__icon';
icon.appendChild(createIconElement(room.icon || 'mdi:home-variant'));
const body = document.createElement('div');
body.className = 'room-item__body';
const activeCount = room.id === 'batteries'
? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0
: Number(room.active_entity_count ?? room.entity_count ?? 0) || 0;
const metaText = room.id === 'main'
? 'Главный экран'
: room.id === 'batteries'
? (room.battery_summary_text || `${room.entity_count || 0} батареек`)
: activeCount > 0
? `${activeCount} ${pluralizeActiveEntities(activeCount)}`
: 'Нет активных';
body.innerHTML = `
<div class="room-item__name">${esc(room.name || '')}</div>
<div class="room-item__meta">${esc(metaText)}</div>
`;
content.append(icon, body);
const tempBadge = roomTemperatureBadge(snapshot, room);
if (tempBadge) {
card.classList.add('has-temp');
const temp = document.createElement('div');
temp.className = 'room-item__temp';
temp.textContent = tempBadge;
card.appendChild(temp);
}
card.append(content);
return card;
};
const orderedRooms = [...rooms]
.filter((room) => room && room.id !== 'main' && room.visible !== false && room.id !== 'batteries')
.sort((left, right) => {
const leftOrder = Number(left.order ?? 9999);
const rightOrder = Number(right.order ?? 9999);
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(left.name || '').localeCompare(String(right.name || ''), 'ru');
});
orderedRooms.forEach((room) => {
const card = roomCard(room);
if (card) cards.appendChild(card);
});
if (batteryRoom && !isMobileViewport()) {
const card = roomCard(batteryRoom);
if (card) cards.appendChild(card);
}
return cards;
}
function currentDashboardCardsContainer() {
const snapshot = state.snapshot || bootstrap;
const room = snapshot.selected_space || snapshot.selected_room || {};
if (room.id === 'main') {
return mainCardsContainer();
}
return els.dashboardSurface;
}
function findRenderedCard(entityId) {
if (!entityId) return null;
return q(`[data-entity-id="${CSS.escape(entityId)}"]`, els.dashboardSurface);
}
function sortMainCardsBySnapshot(container) {
const snapshot = state.snapshot || {};
const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((entity) => entity.entity_id);
const order = new Map(orderedMainIds.map((entityId, index) => [entityId, index]));
const cards = Array.from(container?.querySelectorAll('.grid-card[data-entity-id]') || []);
cards.sort((left, right) => {
const leftId = left.dataset.entityId || '';
const rightId = right.dataset.entityId || '';
const leftOrder = order.has(leftId) ? order.get(leftId) : Number.MAX_SAFE_INTEGER;
const rightOrder = order.has(rightId) ? order.get(rightId) : Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return leftId.localeCompare(rightId, 'ru');
});
cards.forEach((card) => container.appendChild(card));
}
function updateMainWeatherCard() {
const snapshot = state.snapshot || {};
const hero = q('.main-dashboard__hero', els.dashboardSurface);
if (!hero) return false;
const next = renderMainHero(snapshot);
hero.replaceWith(next);
return true;
}
function updateMainEntityCard(entityId) {
const snapshot = state.snapshot || {};
const container = mainCardsContainer();
if (!container) return false;
const entity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId);
const existing = q(`[data-entity-id="${CSS.escape(entityId)}"]`, container);
const shouldShow = shouldShowMainEntity(entity);
if (existing && !shouldShow) {
existing.remove();
return true;
}
if (!shouldShow) {
return false;
}
const nextCard = renderEntityCard(entity, { isMain: true });
if (existing) {
existing.replaceWith(nextCard);
return true;
}
const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((item) => item.entity_id);
const nextIndex = orderedMainIds.indexOf(entityId);
const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]'));
for (const card of cards) {
const cardId = card.dataset.entityId;
const cardIndex = orderedMainIds.indexOf(cardId);
if (cardIndex > nextIndex && nextIndex !== -1) {
card.before(nextCard);
return true;
}
}
container.appendChild(nextCard);
return true;
}
function updateRoomEntityCard(entityId) {
const snapshot = state.snapshot || {};
const room = snapshot.selected_space || snapshot.selected_room || {};
if (room.id === 'main') {
return updateMainEntityCard(entityId);
}
if (room.id === 'batteries') {
renderDashboardOnly();
return true;
}
renderDashboardOnly();
return true;
}
function setCardInteractionLock(entityId) {
state.roomDrag = state.roomDrag || {};
state.roomDrag.suppressClickUntil = Date.now() + 180;
state.roomDrag.entityId = entityId;
}
function openConfirm(options = {}) {
const backdrop = els.confirmBackdrop;
if (!backdrop) return Promise.resolve(false);
els.confirmTitle.textContent = options.title || 'Хотите закрыть?';
els.confirmMessage.textContent = options.message || 'Это действие отправит команду закрытия.';
backdrop.classList.add('is-open');
backdrop.setAttribute('aria-hidden', 'false');
return new Promise((resolve) => {
state.confirmResolver = resolve;
const finish = (result) => {
if (state.confirmResolver) {
const resolver = state.confirmResolver;
state.confirmResolver = null;
resolver(result);
}
backdrop.classList.remove('is-open');
backdrop.setAttribute('aria-hidden', 'true');
};
const onYes = () => finish(true);
const onNo = () => finish(false);
const onBackdrop = (event) => {
if (event.target === backdrop) onNo();
};
const cleanup = () => {
els.confirmYes.removeEventListener('click', onYes);
els.confirmNo.removeEventListener('click', onNo);
backdrop.removeEventListener('click', onBackdrop);
};
els.confirmYes.addEventListener('click', () => {
cleanup();
onYes();
}, { once: true });
els.confirmNo.addEventListener('click', () => {
cleanup();
onNo();
}, { once: true });
backdrop.addEventListener('click', (event) => {
if (event.target === backdrop) {
cleanup();
onNo();
}
}, { once: true });
});
}
function roomCollections() {
const snapshot = state.snapshot || {};
const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : Array.isArray(snapshot.rooms) ? snapshot.rooms : [];
const visible = spaces.filter((room) => room.id !== 'main' && room.visible !== false);
const hidden = spaces.filter((room) => room.id !== 'main' && room.visible === false);
return { visible, hidden };
}
function orderedRoomIdsFromGroup(groupEl) {
return qa('.room-item', groupEl)
.map((item) => item.dataset.roomId)
.filter((roomId) => roomId && roomId !== 'main');
}
function roomById(roomId) {
const snapshot = state.snapshot || {};
const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : Array.isArray(snapshot.rooms) ? snapshot.rooms : [];
return spaces.find((room) => room.id === roomId) || null;
}
async function persistRoomOrderForGroup(groupEl, hidden = false) {
const snapshot = state.snapshot || {};
const roomIds = orderedRoomIdsFromGroup(groupEl);
if (!roomIds.length) return;
const baseOrder = hidden ? 1000 : 0;
const roomsById = new Map((snapshot.spaces || snapshot.rooms || []).map((room) => [room.id, room]));
const changes = roomIds.map((roomId, index) => {
const room = roomsById.get(roomId);
const nextOrder = baseOrder + (index * 10);
if (!room || Number(room.order ?? 9999) === nextOrder) {
return null;
}
return { roomId, nextOrder };
}).filter(Boolean);
if (!changes.length) return;
await Promise.all(changes.map(({ roomId, nextOrder }) => apiPost('save-space-override', {
room_id: roomId,
order: nextOrder,
})));
changes.forEach(({ roomId, nextOrder }) => {
patchSnapshotSpace(roomId, { order: nextOrder });
});
renderSidebarOnly();
}
function clearRoomDragState() {
const drag = state.roomDrag;
if (!drag) return;
if (drag.itemEl) {
drag.itemEl.classList.remove('is-dragging');
drag.itemEl.removeAttribute('aria-grabbed');
}
state.roomDrag = null;
}
function startRoomDrag(room, itemEl, groupEl, hidden, event) {
if (!state.editMode || room.id === 'main') return;
if (event.target.closest('button')) return;
const pointerId = event.pointerId;
const drag = {
roomId: room.id,
itemEl,
groupEl,
hidden,
pointerId,
startX: event.clientX,
startY: event.clientY,
moved: false,
suppressClickUntil: 0,
};
state.roomDrag = drag;
itemEl.classList.add('is-dragging');
itemEl.setAttribute('aria-grabbed', 'true');
if (itemEl.setPointerCapture) {
try {
itemEl.setPointerCapture(pointerId);
} catch (error) {
console.warn(error);
}
}
event.preventDefault();
}
function roomDropTargetAtPoint(x, y, groupEl, draggedId) {
const node = document.elementFromPoint(x, y);
const item = node?.closest?.('.room-item');
if (!item || item.dataset.roomId === draggedId || item.dataset.roomGroup !== (state.roomDrag?.hidden ? 'hidden' : 'visible')) {
return null;
}
if (!groupEl.contains(item)) {
return null;
}
return item;
}
function moveRoomDrag(clientX, clientY) {
const drag = state.roomDrag;
if (!drag) return;
const dx = Math.abs(clientX - drag.startX);
const dy = Math.abs(clientY - drag.startY);
if (!drag.moved && Math.max(dx, dy) < 6) {
return;
}
drag.moved = true;
const target = roomDropTargetAtPoint(clientX, clientY, drag.groupEl, drag.roomId);
if (!target) return;
const targetRect = target.getBoundingClientRect();
const before = clientY < (targetRect.top + targetRect.height / 2);
if (before) {
drag.groupEl.insertBefore(drag.itemEl, target);
} else {
drag.groupEl.insertBefore(drag.itemEl, target.nextSibling);
}
}
async function finishRoomDrag() {
const drag = state.roomDrag;
if (!drag) return;
const itemEl = drag.itemEl;
const groupEl = drag.groupEl;
const hidden = drag.hidden;
const moved = drag.moved;
itemEl.classList.remove('is-dragging');
itemEl.removeAttribute('aria-grabbed');
if (drag.itemEl.releasePointerCapture && drag.pointerId !== null) {
try {
drag.itemEl.releasePointerCapture(drag.pointerId);
} catch (error) {
console.warn(error);
}
}
state.roomDrag = {
...drag,
suppressClickUntil: Date.now() + 200,
};
if (moved) {
await persistRoomOrderForGroup(groupEl, hidden);
}
window.setTimeout(() => {
if (state.roomDrag && Date.now() >= (state.roomDrag.suppressClickUntil || 0)) {
state.roomDrag = null;
}
}, 220);
}
function updateEntityInCollection(collection, entityId, updater) {
if (!Array.isArray(collection)) return false;
let changed = false;
collection.forEach((entity) => {
if (!entity || entity.entity_id !== entityId) return;
updater(entity);
changed = true;
});
return changed;
}
function updateEntityInMap(map, entityId, updater) {
if (!map || typeof map !== 'object') return false;
const entity = map[entityId];
if (!entity || typeof entity !== 'object') return false;
updater(entity);
return true;
}
function patchSnapshotEntity(entityId, patch = {}) {
const snapshot = state.snapshot || {};
let changed = false;
const applyPatch = (entity) => {
Object.assign(entity, patch);
changed = true;
};
updateEntityInCollection(snapshot.main_entities, entityId, applyPatch);
updateEntityInMap(snapshot.entity_index, entityId, applyPatch);
updateEntityInMap(snapshot.space_index, entityId, applyPatch);
if (snapshot.space_entities && typeof snapshot.space_entities === 'object') {
Object.values(snapshot.space_entities).forEach((collection) => {
updateEntityInCollection(collection, entityId, applyPatch);
});
}
if (snapshot.battery_room?.entities) {
updateEntityInCollection(snapshot.battery_room.entities, entityId, applyPatch);
}
if (snapshot.selected_space?.entities) {
updateEntityInCollection(snapshot.selected_space.entities, entityId, applyPatch);
}
if (snapshot.selected_room?.entities) {
updateEntityInCollection(snapshot.selected_room.entities, entityId, applyPatch);
}
return changed;
}
function syncMainEntities(entityId, sourceEntity, patch = {}) {
const snapshot = state.snapshot || {};
const list = Array.isArray(snapshot.main_entities) ? snapshot.main_entities : [];
const index = list.findIndex((entity) => entity && entity.entity_id === entityId);
const entity = index >= 0 ? list[index] : null;
const nextState = String(patch.state ?? sourceEntity?.state ?? '').toLowerCase();
const definition = getEntityDefinition(snapshot, entityId) || sourceEntity || entity || { entity_id: entityId };
const domain = String(definition?.domain || entity?.domain || entityId.split('.')[0] || '').toLowerCase();
const isDoorContact = Boolean(definition?.is_door_contact || entity?.is_door_contact);
const shouldDisplay = domain === 'cover'
? ['open', 'opening', 'closing'].includes(nextState)
: domain === 'binary_sensor'
? isDoorContact && ['on', 'open'].includes(nextState)
: ['on', 'cool', 'heat', 'heating', 'cooling'].includes(nextState) || (domain === 'fan' && nextState === 'on');
const isAllowedDomain = ['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain);
const isAuto = Boolean(definition?.is_auto);
if (definition?.is_hidden || !isAuto) {
if (index >= 0) {
list.splice(index, 1);
if (snapshot.selected_space?.id === 'main') {
snapshot.selected_space.entities = list;
snapshot.selected_room = snapshot.selected_space;
}
return true;
}
return false;
}
if (!isAllowedDomain || (domain === 'binary_sensor' && !isDoorContact)) {
if (index >= 0) {
list.splice(index, 1);
if (snapshot.selected_space?.id === 'main') {
snapshot.selected_space.entities = list;
snapshot.selected_room = snapshot.selected_space;
}
return true;
}
return false;
}
if (!shouldDisplay) {
if (index >= 0) {
list.splice(index, 1);
} else {
return false;
}
} else if (index >= 0) {
Object.assign(entity, {
...definition,
...patch,
});
} else {
list.push({
...definition,
...patch,
last_changed: patch.last_changed || sourceEntity?.last_changed || sourceEntity?.last_updated || new Date().toISOString(),
});
}
list.sort((a, b) => {
const timeA = entitySortTime(a);
const timeB = entitySortTime(b);
if (timeA !== timeB) return timeA - timeB;
return String(a.name || '').localeCompare(String(b.name || ''), 'ru');
});
if (snapshot.selected_space?.id === 'main') {
snapshot.selected_space.entities = list;
snapshot.selected_room = snapshot.selected_space;
}
return true;
}
function getEntityFromSnapshot(snapshot, entityId) {
if (!snapshot || !entityId) return null;
const collections = [
snapshot.main_entities,
snapshot.selected_space?.entities,
snapshot.selected_room?.entities,
];
if (snapshot.entity_index && typeof snapshot.entity_index === 'object') {
collections.push(Object.values(snapshot.entity_index));
}
if (snapshot.space_index && typeof snapshot.space_index === 'object') {
Object.values(snapshot.space_index).forEach((room) => {
if (room?.entities) {
collections.push(room.entities);
}
});
}
if (snapshot.space_entities && typeof snapshot.space_entities === 'object') {
Object.values(snapshot.space_entities).forEach((entities) => collections.push(entities));
}
if (snapshot.battery_room?.entities) {
collections.push(snapshot.battery_room.entities);
}
for (const collection of collections) {
if (!Array.isArray(collection)) continue;
const found = collection.find((entity) => entity && entity.entity_id === entityId);
if (found) return found;
}
return null;
}
function getEntityDefinition(snapshot, entityId) {
if (!snapshot || !entityId) return null;
if (snapshot.entity_index && typeof snapshot.entity_index === 'object' && snapshot.entity_index[entityId]) {
return snapshot.entity_index[entityId];
}
return getEntityFromSnapshot(snapshot, entityId);
}
function statePayloadChanged(existing, incoming) {
if (!existing || !incoming) return true;
if (String(existing.state ?? '') !== String(incoming.state ?? '')) return true;
return JSON.stringify(existing.attributes || {}) !== JSON.stringify(incoming.attributes || {});
}
function patchSnapshotSpace(roomId, patch = {}) {
const snapshot = state.snapshot || {};
const collections = [snapshot.spaces, snapshot.rooms];
let changed = false;
collections.forEach((collection) => {
if (!Array.isArray(collection)) return;
collection.forEach((room) => {
if (!room || room.id !== roomId) return;
Object.assign(room, patch);
changed = true;
});
});
if (snapshot.space_index && snapshot.space_index[roomId]) {
Object.assign(snapshot.space_index[roomId], patch);
changed = true;
}
if (snapshot.selected_space?.id === roomId) {
Object.assign(snapshot.selected_space, patch);
changed = true;
}
if (snapshot.selected_room?.id === roomId) {
Object.assign(snapshot.selected_room, patch);
changed = true;
}
return changed;
}
function patchSnapshotSelection(roomId) {
const snapshot = state.snapshot || {};
const spaces = snapshot.spaces || snapshot.rooms || [];
const room = roomId === 'main'
? {
id: 'main',
name: snapshot.settings?.main_room_name || 'Главная',
icon: snapshot.settings?.main_room_icon || 'mdi:home',
visible: true,
entities: snapshot.main_entities || [],
}
: roomId === 'batteries'
? snapshot.battery_room
: snapshot.space_index?.[roomId] || spaces.find((space) => space.id === roomId);
if (!room) return;
state.selectedRoomId = roomId;
if (roomId === 'main') {
clearRoomAutoReturnTimer();
snapshot.selected_space = {
id: 'main',
name: snapshot.settings?.main_room_name || 'Главная',
icon: snapshot.settings?.main_room_icon || 'mdi:home',
visible: true,
entities: snapshot.main_entities || [],
};
snapshot.selected_room = snapshot.selected_space;
return;
}
if (roomId === 'batteries') {
snapshot.selected_space = {
...room,
entities: Array.isArray(room.entities) ? room.entities : [],
};
snapshot.selected_room = snapshot.selected_space;
return;
}
const entities = snapshot.space_index?.[roomId]?.entities || snapshot.space_entities?.[roomId] || room.entities || [];
snapshot.selected_space = {
...room,
entities,
};
snapshot.selected_room = snapshot.selected_space;
}
function applyPopupState(active, sensorEntityId) {
if (isHaRuntime()) {
return;
}
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 = {}) {
if (isHaRuntime()) {
return;
}
const snapshot = state.snapshot || bootstrap;
snapshot.popup = mergePopupWithCamera({
...(snapshot.popup || {}),
...popup,
});
renderPopup(snapshot);
}
function syncTriggerPopup(entityId, stateValue) {
if (isHaRuntime()) {
return;
}
const value = String(stateValue || '').toLowerCase();
if (!['on', 'off'].includes(value)) {
return;
}
apiPost('popup', { sensor_entity_id: entityId, state: value })
.then((response) => {
if (response?.popup) {
applyPopupSnapshot(response.popup);
}
})
.catch((error) => {
console.warn(error);
});
}
function haConnection() {
if (isHaRuntime()) {
return null;
}
return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {};
}
function haWsUrl(baseUrl) {
if (isHaRuntime()) {
return '';
}
if (!baseUrl) return '';
try {
const url = new URL(baseUrl);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = '/api/websocket';
url.search = '';
url.hash = '';
return url.toString();
} catch (error) {
return '';
}
}
function setStatus(text, tone = '') {
if (!els.connectionStatus) return;
els.connectionStatus.textContent = text;
els.connectionStatus.dataset.tone = tone;
}
function clearRoomAutoReturnTimer() {
if (state.roomAutoReturnTimer) {
clearTimeout(state.roomAutoReturnTimer);
state.roomAutoReturnTimer = null;
}
}
function scheduleRoomAutoReturn(roomId) {
const nextRoomId = roomId || 'main';
clearRoomAutoReturnTimer();
if (nextRoomId === 'main' || isMobileViewport()) {
return;
}
state.roomAutoReturnTimer = window.setTimeout(() => {
state.roomAutoReturnTimer = null;
if ((state.selectedRoomId || 'main') === nextRoomId) {
setSelectedRoom('main');
}
}, 120000);
}
async function setSelectedRoom(roomId) {
const nextRoomId = roomId || 'main';
const token = ++state.roomSelectionToken;
clearRoomAutoReturnTimer();
closeTemperatureSensorPopup();
patchSnapshotSelection(nextRoomId);
if (isMobileViewport()) {
setMobileView('room');
}
render();
scheduleRoomAutoReturn(nextRoomId);
try {
const snapshot = await fetchSnapshot(nextRoomId);
if (token !== state.roomSelectionToken) {
return;
}
state.snapshot = snapshot;
patchSnapshotSelection(nextRoomId);
render();
} catch (error) {
if (token !== state.roomSelectionToken) {
return;
}
console.warn(error);
}
}
function createButton(label, subtitle, icon, className = '', attrs = {}) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = className;
Object.entries(attrs).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
btn.dataset[key] = value;
}
});
const iconEl = document.createElement('i');
iconEl.className = iconClass(icon);
const body = document.createElement('div');
body.className = 'mushroom-button__body';
const titleEl = document.createElement('div');
titleEl.className = 'mushroom-button__title';
titleEl.textContent = label;
body.appendChild(titleEl);
if (subtitle) {
const subEl = document.createElement('div');
subEl.className = 'mushroom-button__subtitle';
subEl.textContent = subtitle;
body.appendChild(subEl);
}
btn.appendChild(iconEl);
btn.appendChild(body);
return btn;
}
function mainWeatherCard(weather) {
const card = document.createElement('article');
card.className = 'grid-card grid-card--weather grid-card--weather-compact';
const inner = document.createElement('div');
inner.className = 'grid-card__inner weather-card weather-card--compact';
const title = document.createElement('div');
title.className = 'grid-card__title_weather';
title.textContent = 'Погода';
const rows = document.createElement('div');
rows.className = 'weather-card__rows';
[
['Интернет', weather?.temperature != null ? `${Number(weather.temperature).toFixed(0)}°C` : '—'],
['Датчик', weather?.sensor_temperature != null ? `${weather.sensor_temperature}°C` : '—'],
['Ветер', weather?.wind_speed != null ? `${Math.round(Number(weather.wind_speed))} км/ч` : '—'],
].forEach(([label, value]) => {
const row = document.createElement('div');
row.className = 'weather-card__row';
row.innerHTML = `<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 initialValue = coverPositionValue(entity);
const valueRow = document.createElement('div');
valueRow.className = 'entity-modal__cover-meta';
const label = document.createElement('div');
label.className = 'entity-modal__cover-label';
label.textContent = 'Открыт на';
const value = document.createElement('div');
value.className = 'entity-modal__cover-value';
value.textContent = `${initialValue}%`;
valueRow.append(label, value);
const actions = document.createElement('div');
actions.className = 'entity-modal__actions entity-modal__actions--vertical';
const openBtn = createButton('Открыть', null, 'mdi:arrow-up', 'mushroom-button mushroom-button--small mushroom-button--square');
openBtn.addEventListener('click', () => handleEntityAction(entity, 'open'));
const stopBtn = createButton('Стоп', null, 'mdi:stop', 'mushroom-button mushroom-button--small mushroom-button--square');
stopBtn.addEventListener('click', () => handleEntityAction(entity, 'stop'));
const closeBtn = createButton('Закрыть', null, 'mdi:arrow-down', 'mushroom-button mushroom-button--small mushroom-button--square');
closeBtn.addEventListener('click', () => handleEntityAction(entity, 'close'));
actions.append(openBtn, stopBtn, closeBtn);
const progress = document.createElement('div');
progress.className = 'entity-modal__cover-track';
progress.tabIndex = 0;
progress.setAttribute('role', 'slider');
progress.setAttribute('aria-label', 'Позиция жалюзи');
progress.setAttribute('aria-valuemin', '0');
progress.setAttribute('aria-valuemax', '100');
const fill = document.createElement('div');
fill.className = 'entity-modal__cover-fill';
fill.style.height = `${initialValue}%`;
fill.style.bottom = '0';
fill.style.width = '100%';
progress.appendChild(fill);
const handle = document.createElement('div');
handle.className = 'entity-modal__cover-handle';
progress.appendChild(handle);
let currentValue = initialValue;
const syncValue = (nextValue) => {
currentValue = Math.max(0, Math.min(100, Math.round(nextValue)));
fill.style.height = `${currentValue}%`;
handle.style.bottom = `calc(${currentValue}% - 10px)`;
value.textContent = `${currentValue}%`;
progress.setAttribute('aria-valuenow', String(currentValue));
progress.setAttribute('aria-valuetext', `${currentValue}%`);
};
syncValue(initialValue);
const updateFromPointer = (clientY) => {
const rect = progress.getBoundingClientRect();
const ratio = 1 - ((clientY - rect.top) / rect.height);
const nextValue = Math.max(0, Math.min(100, Math.round(ratio * 100)));
syncValue(nextValue);
};
let dragPointerId = null;
const onPointerMove = (event) => {
if (dragPointerId !== event.pointerId) return;
event.preventDefault();
updateFromPointer(event.clientY);
};
const onPointerUp = (event) => {
if (dragPointerId !== event.pointerId) return;
event.preventDefault();
progress.releasePointerCapture?.(dragPointerId);
dragPointerId = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
handleCoverPosition(entity, currentValue);
};
progress.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
dragPointerId = event.pointerId;
progress.setPointerCapture?.(dragPointerId);
updateFromPointer(event.clientY);
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', onPointerUp, { passive: false });
window.addEventListener('pointercancel', onPointerUp, { passive: false });
});
progress.addEventListener('keydown', (event) => {
const step = event.shiftKey ? 10 : 5;
if (event.key === 'ArrowUp' || event.key === 'ArrowRight') {
event.preventDefault();
syncValue(currentValue + step);
handleCoverPosition(entity, currentValue);
} else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') {
event.preventDefault();
syncValue(currentValue - step);
handleCoverPosition(entity, currentValue);
} else if (event.key === 'Home') {
event.preventDefault();
syncValue(0);
handleCoverPosition(entity, 0);
} else if (event.key === 'End') {
event.preventDefault();
syncValue(100);
handleCoverPosition(entity, 100);
}
});
rail.append(valueRow, progress);
wrap.append(rail, actions);
return wrap;
}
function renderClimatePopup(entity) {
const wrap = document.createElement('div');
wrap.className = 'entity-modal__climate';
const tempBlock = document.createElement('div');
tempBlock.className = 'entity-modal__climate-summary';
tempBlock.innerHTML = `
<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.appendChild(createIconElement('mdi:minus'));
minus.addEventListener('click', () => handleClimateTemperature(entity, -1));
const plus = document.createElement('button');
plus.type = 'button';
plus.className = 'round-button entity-modal__round-button';
plus.appendChild(createIconElement('mdi:plus'));
plus.addEventListener('click', () => handleClimateTemperature(entity, 1));
controls.append(minus, plus);
const modes = document.createElement('div');
modes.className = 'entity-modal__modes';
[
['hvac_modes', 'Режим', 'set_hvac_mode'],
['fan_modes', 'Вентилятор', 'set_fan_mode'],
['swing_modes', 'Качание', 'set_swing_mode'],
['preset_modes', 'Предустановки', 'set_preset_mode'],
].forEach(([attrName, title, command]) => {
const block = climateOptionButtons(entity, attrName, command, title);
if (block) {
modes.appendChild(block);
}
});
wrap.append(tempBlock, controls, modes);
return wrap;
}
function climateGroupTitle(attrName) {
const key = String(attrName || '').toLowerCase();
switch (key) {
case 'hvac_modes':
return 'Режим';
case 'fan_modes':
return 'Вентилятор';
case 'swing_modes':
return 'Качание';
case 'preset_modes':
return 'Предустановки';
default:
return 'Режим';
}
}
function climateOptionLabel(attrName, value) {
const key = String(value ?? '').trim().toLowerCase();
const normalized = key.replace(/\s+/g, '_');
const maps = {
hvac_modes: {
off: 'Выключено',
auto: 'Авто',
cool: 'Охлаждение',
heat: 'Обогрев',
dry: 'Осушение',
fan_only: 'Только вентилятор',
heat_cool: 'Авто',
eco: 'Эко',
away: 'Вне дома',
sleep: 'Сон',
},
fan_modes: {
auto: 'Авто',
low: 'Низкая',
low_mid: 'Ниже средней',
low_medium: 'Ниже средней',
medium: 'Средняя',
mid: 'Средняя',
mid_high: 'Выше средней',
high: 'Высокая',
turbo: 'Турбо',
diffuse: 'Рассеянный',
},
swing_modes: {
off: 'Выкл',
top: 'Верх',
middletop1: 'Верх 1',
middletop2: 'Верх 2',
middlebottom2: 'Низ 2',
middlebottom1: 'Низ 1',
bottom: 'Низ',
swing: 'Авто',
auto: 'Авто',
},
preset_modes: {
none: 'Нет',
sleep: 'Сон',
boost: 'Турбо',
eco: 'Эко',
away: 'Вне дома',
home: 'Дома',
comfort: 'Комфорт',
quiet: 'Тихо',
},
};
const map = maps[String(attrName || '').toLowerCase()] || {};
if (map[normalized]) {
return map[normalized];
}
return String(value ?? '').replace(/_/g, ' ');
}
function climateStateLabel(value) {
const key = String(value ?? '').trim().toLowerCase();
const labels = {
off: 'Выключено',
idle: 'Ожидание',
auto: 'Авто',
cool: 'Охлаждение',
heating: 'Нагрев',
heat: 'Обогрев',
cooling: 'Охлаждение',
dry: 'Осушение',
fan: 'Вентиляция',
heat_cool: 'Авто',
on: 'Включено',
};
return labels[key] || String(value ?? '—').replace(/_/g, ' ');
}
function coverPositionValue(entity) {
const currentPosition = Number(entity?.attributes?.current_position);
if (Number.isFinite(currentPosition)) {
return Math.max(0, Math.min(100, Math.round(currentPosition)));
}
const state = String(entity?.state || '').toLowerCase();
return state === 'open' || state === 'opening' ? 100 : 0;
}
async function handleEntityService(entity, command, value = null, patch = null) {
try {
if (patch) {
patchSnapshotEntity(entity.entity_id, patch);
}
if (state.entityPopup?.active) {
renderEntityPopup(state.snapshot || bootstrap);
}
refreshCurrentRoomLayout(entity.entity_id);
await apiPost('service', {
entity_id: entity.entity_id,
command,
...(value !== null && value !== undefined ? { value } : {}),
});
if (state.entityPopup?.active) {
renderEntityPopup(state.snapshot || bootstrap);
}
} catch (error) {
console.error(error);
setStatus('Ошибка команды', 'error');
}
}
function handleCoverPosition(entity, value) {
const next = normalizePositionValue(value);
if (next === null) return;
const currentPosition = Number(entity.attributes?.current_position);
let stateValue = undefined;
if (next === 0) {
stateValue = 'closed';
} else if (next === 100) {
stateValue = 'open';
} else if (Number.isFinite(currentPosition)) {
stateValue = next >= currentPosition ? 'opening' : 'closing';
}
const patch = {
attributes: {
...(entity.attributes || {}),
current_position: next,
},
};
if (stateValue) {
patch.state = stateValue;
}
handleEntityService(entity, 'set_position', next, patch);
}
async function handleMainWeatherAction(action) {
const snapshot = state.snapshot || bootstrap;
const entityId = String(action?.entity_id || '').trim();
if (!entityId) return;
const command = String(action?.command || 'set_temperature').trim() || 'set_temperature';
const value = action?.value;
const stateEntityId = String(action?.state_entity_id || entityId).trim() || entityId;
const stateEntity = getEntityFromSnapshot(snapshot, stateEntityId) || getEntityDefinition(snapshot, stateEntityId);
const targetEntity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId);
const nextValue = value !== null && value !== undefined && value !== '' ? String(value) : '';
if (stateEntity) {
const nextAttributes = { ...(stateEntity.attributes || {}) };
if (command === 'set_temperature' && value !== null && value !== undefined && value !== '') {
nextAttributes.temperature = Number(value);
}
const patch = {
attributes: nextAttributes,
};
if (stateEntityId !== entityId || command !== 'set_temperature') {
patch.state = nextValue || stateEntity.state;
}
patchSnapshotEntity(stateEntityId, patch);
}
if (targetEntity && targetEntity.entity_id !== stateEntityId) {
const nextAttributes = { ...(targetEntity.attributes || {}) };
if (command === 'set_temperature' && value !== null && value !== undefined && value !== '') {
nextAttributes.temperature = Number(value);
}
patchSnapshotEntity(targetEntity.entity_id, {
attributes: nextAttributes,
});
}
renderDashboardOnly();
try {
await apiPost('service', {
entity_id: entityId,
command,
...(value !== null && value !== undefined ? { value } : {}),
});
} catch (error) {
console.error(error);
setStatus('Ошибка команды', 'error');
}
}
function handleClimateCommand(entity, command, value) {
const patch = {};
if (command === 'set_hvac_mode') {
patch.attributes = {
...(entity.attributes || {}),
hvac_mode: value,
};
} else if (command === 'set_fan_mode') {
patch.attributes = {
...(entity.attributes || {}),
fan_mode: value,
};
} else if (command === 'set_swing_mode') {
patch.attributes = {
...(entity.attributes || {}),
swing_mode: value,
};
} else if (command === 'set_preset_mode') {
patch.attributes = {
...(entity.attributes || {}),
preset_mode: value,
};
}
handleEntityService(entity, command, value, Object.keys(patch).length ? patch : null);
}
function renderToggleCard(entity, { isMain = false } = {}) {
const card = document.createElement('article');
const active = ['on', 'open', 'cool', 'heat', 'heating', 'cooling'].includes(String(entity.state).toLowerCase());
const isDoorContact = Boolean(entity.is_door_contact);
card.className = `grid-card ${!isDoorContact ? 'grid-card--tap ' : ''}${isMain ? 'grid-card--auto' : 'grid-card--entity'} ${isDoorContact ? 'grid-card--door' : ''} ${active ? 'is-active' : ''}`;
card.dataset.entityId = entity.entity_id;
if (!isDoorContact) {
card.dataset.clickToggle = 'true';
card.tabIndex = 0;
card.setAttribute('role', 'button');
}
const inner = document.createElement('div');
inner.className = 'grid-card__inner';
const icon = document.createElement('div');
icon.className = `grid-card__icon${isMain && active && !isDoorContact ? ' grid-card__icon--active' : ''}${isDoorContact ? ' grid-card__icon--door' : ''}`;
icon.appendChild(createIconElement(entity.icon));
const text = buildEntityTitle(entity.name);
const left = document.createElement('div');
left.className = 'grid-card__header';
left.append(icon, text);
if (state.editMode) {
left.appendChild(renderEntityTypeLabel(entity));
}
inner.appendChild(left);
if (state.editMode) {
inner.appendChild(renderEditActions(entity));
}
card.appendChild(inner);
if (!isDoorContact) {
card.addEventListener('click', (event) => {
if (event.target.closest('button')) return;
handleEntityAction(entity, 'toggle');
});
card.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleEntityAction(entity, 'toggle');
}
});
}
return card;
}
function renderCoverCard(entity, options = {}) {
const card = document.createElement('article');
const currentPosition = coverPositionValue(entity);
const coverState = String(entity.state).toLowerCase();
const isOpen = ['open', 'opening'].includes(coverState);
const hasVisiblePosition = currentPosition > 0 || isOpen;
card.className = `grid-card grid-card--cover ${!options.isMain && isOpen ? 'is-active' : ''}`;
card.dataset.entityId = entity.entity_id;
card.tabIndex = 0;
card.setAttribute('role', 'button');
const inner = document.createElement('div');
inner.className = 'grid-card__inner cover-card';
const icon = document.createElement('div');
icon.className = `grid-card__icon${options.isMain && isOpen ? ' grid-card__icon--active' : ''}`;
icon.appendChild(createIconElement(entity.icon));
const text = buildEntityTitle(entity.name);
const left = document.createElement('div');
left.className = 'grid-card__header';
left.append(icon, text);
if (state.editMode) {
left.appendChild(renderEntityTypeLabel(entity));
}
const rail = document.createElement('div');
rail.className = 'cover-card__rail';
if (hasVisiblePosition) {
const progress = document.createElement('div');
progress.className = 'cover-progress';
const bar = document.createElement('div');
bar.className = 'cover-progress__value';
const pos = currentPosition > 0 ? currentPosition : 100;
bar.style.width = `${Math.max(0, Math.min(100, pos))}%`;
progress.appendChild(bar);
if (!options.isMain) {
rail.append(progress);
inner.append(left, rail);
if (state.editMode) {
inner.appendChild(renderEditActions(entity));
}
} else {
inner.append(left, progress);
}
} else {
inner.append(left);
if (state.editMode) {
inner.appendChild(renderEditActions(entity));
}
}
card.appendChild(inner);
card.addEventListener('click', async (event) => {
if (event.target.closest('button')) return;
if (options.isMain) {
const confirmed = await openConfirm({
title: 'Хотите закрыть?',
message: `${entity.name} будет закрыт.`,
});
if (confirmed) {
handleEntityAction(entity, 'close');
}
return;
}
openEntityPopup(entity.entity_id);
});
card.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
if (options.isMain) {
const confirmed = await openConfirm({
title: 'Хотите закрыть?',
message: `${entity.name} будет закрыт.`,
});
if (confirmed) {
handleEntityAction(entity, 'close');
}
return;
}
openEntityPopup(entity.entity_id);
});
return card;
}
function renderClimateCard(entity, options = {}) {
const card = document.createElement('article');
const active = !['off', 'unavailable', 'unknown'].includes(String(entity.state).toLowerCase());
card.className = `grid-card grid-card--climate grid-card--tap ${!options.isMain && active ? 'is-active' : ''}`;
card.dataset.entityId = entity.entity_id;
card.dataset.clickToggle = 'true';
card.tabIndex = 0;
card.setAttribute('role', 'button');
const inner = document.createElement('div');
inner.className = 'grid-card__inner climate-card';
const icon = document.createElement('div');
icon.className = `grid-card__icon${options.isMain && active ? ' grid-card__icon--active' : ''}`;
icon.appendChild(createIconElement(entity.icon));
const text = buildEntityTitle(entity.name);
const left = document.createElement('div');
left.className = 'grid-card__header';
left.append(icon, text);
if (state.editMode) {
left.appendChild(renderEntityTypeLabel(entity));
}
const tempMeta = document.createElement('div');
tempMeta.className = 'climate-card__meta';
tempMeta.innerHTML = `
<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.appendChild(createIconElement('mdi:arrow-up'));
upBtn.appendChild(document.createTextNode(' Вверх'));
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.appendChild(createIconElement('mdi:arrow-down'));
downBtn.appendChild(document.createTextNode(' Вниз'));
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.appendChild(createIconElement(hidden ? 'mdi:eye' : 'mdi:eye-off'));
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.appendChild(createIconElement('mdi:cog-outline'));
settingsBtn.appendChild(document.createTextNode(' Настройки'));
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.appendChild(createIconElement('mdi:arrow-up'));
upBtn.appendChild(document.createTextNode(' Вверх'));
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.appendChild(createIconElement('mdi:arrow-down'));
downBtn.appendChild(document.createTextNode(' Вниз'));
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.appendChild(createIconElement('mdi:delete-outline'));
deleteBtn.appendChild(document.createTextNode(' Удалить'));
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.appendChild(createIconElement('mdi:thermometer'));
tempBtn.appendChild(document.createTextNode(' Выбрать датчик температуры'));
tempBtn.addEventListener('click', (event) => {
event.stopPropagation();
openTemperatureSensorPopup(room.id);
});
settings.appendChild(tempBtn);
wrap.appendChild(settings);
}
return wrap;
}
async function reorderRoomGridEntry(roomId, kind, entryId, direction) {
const nextRoomId = roomId || currentRoom()?.id || state.selectedRoomId || 'main';
if (!nextRoomId || nextRoomId === 'main' || !entryId || !direction || isMobileViewport()) {
return null;
}
try {
const snapshot = state.snapshot || bootstrap;
const entries = roomGridEntries(snapshot, nextRoomId);
const currentIndex = entries.findIndex((entry) => entry.kind === kind && entry.id === entryId);
if (currentIndex < 0) {
return null;
}
const targetIndex = currentIndex + direction;
if (targetIndex < 0 || targetIndex >= entries.length) {
return null;
}
const reordered = entries.slice();
const [moved] = reordered.splice(currentIndex, 1);
reordered.splice(targetIndex, 0, moved);
await apiPost('reorder-room-grid', {
room_id: nextRoomId,
entries: reordered.map((entry) => ({
kind: entry.kind,
id: entry.id,
})),
});
try {
await loadSnapshot(state.selectedRoomId || nextRoomId || 'main');
} catch (reloadError) {
console.warn(reloadError);
}
render();
} catch (error) {
console.error(error);
setStatus('Ошибка сохранения', 'error');
}
return null;
}
function renderBatteryCard(item) {
const status = String(item.battery_status || 'unknown');
const card = document.createElement('article');
card.className = `grid-card battery-card battery-card--${status}`;
card.dataset.entityId = item.entity_id;
card.dataset.batteryStatus = status;
const inner = document.createElement('div');
inner.className = 'grid-card__inner battery-card__inner';
const main = document.createElement('div');
main.className = 'battery-card__main';
const icon = document.createElement('div');
icon.className = 'battery-card__icon';
icon.appendChild(createIconElement(item.battery_icon || 'mdi:battery-outline'));
const text = document.createElement('div');
text.className = 'battery-card__text';
const title = document.createElement('div');
title.className = 'battery-card__title';
title.textContent = item.name || item.entity_id;
const source = document.createElement('div');
source.className = 'battery-card__source';
source.textContent = item.source_text || [item.source_room_name, item.source_device_name].filter(Boolean).join(' | ') || 'Без комнаты';
text.append(title, source);
main.append(icon, text);
const side = document.createElement('div');
side.className = 'battery-card__side';
const percent = document.createElement('div');
percent.className = 'battery-card__percent';
percent.textContent = item.battery_percent_text || item.battery_status_label || '—';
const statusLabel = document.createElement('div');
statusLabel.className = 'battery-card__status';
statusLabel.textContent = item.battery_status_label || 'Неизвестно';
side.append(percent, statusLabel);
const footer = document.createElement('div');
footer.className = 'battery-card__footer';
footer.textContent = item.forecast_text
|| item.forecast_reason
|| (status === 'ok' ? 'Прогноз недоступен' : item.battery_status_label || '');
const hasFooter = Boolean(footer.textContent);
inner.append(main, side);
if (hasFooter) {
inner.appendChild(footer);
}
card.appendChild(inner);
return card;
}
function renderRoomButtons(snapshot, rooms, batteryRoom = null) {
if (!els.roomList) {
return;
}
els.roomList.innerHTML = '';
const sortedRooms = [...(rooms || [])].sort((left, right) => {
const leftVisible = left?.visible === false ? 1 : 0;
const rightVisible = right?.visible === false ? 1 : 0;
if (leftVisible !== rightVisible) {
return leftVisible - rightVisible;
}
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 || {};
const setText = (el, value) => {
if (el) {
el.textContent = value;
}
};
if (els.contentTop) {
els.contentTop.classList.toggle('is-main', room.id === 'main');
}
if (els.contentHeader) {
els.contentHeader.classList.toggle('hidden', room.id === 'main' && !isMobileRoomView());
}
updateMainPrintStrip(snapshot);
if (room.id === 'batteries') {
setText(els.selectedRoomEyebrow, 'Псевдо-комната');
setText(els.selectedRoomTitle, room.name || 'Батарейки');
const total = Number(room.entity_count ?? 0) || 0;
const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0;
const unavailable = Number(room.unavailable_count ?? 0) || 0;
const unknown = Number(room.unknown_count ?? 0) || 0;
const summaryParts = [];
if (critical > 0) {
summaryParts.push(`${critical} ${pluralizeRu(critical, 'проблемная', 'проблемных', 'проблемных')}`);
}
if (unavailable > 0) {
summaryParts.push(`${unavailable} ${pluralizeRu(unavailable, 'недоступная', 'недоступных', 'недоступных')}`);
}
if (unknown > 0) {
summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`);
}
setText(els.selectedRoomMeta, summaryParts.length
? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`
: `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`);
renderSelectedRoomActions(snapshot);
return;
}
if (room.id !== 'main') {
setText(els.selectedRoomEyebrow, 'Пространство');
setText(els.selectedRoomTitle, room.name || 'Панель');
const entities = roomEntities(snapshot, room.id || 'main');
const activeCount = Number(room.active_entity_count ?? entities.length) || 0;
setText(els.selectedRoomMeta, `${activeCount} ${pluralizeActiveEntities(activeCount)}`);
renderSelectedRoomActions(snapshot);
return;
}
const entities = roomEntities(snapshot, room.id || 'main');
setText(els.selectedRoomEyebrow, '');
setText(els.selectedRoomTitle, room.name || 'Панель');
setText(els.selectedRoomMeta, `${entities.length} ${pluralizeIncludedEntities(entities.length)}`);
renderSelectedRoomActions(snapshot);
}
function renderSelectedRoomActions(snapshot) {
if (!els.selectedRoomActions) return;
const room = snapshot.selected_space || snapshot.selected_room || {};
els.selectedRoomActions.replaceChildren();
if (isMobileViewport() || !state.editMode || room.id === 'main' || room.id === 'batteries') {
return;
}
const addButton = document.createElement('button');
addButton.type = 'button';
addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button';
addButton.appendChild(createIconElement('mdi:plus'));
addButton.appendChild(document.createElement('span')).textContent = 'Пустая карточка';
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.appendChild(createIconElement('mdi:thermometer'));
temperatureButton.appendChild(document.createElement('span')).textContent = 'Выбрать датчик температуры';
temperatureButton.addEventListener('click', () => {
openTemperatureSensorPopup(room.id);
});
els.selectedRoomActions.append(addButton, temperatureButton);
}
function renderDashboard(snapshot) {
const room = snapshot.selected_space || snapshot.selected_room || {};
const grid = els.dashboardSurface;
if (!grid) {
return;
}
grid.innerHTML = '';
if (room.id === 'main') {
const layout = document.createElement('div');
layout.className = 'main-dashboard';
const mainEntities = roomEntities(snapshot, 'main');
const cards = document.createElement('div');
cards.className = 'grid-surface main-dashboard__cards';
if (mainEntities.length) {
mainEntities.forEach((entity) => {
cards.appendChild(renderEntityCard(entity, { isMain: true }));
});
}
const hero = renderMainHero(snapshot);
layout.append(hero);
if (mainEntities.length) {
layout.append(cards);
}
grid.appendChild(layout);
return;
}
if (room.id === 'batteries') {
const section = document.createElement('section');
section.className = 'room-entities-section battery-room';
const header = document.createElement('div');
header.className = 'room-entities-section__header battery-room__header';
const title = document.createElement('div');
title.className = 'room-entities-section__title';
title.textContent = room.battery_summary_text || `${Number(room.entity_count ?? 0) || 0} батареек`;
header.appendChild(title);
section.appendChild(header);
const list = document.createElement('div');
list.className = 'battery-room__list';
const items = Array.isArray(room.entities) ? room.entities : [];
items.forEach((item) => {
list.appendChild(renderBatteryCard(item));
});
if (!items.length) {
const empty = document.createElement('article');
empty.className = 'loading-card battery-room__empty';
empty.textContent = 'Батарейки с ярлыком «Батарейка» не найдены.';
list.appendChild(empty);
}
section.appendChild(list);
grid.appendChild(section);
return;
}
const visibleEntries = roomGridEntries(snapshot, room.id);
const hiddenEntitiesList = state.editMode && room.id !== 'main'
? roomEntitiesIncludingHidden(snapshot, room.id).filter((entity) => entity.visible === false)
: [];
const visibleSection = document.createElement('section');
visibleSection.className = 'room-entities-section';
if (state.editMode) {
const visibleHeader = document.createElement('div');
visibleHeader.className = 'room-entities-section__header';
const visibleTitle = document.createElement('div');
visibleTitle.className = 'room-entities-section__title';
visibleTitle.textContent = 'Объекты';
visibleHeader.appendChild(visibleTitle);
visibleSection.appendChild(visibleHeader);
}
const visibleGrid = document.createElement('div');
visibleGrid.className = 'grid-surface room-entities-section__grid';
visibleEntries.forEach((entry) => {
if (entry.kind === 'layout') {
visibleGrid.appendChild(renderLayoutCard(entry.payload, room));
} else {
visibleGrid.appendChild(renderEntityCard(entry.payload));
}
});
if (!visibleEntries.length) {
const empty = document.createElement('article');
empty.className = 'loading-card grid-card--full';
empty.textContent = 'В этой комнате нет доступных объектов.';
visibleGrid.appendChild(empty);
}
visibleSection.appendChild(visibleGrid);
grid.appendChild(visibleSection);
if (state.editMode && hiddenEntitiesList.length) {
const hiddenSection = document.createElement('section');
hiddenSection.className = 'room-entities-section room-entities-section--hidden';
const hiddenHeader = document.createElement('div');
hiddenHeader.className = 'room-entities-section__header';
const hiddenTitle = document.createElement('div');
hiddenTitle.className = 'room-entities-section__title';
hiddenTitle.textContent = 'Скрытые объекты';
const hiddenMeta = document.createElement('div');
hiddenMeta.className = 'room-entities-section__meta';
hiddenMeta.textContent = `${hiddenEntitiesList.length} ${pluralizeEntities(hiddenEntitiesList.length)}`;
hiddenHeader.append(hiddenTitle, hiddenMeta);
hiddenSection.appendChild(hiddenHeader);
const hiddenGrid = document.createElement('div');
hiddenGrid.className = 'grid-surface room-entities-section__grid room-entities-section__grid--hidden';
hiddenEntitiesList.forEach((entity) => {
hiddenGrid.appendChild(renderEntityCard(entity));
});
hiddenSection.appendChild(hiddenGrid);
grid.appendChild(hiddenSection);
}
}
function renderPopup(snapshot) {
if (isHaRuntime()) {
hidePopup({ preserveSnapshot: true });
return;
}
if (isMobileViewport()) {
hidePopup({ preserveSnapshot: true });
return;
}
const popup = mergePopupWithCamera(snapshot.popup || {});
const signature = JSON.stringify([
popup.active,
popup.sensor_entity_id || '',
popup.expires_at || '',
popup.stream_url || '',
popup.poster_url || '',
popup.stream_mode || '',
]);
if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) {
if (els.cameraBackdrop) {
els.cameraBackdrop.classList.add('is-open');
els.cameraBackdrop.setAttribute('aria-hidden', 'false');
}
return;
}
state.lastPopupSignature = signature;
if (!popup.active) {
hidePopup();
return;
}
if (els.cameraPoster) {
els.cameraPoster.src = popup.poster_url || '';
els.cameraPoster.alt = popup.sensor_entity_id || 'camera';
}
if (els.cameraBackdrop) {
els.cameraBackdrop.classList.add('is-open');
els.cameraBackdrop.setAttribute('aria-hidden', 'false');
}
if (els.cameraPlaceholder) {
els.cameraPlaceholder.classList.add('is-visible');
}
const expiresAt = Number(popup.expires_at || 0);
if (expiresAt > 0) {
let closeRequested = false;
const updateCountdown = () => {
const remaining = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
if (remaining > 0) {
if (els.cameraCountdown) {
els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`;
}
return;
}
if (els.cameraCountdown) {
els.cameraCountdown.textContent = 'Закрытие...';
}
if (closeRequested) {
return;
}
closeRequested = true;
clearInterval(state.popupDismissTimer);
state.popupDismissTimer = null;
apiPost('popup', { command: 'close' })
.then((response) => {
if (response?.popup) {
applyPopupSnapshot(response.popup);
} else {
hidePopup();
}
})
.catch(() => {
hidePopup();
});
};
updateCountdown();
clearInterval(state.popupDismissTimer);
state.popupDismissTimer = setInterval(updateCountdown, 1000);
} else {
if (els.cameraCountdown) {
els.cameraCountdown.textContent = '';
}
clearInterval(state.popupDismissTimer);
state.popupDismissTimer = null;
}
const streamUrl = popup.stream_url || '';
const resolvedMode = resolvePopupStreamMode(streamUrl, popup.stream_mode || '');
renderStream(streamUrl, resolvedMode, popup.poster_url || '');
}
function hidePopup(options = {}) {
const { suppressAutoOpen = false, preserveSnapshot = false } = options;
if (suppressAutoOpen) {
state.popupAutoOpenBlockedUntil = Date.now() + 60000;
}
state.lastPopupSignature = '';
state.snapshot = state.snapshot || bootstrap;
if (!preserveSnapshot && state.snapshot.popup) {
state.snapshot.popup = {
...state.snapshot.popup,
active: false,
};
}
if (els.cameraBackdrop) {
els.cameraBackdrop.classList.remove('is-open');
els.cameraBackdrop.setAttribute('aria-hidden', 'true');
}
if (els.cameraStage) {
els.cameraStage.innerHTML = '';
if (els.cameraPoster) {
els.cameraStage.appendChild(els.cameraPoster);
}
if (els.cameraPlaceholder) {
els.cameraStage.appendChild(els.cameraPlaceholder);
els.cameraPlaceholder.classList.add('is-visible');
}
}
if (els.cameraPoster) {
els.cameraPoster.removeAttribute('src');
}
if (els.cameraCountdown) {
els.cameraCountdown.textContent = '';
}
clearInterval(state.popupDismissTimer);
state.popupDismissTimer = null;
destroyStream();
}
async function showDebugPopup() {
if (isHaRuntime()) {
return;
}
try {
const response = await apiPost('popup', { command: 'open' });
const snapshot = state.snapshot || bootstrap;
state.snapshot = snapshot;
applyPopupSnapshot(response.popup || {});
} catch (error) {
console.error(error);
setStatus('Ошибка popup', 'error');
}
}
function destroyStream() {
if (state.hlsInstance) {
try {
state.hlsInstance.destroy();
} catch (error) {
console.warn(error);
}
state.hlsInstance = null;
}
}
function inferStreamMode(url) {
if (!url) return 'poster';
if (url.includes('.m3u8')) return 'hls';
if (url.includes('.mp4')) return 'video';
if (url.includes('stream.html')) return 'iframe';
if (url.startsWith('http')) return 'iframe';
return 'iframe';
}
function mutedStreamUrl(url) {
if (!url) return '';
try {
const parsed = new URL(url, window.location.href);
if (parsed.pathname.includes('webrtc.html')) {
parsed.searchParams.set('media', 'video');
}
parsed.searchParams.set('mute', '1');
parsed.searchParams.set('volume', '0');
parsed.searchParams.set('autoplay', '1');
return parsed.toString();
} catch (error) {
return url;
}
}
async function loadHlsScript() {
if (window.Hls) return;
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
async function renderStream(url, mode, posterUrl) {
destroyStream();
els.cameraStage.innerHTML = '';
els.cameraStage.appendChild(els.cameraPoster);
els.cameraStage.appendChild(els.cameraPlaceholder);
els.cameraPlaceholder.classList.add('is-visible');
els.cameraPoster.src = posterUrl || '';
if (!url) {
return;
}
if (mode === 'iframe') {
const iframe = document.createElement('iframe');
iframe.classList.add('is-loading');
iframe.src = mutedStreamUrl(url);
iframe.allow = 'autoplay; fullscreen; picture-in-picture';
iframe.referrerPolicy = 'no-referrer';
iframe.addEventListener('load', () => {
iframe.classList.add('is-ready');
els.cameraPlaceholder.classList.remove('is-visible');
});
iframe.addEventListener('error', () => els.cameraPlaceholder.classList.add('is-visible'));
els.cameraStage.appendChild(iframe);
return;
}
const video = document.createElement('video');
video.classList.add('is-loading');
video.autoplay = true;
video.muted = true;
video.defaultMuted = true;
video.volume = 0;
video.playsInline = true;
video.setAttribute('muted', '');
video.setAttribute('playsinline', '');
video.setAttribute('webkit-playsinline', '');
video.controls = false;
video.poster = posterUrl || '';
video.preload = 'metadata';
video.addEventListener('loadeddata', () => {
video.classList.add('is-ready');
els.cameraPlaceholder.classList.remove('is-visible');
});
video.addEventListener('canplay', () => {
video.classList.add('is-ready');
els.cameraPlaceholder.classList.remove('is-visible');
});
video.addEventListener('error', () => els.cameraPlaceholder.classList.add('is-visible'));
els.cameraStage.appendChild(video);
if (mode === 'hls') {
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
return;
}
try {
await loadHlsScript();
if (window.Hls) {
const hls = new window.Hls({
lowLatencyMode: true,
});
hls.loadSource(url);
hls.attachMedia(video);
state.hlsInstance = hls;
hls.on(window.Hls.Events.MANIFEST_PARSED, () => els.cameraPlaceholder.classList.remove('is-visible'));
hls.on(window.Hls.Events.ERROR, () => els.cameraPlaceholder.classList.add('is-visible'));
return;
}
} catch (error) {
console.warn('HLS load failed', error);
}
}
video.src = url;
}
function render() {
const snapshot = state.snapshot || bootstrap;
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) {
return;
}
const renderSignature = buildRenderSignature(snapshot);
if (renderSignature === state.lastRenderSignature) {
return;
}
state.lastRenderSignature = renderSignature;
if (renderSignature !== state.debugLastRenderSignature) {
state.debugLastRenderSignature = renderSignature;
debugLog('render()', snapshotSummary(snapshot));
}
renderSidebarSection(snapshot);
renderContentSection(snapshot);
renderPopup(snapshot);
renderEntityPopup(snapshot);
renderTemperatureSensorPopup(snapshot);
}
function renderDashboardOnly() {
renderContentSection(state.snapshot || bootstrap);
}
function refreshCurrentRoomLayout(entityId) {
const snapshot = state.snapshot || bootstrap;
const room = snapshot.selected_space || snapshot.selected_room || {};
if (room.id === 'main') {
updateMainEntityCard(entityId);
renderSelectedRoom(snapshot);
return;
}
if (room.id === 'batteries') {
renderDashboardOnly();
return;
}
renderDashboardOnly();
}
function refreshCurrentRoomOrder() {
const snapshot = state.snapshot || bootstrap;
const room = snapshot.selected_space || snapshot.selected_room || {};
if (room.id === 'main') {
const container = mainCardsContainer();
if (container) sortMainCardsBySnapshot(container);
return;
}
const container = els.dashboardSurface;
if (!container) {
return;
}
const order = new Map(roomEntities(snapshot, room.id).map((entity) => [entity.entity_id, Number(entity.order ?? 9999)]));
const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]'));
cards.sort((left, right) => {
const leftOrder = order.get(left.dataset.entityId) ?? Number.MAX_SAFE_INTEGER;
const rightOrder = order.get(right.dataset.entityId) ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(left.dataset.entityId || '').localeCompare(String(right.dataset.entityId || ''), 'ru');
});
cards.forEach((card) => container.appendChild(card));
}
function renderSidebarOnly() {
renderSidebarSection(state.snapshot || bootstrap);
}
function renderSelectionOnly() {
renderContentSection(state.snapshot || bootstrap);
}
async function handleEntityAction(entity, command) {
try {
const snapshot = state.snapshot || bootstrap;
const nextState = optimisticStateForCommand(entity, command);
const isCurrentRoomEntity = state.selectedRoomId !== 'main'
&& roomEntities(snapshot, state.selectedRoomId).some((item) => item.entity_id === entity.entity_id);
if (nextState !== null) {
if (state.selectedRoomId === 'main' || isMainDisplayEntity(entity)) {
patchSnapshotEntity(entity.entity_id, {
state: nextState,
attributes: entity.attributes || {},
last_changed: entity.last_changed || entity.last_updated || new Date().toISOString(),
});
syncMainEntities(entity.entity_id, entity, {
state: nextState,
attributes: entity.attributes || {},
last_changed: entity.last_changed || entity.last_updated || new Date().toISOString(),
});
refreshCurrentRoomLayout(entity.entity_id);
} else if (isCurrentRoomEntity) {
patchSnapshotEntity(entity.entity_id, {
state: nextState,
attributes: entity.attributes || {},
});
refreshCurrentRoomLayout(entity.entity_id);
}
}
await apiPost('service', {
entity_id: entity.entity_id,
command,
});
} catch (error) {
console.error(error);
setStatus('Ошибка команды', 'error');
}
}
async function handleClimateTemperature(entity, delta) {
const current = Number(entity.attributes?.temperature);
if (!Number.isFinite(current)) return;
const target = Math.round((current + delta) * 2) / 2;
try {
patchSnapshotEntity(entity.entity_id, {
attributes: {
...(entity.attributes || {}),
temperature: target,
},
});
if (state.entityPopup?.active) {
renderEntityPopup(state.snapshot || bootstrap);
}
refreshCurrentRoomLayout(entity.entity_id);
await apiPost('service', {
entity_id: entity.entity_id,
command: 'set_temperature',
value: target,
});
if (state.entityPopup?.active) {
renderEntityPopup(state.snapshot || bootstrap);
}
} catch (error) {
console.error(error);
setStatus('Ошибка температуры', 'error');
}
}
async function saveOverridePatch(entity, patch) {
const room = currentRoom();
if (!room || room.id === 'main') return;
try {
await apiPost('save-entity-override', {
room_id: room.id,
entity_id: entity.entity_id,
...patch,
});
patchSnapshotEntity(entity.entity_id, {
visible: patch.visible !== undefined ? Boolean(patch.visible) : undefined,
order: patch.order !== undefined ? patch.order : undefined,
});
try {
await loadSnapshot(state.selectedRoomId || room.id || 'main');
} catch (reloadError) {
console.warn(reloadError);
}
render();
} catch (error) {
console.error(error);
setStatus('Ошибка сохранения', 'error');
}
}
async function saveSpacePatch(room, patch) {
try {
const nextPatch = {};
if (patch.visible !== undefined) nextPatch.visible = Boolean(patch.visible);
if (patch.order !== undefined) nextPatch.order = patch.order;
if (patch.name !== undefined) nextPatch.name = patch.name;
if (patch.icon !== undefined) nextPatch.icon = patch.icon;
if (patch.temperature_sensor_entity_id !== undefined) {
nextPatch.temperature_sensor_entity_id = String(patch.temperature_sensor_entity_id || '');
}
await apiPost('save-space-override', {
room_id: room.id,
...patch,
});
patchSnapshotSpace(room.id, nextPatch);
try {
await loadSnapshot(state.selectedRoomId || room.id || 'main');
} catch (reloadError) {
console.warn(reloadError);
}
render();
} catch (error) {
console.error(error);
setStatus('Ошибка сохранения', 'error');
}
}
async function createRoomLayoutItem(roomId) {
const room = currentRoom();
const nextRoomId = roomId || room?.id || state.selectedRoomId || 'main';
if (!nextRoomId || nextRoomId === 'main' || isMobileViewport()) {
return null;
}
try {
const snapshot = state.snapshot || bootstrap;
const items = roomGridEntries(snapshot, nextRoomId);
const maxOrder = items.reduce((max, item) => Math.max(max, Number(item.order ?? 9999) || 9999), 0);
await apiPost('create-room-layout-item', {
room_id: nextRoomId,
order: maxOrder + 10,
});
try {
await loadSnapshot(state.selectedRoomId || nextRoomId || 'main');
} catch (reloadError) {
console.warn(reloadError);
}
render();
} catch (error) {
console.error(error);
setStatus('Ошибка сохранения', 'error');
}
return null;
}
async function saveRoomLayoutItem(roomId, layoutItemId, patch) {
const nextRoomId = roomId || state.selectedRoomId || 'main';
if (!nextRoomId || nextRoomId === 'main' || !layoutItemId) return;
try {
await apiPost('save-room-layout-item', {
room_id: nextRoomId,
layout_item_id: layoutItemId,
...patch,
});
try {
await loadSnapshot(state.selectedRoomId || nextRoomId || 'main');
} catch (reloadError) {
console.warn(reloadError);
}
render();
} catch (error) {
console.error(error);
setStatus('Ошибка сохранения', 'error');
}
}
async function deleteRoomLayoutItem(roomId, layoutItemId) {
const nextRoomId = roomId || state.selectedRoomId || 'main';
if (!nextRoomId || nextRoomId === 'main' || !layoutItemId) return;
try {
await apiPost('delete-room-layout-item', {
room_id: nextRoomId,
layout_item_id: layoutItemId,
});
try {
await loadSnapshot(state.selectedRoomId || nextRoomId || 'main');
} catch (reloadError) {
console.warn(reloadError);
}
render();
} catch (error) {
console.error(error);
setStatus('Ошибка сохранения', 'error');
}
}
function wireEvents() {
const bind = (el, type, handler, options) => {
if (!el) return;
el.addEventListener(type, handler, options);
};
els.selectedRoomBack?.addEventListener('click', () => {
if (!isMobileViewport()) return;
closeEntityPopup();
setMobileView('spaces');
syncLayoutState();
renderSidebarOnly();
renderSelectionOnly();
});
if (!isHaRuntime()) {
bind(els.cameraBackdrop, 'click', (event) => {
if (event.target === els.cameraBackdrop) {
apiPost('popup', { command: 'close' }).catch(() => {});
hidePopup({ suppressAutoOpen: true });
}
});
bind(els.cameraModalPanel, 'click', (event) => {
event.stopPropagation();
});
const closeCameraPopup = async (event) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
try {
await apiPost('popup', { command: 'close' });
} catch (error) {
console.warn(error);
}
hidePopup({ suppressAutoOpen: true });
};
bind(els.cameraClose, 'pointerdown', closeCameraPopup);
bind(els.cameraClose, 'click', closeCameraPopup);
}
els.entityBackdrop?.addEventListener('click', (event) => {
if (event.target === els.entityBackdrop) {
closeEntityPopup();
}
});
bind(els.entityModalPanel, 'click', (event) => {
event.stopPropagation();
});
els.entityClose?.addEventListener('click', () => {
closeEntityPopup();
});
els.temperatureSensorBackdrop?.addEventListener('click', (event) => {
if (event.target === els.temperatureSensorBackdrop) {
closeTemperatureSensorPopup();
}
});
bind(els.temperatureSensorModalPanel, 'click', (event) => {
event.stopPropagation();
});
bind(els.temperatureSensorClose, 'click', () => {
closeTemperatureSensorPopup();
});
bind(els.popupDebugButton, 'click', () => {
showDebugPopup();
});
bind(els.editModeToggle, 'click', async () => {
state.editMode = !state.editMode;
try {
await apiPost('save-settings', {
edit_mode: state.editMode,
});
} catch (error) {
console.warn(error);
}
state.snapshot = state.snapshot || bootstrap;
if (state.snapshot?.settings) {
state.snapshot.settings.edit_mode = state.editMode;
}
if (!state.editMode && currentRoom()?.visible === false) {
patchSnapshotSelection('main');
}
try {
await loadSnapshot(state.selectedRoomId || 'main');
} catch (error) {
console.warn(error);
}
render();
});
}
function initRefs() {
els.appShell = q('.app-shell');
els.clockTime = $('clock-time');
els.clockDate = $('clock-date');
els.roomsCount = $('rooms-count');
els.roomList = $('room-list');
els.editModeToggle = $('edit-mode-toggle');
els.selectedRoomBack = $('selected-room-back');
els.contentTop = q('.content-top');
els.mainPrintStripSlot = $('main-print-strip-slot');
els.contentHeader = q('.content-header');
els.selectedRoomActions = $('selected-room-actions');
els.selectedRoomEyebrow = $('selected-room-eyebrow');
els.selectedRoomTitle = $('selected-room-title');
els.selectedRoomMeta = $('selected-room-meta');
els.dashboardSurface = $('dashboard-surface');
els.cameraBackdrop = $('camera-modal');
els.cameraModalPanel = $('camera-modal-panel');
els.cameraClose = $('camera-modal-close');
els.popupDebugButton = $('popup-debug-button');
els.cameraStage = $('camera-stage');
els.cameraPoster = $('camera-poster');
els.cameraPlaceholder = $('camera-placeholder');
els.cameraCountdown = $('camera-countdown');
els.confirmBackdrop = $('confirm-modal');
els.confirmTitle = $('confirm-modal-title');
els.confirmMessage = $('confirm-modal-message');
els.confirmYes = $('confirm-modal-yes');
els.confirmNo = $('confirm-modal-no');
els.entityBackdrop = $('entity-modal');
els.entityModalPanel = $('entity-modal-panel');
els.entityClose = $('entity-modal-close');
els.entityEyebrow = $('entity-modal-eyebrow');
els.entityTitle = $('entity-modal-title');
els.entityBody = $('entity-modal-body');
els.temperatureSensorBackdrop = $('temperature-sensor-modal');
els.temperatureSensorModalPanel = $('temperature-sensor-modal-panel');
els.temperatureSensorClose = $('temperature-sensor-modal-close');
els.temperatureSensorTitle = $('temperature-sensor-modal-title');
els.temperatureSensorBody = $('temperature-sensor-modal-body');
if (els.cameraPoster && !els.cameraPoster.dataset.boundErrorHandler) {
els.cameraPoster.dataset.boundErrorHandler = '1';
els.cameraPoster.addEventListener('error', () => {
els.cameraPoster.removeAttribute('src');
els.cameraPlaceholder.classList.add('is-visible');
});
els.cameraPoster.addEventListener('load', () => {
els.cameraPlaceholder.classList.remove('is-visible');
});
}
}
function updateClock() {
if (els.clockTime) {
els.clockTime.textContent = formatTime(new Date());
}
if (els.clockDate) {
els.clockDate.textContent = formatDate(new Date());
}
updateMainPrintStrip();
}
function stopRealtime() {
if (state.haSocket) {
try {
state.haSocket.close();
} catch (error) {
console.warn(error);
}
state.haSocket = null;
}
clearTimeout(state.haReconnectTimer);
state.haReconnectTimer = null;
state.haSocketState = 'disconnected';
stopSnapshotPolling();
}
function scheduleReconnect() {
clearTimeout(state.haReconnectTimer);
state.haSocketState = 'reconnecting';
const delay = Math.min(state.haReconnectDelay, 30000);
state.haReconnectDelay = Math.min(state.haReconnectDelay * 2, 30000);
state.haReconnectTimer = window.setTimeout(() => {
connectRealtime();
}, delay);
}
function stopSnapshotPolling() {
if (state.snapshotPollTimer) {
clearInterval(state.snapshotPollTimer);
state.snapshotPollTimer = null;
}
}
function startSnapshotPolling() {
if (isHaRuntime()) {
return;
}
const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000));
if (state.snapshotPollTimer) {
clearInterval(state.snapshotPollTimer);
}
state.snapshotPollTimer = window.setInterval(async () => {
try {
await loadSnapshot(state.selectedRoomId || 'main');
render();
} catch (error) {
console.warn(error);
}
}, interval);
}
function handleHaMessage(message) {
if (isHaRuntime()) {
return;
}
if (!message || typeof message !== 'object') {
return;
}
if (message.type === 'auth_required') {
const connection = haConnection();
if (!connection.token) return;
state.haSocket?.send(JSON.stringify({
type: 'auth',
access_token: connection.token,
}));
return;
}
if (message.type === 'auth_ok') {
state.haSocketState = 'auth_ok';
state.haReconnectDelay = 1000;
state.haSocket?.send(JSON.stringify({
id: state.haSubscribeId++,
type: 'subscribe_events',
event_type: 'state_changed',
}));
return;
}
if (message.type === 'result' && message.success) {
if (state.haSocketState !== 'connected') {
state.haSocketState = 'connected';
}
return;
}
if (message.type === 'event' && message.event?.event_type === 'state_changed') {
const event = message.event;
const entityId = event?.data?.entity_id;
const newState = event?.data?.new_state;
if (entityId && newState) {
const snapshot = state.snapshot || bootstrap;
const currentRoomId = state.selectedRoomId || 'main';
const existingEntity = getEntityFromSnapshot(snapshot, entityId);
const entityDefinition = getEntityDefinition(snapshot, entityId);
const entityRecord = existingEntity || entityDefinition;
if (entityRecord?.is_hidden) {
patchSnapshotEntity(entityId, {
state: newState.state,
attributes: newState.attributes || {},
last_changed: newState.last_changed || newState.last_updated || entityRecord.last_changed,
last_updated: newState.last_updated || entityRecord.last_updated,
});
return;
}
const affectsWeather = snapshot.weather?.entity_id === entityId || entityId === 'sensor.weather_temperature';
const affectsRoom = currentRoomId !== 'main' && existingEntity !== null;
const affectsTemperatureBadge = isTemperatureSensorEntity(entityRecord);
if (entityRecord && !statePayloadChanged(entityRecord, newState)) {
const triggerEntities = popupTriggerEntities();
if (triggerEntities.has(entityId)) {
syncTriggerPopup(entityId, newState.state);
}
return;
}
patchSnapshotEntity(entityId, {
state: newState.state,
attributes: newState.attributes || {},
last_changed: newState.last_changed || newState.last_updated || entityRecord?.last_changed,
last_updated: newState.last_updated || entityRecord?.last_updated,
});
const mainChanged = syncMainEntities(entityId, entityRecord, {
state: newState.state,
attributes: newState.attributes || {},
last_changed: newState.last_changed || newState.last_updated || entityRecord?.last_changed,
last_updated: newState.last_updated || entityRecord?.last_updated,
});
if (snapshot.weather?.entity_id === entityId) {
snapshot.weather.state = newState.state;
snapshot.weather.temperature = newState.attributes?.temperature ?? snapshot.weather.temperature;
snapshot.weather.wind_speed = newState.attributes?.wind_speed ?? snapshot.weather.wind_speed;
snapshot.weather.condition = newState.attributes?.condition ?? newState.state;
}
if (snapshot.weather && entityId === 'sensor.weather_temperature') {
snapshot.weather.sensor_temperature = newState.state;
}
const triggerEntities = popupTriggerEntities();
if (triggerEntities.has(entityId)) {
syncTriggerPopup(entityId, newState.state);
}
const affectsBoiler = mainBoilerConfig(snapshot)?.sensor_entity_id === entityId;
const affectsPrint = mainPrintAffectsEntity(entityId);
if (currentRoomId === 'main' && (mainChanged || affectsWeather || mainWeatherActionAffectsEntity(entityId) || affectsBoiler || affectsPrint)) {
if (affectsWeather || mainWeatherActionAffectsEntity(entityId) || affectsBoiler || affectsPrint) {
updateMainWeatherCard();
}
if (mainChanged) {
updateMainEntityCard(entityId);
}
renderSelectedRoom(snapshot);
renderSidebarOnly();
} else if (affectsRoom) {
updateRoomEntityCard(entityId);
renderSelectedRoom(snapshot);
renderSidebarOnly();
}
if (affectsTemperatureBadge) {
renderSidebarOnly();
}
if (state.entityPopup?.active) {
renderEntityPopup(snapshot);
}
}
}
}
function connectRealtime() {
if (isHaRuntime()) {
setStatus('HA native mode', 'online');
stopSnapshotPolling();
return;
}
const connection = haConnection();
const baseUrl = connection.base_url || '';
const token = connection.token || '';
const wsUrl = haWsUrl(baseUrl);
if (!wsUrl || !token) {
state.haSocketState = 'unavailable';
setStatus('Online', 'online');
startSnapshotPolling();
return;
}
if (window.location.protocol === 'https:' && wsUrl.startsWith('ws://')) {
state.haSocketState = 'unavailable';
setStatus('Polling mode', 'online');
startSnapshotPolling();
return;
}
stopSnapshotPolling();
stopRealtime();
state.haSocketState = 'connecting';
setStatus('Connecting WS...', 'loading');
try {
const socket = new WebSocket(wsUrl);
state.haSocket = socket;
socket.onopen = () => {
state.haSocketState = 'open';
};
socket.onmessage = (event) => {
try {
handleHaMessage(JSON.parse(event.data));
} catch (error) {
console.warn(error);
}
};
socket.onerror = () => {
setStatus('WS error', 'error');
startSnapshotPolling();
};
socket.onclose = () => {
state.haSocket = null;
if (state.haSocketState !== 'disconnected') {
scheduleReconnect();
}
};
} catch (error) {
console.error(error);
scheduleReconnect();
}
}
async function start() {
debugLog('start()', {
ha_runtime: isHaRuntime(),
embed_mode: Boolean(bootstrap?.ui?.embed),
mode: bootstrap?.ui?.mode || 'unknown',
});
initRefs();
state.embedMode = detectEmbeddedContext() || isHaRuntime();
syncLayoutState();
syncViewportState();
bindPressFeedback();
updateClock();
clearInterval(state.clockTimer);
state.clockTimer = setInterval(updateClock, 1000);
wireEvents();
if (!state.haSnapshotListenerInstalled) {
state.haSnapshotListenerInstalled = true;
window.addEventListener('wall-panel-snapshot-updated', (event) => {
const snapshot = event?.detail?.snapshot || event?.detail || null;
if (!snapshot || typeof snapshot !== 'object') {
return;
}
debugLog('wall-panel-snapshot-updated', snapshotSummary(snapshot));
state.snapshot = snapshot;
render();
});
}
const viewportQuery = mobileViewportQuery();
const handleViewportChange = () => {
syncViewportState();
render();
};
if (typeof viewportQuery.addEventListener === 'function') {
viewportQuery.addEventListener('change', handleViewportChange);
} else if (typeof viewportQuery.addListener === 'function') {
viewportQuery.addListener(handleViewportChange);
}
state.snapshot = await resolveInitialSnapshot();
debugLog('initial snapshot applied', snapshotSummary(state.snapshot || bootstrap));
render();
connectRealtime();
if (!state.snapshotPollTimer) {
startSnapshotPolling();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();