2303 lines
80 KiB
JavaScript
Executable File
2303 lines
80 KiB
JavaScript
Executable File
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);
|
||
}
|