wallpanell/custom_components/wall_panel/frontend/panel.js
2026-03-25 19:34:45 +03:00

1795 lines
66 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.

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 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 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 [];
const labels = [];
for (const item of source) {
if (typeof item === 'string' || typeof item === 'number') {
labels.push(String(item));
continue;
}
if (item && typeof item === 'object') {
['name', 'label', 'id', 'label_id', 'entity_id'].forEach((key) => {
if (item[key]) {
labels.push(String(item[key]));
}
});
}
}
return labels;
}
function labelsFromEntity(entity, registryEntry = {}) {
const labels = [];
[
entity?.attributes?.labels,
entity?.attributes?.label_ids,
entity?.labels,
registryEntry?.labels,
registryEntry?.label_ids,
].forEach((source) => labels.push(...flattenLabelSource(source)));
return Array.from(new Set(labels.map(String)));
}
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 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 = String(area.area_id || area.id || '').trim();
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 = String(area.area_id || area.id || areaId || '');
const floorId = String(room.floor_id || area.floor_id || 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 = String(registryEntry.area_id || '');
const deviceId = String(registryEntry.device_id || registryEntry.id || registryEntry.di || '');
const device = deviceId && deviceRegistry[deviceId] ? deviceRegistry[deviceId] : null;
const deviceAreaId = String(device?.area_id || '');
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);
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: String(registryEntry.area_id || ''),
device_id: String(registryEntry.device_id || registryEntry.di || ''),
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) || labels.some((label) => lower(label).includes(lower(autoLabel)) && lower(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) || labels.some((label) => lower(label).includes(lower(autoLabel)) && lower(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 (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) {
const states = toList(haData.states);
const entityRegistry = registryMap(haData.entity_registry, ['entity_id']);
const deviceRegistry = registryMap(haData.device_registry, ['device_id', 'id', 'di']);
const deviceGroups = {};
for (const entity of states) {
const entityId = String(entity?.entity_id || '');
if (!entityId) continue;
const registryEntry = entityRegistry[entityId] || {};
const deviceId = String(registryEntry.device_id || registryEntry.id || registryEntry.di || '');
const device = deviceId && deviceRegistry[deviceId] ? deviceRegistry[deviceId] : {};
const labels = labelsFromEntity(entity, registryEntry).concat(labelsFromEntity({}, device));
const hasBatteryLabel = labels.some((label) => /battery|батар/i.test(String(label)));
if (!hasBatteryLabel && !/battery$/i.test(entityId) && !/battery_/i.test(entityId)) {
continue;
}
const key = deviceId ? `device:${deviceId}` : `entity:${entityId}`;
if (!deviceGroups[key]) {
deviceGroups[key] = {
device_id: deviceId,
device,
items: [],
};
}
deviceGroups[key].items.push({ entity, 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);
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,
};
bestScore = score;
}
}
if (!best) 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: labelsFromEntity(best.item, entityRegistry[best.entity_id] || {}),
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 battery = batteryRoom(config, haData, rooms, selectedRoomId);
const entityIndexMap = entityIndex(config, haData);
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._registryCache = null;
this._registryCachePromise = null;
}
set hass(hass) {
this._hass = hass;
this._syncRuntime();
}
set panel(panel) {
this._panel = panel;
this._syncRuntime();
}
set narrow(narrow) {
this._narrow = Boolean(narrow);
this._syncRuntime();
}
connectedCallback() {
panelLog('connectedCallback()', {
entry_id: this._entryId(),
selected_room_id: this._selectedRoom(),
});
this._ensureShell();
this._ensureAssets();
this._ensureBridge();
this._syncRuntime();
}
disconnectedCallback() {
if (this._popupCloseTimer) {
clearTimeout(this._popupCloseTimer);
this._popupCloseTimer = 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';
}
_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;
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 = `
<style data-striker-panel-css="1">
@import url("/api/wall_panel/assets/app.css?v=${this._assetVersion}");
</style>
<div class="app-shell app-shell--embed">
<aside class="sidebar">
<section class="clock-panel">
<div class="clock-panel__time" id="clock-time">--:--</div>
<div class="clock-panel__date" id="clock-date">---</div>
</section>
<section class="rooms-panel">
<div class="panel-header">
<div>
<div class="panel-header__label">Пространства</div>
<div class="panel-header__sub" id="rooms-count">0</div>
</div>
<button class="icon-button" id="edit-mode-toggle" type="button" aria-label="Edit mode">
<i class="mdi mdi-cog-outline"></i>
</button>
</div>
<div class="room-list" id="room-list"></div>
</section>
</aside>
<main class="content">
<div class="content-top" id="content-top">
<div class="main-print-strip-slot" id="main-print-strip-slot"></div>
</div>
<header class="content-header">
<button class="icon-button icon-button--ghost content-header__back" id="selected-room-back" type="button" aria-label="Back" hidden>
<i class="mdi mdi-arrow-left"></i>
</button>
<div>
<div class="content-header__eyebrow" id="selected-room-eyebrow"></div>
<h1 class="content-header__title" id="selected-room-title"></h1>
<div class="content-header__meta" id="selected-room-meta"></div>
</div>
<div class="content-header__actions" id="selected-room-actions"></div>
</header>
<section class="dashboard-grid" id="dashboard-grid">
<div class="grid-surface" id="dashboard-surface">
<div class="loading-card" style="display: none;">Загрузка панели...</div>
</div>
</section>
</main>
</div>
<div class="modal-backdrop" id="camera-modal" aria-hidden="true">
<div class="camera-modal" id="camera-modal-panel">
<button class="icon-button icon-button--ghost camera-modal__close" id="camera-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
<div class="camera-modal__body">
<div class="camera-stage" id="camera-stage">
<img class="camera-stage__poster" id="camera-poster" alt="Camera poster">
<div class="camera-stage__placeholder" id="camera-placeholder">
<div class="camera-stage__placeholder-icon"><i class="mdi mdi-cctv"></i></div>
<div class="camera-stage__placeholder-title">Поток загружается</div>
<div class="camera-stage__placeholder-subtitle">Показываем poster, пока не доступен video bridge</div>
</div>
</div>
<div class="camera-modal__footer">
<div class="camera-modal__countdown" id="camera-countdown"></div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" id="entity-modal" aria-hidden="true">
<div class="entity-modal" id="entity-modal-panel" role="dialog" aria-modal="true" aria-labelledby="entity-modal-title">
<div class="entity-modal__header">
<div>
<div class="entity-modal__eyebrow" id="entity-modal-eyebrow"></div>
<div class="entity-modal__title" id="entity-modal-title">Устройство</div>
</div>
<button class="icon-button icon-button--ghost" id="entity-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="entity-modal__body" id="entity-modal-body"></div>
</div>
</div>
<div class="modal-backdrop" id="temperature-sensor-modal" aria-hidden="true">
<div class="temperature-sensor-modal" id="temperature-sensor-modal-panel" role="dialog" aria-modal="true" aria-labelledby="temperature-sensor-modal-title">
<div class="temperature-sensor-modal__header">
<div>
<div class="temperature-sensor-modal__eyebrow">Настройка комнаты</div>
<div class="temperature-sensor-modal__title" id="temperature-sensor-modal-title">Выбрать датчик температуры</div>
</div>
<button class="icon-button icon-button--ghost" id="temperature-sensor-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="temperature-sensor-modal__body" id="temperature-sensor-modal-body"></div>
</div>
</div>
<div class="modal-backdrop" id="confirm-modal" aria-hidden="true">
<div class="confirm-modal" id="confirm-modal-panel" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
<div class="confirm-modal__header">
<div>
<div class="confirm-modal__eyebrow">Подтверждение</div>
<div class="confirm-modal__title" id="confirm-modal-title">Хотите закрыть?</div>
</div>
</div>
<div class="confirm-modal__body" id="confirm-modal-message">Это действие отправит команду закрытия.</div>
<div class="confirm-modal__footer">
<button class="mushroom-button mushroom-button--small" id="confirm-modal-no" type="button">Нет</button>
<button class="mushroom-button mushroom-button--small is-on" id="confirm-modal-yes" type="button">Да</button>
</div>
</div>
</div>
`;
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) {
panelLog('emitSnapshot()', panelSnapshotSummary(snapshot));
window.APP_BOOTSTRAP = snapshot;
window.dispatchEvent(new CustomEvent(WALL_PANEL_HA_EVENT, { detail: { snapshot } }));
try {
window.StrikerPanelClient?.renderFromSnapshot?.(snapshot);
} catch (error) {
panelWarn('renderFromSnapshot() failed', error);
}
}
_getRegistryMaps() {
if (this._registryCache) {
return this._registryCache;
}
const hass = this._hass || {};
return {
entities: registryMap(hass.entities, ['entity_id']),
devices: registryMap(hass.devices, ['device_id', 'id', 'di']),
areas: registryMap(hass.areas, ['area_id', 'id']),
floors: registryMap(hass.floors, ['floor_id', 'id']),
};
}
async _refreshRegistryCache(force = false) {
if (!force && this._registryCache) {
return this._registryCache;
}
if (!force && this._registryCachePromise) {
return this._registryCachePromise;
}
const hass = this._hass || {};
if (!hass?.callWS) {
this._registryCache = this._getRegistryMaps();
return this._registryCache;
}
this._registryCachePromise = (async () => {
try {
const [entities, devices, areas, floors] = await Promise.all([
hass.callWS({ type: 'config/entity_registry/list' }),
hass.callWS({ type: 'config/device_registry/list' }),
hass.callWS({ type: 'config/area_registry/list' }),
hass.callWS({ type: 'config/floor_registry/list' }),
]);
this._registryCache = {
entities: registryMap(entities, ['entity_id']),
devices: registryMap(devices, ['device_id', 'id', 'di']),
areas: registryMap(areas, ['area_id', 'id']),
floors: registryMap(floors, ['floor_id', 'id']),
};
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) => item?.entity_id || item?.id || '') : [],
entity_labels_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => ({ id: item?.entity_id || item?.id || '', labels: item?.labels || item?.label_ids || [] })) : [],
});
return this._registryCache;
} catch (error) {
panelWarn('registry cache refresh failed, falling back to hass.* maps', error);
this._registryCache = this._getRegistryMaps();
return this._registryCache;
} finally {
this._registryCachePromise = null;
}
})();
return this._registryCachePromise;
}
_buildSnapshot(roomId = this._selectedRoom()) {
const config = this._currentConfig();
const hass = this._hass || {};
const states = hass.states || {};
const haData = {
states: Object.values(states),
...this._getRegistryMaps(),
};
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 main = mainEntities(config, haData);
const battery = batteryRoom(config, haData, rooms, roomId);
const entityIndexMap = entityIndex(config, haData);
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);
if (popup.active && popup.expires_at && Date.now() >= Number(popup.expires_at) * 1000) {
popup.active = false;
popup.expires_at = null;
this._popupState = popup;
}
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: 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,
};
}
_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) {
const config = this._currentConfig();
const triggerEntities = new Set((config.camera?.trigger_entities || []).map(String));
if (!triggerEntities.size) return;
const previous = this._triggerSnapshot;
this._triggerSnapshot = nextStates;
const timeoutMinutes = Math.max(1, Number(config.camera?.popup_timeout_minutes || 3));
const closeDelaySeconds = 30;
for (const entityId of triggerEntities) {
const current = String(nextStates[entityId] || '').toLowerCase();
const prev = String(previous[entityId] || '').toLowerCase();
if (current === 'on' && prev !== 'on') {
this._popupState = {
active: true,
sensor_entity_id: entityId,
opened_at: Math.floor(Date.now() / 1000),
expires_at: null,
};
this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
return;
}
if (current === 'off' && prev === 'on' && this._popupState.active && this._popupState.sensor_entity_id === entityId) {
this._popupState.active = true;
this._popupState.expires_at = Math.floor(Date.now() / 1000) + closeDelaySeconds;
this._schedulePopupClose(closeDelaySeconds * 1000);
this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
return;
}
if (this._popupState.active && this._popupState.expires_at && Math.floor(Date.now() / 1000) >= Number(this._popupState.expires_at)) {
this._popupState.active = false;
this._popupState.expires_at = null;
this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
}
}
}
_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 timeoutMinutes = Math.max(1, Number(config.camera?.popup_timeout_minutes || 3));
if (String(payload.command || '').toLowerCase() === 'close') {
this._popupState = {
active: false,
sensor_entity_id: null,
opened_at: this._popupState.opened_at || null,
expires_at: null,
};
} else if (String(payload.command || '').toLowerCase() === 'open') {
this._popupState = {
active: true,
sensor_entity_id: String(payload.sensor_entity_id || 'debug'),
opened_at: Math.floor(Date.now() / 1000),
expires_at: Math.floor(Date.now() / 1000) + timeoutMinutes * 60,
};
this._schedulePopupClose(timeoutMinutes * 60 * 1000);
} else if (payload.sensor_entity_id) {
const sensor = String(payload.sensor_entity_id || '');
const stateValue = String(payload.state || payload.to || '').toLowerCase();
if (stateValue === 'on') {
this._popupState = {
active: true,
sensor_entity_id: sensor,
opened_at: Math.floor(Date.now() / 1000),
expires_at: null,
};
} else if (stateValue === 'off' && this._popupState.active && this._popupState.sensor_entity_id === sensor) {
this._popupState.expires_at = Math.floor(Date.now() / 1000) + 30;
this._schedulePopupClose(30 * 1000);
}
}
panelLog('bridge popup updated', {
command: String(payload.command || '').toLowerCase() || 'update',
sensor_entity_id: payload.sensor_entity_id || null,
active: Boolean(this._popupState.active),
});
const snapshot = this._buildSnapshot(this._selectedRoom());
this._emitSnapshot(snapshot);
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() {
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().then(() => {
const snapshot = this._buildSnapshot(currentRoom);
const signature = JSON.stringify([
snapshot?.selected_room?.id || snapshot?.selected_space?.id || 'main',
Array.isArray(snapshot?.rooms) ? snapshot.rooms.length : Array.isArray(snapshot?.spaces) ? snapshot.spaces.length : 0,
Array.isArray(snapshot?.main_entities) ? snapshot.main_entities.length : 0,
Boolean(snapshot?.popup?.active),
Boolean(snapshot?.ui?.mode === 'ha-native' || snapshot?.ui?.mode === 'ha'),
]);
if (signature !== this._debugLastSnapshotSignature) {
this._debugLastSnapshotSignature = signature;
panelLog('syncRuntime()', panelSnapshotSummary(snapshot));
}
this._emitSnapshot(snapshot);
}).catch((error) => {
panelWarn('syncRuntime registry refresh failed', error);
const snapshot = this._buildSnapshot(currentRoom);
this._emitSnapshot(snapshot);
});
}
}
if (!customElements.get('striker-panel-panel')) {
customElements.define('striker-panel-panel', StrikerPanelPanel);
}