This commit is contained in:
Striker72rus 2026-03-25 19:34:45 +03:00
parent b5d3feb726
commit 03f21e2de8
13 changed files with 10658 additions and 792 deletions

View File

@ -2115,6 +2115,11 @@ body.is-mobile-ui #camera-modal {
max-width: none; max-width: none;
} }
.main-dashboard__cards .main-dashboard__room-grid {
grid-column: 1 / -1;
width: 100%;
}
.room-entities-section { .room-entities-section {
display: grid; display: grid;
gap: 14px; gap: 14px;

View File

@ -42,20 +42,210 @@
haSubscribeId: 1, haSubscribeId: 1,
roomSelectionToken: 0, roomSelectionToken: 0,
snapshotPollTimer: null, snapshotPollTimer: null,
haSnapshotListenerInstalled: false,
debugLastRenderSignature: '',
}; };
const els = {}; 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) { 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) { function q(sel, root) {
return root.querySelector(sel); const actualRoot = root || client.mountRoot || document;
return actualRoot.querySelector(sel);
} }
function qa(sel, root = document) { function qa(sel, root) {
return Array.from(root.querySelectorAll(sel)); 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 = [ const PRESSABLE_SELECTOR = [
@ -198,6 +388,7 @@
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'icon-node'; wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
Promise.resolve(getIcon(name)).then((definition) => { Promise.resolve(getIcon(name)).then((definition) => {
if (!definition || !wrap.isConnected) return; if (!definition || !wrap.isConnected) return;
wrap.replaceChildren(createSvgIcon(definition)); wrap.replaceChildren(createSvgIcon(definition));
@ -229,6 +420,7 @@
: source.replace(/^fab:/, 'fa-brands:'); : source.replace(/^fab:/, 'fa-brands:');
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'icon-node'; wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
const img = document.createElement('img'); const img = document.createElement('img');
img.className = 'icon-node__img'; img.className = 'icon-node__img';
img.alt = ''; img.alt = '';
@ -236,6 +428,10 @@
img.loading = 'lazy'; img.loading = 'lazy';
img.referrerPolicy = 'no-referrer'; img.referrerPolicy = 'no-referrer';
img.src = `https://api.iconify.design/${mappedSource}.svg`; img.src = `https://api.iconify.design/${mappedSource}.svg`;
img.addEventListener('load', () => {
if (!img.isConnected || !wrap.isConnected) return;
wrap.replaceChildren(img);
});
img.addEventListener('error', () => { img.addEventListener('error', () => {
if (img.dataset.fallbackApplied === '1') return; if (img.dataset.fallbackApplied === '1') return;
img.dataset.fallbackApplied = '1'; img.dataset.fallbackApplied = '1';
@ -252,6 +448,7 @@
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'icon-node'; wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
if (source.includes(':')) { if (source.includes(':')) {
const img = document.createElement('img'); const img = document.createElement('img');
@ -261,6 +458,10 @@
img.loading = 'lazy'; img.loading = 'lazy';
img.referrerPolicy = 'no-referrer'; img.referrerPolicy = 'no-referrer';
img.src = `https://api.iconify.design/${source}.svg`; img.src = `https://api.iconify.design/${source}.svg`;
img.addEventListener('load', () => {
if (!img.isConnected || !wrap.isConnected) return;
wrap.replaceChildren(img);
});
img.addEventListener('error', () => { img.addEventListener('error', () => {
if (img.dataset.fallbackApplied === '1') return; if (img.dataset.fallbackApplied === '1') return;
img.dataset.fallbackApplied = '1'; img.dataset.fallbackApplied = '1';
@ -448,6 +649,9 @@
} }
function buildUrl(action, params = {}) { function buildUrl(action, params = {}) {
if (isHaRuntime()) {
return '';
}
const url = new URL('api.php', window.location.href); const url = new URL('api.php', window.location.href);
url.searchParams.set('action', action); url.searchParams.set('action', action);
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
@ -463,6 +667,12 @@
} }
async function apiGet(action, params = {}) { 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), { const res = await fetch(buildUrl(action, params), {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
cache: 'no-store', cache: 'no-store',
@ -474,6 +684,12 @@
} }
async function apiPost(action, payload = {}) { 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), { const res = await fetch(buildUrl(action), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
@ -487,7 +703,11 @@
} }
async function fetchSnapshot(roomId = state.selectedRoomId || 'main') { 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') { async function loadSnapshot(roomId = state.selectedRoomId || 'main') {
@ -1004,6 +1224,87 @@
return q('.main-dashboard__cards', els.dashboardSurface); 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() { function currentDashboardCardsContainer() {
const snapshot = state.snapshot || bootstrap; const snapshot = state.snapshot || bootstrap;
const room = snapshot.selected_space || snapshot.selected_room || {}; const room = snapshot.selected_space || snapshot.selected_room || {};
@ -1613,10 +1914,16 @@
} }
function haConnection() { function haConnection() {
if (isHaRuntime()) {
return null;
}
return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {}; return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {};
} }
function haWsUrl(baseUrl) { function haWsUrl(baseUrl) {
if (isHaRuntime()) {
return '';
}
if (!baseUrl) return ''; if (!baseUrl) return '';
try { try {
const url = new URL(baseUrl); const url = new URL(baseUrl);
@ -3342,6 +3649,9 @@
} }
function renderRoomButtons(snapshot, rooms, batteryRoom = null) { function renderRoomButtons(snapshot, rooms, batteryRoom = null) {
if (!els.roomList) {
return;
}
els.roomList.innerHTML = ''; els.roomList.innerHTML = '';
const sortedRooms = [...(rooms || [])].sort((left, right) => { const sortedRooms = [...(rooms || [])].sort((left, right) => {
if (left.id === 'main') return -1; if (left.id === 'main') return -1;
@ -3466,6 +3776,11 @@
function renderSelectedRoom(snapshot) { function renderSelectedRoom(snapshot) {
const room = snapshot.selected_space || snapshot.selected_room || {}; const room = snapshot.selected_space || snapshot.selected_room || {};
const setText = (el, value) => {
if (el) {
el.textContent = value;
}
};
if (els.contentTop) { if (els.contentTop) {
els.contentTop.classList.toggle('is-main', room.id === 'main'); els.contentTop.classList.toggle('is-main', room.id === 'main');
} }
@ -3474,8 +3789,8 @@
} }
updateMainPrintStrip(snapshot); updateMainPrintStrip(snapshot);
if (room.id === 'batteries') { if (room.id === 'batteries') {
els.selectedRoomEyebrow.textContent = 'Псевдо-комната'; setText(els.selectedRoomEyebrow, 'Псевдо-комната');
els.selectedRoomTitle.textContent = room.name || 'Батарейки'; setText(els.selectedRoomTitle, room.name || 'Батарейки');
const total = Number(room.entity_count ?? 0) || 0; const total = Number(room.entity_count ?? 0) || 0;
const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0; const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0;
const unavailable = Number(room.unavailable_count ?? 0) || 0; const unavailable = Number(room.unavailable_count ?? 0) || 0;
@ -3490,26 +3805,26 @@
if (unknown > 0) { if (unknown > 0) {
summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`); summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`);
} }
els.selectedRoomMeta.textContent = summaryParts.length setText(els.selectedRoomMeta, summaryParts.length
? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}` ? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`
: `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`; : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`);
renderSelectedRoomActions(snapshot); renderSelectedRoomActions(snapshot);
return; return;
} }
if (room.id !== 'main') { if (room.id !== 'main') {
els.selectedRoomEyebrow.textContent = 'Пространство'; setText(els.selectedRoomEyebrow, 'Пространство');
els.selectedRoomTitle.textContent = room.name || 'Панель'; setText(els.selectedRoomTitle, room.name || 'Панель');
const entities = roomEntities(snapshot, room.id || 'main'); const entities = roomEntities(snapshot, room.id || 'main');
const activeCount = Number(room.active_entity_count ?? entities.length) || 0; const activeCount = Number(room.active_entity_count ?? entities.length) || 0;
els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`; setText(els.selectedRoomMeta, `${activeCount} ${pluralizeActiveEntities(activeCount)}`);
renderSelectedRoomActions(snapshot); renderSelectedRoomActions(snapshot);
return; return;
} }
const entities = roomEntities(snapshot, room.id || 'main'); const entities = roomEntities(snapshot, room.id || 'main');
els.selectedRoomEyebrow.textContent = ''; setText(els.selectedRoomEyebrow, '');
els.selectedRoomTitle.textContent = room.name || 'Панель'; setText(els.selectedRoomTitle, room.name || 'Панель');
els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`; setText(els.selectedRoomMeta, `${entities.length} ${pluralizeIncludedEntities(entities.length)}`);
renderSelectedRoomActions(snapshot); renderSelectedRoomActions(snapshot);
} }
@ -3545,23 +3860,32 @@
function renderDashboard(snapshot) { function renderDashboard(snapshot) {
const room = snapshot.selected_space || snapshot.selected_room || {}; const room = snapshot.selected_space || snapshot.selected_room || {};
const grid = els.dashboardSurface; const grid = els.dashboardSurface;
if (!grid) {
return;
}
grid.innerHTML = ''; grid.innerHTML = '';
if (room.id === 'main') { if (room.id === 'main') {
const layout = document.createElement('div'); const layout = document.createElement('div');
layout.className = 'main-dashboard'; layout.className = 'main-dashboard';
const hero = renderMainHero(snapshot); const mainEntities = roomEntities(snapshot, 'main');
const cards = document.createElement('div'); const cards = document.createElement('div');
cards.className = 'grid-surface main-dashboard__cards'; cards.className = 'grid-surface main-dashboard__cards';
const mainEntities = roomEntities(snapshot, 'main'); if (mainEntities.length) {
mainEntities.forEach((entity) => { mainEntities.forEach((entity) => {
cards.appendChild(renderEntityCard(entity, { isMain: true })); 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); grid.appendChild(layout);
return; return;
} }
@ -3683,8 +4007,10 @@
]); ]);
if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) { if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) {
els.cameraBackdrop.classList.add('is-open'); if (els.cameraBackdrop) {
els.cameraBackdrop.setAttribute('aria-hidden', 'false'); els.cameraBackdrop.classList.add('is-open');
els.cameraBackdrop.setAttribute('aria-hidden', 'false');
}
return; return;
} }
@ -3695,11 +4021,17 @@
return; return;
} }
els.cameraPoster.src = popup.poster_url || ''; if (els.cameraPoster) {
els.cameraPoster.alt = popup.sensor_entity_id || 'camera'; els.cameraPoster.src = popup.poster_url || '';
els.cameraBackdrop.classList.add('is-open'); els.cameraPoster.alt = popup.sensor_entity_id || 'camera';
els.cameraBackdrop.setAttribute('aria-hidden', 'false'); }
els.cameraPlaceholder.classList.add('is-visible'); 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); const expiresAt = Number(popup.expires_at || 0);
if (expiresAt > 0) { if (expiresAt > 0) {
@ -3709,11 +4041,15 @@
const mins = Math.floor(remaining / 60); const mins = Math.floor(remaining / 60);
const secs = remaining % 60; const secs = remaining % 60;
if (remaining > 0) { if (remaining > 0) {
els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`; if (els.cameraCountdown) {
els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`;
}
return; return;
} }
els.cameraCountdown.textContent = 'Закрытие...'; if (els.cameraCountdown) {
els.cameraCountdown.textContent = 'Закрытие...';
}
if (closeRequested) { if (closeRequested) {
return; return;
} }
@ -3736,7 +4072,9 @@
clearInterval(state.popupDismissTimer); clearInterval(state.popupDismissTimer);
state.popupDismissTimer = setInterval(updateCountdown, 1000); state.popupDismissTimer = setInterval(updateCountdown, 1000);
} else { } else {
els.cameraCountdown.textContent = ''; if (els.cameraCountdown) {
els.cameraCountdown.textContent = '';
}
clearInterval(state.popupDismissTimer); clearInterval(state.popupDismissTimer);
state.popupDismissTimer = null; state.popupDismissTimer = null;
} }
@ -3759,14 +4097,26 @@
active: false, active: false,
}; };
} }
els.cameraBackdrop.classList.remove('is-open'); if (els.cameraBackdrop) {
els.cameraBackdrop.setAttribute('aria-hidden', 'true'); els.cameraBackdrop.classList.remove('is-open');
els.cameraStage.innerHTML = ''; els.cameraBackdrop.setAttribute('aria-hidden', 'true');
els.cameraStage.appendChild(els.cameraPoster); }
els.cameraStage.appendChild(els.cameraPlaceholder); if (els.cameraStage) {
els.cameraPlaceholder.classList.add('is-visible'); els.cameraStage.innerHTML = '';
els.cameraPoster.removeAttribute('src'); if (els.cameraPoster) {
els.cameraCountdown.textContent = ''; 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); clearInterval(state.popupDismissTimer);
state.popupDismissTimer = null; state.popupDismissTimer = null;
destroyStream(); destroyStream();
@ -3916,18 +4266,34 @@
return; 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(); syncLayoutState();
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
renderSelectedRoom(snapshot);
renderDashboard(snapshot); renderDashboard(snapshot);
renderSelectedRoom(snapshot);
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
renderPopup(snapshot); renderPopup(snapshot);
renderEntityPopup(snapshot); renderEntityPopup(snapshot);
renderTemperatureSensorPopup(snapshot); renderTemperatureSensorPopup(snapshot);
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; if (els.roomsCount) {
els.editModeToggle.classList.toggle('is-active', state.editMode); els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; }
if (els.editModeToggle) {
els.editModeToggle.classList.toggle('is-active', state.editMode);
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
}
} }
function renderDashboardOnly() { function renderDashboardOnly() {
@ -3939,6 +4305,14 @@
renderPopup(snapshot); renderPopup(snapshot);
renderEntityPopup(snapshot); renderEntityPopup(snapshot);
renderTemperatureSensorPopup(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) { function refreshCurrentRoomLayout(entityId) {
@ -3968,6 +4342,9 @@
} }
const container = els.dashboardSurface; 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 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]')); const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]'));
cards.sort((left, right) => { cards.sort((left, right) => {
@ -3984,9 +4361,13 @@
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return;
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; if (els.roomsCount) {
els.editModeToggle.classList.toggle('is-active', state.editMode); els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; }
if (els.editModeToggle) {
els.editModeToggle.classList.toggle('is-active', state.editMode);
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
}
} }
function renderSelectionOnly() { function renderSelectionOnly() {
@ -4191,6 +4572,11 @@
} }
function wireEvents() { function wireEvents() {
const bind = (el, type, handler, options) => {
if (!el) return;
el.addEventListener(type, handler, options);
};
els.selectedRoomBack?.addEventListener('click', () => { els.selectedRoomBack?.addEventListener('click', () => {
if (!isMobileViewport()) return; if (!isMobileViewport()) return;
closeEntityPopup(); closeEntityPopup();
@ -4200,14 +4586,14 @@
renderSelectionOnly(); renderSelectionOnly();
}); });
els.cameraBackdrop.addEventListener('click', (event) => { bind(els.cameraBackdrop, 'click', (event) => {
if (event.target === els.cameraBackdrop) { if (event.target === els.cameraBackdrop) {
apiPost('popup', { command: 'close' }).catch(() => {}); apiPost('popup', { command: 'close' }).catch(() => {});
hidePopup({ suppressAutoOpen: true }); hidePopup({ suppressAutoOpen: true });
} }
}); });
els.cameraModalPanel.addEventListener('click', (event) => { bind(els.cameraModalPanel, 'click', (event) => {
event.stopPropagation(); event.stopPropagation();
}); });
@ -4224,8 +4610,8 @@
hidePopup({ suppressAutoOpen: true }); hidePopup({ suppressAutoOpen: true });
}; };
els.cameraClose.addEventListener('pointerdown', closeCameraPopup); bind(els.cameraClose, 'pointerdown', closeCameraPopup);
els.cameraClose.addEventListener('click', closeCameraPopup); bind(els.cameraClose, 'click', closeCameraPopup);
els.entityBackdrop?.addEventListener('click', (event) => { els.entityBackdrop?.addEventListener('click', (event) => {
if (event.target === els.entityBackdrop) { if (event.target === els.entityBackdrop) {
@ -4233,7 +4619,7 @@
} }
}); });
els.entityModalPanel?.addEventListener('click', (event) => { bind(els.entityModalPanel, 'click', (event) => {
event.stopPropagation(); event.stopPropagation();
}); });
@ -4247,19 +4633,19 @@
} }
}); });
els.temperatureSensorModalPanel?.addEventListener('click', (event) => { bind(els.temperatureSensorModalPanel, 'click', (event) => {
event.stopPropagation(); event.stopPropagation();
}); });
els.temperatureSensorClose?.addEventListener('click', () => { bind(els.temperatureSensorClose, 'click', () => {
closeTemperatureSensorPopup(); closeTemperatureSensorPopup();
}); });
els.popupDebugButton?.addEventListener('click', () => { bind(els.popupDebugButton, 'click', () => {
showDebugPopup(); showDebugPopup();
}); });
els.editModeToggle.addEventListener('click', async () => { bind(els.editModeToggle, 'click', async () => {
state.editMode = !state.editMode; state.editMode = !state.editMode;
try { try {
await apiPost('save-settings', { await apiPost('save-settings', {
@ -4380,6 +4766,9 @@
} }
function startSnapshotPolling() { function startSnapshotPolling() {
if (isHaRuntime()) {
return;
}
const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000)); const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000));
if (state.snapshotPollTimer) { if (state.snapshotPollTimer) {
clearInterval(state.snapshotPollTimer); clearInterval(state.snapshotPollTimer);
@ -4396,6 +4785,9 @@
} }
function handleHaMessage(message) { function handleHaMessage(message) {
if (isHaRuntime()) {
return;
}
if (!message || typeof message !== 'object') { if (!message || typeof message !== 'object') {
return; return;
} }
@ -4519,6 +4911,11 @@
} }
function connectRealtime() { function connectRealtime() {
if (isHaRuntime()) {
setStatus('HA native mode', 'online');
stopSnapshotPolling();
return;
}
const connection = haConnection(); const connection = haConnection();
const baseUrl = connection.base_url || ''; const baseUrl = connection.base_url || '';
const token = connection.token || ''; const token = connection.token || '';
@ -4572,6 +4969,11 @@
} }
async function start() { async function start() {
debugLog('start()', {
ha_runtime: isHaRuntime(),
embed_mode: Boolean(bootstrap?.ui?.embed),
mode: bootstrap?.ui?.mode || 'unknown',
});
initRefs(); initRefs();
state.embedMode = detectEmbeddedContext(); state.embedMode = detectEmbeddedContext();
syncLayoutState(); syncLayoutState();
@ -4582,6 +4984,19 @@
state.clockTimer = setInterval(updateClock, 1000); state.clockTimer = setInterval(updateClock, 1000);
wireEvents(); 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 viewportQuery = mobileViewportQuery();
const handleViewportChange = () => { const handleViewportChange = () => {
syncViewportState(); syncViewportState();
@ -4593,8 +5008,8 @@
viewportQuery.addListener(handleViewportChange); viewportQuery.addListener(handleViewportChange);
} }
const initial = window.APP_BOOTSTRAP || {}; state.snapshot = await resolveInitialSnapshot();
state.snapshot = initial; debugLog('initial snapshot applied', snapshotSummary(state.snapshot || bootstrap));
render(); render();
connectRealtime(); connectRealtime();
if (!state.snapshotPollTimer) { if (!state.snapshotPollTimer) {
@ -4602,5 +5017,9 @@
} }
} }
document.addEventListener('DOMContentLoaded', start); if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})(); })();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -14,29 +14,25 @@ from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, Tex
from .const import ( from .const import (
CONF_CONFIG, CONF_CONFIG,
CONF_FRONTEND_URL_PATH, CONF_FRONTEND_URL_PATH,
CONF_PANEL_URL,
CONF_REQUIRE_ADMIN, CONF_REQUIRE_ADMIN,
CONF_SIDEBAR_ICON, CONF_SIDEBAR_ICON,
CONF_SIDEBAR_TITLE, CONF_SIDEBAR_TITLE,
CONF_SYNC_TOKEN, CONF_SYNC_TOKEN,
DEFAULT_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH,
DEFAULT_PANEL_URL,
DEFAULT_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON,
DEFAULT_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE,
DOMAIN, 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: def _schema(defaults: dict[str, Any]) -> vol.Schema:
return vol.Schema({ return vol.Schema({
vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, "Striker Panel")): str, 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_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_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_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_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( vol.Optional(
CONF_CONFIG, CONF_CONFIG,
default=defaults.get(CONF_CONFIG, config_to_json(normalize_config({}))), default=defaults.get(CONF_CONFIG, config_to_json(normalize_config({}))),
@ -62,12 +58,11 @@ class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
else: else:
data = { data = {
CONF_NAME: user_input.get(CONF_NAME, "Striker Panel"), 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_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_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_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_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, CONF_CONFIG: config,
} }
await self.async_set_unique_id(DOMAIN) await self.async_set_unique_id(DOMAIN)
@ -76,12 +71,10 @@ class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
defaults = { defaults = {
CONF_NAME: "Striker Panel", CONF_NAME: "Striker Panel",
CONF_PANEL_URL: DEFAULT_PANEL_URL,
CONF_SIDEBAR_TITLE: DEFAULT_SIDEBAR_TITLE, CONF_SIDEBAR_TITLE: DEFAULT_SIDEBAR_TITLE,
CONF_SIDEBAR_ICON: DEFAULT_SIDEBAR_ICON, CONF_SIDEBAR_ICON: DEFAULT_SIDEBAR_ICON,
CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH, CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH,
CONF_REQUIRE_ADMIN: False, CONF_REQUIRE_ADMIN: False,
CONF_SYNC_TOKEN: secrets.token_urlsafe(24),
CONF_CONFIG: config_to_json(normalize_config({})), CONF_CONFIG: config_to_json(normalize_config({})),
} }
return self.async_show_form(step_id="user", data_schema=_schema(defaults), errors=errors) 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 = dict(self.config_entry.options)
data.update({ data.update({
CONF_NAME: user_input.get(CONF_NAME, data.get(CONF_NAME, "Striker Panel")), 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_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_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_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_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, CONF_CONFIG: config,
}) })
return self.async_create_entry(title="", data=data) return self.async_create_entry(title="", data=data)
defaults = { defaults = {
CONF_NAME: self.config_entry.options.get(CONF_NAME, "Striker Panel"), 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_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_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_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_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(current_entry_config(self.config_entry)),
CONF_CONFIG: config_to_json(normalize_config(self.config_entry.options.get(CONF_CONFIG, {}))),
} }
return self.async_show_form(step_id="init", data_schema=_schema(defaults), errors=errors) return self.async_show_form(step_id="init", data_schema=_schema(defaults), errors=errors)

View File

@ -21,12 +21,23 @@ from .const import (
DEFAULT_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE,
DOMAIN, 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: async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
"""Register the custom panel and static frontend assets.""" """Register the custom panel and static frontend assets."""
frontend_dir = Path(__file__).parent / "frontend" 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, {}) state = hass.data.setdefault(DOMAIN, {})
if not state.get("_static_paths_registered"): if not state.get("_static_paths_registered"):
await hass.http.async_register_static_paths([ await hass.http.async_register_static_paths([
@ -35,16 +46,21 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
str(frontend_dir), str(frontend_dir),
cache_headers=False, cache_headers=False,
), ),
StaticPathConfig(
f"/api/{DOMAIN}/assets",
str(assets_dir),
cache_headers=False,
),
]) ])
state["_static_paths_registered"] = True 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_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() 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)) 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() sync_token = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
asset_version = str(int(time.time())) asset_version = str(int(time.time()))
runtime_config = current_entry_config(entry)
async_register_built_in_panel( async_register_built_in_panel(
hass, hass,
@ -56,13 +72,12 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
"_panel_custom": { "_panel_custom": {
"name": "striker-panel-panel", "name": "striker-panel-panel",
"module_url": f"/api/{DOMAIN}/frontend/panel.js?v={asset_version}", "module_url": f"/api/{DOMAIN}/frontend/panel.js?v={asset_version}",
"embed_iframe": False,
"trust_external": False,
"config": { "config": {
"panel_url": panel_url, "runtime_config": runtime_config,
"panel_url_path": panel_url_path, "ui_mode": "ha-native",
"entry_id": entry.entry_id, "entry_id": entry.entry_id,
"sync_token": sync_token, "sync_token": sync_token,
"config_url": f"/api/{DOMAIN}/config/{entry.entry_id}",
}, },
} }
}, },

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,9 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from copy import deepcopy from copy import deepcopy
from pathlib import Path
from typing import Any from typing import Any
from .const import ( 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]: def default_config() -> dict[str, Any]:
return { return {
"app": { "app": {
@ -79,7 +88,79 @@ def parse_config_json(raw: str) -> dict[str, Any]:
def current_entry_config(entry) -> 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]: 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 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: def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
for key, value in source.items(): for key, value in source.items():
if isinstance(value, dict) and isinstance(target.get(key), dict): if isinstance(value, dict) and isinstance(target.get(key), dict):

View File

@ -24,6 +24,7 @@ from .helpers import (
normalize_config, normalize_config,
reorder_room_grid, reorder_room_grid,
save_settings, save_settings,
save_shared_config,
update_entity_override, update_entity_override,
update_room_layout_item, update_room_layout_item,
update_room_override, update_room_override,
@ -66,6 +67,11 @@ def _request_token(request: web.Request) -> str:
return request.query.get("token", "").strip() 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: def _authorized(entry, request: web.Request) -> bool:
expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip() expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
if not expected: if not expected:
@ -74,6 +80,19 @@ def _authorized(entry, request: web.Request) -> bool:
return secrets.compare_digest(_request_token(request), expected) 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: def _response(data: Any, status: int = 200) -> web.Response:
if isinstance(data, str): if isinstance(data, str):
return web.Response(text=data, status=status, content_type="text/plain; charset=utf-8") 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) entry = _entry_from_hass(hass, entry_id)
if entry is None: if entry is None:
return _response({"ok": False, "error": "Unknown entry"}, 404) 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) _LOGGER.warning("Wall Panel proxy denied for %s: unauthorized", entry_id)
return _response({"ok": False, "error": "Unauthorized"}, 401) 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() response_body = await upstream.read()
_LOGGER.warning("Wall Panel upstream %s %s", upstream.status, upstream_url) _LOGGER.warning("Wall Panel upstream %s %s", upstream.status, upstream_url)
return web.Response( response = web.Response(
body=response_body, body=response_body,
status=upstream.status, status=upstream.status,
headers=response_headers, 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: 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: def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> None:
save_shared_config(config)
options = dict(entry.options) options = dict(entry.options)
options[CONF_CONFIG] = config options[CONF_CONFIG] = config
hass.config_entries.async_update_entry(entry, options=options) hass.config_entries.async_update_entry(entry, options=options)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"active": false, "active": false,
"sensor_entity_id": "binary_sensor.barn_all_occupancy", "sensor_entity_id": "binary_sensor.doorbell_all_occupancy",
"opened_at": 1774445418, "opened_at": 1774456141,
"expires_at": null "expires_at": null
} }

View File

@ -2115,6 +2115,11 @@ body.is-mobile-ui #camera-modal {
max-width: none; max-width: none;
} }
.main-dashboard__cards .main-dashboard__room-grid {
grid-column: 1 / -1;
width: 100%;
}
.room-entities-section { .room-entities-section {
display: grid; display: grid;
gap: 14px; gap: 14px;

View File

@ -42,20 +42,210 @@
haSubscribeId: 1, haSubscribeId: 1,
roomSelectionToken: 0, roomSelectionToken: 0,
snapshotPollTimer: null, snapshotPollTimer: null,
haSnapshotListenerInstalled: false,
debugLastRenderSignature: '',
}; };
const els = {}; 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) { 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) { function q(sel, root) {
return root.querySelector(sel); const actualRoot = root || client.mountRoot || document;
return actualRoot.querySelector(sel);
} }
function qa(sel, root = document) { function qa(sel, root) {
return Array.from(root.querySelectorAll(sel)); 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 = [ const PRESSABLE_SELECTOR = [
@ -198,6 +388,7 @@
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'icon-node'; wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
Promise.resolve(getIcon(name)).then((definition) => { Promise.resolve(getIcon(name)).then((definition) => {
if (!definition || !wrap.isConnected) return; if (!definition || !wrap.isConnected) return;
wrap.replaceChildren(createSvgIcon(definition)); wrap.replaceChildren(createSvgIcon(definition));
@ -229,6 +420,7 @@
: source.replace(/^fab:/, 'fa-brands:'); : source.replace(/^fab:/, 'fa-brands:');
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'icon-node'; wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
const img = document.createElement('img'); const img = document.createElement('img');
img.className = 'icon-node__img'; img.className = 'icon-node__img';
img.alt = ''; img.alt = '';
@ -236,6 +428,10 @@
img.loading = 'lazy'; img.loading = 'lazy';
img.referrerPolicy = 'no-referrer'; img.referrerPolicy = 'no-referrer';
img.src = `https://api.iconify.design/${mappedSource}.svg`; img.src = `https://api.iconify.design/${mappedSource}.svg`;
img.addEventListener('load', () => {
if (!img.isConnected || !wrap.isConnected) return;
wrap.replaceChildren(img);
});
img.addEventListener('error', () => { img.addEventListener('error', () => {
if (img.dataset.fallbackApplied === '1') return; if (img.dataset.fallbackApplied === '1') return;
img.dataset.fallbackApplied = '1'; img.dataset.fallbackApplied = '1';
@ -252,6 +448,7 @@
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'icon-node'; wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
if (source.includes(':')) { if (source.includes(':')) {
const img = document.createElement('img'); const img = document.createElement('img');
@ -261,6 +458,10 @@
img.loading = 'lazy'; img.loading = 'lazy';
img.referrerPolicy = 'no-referrer'; img.referrerPolicy = 'no-referrer';
img.src = `https://api.iconify.design/${source}.svg`; img.src = `https://api.iconify.design/${source}.svg`;
img.addEventListener('load', () => {
if (!img.isConnected || !wrap.isConnected) return;
wrap.replaceChildren(img);
});
img.addEventListener('error', () => { img.addEventListener('error', () => {
if (img.dataset.fallbackApplied === '1') return; if (img.dataset.fallbackApplied === '1') return;
img.dataset.fallbackApplied = '1'; img.dataset.fallbackApplied = '1';
@ -448,6 +649,9 @@
} }
function buildUrl(action, params = {}) { function buildUrl(action, params = {}) {
if (isHaRuntime()) {
return '';
}
const url = new URL('api.php', window.location.href); const url = new URL('api.php', window.location.href);
url.searchParams.set('action', action); url.searchParams.set('action', action);
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
@ -463,6 +667,12 @@
} }
async function apiGet(action, params = {}) { 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), { const res = await fetch(buildUrl(action, params), {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
cache: 'no-store', cache: 'no-store',
@ -474,6 +684,12 @@
} }
async function apiPost(action, payload = {}) { 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), { const res = await fetch(buildUrl(action), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
@ -487,7 +703,11 @@
} }
async function fetchSnapshot(roomId = state.selectedRoomId || 'main') { 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') { async function loadSnapshot(roomId = state.selectedRoomId || 'main') {
@ -1004,6 +1224,87 @@
return q('.main-dashboard__cards', els.dashboardSurface); 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() { function currentDashboardCardsContainer() {
const snapshot = state.snapshot || bootstrap; const snapshot = state.snapshot || bootstrap;
const room = snapshot.selected_space || snapshot.selected_room || {}; const room = snapshot.selected_space || snapshot.selected_room || {};
@ -1613,10 +1914,16 @@
} }
function haConnection() { function haConnection() {
if (isHaRuntime()) {
return null;
}
return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {}; return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {};
} }
function haWsUrl(baseUrl) { function haWsUrl(baseUrl) {
if (isHaRuntime()) {
return '';
}
if (!baseUrl) return ''; if (!baseUrl) return '';
try { try {
const url = new URL(baseUrl); const url = new URL(baseUrl);
@ -3342,6 +3649,9 @@
} }
function renderRoomButtons(snapshot, rooms, batteryRoom = null) { function renderRoomButtons(snapshot, rooms, batteryRoom = null) {
if (!els.roomList) {
return;
}
els.roomList.innerHTML = ''; els.roomList.innerHTML = '';
const sortedRooms = [...(rooms || [])].sort((left, right) => { const sortedRooms = [...(rooms || [])].sort((left, right) => {
if (left.id === 'main') return -1; if (left.id === 'main') return -1;
@ -3466,6 +3776,11 @@
function renderSelectedRoom(snapshot) { function renderSelectedRoom(snapshot) {
const room = snapshot.selected_space || snapshot.selected_room || {}; const room = snapshot.selected_space || snapshot.selected_room || {};
const setText = (el, value) => {
if (el) {
el.textContent = value;
}
};
if (els.contentTop) { if (els.contentTop) {
els.contentTop.classList.toggle('is-main', room.id === 'main'); els.contentTop.classList.toggle('is-main', room.id === 'main');
} }
@ -3474,8 +3789,8 @@
} }
updateMainPrintStrip(snapshot); updateMainPrintStrip(snapshot);
if (room.id === 'batteries') { if (room.id === 'batteries') {
els.selectedRoomEyebrow.textContent = 'Псевдо-комната'; setText(els.selectedRoomEyebrow, 'Псевдо-комната');
els.selectedRoomTitle.textContent = room.name || 'Батарейки'; setText(els.selectedRoomTitle, room.name || 'Батарейки');
const total = Number(room.entity_count ?? 0) || 0; const total = Number(room.entity_count ?? 0) || 0;
const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0; const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0;
const unavailable = Number(room.unavailable_count ?? 0) || 0; const unavailable = Number(room.unavailable_count ?? 0) || 0;
@ -3490,26 +3805,26 @@
if (unknown > 0) { if (unknown > 0) {
summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`); summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`);
} }
els.selectedRoomMeta.textContent = summaryParts.length setText(els.selectedRoomMeta, summaryParts.length
? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}` ? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`
: `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`; : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`);
renderSelectedRoomActions(snapshot); renderSelectedRoomActions(snapshot);
return; return;
} }
if (room.id !== 'main') { if (room.id !== 'main') {
els.selectedRoomEyebrow.textContent = 'Пространство'; setText(els.selectedRoomEyebrow, 'Пространство');
els.selectedRoomTitle.textContent = room.name || 'Панель'; setText(els.selectedRoomTitle, room.name || 'Панель');
const entities = roomEntities(snapshot, room.id || 'main'); const entities = roomEntities(snapshot, room.id || 'main');
const activeCount = Number(room.active_entity_count ?? entities.length) || 0; const activeCount = Number(room.active_entity_count ?? entities.length) || 0;
els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`; setText(els.selectedRoomMeta, `${activeCount} ${pluralizeActiveEntities(activeCount)}`);
renderSelectedRoomActions(snapshot); renderSelectedRoomActions(snapshot);
return; return;
} }
const entities = roomEntities(snapshot, room.id || 'main'); const entities = roomEntities(snapshot, room.id || 'main');
els.selectedRoomEyebrow.textContent = ''; setText(els.selectedRoomEyebrow, '');
els.selectedRoomTitle.textContent = room.name || 'Панель'; setText(els.selectedRoomTitle, room.name || 'Панель');
els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`; setText(els.selectedRoomMeta, `${entities.length} ${pluralizeIncludedEntities(entities.length)}`);
renderSelectedRoomActions(snapshot); renderSelectedRoomActions(snapshot);
} }
@ -3545,23 +3860,32 @@
function renderDashboard(snapshot) { function renderDashboard(snapshot) {
const room = snapshot.selected_space || snapshot.selected_room || {}; const room = snapshot.selected_space || snapshot.selected_room || {};
const grid = els.dashboardSurface; const grid = els.dashboardSurface;
if (!grid) {
return;
}
grid.innerHTML = ''; grid.innerHTML = '';
if (room.id === 'main') { if (room.id === 'main') {
const layout = document.createElement('div'); const layout = document.createElement('div');
layout.className = 'main-dashboard'; layout.className = 'main-dashboard';
const hero = renderMainHero(snapshot); const mainEntities = roomEntities(snapshot, 'main');
const cards = document.createElement('div'); const cards = document.createElement('div');
cards.className = 'grid-surface main-dashboard__cards'; cards.className = 'grid-surface main-dashboard__cards';
const mainEntities = roomEntities(snapshot, 'main'); if (mainEntities.length) {
mainEntities.forEach((entity) => { mainEntities.forEach((entity) => {
cards.appendChild(renderEntityCard(entity, { isMain: true })); 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); grid.appendChild(layout);
return; return;
} }
@ -3683,8 +4007,10 @@
]); ]);
if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) { if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) {
els.cameraBackdrop.classList.add('is-open'); if (els.cameraBackdrop) {
els.cameraBackdrop.setAttribute('aria-hidden', 'false'); els.cameraBackdrop.classList.add('is-open');
els.cameraBackdrop.setAttribute('aria-hidden', 'false');
}
return; return;
} }
@ -3695,11 +4021,17 @@
return; return;
} }
els.cameraPoster.src = popup.poster_url || ''; if (els.cameraPoster) {
els.cameraPoster.alt = popup.sensor_entity_id || 'camera'; els.cameraPoster.src = popup.poster_url || '';
els.cameraBackdrop.classList.add('is-open'); els.cameraPoster.alt = popup.sensor_entity_id || 'camera';
els.cameraBackdrop.setAttribute('aria-hidden', 'false'); }
els.cameraPlaceholder.classList.add('is-visible'); 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); const expiresAt = Number(popup.expires_at || 0);
if (expiresAt > 0) { if (expiresAt > 0) {
@ -3709,11 +4041,15 @@
const mins = Math.floor(remaining / 60); const mins = Math.floor(remaining / 60);
const secs = remaining % 60; const secs = remaining % 60;
if (remaining > 0) { if (remaining > 0) {
els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`; if (els.cameraCountdown) {
els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`;
}
return; return;
} }
els.cameraCountdown.textContent = 'Закрытие...'; if (els.cameraCountdown) {
els.cameraCountdown.textContent = 'Закрытие...';
}
if (closeRequested) { if (closeRequested) {
return; return;
} }
@ -3736,7 +4072,9 @@
clearInterval(state.popupDismissTimer); clearInterval(state.popupDismissTimer);
state.popupDismissTimer = setInterval(updateCountdown, 1000); state.popupDismissTimer = setInterval(updateCountdown, 1000);
} else { } else {
els.cameraCountdown.textContent = ''; if (els.cameraCountdown) {
els.cameraCountdown.textContent = '';
}
clearInterval(state.popupDismissTimer); clearInterval(state.popupDismissTimer);
state.popupDismissTimer = null; state.popupDismissTimer = null;
} }
@ -3759,14 +4097,26 @@
active: false, active: false,
}; };
} }
els.cameraBackdrop.classList.remove('is-open'); if (els.cameraBackdrop) {
els.cameraBackdrop.setAttribute('aria-hidden', 'true'); els.cameraBackdrop.classList.remove('is-open');
els.cameraStage.innerHTML = ''; els.cameraBackdrop.setAttribute('aria-hidden', 'true');
els.cameraStage.appendChild(els.cameraPoster); }
els.cameraStage.appendChild(els.cameraPlaceholder); if (els.cameraStage) {
els.cameraPlaceholder.classList.add('is-visible'); els.cameraStage.innerHTML = '';
els.cameraPoster.removeAttribute('src'); if (els.cameraPoster) {
els.cameraCountdown.textContent = ''; 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); clearInterval(state.popupDismissTimer);
state.popupDismissTimer = null; state.popupDismissTimer = null;
destroyStream(); destroyStream();
@ -3916,18 +4266,34 @@
return; 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(); syncLayoutState();
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
renderSelectedRoom(snapshot);
renderDashboard(snapshot); renderDashboard(snapshot);
renderSelectedRoom(snapshot);
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
renderPopup(snapshot); renderPopup(snapshot);
renderEntityPopup(snapshot); renderEntityPopup(snapshot);
renderTemperatureSensorPopup(snapshot); renderTemperatureSensorPopup(snapshot);
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; if (els.roomsCount) {
els.editModeToggle.classList.toggle('is-active', state.editMode); els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; }
if (els.editModeToggle) {
els.editModeToggle.classList.toggle('is-active', state.editMode);
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
}
} }
function renderDashboardOnly() { function renderDashboardOnly() {
@ -3939,6 +4305,14 @@
renderPopup(snapshot); renderPopup(snapshot);
renderEntityPopup(snapshot); renderEntityPopup(snapshot);
renderTemperatureSensorPopup(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) { function refreshCurrentRoomLayout(entityId) {
@ -3968,6 +4342,9 @@
} }
const container = els.dashboardSurface; 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 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]')); const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]'));
cards.sort((left, right) => { cards.sort((left, right) => {
@ -3984,9 +4361,13 @@
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return;
renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; if (els.roomsCount) {
els.editModeToggle.classList.toggle('is-active', state.editMode); els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; }
if (els.editModeToggle) {
els.editModeToggle.classList.toggle('is-active', state.editMode);
els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
}
} }
function renderSelectionOnly() { function renderSelectionOnly() {
@ -4191,6 +4572,11 @@
} }
function wireEvents() { function wireEvents() {
const bind = (el, type, handler, options) => {
if (!el) return;
el.addEventListener(type, handler, options);
};
els.selectedRoomBack?.addEventListener('click', () => { els.selectedRoomBack?.addEventListener('click', () => {
if (!isMobileViewport()) return; if (!isMobileViewport()) return;
closeEntityPopup(); closeEntityPopup();
@ -4200,14 +4586,14 @@
renderSelectionOnly(); renderSelectionOnly();
}); });
els.cameraBackdrop.addEventListener('click', (event) => { bind(els.cameraBackdrop, 'click', (event) => {
if (event.target === els.cameraBackdrop) { if (event.target === els.cameraBackdrop) {
apiPost('popup', { command: 'close' }).catch(() => {}); apiPost('popup', { command: 'close' }).catch(() => {});
hidePopup({ suppressAutoOpen: true }); hidePopup({ suppressAutoOpen: true });
} }
}); });
els.cameraModalPanel.addEventListener('click', (event) => { bind(els.cameraModalPanel, 'click', (event) => {
event.stopPropagation(); event.stopPropagation();
}); });
@ -4224,8 +4610,8 @@
hidePopup({ suppressAutoOpen: true }); hidePopup({ suppressAutoOpen: true });
}; };
els.cameraClose.addEventListener('pointerdown', closeCameraPopup); bind(els.cameraClose, 'pointerdown', closeCameraPopup);
els.cameraClose.addEventListener('click', closeCameraPopup); bind(els.cameraClose, 'click', closeCameraPopup);
els.entityBackdrop?.addEventListener('click', (event) => { els.entityBackdrop?.addEventListener('click', (event) => {
if (event.target === els.entityBackdrop) { if (event.target === els.entityBackdrop) {
@ -4233,7 +4619,7 @@
} }
}); });
els.entityModalPanel?.addEventListener('click', (event) => { bind(els.entityModalPanel, 'click', (event) => {
event.stopPropagation(); event.stopPropagation();
}); });
@ -4247,19 +4633,19 @@
} }
}); });
els.temperatureSensorModalPanel?.addEventListener('click', (event) => { bind(els.temperatureSensorModalPanel, 'click', (event) => {
event.stopPropagation(); event.stopPropagation();
}); });
els.temperatureSensorClose?.addEventListener('click', () => { bind(els.temperatureSensorClose, 'click', () => {
closeTemperatureSensorPopup(); closeTemperatureSensorPopup();
}); });
els.popupDebugButton?.addEventListener('click', () => { bind(els.popupDebugButton, 'click', () => {
showDebugPopup(); showDebugPopup();
}); });
els.editModeToggle.addEventListener('click', async () => { bind(els.editModeToggle, 'click', async () => {
state.editMode = !state.editMode; state.editMode = !state.editMode;
try { try {
await apiPost('save-settings', { await apiPost('save-settings', {
@ -4380,6 +4766,9 @@
} }
function startSnapshotPolling() { function startSnapshotPolling() {
if (isHaRuntime()) {
return;
}
const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000)); const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000));
if (state.snapshotPollTimer) { if (state.snapshotPollTimer) {
clearInterval(state.snapshotPollTimer); clearInterval(state.snapshotPollTimer);
@ -4396,6 +4785,9 @@
} }
function handleHaMessage(message) { function handleHaMessage(message) {
if (isHaRuntime()) {
return;
}
if (!message || typeof message !== 'object') { if (!message || typeof message !== 'object') {
return; return;
} }
@ -4519,6 +4911,11 @@
} }
function connectRealtime() { function connectRealtime() {
if (isHaRuntime()) {
setStatus('HA native mode', 'online');
stopSnapshotPolling();
return;
}
const connection = haConnection(); const connection = haConnection();
const baseUrl = connection.base_url || ''; const baseUrl = connection.base_url || '';
const token = connection.token || ''; const token = connection.token || '';
@ -4572,6 +4969,11 @@
} }
async function start() { async function start() {
debugLog('start()', {
ha_runtime: isHaRuntime(),
embed_mode: Boolean(bootstrap?.ui?.embed),
mode: bootstrap?.ui?.mode || 'unknown',
});
initRefs(); initRefs();
state.embedMode = detectEmbeddedContext(); state.embedMode = detectEmbeddedContext();
syncLayoutState(); syncLayoutState();
@ -4582,6 +4984,19 @@
state.clockTimer = setInterval(updateClock, 1000); state.clockTimer = setInterval(updateClock, 1000);
wireEvents(); 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 viewportQuery = mobileViewportQuery();
const handleViewportChange = () => { const handleViewportChange = () => {
syncViewportState(); syncViewportState();
@ -4593,8 +5008,8 @@
viewportQuery.addListener(handleViewportChange); viewportQuery.addListener(handleViewportChange);
} }
const initial = window.APP_BOOTSTRAP || {}; state.snapshot = await resolveInitialSnapshot();
state.snapshot = initial; debugLog('initial snapshot applied', snapshotSummary(state.snapshot || bootstrap));
render(); render();
connectRealtime(); connectRealtime();
if (!state.snapshotPollTimer) { if (!state.snapshotPollTimer) {
@ -4602,5 +5017,9 @@
} }
} }
document.addEventListener('DOMContentLoaded', start); if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})(); })();