This commit is contained in:
Striker72rus 2026-03-25 23:55:49 +03:00
parent 03f21e2de8
commit 7e2f5b18b3
15 changed files with 2702 additions and 685 deletions

View File

@ -49,6 +49,11 @@ body.is-embedded {
overflow: auto; overflow: auto;
} }
body.is-ha-native #camera-modal,
body.is-ha-native .camera-modal {
display: none !important;
}
button, button,
input, input,
select, select,
@ -261,7 +266,7 @@ textarea {
.room-list__group { .room-list__group {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 10px;
} }
@ -293,7 +298,8 @@ textarea {
height: 42px; height: 42px;
} }
.app-shell--embed .room-item__icon i { .app-shell--embed .room-item__icon i,
.app-shell--embed .room-item__icon ha-icon {
font-size: 32px; font-size: 32px;
} }
@ -375,6 +381,17 @@ textarea {
justify-content: center; justify-content: center;
} }
.room-item__icon ha-icon,
.grid-card__icon ha-icon,
.mushroom-button__icon ha-icon,
.icon-node--ha {
width: 100%;
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-node__img { .icon-node__img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -509,6 +526,10 @@ textarea {
padding: 22px 16px 20px; padding: 22px 16px 20px;
} }
.app-shell.is-ha-native .content-top {
margin-top: 0;
}
.content-top { .content-top {
display: none; display: none;
margin-bottom: 16px; margin-bottom: 16px;

View File

@ -3,7 +3,7 @@
const MOBILE_BREAKPOINT = 920; const MOBILE_BREAKPOINT = 920;
const state = { const state = {
snapshot: bootstrap, snapshot: bootstrap,
embedMode: Boolean(bootstrap?.ui?.embed), embedMode: Boolean(bootstrap?.ui?.embed || window.WALL_PANEL_HA_MODE),
selectedRoomId: 'main', selectedRoomId: 'main',
isMobileViewport: false, isMobileViewport: false,
mobileView: 'spaces', mobileView: 'spaces',
@ -44,10 +44,14 @@
snapshotPollTimer: null, snapshotPollTimer: null,
haSnapshotListenerInstalled: false, haSnapshotListenerInstalled: false,
debugLastRenderSignature: '', debugLastRenderSignature: '',
lastRenderSignature: '',
lastSidebarRenderSignature: '',
lastContentRenderSignature: '',
}; };
const els = {}; const els = {};
const client = window.StrikerPanelClient || (window.StrikerPanelClient = {}); const client = window.StrikerPanelClient || (window.StrikerPanelClient = {});
let renderFrame = null;
const debugEnabled = (() => { const debugEnabled = (() => {
try { try {
if (window.StrikerPanelDebug) return true; if (window.StrikerPanelDebug) return true;
@ -81,14 +85,20 @@
} }
state.snapshot = snapshot; state.snapshot = snapshot;
initRefs(); initRefs();
state.embedMode = detectEmbeddedContext(); state.embedMode = detectEmbeddedContext() || isHaRuntime();
syncLayoutState(); syncLayoutState();
render(); if (renderFrame) {
return;
}
renderFrame = window.requestAnimationFrame(() => {
renderFrame = null;
render();
});
}; };
client.refresh = () => { client.refresh = () => {
initRefs(); initRefs();
state.embedMode = detectEmbeddedContext(); state.embedMode = detectEmbeddedContext() || isHaRuntime();
syncLayoutState(); syncLayoutState();
render(); render();
}; };
@ -118,11 +128,22 @@
function isHaRuntime() { function isHaRuntime() {
return Boolean( return Boolean(
window.WALL_PANEL_HA_MODE ||
haBridge() haBridge()
|| bootstrap?.ui?.mode === 'ha-native' || bootstrap?.ui?.mode === 'ha-native'
); );
} }
async function waitForHaBridge(timeoutMs = 1000) {
const startedAt = Date.now();
let bridge = haBridge();
while (!bridge && (Date.now() - startedAt) < timeoutMs) {
await sleep(50);
bridge = haBridge();
}
return bridge;
}
function sleep(ms) { function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
} }
@ -160,6 +181,254 @@
}; };
} }
function snapshotEntityToken(snapshot, entityId) {
if (!snapshot || !entityId) {
return null;
}
const collections = [
snapshot.main_entities,
snapshot.selected_space?.entities,
snapshot.selected_room?.entities,
];
if (snapshot.entity_index && typeof snapshot.entity_index === 'object') {
collections.push(Object.values(snapshot.entity_index));
}
if (snapshot.space_index && typeof snapshot.space_index === 'object') {
Object.values(snapshot.space_index).forEach((room) => {
if (room?.entities) {
collections.push(room.entities);
}
});
}
if (snapshot.space_entities && typeof snapshot.space_entities === 'object') {
Object.values(snapshot.space_entities).forEach((entities) => collections.push(entities));
}
if (snapshot.battery_room?.entities) {
collections.push(snapshot.battery_room.entities);
}
for (const collection of collections) {
if (!Array.isArray(collection)) continue;
const found = collection.find((entity) => entity && entity.entity_id === entityId);
if (found) {
const attr = found.attributes || {};
return [
String(found.entity_id || ''),
String(found.state ?? ''),
String(found.last_changed || found.last_updated || ''),
String(attr.current_temperature ?? attr.temperature ?? ''),
String(attr.current_position ?? ''),
String(attr.hvac_action ?? ''),
];
}
}
return null;
}
function entityRenderToken(entity) {
const attr = entity?.attributes || {};
return [
String(entity?.entity_id || ''),
String(entity?.state ?? ''),
String(entity?.order ?? 9999),
entity?.visible === false ? '0' : '1',
String(entity?.domain || ''),
String(entity?.card_type || ''),
String(entity?.subtitle || ''),
String(entity?.icon || ''),
String(attr.current_temperature ?? attr.temperature ?? ''),
String(attr.current_position ?? ''),
String(attr.hvac_action ?? ''),
];
}
function roomRenderToken(room) {
return [
String(room?.id || ''),
String(room?.name || ''),
String(room?.icon || ''),
room?.visible === false ? '0' : '1',
String(room?.order ?? 9999),
String(room?.entity_count ?? 0),
String(room?.active_entity_count ?? 0),
String(room?.temperature_badge || ''),
String(room?.battery_summary_text || ''),
room?.virtual ? '1' : '0',
];
}
function selectedRoomRenderToken(snapshot) {
const room = snapshot?.selected_room || snapshot?.selected_space || {};
if (!room?.id) {
return ['unknown'];
}
if (room.id === 'main') {
const boiler = snapshot?.settings?.main_boiler || {};
const printConfig = snapshot?.settings?.main_print || {};
const weatherActions = Array.isArray(snapshot?.settings?.main_weather_actions)
? snapshot.settings.main_weather_actions
: [];
return [
'main',
room.name || '',
(Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : []).map(entityRenderToken),
snapshotEntityToken(snapshot, boiler.sensor_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.current_stage_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.print_progress_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.start_time_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.end_time_entity_id || ''),
weatherActions.map((action) => [
String(action?.entity_id || ''),
String(action?.state_entity_id || ''),
String(action?.command || ''),
String(action?.value ?? ''),
String(action?.active_value ?? ''),
String(action?.label_active || ''),
String(action?.label_inactive || ''),
String(mainWeatherActionIsActive(snapshot, action) ? '1' : '0'),
]),
snapshot?.weather ? [
String(snapshot.weather.entity_id || ''),
String(snapshot.weather.state ?? ''),
String(snapshot.weather.temperature ?? ''),
String(snapshot.weather.sensor_temperature ?? ''),
String(snapshot.weather.wind_speed ?? ''),
String(snapshot.weather.condition ?? ''),
] : null,
];
}
if (room.id === 'batteries') {
return [
'batteries',
room.name || '',
room.battery_summary_text || '',
Number(room.entity_count ?? 0) || 0,
Number(room.problem_count ?? room.active_entity_count ?? 0) || 0,
(Array.isArray(room.entities) ? room.entities : []).map((item) => [
String(item?.entity_id || ''),
String(item?.battery_status || ''),
String(item?.battery_percent_text || ''),
String(item?.forecast_minutes_left ?? ''),
String(item?.forecast_text || ''),
String(item?.source_text || ''),
]),
];
}
return [
room.id,
room.name || '',
room.icon || '',
room.visible === false ? '0' : '1',
String(room.order ?? 9999),
String(room.temperature_badge || ''),
roomGridEntries(snapshot, room.id).map((entry) => (
entry.kind === 'layout'
? ['layout', String(entry.id || ''), String(entry.order ?? 9999), String(entry.payload?.type || 'ghost')]
: ['entity', ...entityRenderToken(entry.payload)]
)),
state.editMode
? roomEntitiesIncludingHidden(snapshot, room.id)
.filter((entity) => entity.visible === false)
.map(entityRenderToken)
: [],
];
}
function buildVisibleSnapshotSignature(snapshot) {
const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : [];
const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || {};
const batteryRoom = snapshot?.battery_room || null;
return JSON.stringify([
String(snapshot?.ui?.mode || 'unknown'),
String(selectedRoom?.id || 'main'),
rooms.map(roomRenderToken),
batteryRoom ? roomRenderToken(batteryRoom) : null,
selectedRoomRenderToken(snapshot),
]);
}
function buildSidebarRenderSignature(snapshot) {
const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : [];
const batteryRoom = snapshot?.battery_room || null;
return JSON.stringify([
String(state.selectedRoomId || 'main'),
String(state.editMode ? '1' : '0'),
String(state.mobileView || 'spaces'),
String(isMobileViewport() ? '1' : '0'),
rooms.map(roomRenderToken),
batteryRoom ? roomRenderToken(batteryRoom) : null,
]);
}
function buildContentRenderSignature(snapshot) {
const room = snapshot?.selected_room || snapshot?.selected_space || {};
return JSON.stringify([
String(state.selectedRoomId || room.id || 'main'),
String(room.id || 'main'),
String(state.editMode ? '1' : '0'),
String(state.mobileView || 'spaces'),
String(isMobileViewport() ? '1' : '0'),
selectedRoomRenderToken(snapshot),
]);
}
function buildRenderSignature(snapshot) {
const history = state.mainBoilerHistory || {};
return JSON.stringify([
buildVisibleSnapshotSignature(snapshot),
String(state.selectedRoomId || 'main'),
String(state.editMode ? '1' : '0'),
String(state.mobileView || 'spaces'),
String(isMobileViewport() ? '1' : '0'),
String(history.entityId || ''),
String(history.loadedAt || 0),
String(Array.isArray(history.points) ? history.points.length : 0),
String(history.loading ? '1' : '0'),
String(history.error || ''),
String(state.entityPopup?.active ? '1' : '0'),
String(state.entityPopup?.entityId || ''),
String(state.temperatureSensorPopup?.active ? '1' : '0'),
String(state.temperatureSensorPopup?.roomId || ''),
String(state.lastPopupSignature || ''),
String(state.lastEntityPopupSignature || ''),
String(state.lastTemperatureSensorPopupSignature || ''),
]);
}
function renderSidebarSection(snapshot) {
const nextSignature = buildSidebarRenderSignature(snapshot);
if (nextSignature === state.lastSidebarRenderSignature) {
return false;
}
state.lastSidebarRenderSignature = nextSignature;
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';
}
return true;
}
function renderContentSection(snapshot) {
const nextSignature = buildContentRenderSignature(snapshot);
if (nextSignature === state.lastContentRenderSignature) {
return false;
}
state.lastContentRenderSignature = nextSignature;
syncLayoutState();
renderDashboard(snapshot);
renderSelectedRoom(snapshot);
return true;
}
function requestSummary(action, params = {}) { function requestSummary(action, params = {}) {
const summary = { action }; const summary = { action };
['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => { ['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => {
@ -309,6 +578,9 @@
} }
function detectEmbeddedContext() { function detectEmbeddedContext() {
if (isHaRuntime()) {
return true;
}
if (Boolean(bootstrap?.ui?.embed)) { if (Boolean(bootstrap?.ui?.embed)) {
return true; return true;
} }
@ -378,20 +650,160 @@
return svg; return svg;
} }
function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { const iconTemplateCache = new Map();
const iconTemplatePromiseCache = new Map();
const customBrandIconsUrl = 'https://home.striker72rus.ru/local/community/custom-brand-icons/custom-brand-icons.js';
let customBrandIconsPromise = null;
function templateFromSvgText(svgText) {
const template = document.createElement('template');
template.innerHTML = String(svgText || '').trim();
return template;
}
function iconUrl(source) {
return `https://api.iconify.design/${source}.svg`;
}
function ensureCustomBrandIconsLoaded() {
if (window.customIcons || window.customIconsets) {
return Promise.resolve(true);
}
if (customBrandIconsPromise) {
return customBrandIconsPromise;
}
const existing = document.querySelector('script[data-wall-panel-custom-brand-icons="1"]');
if (existing) {
customBrandIconsPromise = new Promise((resolve, reject) => {
if (window.customIcons || window.customIconsets) {
resolve(true);
return;
}
existing.addEventListener('load', () => resolve(true), { once: true });
existing.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true });
}).finally(() => {
customBrandIconsPromise = null;
});
return customBrandIconsPromise;
}
const script = document.createElement('script');
script.src = customBrandIconsUrl;
script.defer = true;
script.async = true;
script.dataset.wallPanelCustomBrandIcons = '1';
customBrandIconsPromise = new Promise((resolve, reject) => {
script.addEventListener('load', () => resolve(true), { once: true });
script.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true });
}).finally(() => {
customBrandIconsPromise = null;
});
(document.head || document.documentElement).appendChild(script);
return customBrandIconsPromise;
}
function applyTemplateToNode(target, template) {
if (!target || !template || !target.isConnected) {
return;
}
target.replaceChildren(template.content.cloneNode(true));
}
function primeRemoteIconTemplate(source) {
const key = `remote:${source}`;
if (iconTemplateCache.has(key)) {
return Promise.resolve(iconTemplateCache.get(key));
}
if (iconTemplatePromiseCache.has(key)) {
return iconTemplatePromiseCache.get(key);
}
const promise = fetch(iconUrl(source), {
cache: 'force-cache',
credentials: 'omit',
headers: { Accept: 'image/svg+xml' },
}).then((res) => {
if (!res.ok) {
throw new Error(`Icon load failed: ${res.status}`);
}
return res.text();
}).then((svgText) => {
const template = templateFromSvgText(svgText);
iconTemplateCache.set(key, template);
return template;
}).finally(() => {
iconTemplatePromiseCache.delete(key);
});
iconTemplatePromiseCache.set(key, promise);
return promise;
}
function primeCustomIconTemplate(source) {
const key = `custom:${source}`;
if (iconTemplateCache.has(key)) {
return Promise.resolve(iconTemplateCache.get(key));
}
if (iconTemplatePromiseCache.has(key)) {
return iconTemplatePromiseCache.get(key);
}
const [prefix, name] = source.split(':', 2); const [prefix, name] = source.split(':', 2);
const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix]; const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix];
const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon; const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon;
if (typeof getIcon !== 'function') { if (typeof getIcon !== 'function') {
return null; return Promise.resolve(null);
}
const promise = Promise.resolve(getIcon(name)).then((definition) => {
if (!definition) {
return null;
}
const template = document.createElement('template');
template.content.appendChild(createSvgIcon(definition));
iconTemplateCache.set(key, template);
return template;
}).finally(() => {
iconTemplatePromiseCache.delete(key);
});
iconTemplatePromiseCache.set(key, promise);
return promise;
}
function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') {
const cached = iconTemplateCache.get(`custom:${source}`);
if (cached) {
return cached.content.cloneNode(true);
}
const [prefix] = source.split(':', 2);
const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix];
const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon;
if (typeof getIcon !== 'function') {
if (!isHaRuntime()) {
return null;
}
const wrap = document.createElement('span');
wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
ensureCustomBrandIconsLoaded().then(() => primeCustomIconTemplate(source)).then((template) => {
if (!template) return;
applyTemplateToNode(wrap, template);
}).catch(() => {
if (!wrap.isConnected) return;
wrap.replaceChildren(createIconElement(fallback));
});
return wrap;
} }
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'icon-node'; wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback)); wrap.appendChild(createIconElement(fallback));
Promise.resolve(getIcon(name)).then((definition) => { primeCustomIconTemplate(source).then((template) => {
if (!definition || !wrap.isConnected) return; if (!template) return;
wrap.replaceChildren(createSvgIcon(definition)); applyTemplateToNode(wrap, template);
}).catch(() => { }).catch(() => {
if (!wrap.isConnected) return; if (!wrap.isConnected) return;
wrap.replaceChildren(createIconElement(fallback)); wrap.replaceChildren(createIconElement(fallback));
@ -403,77 +815,71 @@
const source = normalizeIconSource(icon) || fallback; const source = normalizeIconSource(icon) || fallback;
if (source.startsWith('mdi:')) { if (source.startsWith('mdi:')) {
if (isHaRuntime()) {
const haIcon = document.createElement('ha-icon');
haIcon.setAttribute('icon', source);
haIcon.className = 'icon-node icon-node--ha';
return haIcon;
}
const i = document.createElement('i'); const i = document.createElement('i');
i.className = iconClass(source); i.className = iconClass(source);
i.setAttribute('aria-hidden', 'true');
return i; return i;
} }
if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) {
const custom = createCustomIconElement(source, fallback);
if (custom) {
return custom;
}
const mappedSource = source.startsWith('fas:')
? source.replace(/^fas:/, 'fa-solid:')
: source.startsWith('far:')
? source.replace(/^far:/, 'fa-regular:')
: 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 = '';
img.decoding = 'async';
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';
wrap.replaceChildren(createIconElement(fallback));
});
wrap.appendChild(img);
return wrap;
}
const custom = createCustomIconElement(source, fallback); const custom = createCustomIconElement(source, fallback);
if (custom) { if (custom) {
return custom; return custom;
} }
if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) {
const mappedSource = source.startsWith('fas:')
? source.replace(/^fas:/, 'fa-solid:')
: source.startsWith('far:')
? source.replace(/^far:/, 'fa-regular:')
: source.replace(/^fab:/, 'fa-brands:');
const cached = iconTemplateCache.get(`remote:${mappedSource}`);
if (cached) {
return cached.content.cloneNode(true);
}
const wrap = document.createElement('span');
wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
primeRemoteIconTemplate(mappedSource).then((template) => {
applyTemplateToNode(wrap, template);
}).catch(() => {
if (!wrap.isConnected) return;
wrap.replaceChildren(createIconElement(fallback));
});
return wrap;
}
const remoteCached = iconTemplateCache.get(`remote:${source}`);
if (remoteCached) {
return remoteCached.content.cloneNode(true);
}
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'icon-node'; wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback)); wrap.appendChild(createIconElement(fallback));
if (source.includes(':')) { if (source.includes(':')) {
const img = document.createElement('img'); primeRemoteIconTemplate(source).then((template) => {
img.className = 'icon-node__img'; applyTemplateToNode(wrap, template);
img.alt = ''; }).catch(() => {
img.decoding = 'async'; if (!wrap.isConnected) return;
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';
wrap.replaceChildren(createIconElement(fallback)); wrap.replaceChildren(createIconElement(fallback));
}); });
wrap.appendChild(img);
return wrap; return wrap;
} }
return createIconElement(fallback); return createIconElement(fallback);
} }
if (isHaRuntime()) {
void ensureCustomBrandIconsLoaded();
}
function esc(value) { function esc(value) {
return String(value ?? ''); return String(value ?? '');
} }
@ -550,6 +956,9 @@
} }
function popupTriggerEntities() { function popupTriggerEntities() {
if (isHaRuntime()) {
return new Set();
}
const fromSnapshot = state.snapshot?.settings?.camera?.trigger_entities; const fromSnapshot = state.snapshot?.settings?.camera?.trigger_entities;
const fromBootstrap = bootstrap?.settings?.camera?.trigger_entities; const fromBootstrap = bootstrap?.settings?.camera?.trigger_entities;
const triggers = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : []; const triggers = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : [];
@ -669,10 +1078,18 @@
async function apiGet(action, params = {}) { async function apiGet(action, params = {}) {
const bridge = haBridge(); const bridge = haBridge();
if (bridge?.request) { if (bridge?.request) {
console.log('[Striker Panel]', 'apiGet via bridge', requestSummary(action, params)); debugLog('apiGet via bridge', requestSummary(action, params));
return bridge.request('GET', action, params); return bridge.request('GET', action, params);
} }
console.log('[Striker Panel]', 'apiGet via http', requestSummary(action, params)); if (isHaRuntime()) {
const waited = await waitForHaBridge(1000);
if (waited?.request) {
debugLog('apiGet via delayed bridge', requestSummary(action, params));
return waited.request('GET', action, params);
}
throw new Error('HA bridge is not ready');
}
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',
@ -686,10 +1103,18 @@
async function apiPost(action, payload = {}) { async function apiPost(action, payload = {}) {
const bridge = haBridge(); const bridge = haBridge();
if (bridge?.request) { if (bridge?.request) {
console.log('[Striker Panel]', 'apiPost via bridge', requestSummary(action, payload)); debugLog('apiPost via bridge', requestSummary(action, payload));
return bridge.request('POST', action, payload); return bridge.request('POST', action, payload);
} }
console.log('[Striker Panel]', 'apiPost via http', requestSummary(action, payload)); if (isHaRuntime()) {
const waited = await waitForHaBridge(1000);
if (waited?.request) {
debugLog('apiPost via delayed bridge', requestSummary(action, payload));
return waited.request('POST', action, payload);
}
throw new Error('HA bridge is not ready');
}
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' },
@ -1183,12 +1608,16 @@
if (!els.appShell) return; if (!els.appShell) return;
const mobile = isMobileViewport(); const mobile = isMobileViewport();
const embedded = Boolean(state.embedMode); const haNative = isHaRuntime();
const embedded = Boolean(state.embedMode || haNative);
state.embedMode = embedded;
document.body.classList.toggle('is-mobile-ui', mobile); document.body.classList.toggle('is-mobile-ui', mobile);
document.body.classList.toggle('is-embedded', embedded); document.body.classList.toggle('is-embedded', embedded);
document.body.classList.toggle('is-ha-native', haNative);
els.appShell.classList.toggle('is-mobile', mobile); els.appShell.classList.toggle('is-mobile', mobile);
els.appShell.classList.toggle('is-desktop', !mobile); els.appShell.classList.toggle('is-desktop', !mobile);
els.appShell.classList.toggle('app-shell--embed', embedded); els.appShell.classList.toggle('app-shell--embed', embedded);
els.appShell.classList.toggle('is-ha-native', haNative);
els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room'); els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room');
els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room'); els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room');
@ -1865,6 +2294,9 @@
} }
function applyPopupState(active, sensorEntityId) { function applyPopupState(active, sensorEntityId) {
if (isHaRuntime()) {
return;
}
const camera = state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; const camera = state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {};
const popup = state.snapshot?.popup || {}; const popup = state.snapshot?.popup || {};
if (active && Date.now() < Number(state.popupAutoOpenBlockedUntil || 0)) { if (active && Date.now() < Number(state.popupAutoOpenBlockedUntil || 0)) {
@ -1888,6 +2320,9 @@
} }
function applyPopupSnapshot(popup = {}) { function applyPopupSnapshot(popup = {}) {
if (isHaRuntime()) {
return;
}
const snapshot = state.snapshot || bootstrap; const snapshot = state.snapshot || bootstrap;
snapshot.popup = mergePopupWithCamera({ snapshot.popup = mergePopupWithCamera({
...(snapshot.popup || {}), ...(snapshot.popup || {}),
@ -1897,6 +2332,9 @@
} }
function syncTriggerPopup(entityId, stateValue) { function syncTriggerPopup(entityId, stateValue) {
if (isHaRuntime()) {
return;
}
const value = String(stateValue || '').toLowerCase(); const value = String(stateValue || '').toLowerCase();
if (!['on', 'off'].includes(value)) { if (!['on', 'off'].includes(value)) {
return; return;
@ -2870,13 +3308,13 @@
const minus = document.createElement('button'); const minus = document.createElement('button');
minus.type = 'button'; minus.type = 'button';
minus.className = 'round-button entity-modal__round-button'; minus.className = 'round-button entity-modal__round-button';
minus.innerHTML = '<i class="mdi mdi-minus"></i>'; minus.appendChild(createIconElement('mdi:minus'));
minus.addEventListener('click', () => handleClimateTemperature(entity, -1)); minus.addEventListener('click', () => handleClimateTemperature(entity, -1));
const plus = document.createElement('button'); const plus = document.createElement('button');
plus.type = 'button'; plus.type = 'button';
plus.className = 'round-button entity-modal__round-button'; plus.className = 'round-button entity-modal__round-button';
plus.innerHTML = '<i class="mdi mdi-plus"></i>'; plus.appendChild(createIconElement('mdi:plus'));
plus.addEventListener('click', () => handleClimateTemperature(entity, 1)); plus.addEventListener('click', () => handleClimateTemperature(entity, 1));
controls.append(minus, plus); controls.append(minus, plus);
@ -3349,13 +3787,15 @@
const upBtn = document.createElement('button'); const upBtn = document.createElement('button');
upBtn.type = 'button'; upBtn.type = 'button';
upBtn.className = 'mushroom-button mushroom-button--small'; upBtn.className = 'mushroom-button mushroom-button--small';
upBtn.innerHTML = '<i class="mdi mdi-arrow-up"></i> Вверх'; upBtn.appendChild(createIconElement('mdi:arrow-up'));
upBtn.appendChild(document.createTextNode(' Вверх'));
upBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, -1)); upBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, -1));
const downBtn = document.createElement('button'); const downBtn = document.createElement('button');
downBtn.type = 'button'; downBtn.type = 'button';
downBtn.className = 'mushroom-button mushroom-button--small'; downBtn.className = 'mushroom-button mushroom-button--small';
downBtn.innerHTML = '<i class="mdi mdi-arrow-down"></i> Вниз'; downBtn.appendChild(createIconElement('mdi:arrow-down'));
downBtn.appendChild(document.createTextNode(' Вниз'));
downBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1)); downBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1));
actions.append(upBtn, downBtn); actions.append(upBtn, downBtn);
@ -3371,7 +3811,7 @@
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.className = 'mini-action mini-action--wide'; btn.className = 'mini-action mini-action--wide';
btn.innerHTML = hidden ? '<i class="mdi mdi-eye"></i>' : '<i class="mdi mdi-eye-off"></i>'; btn.appendChild(createIconElement(hidden ? 'mdi:eye' : 'mdi:eye-off'));
btn.title = hidden ? 'Показать' : 'Скрыть'; btn.title = hidden ? 'Показать' : 'Скрыть';
btn.addEventListener('click', (event) => { btn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
@ -3484,7 +3924,8 @@
const settingsBtn = document.createElement('button'); const settingsBtn = document.createElement('button');
settingsBtn.type = 'button'; settingsBtn.type = 'button';
settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide';
settingsBtn.innerHTML = '<i class="mdi mdi-cog-outline"></i> Настройки'; settingsBtn.appendChild(createIconElement('mdi:cog-outline'));
settingsBtn.appendChild(document.createTextNode(' Настройки'));
settingsBtn.addEventListener('click', (event) => { settingsBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
state.layoutItemSettingsOpen = { state.layoutItemSettingsOpen = {
@ -3497,7 +3938,8 @@
const upBtn = document.createElement('button'); const upBtn = document.createElement('button');
upBtn.type = 'button'; upBtn.type = 'button';
upBtn.className = 'mushroom-button mushroom-button--small'; upBtn.className = 'mushroom-button mushroom-button--small';
upBtn.innerHTML = '<i class="mdi mdi-arrow-up"></i> Вверх'; upBtn.appendChild(createIconElement('mdi:arrow-up'));
upBtn.appendChild(document.createTextNode(' Вверх'));
upBtn.addEventListener('click', (event) => { upBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
reorderRoomGridEntry(room.id, 'layout', item.id, -1); reorderRoomGridEntry(room.id, 'layout', item.id, -1);
@ -3506,7 +3948,8 @@
const downBtn = document.createElement('button'); const downBtn = document.createElement('button');
downBtn.type = 'button'; downBtn.type = 'button';
downBtn.className = 'mushroom-button mushroom-button--small'; downBtn.className = 'mushroom-button mushroom-button--small';
downBtn.innerHTML = '<i class="mdi mdi-arrow-down"></i> Вниз'; downBtn.appendChild(createIconElement('mdi:arrow-down'));
downBtn.appendChild(document.createTextNode(' Вниз'));
downBtn.addEventListener('click', (event) => { downBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
reorderRoomGridEntry(room.id, 'layout', item.id, 1); reorderRoomGridEntry(room.id, 'layout', item.id, 1);
@ -3515,7 +3958,8 @@
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.type = 'button'; deleteBtn.type = 'button';
deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide';
deleteBtn.innerHTML = '<i class="mdi mdi-delete-outline"></i> Удалить'; deleteBtn.appendChild(createIconElement('mdi:delete-outline'));
deleteBtn.appendChild(document.createTextNode(' Удалить'));
deleteBtn.addEventListener('click', (event) => { deleteBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
deleteRoomLayoutItem(room.id, item.id); deleteRoomLayoutItem(room.id, item.id);
@ -3531,7 +3975,8 @@
const tempBtn = document.createElement('button'); const tempBtn = document.createElement('button');
tempBtn.type = 'button'; tempBtn.type = 'button';
tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide';
tempBtn.innerHTML = '<i class="mdi mdi-thermometer"></i> Выбрать датчик температуры'; tempBtn.appendChild(createIconElement('mdi:thermometer'));
tempBtn.appendChild(document.createTextNode(' Выбрать датчик температуры'));
tempBtn.addEventListener('click', (event) => { tempBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
openTemperatureSensorPopup(room.id); openTemperatureSensorPopup(room.id);
@ -3654,6 +4099,12 @@
} }
els.roomList.innerHTML = ''; els.roomList.innerHTML = '';
const sortedRooms = [...(rooms || [])].sort((left, right) => { const sortedRooms = [...(rooms || [])].sort((left, right) => {
const leftVisible = left?.visible === false ? 1 : 0;
const rightVisible = right?.visible === false ? 1 : 0;
if (leftVisible !== rightVisible) {
return leftVisible - rightVisible;
}
if (left.id === 'main') return -1; if (left.id === 'main') return -1;
if (right.id === 'main') return 1; if (right.id === 'main') return 1;
@ -3841,7 +4292,8 @@
const addButton = document.createElement('button'); const addButton = document.createElement('button');
addButton.type = 'button'; addButton.type = 'button';
addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button';
addButton.innerHTML = '<i class="mdi mdi-plus"></i><span>Пустая карточка</span>'; addButton.appendChild(createIconElement('mdi:plus'));
addButton.appendChild(document.createElement('span')).textContent = 'Пустая карточка';
addButton.addEventListener('click', () => { addButton.addEventListener('click', () => {
createRoomLayoutItem(room.id); createRoomLayoutItem(room.id);
}); });
@ -3849,7 +4301,8 @@
const temperatureButton = document.createElement('button'); const temperatureButton = document.createElement('button');
temperatureButton.type = 'button'; temperatureButton.type = 'button';
temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button';
temperatureButton.innerHTML = '<i class="mdi mdi-thermometer"></i><span>Выбрать датчик температуры</span>'; temperatureButton.appendChild(createIconElement('mdi:thermometer'));
temperatureButton.appendChild(document.createElement('span')).textContent = 'Выбрать датчик температуры';
temperatureButton.addEventListener('click', () => { temperatureButton.addEventListener('click', () => {
openTemperatureSensorPopup(room.id); openTemperatureSensorPopup(room.id);
}); });
@ -3991,6 +4444,10 @@
} }
function renderPopup(snapshot) { function renderPopup(snapshot) {
if (isHaRuntime()) {
hidePopup({ preserveSnapshot: true });
return;
}
if (isMobileViewport()) { if (isMobileViewport()) {
hidePopup({ preserveSnapshot: true }); hidePopup({ preserveSnapshot: true });
return; return;
@ -4123,6 +4580,9 @@
} }
async function showDebugPopup() { async function showDebugPopup() {
if (isHaRuntime()) {
return;
}
try { try {
const response = await apiPost('popup', { command: 'open' }); const response = await apiPost('popup', { command: 'open' });
const snapshot = state.snapshot || bootstrap; const snapshot = state.snapshot || bootstrap;
@ -4266,53 +4726,25 @@
return; return;
} }
const renderSignature = JSON.stringify([ const renderSignature = buildRenderSignature(snapshot);
snapshot?.selected_room?.id || snapshot?.selected_space?.id || 'main', if (renderSignature === state.lastRenderSignature) {
Array.isArray(snapshot?.rooms) ? snapshot.rooms.length : Array.isArray(snapshot?.spaces) ? snapshot.spaces.length : 0, return;
Array.isArray(snapshot?.main_entities) ? snapshot.main_entities.length : 0, }
Boolean(snapshot?.popup?.active), state.lastRenderSignature = renderSignature;
Boolean(snapshot?.ui?.mode === 'ha-native'),
]);
if (renderSignature !== state.debugLastRenderSignature) { if (renderSignature !== state.debugLastRenderSignature) {
state.debugLastRenderSignature = renderSignature; state.debugLastRenderSignature = renderSignature;
debugLog('render()', snapshotSummary(snapshot)); debugLog('render()', snapshotSummary(snapshot));
} }
syncLayoutState(); renderSidebarSection(snapshot);
renderDashboard(snapshot); renderContentSection(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);
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() { function renderDashboardOnly() {
const snapshot = state.snapshot || bootstrap; renderContentSection(state.snapshot || bootstrap);
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return;
syncLayoutState();
renderSelectedRoom(snapshot);
renderDashboard(snapshot);
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) { function refreshCurrentRoomLayout(entityId) {
@ -4357,24 +4789,11 @@
} }
function renderSidebarOnly() { function renderSidebarOnly() {
const snapshot = state.snapshot || bootstrap; renderSidebarSection(state.snapshot || bootstrap);
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() { function renderSelectionOnly() {
const snapshot = state.snapshot || bootstrap; renderContentSection(state.snapshot || bootstrap);
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return;
syncLayoutState();
renderSelectedRoom(snapshot);
} }
async function handleEntityAction(entity, command) { async function handleEntityAction(entity, command) {
@ -4586,32 +5005,34 @@
renderSelectionOnly(); renderSelectionOnly();
}); });
bind(els.cameraBackdrop, 'click', (event) => { if (!isHaRuntime()) {
if (event.target === els.cameraBackdrop) { bind(els.cameraBackdrop, 'click', (event) => {
apiPost('popup', { command: 'close' }).catch(() => {}); if (event.target === els.cameraBackdrop) {
hidePopup({ suppressAutoOpen: true }); apiPost('popup', { command: 'close' }).catch(() => {});
} hidePopup({ suppressAutoOpen: true });
}); }
});
bind(els.cameraModalPanel, 'click', (event) => { bind(els.cameraModalPanel, 'click', (event) => {
event.stopPropagation();
});
const closeCameraPopup = async (event) => {
if (event) {
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} });
try {
await apiPost('popup', { command: 'close' });
} catch (error) {
console.warn(error);
}
hidePopup({ suppressAutoOpen: true });
};
bind(els.cameraClose, 'pointerdown', closeCameraPopup); const closeCameraPopup = async (event) => {
bind(els.cameraClose, 'click', closeCameraPopup); if (event) {
event.preventDefault();
event.stopPropagation();
}
try {
await apiPost('popup', { command: 'close' });
} catch (error) {
console.warn(error);
}
hidePopup({ suppressAutoOpen: true });
};
bind(els.cameraClose, 'pointerdown', 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) {
@ -4975,7 +5396,7 @@
mode: bootstrap?.ui?.mode || 'unknown', mode: bootstrap?.ui?.mode || 'unknown',
}); });
initRefs(); initRefs();
state.embedMode = detectEmbeddedContext(); state.embedMode = detectEmbeddedContext() || isHaRuntime();
syncLayoutState(); syncLayoutState();
syncViewportState(); syncViewportState();
bindPressFeedback(); bindPressFeedback();

View File

@ -6,6 +6,8 @@ import logging
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import DEFAULT_DASHBOARD_URL_PATH
from .const import DEFAULT_FRONTEND_URL_PATH
from .const import DOMAIN from .const import DOMAIN
from .frontend import async_setup_frontend from .frontend import async_setup_frontend
from .helpers import current_entry_config from .helpers import current_entry_config
@ -53,8 +55,16 @@ async def async_unload_entry(hass: HomeAssistant, entry) -> bool:
from homeassistant.components.frontend import async_remove_panel from homeassistant.components.frontend import async_remove_panel
state = hass.data.get(DOMAIN, {}) state = hass.data.get(DOMAIN, {})
panel_url_path = str(state.get(entry.entry_id, {}).get("panel_url_path") or entry.options.get("frontend_url_path", "wall-panel") or "wall-panel").strip() panel_url_path = str(state.get(entry.entry_id, {}).get("panel_url_path") or DEFAULT_FRONTEND_URL_PATH).strip()
dashboard_url_path = str(state.get(entry.entry_id, {}).get("dashboard_url_path") or DEFAULT_DASHBOARD_URL_PATH).strip()
async_remove_panel(hass, panel_url_path) async_remove_panel(hass, panel_url_path)
async_remove_panel(hass, dashboard_url_path)
lovelace = hass.data.get("lovelace")
dashboards = getattr(lovelace, "dashboards", None)
if dashboards is None and isinstance(lovelace, dict):
dashboards = lovelace.get("dashboards")
if dashboards is not None:
dashboards.pop(dashboard_url_path, None)
state.pop(entry.entry_id, None) state.pop(entry.entry_id, None)
return True return True

View File

@ -49,6 +49,11 @@ body.is-embedded {
overflow: auto; overflow: auto;
} }
body.is-ha-native #camera-modal,
body.is-ha-native .camera-modal {
display: none !important;
}
button, button,
input, input,
select, select,
@ -261,7 +266,7 @@ textarea {
.room-list__group { .room-list__group {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 10px;
} }
@ -293,7 +298,8 @@ textarea {
height: 42px; height: 42px;
} }
.app-shell--embed .room-item__icon i { .app-shell--embed .room-item__icon i,
.app-shell--embed .room-item__icon ha-icon {
font-size: 32px; font-size: 32px;
} }
@ -375,6 +381,17 @@ textarea {
justify-content: center; justify-content: center;
} }
.room-item__icon ha-icon,
.grid-card__icon ha-icon,
.mushroom-button__icon ha-icon,
.icon-node--ha {
width: 100%;
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-node__img { .icon-node__img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -509,6 +526,10 @@ textarea {
padding: 22px 16px 20px; padding: 22px 16px 20px;
} }
.app-shell.is-ha-native .content-top {
margin-top: 0;
}
.content-top { .content-top {
display: none; display: none;
margin-bottom: 16px; margin-bottom: 16px;

File diff suppressed because it is too large Load Diff

View File

@ -13,12 +13,10 @@ from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, Tex
from .const import ( from .const import (
CONF_CONFIG, CONF_CONFIG,
CONF_FRONTEND_URL_PATH,
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_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON,
DEFAULT_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE,
DOMAIN, DOMAIN,
@ -31,7 +29,6 @@ def _schema(defaults: dict[str, Any]) -> 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_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_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( vol.Optional(
CONF_CONFIG, CONF_CONFIG,
@ -60,7 +57,6 @@ class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_NAME: user_input.get(CONF_NAME, "Striker Panel"), CONF_NAME: user_input.get(CONF_NAME, "Striker Panel"),
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_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)), CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)),
CONF_SYNC_TOKEN: secrets.token_urlsafe(24), CONF_SYNC_TOKEN: secrets.token_urlsafe(24),
CONF_CONFIG: config, CONF_CONFIG: config,
@ -73,7 +69,6 @@ class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_NAME: "Striker Panel", CONF_NAME: "Striker Panel",
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_REQUIRE_ADMIN: False, CONF_REQUIRE_ADMIN: False,
CONF_CONFIG: config_to_json(normalize_config({})), CONF_CONFIG: config_to_json(normalize_config({})),
} }
@ -102,7 +97,6 @@ class WallPanelOptionsFlow(config_entries.OptionsFlow):
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_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_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)), CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)),
CONF_SYNC_TOKEN: data.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)), CONF_SYNC_TOKEN: data.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)),
CONF_CONFIG: config, CONF_CONFIG: config,
@ -113,7 +107,6 @@ class WallPanelOptionsFlow(config_entries.OptionsFlow):
CONF_NAME: self.config_entry.options.get(CONF_NAME, "Striker Panel"), CONF_NAME: self.config_entry.options.get(CONF_NAME, "Striker Panel"),
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_REQUIRE_ADMIN: self.config_entry.options.get(CONF_REQUIRE_ADMIN, False), CONF_REQUIRE_ADMIN: self.config_entry.options.get(CONF_REQUIRE_ADMIN, False),
CONF_CONFIG: config_to_json(current_entry_config(self.config_entry)), CONF_CONFIG: config_to_json(current_entry_config(self.config_entry)),
} }

View File

@ -15,3 +15,4 @@ DEFAULT_PANEL_URL = ""
DEFAULT_SIDEBAR_TITLE = "Striker Panel" DEFAULT_SIDEBAR_TITLE = "Striker Panel"
DEFAULT_SIDEBAR_ICON = "mdi:view-dashboard" DEFAULT_SIDEBAR_ICON = "mdi:view-dashboard"
DEFAULT_FRONTEND_URL_PATH = "striker-panel" DEFAULT_FRONTEND_URL_PATH = "striker-panel"
DEFAULT_DASHBOARD_URL_PATH = "wall-panel"

View File

@ -5,13 +5,14 @@ from __future__ import annotations
import time import time
from pathlib import Path from pathlib import Path
from homeassistant.components.lovelace import _register_panel
from homeassistant.components.lovelace.dashboard import LovelaceYAML
from homeassistant.components.frontend import async_register_built_in_panel from homeassistant.components.frontend import async_register_built_in_panel
from homeassistant.components.http import StaticPathConfig from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import ( from .const import (
CONF_FRONTEND_URL_PATH, DEFAULT_DASHBOARD_URL_PATH,
CONF_PANEL_URL,
CONF_REQUIRE_ADMIN, CONF_REQUIRE_ADMIN,
CONF_SIDEBAR_ICON, CONF_SIDEBAR_ICON,
CONF_SIDEBAR_TITLE, CONF_SIDEBAR_TITLE,
@ -25,10 +26,38 @@ from .helpers import current_entry_config
def _panel_url_path(entry) -> str: 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() # Keep the HA panel identity stable so Home Assistant can always
if raw in {"", "wall-panel"}: # discover this panel in the default-panel picker.
return DEFAULT_FRONTEND_URL_PATH return DEFAULT_FRONTEND_URL_PATH
return raw
def _dashboard_url_path(entry) -> str:
# Keep the Lovelace dashboard identity stable and separate from the
# built-in HA panel path.
return DEFAULT_DASHBOARD_URL_PATH
def _dashboard_config(sidebar_title: str, sidebar_icon: str, require_admin: bool) -> dict[str, object]:
return {
"mode": "yaml",
"title": sidebar_title,
"icon": sidebar_icon,
"show_in_sidebar": False,
"require_admin": require_admin,
"filename": "custom_components/wall_panel/lovelace/ui-lovelace.yaml",
}
def _register_lovelace_dashboard(hass: HomeAssistant, dashboard_url_path: str, dashboard_config: dict[str, object]) -> None:
lovelace = hass.data.setdefault("lovelace", {})
dashboards = getattr(lovelace, "dashboards", None)
if dashboards is None and isinstance(lovelace, dict):
dashboards = lovelace.setdefault("dashboards", {})
if dashboards is None:
return
dashboards[dashboard_url_path] = LovelaceYAML(hass, dashboard_url_path, dashboard_config)
_register_panel(hass, dashboard_url_path, "yaml", dashboard_config, False)
async def async_setup_frontend(hass: HomeAssistant, entry) -> str: async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
@ -55,12 +84,16 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
state["_static_paths_registered"] = True state["_static_paths_registered"] = True
panel_url_path = _panel_url_path(entry) panel_url_path = _panel_url_path(entry)
dashboard_url_path = _dashboard_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))
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) runtime_config = current_entry_config(entry)
dashboard_config = _dashboard_config(sidebar_title, sidebar_icon, require_admin)
_register_lovelace_dashboard(hass, dashboard_url_path, dashboard_config)
async_register_built_in_panel( async_register_built_in_panel(
hass, hass,
@ -85,4 +118,11 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
update=True, update=True,
) )
state = hass.data.setdefault(DOMAIN, {})
state[entry.entry_id] = {
**state.get(entry.entry_id, {}),
"dashboard_url_path": dashboard_url_path,
"panel_url_path": panel_url_path,
}
return panel_url_path return panel_url_path

File diff suppressed because it is too large Load Diff

View File

@ -169,7 +169,8 @@ def current_entry_panel(entry) -> dict[str, Any]:
CONF_SYNC_TOKEN: str(entry.options.get(CONF_SYNC_TOKEN, "") or ""), CONF_SYNC_TOKEN: str(entry.options.get(CONF_SYNC_TOKEN, "") or ""),
CONF_SIDEBAR_TITLE: str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE), CONF_SIDEBAR_TITLE: str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
CONF_SIDEBAR_ICON: str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON), CONF_SIDEBAR_ICON: str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
CONF_FRONTEND_URL_PATH: str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH), # The HA panel path must remain canonical and independent from the PHP proxy URL.
CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH,
CONF_REQUIRE_ADMIN: bool(entry.options.get(CONF_REQUIRE_ADMIN, False)), CONF_REQUIRE_ADMIN: bool(entry.options.get(CONF_REQUIRE_ADMIN, False)),
} }

View File

@ -0,0 +1,9 @@
title: Home Panel
views:
- title: Home Panel
path: home-panel
panel: true
cards:
- type: iframe
url: /striker-panel
aspect_ratio: 100%

View File

@ -1,11 +1,11 @@
{ {
"items": { "items": {
"sensor.garage_motion_battery": { "sensor.garage_motion_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 100 "value": 100
}, },
{ {
@ -20,11 +20,11 @@
"percent": 100 "percent": 100
}, },
"sensor.garage_light_battery": { "sensor.garage_light_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 100 "value": 100
}, },
{ {
@ -39,11 +39,11 @@
"percent": 100 "percent": 100
}, },
"sensor.garage_door_motion_battery": { "sensor.garage_door_motion_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 100 "value": 100
}, },
{ {
@ -58,11 +58,11 @@
"percent": 100 "percent": 100
}, },
"sensor.stair_up_motion_battery": { "sensor.stair_up_motion_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 100 "value": 100
}, },
{ {
@ -77,11 +77,11 @@
"percent": 100 "percent": 100
}, },
"sensor.stair_down_motion_battery": { "sensor.stair_down_motion_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 100 "value": 100
}, },
{ {
@ -96,11 +96,11 @@
"percent": 100 "percent": 100
}, },
"sensor.stair_light_battery": { "sensor.stair_light_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 100 "value": 100
}, },
{ {
@ -115,11 +115,11 @@
"percent": 100 "percent": 100
}, },
"sensor.door_sensor_2_battery": { "sensor.door_sensor_2_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 90 "value": 90
}, },
{ {
@ -134,11 +134,11 @@
"percent": 90 "percent": 90
}, },
"sensor.wleak_battery": { "sensor.wleak_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 100 "value": 100
}, },
{ {
@ -153,11 +153,11 @@
"percent": 100 "percent": 100
}, },
"sensor.0xa4c138433d675809_battery": { "sensor.0xa4c138433d675809_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 1 "value": 1
}, },
{ {
@ -172,11 +172,11 @@
"percent": 1 "percent": 1
}, },
"sensor.0xa4c138997cb4fdd1_battery": { "sensor.0xa4c138997cb4fdd1_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 100 "value": 100
}, },
{ {
@ -191,11 +191,11 @@
"percent": 100 "percent": 100
}, },
"sensor.printer_knopka_battery": { "sensor.printer_knopka_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 74 "value": 74
}, },
{ {
@ -210,11 +210,11 @@
"percent": 74 "percent": 74
}, },
"sensor.lestnitsa_dvizhenie_2_etazh_battery": { "sensor.lestnitsa_dvizhenie_2_etazh_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 100 "value": 100
}, },
{ {
@ -229,11 +229,11 @@
"percent": 100 "percent": 100
}, },
"sensor.spalnia_knopka_girliand_battery": { "sensor.spalnia_knopka_girliand_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 29 "value": 29
}, },
{ {
@ -248,11 +248,11 @@
"percent": 29 "percent": 29
}, },
"sensor.ulitsa_temperatura_battery": { "sensor.ulitsa_temperatura_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 63 "value": 63
}, },
{ {
@ -267,11 +267,11 @@
"percent": 62 "percent": 62
}, },
"sensor.0x44e2f8fffeb65d8e_battery": { "sensor.0x44e2f8fffeb65d8e_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 50 "value": 50
}, },
{ {
@ -289,40 +289,24 @@
{ {
"timestamp": 1773925068, "timestamp": 1773925068,
"value": 55 "value": 55
},
{
"timestamp": 1773944510,
"value": 50
} }
], ],
"forecast_minutes_left": null, "forecast_minutes_left": null,
"forecast_text": null, "forecast_text": null,
"forecast_slope_per_hour": 0.2435, "forecast_slope_per_hour": 0.054,
"forecast_reason": "Заряд не падает", "forecast_reason": "Заряд не падает",
"percent": 40 "percent": 40
}, },
"sensor.0x54ef4410009a6a11_battery": { "sensor.0x54ef4410009a6a11_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 92
},
{
"timestamp": 1773852462,
"value": 93
},
{
"timestamp": 1773855575,
"value": 91
},
{
"timestamp": 1773858820,
"value": 92
},
{
"timestamp": 1773862156,
"value": 91
},
{
"timestamp": 1773865222,
"value": 92 "value": 92
}, },
{ {
@ -388,20 +372,32 @@
{ {
"timestamp": 1773936397, "timestamp": 1773936397,
"value": 93 "value": 93
},
{
"timestamp": 1773939621,
"value": 92
},
{
"timestamp": 1773942985,
"value": 94
},
{
"timestamp": 1773946246,
"value": 93
} }
], ],
"forecast_minutes_left": null, "forecast_minutes_left": null,
"forecast_text": null, "forecast_text": null,
"forecast_slope_per_hour": 0.015, "forecast_slope_per_hour": 0.0057,
"forecast_reason": "Заряд не падает", "forecast_reason": "Нет заметного разряда",
"percent": 92 "percent": 89
}, },
"sensor.0x00124b0035558456_battery": { "sensor.0x00124b0035558456_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 82 "value": 82
}, },
{ {
@ -416,11 +412,11 @@
"percent": 73 "percent": 73
}, },
"sensor.0xa4c13874f5fdfd2a_battery": { "sensor.0xa4c13874f5fdfd2a_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 92 "value": 92
}, },
{ {
@ -435,11 +431,11 @@
"percent": 93 "percent": 93
}, },
"sensor.0x54ef44100119db20_battery": { "sensor.0x54ef44100119db20_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 100 "value": 100
}, },
{ {
@ -464,11 +460,11 @@
"percent": 100 "percent": 100
}, },
"sensor.0x0ceff6fffe6cffc4_battery": { "sensor.0x0ceff6fffe6cffc4_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 45 "value": 45
}, },
{ {
@ -510,20 +506,52 @@
{ {
"timestamp": 1773925068, "timestamp": 1773925068,
"value": 50 "value": 50
},
{
"timestamp": 1773944491,
"value": 55
},
{
"timestamp": 1773944511,
"value": 50
},
{
"timestamp": 1773946215,
"value": 45
},
{
"timestamp": 1773946247,
"value": 50
},
{
"timestamp": 1773946532,
"value": 45
},
{
"timestamp": 1773946537,
"value": 55
},
{
"timestamp": 1773946544,
"value": 45
},
{
"timestamp": 1773946566,
"value": 50
} }
], ],
"forecast_minutes_left": null, "forecast_minutes_left": null,
"forecast_text": null, "forecast_text": null,
"forecast_slope_per_hour": 0.2618, "forecast_slope_per_hour": 0.1586,
"forecast_reason": "Заряд не падает", "forecast_reason": "Заряд не падает",
"percent": 55 "percent": 50
}, },
"sensor.0x0ceff6fffe6cdee0_battery": { "sensor.0x0ceff6fffe6cdee0_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 0 "value": 0
}, },
{ {
@ -557,13 +585,21 @@
{ {
"timestamp": 1773925068, "timestamp": 1773925068,
"value": 60 "value": 60
},
{
"timestamp": 1773944490,
"value": 65
},
{
"timestamp": 1773944518,
"value": 55
} }
], ],
"forecast_minutes_left": null, "forecast_minutes_left": null,
"forecast_text": null, "forecast_text": null,
"forecast_slope_per_hour": 3.1712, "forecast_slope_per_hour": 2.7826,
"forecast_reason": "Заряд не падает", "forecast_reason": "Заряд не падает",
"percent": 55 "percent": 50
}, },
"sensor.0x705464fffe43dee0_battery": { "sensor.0x705464fffe43dee0_battery": {
"loaded_at": 1774456345, "loaded_at": 1774456345,
@ -605,11 +641,11 @@
"percent": 25 "percent": 25
}, },
"sensor.0xa4c138259d164c22_battery": { "sensor.0xa4c138259d164c22_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 88.5 "value": 88.5
}, },
{ {
@ -647,11 +683,11 @@
"percent": 88 "percent": 88
}, },
"sensor.spalnya_temp_battery": { "sensor.spalnya_temp_battery": {
"loaded_at": 1774456464, "loaded_at": 1774470062,
"history_hours": 4320, "history_hours": 4320,
"points": [ "points": [
{ {
"timestamp": 1773851664, "timestamp": 1773865262,
"value": 3 "value": 3
}, },
{ {

View File

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

View File

@ -49,6 +49,11 @@ body.is-embedded {
overflow: auto; overflow: auto;
} }
body.is-ha-native #camera-modal,
body.is-ha-native .camera-modal {
display: none !important;
}
button, button,
input, input,
select, select,
@ -261,7 +266,7 @@ textarea {
.room-list__group { .room-list__group {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 10px;
} }
@ -293,7 +298,8 @@ textarea {
height: 42px; height: 42px;
} }
.app-shell--embed .room-item__icon i { .app-shell--embed .room-item__icon i,
.app-shell--embed .room-item__icon ha-icon {
font-size: 32px; font-size: 32px;
} }
@ -375,6 +381,17 @@ textarea {
justify-content: center; justify-content: center;
} }
.room-item__icon ha-icon,
.grid-card__icon ha-icon,
.mushroom-button__icon ha-icon,
.icon-node--ha {
width: 100%;
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-node__img { .icon-node__img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -509,6 +526,10 @@ textarea {
padding: 22px 16px 20px; padding: 22px 16px 20px;
} }
.app-shell.is-ha-native .content-top {
margin-top: 0;
}
.content-top { .content-top {
display: none; display: none;
margin-bottom: 16px; margin-bottom: 16px;

View File

@ -3,7 +3,7 @@
const MOBILE_BREAKPOINT = 920; const MOBILE_BREAKPOINT = 920;
const state = { const state = {
snapshot: bootstrap, snapshot: bootstrap,
embedMode: Boolean(bootstrap?.ui?.embed), embedMode: Boolean(bootstrap?.ui?.embed || window.WALL_PANEL_HA_MODE),
selectedRoomId: 'main', selectedRoomId: 'main',
isMobileViewport: false, isMobileViewport: false,
mobileView: 'spaces', mobileView: 'spaces',
@ -44,10 +44,14 @@
snapshotPollTimer: null, snapshotPollTimer: null,
haSnapshotListenerInstalled: false, haSnapshotListenerInstalled: false,
debugLastRenderSignature: '', debugLastRenderSignature: '',
lastRenderSignature: '',
lastSidebarRenderSignature: '',
lastContentRenderSignature: '',
}; };
const els = {}; const els = {};
const client = window.StrikerPanelClient || (window.StrikerPanelClient = {}); const client = window.StrikerPanelClient || (window.StrikerPanelClient = {});
let renderFrame = null;
const debugEnabled = (() => { const debugEnabled = (() => {
try { try {
if (window.StrikerPanelDebug) return true; if (window.StrikerPanelDebug) return true;
@ -81,14 +85,20 @@
} }
state.snapshot = snapshot; state.snapshot = snapshot;
initRefs(); initRefs();
state.embedMode = detectEmbeddedContext(); state.embedMode = detectEmbeddedContext() || isHaRuntime();
syncLayoutState(); syncLayoutState();
render(); if (renderFrame) {
return;
}
renderFrame = window.requestAnimationFrame(() => {
renderFrame = null;
render();
});
}; };
client.refresh = () => { client.refresh = () => {
initRefs(); initRefs();
state.embedMode = detectEmbeddedContext(); state.embedMode = detectEmbeddedContext() || isHaRuntime();
syncLayoutState(); syncLayoutState();
render(); render();
}; };
@ -118,11 +128,22 @@
function isHaRuntime() { function isHaRuntime() {
return Boolean( return Boolean(
window.WALL_PANEL_HA_MODE ||
haBridge() haBridge()
|| bootstrap?.ui?.mode === 'ha-native' || bootstrap?.ui?.mode === 'ha-native'
); );
} }
async function waitForHaBridge(timeoutMs = 1000) {
const startedAt = Date.now();
let bridge = haBridge();
while (!bridge && (Date.now() - startedAt) < timeoutMs) {
await sleep(50);
bridge = haBridge();
}
return bridge;
}
function sleep(ms) { function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
} }
@ -160,6 +181,254 @@
}; };
} }
function snapshotEntityToken(snapshot, entityId) {
if (!snapshot || !entityId) {
return null;
}
const collections = [
snapshot.main_entities,
snapshot.selected_space?.entities,
snapshot.selected_room?.entities,
];
if (snapshot.entity_index && typeof snapshot.entity_index === 'object') {
collections.push(Object.values(snapshot.entity_index));
}
if (snapshot.space_index && typeof snapshot.space_index === 'object') {
Object.values(snapshot.space_index).forEach((room) => {
if (room?.entities) {
collections.push(room.entities);
}
});
}
if (snapshot.space_entities && typeof snapshot.space_entities === 'object') {
Object.values(snapshot.space_entities).forEach((entities) => collections.push(entities));
}
if (snapshot.battery_room?.entities) {
collections.push(snapshot.battery_room.entities);
}
for (const collection of collections) {
if (!Array.isArray(collection)) continue;
const found = collection.find((entity) => entity && entity.entity_id === entityId);
if (found) {
const attr = found.attributes || {};
return [
String(found.entity_id || ''),
String(found.state ?? ''),
String(found.last_changed || found.last_updated || ''),
String(attr.current_temperature ?? attr.temperature ?? ''),
String(attr.current_position ?? ''),
String(attr.hvac_action ?? ''),
];
}
}
return null;
}
function entityRenderToken(entity) {
const attr = entity?.attributes || {};
return [
String(entity?.entity_id || ''),
String(entity?.state ?? ''),
String(entity?.order ?? 9999),
entity?.visible === false ? '0' : '1',
String(entity?.domain || ''),
String(entity?.card_type || ''),
String(entity?.subtitle || ''),
String(entity?.icon || ''),
String(attr.current_temperature ?? attr.temperature ?? ''),
String(attr.current_position ?? ''),
String(attr.hvac_action ?? ''),
];
}
function roomRenderToken(room) {
return [
String(room?.id || ''),
String(room?.name || ''),
String(room?.icon || ''),
room?.visible === false ? '0' : '1',
String(room?.order ?? 9999),
String(room?.entity_count ?? 0),
String(room?.active_entity_count ?? 0),
String(room?.temperature_badge || ''),
String(room?.battery_summary_text || ''),
room?.virtual ? '1' : '0',
];
}
function selectedRoomRenderToken(snapshot) {
const room = snapshot?.selected_room || snapshot?.selected_space || {};
if (!room?.id) {
return ['unknown'];
}
if (room.id === 'main') {
const boiler = snapshot?.settings?.main_boiler || {};
const printConfig = snapshot?.settings?.main_print || {};
const weatherActions = Array.isArray(snapshot?.settings?.main_weather_actions)
? snapshot.settings.main_weather_actions
: [];
return [
'main',
room.name || '',
(Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : []).map(entityRenderToken),
snapshotEntityToken(snapshot, boiler.sensor_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.current_stage_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.print_progress_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.start_time_entity_id || ''),
snapshotEntityToken(snapshot, printConfig.end_time_entity_id || ''),
weatherActions.map((action) => [
String(action?.entity_id || ''),
String(action?.state_entity_id || ''),
String(action?.command || ''),
String(action?.value ?? ''),
String(action?.active_value ?? ''),
String(action?.label_active || ''),
String(action?.label_inactive || ''),
String(mainWeatherActionIsActive(snapshot, action) ? '1' : '0'),
]),
snapshot?.weather ? [
String(snapshot.weather.entity_id || ''),
String(snapshot.weather.state ?? ''),
String(snapshot.weather.temperature ?? ''),
String(snapshot.weather.sensor_temperature ?? ''),
String(snapshot.weather.wind_speed ?? ''),
String(snapshot.weather.condition ?? ''),
] : null,
];
}
if (room.id === 'batteries') {
return [
'batteries',
room.name || '',
room.battery_summary_text || '',
Number(room.entity_count ?? 0) || 0,
Number(room.problem_count ?? room.active_entity_count ?? 0) || 0,
(Array.isArray(room.entities) ? room.entities : []).map((item) => [
String(item?.entity_id || ''),
String(item?.battery_status || ''),
String(item?.battery_percent_text || ''),
String(item?.forecast_minutes_left ?? ''),
String(item?.forecast_text || ''),
String(item?.source_text || ''),
]),
];
}
return [
room.id,
room.name || '',
room.icon || '',
room.visible === false ? '0' : '1',
String(room.order ?? 9999),
String(room.temperature_badge || ''),
roomGridEntries(snapshot, room.id).map((entry) => (
entry.kind === 'layout'
? ['layout', String(entry.id || ''), String(entry.order ?? 9999), String(entry.payload?.type || 'ghost')]
: ['entity', ...entityRenderToken(entry.payload)]
)),
state.editMode
? roomEntitiesIncludingHidden(snapshot, room.id)
.filter((entity) => entity.visible === false)
.map(entityRenderToken)
: [],
];
}
function buildVisibleSnapshotSignature(snapshot) {
const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : [];
const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || {};
const batteryRoom = snapshot?.battery_room || null;
return JSON.stringify([
String(snapshot?.ui?.mode || 'unknown'),
String(selectedRoom?.id || 'main'),
rooms.map(roomRenderToken),
batteryRoom ? roomRenderToken(batteryRoom) : null,
selectedRoomRenderToken(snapshot),
]);
}
function buildSidebarRenderSignature(snapshot) {
const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : [];
const batteryRoom = snapshot?.battery_room || null;
return JSON.stringify([
String(state.selectedRoomId || 'main'),
String(state.editMode ? '1' : '0'),
String(state.mobileView || 'spaces'),
String(isMobileViewport() ? '1' : '0'),
rooms.map(roomRenderToken),
batteryRoom ? roomRenderToken(batteryRoom) : null,
]);
}
function buildContentRenderSignature(snapshot) {
const room = snapshot?.selected_room || snapshot?.selected_space || {};
return JSON.stringify([
String(state.selectedRoomId || room.id || 'main'),
String(room.id || 'main'),
String(state.editMode ? '1' : '0'),
String(state.mobileView || 'spaces'),
String(isMobileViewport() ? '1' : '0'),
selectedRoomRenderToken(snapshot),
]);
}
function buildRenderSignature(snapshot) {
const history = state.mainBoilerHistory || {};
return JSON.stringify([
buildVisibleSnapshotSignature(snapshot),
String(state.selectedRoomId || 'main'),
String(state.editMode ? '1' : '0'),
String(state.mobileView || 'spaces'),
String(isMobileViewport() ? '1' : '0'),
String(history.entityId || ''),
String(history.loadedAt || 0),
String(Array.isArray(history.points) ? history.points.length : 0),
String(history.loading ? '1' : '0'),
String(history.error || ''),
String(state.entityPopup?.active ? '1' : '0'),
String(state.entityPopup?.entityId || ''),
String(state.temperatureSensorPopup?.active ? '1' : '0'),
String(state.temperatureSensorPopup?.roomId || ''),
String(state.lastPopupSignature || ''),
String(state.lastEntityPopupSignature || ''),
String(state.lastTemperatureSensorPopupSignature || ''),
]);
}
function renderSidebarSection(snapshot) {
const nextSignature = buildSidebarRenderSignature(snapshot);
if (nextSignature === state.lastSidebarRenderSignature) {
return false;
}
state.lastSidebarRenderSignature = nextSignature;
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';
}
return true;
}
function renderContentSection(snapshot) {
const nextSignature = buildContentRenderSignature(snapshot);
if (nextSignature === state.lastContentRenderSignature) {
return false;
}
state.lastContentRenderSignature = nextSignature;
syncLayoutState();
renderDashboard(snapshot);
renderSelectedRoom(snapshot);
return true;
}
function requestSummary(action, params = {}) { function requestSummary(action, params = {}) {
const summary = { action }; const summary = { action };
['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => { ['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => {
@ -309,6 +578,9 @@
} }
function detectEmbeddedContext() { function detectEmbeddedContext() {
if (isHaRuntime()) {
return true;
}
if (Boolean(bootstrap?.ui?.embed)) { if (Boolean(bootstrap?.ui?.embed)) {
return true; return true;
} }
@ -378,20 +650,160 @@
return svg; return svg;
} }
function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { const iconTemplateCache = new Map();
const iconTemplatePromiseCache = new Map();
const customBrandIconsUrl = 'https://home.striker72rus.ru/local/community/custom-brand-icons/custom-brand-icons.js';
let customBrandIconsPromise = null;
function templateFromSvgText(svgText) {
const template = document.createElement('template');
template.innerHTML = String(svgText || '').trim();
return template;
}
function iconUrl(source) {
return `https://api.iconify.design/${source}.svg`;
}
function ensureCustomBrandIconsLoaded() {
if (window.customIcons || window.customIconsets) {
return Promise.resolve(true);
}
if (customBrandIconsPromise) {
return customBrandIconsPromise;
}
const existing = document.querySelector('script[data-wall-panel-custom-brand-icons="1"]');
if (existing) {
customBrandIconsPromise = new Promise((resolve, reject) => {
if (window.customIcons || window.customIconsets) {
resolve(true);
return;
}
existing.addEventListener('load', () => resolve(true), { once: true });
existing.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true });
}).finally(() => {
customBrandIconsPromise = null;
});
return customBrandIconsPromise;
}
const script = document.createElement('script');
script.src = customBrandIconsUrl;
script.defer = true;
script.async = true;
script.dataset.wallPanelCustomBrandIcons = '1';
customBrandIconsPromise = new Promise((resolve, reject) => {
script.addEventListener('load', () => resolve(true), { once: true });
script.addEventListener('error', () => reject(new Error('custom-brand-icons load failed')), { once: true });
}).finally(() => {
customBrandIconsPromise = null;
});
(document.head || document.documentElement).appendChild(script);
return customBrandIconsPromise;
}
function applyTemplateToNode(target, template) {
if (!target || !template || !target.isConnected) {
return;
}
target.replaceChildren(template.content.cloneNode(true));
}
function primeRemoteIconTemplate(source) {
const key = `remote:${source}`;
if (iconTemplateCache.has(key)) {
return Promise.resolve(iconTemplateCache.get(key));
}
if (iconTemplatePromiseCache.has(key)) {
return iconTemplatePromiseCache.get(key);
}
const promise = fetch(iconUrl(source), {
cache: 'force-cache',
credentials: 'omit',
headers: { Accept: 'image/svg+xml' },
}).then((res) => {
if (!res.ok) {
throw new Error(`Icon load failed: ${res.status}`);
}
return res.text();
}).then((svgText) => {
const template = templateFromSvgText(svgText);
iconTemplateCache.set(key, template);
return template;
}).finally(() => {
iconTemplatePromiseCache.delete(key);
});
iconTemplatePromiseCache.set(key, promise);
return promise;
}
function primeCustomIconTemplate(source) {
const key = `custom:${source}`;
if (iconTemplateCache.has(key)) {
return Promise.resolve(iconTemplateCache.get(key));
}
if (iconTemplatePromiseCache.has(key)) {
return iconTemplatePromiseCache.get(key);
}
const [prefix, name] = source.split(':', 2); const [prefix, name] = source.split(':', 2);
const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix]; const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix];
const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon; const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon;
if (typeof getIcon !== 'function') { if (typeof getIcon !== 'function') {
return null; return Promise.resolve(null);
}
const promise = Promise.resolve(getIcon(name)).then((definition) => {
if (!definition) {
return null;
}
const template = document.createElement('template');
template.content.appendChild(createSvgIcon(definition));
iconTemplateCache.set(key, template);
return template;
}).finally(() => {
iconTemplatePromiseCache.delete(key);
});
iconTemplatePromiseCache.set(key, promise);
return promise;
}
function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') {
const cached = iconTemplateCache.get(`custom:${source}`);
if (cached) {
return cached.content.cloneNode(true);
}
const [prefix] = source.split(':', 2);
const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix];
const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon;
if (typeof getIcon !== 'function') {
if (!isHaRuntime()) {
return null;
}
const wrap = document.createElement('span');
wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
ensureCustomBrandIconsLoaded().then(() => primeCustomIconTemplate(source)).then((template) => {
if (!template) return;
applyTemplateToNode(wrap, template);
}).catch(() => {
if (!wrap.isConnected) return;
wrap.replaceChildren(createIconElement(fallback));
});
return wrap;
} }
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'icon-node'; wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback)); wrap.appendChild(createIconElement(fallback));
Promise.resolve(getIcon(name)).then((definition) => { primeCustomIconTemplate(source).then((template) => {
if (!definition || !wrap.isConnected) return; if (!template) return;
wrap.replaceChildren(createSvgIcon(definition)); applyTemplateToNode(wrap, template);
}).catch(() => { }).catch(() => {
if (!wrap.isConnected) return; if (!wrap.isConnected) return;
wrap.replaceChildren(createIconElement(fallback)); wrap.replaceChildren(createIconElement(fallback));
@ -403,77 +815,71 @@
const source = normalizeIconSource(icon) || fallback; const source = normalizeIconSource(icon) || fallback;
if (source.startsWith('mdi:')) { if (source.startsWith('mdi:')) {
if (isHaRuntime()) {
const haIcon = document.createElement('ha-icon');
haIcon.setAttribute('icon', source);
haIcon.className = 'icon-node icon-node--ha';
return haIcon;
}
const i = document.createElement('i'); const i = document.createElement('i');
i.className = iconClass(source); i.className = iconClass(source);
i.setAttribute('aria-hidden', 'true');
return i; return i;
} }
if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) {
const custom = createCustomIconElement(source, fallback);
if (custom) {
return custom;
}
const mappedSource = source.startsWith('fas:')
? source.replace(/^fas:/, 'fa-solid:')
: source.startsWith('far:')
? source.replace(/^far:/, 'fa-regular:')
: 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 = '';
img.decoding = 'async';
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';
wrap.replaceChildren(createIconElement(fallback));
});
wrap.appendChild(img);
return wrap;
}
const custom = createCustomIconElement(source, fallback); const custom = createCustomIconElement(source, fallback);
if (custom) { if (custom) {
return custom; return custom;
} }
if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) {
const mappedSource = source.startsWith('fas:')
? source.replace(/^fas:/, 'fa-solid:')
: source.startsWith('far:')
? source.replace(/^far:/, 'fa-regular:')
: source.replace(/^fab:/, 'fa-brands:');
const cached = iconTemplateCache.get(`remote:${mappedSource}`);
if (cached) {
return cached.content.cloneNode(true);
}
const wrap = document.createElement('span');
wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback));
primeRemoteIconTemplate(mappedSource).then((template) => {
applyTemplateToNode(wrap, template);
}).catch(() => {
if (!wrap.isConnected) return;
wrap.replaceChildren(createIconElement(fallback));
});
return wrap;
}
const remoteCached = iconTemplateCache.get(`remote:${source}`);
if (remoteCached) {
return remoteCached.content.cloneNode(true);
}
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'icon-node'; wrap.className = 'icon-node';
wrap.appendChild(createIconElement(fallback)); wrap.appendChild(createIconElement(fallback));
if (source.includes(':')) { if (source.includes(':')) {
const img = document.createElement('img'); primeRemoteIconTemplate(source).then((template) => {
img.className = 'icon-node__img'; applyTemplateToNode(wrap, template);
img.alt = ''; }).catch(() => {
img.decoding = 'async'; if (!wrap.isConnected) return;
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';
wrap.replaceChildren(createIconElement(fallback)); wrap.replaceChildren(createIconElement(fallback));
}); });
wrap.appendChild(img);
return wrap; return wrap;
} }
return createIconElement(fallback); return createIconElement(fallback);
} }
if (isHaRuntime()) {
void ensureCustomBrandIconsLoaded();
}
function esc(value) { function esc(value) {
return String(value ?? ''); return String(value ?? '');
} }
@ -550,6 +956,9 @@
} }
function popupTriggerEntities() { function popupTriggerEntities() {
if (isHaRuntime()) {
return new Set();
}
const fromSnapshot = state.snapshot?.settings?.camera?.trigger_entities; const fromSnapshot = state.snapshot?.settings?.camera?.trigger_entities;
const fromBootstrap = bootstrap?.settings?.camera?.trigger_entities; const fromBootstrap = bootstrap?.settings?.camera?.trigger_entities;
const triggers = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : []; const triggers = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : [];
@ -672,6 +1081,14 @@
debugLog('apiGet via bridge', requestSummary(action, params)); debugLog('apiGet via bridge', requestSummary(action, params));
return bridge.request('GET', action, params); return bridge.request('GET', action, params);
} }
if (isHaRuntime()) {
const waited = await waitForHaBridge(1000);
if (waited?.request) {
debugLog('apiGet via delayed bridge', requestSummary(action, params));
return waited.request('GET', action, params);
}
throw new Error('HA bridge is not ready');
}
debugLog('apiGet via http', requestSummary(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' },
@ -689,6 +1106,14 @@
debugLog('apiPost via bridge', requestSummary(action, payload)); debugLog('apiPost via bridge', requestSummary(action, payload));
return bridge.request('POST', action, payload); return bridge.request('POST', action, payload);
} }
if (isHaRuntime()) {
const waited = await waitForHaBridge(1000);
if (waited?.request) {
debugLog('apiPost via delayed bridge', requestSummary(action, payload));
return waited.request('POST', action, payload);
}
throw new Error('HA bridge is not ready');
}
debugLog('apiPost via http', requestSummary(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',
@ -1183,12 +1608,16 @@
if (!els.appShell) return; if (!els.appShell) return;
const mobile = isMobileViewport(); const mobile = isMobileViewport();
const embedded = Boolean(state.embedMode); const haNative = isHaRuntime();
const embedded = Boolean(state.embedMode || haNative);
state.embedMode = embedded;
document.body.classList.toggle('is-mobile-ui', mobile); document.body.classList.toggle('is-mobile-ui', mobile);
document.body.classList.toggle('is-embedded', embedded); document.body.classList.toggle('is-embedded', embedded);
document.body.classList.toggle('is-ha-native', haNative);
els.appShell.classList.toggle('is-mobile', mobile); els.appShell.classList.toggle('is-mobile', mobile);
els.appShell.classList.toggle('is-desktop', !mobile); els.appShell.classList.toggle('is-desktop', !mobile);
els.appShell.classList.toggle('app-shell--embed', embedded); els.appShell.classList.toggle('app-shell--embed', embedded);
els.appShell.classList.toggle('is-ha-native', haNative);
els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room'); els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room');
els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room'); els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room');
@ -1865,6 +2294,9 @@
} }
function applyPopupState(active, sensorEntityId) { function applyPopupState(active, sensorEntityId) {
if (isHaRuntime()) {
return;
}
const camera = state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; const camera = state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {};
const popup = state.snapshot?.popup || {}; const popup = state.snapshot?.popup || {};
if (active && Date.now() < Number(state.popupAutoOpenBlockedUntil || 0)) { if (active && Date.now() < Number(state.popupAutoOpenBlockedUntil || 0)) {
@ -1888,6 +2320,9 @@
} }
function applyPopupSnapshot(popup = {}) { function applyPopupSnapshot(popup = {}) {
if (isHaRuntime()) {
return;
}
const snapshot = state.snapshot || bootstrap; const snapshot = state.snapshot || bootstrap;
snapshot.popup = mergePopupWithCamera({ snapshot.popup = mergePopupWithCamera({
...(snapshot.popup || {}), ...(snapshot.popup || {}),
@ -1897,6 +2332,9 @@
} }
function syncTriggerPopup(entityId, stateValue) { function syncTriggerPopup(entityId, stateValue) {
if (isHaRuntime()) {
return;
}
const value = String(stateValue || '').toLowerCase(); const value = String(stateValue || '').toLowerCase();
if (!['on', 'off'].includes(value)) { if (!['on', 'off'].includes(value)) {
return; return;
@ -2870,13 +3308,13 @@
const minus = document.createElement('button'); const minus = document.createElement('button');
minus.type = 'button'; minus.type = 'button';
minus.className = 'round-button entity-modal__round-button'; minus.className = 'round-button entity-modal__round-button';
minus.innerHTML = '<i class="mdi mdi-minus"></i>'; minus.appendChild(createIconElement('mdi:minus'));
minus.addEventListener('click', () => handleClimateTemperature(entity, -1)); minus.addEventListener('click', () => handleClimateTemperature(entity, -1));
const plus = document.createElement('button'); const plus = document.createElement('button');
plus.type = 'button'; plus.type = 'button';
plus.className = 'round-button entity-modal__round-button'; plus.className = 'round-button entity-modal__round-button';
plus.innerHTML = '<i class="mdi mdi-plus"></i>'; plus.appendChild(createIconElement('mdi:plus'));
plus.addEventListener('click', () => handleClimateTemperature(entity, 1)); plus.addEventListener('click', () => handleClimateTemperature(entity, 1));
controls.append(minus, plus); controls.append(minus, plus);
@ -3349,13 +3787,15 @@
const upBtn = document.createElement('button'); const upBtn = document.createElement('button');
upBtn.type = 'button'; upBtn.type = 'button';
upBtn.className = 'mushroom-button mushroom-button--small'; upBtn.className = 'mushroom-button mushroom-button--small';
upBtn.innerHTML = '<i class="mdi mdi-arrow-up"></i> Вверх'; upBtn.appendChild(createIconElement('mdi:arrow-up'));
upBtn.appendChild(document.createTextNode(' Вверх'));
upBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, -1)); upBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, -1));
const downBtn = document.createElement('button'); const downBtn = document.createElement('button');
downBtn.type = 'button'; downBtn.type = 'button';
downBtn.className = 'mushroom-button mushroom-button--small'; downBtn.className = 'mushroom-button mushroom-button--small';
downBtn.innerHTML = '<i class="mdi mdi-arrow-down"></i> Вниз'; downBtn.appendChild(createIconElement('mdi:arrow-down'));
downBtn.appendChild(document.createTextNode(' Вниз'));
downBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1)); downBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1));
actions.append(upBtn, downBtn); actions.append(upBtn, downBtn);
@ -3371,7 +3811,7 @@
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.className = 'mini-action mini-action--wide'; btn.className = 'mini-action mini-action--wide';
btn.innerHTML = hidden ? '<i class="mdi mdi-eye"></i>' : '<i class="mdi mdi-eye-off"></i>'; btn.appendChild(createIconElement(hidden ? 'mdi:eye' : 'mdi:eye-off'));
btn.title = hidden ? 'Показать' : 'Скрыть'; btn.title = hidden ? 'Показать' : 'Скрыть';
btn.addEventListener('click', (event) => { btn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
@ -3484,7 +3924,8 @@
const settingsBtn = document.createElement('button'); const settingsBtn = document.createElement('button');
settingsBtn.type = 'button'; settingsBtn.type = 'button';
settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide';
settingsBtn.innerHTML = '<i class="mdi mdi-cog-outline"></i> Настройки'; settingsBtn.appendChild(createIconElement('mdi:cog-outline'));
settingsBtn.appendChild(document.createTextNode(' Настройки'));
settingsBtn.addEventListener('click', (event) => { settingsBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
state.layoutItemSettingsOpen = { state.layoutItemSettingsOpen = {
@ -3497,7 +3938,8 @@
const upBtn = document.createElement('button'); const upBtn = document.createElement('button');
upBtn.type = 'button'; upBtn.type = 'button';
upBtn.className = 'mushroom-button mushroom-button--small'; upBtn.className = 'mushroom-button mushroom-button--small';
upBtn.innerHTML = '<i class="mdi mdi-arrow-up"></i> Вверх'; upBtn.appendChild(createIconElement('mdi:arrow-up'));
upBtn.appendChild(document.createTextNode(' Вверх'));
upBtn.addEventListener('click', (event) => { upBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
reorderRoomGridEntry(room.id, 'layout', item.id, -1); reorderRoomGridEntry(room.id, 'layout', item.id, -1);
@ -3506,7 +3948,8 @@
const downBtn = document.createElement('button'); const downBtn = document.createElement('button');
downBtn.type = 'button'; downBtn.type = 'button';
downBtn.className = 'mushroom-button mushroom-button--small'; downBtn.className = 'mushroom-button mushroom-button--small';
downBtn.innerHTML = '<i class="mdi mdi-arrow-down"></i> Вниз'; downBtn.appendChild(createIconElement('mdi:arrow-down'));
downBtn.appendChild(document.createTextNode(' Вниз'));
downBtn.addEventListener('click', (event) => { downBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
reorderRoomGridEntry(room.id, 'layout', item.id, 1); reorderRoomGridEntry(room.id, 'layout', item.id, 1);
@ -3515,7 +3958,8 @@
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.type = 'button'; deleteBtn.type = 'button';
deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide';
deleteBtn.innerHTML = '<i class="mdi mdi-delete-outline"></i> Удалить'; deleteBtn.appendChild(createIconElement('mdi:delete-outline'));
deleteBtn.appendChild(document.createTextNode(' Удалить'));
deleteBtn.addEventListener('click', (event) => { deleteBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
deleteRoomLayoutItem(room.id, item.id); deleteRoomLayoutItem(room.id, item.id);
@ -3531,7 +3975,8 @@
const tempBtn = document.createElement('button'); const tempBtn = document.createElement('button');
tempBtn.type = 'button'; tempBtn.type = 'button';
tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide';
tempBtn.innerHTML = '<i class="mdi mdi-thermometer"></i> Выбрать датчик температуры'; tempBtn.appendChild(createIconElement('mdi:thermometer'));
tempBtn.appendChild(document.createTextNode(' Выбрать датчик температуры'));
tempBtn.addEventListener('click', (event) => { tempBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
openTemperatureSensorPopup(room.id); openTemperatureSensorPopup(room.id);
@ -3654,6 +4099,12 @@
} }
els.roomList.innerHTML = ''; els.roomList.innerHTML = '';
const sortedRooms = [...(rooms || [])].sort((left, right) => { const sortedRooms = [...(rooms || [])].sort((left, right) => {
const leftVisible = left?.visible === false ? 1 : 0;
const rightVisible = right?.visible === false ? 1 : 0;
if (leftVisible !== rightVisible) {
return leftVisible - rightVisible;
}
if (left.id === 'main') return -1; if (left.id === 'main') return -1;
if (right.id === 'main') return 1; if (right.id === 'main') return 1;
@ -3841,7 +4292,8 @@
const addButton = document.createElement('button'); const addButton = document.createElement('button');
addButton.type = 'button'; addButton.type = 'button';
addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button';
addButton.innerHTML = '<i class="mdi mdi-plus"></i><span>Пустая карточка</span>'; addButton.appendChild(createIconElement('mdi:plus'));
addButton.appendChild(document.createElement('span')).textContent = 'Пустая карточка';
addButton.addEventListener('click', () => { addButton.addEventListener('click', () => {
createRoomLayoutItem(room.id); createRoomLayoutItem(room.id);
}); });
@ -3849,7 +4301,8 @@
const temperatureButton = document.createElement('button'); const temperatureButton = document.createElement('button');
temperatureButton.type = 'button'; temperatureButton.type = 'button';
temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button';
temperatureButton.innerHTML = '<i class="mdi mdi-thermometer"></i><span>Выбрать датчик температуры</span>'; temperatureButton.appendChild(createIconElement('mdi:thermometer'));
temperatureButton.appendChild(document.createElement('span')).textContent = 'Выбрать датчик температуры';
temperatureButton.addEventListener('click', () => { temperatureButton.addEventListener('click', () => {
openTemperatureSensorPopup(room.id); openTemperatureSensorPopup(room.id);
}); });
@ -3991,6 +4444,10 @@
} }
function renderPopup(snapshot) { function renderPopup(snapshot) {
if (isHaRuntime()) {
hidePopup({ preserveSnapshot: true });
return;
}
if (isMobileViewport()) { if (isMobileViewport()) {
hidePopup({ preserveSnapshot: true }); hidePopup({ preserveSnapshot: true });
return; return;
@ -4123,6 +4580,9 @@
} }
async function showDebugPopup() { async function showDebugPopup() {
if (isHaRuntime()) {
return;
}
try { try {
const response = await apiPost('popup', { command: 'open' }); const response = await apiPost('popup', { command: 'open' });
const snapshot = state.snapshot || bootstrap; const snapshot = state.snapshot || bootstrap;
@ -4266,53 +4726,25 @@
return; return;
} }
const renderSignature = JSON.stringify([ const renderSignature = buildRenderSignature(snapshot);
snapshot?.selected_room?.id || snapshot?.selected_space?.id || 'main', if (renderSignature === state.lastRenderSignature) {
Array.isArray(snapshot?.rooms) ? snapshot.rooms.length : Array.isArray(snapshot?.spaces) ? snapshot.spaces.length : 0, return;
Array.isArray(snapshot?.main_entities) ? snapshot.main_entities.length : 0, }
Boolean(snapshot?.popup?.active), state.lastRenderSignature = renderSignature;
Boolean(snapshot?.ui?.mode === 'ha-native'),
]);
if (renderSignature !== state.debugLastRenderSignature) { if (renderSignature !== state.debugLastRenderSignature) {
state.debugLastRenderSignature = renderSignature; state.debugLastRenderSignature = renderSignature;
debugLog('render()', snapshotSummary(snapshot)); debugLog('render()', snapshotSummary(snapshot));
} }
syncLayoutState(); renderSidebarSection(snapshot);
renderDashboard(snapshot); renderContentSection(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);
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() { function renderDashboardOnly() {
const snapshot = state.snapshot || bootstrap; renderContentSection(state.snapshot || bootstrap);
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return;
syncLayoutState();
renderSelectedRoom(snapshot);
renderDashboard(snapshot);
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) { function refreshCurrentRoomLayout(entityId) {
@ -4357,24 +4789,11 @@
} }
function renderSidebarOnly() { function renderSidebarOnly() {
const snapshot = state.snapshot || bootstrap; renderSidebarSection(state.snapshot || bootstrap);
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() { function renderSelectionOnly() {
const snapshot = state.snapshot || bootstrap; renderContentSection(state.snapshot || bootstrap);
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return;
syncLayoutState();
renderSelectedRoom(snapshot);
} }
async function handleEntityAction(entity, command) { async function handleEntityAction(entity, command) {
@ -4586,32 +5005,34 @@
renderSelectionOnly(); renderSelectionOnly();
}); });
bind(els.cameraBackdrop, 'click', (event) => { if (!isHaRuntime()) {
if (event.target === els.cameraBackdrop) { bind(els.cameraBackdrop, 'click', (event) => {
apiPost('popup', { command: 'close' }).catch(() => {}); if (event.target === els.cameraBackdrop) {
hidePopup({ suppressAutoOpen: true }); apiPost('popup', { command: 'close' }).catch(() => {});
} hidePopup({ suppressAutoOpen: true });
}); }
});
bind(els.cameraModalPanel, 'click', (event) => { bind(els.cameraModalPanel, 'click', (event) => {
event.stopPropagation();
});
const closeCameraPopup = async (event) => {
if (event) {
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} });
try {
await apiPost('popup', { command: 'close' });
} catch (error) {
console.warn(error);
}
hidePopup({ suppressAutoOpen: true });
};
bind(els.cameraClose, 'pointerdown', closeCameraPopup); const closeCameraPopup = async (event) => {
bind(els.cameraClose, 'click', closeCameraPopup); if (event) {
event.preventDefault();
event.stopPropagation();
}
try {
await apiPost('popup', { command: 'close' });
} catch (error) {
console.warn(error);
}
hidePopup({ suppressAutoOpen: true });
};
bind(els.cameraClose, 'pointerdown', 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) {
@ -4975,7 +5396,7 @@
mode: bootstrap?.ui?.mode || 'unknown', mode: bootstrap?.ui?.mode || 'unknown',
}); });
initRefs(); initRefs();
state.embedMode = detectEmbeddedContext(); state.embedMode = detectEmbeddedContext() || isHaRuntime();
syncLayoutState(); syncLayoutState();
syncViewportState(); syncViewportState();
bindPressFeedback(); bindPressFeedback();