1795 lines
66 KiB
JavaScript
Executable File
1795 lines
66 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 normalizeConfig(value) {
|
||
const base = {
|
||
app: {
|
||
title: 'Striker Panel',
|
||
poll_interval_ms: 5000,
|
||
main_room_name: 'Главная',
|
||
main_room_icon: 'mdi:home',
|
||
edit_mode: false,
|
||
battery_history_hours: 4320,
|
||
},
|
||
home_assistant: {
|
||
base_url: '',
|
||
token: '',
|
||
verify_ssl: true,
|
||
sync_url: '',
|
||
sync_token: '',
|
||
sync_timeout: 10,
|
||
sync_verify_ssl: true,
|
||
sync_cache_seconds: 30,
|
||
weather_entity_id: '',
|
||
auto_label: 'auto',
|
||
auto_entity_ids: [],
|
||
},
|
||
camera: {
|
||
rtsp_url: '',
|
||
stream_url: '',
|
||
stream_mode: 'hls',
|
||
poster_url: '',
|
||
popup_timeout_minutes: 3,
|
||
trigger_entities: [],
|
||
},
|
||
rooms: [],
|
||
};
|
||
|
||
const merge = (target, source) => {
|
||
Object.entries(source || {}).forEach(([key, value]) => {
|
||
if (value && typeof value === 'object' && !Array.isArray(value) && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) {
|
||
merge(target[key], value);
|
||
} else {
|
||
target[key] = typeof structuredClone === 'function'
|
||
? structuredClone(value)
|
||
: JSON.parse(JSON.stringify(value));
|
||
}
|
||
});
|
||
};
|
||
|
||
if (value && typeof value === 'object') {
|
||
merge(base, value);
|
||
}
|
||
if (!Array.isArray(base.rooms)) {
|
||
base.rooms = [];
|
||
}
|
||
return base;
|
||
}
|
||
|
||
function toList(source) {
|
||
if (!source) return [];
|
||
if (Array.isArray(source)) return source.filter(Boolean);
|
||
if (typeof source === 'object') return Object.values(source).filter(Boolean);
|
||
return [];
|
||
}
|
||
|
||
function lower(value) {
|
||
return String(value ?? '').trim().toLowerCase();
|
||
}
|
||
|
||
function normalizeLookupKey(value) {
|
||
return lower(value)
|
||
.normalize('NFKD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[^a-z0-9а-яё]+/gi, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function clone(value) {
|
||
return typeof structuredClone === 'function'
|
||
? structuredClone(value)
|
||
: JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function panelSnapshotSummary(snapshot) {
|
||
const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || null;
|
||
const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : [];
|
||
const mainEntities = Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : [];
|
||
const selectedEntities = Array.isArray(selectedRoom?.entities) ? selectedRoom.entities : [];
|
||
const popup = snapshot?.popup || {};
|
||
return {
|
||
mode: snapshot?.ui?.mode || 'unknown',
|
||
selected_room_id: selectedRoom?.id || null,
|
||
selected_room_name: selectedRoom?.name || null,
|
||
rooms: rooms.length,
|
||
main_entities: mainEntities.length,
|
||
selected_room_entities: selectedEntities.length,
|
||
popup_active: Boolean(popup.active),
|
||
popup_sensor: popup.sensor_entity_id || null,
|
||
};
|
||
}
|
||
|
||
function panelRequestSummary(method, action, payload = {}) {
|
||
const summary = {
|
||
method: String(method || '').toUpperCase(),
|
||
action: String(action || ''),
|
||
};
|
||
['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => {
|
||
if (payload[key] !== undefined && payload[key] !== null && payload[key] !== '') {
|
||
summary[key] = payload[key];
|
||
}
|
||
});
|
||
if (payload.payload && typeof payload.payload === 'object') {
|
||
summary.payload_keys = Object.keys(payload.payload);
|
||
}
|
||
return summary;
|
||
}
|
||
|
||
function previewEntityIds(items, limit = 8) {
|
||
return toList(items)
|
||
.slice(0, Math.max(0, Number(limit) || 0))
|
||
.map((item) => String(item?.entity_id || item?.id || item?.name || ''))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function appEntityDomain(entityId) {
|
||
return String(entityId || '').split('.', 2)[0] || 'unknown';
|
||
}
|
||
|
||
function defaultCardType(entityId) {
|
||
const domain = appEntityDomain(entityId);
|
||
switch (domain) {
|
||
case 'cover':
|
||
return 'cover';
|
||
case 'climate':
|
||
return 'climate';
|
||
case 'light':
|
||
case 'switch':
|
||
return 'toggle';
|
||
default:
|
||
return 'toggle';
|
||
}
|
||
}
|
||
|
||
function isActiveEntity(entity) {
|
||
const state = lower(entity?.state);
|
||
const domain = appEntityDomain(entity?.entity_id);
|
||
const deviceClass = lower(entity?.attributes?.device_class);
|
||
if (['unavailable', 'unknown', 'none'].includes(state)) {
|
||
return false;
|
||
}
|
||
if (domain === 'binary_sensor') {
|
||
return deviceClass === 'door'
|
||
? ['on', 'open'].includes(state)
|
||
: !['off', 'false', '0', 'idle'].includes(state);
|
||
}
|
||
if (domain === 'cover') {
|
||
return ['open', 'opening', 'closing'].includes(state);
|
||
}
|
||
if (domain === 'climate') {
|
||
return !['off', 'unavailable', 'unknown'].includes(state);
|
||
}
|
||
return !['off', 'false', '0', 'idle'].includes(state);
|
||
}
|
||
|
||
function flattenLabelSource(source) {
|
||
if (source == null) return [];
|
||
if (typeof source === 'string' || typeof source === 'number') return [String(source)];
|
||
if (!Array.isArray(source)) return [];
|
||
const labels = [];
|
||
for (const item of source) {
|
||
if (typeof item === 'string' || typeof item === 'number') {
|
||
labels.push(String(item));
|
||
continue;
|
||
}
|
||
if (item && typeof item === 'object') {
|
||
['name', 'label', 'id', 'label_id', 'entity_id'].forEach((key) => {
|
||
if (item[key]) {
|
||
labels.push(String(item[key]));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
return labels;
|
||
}
|
||
|
||
function labelsFromEntity(entity, registryEntry = {}) {
|
||
const labels = [];
|
||
[
|
||
entity?.attributes?.labels,
|
||
entity?.attributes?.label_ids,
|
||
entity?.labels,
|
||
registryEntry?.labels,
|
||
registryEntry?.label_ids,
|
||
].forEach((source) => labels.push(...flattenLabelSource(source)));
|
||
return Array.from(new Set(labels.map(String)));
|
||
}
|
||
|
||
function entityName(entity, registryEntry = {}, override = {}) {
|
||
if (override?.title) {
|
||
return String(override.title);
|
||
}
|
||
const domain = appEntityDomain(entity?.entity_id);
|
||
if (domain === 'fan' && registryEntry?.name) {
|
||
return String(registryEntry.name);
|
||
}
|
||
if (entity?.attributes?.friendly_name) {
|
||
return String(entity.attributes.friendly_name);
|
||
}
|
||
if (registryEntry?.name) {
|
||
return String(registryEntry.name);
|
||
}
|
||
return String(entity?.entity_id || 'entity');
|
||
}
|
||
|
||
function entityIcon(entity, override = {}) {
|
||
if (override?.icon) {
|
||
return String(override.icon);
|
||
}
|
||
if (entity?.attributes?.icon) {
|
||
return String(entity.attributes.icon);
|
||
}
|
||
switch (appEntityDomain(entity?.entity_id)) {
|
||
case 'light':
|
||
return 'mdi:lightbulb';
|
||
case 'switch':
|
||
return 'mdi:toggle-switch';
|
||
case 'cover':
|
||
return 'mdi:curtains';
|
||
case 'climate':
|
||
return 'mdi:air-conditioner';
|
||
case 'weather':
|
||
return 'mdi:weather-partly-cloudy';
|
||
case 'binary_sensor':
|
||
return 'mdi:motion-sensor';
|
||
default:
|
||
return 'mdi:devices';
|
||
}
|
||
}
|
||
|
||
function entitySubtitle(entity, cardType) {
|
||
const state = String(entity?.state ?? '');
|
||
const attr = entity?.attributes || {};
|
||
if (cardType === 'cover') {
|
||
if (Number.isFinite(Number(attr.current_position))) {
|
||
return `Позиция ${Math.round(Number(attr.current_position))}%`;
|
||
}
|
||
if (state === 'open') return 'Открыто';
|
||
if (state === 'closed') return 'Закрыто';
|
||
return state ? state : 'Нет данных';
|
||
}
|
||
if (cardType === 'climate') {
|
||
const pieces = [];
|
||
if (Number.isFinite(Number(attr.current_temperature))) {
|
||
pieces.push(`${String(attr.current_temperature).replace(/\.0+$/, '')}°`);
|
||
}
|
||
if (Number.isFinite(Number(attr.temperature))) {
|
||
pieces.push(`Цель ${String(attr.temperature).replace(/\.0+$/, '')}°`);
|
||
}
|
||
if (attr.hvac_action) {
|
||
pieces.push(String(attr.hvac_action));
|
||
}
|
||
return pieces.length ? pieces.join(' · ') : 'Климат';
|
||
}
|
||
if (state === '') return 'Нет данных';
|
||
if (Number.isFinite(Number(attr.current_temperature))) {
|
||
return `${state} · ${String(attr.current_temperature).replace(/\.0+$/, '')}°`;
|
||
}
|
||
switch (state) {
|
||
case 'on':
|
||
return 'Включено';
|
||
case 'off':
|
||
return 'Выключено';
|
||
case 'open':
|
||
return 'Открыто';
|
||
case 'closed':
|
||
return 'Закрыто';
|
||
default:
|
||
return state;
|
||
}
|
||
}
|
||
|
||
function batteryStatusRank(status) {
|
||
switch (status) {
|
||
case 'empty':
|
||
return 0;
|
||
case 'critical':
|
||
return 1;
|
||
case 'low':
|
||
return 2;
|
||
case 'unavailable':
|
||
return 3;
|
||
case 'unknown':
|
||
return 4;
|
||
default:
|
||
return 5;
|
||
}
|
||
}
|
||
|
||
function batteryStatusFromPercent(entity, percent) {
|
||
const state = lower(entity?.state);
|
||
if (['unavailable'].includes(state)) return 'unavailable';
|
||
if (['unknown', 'none', ''].includes(state) && percent == null) return 'unknown';
|
||
if (percent != null && percent <= 5) return 'empty';
|
||
if (percent != null && percent <= 15) return 'critical';
|
||
if (percent != null && percent <= 30) return 'low';
|
||
return 'ok';
|
||
}
|
||
|
||
function batteryPercentFromEntity(entity) {
|
||
const candidates = [
|
||
entity?.state,
|
||
entity?.attributes?.battery_level,
|
||
entity?.attributes?.battery_percentage,
|
||
entity?.attributes?.battery,
|
||
entity?.attributes?.percentage,
|
||
];
|
||
for (const candidate of candidates) {
|
||
if (candidate == null) continue;
|
||
const text = String(candidate).trim().replace(',', '.').replace(/%$/, '');
|
||
if (!text || !Number.isFinite(Number(text))) continue;
|
||
const value = Number(text);
|
||
if (value < 0 || value > 1000) continue;
|
||
return value > 100 ? value / 10 : value;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function batterySummaryText(counts, total) {
|
||
const empty = Number(counts.empty || 0);
|
||
const critical = Number(counts.critical || 0);
|
||
const low = Number(counts.low || 0);
|
||
const unavailable = Number(counts.unavailable || 0);
|
||
const unknown = Number(counts.unknown || 0);
|
||
const problems = empty + critical + low + unavailable + unknown;
|
||
if (!total) return 'Нет батареек';
|
||
if (!problems) return `Все ${total} батареек в норме`;
|
||
const parts = [];
|
||
if (empty) parts.push(`${empty} разряжены`);
|
||
if (critical) parts.push(`${critical} скоро разрядится`);
|
||
if (low) parts.push(`${low} низкий заряд`);
|
||
if (unavailable) parts.push(`${unavailable} недоступны`);
|
||
if (unknown) parts.push(`${unknown} неизвестно`);
|
||
return `${total} батареек, ${parts.join(', ')}`;
|
||
}
|
||
|
||
function registryMap(source, keyCandidates = ['entity_id']) {
|
||
const map = {};
|
||
for (const item of toList(source)) {
|
||
if (!item || typeof item !== 'object') continue;
|
||
let id = '';
|
||
for (const key of keyCandidates) {
|
||
if (item[key]) {
|
||
id = String(item[key]);
|
||
break;
|
||
}
|
||
}
|
||
if (id) {
|
||
map[id] = item;
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function roomLayoutItems(room) {
|
||
const items = Array.isArray(room?.layout_items) ? room.layout_items : [];
|
||
return items
|
||
.filter((item) => item && typeof item === 'object' && lower(item.type || 'ghost') === 'ghost')
|
||
.map((item) => ({
|
||
id: String(item.id || item.layout_item_id || ''),
|
||
type: 'ghost',
|
||
order: Number.isFinite(Number(item.order)) ? Number(item.order) : 9999,
|
||
}))
|
||
.filter((item) => item.id)
|
||
.sort((left, right) => (left.order - right.order) || left.id.localeCompare(right.id, 'ru'));
|
||
}
|
||
|
||
function roomMatchKeys(room) {
|
||
return Array.from(new Set([
|
||
room?.area_id,
|
||
room?.id,
|
||
room?.name,
|
||
].map(normalizeLookupKey).filter(Boolean)));
|
||
}
|
||
|
||
function registryAreaMaps(areas) {
|
||
const byId = registryMap(areas, ['area_id', 'id']);
|
||
const byName = {};
|
||
for (const area of toList(areas)) {
|
||
if (!area || typeof area !== 'object') continue;
|
||
const areaId = String(area.area_id || area.id || '').trim();
|
||
const areaIdKey = normalizeLookupKey(areaId);
|
||
const areaNameKey = normalizeLookupKey(area.name || area.alias || areaId);
|
||
if (areaIdKey) {
|
||
byName[areaIdKey] = area;
|
||
}
|
||
if (areaNameKey) {
|
||
byName[areaNameKey] = area;
|
||
}
|
||
}
|
||
return { byId, byName };
|
||
}
|
||
|
||
function roomDefinitions(config, haData) {
|
||
const { byId: areasById, byName: areasByName } = registryAreaMaps(haData.areas);
|
||
const floorsById = registryMap(haData.floors, ['floor_id', 'id']);
|
||
const rooms = [];
|
||
const configured = Array.isArray(config.rooms) ? config.rooms : [];
|
||
|
||
for (const room of configured) {
|
||
if (!room || typeof room !== 'object' || !room.id) continue;
|
||
const roomKeys = roomMatchKeys(room);
|
||
const areaId = String(room.area_id || room.id || '');
|
||
const area = areasById[areaId]
|
||
|| roomKeys.map((key) => areasByName[key]).find(Boolean)
|
||
|| {};
|
||
const resolvedAreaId = String(area.area_id || area.id || areaId || '');
|
||
const floorId = String(room.floor_id || area.floor_id || area.fi || area.floor || '');
|
||
const floor = floorsById[floorId] || {};
|
||
rooms.push({
|
||
id: String(room.id),
|
||
name: String(room.name || area.name || room.id),
|
||
icon: String(room.icon || area.icon || 'mdi:home-variant'),
|
||
area_id: resolvedAreaId,
|
||
floor_id: floorId || null,
|
||
floor_name: String(room.floor_name || floor.name || floorId || ''),
|
||
floor_level: Number.isFinite(Number(room.floor_level)) ? Number(room.floor_level) : Number.isFinite(Number(floor.level)) ? Number(floor.level) : 0,
|
||
visible: room.visible !== false,
|
||
order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999,
|
||
entity_ids: Array.isArray(room.entity_ids) ? room.entity_ids.map(String) : [],
|
||
entity_overrides: room.entity_overrides && typeof room.entity_overrides === 'object' ? room.entity_overrides : {},
|
||
layout_items: roomLayoutItems(room),
|
||
temperature_sensor_entity_id: String(room.temperature_sensor_entity_id || ''),
|
||
match_keys: roomKeys,
|
||
});
|
||
}
|
||
|
||
rooms.sort((a, b) => {
|
||
if (Boolean(a.visible) !== Boolean(b.visible)) return a.visible ? -1 : 1;
|
||
if (a.order !== b.order) return a.order - b.order;
|
||
if (a.floor_level !== b.floor_level) return a.floor_level - b.floor_level;
|
||
return a.name.localeCompare(b.name, 'ru');
|
||
});
|
||
return rooms;
|
||
}
|
||
|
||
function roomEntities(room, haData, includeHidden = false) {
|
||
const states = toList(haData.states);
|
||
const entityRegistry = registryMap(haData.entity_registry, ['entity_id']);
|
||
const deviceRegistry = registryMap(haData.device_registry, ['device_id', 'id', 'di']);
|
||
const { byId: areasById, byName: areasByName } = registryAreaMaps(haData.areas);
|
||
const explicitIds = [
|
||
...(Array.isArray(room?.entity_ids) ? room.entity_ids.map(String) : []),
|
||
...Object.keys(room?.entity_overrides || {}),
|
||
].map(String).filter(Boolean);
|
||
const areaId = String(room?.area_id || room?.id || '');
|
||
const roomKeys = Array.isArray(room?.match_keys) && room.match_keys.length ? room.match_keys : roomMatchKeys(room);
|
||
const entityOverrides = room?.entity_overrides && typeof room.entity_overrides === 'object' ? room.entity_overrides : {};
|
||
const candidates = [];
|
||
const roomAreaIds = new Set();
|
||
const roomAreaNames = new Set();
|
||
if (areaId) {
|
||
roomAreaIds.add(areaId);
|
||
roomAreaNames.add(normalizeLookupKey(areaId));
|
||
}
|
||
for (const key of roomKeys) {
|
||
const matchedArea = areasByName[key];
|
||
if (!matchedArea) continue;
|
||
const matchedAreaId = String(matchedArea.area_id || matchedArea.id || '');
|
||
if (matchedAreaId) {
|
||
roomAreaIds.add(matchedAreaId);
|
||
roomAreaNames.add(normalizeLookupKey(matchedAreaId));
|
||
}
|
||
roomAreaNames.add(normalizeLookupKey(matchedArea.name || matchedAreaId));
|
||
roomAreaNames.add(normalizeLookupKey(matchedArea.alias || matchedArea.name || matchedAreaId));
|
||
}
|
||
|
||
for (const entity of states) {
|
||
const entityId = String(entity?.entity_id || '');
|
||
if (!entityId) continue;
|
||
const registryEntry = entityRegistry[entityId] || {};
|
||
const entityAreaId = String(registryEntry.area_id || '');
|
||
const deviceId = String(registryEntry.device_id || registryEntry.id || registryEntry.di || '');
|
||
const device = deviceId && deviceRegistry[deviceId] ? deviceRegistry[deviceId] : null;
|
||
const deviceAreaId = String(device?.area_id || '');
|
||
const entityAreaName = normalizeLookupKey(areasById[entityAreaId]?.name || areasByName[normalizeLookupKey(entityAreaId)]?.name || entityAreaId);
|
||
const deviceAreaName = normalizeLookupKey(areasById[deviceAreaId]?.name || areasByName[normalizeLookupKey(deviceAreaId)]?.name || deviceAreaId);
|
||
const matchesArea = [entityAreaId, deviceAreaId].some((candidate) => candidate && roomAreaIds.has(candidate))
|
||
|| [entityAreaName, deviceAreaName].some((candidate) => candidate && roomAreaNames.has(candidate));
|
||
const matchesExplicit = explicitIds.includes(entityId);
|
||
if (!matchesArea && !matchesExplicit) continue;
|
||
if (registryEntry.hidden_by || registryEntry.disabled_by) continue;
|
||
const override = entityOverrides[entityId] && typeof entityOverrides[entityId] === 'object' ? entityOverrides[entityId] : {};
|
||
const visible = override.visible !== false;
|
||
if (!visible && !includeHidden) continue;
|
||
const cardType = String(override.card_type || defaultCardType(entityId));
|
||
candidates.push({
|
||
entity_id: entityId,
|
||
domain: appEntityDomain(entityId),
|
||
name: entityName(entity, registryEntry, override),
|
||
icon: entityIcon(entity, override),
|
||
state: entity.state ?? 'unknown',
|
||
attributes: entity.attributes || {},
|
||
labels: labelsFromEntity(entity, registryEntry),
|
||
area_id: entityAreaId || null,
|
||
card_type: cardType,
|
||
order: Number.isFinite(Number(override.order)) ? Number(override.order) : 9999,
|
||
subtitle: entitySubtitle(entity, cardType),
|
||
override,
|
||
visible,
|
||
});
|
||
}
|
||
|
||
candidates.sort((a, b) => (a.order - b.order) || a.name.localeCompare(b.name, 'ru'));
|
||
return candidates;
|
||
}
|
||
|
||
function roomActiveCount(items) {
|
||
return items.reduce((count, item) => count + (isActiveEntity(item) ? 1 : 0), 0);
|
||
}
|
||
|
||
function roomTemperatureBadge(items) {
|
||
for (const item of items) {
|
||
const attr = item?.attributes || {};
|
||
const candidate = attr.current_temperature ?? attr.temperature ?? null;
|
||
if (candidate != null && Number.isFinite(Number(candidate))) {
|
||
return `${Math.round(Number(candidate))}°`;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function roomTemperatureCandidate(room, items) {
|
||
const selectedSensorId = String(room?.temperature_sensor_entity_id || '').trim();
|
||
if (!selectedSensorId) return null;
|
||
const entity = items.find((item) => item.entity_id === selectedSensorId);
|
||
if (!entity) return null;
|
||
const attr = entity.attributes || {};
|
||
const candidate = attr.current_temperature ?? attr.temperature ?? entity.state ?? null;
|
||
if (candidate == null || !Number.isFinite(Number(candidate))) return null;
|
||
const domain = appEntityDomain(entity.entity_id);
|
||
const deviceClass = lower(attr.device_class);
|
||
const unit = lower(attr.unit_of_measurement);
|
||
if (domain !== 'sensor') return null;
|
||
if (domain === 'sensor' && deviceClass !== 'temperature' && unit !== '°c' && unit !== 'c' && !String(entity.entity_id).endsWith('_temperature')) {
|
||
return null;
|
||
}
|
||
return `${Math.round(Number(candidate))}°`;
|
||
}
|
||
|
||
function roomSummary(room, items, haData) {
|
||
const allItems = roomEntities(room, haData, true);
|
||
let temperatureBadge = roomTemperatureCandidate(room, allItems) || roomTemperatureBadge(items);
|
||
if (!temperatureBadge) {
|
||
const explicitIds = [];
|
||
for (const [entityId, override] of Object.entries(room?.entity_overrides || {})) {
|
||
if (!override || typeof override !== 'object') continue;
|
||
if (String(entityId).endsWith('_temperature') && override.visible !== false) {
|
||
explicitIds.push(entityId);
|
||
}
|
||
}
|
||
for (const temperatureEntityId of explicitIds) {
|
||
const entity = toList(haData.states).find((item) => String(item?.entity_id || '') === temperatureEntityId);
|
||
if (!entity) continue;
|
||
const candidate = entity.attributes?.current_temperature ?? entity.attributes?.temperature ?? entity.state ?? null;
|
||
if (candidate != null && Number.isFinite(Number(candidate))) {
|
||
temperatureBadge = `${Math.round(Number(candidate))}°`;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
const activeCount = roomActiveCount(items);
|
||
return {
|
||
id: room.id,
|
||
name: room.name,
|
||
icon: room.icon,
|
||
floor_id: room.floor_id,
|
||
floor_name: room.floor_name,
|
||
floor_level: room.floor_level,
|
||
visible: Boolean(room.visible),
|
||
order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999,
|
||
temperature_sensor_entity_id: room.temperature_sensor_entity_id || null,
|
||
entity_count: activeCount,
|
||
active_entity_count: activeCount,
|
||
temperature_badge: temperatureBadge,
|
||
};
|
||
}
|
||
|
||
function entityIndex(config, haData) {
|
||
const states = toList(haData.states);
|
||
const entityRegistry = registryMap(haData.entity_registry, ['entity_id']);
|
||
const autoLabel = String(config?.home_assistant?.auto_label || 'auto');
|
||
const manualAuto = new Set((config?.home_assistant?.auto_entity_ids || []).map(String));
|
||
const items = {};
|
||
|
||
for (const entity of states) {
|
||
const entityId = String(entity?.entity_id || '');
|
||
if (!entityId) continue;
|
||
const registryEntry = entityRegistry[entityId] || {};
|
||
const labels = labelsFromEntity(entity, registryEntry);
|
||
const isHidden = Boolean(registryEntry.hidden_by || registryEntry.disabled_by);
|
||
items[entityId] = {
|
||
entity_id: entityId,
|
||
domain: appEntityDomain(entityId),
|
||
name: entityName(entity, registryEntry, {}),
|
||
icon: entityIcon(entity, {}),
|
||
state: entity.state ?? 'unknown',
|
||
attributes: entity.attributes || {},
|
||
labels,
|
||
area_id: String(registryEntry.area_id || ''),
|
||
device_id: String(registryEntry.device_id || registryEntry.di || ''),
|
||
card_type: defaultCardType(entityId),
|
||
is_door_contact: appEntityDomain(entityId) === 'binary_sensor' && (lower(entity?.attributes?.device_class) === 'door' || /door|двер/i.test(String(entity?.attributes?.friendly_name || registryEntry.name || ''))),
|
||
is_auto: manualAuto.has(entityId) || labels.some((label) => lower(label).includes(lower(autoLabel)) && lower(autoLabel) !== ''),
|
||
is_hidden: isHidden,
|
||
last_changed: String(entity.last_changed || entity.last_updated || ''),
|
||
};
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
function mainEntities(config, haData) {
|
||
const states = toList(haData.states);
|
||
const entityRegistry = registryMap(haData.entity_registry, ['entity_id']);
|
||
const autoLabel = String(config?.home_assistant?.auto_label || 'auto');
|
||
const manualAuto = new Set((config?.home_assistant?.auto_entity_ids || []).map(String));
|
||
const items = [];
|
||
const debug = {
|
||
total_states: states.length,
|
||
auto_matches: 0,
|
||
auto_preview: [],
|
||
active_allowed_preview: [],
|
||
domain_rejected: 0,
|
||
};
|
||
|
||
for (const entity of states) {
|
||
const entityId = String(entity?.entity_id || '');
|
||
if (!entityId) continue;
|
||
const registryEntry = entityRegistry[entityId] || {};
|
||
const labels = labelsFromEntity(entity, registryEntry);
|
||
const isAuto = manualAuto.has(entityId) || labels.some((label) => lower(label).includes(lower(autoLabel)) && lower(autoLabel) !== '');
|
||
const domain = appEntityDomain(entityId);
|
||
const isDoorContact = domain === 'binary_sensor' && (lower(entity?.attributes?.device_class) === 'door' || /door|двер/i.test(String(entity?.attributes?.friendly_name || registryEntry.name || '')));
|
||
if (isAuto) {
|
||
debug.auto_matches += 1;
|
||
if (debug.auto_preview.length < 10) debug.auto_preview.push(entityId);
|
||
}
|
||
if (!isAuto || !isActiveEntity(entity)) continue;
|
||
if (debug.active_allowed_preview.length < 10) debug.active_allowed_preview.push(entityId);
|
||
if (!['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain)) {
|
||
debug.domain_rejected += 1;
|
||
continue;
|
||
}
|
||
if (domain === 'binary_sensor' && !isDoorContact) continue;
|
||
const cardType = defaultCardType(entityId);
|
||
items.push({
|
||
entity_id: entityId,
|
||
domain,
|
||
name: entityName(entity, registryEntry, {}),
|
||
icon: entityIcon(entity, {}),
|
||
state: entity.state ?? 'unknown',
|
||
attributes: entity.attributes || {},
|
||
labels,
|
||
card_type: cardType,
|
||
is_door_contact: isDoorContact,
|
||
subtitle: entitySubtitle(entity, cardType),
|
||
last_changed: String(entity.last_changed || entity.last_updated || ''),
|
||
});
|
||
}
|
||
|
||
items.sort((a, b) => {
|
||
const timeA = Date.parse(a.last_changed || '') || 0;
|
||
const timeB = Date.parse(b.last_changed || '') || 0;
|
||
if (timeA !== timeB) return timeA - timeB;
|
||
return a.name.localeCompare(b.name, 'ru');
|
||
});
|
||
panelLog('mainEntities()', {
|
||
total_states: debug.total_states,
|
||
auto_matches: debug.auto_matches,
|
||
auto_preview: debug.auto_preview,
|
||
active_allowed_preview: debug.active_allowed_preview,
|
||
domain_rejected: debug.domain_rejected,
|
||
final_count: items.length,
|
||
final_preview: previewEntityIds(items),
|
||
});
|
||
return items;
|
||
}
|
||
|
||
function selectWeatherEntity(haData, config) {
|
||
const preferred = String(config?.home_assistant?.weather_entity_id || '').trim();
|
||
const states = toList(haData.states);
|
||
if (preferred) {
|
||
const match = states.find((entity) => String(entity?.entity_id || '') === preferred);
|
||
if (match) return match;
|
||
}
|
||
const direct = states.find((entity) => String(entity?.entity_id || '') === 'weather.yandex_weather');
|
||
if (direct) return direct;
|
||
return states.find((entity) => appEntityDomain(entity?.entity_id) === 'weather') || null;
|
||
}
|
||
|
||
function weatherSummary(entity) {
|
||
if (!entity) return null;
|
||
const attr = entity.attributes || {};
|
||
return {
|
||
entity_id: entity.entity_id || null,
|
||
name: attr.friendly_name || 'Погода',
|
||
state: entity.state ?? null,
|
||
temperature: attr.temperature ?? null,
|
||
sensor_temperature: null,
|
||
wind_speed: attr.wind_speed ?? null,
|
||
condition: attr.condition ?? entity.state ?? null,
|
||
};
|
||
}
|
||
|
||
function batteryRoom(config, haData, rooms, selectedRoomId) {
|
||
const states = toList(haData.states);
|
||
const entityRegistry = registryMap(haData.entity_registry, ['entity_id']);
|
||
const deviceRegistry = registryMap(haData.device_registry, ['device_id', 'id', 'di']);
|
||
const deviceGroups = {};
|
||
|
||
for (const entity of states) {
|
||
const entityId = String(entity?.entity_id || '');
|
||
if (!entityId) continue;
|
||
const registryEntry = entityRegistry[entityId] || {};
|
||
const deviceId = String(registryEntry.device_id || registryEntry.id || registryEntry.di || '');
|
||
const device = deviceId && deviceRegistry[deviceId] ? deviceRegistry[deviceId] : {};
|
||
const labels = labelsFromEntity(entity, registryEntry).concat(labelsFromEntity({}, device));
|
||
const hasBatteryLabel = labels.some((label) => /battery|батар/i.test(String(label)));
|
||
if (!hasBatteryLabel && !/battery$/i.test(entityId) && !/battery_/i.test(entityId)) {
|
||
continue;
|
||
}
|
||
const key = deviceId ? `device:${deviceId}` : `entity:${entityId}`;
|
||
if (!deviceGroups[key]) {
|
||
deviceGroups[key] = {
|
||
device_id: deviceId,
|
||
device,
|
||
items: [],
|
||
};
|
||
}
|
||
deviceGroups[key].items.push({ entity, registry: registryEntry });
|
||
}
|
||
|
||
const batteryEntities = [];
|
||
for (const group of Object.values(deviceGroups)) {
|
||
let best = null;
|
||
let bestScore = Number.NEGATIVE_INFINITY;
|
||
for (const candidate of group.items) {
|
||
const item = candidate.entity || {};
|
||
const entityId = String(item.entity_id || '');
|
||
if (!entityId) continue;
|
||
const percent = batteryPercentFromEntity(item);
|
||
const status = batteryStatusFromPercent(item, percent);
|
||
let score = /battery$/i.test(entityId) ? 1000 : 0;
|
||
if (percent != null) score += 100;
|
||
if (!['unknown', 'unavailable', 'none'].includes(lower(item.state))) score += 10;
|
||
if (appEntityDomain(entityId) === 'sensor') score += 5;
|
||
if (score > bestScore || (score === bestScore && entityId.localeCompare(String(best?.entity_id || ''), 'ru') < 0)) {
|
||
best = {
|
||
entity_id: entityId,
|
||
item,
|
||
status,
|
||
percent,
|
||
};
|
||
bestScore = score;
|
||
}
|
||
}
|
||
if (!best) continue;
|
||
const device = group.device || {};
|
||
const percent = best.percent;
|
||
const status = best.status;
|
||
batteryEntities.push({
|
||
entity_id: best.entity_id,
|
||
name: String(best.item?.attributes?.friendly_name || best.entity_id),
|
||
source_room_name: String(device.name_by_user || device.name || best.entity_id),
|
||
source_device_name: String(device.name_by_user || device.name || ''),
|
||
source_text: String(device.name_by_user || device.name || best.entity_id),
|
||
domain: appEntityDomain(best.entity_id),
|
||
battery_percent: percent,
|
||
battery_percent_text: percent == null ? null : `${String(Math.round(percent))}%`,
|
||
battery_status: status,
|
||
battery_status_label: status,
|
||
battery_icon: status === 'low' ? 'mdi:battery-30' : status === 'critical' ? 'mdi:battery-alert' : status === 'empty' ? 'mdi:battery-outline' : 'mdi:battery',
|
||
forecast_minutes_left: null,
|
||
forecast_text: null,
|
||
forecast_reason: null,
|
||
last_seen_state: String(best.item?.state ?? 'unknown'),
|
||
labels: labelsFromEntity(best.item, entityRegistry[best.entity_id] || {}),
|
||
order: 9999,
|
||
});
|
||
}
|
||
|
||
batteryEntities.sort((a, b) => {
|
||
const rankA = batteryStatusRank(String(a.battery_status || 'ok'));
|
||
const rankB = batteryStatusRank(String(b.battery_status || 'ok'));
|
||
if (rankA !== rankB) return rankA - rankB;
|
||
const percentA = a.battery_percent;
|
||
const percentB = b.battery_percent;
|
||
if (percentA != null && percentB != null && percentA !== percentB) return percentA - percentB;
|
||
if (percentA != null && percentB == null) return -1;
|
||
if (percentA == null && percentB != null) return 1;
|
||
return String(a.name || '').localeCompare(String(b.name || ''), 'ru');
|
||
});
|
||
|
||
const counts = batteryEntities.reduce((acc, item) => {
|
||
acc[item.battery_status] = (acc[item.battery_status] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
const total = batteryEntities.length;
|
||
const problem = ['empty', 'critical', 'low', 'unavailable', 'unknown'].reduce((sum, key) => sum + Number(counts[key] || 0), 0);
|
||
|
||
return {
|
||
id: 'batteries',
|
||
name: 'Батарейки',
|
||
icon: 'mdi:battery-outline',
|
||
visible: true,
|
||
virtual: true,
|
||
layout_items: [],
|
||
entities: batteryEntities,
|
||
entity_count: total,
|
||
active_entity_count: problem,
|
||
problem_count: problem,
|
||
battery_counts: counts,
|
||
battery_summary_text: batterySummaryText(counts, total),
|
||
critical_count: Number(counts.empty || 0) + Number(counts.critical || 0) + Number(counts.low || 0),
|
||
low_count: Number(counts.low || 0),
|
||
unavailable_count: Number(counts.unavailable || 0),
|
||
unknown_count: Number(counts.unknown || 0),
|
||
order: -1000,
|
||
};
|
||
}
|
||
|
||
function buildSnapshotFromHa(panelConfig, hass, selectedRoomId = 'main', popupState = null) {
|
||
const config = normalizeConfig(panelConfig.runtime_config || panelConfig.config || panelConfig || {});
|
||
const states = hass?.states || {};
|
||
const haData = {
|
||
states: Object.values(states),
|
||
areas: hass?.areas || {},
|
||
floors: hass?.floors || {},
|
||
entity_registry: hass?.entities || {},
|
||
device_registry: hass?.devices || {},
|
||
};
|
||
const editMode = Boolean(config?.app?.edit_mode);
|
||
const rooms = roomDefinitions(config, haData);
|
||
const mainRoom = {
|
||
id: 'main',
|
||
name: String(config?.app?.main_room_name || 'Главная'),
|
||
icon: String(config?.app?.main_room_icon || 'mdi:home'),
|
||
visible: true,
|
||
entity_count: 0,
|
||
};
|
||
|
||
const roomSummaries = [mainRoom];
|
||
const roomViews = {};
|
||
const spaceIndex = {};
|
||
const spaceEntities = {};
|
||
|
||
for (const room of rooms) {
|
||
const entities = roomEntities(room, haData, editMode);
|
||
const summary = roomSummary(room, entities, haData);
|
||
roomSummaries.push(summary);
|
||
roomViews[room.id] = {
|
||
id: room.id,
|
||
name: room.name,
|
||
icon: room.icon,
|
||
visible: Boolean(room.visible),
|
||
order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999,
|
||
entities,
|
||
layout_items: roomLayoutItems(room),
|
||
entity_count: summary.entity_count,
|
||
active_entity_count: summary.active_entity_count,
|
||
temperature_badge: summary.temperature_badge,
|
||
temperature_sensor_entity_id: room.temperature_sensor_entity_id,
|
||
};
|
||
spaceIndex[room.id] = roomViews[room.id];
|
||
spaceEntities[room.id] = entities;
|
||
}
|
||
|
||
const battery = batteryRoom(config, haData, rooms, selectedRoomId);
|
||
const entityIndexMap = entityIndex(config, haData);
|
||
const main = mainEntities(config, haData);
|
||
const weatherEntity = selectWeatherEntity(haData, config);
|
||
const weather = weatherSummary(weatherEntity);
|
||
const weatherSensor = states['sensor.weather_temperature'];
|
||
if (weather && weatherSensor) {
|
||
weather.sensor_temperature = weatherSensor.state ?? null;
|
||
}
|
||
|
||
const nextRoomId = selectedRoomId || 'main';
|
||
let selectedRoom;
|
||
if (nextRoomId === 'main') {
|
||
selectedRoom = {
|
||
id: 'main',
|
||
name: mainRoom.name,
|
||
icon: mainRoom.icon,
|
||
visible: true,
|
||
entities: main,
|
||
layout_items: [],
|
||
entity_count: main.length,
|
||
active_entity_count: main.length,
|
||
temperature_badge: null,
|
||
};
|
||
selectedRoom.weather = weather;
|
||
} else if (nextRoomId === 'batteries') {
|
||
selectedRoom = battery;
|
||
} else {
|
||
selectedRoom = spaceIndex[nextRoomId] || roomViews[nextRoomId] || {
|
||
id: nextRoomId,
|
||
name: nextRoomId,
|
||
icon: 'mdi:home-variant',
|
||
visible: true,
|
||
entities: [],
|
||
layout_items: [],
|
||
entity_count: 0,
|
||
active_entity_count: 0,
|
||
temperature_badge: null,
|
||
};
|
||
}
|
||
|
||
const camera = config.camera || {};
|
||
const selectedPopup = popupState || null;
|
||
const popup = {
|
||
active: Boolean(selectedPopup?.active),
|
||
sensor_entity_id: selectedPopup?.sensor_entity_id ?? null,
|
||
opened_at: selectedPopup?.opened_at ?? null,
|
||
expires_at: selectedPopup?.expires_at ?? null,
|
||
poster_url: String(camera.poster_url || ''),
|
||
stream_url: String(camera.stream_url || ''),
|
||
stream_mode: String(camera.stream_mode || 'hls'),
|
||
title: 'Камера',
|
||
};
|
||
|
||
panelLog('buildSnapshot()', {
|
||
selected_room_id: nextRoomId,
|
||
rooms_total: rooms.length,
|
||
rooms_preview: rooms.slice(0, 8).map((room) => ({
|
||
id: room.id,
|
||
name: room.name,
|
||
visible: Boolean(room.visible),
|
||
entity_ids: previewEntityIds(room.entity_ids, 6),
|
||
})),
|
||
room_entity_counts: Object.fromEntries(
|
||
Object.entries(spaceEntities).slice(0, 8).map(([roomId, entities]) => [roomId, {
|
||
count: Array.isArray(entities) ? entities.length : 0,
|
||
preview: previewEntityIds(entities, 6),
|
||
}])
|
||
),
|
||
main_entities: main.length,
|
||
main_preview: previewEntityIds(main, 10),
|
||
selected_room_entities: Array.isArray(selectedRoom?.entities) ? selectedRoom.entities.length : 0,
|
||
selected_room_preview: previewEntityIds(selectedRoom?.entities, 10),
|
||
weather_entity_id: weather?.entity_id || null,
|
||
popup_active: Boolean(popup.active),
|
||
});
|
||
|
||
return {
|
||
ok: true,
|
||
demo: false,
|
||
server_time: Math.floor(Date.now() / 1000),
|
||
settings: {
|
||
title: String(config.app?.title || 'Striker Panel'),
|
||
poll_interval_ms: Number(config.app?.poll_interval_ms || 5000),
|
||
edit_mode: Boolean(config.app?.edit_mode),
|
||
main_room_name: mainRoom.name,
|
||
ha_connection: {
|
||
base_url: '',
|
||
token: '',
|
||
verify_ssl: true,
|
||
},
|
||
camera: {
|
||
poster_url: String(camera.poster_url || ''),
|
||
stream_url: String(camera.stream_url || ''),
|
||
stream_mode: String(camera.stream_mode || 'hls'),
|
||
popup_timeout_minutes: Number(camera.popup_timeout_minutes || 3),
|
||
trigger_entities: Array.isArray(camera.trigger_entities) ? camera.trigger_entities.map(String) : [],
|
||
},
|
||
main_weather_actions: Array.isArray(config.app?.main_weather_actions) ? config.app.main_weather_actions : [],
|
||
main_boiler: config.app?.main_boiler || {},
|
||
main_print: config.app?.main_print || {},
|
||
},
|
||
spaces: roomSummaries,
|
||
selected_space: selectedRoom,
|
||
space_index: spaceIndex,
|
||
space_entities: spaceEntities,
|
||
entity_index: entityIndexMap,
|
||
weather,
|
||
main_entities: main,
|
||
battery_room: battery,
|
||
temperature_sensor_entity_id: selectedRoom?.temperature_sensor_entity_id ?? null,
|
||
popup,
|
||
rooms: roomSummaries,
|
||
selected_room: selectedRoom,
|
||
ui: {
|
||
embed: true,
|
||
mode: 'ha',
|
||
shell: 'ha-native',
|
||
config_source: 'ha-local',
|
||
proxy_token: '',
|
||
},
|
||
runtime_config: config,
|
||
};
|
||
}
|
||
|
||
class StrikerPanelPanel extends HTMLElement {
|
||
constructor() {
|
||
super();
|
||
this._hass = null;
|
||
this._panel = null;
|
||
this._narrow = false;
|
||
this._initialized = false;
|
||
this._assetsLoaded = false;
|
||
this._bridge = null;
|
||
this._runtimeConfig = null;
|
||
this._selectedRoomId = 'main';
|
||
this._popupState = {
|
||
active: false,
|
||
sensor_entity_id: null,
|
||
opened_at: null,
|
||
expires_at: null,
|
||
};
|
||
this._triggerSnapshot = {};
|
||
this._popupCloseTimer = null;
|
||
this._assetVersion = String(Date.now());
|
||
this._debugLastSnapshotSignature = '';
|
||
this._registryCache = null;
|
||
this._registryCachePromise = null;
|
||
}
|
||
|
||
set hass(hass) {
|
||
this._hass = hass;
|
||
this._syncRuntime();
|
||
}
|
||
|
||
set panel(panel) {
|
||
this._panel = panel;
|
||
this._syncRuntime();
|
||
}
|
||
|
||
set narrow(narrow) {
|
||
this._narrow = Boolean(narrow);
|
||
this._syncRuntime();
|
||
}
|
||
|
||
connectedCallback() {
|
||
panelLog('connectedCallback()', {
|
||
entry_id: this._entryId(),
|
||
selected_room_id: this._selectedRoom(),
|
||
});
|
||
this._ensureShell();
|
||
this._ensureAssets();
|
||
this._ensureBridge();
|
||
this._syncRuntime();
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
if (this._popupCloseTimer) {
|
||
clearTimeout(this._popupCloseTimer);
|
||
this._popupCloseTimer = null;
|
||
}
|
||
}
|
||
|
||
_panelConfig() {
|
||
const config = this._panel?.config || {};
|
||
const customConfig = config._panel_custom || {};
|
||
return customConfig.config || customConfig || config;
|
||
}
|
||
|
||
_entryId() {
|
||
return String(this._panelConfig().entry_id || '').trim();
|
||
}
|
||
|
||
_syncToken() {
|
||
return String(this._panelConfig().sync_token || '').trim();
|
||
}
|
||
|
||
_selectedRoom() {
|
||
return this._selectedRoomId || 'main';
|
||
}
|
||
|
||
_currentConfig() {
|
||
const runtimeConfig = this._runtimeConfig || this._panelConfig().runtime_config || this._panelConfig().config || {};
|
||
this._runtimeConfig = normalizeConfig(runtimeConfig);
|
||
return this._runtimeConfig;
|
||
}
|
||
|
||
_ensureShell() {
|
||
if (this._initialized) {
|
||
return;
|
||
}
|
||
window.StrikerPanelClient = window.StrikerPanelClient || {};
|
||
window.StrikerPanelClient.mountRoot = this.shadowRoot || this;
|
||
this.style.display = 'block';
|
||
this.style.width = '100%';
|
||
this.style.height = '100%';
|
||
this.style.minHeight = '100vh';
|
||
this.style.background = 'var(--primary-background-color, #0d0f14)';
|
||
this.innerHTML = `
|
||
<style data-striker-panel-css="1">
|
||
@import url("/api/wall_panel/assets/app.css?v=${this._assetVersion}");
|
||
</style>
|
||
<div class="app-shell app-shell--embed">
|
||
<aside class="sidebar">
|
||
<section class="clock-panel">
|
||
<div class="clock-panel__time" id="clock-time">--:--</div>
|
||
<div class="clock-panel__date" id="clock-date">---</div>
|
||
</section>
|
||
|
||
<section class="rooms-panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<div class="panel-header__label">Пространства</div>
|
||
<div class="panel-header__sub" id="rooms-count">0</div>
|
||
</div>
|
||
<button class="icon-button" id="edit-mode-toggle" type="button" aria-label="Edit mode">
|
||
<i class="mdi mdi-cog-outline"></i>
|
||
</button>
|
||
</div>
|
||
<div class="room-list" id="room-list"></div>
|
||
</section>
|
||
</aside>
|
||
|
||
<main class="content">
|
||
<div class="content-top" id="content-top">
|
||
<div class="main-print-strip-slot" id="main-print-strip-slot"></div>
|
||
</div>
|
||
<header class="content-header">
|
||
<button class="icon-button icon-button--ghost content-header__back" id="selected-room-back" type="button" aria-label="Back" hidden>
|
||
<i class="mdi mdi-arrow-left"></i>
|
||
</button>
|
||
<div>
|
||
<div class="content-header__eyebrow" id="selected-room-eyebrow"></div>
|
||
<h1 class="content-header__title" id="selected-room-title"></h1>
|
||
<div class="content-header__meta" id="selected-room-meta"></div>
|
||
</div>
|
||
<div class="content-header__actions" id="selected-room-actions"></div>
|
||
</header>
|
||
|
||
<section class="dashboard-grid" id="dashboard-grid">
|
||
<div class="grid-surface" id="dashboard-surface">
|
||
<div class="loading-card" style="display: none;">Загрузка панели...</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="camera-modal" aria-hidden="true">
|
||
<div class="camera-modal" id="camera-modal-panel">
|
||
<button class="icon-button icon-button--ghost camera-modal__close" id="camera-modal-close" type="button" aria-label="Close">
|
||
<i class="mdi mdi-close"></i>
|
||
</button>
|
||
<div class="camera-modal__body">
|
||
<div class="camera-stage" id="camera-stage">
|
||
<img class="camera-stage__poster" id="camera-poster" alt="Camera poster">
|
||
<div class="camera-stage__placeholder" id="camera-placeholder">
|
||
<div class="camera-stage__placeholder-icon"><i class="mdi mdi-cctv"></i></div>
|
||
<div class="camera-stage__placeholder-title">Поток загружается</div>
|
||
<div class="camera-stage__placeholder-subtitle">Показываем poster, пока не доступен video bridge</div>
|
||
</div>
|
||
</div>
|
||
<div class="camera-modal__footer">
|
||
<div class="camera-modal__countdown" id="camera-countdown"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="entity-modal" aria-hidden="true">
|
||
<div class="entity-modal" id="entity-modal-panel" role="dialog" aria-modal="true" aria-labelledby="entity-modal-title">
|
||
<div class="entity-modal__header">
|
||
<div>
|
||
<div class="entity-modal__eyebrow" id="entity-modal-eyebrow"></div>
|
||
<div class="entity-modal__title" id="entity-modal-title">Устройство</div>
|
||
</div>
|
||
<button class="icon-button icon-button--ghost" id="entity-modal-close" type="button" aria-label="Close">
|
||
<i class="mdi mdi-close"></i>
|
||
</button>
|
||
</div>
|
||
<div class="entity-modal__body" id="entity-modal-body"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="temperature-sensor-modal" aria-hidden="true">
|
||
<div class="temperature-sensor-modal" id="temperature-sensor-modal-panel" role="dialog" aria-modal="true" aria-labelledby="temperature-sensor-modal-title">
|
||
<div class="temperature-sensor-modal__header">
|
||
<div>
|
||
<div class="temperature-sensor-modal__eyebrow">Настройка комнаты</div>
|
||
<div class="temperature-sensor-modal__title" id="temperature-sensor-modal-title">Выбрать датчик температуры</div>
|
||
</div>
|
||
<button class="icon-button icon-button--ghost" id="temperature-sensor-modal-close" type="button" aria-label="Close">
|
||
<i class="mdi mdi-close"></i>
|
||
</button>
|
||
</div>
|
||
<div class="temperature-sensor-modal__body" id="temperature-sensor-modal-body"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="confirm-modal" aria-hidden="true">
|
||
<div class="confirm-modal" id="confirm-modal-panel" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
|
||
<div class="confirm-modal__header">
|
||
<div>
|
||
<div class="confirm-modal__eyebrow">Подтверждение</div>
|
||
<div class="confirm-modal__title" id="confirm-modal-title">Хотите закрыть?</div>
|
||
</div>
|
||
</div>
|
||
<div class="confirm-modal__body" id="confirm-modal-message">Это действие отправит команду закрытия.</div>
|
||
<div class="confirm-modal__footer">
|
||
<button class="mushroom-button mushroom-button--small" id="confirm-modal-no" type="button">Нет</button>
|
||
<button class="mushroom-button mushroom-button--small is-on" id="confirm-modal-yes" type="button">Да</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
this._initialized = true;
|
||
}
|
||
|
||
_ensureAssets() {
|
||
if (this._assetsLoaded) {
|
||
return;
|
||
}
|
||
const jsSrc = `/api/wall_panel/assets/app.js?v=${this._assetVersion}`;
|
||
|
||
if (!document.querySelector(`link[data-striker-panel-mdi="1"]`)) {
|
||
const link = document.createElement('link');
|
||
link.rel = 'stylesheet';
|
||
link.href = 'https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css';
|
||
link.dataset.strikerPanelMdi = '1';
|
||
document.head.appendChild(link);
|
||
}
|
||
|
||
if (!document.querySelector(`script[data-striker-panel-app="1"][src="${jsSrc}"]`)) {
|
||
const script = document.createElement('script');
|
||
script.src = jsSrc;
|
||
script.defer = true;
|
||
script.dataset.strikerPanelApp = '1';
|
||
document.head.appendChild(script);
|
||
}
|
||
|
||
this._assetsLoaded = true;
|
||
}
|
||
|
||
_emitSnapshot(snapshot) {
|
||
panelLog('emitSnapshot()', panelSnapshotSummary(snapshot));
|
||
window.APP_BOOTSTRAP = snapshot;
|
||
window.dispatchEvent(new CustomEvent(WALL_PANEL_HA_EVENT, { detail: { snapshot } }));
|
||
try {
|
||
window.StrikerPanelClient?.renderFromSnapshot?.(snapshot);
|
||
} catch (error) {
|
||
panelWarn('renderFromSnapshot() failed', error);
|
||
}
|
||
}
|
||
|
||
_getRegistryMaps() {
|
||
if (this._registryCache) {
|
||
return this._registryCache;
|
||
}
|
||
const hass = this._hass || {};
|
||
return {
|
||
entities: registryMap(hass.entities, ['entity_id']),
|
||
devices: registryMap(hass.devices, ['device_id', 'id', 'di']),
|
||
areas: registryMap(hass.areas, ['area_id', 'id']),
|
||
floors: registryMap(hass.floors, ['floor_id', 'id']),
|
||
};
|
||
}
|
||
|
||
async _refreshRegistryCache(force = false) {
|
||
if (!force && this._registryCache) {
|
||
return this._registryCache;
|
||
}
|
||
|
||
if (!force && this._registryCachePromise) {
|
||
return this._registryCachePromise;
|
||
}
|
||
|
||
const hass = this._hass || {};
|
||
if (!hass?.callWS) {
|
||
this._registryCache = this._getRegistryMaps();
|
||
return this._registryCache;
|
||
}
|
||
|
||
this._registryCachePromise = (async () => {
|
||
try {
|
||
const [entities, devices, areas, floors] = await Promise.all([
|
||
hass.callWS({ type: 'config/entity_registry/list' }),
|
||
hass.callWS({ type: 'config/device_registry/list' }),
|
||
hass.callWS({ type: 'config/area_registry/list' }),
|
||
hass.callWS({ type: 'config/floor_registry/list' }),
|
||
]);
|
||
this._registryCache = {
|
||
entities: registryMap(entities, ['entity_id']),
|
||
devices: registryMap(devices, ['device_id', 'id', 'di']),
|
||
areas: registryMap(areas, ['area_id', 'id']),
|
||
floors: registryMap(floors, ['floor_id', 'id']),
|
||
};
|
||
panelLog('registry cache refreshed', {
|
||
entities: Array.isArray(entities) ? entities.length : 0,
|
||
devices: Array.isArray(devices) ? devices.length : 0,
|
||
areas: Array.isArray(areas) ? areas.length : 0,
|
||
floors: Array.isArray(floors) ? floors.length : 0,
|
||
entity_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => item?.entity_id || item?.id || '') : [],
|
||
entity_labels_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => ({ id: item?.entity_id || item?.id || '', labels: item?.labels || item?.label_ids || [] })) : [],
|
||
});
|
||
return this._registryCache;
|
||
} catch (error) {
|
||
panelWarn('registry cache refresh failed, falling back to hass.* maps', error);
|
||
this._registryCache = this._getRegistryMaps();
|
||
return this._registryCache;
|
||
} finally {
|
||
this._registryCachePromise = null;
|
||
}
|
||
})();
|
||
|
||
return this._registryCachePromise;
|
||
}
|
||
|
||
_buildSnapshot(roomId = this._selectedRoom()) {
|
||
const config = this._currentConfig();
|
||
const hass = this._hass || {};
|
||
const states = hass.states || {};
|
||
const haData = {
|
||
states: Object.values(states),
|
||
...this._getRegistryMaps(),
|
||
};
|
||
|
||
const editMode = Boolean(config?.app?.edit_mode);
|
||
const rooms = roomDefinitions(config, haData);
|
||
const mainRoom = {
|
||
id: 'main',
|
||
name: String(config?.app?.main_room_name || 'Главная'),
|
||
icon: String(config?.app?.main_room_icon || 'mdi:home'),
|
||
visible: true,
|
||
entity_count: 0,
|
||
};
|
||
const roomSummaries = [mainRoom];
|
||
const roomViews = {};
|
||
const spaceIndex = {};
|
||
const spaceEntities = {};
|
||
|
||
for (const room of rooms) {
|
||
const entities = roomEntities(room, haData, editMode);
|
||
const summary = roomSummary(room, entities, haData);
|
||
roomSummaries.push(summary);
|
||
roomViews[room.id] = {
|
||
id: room.id,
|
||
name: room.name,
|
||
icon: room.icon,
|
||
visible: Boolean(room.visible),
|
||
order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999,
|
||
entities,
|
||
layout_items: roomLayoutItems(room),
|
||
entity_count: summary.entity_count,
|
||
active_entity_count: summary.active_entity_count,
|
||
temperature_badge: summary.temperature_badge,
|
||
temperature_sensor_entity_id: room.temperature_sensor_entity_id,
|
||
};
|
||
spaceIndex[room.id] = roomViews[room.id];
|
||
spaceEntities[room.id] = entities;
|
||
}
|
||
|
||
const weatherEntity = selectWeatherEntity(haData, config);
|
||
const weather = weatherSummary(weatherEntity);
|
||
const weatherSensor = states['sensor.weather_temperature'];
|
||
if (weather && weatherSensor) {
|
||
weather.sensor_temperature = weatherSensor.state ?? null;
|
||
}
|
||
|
||
const main = mainEntities(config, haData);
|
||
const battery = batteryRoom(config, haData, rooms, roomId);
|
||
const entityIndexMap = entityIndex(config, haData);
|
||
|
||
let selectedRoom;
|
||
if (roomId === 'main') {
|
||
selectedRoom = {
|
||
id: 'main',
|
||
name: mainRoom.name,
|
||
icon: mainRoom.icon,
|
||
visible: true,
|
||
entities: main,
|
||
layout_items: [],
|
||
entity_count: main.length,
|
||
active_entity_count: main.length,
|
||
temperature_badge: null,
|
||
};
|
||
selectedRoom.weather = weather;
|
||
} else if (roomId === 'batteries') {
|
||
selectedRoom = battery;
|
||
} else {
|
||
selectedRoom = spaceIndex[roomId] || roomViews[roomId] || {
|
||
id: roomId,
|
||
name: roomId,
|
||
icon: 'mdi:home-variant',
|
||
visible: true,
|
||
entities: [],
|
||
layout_items: [],
|
||
entity_count: 0,
|
||
active_entity_count: 0,
|
||
temperature_badge: null,
|
||
};
|
||
}
|
||
|
||
const camera = config.camera || {};
|
||
const popup = clone(this._popupState);
|
||
if (popup.active && popup.expires_at && Date.now() >= Number(popup.expires_at) * 1000) {
|
||
popup.active = false;
|
||
popup.expires_at = null;
|
||
this._popupState = popup;
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
demo: false,
|
||
server_time: Math.floor(Date.now() / 1000),
|
||
settings: {
|
||
title: String(config.app?.title || 'Striker Panel'),
|
||
poll_interval_ms: Number(config.app?.poll_interval_ms || 5000),
|
||
edit_mode: Boolean(config.app?.edit_mode),
|
||
main_room_name: mainRoom.name,
|
||
ha_connection: null,
|
||
camera: {
|
||
poster_url: String(camera.poster_url || ''),
|
||
stream_url: String(camera.stream_url || ''),
|
||
stream_mode: String(camera.stream_mode || 'hls'),
|
||
popup_timeout_minutes: Number(camera.popup_timeout_minutes || 3),
|
||
trigger_entities: Array.isArray(camera.trigger_entities) ? camera.trigger_entities.map(String) : [],
|
||
},
|
||
main_weather_actions: Array.isArray(config.app?.main_weather_actions) ? config.app.main_weather_actions : [],
|
||
main_boiler: config.app?.main_boiler || {},
|
||
main_print: config.app?.main_print || {},
|
||
},
|
||
spaces: roomSummaries,
|
||
selected_space: selectedRoom,
|
||
space_index: spaceIndex,
|
||
space_entities: spaceEntities,
|
||
entity_index: entityIndexMap,
|
||
weather,
|
||
main_entities: main,
|
||
battery_room: battery,
|
||
temperature_sensor_entity_id: selectedRoom?.temperature_sensor_entity_id ?? null,
|
||
popup: {
|
||
active: Boolean(popup.active),
|
||
sensor_entity_id: popup.sensor_entity_id ?? null,
|
||
opened_at: popup.opened_at ?? null,
|
||
expires_at: popup.expires_at ?? null,
|
||
poster_url: String(camera.poster_url || ''),
|
||
stream_url: String(camera.stream_url || ''),
|
||
stream_mode: String(camera.stream_mode || 'hls'),
|
||
title: 'Камера',
|
||
},
|
||
rooms: roomSummaries,
|
||
selected_room: selectedRoom,
|
||
ui: {
|
||
embed: true,
|
||
mode: 'ha-native',
|
||
shell: 'ha-native',
|
||
config_source: 'ha-local',
|
||
proxy_token: '',
|
||
},
|
||
runtime_config: config,
|
||
};
|
||
}
|
||
|
||
_schedulePopupClose(delayMs) {
|
||
if (this._popupCloseTimer) {
|
||
clearTimeout(this._popupCloseTimer);
|
||
}
|
||
this._popupCloseTimer = setTimeout(() => {
|
||
this._popupCloseTimer = null;
|
||
this._popupState.active = false;
|
||
this._popupState.expires_at = null;
|
||
this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
|
||
}, delayMs);
|
||
}
|
||
|
||
_updateTriggerPopup(nextStates) {
|
||
const config = this._currentConfig();
|
||
const triggerEntities = new Set((config.camera?.trigger_entities || []).map(String));
|
||
if (!triggerEntities.size) return;
|
||
const previous = this._triggerSnapshot;
|
||
this._triggerSnapshot = nextStates;
|
||
const timeoutMinutes = Math.max(1, Number(config.camera?.popup_timeout_minutes || 3));
|
||
const closeDelaySeconds = 30;
|
||
for (const entityId of triggerEntities) {
|
||
const current = String(nextStates[entityId] || '').toLowerCase();
|
||
const prev = String(previous[entityId] || '').toLowerCase();
|
||
if (current === 'on' && prev !== 'on') {
|
||
this._popupState = {
|
||
active: true,
|
||
sensor_entity_id: entityId,
|
||
opened_at: Math.floor(Date.now() / 1000),
|
||
expires_at: null,
|
||
};
|
||
this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
|
||
return;
|
||
}
|
||
if (current === 'off' && prev === 'on' && this._popupState.active && this._popupState.sensor_entity_id === entityId) {
|
||
this._popupState.active = true;
|
||
this._popupState.expires_at = Math.floor(Date.now() / 1000) + closeDelaySeconds;
|
||
this._schedulePopupClose(closeDelaySeconds * 1000);
|
||
this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
|
||
return;
|
||
}
|
||
if (this._popupState.active && this._popupState.expires_at && Math.floor(Date.now() / 1000) >= Number(this._popupState.expires_at)) {
|
||
this._popupState.active = false;
|
||
this._popupState.expires_at = null;
|
||
this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
|
||
}
|
||
}
|
||
}
|
||
|
||
_refreshFromHass() {
|
||
if (!this._hass) {
|
||
return;
|
||
}
|
||
const nextStates = {};
|
||
Object.entries(this._hass.states || {}).forEach(([entityId, entity]) => {
|
||
nextStates[entityId] = entity?.state;
|
||
});
|
||
this._updateTriggerPopup(nextStates);
|
||
const snapshot = this._buildSnapshot(this._selectedRoom());
|
||
this._emitSnapshot(snapshot);
|
||
}
|
||
|
||
_ensureBridge() {
|
||
if (this._bridge) {
|
||
return;
|
||
}
|
||
panelLog('bridge init');
|
||
this._bridge = {
|
||
mode: 'ha',
|
||
request: (method, action, payload = {}) => this._request(method, action, payload),
|
||
getSnapshot: (roomId = this._selectedRoom()) => Promise.resolve(this._buildSnapshot(roomId || this._selectedRoom())),
|
||
setSelectedRoomId: (roomId) => {
|
||
this._selectedRoomId = String(roomId || 'main');
|
||
},
|
||
getConfig: () => clone(this._currentConfig()),
|
||
};
|
||
window.WALL_PANEL_HA_BRIDGE = this._bridge;
|
||
}
|
||
|
||
async _request(method, action, payload = {}) {
|
||
const normalizedAction = String(action || '').trim().toLowerCase();
|
||
const config = this._currentConfig();
|
||
const entryId = this._entryId();
|
||
const token = this._syncToken();
|
||
panelLog('bridge request', panelRequestSummary(method, action, payload));
|
||
|
||
if (method === 'GET' && normalizedAction === 'snapshot') {
|
||
const roomId = String(payload.space_id || payload.room_id || this._selectedRoom() || 'main');
|
||
this._selectedRoomId = roomId;
|
||
panelLog('bridge snapshot -> build', { room_id: roomId });
|
||
return this._buildSnapshot(roomId);
|
||
}
|
||
|
||
if (method === 'GET' && normalizedAction === 'history') {
|
||
const entityId = String(payload.entity_id || '').trim();
|
||
const hours = Math.max(1, Math.min(168, Number(payload.hours || 24) || 24));
|
||
if (!entityId) {
|
||
return [];
|
||
}
|
||
if (this._hass?.callWS) {
|
||
const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||
panelLog('bridge history via callWS', { entity_id: entityId, hours });
|
||
const history = await this._hass.callWS({
|
||
type: 'history/history_during_period',
|
||
start_time: start,
|
||
end_time: new Date().toISOString(),
|
||
entity_ids: [entityId],
|
||
minimal_response: true,
|
||
no_attributes: true,
|
||
significant_changes_only: false,
|
||
});
|
||
return { history };
|
||
}
|
||
const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||
panelLog('bridge history via fetch fallback', { entity_id: entityId, hours });
|
||
const url = new URL(`/api/history/period/${encodeURIComponent(start)}`, window.location.origin);
|
||
url.searchParams.set('filter_entity_id', entityId);
|
||
url.searchParams.set('minimal_response', '1');
|
||
url.searchParams.set('no_attributes', '1');
|
||
const res = await fetch(url.toString(), {
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error(`History request failed: ${res.status}`);
|
||
}
|
||
const history = await res.json();
|
||
return { history };
|
||
}
|
||
|
||
if (method === 'POST' && normalizedAction === 'service') {
|
||
const entityId = String(payload.entity_id || '').trim();
|
||
const command = String(payload.command || '').trim();
|
||
const value = payload.value;
|
||
if (!entityId || !command) {
|
||
throw new Error('entity_id and command are required');
|
||
}
|
||
|
||
const domain = appEntityDomain(entityId);
|
||
const serviceMap = {
|
||
toggle: [domain, 'toggle', { entity_id: entityId }],
|
||
turn_on: [domain, 'turn_on', { entity_id: entityId }],
|
||
turn_off: [domain, 'turn_off', { entity_id: entityId }],
|
||
open: ['cover', 'open_cover', { entity_id: entityId }],
|
||
close: ['cover', 'close_cover', { entity_id: entityId }],
|
||
stop: ['cover', 'stop_cover', { entity_id: entityId }],
|
||
set_position: ['cover', 'set_cover_position', { entity_id: entityId, position: value }],
|
||
set_temperature: ['climate', 'set_temperature', { entity_id: entityId, temperature: value }],
|
||
set_hvac_mode: ['climate', 'set_hvac_mode', { entity_id: entityId, hvac_mode: value }],
|
||
set_fan_mode: ['climate', 'set_fan_mode', { entity_id: entityId, fan_mode: value }],
|
||
set_swing_mode: ['climate', 'set_swing_mode', { entity_id: entityId, swing_mode: value }],
|
||
set_preset_mode: ['climate', 'set_preset_mode', { entity_id: entityId, preset_mode: value }],
|
||
};
|
||
const [serviceDomain, serviceName, data] = serviceMap[command] || [domain, command, { entity_id: entityId, ...(value !== undefined ? { value } : {}) }];
|
||
if (this._hass?.callService) {
|
||
panelLog('bridge service via callService', {
|
||
entity_id: entityId,
|
||
command,
|
||
service: `${serviceDomain}.${serviceName}`,
|
||
});
|
||
await this._hass.callService(serviceDomain, serviceName, data);
|
||
} else {
|
||
panelLog('bridge service via fetch fallback', {
|
||
entity_id: entityId,
|
||
command,
|
||
service: `${serviceDomain}.${serviceName}`,
|
||
});
|
||
const url = new URL(`/api/services/${encodeURIComponent(serviceDomain)}/${encodeURIComponent(serviceName)}`, window.location.origin);
|
||
const res = await fetch(url.toString(), {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json',
|
||
},
|
||
body: JSON.stringify(data),
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error(`Home Assistant returned HTTP ${res.status}`);
|
||
}
|
||
}
|
||
this._refreshFromHass();
|
||
return { ok: true };
|
||
}
|
||
|
||
if (method === 'POST' && normalizedAction === 'popup') {
|
||
const timeoutMinutes = Math.max(1, Number(config.camera?.popup_timeout_minutes || 3));
|
||
if (String(payload.command || '').toLowerCase() === 'close') {
|
||
this._popupState = {
|
||
active: false,
|
||
sensor_entity_id: null,
|
||
opened_at: this._popupState.opened_at || null,
|
||
expires_at: null,
|
||
};
|
||
} else if (String(payload.command || '').toLowerCase() === 'open') {
|
||
this._popupState = {
|
||
active: true,
|
||
sensor_entity_id: String(payload.sensor_entity_id || 'debug'),
|
||
opened_at: Math.floor(Date.now() / 1000),
|
||
expires_at: Math.floor(Date.now() / 1000) + timeoutMinutes * 60,
|
||
};
|
||
this._schedulePopupClose(timeoutMinutes * 60 * 1000);
|
||
} else if (payload.sensor_entity_id) {
|
||
const sensor = String(payload.sensor_entity_id || '');
|
||
const stateValue = String(payload.state || payload.to || '').toLowerCase();
|
||
if (stateValue === 'on') {
|
||
this._popupState = {
|
||
active: true,
|
||
sensor_entity_id: sensor,
|
||
opened_at: Math.floor(Date.now() / 1000),
|
||
expires_at: null,
|
||
};
|
||
} else if (stateValue === 'off' && this._popupState.active && this._popupState.sensor_entity_id === sensor) {
|
||
this._popupState.expires_at = Math.floor(Date.now() / 1000) + 30;
|
||
this._schedulePopupClose(30 * 1000);
|
||
}
|
||
}
|
||
panelLog('bridge popup updated', {
|
||
command: String(payload.command || '').toLowerCase() || 'update',
|
||
sensor_entity_id: payload.sensor_entity_id || null,
|
||
active: Boolean(this._popupState.active),
|
||
});
|
||
const snapshot = this._buildSnapshot(this._selectedRoom());
|
||
this._emitSnapshot(snapshot);
|
||
return { ok: true, popup: snapshot.popup };
|
||
}
|
||
|
||
if (method === 'POST' && ['save-settings', 'save-entity-override', 'save-space-override', 'create-room-layout-item', 'save-room-layout-item', 'delete-room-layout-item', 'reorder-room-grid'].includes(normalizedAction)) {
|
||
if (!entryId) {
|
||
throw new Error('entry_id is required');
|
||
}
|
||
if (!token) {
|
||
throw new Error('sync token is required');
|
||
}
|
||
|
||
panelLog('bridge config write', {
|
||
action: normalizedAction,
|
||
entry_id: entryId,
|
||
payload_keys: Object.keys(payload || {}),
|
||
});
|
||
const url = new URL(`/api/wall_panel/config/${encodeURIComponent(entryId)}`, window.location.origin);
|
||
const res = await fetch(url.toString(), {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json',
|
||
'X-Wall-Panel-Token': token,
|
||
},
|
||
body: JSON.stringify({
|
||
action: normalizedAction,
|
||
payload,
|
||
}),
|
||
});
|
||
const json = await res.json();
|
||
if (!res.ok || json.ok === false) {
|
||
throw new Error(json.error || `Request failed: ${res.status}`);
|
||
}
|
||
if (json.config) {
|
||
this._runtimeConfig = normalizeConfig(json.config);
|
||
}
|
||
const snapshot = this._buildSnapshot(this._selectedRoom());
|
||
this._emitSnapshot(snapshot);
|
||
return json;
|
||
}
|
||
|
||
throw new Error(`Unsupported action: ${action}`);
|
||
}
|
||
|
||
_syncRuntime() {
|
||
if (!this.isConnected) {
|
||
return;
|
||
}
|
||
this._ensureShell();
|
||
this._ensureAssets();
|
||
this._ensureBridge();
|
||
this._runtimeConfig = normalizeConfig(this._panelConfig().runtime_config || this._panelConfig().config || this._runtimeConfig || {});
|
||
const currentRoom = this._selectedRoom();
|
||
this._refreshRegistryCache().then(() => {
|
||
const snapshot = this._buildSnapshot(currentRoom);
|
||
const signature = JSON.stringify([
|
||
snapshot?.selected_room?.id || snapshot?.selected_space?.id || 'main',
|
||
Array.isArray(snapshot?.rooms) ? snapshot.rooms.length : Array.isArray(snapshot?.spaces) ? snapshot.spaces.length : 0,
|
||
Array.isArray(snapshot?.main_entities) ? snapshot.main_entities.length : 0,
|
||
Boolean(snapshot?.popup?.active),
|
||
Boolean(snapshot?.ui?.mode === 'ha-native' || snapshot?.ui?.mode === 'ha'),
|
||
]);
|
||
if (signature !== this._debugLastSnapshotSignature) {
|
||
this._debugLastSnapshotSignature = signature;
|
||
panelLog('syncRuntime()', panelSnapshotSummary(snapshot));
|
||
}
|
||
this._emitSnapshot(snapshot);
|
||
}).catch((error) => {
|
||
panelWarn('syncRuntime registry refresh failed', error);
|
||
const snapshot = this._buildSnapshot(currentRoom);
|
||
this._emitSnapshot(snapshot);
|
||
});
|
||
}
|
||
}
|
||
|
||
if (!customElements.get('striker-panel-panel')) {
|
||
customElements.define('striker-panel-panel', StrikerPanelPanel);
|
||
}
|