-
This commit is contained in:
parent
b5d3feb726
commit
03f21e2de8
@ -2115,6 +2115,11 @@ body.is-mobile-ui #camera-modal {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.main-dashboard__cards .main-dashboard__room-grid {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room-entities-section {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
|
||||
485
assets/app.js
485
assets/app.js
@ -42,20 +42,210 @@
|
||||
haSubscribeId: 1,
|
||||
roomSelectionToken: 0,
|
||||
snapshotPollTimer: null,
|
||||
haSnapshotListenerInstalled: false,
|
||||
debugLastRenderSignature: '',
|
||||
};
|
||||
|
||||
const els = {};
|
||||
const client = window.StrikerPanelClient || (window.StrikerPanelClient = {});
|
||||
const debugEnabled = (() => {
|
||||
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 debugLog = (...args) => {
|
||||
if (debugEnabled) {
|
||||
console.log('[Striker Panel]', ...args);
|
||||
}
|
||||
};
|
||||
const debugWarn = (...args) => {
|
||||
if (debugEnabled) {
|
||||
console.warn('[Striker Panel]', ...args);
|
||||
}
|
||||
};
|
||||
|
||||
client.renderFromSnapshot = (snapshot) => {
|
||||
if (!snapshot || typeof snapshot !== 'object') {
|
||||
return;
|
||||
}
|
||||
state.snapshot = snapshot;
|
||||
initRefs();
|
||||
state.embedMode = detectEmbeddedContext();
|
||||
syncLayoutState();
|
||||
render();
|
||||
};
|
||||
|
||||
client.refresh = () => {
|
||||
initRefs();
|
||||
state.embedMode = detectEmbeddedContext();
|
||||
syncLayoutState();
|
||||
render();
|
||||
};
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
const root = client.mountRoot || document;
|
||||
if (!root) return null;
|
||||
if (typeof root.getElementById === 'function') {
|
||||
return root.getElementById(id);
|
||||
}
|
||||
return root.querySelector?.(`#${CSS.escape(id)}`) || null;
|
||||
}
|
||||
|
||||
function q(sel, root = document) {
|
||||
return root.querySelector(sel);
|
||||
function q(sel, root) {
|
||||
const actualRoot = root || client.mountRoot || document;
|
||||
return actualRoot.querySelector(sel);
|
||||
}
|
||||
|
||||
function qa(sel, root = document) {
|
||||
return Array.from(root.querySelectorAll(sel));
|
||||
function qa(sel, root) {
|
||||
const actualRoot = root || client.mountRoot || document;
|
||||
return Array.from(actualRoot.querySelectorAll(sel));
|
||||
}
|
||||
|
||||
function haBridge() {
|
||||
return window.WALL_PANEL_HA_BRIDGE || null;
|
||||
}
|
||||
|
||||
function isHaRuntime() {
|
||||
return Boolean(
|
||||
haBridge()
|
||||
|| bootstrap?.ui?.mode === 'ha-native'
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
|
||||
}
|
||||
|
||||
function snapshotLooksReady(snapshot) {
|
||||
if (!snapshot || typeof snapshot !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const rooms = Array.isArray(snapshot.rooms) ? snapshot.rooms : [];
|
||||
const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : [];
|
||||
if (rooms.length > 0 || spaces.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (snapshot.selected_room?.id || snapshot.selected_space?.id) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function snapshotSummary(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 requestSummary(action, params = {}) {
|
||||
const summary = { action };
|
||||
['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => {
|
||||
if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
|
||||
summary[key] = params[key];
|
||||
}
|
||||
});
|
||||
if (params.payload && typeof params.payload === 'object') {
|
||||
summary.payload_keys = Object.keys(params.payload);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function resolveInitialSnapshot() {
|
||||
const bootstrapSnapshot = window.APP_BOOTSTRAP || bootstrap || {};
|
||||
debugLog('resolveInitialSnapshot()', {
|
||||
ha_runtime: isHaRuntime(),
|
||||
bridge_ready: Boolean(haBridge()),
|
||||
bootstrap_ready: snapshotLooksReady(bootstrapSnapshot),
|
||||
});
|
||||
if (!isHaRuntime()) {
|
||||
debugLog('resolveInitialSnapshot -> bootstrap (standalone)', snapshotSummary(bootstrapSnapshot));
|
||||
return bootstrapSnapshot;
|
||||
}
|
||||
|
||||
const waitForHaBridge = async (timeoutMs = 1000) => {
|
||||
const startedAt = Date.now();
|
||||
let bridge = haBridge();
|
||||
while (!bridge && (Date.now() - startedAt) < timeoutMs) {
|
||||
await sleep(50);
|
||||
bridge = haBridge();
|
||||
}
|
||||
return bridge;
|
||||
};
|
||||
|
||||
const tryBridgeSnapshot = async () => {
|
||||
const bridge = await waitForHaBridge();
|
||||
if (!bridge) {
|
||||
debugLog('HA bridge not ready yet');
|
||||
return null;
|
||||
}
|
||||
|
||||
const roomId = state.selectedRoomId || 'main';
|
||||
try {
|
||||
if (typeof bridge.getSnapshot === 'function') {
|
||||
debugLog('request initial snapshot via bridge.getSnapshot()', { room_id: roomId });
|
||||
const snapshot = await bridge.getSnapshot(roomId);
|
||||
if (snapshotLooksReady(snapshot)) {
|
||||
debugLog('initial snapshot received via bridge.getSnapshot()', snapshotSummary(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugWarn('initial snapshot bridge.getSnapshot() failed', error);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof bridge.request === 'function') {
|
||||
debugLog('request initial snapshot via bridge.request(GET snapshot)', { room_id: roomId });
|
||||
const snapshot = await bridge.request('GET', 'snapshot', { space_id: roomId });
|
||||
if (snapshotLooksReady(snapshot)) {
|
||||
debugLog('initial snapshot received via bridge.request(GET snapshot)', snapshotSummary(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugWarn('initial snapshot bridge.request(GET snapshot) failed', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstPass = await tryBridgeSnapshot();
|
||||
if (firstPass) {
|
||||
return firstPass;
|
||||
}
|
||||
|
||||
await sleep(150);
|
||||
|
||||
const secondPass = await tryBridgeSnapshot();
|
||||
if (secondPass) {
|
||||
return secondPass;
|
||||
}
|
||||
|
||||
debugLog('resolveInitialSnapshot -> fallback bootstrap', snapshotSummary(bootstrapSnapshot));
|
||||
return snapshotLooksReady(bootstrapSnapshot) ? bootstrapSnapshot : bootstrapSnapshot;
|
||||
}
|
||||
|
||||
const PRESSABLE_SELECTOR = [
|
||||
@ -198,6 +388,7 @@
|
||||
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'icon-node';
|
||||
wrap.appendChild(createIconElement(fallback));
|
||||
Promise.resolve(getIcon(name)).then((definition) => {
|
||||
if (!definition || !wrap.isConnected) return;
|
||||
wrap.replaceChildren(createSvgIcon(definition));
|
||||
@ -229,6 +420,7 @@
|
||||
: source.replace(/^fab:/, 'fa-brands:');
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'icon-node';
|
||||
wrap.appendChild(createIconElement(fallback));
|
||||
const img = document.createElement('img');
|
||||
img.className = 'icon-node__img';
|
||||
img.alt = '';
|
||||
@ -236,6 +428,10 @@
|
||||
img.loading = 'lazy';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.src = `https://api.iconify.design/${mappedSource}.svg`;
|
||||
img.addEventListener('load', () => {
|
||||
if (!img.isConnected || !wrap.isConnected) return;
|
||||
wrap.replaceChildren(img);
|
||||
});
|
||||
img.addEventListener('error', () => {
|
||||
if (img.dataset.fallbackApplied === '1') return;
|
||||
img.dataset.fallbackApplied = '1';
|
||||
@ -252,6 +448,7 @@
|
||||
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'icon-node';
|
||||
wrap.appendChild(createIconElement(fallback));
|
||||
|
||||
if (source.includes(':')) {
|
||||
const img = document.createElement('img');
|
||||
@ -261,6 +458,10 @@
|
||||
img.loading = 'lazy';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.src = `https://api.iconify.design/${source}.svg`;
|
||||
img.addEventListener('load', () => {
|
||||
if (!img.isConnected || !wrap.isConnected) return;
|
||||
wrap.replaceChildren(img);
|
||||
});
|
||||
img.addEventListener('error', () => {
|
||||
if (img.dataset.fallbackApplied === '1') return;
|
||||
img.dataset.fallbackApplied = '1';
|
||||
@ -448,6 +649,9 @@
|
||||
}
|
||||
|
||||
function buildUrl(action, params = {}) {
|
||||
if (isHaRuntime()) {
|
||||
return '';
|
||||
}
|
||||
const url = new URL('api.php', window.location.href);
|
||||
url.searchParams.set('action', action);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
@ -463,6 +667,12 @@
|
||||
}
|
||||
|
||||
async function apiGet(action, params = {}) {
|
||||
const bridge = haBridge();
|
||||
if (bridge?.request) {
|
||||
console.log('[Striker Panel]', 'apiGet via bridge', requestSummary(action, params));
|
||||
return bridge.request('GET', action, params);
|
||||
}
|
||||
console.log('[Striker Panel]', 'apiGet via http', requestSummary(action, params));
|
||||
const res = await fetch(buildUrl(action, params), {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
@ -474,6 +684,12 @@
|
||||
}
|
||||
|
||||
async function apiPost(action, payload = {}) {
|
||||
const bridge = haBridge();
|
||||
if (bridge?.request) {
|
||||
console.log('[Striker Panel]', 'apiPost via bridge', requestSummary(action, payload));
|
||||
return bridge.request('POST', action, payload);
|
||||
}
|
||||
console.log('[Striker Panel]', 'apiPost via http', requestSummary(action, payload));
|
||||
const res = await fetch(buildUrl(action), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
@ -487,7 +703,11 @@
|
||||
}
|
||||
|
||||
async function fetchSnapshot(roomId = state.selectedRoomId || 'main') {
|
||||
return apiGet('snapshot', { space_id: roomId || 'main' });
|
||||
const response = await apiGet('snapshot', { space_id: roomId || 'main' });
|
||||
if (response && response.ok === true && response.selected_room) {
|
||||
return response;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function loadSnapshot(roomId = state.selectedRoomId || 'main') {
|
||||
@ -1004,6 +1224,87 @@
|
||||
return q('.main-dashboard__cards', els.dashboardSurface);
|
||||
}
|
||||
|
||||
function renderMainRoomSummaryGrid(snapshot) {
|
||||
const rooms = Array.isArray(snapshot?.spaces) ? snapshot.spaces : Array.isArray(snapshot?.rooms) ? snapshot.rooms : [];
|
||||
const batteryRoom = snapshot?.battery_room || null;
|
||||
const cards = document.createElement('div');
|
||||
cards.className = 'room-list__group main-dashboard__room-grid';
|
||||
|
||||
const roomCard = (room, options = {}) => {
|
||||
if (!room) return null;
|
||||
const card = document.createElement('div');
|
||||
card.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${options.hidden ? 'is-hidden-room' : ''}`.trim();
|
||||
card.dataset.roomId = room.id;
|
||||
card.tabIndex = 0;
|
||||
card.setAttribute('role', 'button');
|
||||
card.addEventListener('click', () => setSelectedRoom(room.id));
|
||||
card.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setSelectedRoom(room.id);
|
||||
}
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'room-item__content';
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'room-item__icon';
|
||||
icon.appendChild(createIconElement(room.icon || 'mdi:home-variant'));
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'room-item__body';
|
||||
const activeCount = room.id === 'batteries'
|
||||
? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0
|
||||
: Number(room.active_entity_count ?? room.entity_count ?? 0) || 0;
|
||||
const metaText = room.id === 'main'
|
||||
? 'Главный экран'
|
||||
: room.id === 'batteries'
|
||||
? (room.battery_summary_text || `${room.entity_count || 0} батареек`)
|
||||
: activeCount > 0
|
||||
? `${activeCount} ${pluralizeActiveEntities(activeCount)}`
|
||||
: 'Нет активных';
|
||||
body.innerHTML = `
|
||||
<div class="room-item__name">${esc(room.name || '')}</div>
|
||||
<div class="room-item__meta">${esc(metaText)}</div>
|
||||
`;
|
||||
content.append(icon, body);
|
||||
|
||||
const tempBadge = roomTemperatureBadge(snapshot, room);
|
||||
if (tempBadge) {
|
||||
card.classList.add('has-temp');
|
||||
const temp = document.createElement('div');
|
||||
temp.className = 'room-item__temp';
|
||||
temp.textContent = tempBadge;
|
||||
card.appendChild(temp);
|
||||
}
|
||||
|
||||
card.append(content);
|
||||
return card;
|
||||
};
|
||||
|
||||
const orderedRooms = [...rooms]
|
||||
.filter((room) => room && room.id !== 'main' && room.visible !== false && room.id !== 'batteries')
|
||||
.sort((left, right) => {
|
||||
const leftOrder = Number(left.order ?? 9999);
|
||||
const rightOrder = Number(right.order ?? 9999);
|
||||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||||
return String(left.name || '').localeCompare(String(right.name || ''), 'ru');
|
||||
});
|
||||
|
||||
orderedRooms.forEach((room) => {
|
||||
const card = roomCard(room);
|
||||
if (card) cards.appendChild(card);
|
||||
});
|
||||
|
||||
if (batteryRoom && !isMobileViewport()) {
|
||||
const card = roomCard(batteryRoom);
|
||||
if (card) cards.appendChild(card);
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
function currentDashboardCardsContainer() {
|
||||
const snapshot = state.snapshot || bootstrap;
|
||||
const room = snapshot.selected_space || snapshot.selected_room || {};
|
||||
@ -1613,10 +1914,16 @@
|
||||
}
|
||||
|
||||
function haConnection() {
|
||||
if (isHaRuntime()) {
|
||||
return null;
|
||||
}
|
||||
return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {};
|
||||
}
|
||||
|
||||
function haWsUrl(baseUrl) {
|
||||
if (isHaRuntime()) {
|
||||
return '';
|
||||
}
|
||||
if (!baseUrl) return '';
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
@ -3342,6 +3649,9 @@
|
||||
}
|
||||
|
||||
function renderRoomButtons(snapshot, rooms, batteryRoom = null) {
|
||||
if (!els.roomList) {
|
||||
return;
|
||||
}
|
||||
els.roomList.innerHTML = '';
|
||||
const sortedRooms = [...(rooms || [])].sort((left, right) => {
|
||||
if (left.id === 'main') return -1;
|
||||
@ -3466,6 +3776,11 @@
|
||||
|
||||
function renderSelectedRoom(snapshot) {
|
||||
const room = snapshot.selected_space || snapshot.selected_room || {};
|
||||
const setText = (el, value) => {
|
||||
if (el) {
|
||||
el.textContent = value;
|
||||
}
|
||||
};
|
||||
if (els.contentTop) {
|
||||
els.contentTop.classList.toggle('is-main', room.id === 'main');
|
||||
}
|
||||
@ -3474,8 +3789,8 @@
|
||||
}
|
||||
updateMainPrintStrip(snapshot);
|
||||
if (room.id === 'batteries') {
|
||||
els.selectedRoomEyebrow.textContent = 'Псевдо-комната';
|
||||
els.selectedRoomTitle.textContent = room.name || 'Батарейки';
|
||||
setText(els.selectedRoomEyebrow, 'Псевдо-комната');
|
||||
setText(els.selectedRoomTitle, room.name || 'Батарейки');
|
||||
const total = Number(room.entity_count ?? 0) || 0;
|
||||
const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0;
|
||||
const unavailable = Number(room.unavailable_count ?? 0) || 0;
|
||||
@ -3490,26 +3805,26 @@
|
||||
if (unknown > 0) {
|
||||
summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`);
|
||||
}
|
||||
els.selectedRoomMeta.textContent = summaryParts.length
|
||||
setText(els.selectedRoomMeta, summaryParts.length
|
||||
? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`
|
||||
: `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`;
|
||||
: `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`);
|
||||
renderSelectedRoomActions(snapshot);
|
||||
return;
|
||||
}
|
||||
if (room.id !== 'main') {
|
||||
els.selectedRoomEyebrow.textContent = 'Пространство';
|
||||
els.selectedRoomTitle.textContent = room.name || 'Панель';
|
||||
setText(els.selectedRoomEyebrow, 'Пространство');
|
||||
setText(els.selectedRoomTitle, room.name || 'Панель');
|
||||
const entities = roomEntities(snapshot, room.id || 'main');
|
||||
const activeCount = Number(room.active_entity_count ?? entities.length) || 0;
|
||||
els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`;
|
||||
setText(els.selectedRoomMeta, `${activeCount} ${pluralizeActiveEntities(activeCount)}`);
|
||||
renderSelectedRoomActions(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
const entities = roomEntities(snapshot, room.id || 'main');
|
||||
els.selectedRoomEyebrow.textContent = '';
|
||||
els.selectedRoomTitle.textContent = room.name || 'Панель';
|
||||
els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`;
|
||||
setText(els.selectedRoomEyebrow, '');
|
||||
setText(els.selectedRoomTitle, room.name || 'Панель');
|
||||
setText(els.selectedRoomMeta, `${entities.length} ${pluralizeIncludedEntities(entities.length)}`);
|
||||
renderSelectedRoomActions(snapshot);
|
||||
}
|
||||
|
||||
@ -3545,23 +3860,32 @@
|
||||
function renderDashboard(snapshot) {
|
||||
const room = snapshot.selected_space || snapshot.selected_room || {};
|
||||
const grid = els.dashboardSurface;
|
||||
if (!grid) {
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (room.id === 'main') {
|
||||
const layout = document.createElement('div');
|
||||
layout.className = 'main-dashboard';
|
||||
|
||||
const hero = renderMainHero(snapshot);
|
||||
|
||||
const mainEntities = roomEntities(snapshot, 'main');
|
||||
const cards = document.createElement('div');
|
||||
cards.className = 'grid-surface main-dashboard__cards';
|
||||
|
||||
const mainEntities = roomEntities(snapshot, 'main');
|
||||
if (mainEntities.length) {
|
||||
mainEntities.forEach((entity) => {
|
||||
cards.appendChild(renderEntityCard(entity, { isMain: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const hero = renderMainHero(snapshot);
|
||||
|
||||
layout.append(hero);
|
||||
if (mainEntities.length) {
|
||||
layout.append(cards);
|
||||
}
|
||||
|
||||
layout.append(hero, cards);
|
||||
grid.appendChild(layout);
|
||||
return;
|
||||
}
|
||||
@ -3683,8 +4007,10 @@
|
||||
]);
|
||||
|
||||
if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) {
|
||||
if (els.cameraBackdrop) {
|
||||
els.cameraBackdrop.classList.add('is-open');
|
||||
els.cameraBackdrop.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -3695,11 +4021,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (els.cameraPoster) {
|
||||
els.cameraPoster.src = popup.poster_url || '';
|
||||
els.cameraPoster.alt = popup.sensor_entity_id || 'camera';
|
||||
}
|
||||
if (els.cameraBackdrop) {
|
||||
els.cameraBackdrop.classList.add('is-open');
|
||||
els.cameraBackdrop.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
if (els.cameraPlaceholder) {
|
||||
els.cameraPlaceholder.classList.add('is-visible');
|
||||
}
|
||||
|
||||
const expiresAt = Number(popup.expires_at || 0);
|
||||
if (expiresAt > 0) {
|
||||
@ -3709,11 +4041,15 @@
|
||||
const mins = Math.floor(remaining / 60);
|
||||
const secs = remaining % 60;
|
||||
if (remaining > 0) {
|
||||
if (els.cameraCountdown) {
|
||||
els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (els.cameraCountdown) {
|
||||
els.cameraCountdown.textContent = 'Закрытие...';
|
||||
}
|
||||
if (closeRequested) {
|
||||
return;
|
||||
}
|
||||
@ -3736,7 +4072,9 @@
|
||||
clearInterval(state.popupDismissTimer);
|
||||
state.popupDismissTimer = setInterval(updateCountdown, 1000);
|
||||
} else {
|
||||
if (els.cameraCountdown) {
|
||||
els.cameraCountdown.textContent = '';
|
||||
}
|
||||
clearInterval(state.popupDismissTimer);
|
||||
state.popupDismissTimer = null;
|
||||
}
|
||||
@ -3759,14 +4097,26 @@
|
||||
active: false,
|
||||
};
|
||||
}
|
||||
if (els.cameraBackdrop) {
|
||||
els.cameraBackdrop.classList.remove('is-open');
|
||||
els.cameraBackdrop.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
if (els.cameraStage) {
|
||||
els.cameraStage.innerHTML = '';
|
||||
if (els.cameraPoster) {
|
||||
els.cameraStage.appendChild(els.cameraPoster);
|
||||
}
|
||||
if (els.cameraPlaceholder) {
|
||||
els.cameraStage.appendChild(els.cameraPlaceholder);
|
||||
els.cameraPlaceholder.classList.add('is-visible');
|
||||
}
|
||||
}
|
||||
if (els.cameraPoster) {
|
||||
els.cameraPoster.removeAttribute('src');
|
||||
}
|
||||
if (els.cameraCountdown) {
|
||||
els.cameraCountdown.textContent = '';
|
||||
}
|
||||
clearInterval(state.popupDismissTimer);
|
||||
state.popupDismissTimer = null;
|
||||
destroyStream();
|
||||
@ -3916,19 +4266,35 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const renderSignature = 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'),
|
||||
]);
|
||||
if (renderSignature !== state.debugLastRenderSignature) {
|
||||
state.debugLastRenderSignature = renderSignature;
|
||||
debugLog('render()', snapshotSummary(snapshot));
|
||||
}
|
||||
|
||||
syncLayoutState();
|
||||
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
|
||||
renderSelectedRoom(snapshot);
|
||||
renderDashboard(snapshot);
|
||||
renderSelectedRoom(snapshot);
|
||||
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
|
||||
renderPopup(snapshot);
|
||||
renderEntityPopup(snapshot);
|
||||
renderTemperatureSensorPopup(snapshot);
|
||||
|
||||
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
|
||||
if (els.roomsCount) {
|
||||
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
|
||||
}
|
||||
if (els.editModeToggle) {
|
||||
els.editModeToggle.classList.toggle('is-active', state.editMode);
|
||||
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardOnly() {
|
||||
const snapshot = state.snapshot || bootstrap;
|
||||
@ -3939,6 +4305,14 @@
|
||||
renderPopup(snapshot);
|
||||
renderEntityPopup(snapshot);
|
||||
renderTemperatureSensorPopup(snapshot);
|
||||
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
|
||||
if (els.roomsCount) {
|
||||
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
|
||||
}
|
||||
if (els.editModeToggle) {
|
||||
els.editModeToggle.classList.toggle('is-active', state.editMode);
|
||||
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCurrentRoomLayout(entityId) {
|
||||
@ -3968,6 +4342,9 @@
|
||||
}
|
||||
|
||||
const container = els.dashboardSurface;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const order = new Map(roomEntities(snapshot, room.id).map((entity) => [entity.entity_id, Number(entity.order ?? 9999)]));
|
||||
const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]'));
|
||||
cards.sort((left, right) => {
|
||||
@ -3984,10 +4361,14 @@
|
||||
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return;
|
||||
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
|
||||
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
|
||||
if (els.roomsCount) {
|
||||
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
|
||||
}
|
||||
if (els.editModeToggle) {
|
||||
els.editModeToggle.classList.toggle('is-active', state.editMode);
|
||||
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectionOnly() {
|
||||
const snapshot = state.snapshot || bootstrap;
|
||||
@ -4191,6 +4572,11 @@
|
||||
}
|
||||
|
||||
function wireEvents() {
|
||||
const bind = (el, type, handler, options) => {
|
||||
if (!el) return;
|
||||
el.addEventListener(type, handler, options);
|
||||
};
|
||||
|
||||
els.selectedRoomBack?.addEventListener('click', () => {
|
||||
if (!isMobileViewport()) return;
|
||||
closeEntityPopup();
|
||||
@ -4200,14 +4586,14 @@
|
||||
renderSelectionOnly();
|
||||
});
|
||||
|
||||
els.cameraBackdrop.addEventListener('click', (event) => {
|
||||
bind(els.cameraBackdrop, 'click', (event) => {
|
||||
if (event.target === els.cameraBackdrop) {
|
||||
apiPost('popup', { command: 'close' }).catch(() => {});
|
||||
hidePopup({ suppressAutoOpen: true });
|
||||
}
|
||||
});
|
||||
|
||||
els.cameraModalPanel.addEventListener('click', (event) => {
|
||||
bind(els.cameraModalPanel, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
@ -4224,8 +4610,8 @@
|
||||
hidePopup({ suppressAutoOpen: true });
|
||||
};
|
||||
|
||||
els.cameraClose.addEventListener('pointerdown', closeCameraPopup);
|
||||
els.cameraClose.addEventListener('click', closeCameraPopup);
|
||||
bind(els.cameraClose, 'pointerdown', closeCameraPopup);
|
||||
bind(els.cameraClose, 'click', closeCameraPopup);
|
||||
|
||||
els.entityBackdrop?.addEventListener('click', (event) => {
|
||||
if (event.target === els.entityBackdrop) {
|
||||
@ -4233,7 +4619,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
els.entityModalPanel?.addEventListener('click', (event) => {
|
||||
bind(els.entityModalPanel, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
@ -4247,19 +4633,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
els.temperatureSensorModalPanel?.addEventListener('click', (event) => {
|
||||
bind(els.temperatureSensorModalPanel, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
els.temperatureSensorClose?.addEventListener('click', () => {
|
||||
bind(els.temperatureSensorClose, 'click', () => {
|
||||
closeTemperatureSensorPopup();
|
||||
});
|
||||
|
||||
els.popupDebugButton?.addEventListener('click', () => {
|
||||
bind(els.popupDebugButton, 'click', () => {
|
||||
showDebugPopup();
|
||||
});
|
||||
|
||||
els.editModeToggle.addEventListener('click', async () => {
|
||||
bind(els.editModeToggle, 'click', async () => {
|
||||
state.editMode = !state.editMode;
|
||||
try {
|
||||
await apiPost('save-settings', {
|
||||
@ -4380,6 +4766,9 @@
|
||||
}
|
||||
|
||||
function startSnapshotPolling() {
|
||||
if (isHaRuntime()) {
|
||||
return;
|
||||
}
|
||||
const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000));
|
||||
if (state.snapshotPollTimer) {
|
||||
clearInterval(state.snapshotPollTimer);
|
||||
@ -4396,6 +4785,9 @@
|
||||
}
|
||||
|
||||
function handleHaMessage(message) {
|
||||
if (isHaRuntime()) {
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object') {
|
||||
return;
|
||||
}
|
||||
@ -4519,6 +4911,11 @@
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
if (isHaRuntime()) {
|
||||
setStatus('HA native mode', 'online');
|
||||
stopSnapshotPolling();
|
||||
return;
|
||||
}
|
||||
const connection = haConnection();
|
||||
const baseUrl = connection.base_url || '';
|
||||
const token = connection.token || '';
|
||||
@ -4572,6 +4969,11 @@
|
||||
}
|
||||
|
||||
async function start() {
|
||||
debugLog('start()', {
|
||||
ha_runtime: isHaRuntime(),
|
||||
embed_mode: Boolean(bootstrap?.ui?.embed),
|
||||
mode: bootstrap?.ui?.mode || 'unknown',
|
||||
});
|
||||
initRefs();
|
||||
state.embedMode = detectEmbeddedContext();
|
||||
syncLayoutState();
|
||||
@ -4582,6 +4984,19 @@
|
||||
state.clockTimer = setInterval(updateClock, 1000);
|
||||
wireEvents();
|
||||
|
||||
if (!state.haSnapshotListenerInstalled) {
|
||||
state.haSnapshotListenerInstalled = true;
|
||||
window.addEventListener('wall-panel-snapshot-updated', (event) => {
|
||||
const snapshot = event?.detail?.snapshot || event?.detail || null;
|
||||
if (!snapshot || typeof snapshot !== 'object') {
|
||||
return;
|
||||
}
|
||||
debugLog('wall-panel-snapshot-updated', snapshotSummary(snapshot));
|
||||
state.snapshot = snapshot;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
const viewportQuery = mobileViewportQuery();
|
||||
const handleViewportChange = () => {
|
||||
syncViewportState();
|
||||
@ -4593,8 +5008,8 @@
|
||||
viewportQuery.addListener(handleViewportChange);
|
||||
}
|
||||
|
||||
const initial = window.APP_BOOTSTRAP || {};
|
||||
state.snapshot = initial;
|
||||
state.snapshot = await resolveInitialSnapshot();
|
||||
debugLog('initial snapshot applied', snapshotSummary(state.snapshot || bootstrap));
|
||||
render();
|
||||
connectRealtime();
|
||||
if (!state.snapshotPollTimer) {
|
||||
@ -4602,5 +5017,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
|
||||
2574
custom_components/wall_panel/assets/app.css
Executable file
2574
custom_components/wall_panel/assets/app.css
Executable file
File diff suppressed because it is too large
Load Diff
4932
custom_components/wall_panel/assets/app.js
Executable file
4932
custom_components/wall_panel/assets/app.js
Executable file
File diff suppressed because it is too large
Load Diff
@ -14,29 +14,25 @@ from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, Tex
|
||||
from .const import (
|
||||
CONF_CONFIG,
|
||||
CONF_FRONTEND_URL_PATH,
|
||||
CONF_PANEL_URL,
|
||||
CONF_REQUIRE_ADMIN,
|
||||
CONF_SIDEBAR_ICON,
|
||||
CONF_SIDEBAR_TITLE,
|
||||
CONF_SYNC_TOKEN,
|
||||
DEFAULT_FRONTEND_URL_PATH,
|
||||
DEFAULT_PANEL_URL,
|
||||
DEFAULT_SIDEBAR_ICON,
|
||||
DEFAULT_SIDEBAR_TITLE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import config_to_json, normalize_config, parse_config_json
|
||||
from .helpers import config_to_json, current_entry_config, normalize_config, parse_config_json
|
||||
|
||||
|
||||
def _schema(defaults: dict[str, Any]) -> vol.Schema:
|
||||
return vol.Schema({
|
||||
vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, "Striker Panel")): str,
|
||||
vol.Optional(CONF_PANEL_URL, default=defaults.get(CONF_PANEL_URL, DEFAULT_PANEL_URL)): str,
|
||||
vol.Optional(CONF_SIDEBAR_TITLE, default=defaults.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE)): str,
|
||||
vol.Optional(CONF_SIDEBAR_ICON, default=defaults.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON)): str,
|
||||
vol.Optional(CONF_FRONTEND_URL_PATH, default=defaults.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH)): str,
|
||||
vol.Optional(CONF_REQUIRE_ADMIN, default=bool(defaults.get(CONF_REQUIRE_ADMIN, False))): bool,
|
||||
vol.Optional(CONF_SYNC_TOKEN, default=defaults.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24))): str,
|
||||
vol.Optional(
|
||||
CONF_CONFIG,
|
||||
default=defaults.get(CONF_CONFIG, config_to_json(normalize_config({}))),
|
||||
@ -62,12 +58,11 @@ class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
data = {
|
||||
CONF_NAME: user_input.get(CONF_NAME, "Striker Panel"),
|
||||
CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""),
|
||||
CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
|
||||
CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
|
||||
CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
|
||||
CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)),
|
||||
CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""),
|
||||
CONF_SYNC_TOKEN: secrets.token_urlsafe(24),
|
||||
CONF_CONFIG: config,
|
||||
}
|
||||
await self.async_set_unique_id(DOMAIN)
|
||||
@ -76,12 +71,10 @@ class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
defaults = {
|
||||
CONF_NAME: "Striker Panel",
|
||||
CONF_PANEL_URL: DEFAULT_PANEL_URL,
|
||||
CONF_SIDEBAR_TITLE: DEFAULT_SIDEBAR_TITLE,
|
||||
CONF_SIDEBAR_ICON: DEFAULT_SIDEBAR_ICON,
|
||||
CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH,
|
||||
CONF_REQUIRE_ADMIN: False,
|
||||
CONF_SYNC_TOKEN: secrets.token_urlsafe(24),
|
||||
CONF_CONFIG: config_to_json(normalize_config({})),
|
||||
}
|
||||
return self.async_show_form(step_id="user", data_schema=_schema(defaults), errors=errors)
|
||||
@ -107,25 +100,22 @@ class WallPanelOptionsFlow(config_entries.OptionsFlow):
|
||||
data = dict(self.config_entry.options)
|
||||
data.update({
|
||||
CONF_NAME: user_input.get(CONF_NAME, data.get(CONF_NAME, "Striker Panel")),
|
||||
CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""),
|
||||
CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
|
||||
CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
|
||||
CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
|
||||
CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)),
|
||||
CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""),
|
||||
CONF_SYNC_TOKEN: data.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)),
|
||||
CONF_CONFIG: config,
|
||||
})
|
||||
return self.async_create_entry(title="", data=data)
|
||||
|
||||
defaults = {
|
||||
CONF_NAME: self.config_entry.options.get(CONF_NAME, "Striker Panel"),
|
||||
CONF_PANEL_URL: self.config_entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL),
|
||||
CONF_SIDEBAR_TITLE: self.config_entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE),
|
||||
CONF_SIDEBAR_ICON: self.config_entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON),
|
||||
CONF_FRONTEND_URL_PATH: self.config_entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH),
|
||||
CONF_REQUIRE_ADMIN: self.config_entry.options.get(CONF_REQUIRE_ADMIN, False),
|
||||
CONF_SYNC_TOKEN: self.config_entry.options.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)),
|
||||
CONF_CONFIG: config_to_json(normalize_config(self.config_entry.options.get(CONF_CONFIG, {}))),
|
||||
CONF_CONFIG: config_to_json(current_entry_config(self.config_entry)),
|
||||
}
|
||||
return self.async_show_form(step_id="init", data_schema=_schema(defaults), errors=errors)
|
||||
|
||||
|
||||
@ -21,12 +21,23 @@ from .const import (
|
||||
DEFAULT_SIDEBAR_TITLE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import current_entry_config
|
||||
|
||||
|
||||
def _panel_url_path(entry) -> str:
|
||||
raw = str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH).strip()
|
||||
if raw in {"", "wall-panel"}:
|
||||
return DEFAULT_FRONTEND_URL_PATH
|
||||
return raw
|
||||
|
||||
|
||||
async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
|
||||
"""Register the custom panel and static frontend assets."""
|
||||
|
||||
frontend_dir = Path(__file__).parent / "frontend"
|
||||
# Bundle the HA-native assets inside the component so the integration
|
||||
# does not depend on files living in the root of /config.
|
||||
assets_dir = Path(__file__).parent / "assets"
|
||||
state = hass.data.setdefault(DOMAIN, {})
|
||||
if not state.get("_static_paths_registered"):
|
||||
await hass.http.async_register_static_paths([
|
||||
@ -35,16 +46,21 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
|
||||
str(frontend_dir),
|
||||
cache_headers=False,
|
||||
),
|
||||
StaticPathConfig(
|
||||
f"/api/{DOMAIN}/assets",
|
||||
str(assets_dir),
|
||||
cache_headers=False,
|
||||
),
|
||||
])
|
||||
state["_static_paths_registered"] = True
|
||||
|
||||
panel_url_path = str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH).strip()
|
||||
panel_url_path = _panel_url_path(entry)
|
||||
sidebar_title = str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE).strip()
|
||||
sidebar_icon = str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON).strip()
|
||||
require_admin = bool(entry.options.get(CONF_REQUIRE_ADMIN, False))
|
||||
panel_url = str(entry.options.get(CONF_PANEL_URL, "") or "").strip()
|
||||
sync_token = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
|
||||
asset_version = str(int(time.time()))
|
||||
runtime_config = current_entry_config(entry)
|
||||
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
@ -56,13 +72,12 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
|
||||
"_panel_custom": {
|
||||
"name": "striker-panel-panel",
|
||||
"module_url": f"/api/{DOMAIN}/frontend/panel.js?v={asset_version}",
|
||||
"embed_iframe": False,
|
||||
"trust_external": False,
|
||||
"config": {
|
||||
"panel_url": panel_url,
|
||||
"panel_url_path": panel_url_path,
|
||||
"runtime_config": runtime_config,
|
||||
"ui_mode": "ha-native",
|
||||
"entry_id": entry.entry_id,
|
||||
"sync_token": sync_token,
|
||||
"config_url": f"/api/{DOMAIN}/config/{entry.entry_id}",
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import (
|
||||
@ -21,6 +23,13 @@ from .const import (
|
||||
)
|
||||
|
||||
|
||||
def shared_config_path() -> Path:
|
||||
override = os.getenv("WALL_PANEL_SHARED_CONFIG_PATH", "").strip()
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path("/config/wall_panel/wall_panel_config.json")
|
||||
|
||||
|
||||
def default_config() -> dict[str, Any]:
|
||||
return {
|
||||
"app": {
|
||||
@ -79,7 +88,79 @@ def parse_config_json(raw: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def current_entry_config(entry) -> dict[str, Any]:
|
||||
return normalize_config(entry.options.get(CONF_CONFIG))
|
||||
path = shared_config_path()
|
||||
options_config = normalize_config(entry.options.get(CONF_CONFIG))
|
||||
|
||||
def _config_score(config: dict[str, Any]) -> int:
|
||||
score = 0
|
||||
rooms = config.get("rooms", [])
|
||||
if isinstance(rooms, list):
|
||||
score += len(rooms) * 25
|
||||
for room in rooms:
|
||||
if not isinstance(room, dict):
|
||||
continue
|
||||
score += len([value for value in (
|
||||
room.get("name"),
|
||||
room.get("icon"),
|
||||
room.get("area_id"),
|
||||
room.get("floor_id"),
|
||||
room.get("temperature_sensor_entity_id"),
|
||||
) if isinstance(value, str) and value.strip()])
|
||||
entity_ids = room.get("entity_ids", [])
|
||||
if isinstance(entity_ids, list):
|
||||
score += len([item for item in entity_ids if str(item).strip()])
|
||||
overrides = room.get("entity_overrides", {})
|
||||
if isinstance(overrides, dict):
|
||||
score += len(overrides) * 3
|
||||
layout_items = room.get("layout_items", [])
|
||||
if isinstance(layout_items, list):
|
||||
score += len(layout_items) * 5
|
||||
|
||||
app = config.get("app", {})
|
||||
if isinstance(app, dict):
|
||||
for key in ("title", "main_room_name", "main_room_icon"):
|
||||
value = app.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
score += 3
|
||||
for key in ("main_boiler", "main_print"):
|
||||
value = app.get(key)
|
||||
if isinstance(value, dict) and value:
|
||||
score += len(value) * 2
|
||||
actions = app.get("main_weather_actions", [])
|
||||
if isinstance(actions, list):
|
||||
score += len(actions) * 2
|
||||
|
||||
camera = config.get("camera", {})
|
||||
if isinstance(camera, dict):
|
||||
for key in ("rtsp_url", "stream_url", "poster_url"):
|
||||
value = camera.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
score += 5
|
||||
trigger_entities = camera.get("trigger_entities", [])
|
||||
if isinstance(trigger_entities, list):
|
||||
score += len([item for item in trigger_entities if str(item).strip()])
|
||||
|
||||
return score
|
||||
|
||||
chosen_config = options_config
|
||||
try:
|
||||
if path.is_file():
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
if raw.strip():
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
file_config = normalize_config(data)
|
||||
if _config_score(file_config) >= _config_score(options_config):
|
||||
chosen_config = file_config
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(config_to_json(chosen_config) + "\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
return chosen_config
|
||||
|
||||
|
||||
def current_entry_panel(entry) -> dict[str, Any]:
|
||||
@ -238,6 +319,12 @@ def build_patch_payload(payload: dict[str, Any], keys: list[str]) -> dict[str, A
|
||||
return result
|
||||
|
||||
|
||||
def save_shared_config(config: dict[str, Any]) -> None:
|
||||
path = shared_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(config_to_json(normalize_config(config)) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
|
||||
for key, value in source.items():
|
||||
if isinstance(value, dict) and isinstance(target.get(key), dict):
|
||||
|
||||
@ -24,6 +24,7 @@ from .helpers import (
|
||||
normalize_config,
|
||||
reorder_room_grid,
|
||||
save_settings,
|
||||
save_shared_config,
|
||||
update_entity_override,
|
||||
update_room_layout_item,
|
||||
update_room_override,
|
||||
@ -66,6 +67,11 @@ def _request_token(request: web.Request) -> str:
|
||||
return request.query.get("token", "").strip()
|
||||
|
||||
|
||||
def _proxy_cookie_name(entry_id: str) -> str:
|
||||
safe_entry_id = "".join(ch for ch in entry_id if ch.isalnum() or ch in {"-", "_"})
|
||||
return f"wall_panel_proxy_{safe_entry_id}"
|
||||
|
||||
|
||||
def _authorized(entry, request: web.Request) -> bool:
|
||||
expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
|
||||
if not expected:
|
||||
@ -74,6 +80,19 @@ def _authorized(entry, request: web.Request) -> bool:
|
||||
return secrets.compare_digest(_request_token(request), expected)
|
||||
|
||||
|
||||
def _proxy_authorized(entry, request: web.Request, entry_id: str) -> bool:
|
||||
expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
|
||||
if not expected:
|
||||
return False
|
||||
|
||||
token = _request_token(request)
|
||||
if token and secrets.compare_digest(token, expected):
|
||||
return True
|
||||
|
||||
cookie = request.cookies.get(_proxy_cookie_name(entry_id), "")
|
||||
return bool(cookie) and secrets.compare_digest(cookie, expected)
|
||||
|
||||
|
||||
def _response(data: Any, status: int = 200) -> web.Response:
|
||||
if isinstance(data, str):
|
||||
return web.Response(text=data, status=status, content_type="text/plain; charset=utf-8")
|
||||
@ -235,7 +254,7 @@ async def _handle_proxy_request(request: web.Request, entry_id: str, path: str,
|
||||
entry = _entry_from_hass(hass, entry_id)
|
||||
if entry is None:
|
||||
return _response({"ok": False, "error": "Unknown entry"}, 404)
|
||||
if not _authorized(entry, request):
|
||||
if not _proxy_authorized(entry, request, entry_id):
|
||||
_LOGGER.warning("Wall Panel proxy denied for %s: unauthorized", entry_id)
|
||||
return _response({"ok": False, "error": "Unauthorized"}, 401)
|
||||
|
||||
@ -286,11 +305,26 @@ async def _handle_proxy_request(request: web.Request, entry_id: str, path: str,
|
||||
|
||||
response_body = await upstream.read()
|
||||
_LOGGER.warning("Wall Panel upstream %s %s", upstream.status, upstream_url)
|
||||
return web.Response(
|
||||
response = web.Response(
|
||||
body=response_body,
|
||||
status=upstream.status,
|
||||
headers=response_headers,
|
||||
)
|
||||
expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
|
||||
if expected:
|
||||
# The iframe's HTML may authenticate with ?token=..., but its relative
|
||||
# CSS/JS/API requests will not inherit that query string. A scoped cookie
|
||||
# keeps the whole proxied panel session authorized.
|
||||
response.set_cookie(
|
||||
_proxy_cookie_name(entry_id),
|
||||
expected,
|
||||
path=_proxy_root(entry_id),
|
||||
secure=True,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
max_age=60 * 60 * 24,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
async def handle_proxy_request(request: web.Request) -> web.StreamResponse:
|
||||
@ -303,6 +337,7 @@ async def handle_proxy_request(request: web.Request) -> web.StreamResponse:
|
||||
|
||||
|
||||
def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> None:
|
||||
save_shared_config(config)
|
||||
options = dict(entry.options)
|
||||
options[CONF_CONFIG] = config
|
||||
hass.config_entries.async_update_entry(entry, options=options)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"active": false,
|
||||
"sensor_entity_id": "binary_sensor.barn_all_occupancy",
|
||||
"opened_at": 1774445418,
|
||||
"sensor_entity_id": "binary_sensor.doorbell_all_occupancy",
|
||||
"opened_at": 1774456141,
|
||||
"expires_at": null
|
||||
}
|
||||
|
||||
@ -2115,6 +2115,11 @@ body.is-mobile-ui #camera-modal {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.main-dashboard__cards .main-dashboard__room-grid {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room-entities-section {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
|
||||
@ -42,20 +42,210 @@
|
||||
haSubscribeId: 1,
|
||||
roomSelectionToken: 0,
|
||||
snapshotPollTimer: null,
|
||||
haSnapshotListenerInstalled: false,
|
||||
debugLastRenderSignature: '',
|
||||
};
|
||||
|
||||
const els = {};
|
||||
const client = window.StrikerPanelClient || (window.StrikerPanelClient = {});
|
||||
const debugEnabled = (() => {
|
||||
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 debugLog = (...args) => {
|
||||
if (debugEnabled) {
|
||||
console.log('[Striker Panel]', ...args);
|
||||
}
|
||||
};
|
||||
const debugWarn = (...args) => {
|
||||
if (debugEnabled) {
|
||||
console.warn('[Striker Panel]', ...args);
|
||||
}
|
||||
};
|
||||
|
||||
client.renderFromSnapshot = (snapshot) => {
|
||||
if (!snapshot || typeof snapshot !== 'object') {
|
||||
return;
|
||||
}
|
||||
state.snapshot = snapshot;
|
||||
initRefs();
|
||||
state.embedMode = detectEmbeddedContext();
|
||||
syncLayoutState();
|
||||
render();
|
||||
};
|
||||
|
||||
client.refresh = () => {
|
||||
initRefs();
|
||||
state.embedMode = detectEmbeddedContext();
|
||||
syncLayoutState();
|
||||
render();
|
||||
};
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
const root = client.mountRoot || document;
|
||||
if (!root) return null;
|
||||
if (typeof root.getElementById === 'function') {
|
||||
return root.getElementById(id);
|
||||
}
|
||||
return root.querySelector?.(`#${CSS.escape(id)}`) || null;
|
||||
}
|
||||
|
||||
function q(sel, root = document) {
|
||||
return root.querySelector(sel);
|
||||
function q(sel, root) {
|
||||
const actualRoot = root || client.mountRoot || document;
|
||||
return actualRoot.querySelector(sel);
|
||||
}
|
||||
|
||||
function qa(sel, root = document) {
|
||||
return Array.from(root.querySelectorAll(sel));
|
||||
function qa(sel, root) {
|
||||
const actualRoot = root || client.mountRoot || document;
|
||||
return Array.from(actualRoot.querySelectorAll(sel));
|
||||
}
|
||||
|
||||
function haBridge() {
|
||||
return window.WALL_PANEL_HA_BRIDGE || null;
|
||||
}
|
||||
|
||||
function isHaRuntime() {
|
||||
return Boolean(
|
||||
haBridge()
|
||||
|| bootstrap?.ui?.mode === 'ha-native'
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
|
||||
}
|
||||
|
||||
function snapshotLooksReady(snapshot) {
|
||||
if (!snapshot || typeof snapshot !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const rooms = Array.isArray(snapshot.rooms) ? snapshot.rooms : [];
|
||||
const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : [];
|
||||
if (rooms.length > 0 || spaces.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (snapshot.selected_room?.id || snapshot.selected_space?.id) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function snapshotSummary(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 requestSummary(action, params = {}) {
|
||||
const summary = { action };
|
||||
['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => {
|
||||
if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
|
||||
summary[key] = params[key];
|
||||
}
|
||||
});
|
||||
if (params.payload && typeof params.payload === 'object') {
|
||||
summary.payload_keys = Object.keys(params.payload);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function resolveInitialSnapshot() {
|
||||
const bootstrapSnapshot = window.APP_BOOTSTRAP || bootstrap || {};
|
||||
debugLog('resolveInitialSnapshot()', {
|
||||
ha_runtime: isHaRuntime(),
|
||||
bridge_ready: Boolean(haBridge()),
|
||||
bootstrap_ready: snapshotLooksReady(bootstrapSnapshot),
|
||||
});
|
||||
if (!isHaRuntime()) {
|
||||
debugLog('resolveInitialSnapshot -> bootstrap (standalone)', snapshotSummary(bootstrapSnapshot));
|
||||
return bootstrapSnapshot;
|
||||
}
|
||||
|
||||
const waitForHaBridge = async (timeoutMs = 1000) => {
|
||||
const startedAt = Date.now();
|
||||
let bridge = haBridge();
|
||||
while (!bridge && (Date.now() - startedAt) < timeoutMs) {
|
||||
await sleep(50);
|
||||
bridge = haBridge();
|
||||
}
|
||||
return bridge;
|
||||
};
|
||||
|
||||
const tryBridgeSnapshot = async () => {
|
||||
const bridge = await waitForHaBridge();
|
||||
if (!bridge) {
|
||||
debugLog('HA bridge not ready yet');
|
||||
return null;
|
||||
}
|
||||
|
||||
const roomId = state.selectedRoomId || 'main';
|
||||
try {
|
||||
if (typeof bridge.getSnapshot === 'function') {
|
||||
debugLog('request initial snapshot via bridge.getSnapshot()', { room_id: roomId });
|
||||
const snapshot = await bridge.getSnapshot(roomId);
|
||||
if (snapshotLooksReady(snapshot)) {
|
||||
debugLog('initial snapshot received via bridge.getSnapshot()', snapshotSummary(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugWarn('initial snapshot bridge.getSnapshot() failed', error);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof bridge.request === 'function') {
|
||||
debugLog('request initial snapshot via bridge.request(GET snapshot)', { room_id: roomId });
|
||||
const snapshot = await bridge.request('GET', 'snapshot', { space_id: roomId });
|
||||
if (snapshotLooksReady(snapshot)) {
|
||||
debugLog('initial snapshot received via bridge.request(GET snapshot)', snapshotSummary(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugWarn('initial snapshot bridge.request(GET snapshot) failed', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstPass = await tryBridgeSnapshot();
|
||||
if (firstPass) {
|
||||
return firstPass;
|
||||
}
|
||||
|
||||
await sleep(150);
|
||||
|
||||
const secondPass = await tryBridgeSnapshot();
|
||||
if (secondPass) {
|
||||
return secondPass;
|
||||
}
|
||||
|
||||
debugLog('resolveInitialSnapshot -> fallback bootstrap', snapshotSummary(bootstrapSnapshot));
|
||||
return snapshotLooksReady(bootstrapSnapshot) ? bootstrapSnapshot : bootstrapSnapshot;
|
||||
}
|
||||
|
||||
const PRESSABLE_SELECTOR = [
|
||||
@ -198,6 +388,7 @@
|
||||
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'icon-node';
|
||||
wrap.appendChild(createIconElement(fallback));
|
||||
Promise.resolve(getIcon(name)).then((definition) => {
|
||||
if (!definition || !wrap.isConnected) return;
|
||||
wrap.replaceChildren(createSvgIcon(definition));
|
||||
@ -229,6 +420,7 @@
|
||||
: source.replace(/^fab:/, 'fa-brands:');
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'icon-node';
|
||||
wrap.appendChild(createIconElement(fallback));
|
||||
const img = document.createElement('img');
|
||||
img.className = 'icon-node__img';
|
||||
img.alt = '';
|
||||
@ -236,6 +428,10 @@
|
||||
img.loading = 'lazy';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.src = `https://api.iconify.design/${mappedSource}.svg`;
|
||||
img.addEventListener('load', () => {
|
||||
if (!img.isConnected || !wrap.isConnected) return;
|
||||
wrap.replaceChildren(img);
|
||||
});
|
||||
img.addEventListener('error', () => {
|
||||
if (img.dataset.fallbackApplied === '1') return;
|
||||
img.dataset.fallbackApplied = '1';
|
||||
@ -252,6 +448,7 @@
|
||||
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'icon-node';
|
||||
wrap.appendChild(createIconElement(fallback));
|
||||
|
||||
if (source.includes(':')) {
|
||||
const img = document.createElement('img');
|
||||
@ -261,6 +458,10 @@
|
||||
img.loading = 'lazy';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.src = `https://api.iconify.design/${source}.svg`;
|
||||
img.addEventListener('load', () => {
|
||||
if (!img.isConnected || !wrap.isConnected) return;
|
||||
wrap.replaceChildren(img);
|
||||
});
|
||||
img.addEventListener('error', () => {
|
||||
if (img.dataset.fallbackApplied === '1') return;
|
||||
img.dataset.fallbackApplied = '1';
|
||||
@ -448,6 +649,9 @@
|
||||
}
|
||||
|
||||
function buildUrl(action, params = {}) {
|
||||
if (isHaRuntime()) {
|
||||
return '';
|
||||
}
|
||||
const url = new URL('api.php', window.location.href);
|
||||
url.searchParams.set('action', action);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
@ -463,6 +667,12 @@
|
||||
}
|
||||
|
||||
async function apiGet(action, params = {}) {
|
||||
const bridge = haBridge();
|
||||
if (bridge?.request) {
|
||||
debugLog('apiGet via bridge', requestSummary(action, params));
|
||||
return bridge.request('GET', action, params);
|
||||
}
|
||||
debugLog('apiGet via http', requestSummary(action, params));
|
||||
const res = await fetch(buildUrl(action, params), {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
@ -474,6 +684,12 @@
|
||||
}
|
||||
|
||||
async function apiPost(action, payload = {}) {
|
||||
const bridge = haBridge();
|
||||
if (bridge?.request) {
|
||||
debugLog('apiPost via bridge', requestSummary(action, payload));
|
||||
return bridge.request('POST', action, payload);
|
||||
}
|
||||
debugLog('apiPost via http', requestSummary(action, payload));
|
||||
const res = await fetch(buildUrl(action), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
@ -487,7 +703,11 @@
|
||||
}
|
||||
|
||||
async function fetchSnapshot(roomId = state.selectedRoomId || 'main') {
|
||||
return apiGet('snapshot', { space_id: roomId || 'main' });
|
||||
const response = await apiGet('snapshot', { space_id: roomId || 'main' });
|
||||
if (response && response.ok === true && response.selected_room) {
|
||||
return response;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function loadSnapshot(roomId = state.selectedRoomId || 'main') {
|
||||
@ -1004,6 +1224,87 @@
|
||||
return q('.main-dashboard__cards', els.dashboardSurface);
|
||||
}
|
||||
|
||||
function renderMainRoomSummaryGrid(snapshot) {
|
||||
const rooms = Array.isArray(snapshot?.spaces) ? snapshot.spaces : Array.isArray(snapshot?.rooms) ? snapshot.rooms : [];
|
||||
const batteryRoom = snapshot?.battery_room || null;
|
||||
const cards = document.createElement('div');
|
||||
cards.className = 'room-list__group main-dashboard__room-grid';
|
||||
|
||||
const roomCard = (room, options = {}) => {
|
||||
if (!room) return null;
|
||||
const card = document.createElement('div');
|
||||
card.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${options.hidden ? 'is-hidden-room' : ''}`.trim();
|
||||
card.dataset.roomId = room.id;
|
||||
card.tabIndex = 0;
|
||||
card.setAttribute('role', 'button');
|
||||
card.addEventListener('click', () => setSelectedRoom(room.id));
|
||||
card.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setSelectedRoom(room.id);
|
||||
}
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'room-item__content';
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'room-item__icon';
|
||||
icon.appendChild(createIconElement(room.icon || 'mdi:home-variant'));
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'room-item__body';
|
||||
const activeCount = room.id === 'batteries'
|
||||
? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0
|
||||
: Number(room.active_entity_count ?? room.entity_count ?? 0) || 0;
|
||||
const metaText = room.id === 'main'
|
||||
? 'Главный экран'
|
||||
: room.id === 'batteries'
|
||||
? (room.battery_summary_text || `${room.entity_count || 0} батареек`)
|
||||
: activeCount > 0
|
||||
? `${activeCount} ${pluralizeActiveEntities(activeCount)}`
|
||||
: 'Нет активных';
|
||||
body.innerHTML = `
|
||||
<div class="room-item__name">${esc(room.name || '')}</div>
|
||||
<div class="room-item__meta">${esc(metaText)}</div>
|
||||
`;
|
||||
content.append(icon, body);
|
||||
|
||||
const tempBadge = roomTemperatureBadge(snapshot, room);
|
||||
if (tempBadge) {
|
||||
card.classList.add('has-temp');
|
||||
const temp = document.createElement('div');
|
||||
temp.className = 'room-item__temp';
|
||||
temp.textContent = tempBadge;
|
||||
card.appendChild(temp);
|
||||
}
|
||||
|
||||
card.append(content);
|
||||
return card;
|
||||
};
|
||||
|
||||
const orderedRooms = [...rooms]
|
||||
.filter((room) => room && room.id !== 'main' && room.visible !== false && room.id !== 'batteries')
|
||||
.sort((left, right) => {
|
||||
const leftOrder = Number(left.order ?? 9999);
|
||||
const rightOrder = Number(right.order ?? 9999);
|
||||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||||
return String(left.name || '').localeCompare(String(right.name || ''), 'ru');
|
||||
});
|
||||
|
||||
orderedRooms.forEach((room) => {
|
||||
const card = roomCard(room);
|
||||
if (card) cards.appendChild(card);
|
||||
});
|
||||
|
||||
if (batteryRoom && !isMobileViewport()) {
|
||||
const card = roomCard(batteryRoom);
|
||||
if (card) cards.appendChild(card);
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
function currentDashboardCardsContainer() {
|
||||
const snapshot = state.snapshot || bootstrap;
|
||||
const room = snapshot.selected_space || snapshot.selected_room || {};
|
||||
@ -1613,10 +1914,16 @@
|
||||
}
|
||||
|
||||
function haConnection() {
|
||||
if (isHaRuntime()) {
|
||||
return null;
|
||||
}
|
||||
return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {};
|
||||
}
|
||||
|
||||
function haWsUrl(baseUrl) {
|
||||
if (isHaRuntime()) {
|
||||
return '';
|
||||
}
|
||||
if (!baseUrl) return '';
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
@ -3342,6 +3649,9 @@
|
||||
}
|
||||
|
||||
function renderRoomButtons(snapshot, rooms, batteryRoom = null) {
|
||||
if (!els.roomList) {
|
||||
return;
|
||||
}
|
||||
els.roomList.innerHTML = '';
|
||||
const sortedRooms = [...(rooms || [])].sort((left, right) => {
|
||||
if (left.id === 'main') return -1;
|
||||
@ -3466,6 +3776,11 @@
|
||||
|
||||
function renderSelectedRoom(snapshot) {
|
||||
const room = snapshot.selected_space || snapshot.selected_room || {};
|
||||
const setText = (el, value) => {
|
||||
if (el) {
|
||||
el.textContent = value;
|
||||
}
|
||||
};
|
||||
if (els.contentTop) {
|
||||
els.contentTop.classList.toggle('is-main', room.id === 'main');
|
||||
}
|
||||
@ -3474,8 +3789,8 @@
|
||||
}
|
||||
updateMainPrintStrip(snapshot);
|
||||
if (room.id === 'batteries') {
|
||||
els.selectedRoomEyebrow.textContent = 'Псевдо-комната';
|
||||
els.selectedRoomTitle.textContent = room.name || 'Батарейки';
|
||||
setText(els.selectedRoomEyebrow, 'Псевдо-комната');
|
||||
setText(els.selectedRoomTitle, room.name || 'Батарейки');
|
||||
const total = Number(room.entity_count ?? 0) || 0;
|
||||
const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0;
|
||||
const unavailable = Number(room.unavailable_count ?? 0) || 0;
|
||||
@ -3490,26 +3805,26 @@
|
||||
if (unknown > 0) {
|
||||
summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`);
|
||||
}
|
||||
els.selectedRoomMeta.textContent = summaryParts.length
|
||||
setText(els.selectedRoomMeta, summaryParts.length
|
||||
? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`
|
||||
: `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`;
|
||||
: `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`);
|
||||
renderSelectedRoomActions(snapshot);
|
||||
return;
|
||||
}
|
||||
if (room.id !== 'main') {
|
||||
els.selectedRoomEyebrow.textContent = 'Пространство';
|
||||
els.selectedRoomTitle.textContent = room.name || 'Панель';
|
||||
setText(els.selectedRoomEyebrow, 'Пространство');
|
||||
setText(els.selectedRoomTitle, room.name || 'Панель');
|
||||
const entities = roomEntities(snapshot, room.id || 'main');
|
||||
const activeCount = Number(room.active_entity_count ?? entities.length) || 0;
|
||||
els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`;
|
||||
setText(els.selectedRoomMeta, `${activeCount} ${pluralizeActiveEntities(activeCount)}`);
|
||||
renderSelectedRoomActions(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
const entities = roomEntities(snapshot, room.id || 'main');
|
||||
els.selectedRoomEyebrow.textContent = '';
|
||||
els.selectedRoomTitle.textContent = room.name || 'Панель';
|
||||
els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`;
|
||||
setText(els.selectedRoomEyebrow, '');
|
||||
setText(els.selectedRoomTitle, room.name || 'Панель');
|
||||
setText(els.selectedRoomMeta, `${entities.length} ${pluralizeIncludedEntities(entities.length)}`);
|
||||
renderSelectedRoomActions(snapshot);
|
||||
}
|
||||
|
||||
@ -3545,23 +3860,32 @@
|
||||
function renderDashboard(snapshot) {
|
||||
const room = snapshot.selected_space || snapshot.selected_room || {};
|
||||
const grid = els.dashboardSurface;
|
||||
if (!grid) {
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (room.id === 'main') {
|
||||
const layout = document.createElement('div');
|
||||
layout.className = 'main-dashboard';
|
||||
|
||||
const hero = renderMainHero(snapshot);
|
||||
|
||||
const mainEntities = roomEntities(snapshot, 'main');
|
||||
const cards = document.createElement('div');
|
||||
cards.className = 'grid-surface main-dashboard__cards';
|
||||
|
||||
const mainEntities = roomEntities(snapshot, 'main');
|
||||
if (mainEntities.length) {
|
||||
mainEntities.forEach((entity) => {
|
||||
cards.appendChild(renderEntityCard(entity, { isMain: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const hero = renderMainHero(snapshot);
|
||||
|
||||
layout.append(hero);
|
||||
if (mainEntities.length) {
|
||||
layout.append(cards);
|
||||
}
|
||||
|
||||
layout.append(hero, cards);
|
||||
grid.appendChild(layout);
|
||||
return;
|
||||
}
|
||||
@ -3683,8 +4007,10 @@
|
||||
]);
|
||||
|
||||
if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) {
|
||||
if (els.cameraBackdrop) {
|
||||
els.cameraBackdrop.classList.add('is-open');
|
||||
els.cameraBackdrop.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -3695,11 +4021,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (els.cameraPoster) {
|
||||
els.cameraPoster.src = popup.poster_url || '';
|
||||
els.cameraPoster.alt = popup.sensor_entity_id || 'camera';
|
||||
}
|
||||
if (els.cameraBackdrop) {
|
||||
els.cameraBackdrop.classList.add('is-open');
|
||||
els.cameraBackdrop.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
if (els.cameraPlaceholder) {
|
||||
els.cameraPlaceholder.classList.add('is-visible');
|
||||
}
|
||||
|
||||
const expiresAt = Number(popup.expires_at || 0);
|
||||
if (expiresAt > 0) {
|
||||
@ -3709,11 +4041,15 @@
|
||||
const mins = Math.floor(remaining / 60);
|
||||
const secs = remaining % 60;
|
||||
if (remaining > 0) {
|
||||
if (els.cameraCountdown) {
|
||||
els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (els.cameraCountdown) {
|
||||
els.cameraCountdown.textContent = 'Закрытие...';
|
||||
}
|
||||
if (closeRequested) {
|
||||
return;
|
||||
}
|
||||
@ -3736,7 +4072,9 @@
|
||||
clearInterval(state.popupDismissTimer);
|
||||
state.popupDismissTimer = setInterval(updateCountdown, 1000);
|
||||
} else {
|
||||
if (els.cameraCountdown) {
|
||||
els.cameraCountdown.textContent = '';
|
||||
}
|
||||
clearInterval(state.popupDismissTimer);
|
||||
state.popupDismissTimer = null;
|
||||
}
|
||||
@ -3759,14 +4097,26 @@
|
||||
active: false,
|
||||
};
|
||||
}
|
||||
if (els.cameraBackdrop) {
|
||||
els.cameraBackdrop.classList.remove('is-open');
|
||||
els.cameraBackdrop.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
if (els.cameraStage) {
|
||||
els.cameraStage.innerHTML = '';
|
||||
if (els.cameraPoster) {
|
||||
els.cameraStage.appendChild(els.cameraPoster);
|
||||
}
|
||||
if (els.cameraPlaceholder) {
|
||||
els.cameraStage.appendChild(els.cameraPlaceholder);
|
||||
els.cameraPlaceholder.classList.add('is-visible');
|
||||
}
|
||||
}
|
||||
if (els.cameraPoster) {
|
||||
els.cameraPoster.removeAttribute('src');
|
||||
}
|
||||
if (els.cameraCountdown) {
|
||||
els.cameraCountdown.textContent = '';
|
||||
}
|
||||
clearInterval(state.popupDismissTimer);
|
||||
state.popupDismissTimer = null;
|
||||
destroyStream();
|
||||
@ -3916,19 +4266,35 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const renderSignature = 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'),
|
||||
]);
|
||||
if (renderSignature !== state.debugLastRenderSignature) {
|
||||
state.debugLastRenderSignature = renderSignature;
|
||||
debugLog('render()', snapshotSummary(snapshot));
|
||||
}
|
||||
|
||||
syncLayoutState();
|
||||
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
|
||||
renderSelectedRoom(snapshot);
|
||||
renderDashboard(snapshot);
|
||||
renderSelectedRoom(snapshot);
|
||||
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
|
||||
renderPopup(snapshot);
|
||||
renderEntityPopup(snapshot);
|
||||
renderTemperatureSensorPopup(snapshot);
|
||||
|
||||
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
|
||||
if (els.roomsCount) {
|
||||
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
|
||||
}
|
||||
if (els.editModeToggle) {
|
||||
els.editModeToggle.classList.toggle('is-active', state.editMode);
|
||||
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardOnly() {
|
||||
const snapshot = state.snapshot || bootstrap;
|
||||
@ -3939,6 +4305,14 @@
|
||||
renderPopup(snapshot);
|
||||
renderEntityPopup(snapshot);
|
||||
renderTemperatureSensorPopup(snapshot);
|
||||
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
|
||||
if (els.roomsCount) {
|
||||
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
|
||||
}
|
||||
if (els.editModeToggle) {
|
||||
els.editModeToggle.classList.toggle('is-active', state.editMode);
|
||||
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCurrentRoomLayout(entityId) {
|
||||
@ -3968,6 +4342,9 @@
|
||||
}
|
||||
|
||||
const container = els.dashboardSurface;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const order = new Map(roomEntities(snapshot, room.id).map((entity) => [entity.entity_id, Number(entity.order ?? 9999)]));
|
||||
const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]'));
|
||||
cards.sort((left, right) => {
|
||||
@ -3984,10 +4361,14 @@
|
||||
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return;
|
||||
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
|
||||
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
|
||||
if (els.roomsCount) {
|
||||
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
|
||||
}
|
||||
if (els.editModeToggle) {
|
||||
els.editModeToggle.classList.toggle('is-active', state.editMode);
|
||||
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectionOnly() {
|
||||
const snapshot = state.snapshot || bootstrap;
|
||||
@ -4191,6 +4572,11 @@
|
||||
}
|
||||
|
||||
function wireEvents() {
|
||||
const bind = (el, type, handler, options) => {
|
||||
if (!el) return;
|
||||
el.addEventListener(type, handler, options);
|
||||
};
|
||||
|
||||
els.selectedRoomBack?.addEventListener('click', () => {
|
||||
if (!isMobileViewport()) return;
|
||||
closeEntityPopup();
|
||||
@ -4200,14 +4586,14 @@
|
||||
renderSelectionOnly();
|
||||
});
|
||||
|
||||
els.cameraBackdrop.addEventListener('click', (event) => {
|
||||
bind(els.cameraBackdrop, 'click', (event) => {
|
||||
if (event.target === els.cameraBackdrop) {
|
||||
apiPost('popup', { command: 'close' }).catch(() => {});
|
||||
hidePopup({ suppressAutoOpen: true });
|
||||
}
|
||||
});
|
||||
|
||||
els.cameraModalPanel.addEventListener('click', (event) => {
|
||||
bind(els.cameraModalPanel, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
@ -4224,8 +4610,8 @@
|
||||
hidePopup({ suppressAutoOpen: true });
|
||||
};
|
||||
|
||||
els.cameraClose.addEventListener('pointerdown', closeCameraPopup);
|
||||
els.cameraClose.addEventListener('click', closeCameraPopup);
|
||||
bind(els.cameraClose, 'pointerdown', closeCameraPopup);
|
||||
bind(els.cameraClose, 'click', closeCameraPopup);
|
||||
|
||||
els.entityBackdrop?.addEventListener('click', (event) => {
|
||||
if (event.target === els.entityBackdrop) {
|
||||
@ -4233,7 +4619,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
els.entityModalPanel?.addEventListener('click', (event) => {
|
||||
bind(els.entityModalPanel, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
@ -4247,19 +4633,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
els.temperatureSensorModalPanel?.addEventListener('click', (event) => {
|
||||
bind(els.temperatureSensorModalPanel, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
els.temperatureSensorClose?.addEventListener('click', () => {
|
||||
bind(els.temperatureSensorClose, 'click', () => {
|
||||
closeTemperatureSensorPopup();
|
||||
});
|
||||
|
||||
els.popupDebugButton?.addEventListener('click', () => {
|
||||
bind(els.popupDebugButton, 'click', () => {
|
||||
showDebugPopup();
|
||||
});
|
||||
|
||||
els.editModeToggle.addEventListener('click', async () => {
|
||||
bind(els.editModeToggle, 'click', async () => {
|
||||
state.editMode = !state.editMode;
|
||||
try {
|
||||
await apiPost('save-settings', {
|
||||
@ -4380,6 +4766,9 @@
|
||||
}
|
||||
|
||||
function startSnapshotPolling() {
|
||||
if (isHaRuntime()) {
|
||||
return;
|
||||
}
|
||||
const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000));
|
||||
if (state.snapshotPollTimer) {
|
||||
clearInterval(state.snapshotPollTimer);
|
||||
@ -4396,6 +4785,9 @@
|
||||
}
|
||||
|
||||
function handleHaMessage(message) {
|
||||
if (isHaRuntime()) {
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object') {
|
||||
return;
|
||||
}
|
||||
@ -4519,6 +4911,11 @@
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
if (isHaRuntime()) {
|
||||
setStatus('HA native mode', 'online');
|
||||
stopSnapshotPolling();
|
||||
return;
|
||||
}
|
||||
const connection = haConnection();
|
||||
const baseUrl = connection.base_url || '';
|
||||
const token = connection.token || '';
|
||||
@ -4572,6 +4969,11 @@
|
||||
}
|
||||
|
||||
async function start() {
|
||||
debugLog('start()', {
|
||||
ha_runtime: isHaRuntime(),
|
||||
embed_mode: Boolean(bootstrap?.ui?.embed),
|
||||
mode: bootstrap?.ui?.mode || 'unknown',
|
||||
});
|
||||
initRefs();
|
||||
state.embedMode = detectEmbeddedContext();
|
||||
syncLayoutState();
|
||||
@ -4582,6 +4984,19 @@
|
||||
state.clockTimer = setInterval(updateClock, 1000);
|
||||
wireEvents();
|
||||
|
||||
if (!state.haSnapshotListenerInstalled) {
|
||||
state.haSnapshotListenerInstalled = true;
|
||||
window.addEventListener('wall-panel-snapshot-updated', (event) => {
|
||||
const snapshot = event?.detail?.snapshot || event?.detail || null;
|
||||
if (!snapshot || typeof snapshot !== 'object') {
|
||||
return;
|
||||
}
|
||||
debugLog('wall-panel-snapshot-updated', snapshotSummary(snapshot));
|
||||
state.snapshot = snapshot;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
const viewportQuery = mobileViewportQuery();
|
||||
const handleViewportChange = () => {
|
||||
syncViewportState();
|
||||
@ -4593,8 +5008,8 @@
|
||||
viewportQuery.addListener(handleViewportChange);
|
||||
}
|
||||
|
||||
const initial = window.APP_BOOTSTRAP || {};
|
||||
state.snapshot = initial;
|
||||
state.snapshot = await resolveInitialSnapshot();
|
||||
debugLog('initial snapshot applied', snapshotSummary(state.snapshot || bootstrap));
|
||||
render();
|
||||
connectRealtime();
|
||||
if (!state.snapshotPollTimer) {
|
||||
@ -4602,5 +5017,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user