wallpanell/custom_components/wall_panel/frontend/panel.js
2026-03-25 23:55:49 +03:00

2303 lines
80 KiB
JavaScript
Executable File
Raw Permalink 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 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 = `
<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">
<ha-icon icon="mdi:cog-outline" aria-hidden="true"></ha-icon>
</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>
<ha-icon icon="mdi:arrow-left" aria-hidden="true"></ha-icon>
</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">
<ha-icon icon="mdi:close" aria-hidden="true"></ha-icon>
</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"><ha-icon icon="mdi:cctv" aria-hidden="true"></ha-icon></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">
<ha-icon icon="mdi:close" aria-hidden="true"></ha-icon>
</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">
<ha-icon icon="mdi:close" aria-hidden="true"></ha-icon>
</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) {
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);
}