const WALL_PANEL_HA_EVENT = 'wall-panel-snapshot-updated'; function isDebugEnabled() { 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 PANEL_DEBUG = isDebugEnabled(); function panelLog(...args) { if (PANEL_DEBUG) { console.log('[Striker Panel]', ...args); } } function panelWarn(...args) { if (PANEL_DEBUG) { console.warn('[Striker Panel]', ...args); } } function shouldTraceEntity(entityId) { return PANEL_DEBUG; } function traceEntity(entityId, label, payload) { if (!shouldTraceEntity(entityId)) { return; } console.groupCollapsed(`[Striker Panel] ${label} ${entityId}`); console.log(payload); console.groupEnd(); } function normalizeConfig(value) { const base = { app: { title: 'Striker Panel', poll_interval_ms: 5000, main_room_name: 'Главная', main_room_icon: 'mdi:home', edit_mode: false, battery_history_hours: 4320, }, home_assistant: { base_url: '', token: '', verify_ssl: true, sync_url: '', sync_token: '', sync_timeout: 10, sync_verify_ssl: true, sync_cache_seconds: 30, weather_entity_id: '', auto_label: 'auto', auto_entity_ids: [], }, camera: { rtsp_url: '', stream_url: '', stream_mode: 'hls', poster_url: '', popup_timeout_minutes: 3, trigger_entities: [], }, rooms: [], }; const merge = (target, source) => { Object.entries(source || {}).forEach(([key, value]) => { if (value && typeof value === 'object' && !Array.isArray(value) && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { merge(target[key], value); } else { target[key] = typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value)); } }); }; if (value && typeof value === 'object') { merge(base, value); } if (!Array.isArray(base.rooms)) { base.rooms = []; } return base; } function toList(source) { if (!source) return []; if (Array.isArray(source)) return source.filter(Boolean); if (typeof source === 'object') return Object.values(source).filter(Boolean); return []; } function lower(value) { return String(value ?? '').trim().toLowerCase(); } function normalizeLookupKey(value) { return lower(value) .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9а-яё]+/gi, ' ') .replace(/\s+/g, ' ') .trim(); } function clone(value) { return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value)); } function panelSnapshotSummary(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 panelRequestSummary(method, action, payload = {}) { const summary = { method: String(method || '').toUpperCase(), action: String(action || ''), }; ['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => { if (payload[key] !== undefined && payload[key] !== null && payload[key] !== '') { summary[key] = payload[key]; } }); if (payload.payload && typeof payload.payload === 'object') { summary.payload_keys = Object.keys(payload.payload); } return summary; } function previewEntityIds(items, limit = 8) { return toList(items) .slice(0, Math.max(0, Number(limit) || 0)) .map((item) => String(item?.entity_id || item?.id || item?.name || '')) .filter(Boolean); } function snapshotEntityLookup(snapshot, entityId) { if (!snapshot || !entityId) { return null; } const collections = [ snapshot.main_entities, snapshot.selected_room?.entities, snapshot.selected_space?.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 snapshotEntitySignature(snapshot, entityId) { const entity = snapshotEntityLookup(snapshot, entityId); if (!entity) { return null; } const attr = entity.attributes || {}; return [ String(entity.entity_id || ''), String(entity.state ?? ''), String(entity.last_changed || entity.last_updated || ''), String(attr.current_temperature ?? attr.temperature ?? ''), String(attr.current_position ?? ''), String(attr.hvac_action ?? ''), ]; } function roomRenderSignature(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 entityRenderSignature(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 selectedRoomSignature(snapshot) { const room = snapshot?.selected_room || snapshot?.selected_space || {}; if (!room?.id) { return ['unknown']; } if (room.id === 'main') { const boilerConfig = snapshot?.settings?.main_boiler || {}; const printConfig = snapshot?.settings?.main_print || {}; const mainWeatherActions = 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(entityRenderSignature), snapshotEntitySignature(snapshot, boilerConfig.sensor_entity_id || ''), snapshotEntitySignature(snapshot, printConfig.current_stage_entity_id || ''), snapshotEntitySignature(snapshot, printConfig.print_progress_entity_id || ''), snapshotEntitySignature(snapshot, printConfig.start_time_entity_id || ''), snapshotEntitySignature(snapshot, printConfig.end_time_entity_id || ''), mainWeatherActions.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 || ''), ]), 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', room.order ?? 9999, 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', ...entityRenderSignature(entry.payload)] )), (snapshot?.edit_mode || snapshot?.settings?.edit_mode || false) ? roomEntitiesIncludingHidden(snapshot, room.id).filter((entity) => entity.visible === false).map(entityRenderSignature) : [], ]; } function buildSnapshotRenderSignature(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'), String(snapshot?.settings?.edit_mode ? '1' : '0'), rooms.map(roomRenderSignature), batteryRoom ? roomRenderSignature(batteryRoom) : null, selectedRoomSignature(snapshot), ]); } function appEntityDomain(entityId) { return String(entityId || '').split('.', 2)[0] || 'unknown'; } function defaultCardType(entityId) { const domain = appEntityDomain(entityId); switch (domain) { case 'cover': return 'cover'; case 'climate': return 'climate'; case 'light': case 'switch': return 'toggle'; default: return 'toggle'; } } function isActiveEntity(entity) { const state = lower(entity?.state); const domain = appEntityDomain(entity?.entity_id); const deviceClass = lower(entity?.attributes?.device_class); if (['unavailable', 'unknown', 'none'].includes(state)) { return false; } if (domain === 'binary_sensor') { return deviceClass === 'door' ? ['on', 'open'].includes(state) : !['off', 'false', '0', 'idle'].includes(state); } if (domain === 'cover') { return ['open', 'opening', 'closing'].includes(state); } if (domain === 'climate') { return !['off', 'unavailable', 'unknown'].includes(state); } return !['off', 'false', '0', 'idle'].includes(state); } function flattenLabelSource(source) { if (source == null) return []; if (typeof source === 'string' || typeof source === 'number') return [String(source)]; if (!Array.isArray(source)) return []; return source .filter((item) => typeof item === 'string' || typeof item === 'number') .map((item) => String(item)); } function labelsFromEntity(entity, registryEntry = {}) { const labels = []; [ registryEntry?.labels, registryEntry?.label_ids, ].forEach((source) => labels.push(...flattenLabelSource(source))); return Array.from(new Set(labels.map(String))); } function entityHasAutoLabel(labels, autoLabel) { const needle = lower(autoLabel); if (!needle) { return false; } return labels.some((label) => { const candidate = lower(label); return candidate === needle || candidate.includes(needle); }); } function batteryLabelMatches(label) { const candidate = lower(label); return [ 'Батарейка', 'Battery', 'batareika', 'batareyka', ].some((needle) => { const normalized = lower(needle); return candidate === normalized || candidate.includes(normalized); }); } function entityName(entity, registryEntry = {}, override = {}) { if (override?.title) { return String(override.title); } const domain = appEntityDomain(entity?.entity_id); if (domain === 'fan' && registryEntry?.name) { return String(registryEntry.name); } if (entity?.attributes?.friendly_name) { return String(entity.attributes.friendly_name); } if (registryEntry?.name) { return String(registryEntry.name); } return String(entity?.entity_id || 'entity'); } function entityIcon(entity, override = {}) { if (override?.icon) { return String(override.icon); } if (entity?.attributes?.icon) { return String(entity.attributes.icon); } switch (appEntityDomain(entity?.entity_id)) { case 'light': return 'mdi:lightbulb'; case 'switch': return 'mdi:toggle-switch'; case 'cover': return 'mdi:curtains'; case 'climate': return 'mdi:air-conditioner'; case 'weather': return 'mdi:weather-partly-cloudy'; case 'binary_sensor': return 'mdi:motion-sensor'; default: return 'mdi:devices'; } } function entitySubtitle(entity, cardType) { const state = String(entity?.state ?? ''); const attr = entity?.attributes || {}; if (cardType === 'cover') { if (Number.isFinite(Number(attr.current_position))) { return `Позиция ${Math.round(Number(attr.current_position))}%`; } if (state === 'open') return 'Открыто'; if (state === 'closed') return 'Закрыто'; return state ? state : 'Нет данных'; } if (cardType === 'climate') { const pieces = []; if (Number.isFinite(Number(attr.current_temperature))) { pieces.push(`${String(attr.current_temperature).replace(/\.0+$/, '')}°`); } if (Number.isFinite(Number(attr.temperature))) { pieces.push(`Цель ${String(attr.temperature).replace(/\.0+$/, '')}°`); } if (attr.hvac_action) { pieces.push(String(attr.hvac_action)); } return pieces.length ? pieces.join(' · ') : 'Климат'; } if (state === '') return 'Нет данных'; if (Number.isFinite(Number(attr.current_temperature))) { return `${state} · ${String(attr.current_temperature).replace(/\.0+$/, '')}°`; } switch (state) { case 'on': return 'Включено'; case 'off': return 'Выключено'; case 'open': return 'Открыто'; case 'closed': return 'Закрыто'; default: return state; } } function batteryStatusRank(status) { switch (status) { case 'empty': return 0; case 'critical': return 1; case 'low': return 2; case 'unavailable': return 3; case 'unknown': return 4; default: return 5; } } function batteryStatusFromPercent(entity, percent) { const state = lower(entity?.state); if (['unavailable'].includes(state)) return 'unavailable'; if (['unknown', 'none', ''].includes(state) && percent == null) return 'unknown'; if (percent != null && percent <= 5) return 'empty'; if (percent != null && percent <= 15) return 'critical'; if (percent != null && percent <= 30) return 'low'; return 'ok'; } function batteryPercentFromEntity(entity) { const candidates = [ entity?.state, entity?.attributes?.battery_level, entity?.attributes?.battery_percentage, entity?.attributes?.battery, entity?.attributes?.percentage, ]; for (const candidate of candidates) { if (candidate == null) continue; const text = String(candidate).trim().replace(',', '.').replace(/%$/, ''); if (!text || !Number.isFinite(Number(text))) continue; const value = Number(text); if (value < 0 || value > 1000) continue; return value > 100 ? value / 10 : value; } return null; } function batterySummaryText(counts, total) { const empty = Number(counts.empty || 0); const critical = Number(counts.critical || 0); const low = Number(counts.low || 0); const unavailable = Number(counts.unavailable || 0); const unknown = Number(counts.unknown || 0); const problems = empty + critical + low + unavailable + unknown; if (!total) return 'Нет батареек'; if (!problems) return `Все ${total} батареек в норме`; const parts = []; if (empty) parts.push(`${empty} разряжены`); if (critical) parts.push(`${critical} скоро разрядится`); if (low) parts.push(`${low} низкий заряд`); if (unavailable) parts.push(`${unavailable} недоступны`); if (unknown) parts.push(`${unknown} неизвестно`); return `${total} батареек, ${parts.join(', ')}`; } function registryMap(source, keyCandidates = ['entity_id']) { const map = {}; for (const item of toList(source)) { if (!item || typeof item !== 'object') continue; let id = ''; for (const key of keyCandidates) { if (item[key]) { id = String(item[key]); break; } } if (id) { map[id] = item; } } return map; } function normalizeRegistryLabels(labels) { if (labels == null) { return []; } if (typeof labels === 'string' || typeof labels === 'number') { return [String(labels)]; } if (!Array.isArray(labels)) { return []; } return Array.from(new Set( labels .filter((item) => typeof item === 'string' || typeof item === 'number') .map((item) => String(item)) )); } function registryPayloadItems(payload, key) { if (Array.isArray(payload)) { return payload; } if (payload && typeof payload === 'object') { if (Array.isArray(payload[key])) { return payload[key]; } if (Array.isArray(payload.result?.[key])) { return payload.result[key]; } } return []; } function registryPayloadEntities(payload) { if (Array.isArray(payload)) { return payload; } if (payload && typeof payload === 'object') { if (Array.isArray(payload.entities)) { return payload.entities; } if (Array.isArray(payload.result?.entities)) { return payload.result.entities; } } return []; } function normalizeHaEntityRegistry(entries) { return registryPayloadEntities(entries).map((entry) => { if (!entry || typeof entry !== 'object') { return null; } return { entity_id: String(entry.ei || entry.entity_id || '').trim(), area_id: String(entry.ai || entry.area_id || '').trim(), labels: normalizeRegistryLabels(entry.lb ?? entry.labels ?? null), label_ids: normalizeRegistryLabels(entry.lb ?? entry.label_ids ?? null), device_id: String(entry.di || entry.device_id || '').trim(), icon: entry.ic || entry.icon || null, translation_key: entry.tk || entry.translation_key || null, entity_category: entry.ec ?? entry.entity_category ?? null, hidden_by: entry.hb || entry.hidden_by || null, name: entry.en || entry.name || null, has_entity_name: Boolean(entry.hn ?? entry.has_entity_name ?? false), platform: entry.pl || entry.platform || null, }; }).filter((entry) => Boolean(entry && entry.entity_id)); } function registryHasLabelsForEntity(registry, entityId) { if (!registry || !entityId) { return false; } const entry = registry[String(entityId)]; if (!entry || typeof entry !== 'object') { return false; } const labels = Array.isArray(entry.labels) ? entry.labels : []; return labels.length > 0 || Boolean(entry.label_ids && entry.label_ids.length); } function registryHasAnyLabels(registry) { if (!registry || typeof registry !== 'object') { return false; } return Object.values(registry).some((entry) => { if (!entry || typeof entry !== 'object') { return false; } const labels = Array.isArray(entry.labels) ? entry.labels : []; const labelIds = Array.isArray(entry.label_ids) ? entry.label_ids : []; return labels.length > 0 || labelIds.length > 0; }); } function normalizeHaAreaRegistry(entries) { return registryPayloadItems(entries, 'areas').map((entry) => { if (!entry || typeof entry !== 'object') { return null; } return { area_id: String(entry.area_id || entry.id || entry.ai || '').trim(), name: String(entry.name || entry.en || '').trim(), icon: entry.icon || entry.ic || null, picture: entry.picture || entry.pi || null, floor_id: String(entry.floor_id || entry.fi || entry.floor || '').trim(), }; }).filter((entry) => Boolean(entry && entry.area_id)); } function normalizeHaFloorRegistry(entries) { return registryPayloadItems(entries, 'floors').map((entry) => { if (!entry || typeof entry !== 'object') { return null; } return { floor_id: String(entry.floor_id || entry.id || entry.fi || '').trim(), name: String(entry.name || entry.en || '').trim(), icon: entry.icon || entry.ic || null, level: entry.level ?? entry.lv ?? null, }; }).filter((entry) => Boolean(entry && entry.floor_id)); } function normalizeHaDeviceRegistry(entries) { return registryPayloadItems(entries, 'devices').map((entry) => { if (!entry || typeof entry !== 'object') { return null; } return { device_id: String(entry.device_id || entry.id || entry.di || '').trim(), area_id: String(entry.area_id || entry.ai || '').trim(), labels: normalizeRegistryLabels(entry.labels ?? null), label_ids: normalizeRegistryLabels(entry.label_ids ?? entry.labels ?? null), name: String(entry.name || entry.en || '').trim(), manufacturer: entry.manufacturer || entry.mf || null, model: entry.model || entry.md || null, name_by_user: String(entry.name_by_user || entry.nbu || '').trim(), original_name: String(entry.original_name || entry.on || '').trim(), }; }).filter((entry) => Boolean(entry && entry.device_id)); } function registryEntityId(entry) { if (!entry || typeof entry !== 'object') { return ''; } return String( entry.entity_id || entry.ei || entry.id || entry.entityId || '' ).trim(); } function registryDeviceId(entry) { if (!entry || typeof entry !== 'object') { return ''; } return String( entry.device_id || entry.di || entry.id || entry.deviceId || '' ).trim(); } function registryAreaId(entry) { if (!entry || typeof entry !== 'object') { return ''; } return String( entry.area_id || entry.ai || entry.id || entry.areaId || '' ).trim(); } function registryFloorId(entry) { if (!entry || typeof entry !== 'object') { return ''; } return String( entry.floor_id || entry.fi || entry.id || entry.floorId || '' ).trim(); } function roomLayoutItems(room) { const items = Array.isArray(room?.layout_items) ? room.layout_items : []; return items .filter((item) => item && typeof item === 'object' && lower(item.type || 'ghost') === 'ghost') .map((item) => ({ id: String(item.id || item.layout_item_id || ''), type: 'ghost', order: Number.isFinite(Number(item.order)) ? Number(item.order) : 9999, })) .filter((item) => item.id) .sort((left, right) => (left.order - right.order) || left.id.localeCompare(right.id, 'ru')); } function roomMatchKeys(room) { return Array.from(new Set([ room?.area_id, room?.id, room?.name, ].map(normalizeLookupKey).filter(Boolean))); } function registryAreaMaps(areas) { const byId = registryMap(areas, ['area_id', 'id']); const byName = {}; for (const area of toList(areas)) { if (!area || typeof area !== 'object') continue; const areaId = registryAreaId(area); const areaIdKey = normalizeLookupKey(areaId); const areaNameKey = normalizeLookupKey(area.name || area.alias || areaId); if (areaIdKey) { byName[areaIdKey] = area; } if (areaNameKey) { byName[areaNameKey] = area; } } return { byId, byName }; } function roomDefinitions(config, haData) { const { byId: areasById, byName: areasByName } = registryAreaMaps(haData.areas); const floorsById = registryMap(haData.floors, ['floor_id', 'id']); const rooms = []; const configured = Array.isArray(config.rooms) ? config.rooms : []; for (const room of configured) { if (!room || typeof room !== 'object' || !room.id) continue; const roomKeys = roomMatchKeys(room); const areaId = String(room.area_id || room.id || ''); const area = areasById[areaId] || roomKeys.map((key) => areasByName[key]).find(Boolean) || {}; const resolvedAreaId = registryAreaId(area) || areaId || ''; const floorId = String(room.floor_id || registryFloorId(area) || area.fi || area.floor || ''); const floor = floorsById[floorId] || {}; rooms.push({ id: String(room.id), name: String(room.name || area.name || room.id), icon: String(room.icon || area.icon || 'mdi:home-variant'), area_id: resolvedAreaId, floor_id: floorId || null, floor_name: String(room.floor_name || floor.name || floorId || ''), floor_level: Number.isFinite(Number(room.floor_level)) ? Number(room.floor_level) : Number.isFinite(Number(floor.level)) ? Number(floor.level) : 0, visible: room.visible !== false, order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999, entity_ids: Array.isArray(room.entity_ids) ? room.entity_ids.map(String) : [], entity_overrides: room.entity_overrides && typeof room.entity_overrides === 'object' ? room.entity_overrides : {}, layout_items: roomLayoutItems(room), temperature_sensor_entity_id: String(room.temperature_sensor_entity_id || ''), match_keys: roomKeys, }); } rooms.sort((a, b) => { if (Boolean(a.visible) !== Boolean(b.visible)) return a.visible ? -1 : 1; if (a.order !== b.order) return a.order - b.order; if (a.floor_level !== b.floor_level) return a.floor_level - b.floor_level; return a.name.localeCompare(b.name, 'ru'); }); return rooms; } function roomEntities(room, haData, includeHidden = false) { const states = toList(haData.states); const entityRegistry = registryMap(haData.entity_registry, ['entity_id']); const deviceRegistry = registryMap(haData.device_registry, ['device_id', 'id', 'di']); const { byId: areasById, byName: areasByName } = registryAreaMaps(haData.areas); const explicitIds = [ ...(Array.isArray(room?.entity_ids) ? room.entity_ids.map(String) : []), ...Object.keys(room?.entity_overrides || {}), ].map(String).filter(Boolean); const areaId = String(room?.area_id || room?.id || ''); const roomKeys = Array.isArray(room?.match_keys) && room.match_keys.length ? room.match_keys : roomMatchKeys(room); const entityOverrides = room?.entity_overrides && typeof room.entity_overrides === 'object' ? room.entity_overrides : {}; const candidates = []; const roomAreaIds = new Set(); const roomAreaNames = new Set(); if (areaId) { roomAreaIds.add(areaId); roomAreaNames.add(normalizeLookupKey(areaId)); } for (const key of roomKeys) { const matchedArea = areasByName[key]; if (!matchedArea) continue; const matchedAreaId = String(matchedArea.area_id || matchedArea.id || ''); if (matchedAreaId) { roomAreaIds.add(matchedAreaId); roomAreaNames.add(normalizeLookupKey(matchedAreaId)); } roomAreaNames.add(normalizeLookupKey(matchedArea.name || matchedAreaId)); roomAreaNames.add(normalizeLookupKey(matchedArea.alias || matchedArea.name || matchedAreaId)); } for (const entity of states) { const entityId = String(entity?.entity_id || ''); if (!entityId) continue; const registryEntry = entityRegistry[entityId] || {}; const entityAreaId = registryAreaId(registryEntry); const deviceId = registryDeviceId(registryEntry); const device = deviceId && deviceRegistry[deviceId] ? deviceRegistry[deviceId] : null; const deviceAreaId = registryAreaId(device); const entityAreaName = normalizeLookupKey(areasById[entityAreaId]?.name || areasByName[normalizeLookupKey(entityAreaId)]?.name || entityAreaId); const deviceAreaName = normalizeLookupKey(areasById[deviceAreaId]?.name || areasByName[normalizeLookupKey(deviceAreaId)]?.name || deviceAreaId); const matchesArea = [entityAreaId, deviceAreaId].some((candidate) => candidate && roomAreaIds.has(candidate)) || [entityAreaName, deviceAreaName].some((candidate) => candidate && roomAreaNames.has(candidate)); const matchesExplicit = explicitIds.includes(entityId); if (!matchesArea && !matchesExplicit) continue; if (registryEntry.hidden_by || registryEntry.disabled_by) continue; const override = entityOverrides[entityId] && typeof entityOverrides[entityId] === 'object' ? entityOverrides[entityId] : {}; const visible = override.visible !== false; if (!visible && !includeHidden) continue; const cardType = String(override.card_type || defaultCardType(entityId)); candidates.push({ entity_id: entityId, domain: appEntityDomain(entityId), name: entityName(entity, registryEntry, override), icon: entityIcon(entity, override), state: entity.state ?? 'unknown', attributes: entity.attributes || {}, labels: labelsFromEntity(entity, registryEntry), area_id: entityAreaId || null, card_type: cardType, order: Number.isFinite(Number(override.order)) ? Number(override.order) : 9999, subtitle: entitySubtitle(entity, cardType), override, visible, }); } candidates.sort((a, b) => (a.order - b.order) || a.name.localeCompare(b.name, 'ru')); return candidates; } function roomActiveCount(items) { return items.reduce((count, item) => count + (isActiveEntity(item) ? 1 : 0), 0); } function roomTemperatureBadge(items) { for (const item of items) { const attr = item?.attributes || {}; const candidate = attr.current_temperature ?? attr.temperature ?? null; if (candidate != null && Number.isFinite(Number(candidate))) { return `${Math.round(Number(candidate))}°`; } } return null; } function roomTemperatureCandidate(room, items) { const selectedSensorId = String(room?.temperature_sensor_entity_id || '').trim(); if (!selectedSensorId) return null; const entity = items.find((item) => item.entity_id === selectedSensorId); if (!entity) return null; const attr = entity.attributes || {}; const candidate = attr.current_temperature ?? attr.temperature ?? entity.state ?? null; if (candidate == null || !Number.isFinite(Number(candidate))) return null; const domain = appEntityDomain(entity.entity_id); const deviceClass = lower(attr.device_class); const unit = lower(attr.unit_of_measurement); if (domain !== 'sensor') return null; if (domain === 'sensor' && deviceClass !== 'temperature' && unit !== '°c' && unit !== 'c' && !String(entity.entity_id).endsWith('_temperature')) { return null; } return `${Math.round(Number(candidate))}°`; } function roomSummary(room, items, haData) { const allItems = roomEntities(room, haData, true); let temperatureBadge = roomTemperatureCandidate(room, allItems) || roomTemperatureBadge(items); if (!temperatureBadge) { const explicitIds = []; for (const [entityId, override] of Object.entries(room?.entity_overrides || {})) { if (!override || typeof override !== 'object') continue; if (String(entityId).endsWith('_temperature') && override.visible !== false) { explicitIds.push(entityId); } } for (const temperatureEntityId of explicitIds) { const entity = toList(haData.states).find((item) => String(item?.entity_id || '') === temperatureEntityId); if (!entity) continue; const candidate = entity.attributes?.current_temperature ?? entity.attributes?.temperature ?? entity.state ?? null; if (candidate != null && Number.isFinite(Number(candidate))) { temperatureBadge = `${Math.round(Number(candidate))}°`; break; } } } const activeCount = roomActiveCount(items); return { id: room.id, name: room.name, icon: room.icon, floor_id: room.floor_id, floor_name: room.floor_name, floor_level: room.floor_level, visible: Boolean(room.visible), order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999, temperature_sensor_entity_id: room.temperature_sensor_entity_id || null, entity_count: activeCount, active_entity_count: activeCount, temperature_badge: temperatureBadge, }; } function entityIndex(config, haData) { const states = toList(haData.states); const entityRegistry = registryMap(haData.entity_registry, ['entity_id']); const autoLabel = String(config?.home_assistant?.auto_label || 'auto'); const manualAuto = new Set((config?.home_assistant?.auto_entity_ids || []).map(String)); const items = {}; for (const entity of states) { const entityId = String(entity?.entity_id || ''); if (!entityId) continue; const registryEntry = entityRegistry[entityId] || {}; const labels = labelsFromEntity(entity, registryEntry); const isHidden = Boolean(registryEntry.hidden_by || registryEntry.disabled_by); if (shouldTraceEntity(entityId)) { traceEntity(entityId, 'entityIndex', { entity: { entity_id: entityId, state: entity.state ?? 'unknown', attributes: entity.attributes || {}, labels: entity.labels || entity.attributes?.labels || entity.attributes?.label_ids || null, }, registryEntry, labels, auto_label: autoLabel, manual_auto: manualAuto.has(entityId), is_auto: manualAuto.has(entityId) || entityHasAutoLabel(labels, autoLabel), is_hidden: isHidden, }); } items[entityId] = { entity_id: entityId, domain: appEntityDomain(entityId), name: entityName(entity, registryEntry, {}), icon: entityIcon(entity, {}), state: entity.state ?? 'unknown', attributes: entity.attributes || {}, labels, area_id: registryAreaId(registryEntry), device_id: registryDeviceId(registryEntry), card_type: defaultCardType(entityId), is_door_contact: appEntityDomain(entityId) === 'binary_sensor' && (lower(entity?.attributes?.device_class) === 'door' || /door|двер/i.test(String(entity?.attributes?.friendly_name || registryEntry.name || ''))), is_auto: manualAuto.has(entityId) || entityHasAutoLabel(labels, autoLabel), is_hidden: isHidden, last_changed: String(entity.last_changed || entity.last_updated || ''), }; } return items; } function mainEntities(config, haData) { const states = toList(haData.states); const entityRegistry = registryMap(haData.entity_registry, ['entity_id']); const autoLabel = String(config?.home_assistant?.auto_label || 'auto'); const manualAuto = new Set((config?.home_assistant?.auto_entity_ids || []).map(String)); const items = []; const debug = { total_states: states.length, auto_matches: 0, auto_preview: [], active_allowed_preview: [], domain_rejected: 0, }; for (const entity of states) { const entityId = String(entity?.entity_id || ''); if (!entityId) continue; const registryEntry = entityRegistry[entityId] || {}; const labels = labelsFromEntity(entity, registryEntry); const isAuto = manualAuto.has(entityId) || entityHasAutoLabel(labels, autoLabel); const domain = appEntityDomain(entityId); const isDoorContact = domain === 'binary_sensor' && (lower(entity?.attributes?.device_class) === 'door' || /door|двер/i.test(String(entity?.attributes?.friendly_name || registryEntry.name || ''))); if (shouldTraceEntity(entityId)) { traceEntity(entityId, 'mainEntities candidate', { entity: { entity_id: entityId, state: entity.state ?? 'unknown', attributes: entity.attributes || {}, labels: entity.labels || entity.attributes?.labels || entity.attributes?.label_ids || null, }, registryEntry, labels, auto_label: autoLabel, manual_auto: manualAuto.has(entityId), is_auto: isAuto, is_active: isActiveEntity(entity), domain, is_door_contact: isDoorContact, }); } if (isAuto) { debug.auto_matches += 1; if (debug.auto_preview.length < 10) debug.auto_preview.push(entityId); } if (!isAuto || !isActiveEntity(entity)) continue; if (debug.active_allowed_preview.length < 10) debug.active_allowed_preview.push(entityId); if (!['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain)) { debug.domain_rejected += 1; continue; } if (domain === 'binary_sensor' && !isDoorContact) continue; const cardType = defaultCardType(entityId); items.push({ entity_id: entityId, domain, name: entityName(entity, registryEntry, {}), icon: entityIcon(entity, {}), state: entity.state ?? 'unknown', attributes: entity.attributes || {}, labels, card_type: cardType, is_door_contact: isDoorContact, subtitle: entitySubtitle(entity, cardType), last_changed: String(entity.last_changed || entity.last_updated || ''), }); } items.sort((a, b) => { const timeA = Date.parse(a.last_changed || '') || 0; const timeB = Date.parse(b.last_changed || '') || 0; if (timeA !== timeB) return timeA - timeB; return a.name.localeCompare(b.name, 'ru'); }); panelLog('mainEntities()', { total_states: debug.total_states, auto_matches: debug.auto_matches, auto_preview: debug.auto_preview, active_allowed_preview: debug.active_allowed_preview, domain_rejected: debug.domain_rejected, final_count: items.length, final_preview: previewEntityIds(items), }); return items; } function selectWeatherEntity(haData, config) { const preferred = String(config?.home_assistant?.weather_entity_id || '').trim(); const states = toList(haData.states); if (preferred) { const match = states.find((entity) => String(entity?.entity_id || '') === preferred); if (match) return match; } const direct = states.find((entity) => String(entity?.entity_id || '') === 'weather.yandex_weather'); if (direct) return direct; return states.find((entity) => appEntityDomain(entity?.entity_id) === 'weather') || null; } function weatherSummary(entity) { if (!entity) return null; const attr = entity.attributes || {}; return { entity_id: entity.entity_id || null, name: attr.friendly_name || 'Погода', state: entity.state ?? null, temperature: attr.temperature ?? null, sensor_temperature: null, wind_speed: attr.wind_speed ?? null, condition: attr.condition ?? entity.state ?? null, }; } function batteryRoom(config, haData, rooms, selectedRoomId, sourceIndex = null) { const entityRegistry = registryMap(haData.entity_registry, ['entity_id']); const deviceRegistry = registryMap(haData.device_registry, ['device_id', 'id', 'di']); const indexItems = sourceIndex && typeof sourceIndex === 'object' ? Object.values(sourceIndex) : Object.values(entityIndex(config, haData)); const deviceGroups = {}; for (const item of indexItems) { const entityId = String(item?.entity_id || ''); if (!entityId) continue; const registryEntry = entityRegistry[entityId] || {}; const deviceId = String(item?.device_id || registryDeviceId(registryEntry) || '').trim(); const device = deviceId && deviceRegistry[deviceId] ? deviceRegistry[deviceId] : {}; const labels = labelsFromEntity({}, device); const hasBatteryLabel = labels.some(batteryLabelMatches); if (shouldTraceEntity(entityId)) { traceEntity(entityId, 'battery candidate', { entity: { entity_id: entityId, state: item?.state ?? 'unknown', attributes: item?.attributes || {}, }, registryEntry, device, labels, has_battery_label: hasBatteryLabel, device_id: deviceId, }); } if (!hasBatteryLabel) { continue; } const key = deviceId ? `device:${deviceId}` : `entity:${entityId}`; if (!deviceGroups[key]) { deviceGroups[key] = { device_id: deviceId, device, items: [], }; } deviceGroups[key].items.push({ entity: item, registry: registryEntry }); } const batteryEntities = []; for (const group of Object.values(deviceGroups)) { let best = null; let bestScore = Number.NEGATIVE_INFINITY; for (const candidate of group.items) { const item = candidate.entity || {}; const entityId = String(item.entity_id || ''); if (!entityId) continue; const percent = batteryPercentFromEntity(item); const status = batteryStatusFromPercent(item, percent); const isBatterySensor = /^sensor\..*_battery$/i.test(entityId); let score = /battery$/i.test(entityId) ? 1000 : 0; if (percent != null) score += 100; if (!['unknown', 'unavailable', 'none'].includes(lower(item.state))) score += 10; if (appEntityDomain(entityId) === 'sensor') score += 5; if (score > bestScore || (score === bestScore && entityId.localeCompare(String(best?.entity_id || ''), 'ru') < 0)) { best = { entity_id: entityId, item, status, percent, is_battery_sensor: isBatterySensor, }; bestScore = score; } } if (!best || !best.is_battery_sensor) continue; const device = group.device || {}; const percent = best.percent; const status = best.status; batteryEntities.push({ entity_id: best.entity_id, name: String(best.item?.attributes?.friendly_name || best.entity_id), source_room_name: String(device.name_by_user || device.name || best.entity_id), source_device_name: String(device.name_by_user || device.name || ''), source_text: String(device.name_by_user || device.name || best.entity_id), domain: appEntityDomain(best.entity_id), battery_percent: percent, battery_percent_text: percent == null ? null : `${String(Math.round(percent))}%`, battery_status: status, battery_status_label: status, battery_icon: status === 'low' ? 'mdi:battery-30' : status === 'critical' ? 'mdi:battery-alert' : status === 'empty' ? 'mdi:battery-outline' : 'mdi:battery', forecast_minutes_left: null, forecast_text: null, forecast_reason: null, last_seen_state: String(best.item?.state ?? 'unknown'), labels: best.item?.labels || [], order: 9999, }); } batteryEntities.sort((a, b) => { const rankA = batteryStatusRank(String(a.battery_status || 'ok')); const rankB = batteryStatusRank(String(b.battery_status || 'ok')); if (rankA !== rankB) return rankA - rankB; const percentA = a.battery_percent; const percentB = b.battery_percent; if (percentA != null && percentB != null && percentA !== percentB) return percentA - percentB; if (percentA != null && percentB == null) return -1; if (percentA == null && percentB != null) return 1; return String(a.name || '').localeCompare(String(b.name || ''), 'ru'); }); const counts = batteryEntities.reduce((acc, item) => { acc[item.battery_status] = (acc[item.battery_status] || 0) + 1; return acc; }, {}); const total = batteryEntities.length; const problem = ['empty', 'critical', 'low', 'unavailable', 'unknown'].reduce((sum, key) => sum + Number(counts[key] || 0), 0); return { id: 'batteries', name: 'Батарейки', icon: 'mdi:battery-outline', visible: true, virtual: true, layout_items: [], entities: batteryEntities, entity_count: total, active_entity_count: problem, problem_count: problem, battery_counts: counts, battery_summary_text: batterySummaryText(counts, total), critical_count: Number(counts.empty || 0) + Number(counts.critical || 0) + Number(counts.low || 0), low_count: Number(counts.low || 0), unavailable_count: Number(counts.unavailable || 0), unknown_count: Number(counts.unknown || 0), order: -1000, }; } function buildSnapshotFromHa(panelConfig, hass, selectedRoomId = 'main', popupState = null) { const config = normalizeConfig(panelConfig.runtime_config || panelConfig.config || panelConfig || {}); const states = hass?.states || {}; const haData = { states: Object.values(states), areas: hass?.areas || {}, floors: hass?.floors || {}, entity_registry: hass?.entities || {}, device_registry: hass?.devices || {}, }; const editMode = Boolean(config?.app?.edit_mode); const rooms = roomDefinitions(config, haData); const mainRoom = { id: 'main', name: String(config?.app?.main_room_name || 'Главная'), icon: String(config?.app?.main_room_icon || 'mdi:home'), visible: true, entity_count: 0, }; const roomSummaries = [mainRoom]; const roomViews = {}; const spaceIndex = {}; const spaceEntities = {}; for (const room of rooms) { const entities = roomEntities(room, haData, editMode); const summary = roomSummary(room, entities, haData); roomSummaries.push(summary); roomViews[room.id] = { id: room.id, name: room.name, icon: room.icon, visible: Boolean(room.visible), order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999, entities, layout_items: roomLayoutItems(room), entity_count: summary.entity_count, active_entity_count: summary.active_entity_count, temperature_badge: summary.temperature_badge, temperature_sensor_entity_id: room.temperature_sensor_entity_id, }; spaceIndex[room.id] = roomViews[room.id]; spaceEntities[room.id] = entities; } const entityIndexMap = entityIndex(config, haData); const battery = batteryRoom(config, haData, rooms, selectedRoomId, entityIndexMap); const main = mainEntities(config, haData); const weatherEntity = selectWeatherEntity(haData, config); const weather = weatherSummary(weatherEntity); const weatherSensor = states['sensor.weather_temperature']; if (weather && weatherSensor) { weather.sensor_temperature = weatherSensor.state ?? null; } const nextRoomId = selectedRoomId || 'main'; let selectedRoom; if (nextRoomId === 'main') { selectedRoom = { id: 'main', name: mainRoom.name, icon: mainRoom.icon, visible: true, entities: main, layout_items: [], entity_count: main.length, active_entity_count: main.length, temperature_badge: null, }; selectedRoom.weather = weather; } else if (nextRoomId === 'batteries') { selectedRoom = battery; } else { selectedRoom = spaceIndex[nextRoomId] || roomViews[nextRoomId] || { id: nextRoomId, name: nextRoomId, icon: 'mdi:home-variant', visible: true, entities: [], layout_items: [], entity_count: 0, active_entity_count: 0, temperature_badge: null, }; } const camera = config.camera || {}; const selectedPopup = popupState || null; const popup = { active: Boolean(selectedPopup?.active), sensor_entity_id: selectedPopup?.sensor_entity_id ?? null, opened_at: selectedPopup?.opened_at ?? null, expires_at: selectedPopup?.expires_at ?? null, poster_url: String(camera.poster_url || ''), stream_url: String(camera.stream_url || ''), stream_mode: String(camera.stream_mode || 'hls'), title: 'Камера', }; panelLog('buildSnapshot()', { selected_room_id: nextRoomId, rooms_total: rooms.length, rooms_preview: rooms.slice(0, 8).map((room) => ({ id: room.id, name: room.name, visible: Boolean(room.visible), entity_ids: previewEntityIds(room.entity_ids, 6), })), room_entity_counts: Object.fromEntries( Object.entries(spaceEntities).slice(0, 8).map(([roomId, entities]) => [roomId, { count: Array.isArray(entities) ? entities.length : 0, preview: previewEntityIds(entities, 6), }]) ), main_entities: main.length, main_preview: previewEntityIds(main, 10), selected_room_entities: Array.isArray(selectedRoom?.entities) ? selectedRoom.entities.length : 0, selected_room_preview: previewEntityIds(selectedRoom?.entities, 10), weather_entity_id: weather?.entity_id || null, popup_active: Boolean(popup.active), }); return { ok: true, demo: false, server_time: Math.floor(Date.now() / 1000), settings: { title: String(config.app?.title || 'Striker Panel'), poll_interval_ms: Number(config.app?.poll_interval_ms || 5000), edit_mode: Boolean(config.app?.edit_mode), main_room_name: mainRoom.name, ha_connection: { base_url: '', token: '', verify_ssl: true, }, camera: { poster_url: String(camera.poster_url || ''), stream_url: String(camera.stream_url || ''), stream_mode: String(camera.stream_mode || 'hls'), popup_timeout_minutes: Number(camera.popup_timeout_minutes || 3), trigger_entities: Array.isArray(camera.trigger_entities) ? camera.trigger_entities.map(String) : [], }, main_weather_actions: Array.isArray(config.app?.main_weather_actions) ? config.app.main_weather_actions : [], main_boiler: config.app?.main_boiler || {}, main_print: config.app?.main_print || {}, }, spaces: roomSummaries, selected_space: selectedRoom, space_index: spaceIndex, space_entities: spaceEntities, entity_index: entityIndexMap, weather, main_entities: main, battery_room: battery, temperature_sensor_entity_id: selectedRoom?.temperature_sensor_entity_id ?? null, popup, rooms: roomSummaries, selected_room: selectedRoom, ui: { embed: true, mode: 'ha', shell: 'ha-native', config_source: 'ha-local', proxy_token: '', }, runtime_config: config, }; } class StrikerPanelPanel extends HTMLElement { constructor() { super(); this._hass = null; this._panel = null; this._narrow = false; this._initialized = false; this._assetsLoaded = false; this._bridge = null; this._runtimeConfig = null; this._selectedRoomId = 'main'; this._popupState = { active: false, sensor_entity_id: null, opened_at: null, expires_at: null, }; this._triggerSnapshot = {}; this._popupCloseTimer = null; this._assetVersion = String(Date.now()); this._debugLastSnapshotSignature = ''; this._lastSnapshotSignature = ''; this._registryCache = null; this._registryCachePromise = null; this._registryCacheReady = false; this._registryCacheLastAttemptAt = 0; this._registryRetryTimer = null; this._emitSnapshotTimer = null; this._pendingSnapshot = null; this._syncRuntimeTimer = null; } set hass(hass) { this._hass = hass; this._scheduleSyncRuntime(); } set panel(panel) { this._panel = panel; this._scheduleSyncRuntime(); } set narrow(narrow) { this._narrow = Boolean(narrow); this._scheduleSyncRuntime(); } connectedCallback() { panelLog('connectedCallback()', { entry_id: this._entryId(), selected_room_id: this._selectedRoom(), }); this._ensureShell(); this._ensureAssets(); this._ensureBridge(); this._scheduleSyncRuntime(true); } disconnectedCallback() { if (this._popupCloseTimer) { clearTimeout(this._popupCloseTimer); this._popupCloseTimer = null; } if (this._syncRuntimeTimer) { clearTimeout(this._syncRuntimeTimer); this._syncRuntimeTimer = null; } if (this._registryRetryTimer) { clearTimeout(this._registryRetryTimer); this._registryRetryTimer = null; } } _panelConfig() { const config = this._panel?.config || {}; const customConfig = config._panel_custom || {}; return customConfig.config || customConfig || config; } _entryId() { return String(this._panelConfig().entry_id || '').trim(); } _syncToken() { return String(this._panelConfig().sync_token || '').trim(); } _selectedRoom() { return this._selectedRoomId || 'main'; } _scheduleSyncRuntime(immediate = false) { if (!this.isConnected) { return; } if (immediate) { if (this._syncRuntimeTimer) { clearTimeout(this._syncRuntimeTimer); this._syncRuntimeTimer = null; } this._syncRuntime(); return; } if (this._syncRuntimeTimer) { return; } this._syncRuntimeTimer = window.setTimeout(() => { this._syncRuntimeTimer = null; this._syncRuntime(); }, 50); } _currentConfig() { const runtimeConfig = this._runtimeConfig || this._panelConfig().runtime_config || this._panelConfig().config || {}; this._runtimeConfig = normalizeConfig(runtimeConfig); return this._runtimeConfig; } _ensureShell() { if (this._initialized) { return; } window.StrikerPanelClient = window.StrikerPanelClient || {}; window.StrikerPanelClient.mountRoot = this.shadowRoot || this; window.WALL_PANEL_HA_MODE = true; this.style.display = 'block'; this.style.width = '100%'; this.style.height = '100%'; this.style.minHeight = '100vh'; this.style.background = 'var(--primary-background-color, #0d0f14)'; this.innerHTML = `
`; this._initialized = true; } _ensureAssets() { if (this._assetsLoaded) { return; } const jsSrc = `/api/wall_panel/assets/app.js?v=${this._assetVersion}`; if (!document.querySelector(`link[data-striker-panel-mdi="1"]`)) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css'; link.dataset.strikerPanelMdi = '1'; document.head.appendChild(link); } if (!document.querySelector(`script[data-striker-panel-app="1"][src="${jsSrc}"]`)) { const script = document.createElement('script'); script.src = jsSrc; script.defer = true; script.dataset.strikerPanelApp = '1'; document.head.appendChild(script); } this._assetsLoaded = true; } _emitSnapshot(snapshot) { this._pendingSnapshot = snapshot; if (this._emitSnapshotTimer) { return; } this._emitSnapshotTimer = window.setTimeout(() => { const nextSnapshot = this._pendingSnapshot; this._pendingSnapshot = null; this._emitSnapshotTimer = null; if (!nextSnapshot) { return; } const signature = String(nextSnapshot.render_signature || ''); if (signature && signature === this._lastSnapshotSignature) { return; } this._lastSnapshotSignature = signature; panelLog('emitSnapshot()', panelSnapshotSummary(nextSnapshot)); window.APP_BOOTSTRAP = nextSnapshot; window.dispatchEvent(new CustomEvent(WALL_PANEL_HA_EVENT, { detail: { snapshot: nextSnapshot } })); try { window.StrikerPanelClient?.renderFromSnapshot?.(nextSnapshot); } catch (error) { panelWarn('renderFromSnapshot() failed', error); } }, 50); } _getRegistryMaps() { if (this._registryCache) { return this._registryCache; } const hass = this._hass || {}; const entityRegistry = registryMap(normalizeHaEntityRegistry(hass.entities), ['entity_id']); const deviceRegistry = registryMap(normalizeHaDeviceRegistry(hass.devices), ['device_id']); const areaRegistry = registryMap(normalizeHaAreaRegistry(hass.areas), ['area_id']); const floorRegistry = registryMap(normalizeHaFloorRegistry(hass.floors), ['floor_id']); return { entity_registry: entityRegistry, device_registry: deviceRegistry, area_registry: areaRegistry, floor_registry: floorRegistry, entities: entityRegistry, devices: deviceRegistry, areas: areaRegistry, floors: floorRegistry, }; } async _refreshRegistryCache(force = false) { const now = Date.now(); if (!force && this._registryCacheReady && this._registryCache) { return this._registryCache; } if (!force && this._registryCachePromise) { return this._registryCachePromise; } if (!force && this._registryCache && (now - this._registryCacheLastAttemptAt) < 5000) { return this._registryCache; } this._registryCacheLastAttemptAt = now; const hass = this._hass || {}; if (!hass?.callWS) { if (!this._registryCache) { this._registryCache = this._getRegistryMaps(); } this._registryCacheReady = false; this._scheduleRegistryRetry(); return this._registryCache; } this._registryCachePromise = (async () => { try { const [entities, devices, areas, floors] = await Promise.all([ hass.callWS({ type: 'config/entity_registry/list_for_display' }), hass.callWS({ type: 'config/device_registry/list' }), hass.callWS({ type: 'config/area_registry/list' }), hass.callWS({ type: 'config/floor_registry/list' }), ]); const entityRegistry = registryMap(normalizeHaEntityRegistry(entities), ['entity_id']); const deviceRegistry = registryMap(normalizeHaDeviceRegistry(devices), ['device_id']); const areaRegistry = registryMap(normalizeHaAreaRegistry(areas), ['area_id']); const floorRegistry = registryMap(normalizeHaFloorRegistry(floors), ['floor_id']); let normalized = { entity_registry: entityRegistry, device_registry: deviceRegistry, area_registry: areaRegistry, floor_registry: floorRegistry, entities: entityRegistry, devices: deviceRegistry, areas: areaRegistry, floors: floorRegistry, }; this._registryCache = normalized; this._registryCacheReady = registryHasAnyLabels(this._registryCache.entity_registry) || registryHasAnyLabels(this._registryCache.device_registry); if (this._registryRetryTimer) { clearTimeout(this._registryRetryTimer); this._registryRetryTimer = null; } panelLog('registry cache refreshed', { entities: Array.isArray(entities) ? entities.length : 0, devices: Array.isArray(devices) ? devices.length : 0, areas: Array.isArray(areas) ? areas.length : 0, floors: Array.isArray(floors) ? floors.length : 0, entity_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => registryEntityId(item)) : [], entity_labels_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => ({ id: registryEntityId(item), labels: normalizeRegistryLabels(item?.lb ?? item?.labels ?? []) })) : [], trace_target: registryEntityId((Array.isArray(entities) ? entities : []).find((item) => registryEntityId(item) === 'light.0x54ef4410011cd300' || registryEntityId(item) === 'light.sarai_svet')) || null, trace_target_normalized: this._registryCache.entity_registry['light.0x54ef4410011cd300'] || this._registryCache.entity_registry['light.sarai_svet'] || null, ready: this._registryCacheReady, }); return this._registryCache; } catch (error) { panelWarn('registry cache refresh failed, falling back to hass.* maps', error); if (!this._registryCache) { this._registryCache = this._getRegistryMaps(); } this._registryCacheReady = false; this._scheduleRegistryRetry(); return this._registryCache; } finally { this._registryCachePromise = null; } })(); return this._registryCachePromise; } _scheduleRegistryRetry(delayMs = 2000) { if (this._registryRetryTimer) { return; } this._registryRetryTimer = window.setTimeout(() => { this._registryRetryTimer = null; if (this.isConnected) { this._syncRuntime(true); } }, delayMs); } _buildSnapshot(roomId = this._selectedRoom()) { const config = this._currentConfig(); const hass = this._hass || {}; const states = hass.states || {}; const haData = { states: Object.values(states), ...this._getRegistryMaps(), }; haData.entity_registry = haData.entity_registry || haData.entities || {}; haData.device_registry = haData.device_registry || haData.devices || {}; haData.area_registry = haData.area_registry || haData.areas || {}; haData.floor_registry = haData.floor_registry || haData.floors || {}; const editMode = Boolean(config?.app?.edit_mode); const rooms = roomDefinitions(config, haData); const mainRoom = { id: 'main', name: String(config?.app?.main_room_name || 'Главная'), icon: String(config?.app?.main_room_icon || 'mdi:home'), visible: true, entity_count: 0, }; const roomSummaries = [mainRoom]; const roomViews = {}; const spaceIndex = {}; const spaceEntities = {}; for (const room of rooms) { const entities = roomEntities(room, haData, editMode); const summary = roomSummary(room, entities, haData); roomSummaries.push(summary); roomViews[room.id] = { id: room.id, name: room.name, icon: room.icon, visible: Boolean(room.visible), order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999, entities, layout_items: roomLayoutItems(room), entity_count: summary.entity_count, active_entity_count: summary.active_entity_count, temperature_badge: summary.temperature_badge, temperature_sensor_entity_id: room.temperature_sensor_entity_id, }; spaceIndex[room.id] = roomViews[room.id]; spaceEntities[room.id] = entities; } const weatherEntity = selectWeatherEntity(haData, config); const weather = weatherSummary(weatherEntity); const weatherSensor = states['sensor.weather_temperature']; if (weather && weatherSensor) { weather.sensor_temperature = weatherSensor.state ?? null; } const entityIndexMap = entityIndex(config, haData); const main = mainEntities(config, haData); const battery = batteryRoom(config, haData, rooms, roomId, entityIndexMap); let selectedRoom; if (roomId === 'main') { selectedRoom = { id: 'main', name: mainRoom.name, icon: mainRoom.icon, visible: true, entities: main, layout_items: [], entity_count: main.length, active_entity_count: main.length, temperature_badge: null, }; selectedRoom.weather = weather; } else if (roomId === 'batteries') { selectedRoom = battery; } else { selectedRoom = spaceIndex[roomId] || roomViews[roomId] || { id: roomId, name: roomId, icon: 'mdi:home-variant', visible: true, entities: [], layout_items: [], entity_count: 0, active_entity_count: 0, temperature_badge: null, }; } const camera = config.camera || {}; const popup = clone(this._popupState); popup.active = false; popup.sensor_entity_id = null; popup.expires_at = null; popup.opened_at = null; popup.poster_url = String(camera.poster_url || ''); popup.stream_url = String(camera.stream_url || ''); popup.stream_mode = String(camera.stream_mode || 'hls'); popup.title = 'Камера'; const snapshot = { ok: true, demo: false, server_time: Math.floor(Date.now() / 1000), settings: { title: String(config.app?.title || 'Striker Panel'), poll_interval_ms: Number(config.app?.poll_interval_ms || 5000), edit_mode: Boolean(config.app?.edit_mode), main_room_name: mainRoom.name, ha_connection: null, camera: { poster_url: String(camera.poster_url || ''), stream_url: String(camera.stream_url || ''), stream_mode: String(camera.stream_mode || 'hls'), popup_timeout_minutes: Number(camera.popup_timeout_minutes || 3), trigger_entities: Array.isArray(camera.trigger_entities) ? camera.trigger_entities.map(String) : [], }, main_weather_actions: Array.isArray(config.app?.main_weather_actions) ? config.app.main_weather_actions : [], main_boiler: config.app?.main_boiler || {}, main_print: config.app?.main_print || {}, }, spaces: roomSummaries, selected_space: selectedRoom, space_index: spaceIndex, space_entities: spaceEntities, entity_index: entityIndexMap, weather, main_entities: main, battery_room: battery, temperature_sensor_entity_id: selectedRoom?.temperature_sensor_entity_id ?? null, popup: { active: Boolean(popup.active), sensor_entity_id: popup.sensor_entity_id ?? null, opened_at: popup.opened_at ?? null, expires_at: popup.expires_at ?? null, poster_url: String(camera.poster_url || ''), stream_url: String(camera.stream_url || ''), stream_mode: String(camera.stream_mode || 'hls'), title: 'Камера', }, rooms: roomSummaries, selected_room: selectedRoom, ui: { embed: true, mode: 'ha-native', shell: 'ha-native', config_source: 'ha-local', proxy_token: '', }, runtime_config: config, }; snapshot.render_signature = buildSnapshotRenderSignature(snapshot); return snapshot; } _schedulePopupClose(delayMs) { if (this._popupCloseTimer) { clearTimeout(this._popupCloseTimer); } this._popupCloseTimer = setTimeout(() => { this._popupCloseTimer = null; this._popupState.active = false; this._popupState.expires_at = null; this._emitSnapshot(this._buildSnapshot(this._selectedRoom())); }, delayMs); } _updateTriggerPopup(nextStates) { this._triggerSnapshot = nextStates; return; } _snapshotChanged(snapshot) { const signature = String(snapshot?.render_signature || ''); if (!signature) { return true; } return signature !== this._lastSnapshotSignature; } _refreshFromHass() { if (!this._hass) { return; } const nextStates = {}; Object.entries(this._hass.states || {}).forEach(([entityId, entity]) => { nextStates[entityId] = entity?.state; }); this._updateTriggerPopup(nextStates); const snapshot = this._buildSnapshot(this._selectedRoom()); this._emitSnapshot(snapshot); } _ensureBridge() { if (this._bridge) { return; } panelLog('bridge init'); this._bridge = { mode: 'ha', request: (method, action, payload = {}) => this._request(method, action, payload), getSnapshot: (roomId = this._selectedRoom()) => Promise.resolve(this._buildSnapshot(roomId || this._selectedRoom())), setSelectedRoomId: (roomId) => { this._selectedRoomId = String(roomId || 'main'); }, getConfig: () => clone(this._currentConfig()), }; window.WALL_PANEL_HA_BRIDGE = this._bridge; } async _request(method, action, payload = {}) { const normalizedAction = String(action || '').trim().toLowerCase(); const config = this._currentConfig(); const entryId = this._entryId(); const token = this._syncToken(); panelLog('bridge request', panelRequestSummary(method, action, payload)); if (method === 'GET' && normalizedAction === 'snapshot') { const roomId = String(payload.space_id || payload.room_id || this._selectedRoom() || 'main'); this._selectedRoomId = roomId; panelLog('bridge snapshot -> build', { room_id: roomId }); return this._buildSnapshot(roomId); } if (method === 'GET' && normalizedAction === 'history') { const entityId = String(payload.entity_id || '').trim(); const hours = Math.max(1, Math.min(168, Number(payload.hours || 24) || 24)); if (!entityId) { return []; } if (this._hass?.callWS) { const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); panelLog('bridge history via callWS', { entity_id: entityId, hours }); const history = await this._hass.callWS({ type: 'history/history_during_period', start_time: start, end_time: new Date().toISOString(), entity_ids: [entityId], minimal_response: true, no_attributes: true, significant_changes_only: false, }); return { history }; } const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); panelLog('bridge history via fetch fallback', { entity_id: entityId, hours }); const url = new URL(`/api/history/period/${encodeURIComponent(start)}`, window.location.origin); url.searchParams.set('filter_entity_id', entityId); url.searchParams.set('minimal_response', '1'); url.searchParams.set('no_attributes', '1'); const res = await fetch(url.toString(), { credentials: 'same-origin', headers: { Accept: 'application/json' }, }); if (!res.ok) { throw new Error(`History request failed: ${res.status}`); } const history = await res.json(); return { history }; } if (method === 'POST' && normalizedAction === 'service') { const entityId = String(payload.entity_id || '').trim(); const command = String(payload.command || '').trim(); const value = payload.value; if (!entityId || !command) { throw new Error('entity_id and command are required'); } const domain = appEntityDomain(entityId); const serviceMap = { toggle: [domain, 'toggle', { entity_id: entityId }], turn_on: [domain, 'turn_on', { entity_id: entityId }], turn_off: [domain, 'turn_off', { entity_id: entityId }], open: ['cover', 'open_cover', { entity_id: entityId }], close: ['cover', 'close_cover', { entity_id: entityId }], stop: ['cover', 'stop_cover', { entity_id: entityId }], set_position: ['cover', 'set_cover_position', { entity_id: entityId, position: value }], set_temperature: ['climate', 'set_temperature', { entity_id: entityId, temperature: value }], set_hvac_mode: ['climate', 'set_hvac_mode', { entity_id: entityId, hvac_mode: value }], set_fan_mode: ['climate', 'set_fan_mode', { entity_id: entityId, fan_mode: value }], set_swing_mode: ['climate', 'set_swing_mode', { entity_id: entityId, swing_mode: value }], set_preset_mode: ['climate', 'set_preset_mode', { entity_id: entityId, preset_mode: value }], }; const [serviceDomain, serviceName, data] = serviceMap[command] || [domain, command, { entity_id: entityId, ...(value !== undefined ? { value } : {}) }]; if (this._hass?.callService) { panelLog('bridge service via callService', { entity_id: entityId, command, service: `${serviceDomain}.${serviceName}`, }); await this._hass.callService(serviceDomain, serviceName, data); } else { panelLog('bridge service via fetch fallback', { entity_id: entityId, command, service: `${serviceDomain}.${serviceName}`, }); const url = new URL(`/api/services/${encodeURIComponent(serviceDomain)}/${encodeURIComponent(serviceName)}`, window.location.origin); const res = await fetch(url.toString(), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(data), }); if (!res.ok) { throw new Error(`Home Assistant returned HTTP ${res.status}`); } } this._refreshFromHass(); return { ok: true }; } if (method === 'POST' && normalizedAction === 'popup') { const snapshot = this._buildSnapshot(this._selectedRoom()); return { ok: true, popup: snapshot.popup }; } if (method === 'POST' && ['save-settings', 'save-entity-override', 'save-space-override', 'create-room-layout-item', 'save-room-layout-item', 'delete-room-layout-item', 'reorder-room-grid'].includes(normalizedAction)) { if (!entryId) { throw new Error('entry_id is required'); } if (!token) { throw new Error('sync token is required'); } panelLog('bridge config write', { action: normalizedAction, entry_id: entryId, payload_keys: Object.keys(payload || {}), }); const url = new URL(`/api/wall_panel/config/${encodeURIComponent(entryId)}`, window.location.origin); const res = await fetch(url.toString(), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Wall-Panel-Token': token, }, body: JSON.stringify({ action: normalizedAction, payload, }), }); const json = await res.json(); if (!res.ok || json.ok === false) { throw new Error(json.error || `Request failed: ${res.status}`); } if (json.config) { this._runtimeConfig = normalizeConfig(json.config); } const snapshot = this._buildSnapshot(this._selectedRoom()); this._emitSnapshot(snapshot); return json; } throw new Error(`Unsupported action: ${action}`); } _syncRuntime(forceRegistryRefresh = false) { if (!this.isConnected) { return; } this._ensureShell(); this._ensureAssets(); this._ensureBridge(); this._runtimeConfig = normalizeConfig(this._panelConfig().runtime_config || this._panelConfig().config || this._runtimeConfig || {}); const currentRoom = this._selectedRoom(); this._refreshRegistryCache(forceRegistryRefresh).then(() => { const snapshot = this._buildSnapshot(currentRoom); const signature = JSON.stringify([ snapshot?.render_signature || '', ]); if (signature !== this._debugLastSnapshotSignature) { this._debugLastSnapshotSignature = signature; panelLog('syncRuntime()', panelSnapshotSummary(snapshot)); } this._emitSnapshot(snapshot); if (!this._registryCacheReady) { this._scheduleRegistryRetry(); } }).catch((error) => { panelWarn('syncRuntime registry refresh failed', error); const snapshot = this._buildSnapshot(currentRoom); this._emitSnapshot(snapshot); this._scheduleRegistryRetry(); }); } } if (!customElements.get('striker-panel-panel')) { customElements.define('striker-panel-panel', StrikerPanelPanel); }