-
${title}
-
${body}
- ${extra}
+
+
+
+
+
+
+
+
![Camera poster]()
+
+
+
Поток загружается
+
Показываем poster, пока не доступен video bridge
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Это действие отправит команду закрытия.
+
`;
+ this._initialized = true;
}
- _renderIframe(panelUrl) {
- if (!this.shadowRoot) {
+ _ensureAssets() {
+ if (this._assetsLoaded) {
return;
}
+ const jsSrc = `/api/wall_panel/assets/app.js?v=${this._assetVersion}`;
- const wrap = this.shadowRoot.querySelector('.wrap');
- if (!wrap) {
- return;
+ if (!document.querySelector(`link[data-striker-panel-mdi="1"]`)) {
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = 'https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css';
+ link.dataset.strikerPanelMdi = '1';
+ document.head.appendChild(link);
}
- const iframe = document.createElement('iframe');
- iframe.src = panelUrl;
- iframe.loading = 'eager';
- iframe.referrerPolicy = 'no-referrer';
- iframe.allow = 'autoplay; fullscreen; picture-in-picture';
- iframe.style.width = '100%';
- iframe.style.height = '100%';
- iframe.style.border = '0';
- iframe.style.display = 'block';
- iframe.style.background = 'transparent';
- iframe.addEventListener('load', () => {
- this._activePanelUrl = panelUrl;
+ if (!document.querySelector(`script[data-striker-panel-app="1"][src="${jsSrc}"]`)) {
+ const script = document.createElement('script');
+ script.src = jsSrc;
+ script.defer = true;
+ script.dataset.strikerPanelApp = '1';
+ document.head.appendChild(script);
+ }
+
+ this._assetsLoaded = true;
+ }
+
+ _emitSnapshot(snapshot) {
+ panelLog('emitSnapshot()', panelSnapshotSummary(snapshot));
+ window.APP_BOOTSTRAP = snapshot;
+ window.dispatchEvent(new CustomEvent(WALL_PANEL_HA_EVENT, { detail: { snapshot } }));
+ try {
+ window.StrikerPanelClient?.renderFromSnapshot?.(snapshot);
+ } catch (error) {
+ panelWarn('renderFromSnapshot() failed', error);
+ }
+ }
+
+ _getRegistryMaps() {
+ if (this._registryCache) {
+ return this._registryCache;
+ }
+ const hass = this._hass || {};
+ return {
+ entities: registryMap(hass.entities, ['entity_id']),
+ devices: registryMap(hass.devices, ['device_id', 'id', 'di']),
+ areas: registryMap(hass.areas, ['area_id', 'id']),
+ floors: registryMap(hass.floors, ['floor_id', 'id']),
+ };
+ }
+
+ async _refreshRegistryCache(force = false) {
+ if (!force && this._registryCache) {
+ return this._registryCache;
+ }
+
+ if (!force && this._registryCachePromise) {
+ return this._registryCachePromise;
+ }
+
+ const hass = this._hass || {};
+ if (!hass?.callWS) {
+ this._registryCache = this._getRegistryMaps();
+ return this._registryCache;
+ }
+
+ this._registryCachePromise = (async () => {
+ try {
+ const [entities, devices, areas, floors] = await Promise.all([
+ hass.callWS({ type: 'config/entity_registry/list' }),
+ hass.callWS({ type: 'config/device_registry/list' }),
+ hass.callWS({ type: 'config/area_registry/list' }),
+ hass.callWS({ type: 'config/floor_registry/list' }),
+ ]);
+ this._registryCache = {
+ entities: registryMap(entities, ['entity_id']),
+ devices: registryMap(devices, ['device_id', 'id', 'di']),
+ areas: registryMap(areas, ['area_id', 'id']),
+ floors: registryMap(floors, ['floor_id', 'id']),
+ };
+ panelLog('registry cache refreshed', {
+ entities: Array.isArray(entities) ? entities.length : 0,
+ devices: Array.isArray(devices) ? devices.length : 0,
+ areas: Array.isArray(areas) ? areas.length : 0,
+ floors: Array.isArray(floors) ? floors.length : 0,
+ entity_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => item?.entity_id || item?.id || '') : [],
+ entity_labels_preview: Array.isArray(entities) ? entities.slice(0, 5).map((item) => ({ id: item?.entity_id || item?.id || '', labels: item?.labels || item?.label_ids || [] })) : [],
+ });
+ return this._registryCache;
+ } catch (error) {
+ panelWarn('registry cache refresh failed, falling back to hass.* maps', error);
+ this._registryCache = this._getRegistryMaps();
+ return this._registryCache;
+ } finally {
+ this._registryCachePromise = null;
+ }
+ })();
+
+ return this._registryCachePromise;
+ }
+
+ _buildSnapshot(roomId = this._selectedRoom()) {
+ const config = this._currentConfig();
+ const hass = this._hass || {};
+ const states = hass.states || {};
+ const haData = {
+ states: Object.values(states),
+ ...this._getRegistryMaps(),
+ };
+
+ const editMode = Boolean(config?.app?.edit_mode);
+ const rooms = roomDefinitions(config, haData);
+ const mainRoom = {
+ id: 'main',
+ name: String(config?.app?.main_room_name || 'Главная'),
+ icon: String(config?.app?.main_room_icon || 'mdi:home'),
+ visible: true,
+ entity_count: 0,
+ };
+ const roomSummaries = [mainRoom];
+ const roomViews = {};
+ const spaceIndex = {};
+ const spaceEntities = {};
+
+ for (const room of rooms) {
+ const entities = roomEntities(room, haData, editMode);
+ const summary = roomSummary(room, entities, haData);
+ roomSummaries.push(summary);
+ roomViews[room.id] = {
+ id: room.id,
+ name: room.name,
+ icon: room.icon,
+ visible: Boolean(room.visible),
+ order: Number.isFinite(Number(room.order)) ? Number(room.order) : 9999,
+ entities,
+ layout_items: roomLayoutItems(room),
+ entity_count: summary.entity_count,
+ active_entity_count: summary.active_entity_count,
+ temperature_badge: summary.temperature_badge,
+ temperature_sensor_entity_id: room.temperature_sensor_entity_id,
+ };
+ spaceIndex[room.id] = roomViews[room.id];
+ spaceEntities[room.id] = entities;
+ }
+
+ const weatherEntity = selectWeatherEntity(haData, config);
+ const weather = weatherSummary(weatherEntity);
+ const weatherSensor = states['sensor.weather_temperature'];
+ if (weather && weatherSensor) {
+ weather.sensor_temperature = weatherSensor.state ?? null;
+ }
+
+ const main = mainEntities(config, haData);
+ const battery = batteryRoom(config, haData, rooms, roomId);
+ const entityIndexMap = entityIndex(config, haData);
+
+ let selectedRoom;
+ if (roomId === 'main') {
+ selectedRoom = {
+ id: 'main',
+ name: mainRoom.name,
+ icon: mainRoom.icon,
+ visible: true,
+ entities: main,
+ layout_items: [],
+ entity_count: main.length,
+ active_entity_count: main.length,
+ temperature_badge: null,
+ };
+ selectedRoom.weather = weather;
+ } else if (roomId === 'batteries') {
+ selectedRoom = battery;
+ } else {
+ selectedRoom = spaceIndex[roomId] || roomViews[roomId] || {
+ id: roomId,
+ name: roomId,
+ icon: 'mdi:home-variant',
+ visible: true,
+ entities: [],
+ layout_items: [],
+ entity_count: 0,
+ active_entity_count: 0,
+ temperature_badge: null,
+ };
+ }
+
+ const camera = config.camera || {};
+ const popup = clone(this._popupState);
+ if (popup.active && popup.expires_at && Date.now() >= Number(popup.expires_at) * 1000) {
+ popup.active = false;
+ popup.expires_at = null;
+ this._popupState = popup;
+ }
+
+ return {
+ ok: true,
+ demo: false,
+ server_time: Math.floor(Date.now() / 1000),
+ settings: {
+ title: String(config.app?.title || 'Striker Panel'),
+ poll_interval_ms: Number(config.app?.poll_interval_ms || 5000),
+ edit_mode: Boolean(config.app?.edit_mode),
+ main_room_name: mainRoom.name,
+ ha_connection: null,
+ camera: {
+ poster_url: String(camera.poster_url || ''),
+ stream_url: String(camera.stream_url || ''),
+ stream_mode: String(camera.stream_mode || 'hls'),
+ popup_timeout_minutes: Number(camera.popup_timeout_minutes || 3),
+ trigger_entities: Array.isArray(camera.trigger_entities) ? camera.trigger_entities.map(String) : [],
+ },
+ main_weather_actions: Array.isArray(config.app?.main_weather_actions) ? config.app.main_weather_actions : [],
+ main_boiler: config.app?.main_boiler || {},
+ main_print: config.app?.main_print || {},
+ },
+ spaces: roomSummaries,
+ selected_space: selectedRoom,
+ space_index: spaceIndex,
+ space_entities: spaceEntities,
+ entity_index: entityIndexMap,
+ weather,
+ main_entities: main,
+ battery_room: battery,
+ temperature_sensor_entity_id: selectedRoom?.temperature_sensor_entity_id ?? null,
+ popup: {
+ active: Boolean(popup.active),
+ sensor_entity_id: popup.sensor_entity_id ?? null,
+ opened_at: popup.opened_at ?? null,
+ expires_at: popup.expires_at ?? null,
+ poster_url: String(camera.poster_url || ''),
+ stream_url: String(camera.stream_url || ''),
+ stream_mode: String(camera.stream_mode || 'hls'),
+ title: 'Камера',
+ },
+ rooms: roomSummaries,
+ selected_room: selectedRoom,
+ ui: {
+ embed: true,
+ mode: 'ha-native',
+ shell: 'ha-native',
+ config_source: 'ha-local',
+ proxy_token: '',
+ },
+ runtime_config: config,
+ };
+ }
+
+ _schedulePopupClose(delayMs) {
+ if (this._popupCloseTimer) {
+ clearTimeout(this._popupCloseTimer);
+ }
+ this._popupCloseTimer = setTimeout(() => {
+ this._popupCloseTimer = null;
+ this._popupState.active = false;
+ this._popupState.expires_at = null;
+ this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
+ }, delayMs);
+ }
+
+ _updateTriggerPopup(nextStates) {
+ const config = this._currentConfig();
+ const triggerEntities = new Set((config.camera?.trigger_entities || []).map(String));
+ if (!triggerEntities.size) return;
+ const previous = this._triggerSnapshot;
+ this._triggerSnapshot = nextStates;
+ const timeoutMinutes = Math.max(1, Number(config.camera?.popup_timeout_minutes || 3));
+ const closeDelaySeconds = 30;
+ for (const entityId of triggerEntities) {
+ const current = String(nextStates[entityId] || '').toLowerCase();
+ const prev = String(previous[entityId] || '').toLowerCase();
+ if (current === 'on' && prev !== 'on') {
+ this._popupState = {
+ active: true,
+ sensor_entity_id: entityId,
+ opened_at: Math.floor(Date.now() / 1000),
+ expires_at: null,
+ };
+ this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
+ return;
+ }
+ if (current === 'off' && prev === 'on' && this._popupState.active && this._popupState.sensor_entity_id === entityId) {
+ this._popupState.active = true;
+ this._popupState.expires_at = Math.floor(Date.now() / 1000) + closeDelaySeconds;
+ this._schedulePopupClose(closeDelaySeconds * 1000);
+ this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
+ return;
+ }
+ if (this._popupState.active && this._popupState.expires_at && Math.floor(Date.now() / 1000) >= Number(this._popupState.expires_at)) {
+ this._popupState.active = false;
+ this._popupState.expires_at = null;
+ this._emitSnapshot(this._buildSnapshot(this._selectedRoom()));
+ }
+ }
+ }
+
+ _refreshFromHass() {
+ if (!this._hass) {
+ return;
+ }
+ const nextStates = {};
+ Object.entries(this._hass.states || {}).forEach(([entityId, entity]) => {
+ nextStates[entityId] = entity?.state;
});
- wrap.replaceChildren(iframe);
+ this._updateTriggerPopup(nextStates);
+ const snapshot = this._buildSnapshot(this._selectedRoom());
+ this._emitSnapshot(snapshot);
}
- async _tryAttachPanel() {
- const payload = this._panelConfig();
- const proxyUrl = this._proxyUrl();
- if (proxyUrl && proxyUrl === this._activePanelUrl) {
+ _ensureBridge() {
+ if (this._bridge) {
return;
}
-
- if (proxyUrl && this._hasPanelUrl()) {
- this._renderIframe(proxyUrl);
- return;
- }
-
- const panelUrl = this._resolveUrl(payload.panel_url || payload.ingress_url || '');
- const configUrl = this._configUrl();
- this._renderMessage(
- 'Waiting for Striker Panel',
- panelUrl
- ? 'Open this panel through Home Assistant after the add-on URL is configured.'
- : 'Set the PHP panel URL in the integration options.',
- configUrl ? `
${configUrl}` : ''
- );
+ panelLog('bridge init');
+ this._bridge = {
+ mode: 'ha',
+ request: (method, action, payload = {}) => this._request(method, action, payload),
+ getSnapshot: (roomId = this._selectedRoom()) => Promise.resolve(this._buildSnapshot(roomId || this._selectedRoom())),
+ setSelectedRoomId: (roomId) => {
+ this._selectedRoomId = String(roomId || 'main');
+ },
+ getConfig: () => clone(this._currentConfig()),
+ };
+ window.WALL_PANEL_HA_BRIDGE = this._bridge;
}
- _render() {
- if (!this.shadowRoot) {
+ async _request(method, action, payload = {}) {
+ const normalizedAction = String(action || '').trim().toLowerCase();
+ const config = this._currentConfig();
+ const entryId = this._entryId();
+ const token = this._syncToken();
+ panelLog('bridge request', panelRequestSummary(method, action, payload));
+
+ if (method === 'GET' && normalizedAction === 'snapshot') {
+ const roomId = String(payload.space_id || payload.room_id || this._selectedRoom() || 'main');
+ this._selectedRoomId = roomId;
+ panelLog('bridge snapshot -> build', { room_id: roomId });
+ return this._buildSnapshot(roomId);
+ }
+
+ if (method === 'GET' && normalizedAction === 'history') {
+ const entityId = String(payload.entity_id || '').trim();
+ const hours = Math.max(1, Math.min(168, Number(payload.hours || 24) || 24));
+ if (!entityId) {
+ return [];
+ }
+ if (this._hass?.callWS) {
+ const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
+ panelLog('bridge history via callWS', { entity_id: entityId, hours });
+ const history = await this._hass.callWS({
+ type: 'history/history_during_period',
+ start_time: start,
+ end_time: new Date().toISOString(),
+ entity_ids: [entityId],
+ minimal_response: true,
+ no_attributes: true,
+ significant_changes_only: false,
+ });
+ return { history };
+ }
+ const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
+ panelLog('bridge history via fetch fallback', { entity_id: entityId, hours });
+ const url = new URL(`/api/history/period/${encodeURIComponent(start)}`, window.location.origin);
+ url.searchParams.set('filter_entity_id', entityId);
+ url.searchParams.set('minimal_response', '1');
+ url.searchParams.set('no_attributes', '1');
+ const res = await fetch(url.toString(), {
+ credentials: 'same-origin',
+ headers: { Accept: 'application/json' },
+ });
+ if (!res.ok) {
+ throw new Error(`History request failed: ${res.status}`);
+ }
+ const history = await res.json();
+ return { history };
+ }
+
+ if (method === 'POST' && normalizedAction === 'service') {
+ const entityId = String(payload.entity_id || '').trim();
+ const command = String(payload.command || '').trim();
+ const value = payload.value;
+ if (!entityId || !command) {
+ throw new Error('entity_id and command are required');
+ }
+
+ const domain = appEntityDomain(entityId);
+ const serviceMap = {
+ toggle: [domain, 'toggle', { entity_id: entityId }],
+ turn_on: [domain, 'turn_on', { entity_id: entityId }],
+ turn_off: [domain, 'turn_off', { entity_id: entityId }],
+ open: ['cover', 'open_cover', { entity_id: entityId }],
+ close: ['cover', 'close_cover', { entity_id: entityId }],
+ stop: ['cover', 'stop_cover', { entity_id: entityId }],
+ set_position: ['cover', 'set_cover_position', { entity_id: entityId, position: value }],
+ set_temperature: ['climate', 'set_temperature', { entity_id: entityId, temperature: value }],
+ set_hvac_mode: ['climate', 'set_hvac_mode', { entity_id: entityId, hvac_mode: value }],
+ set_fan_mode: ['climate', 'set_fan_mode', { entity_id: entityId, fan_mode: value }],
+ set_swing_mode: ['climate', 'set_swing_mode', { entity_id: entityId, swing_mode: value }],
+ set_preset_mode: ['climate', 'set_preset_mode', { entity_id: entityId, preset_mode: value }],
+ };
+ const [serviceDomain, serviceName, data] = serviceMap[command] || [domain, command, { entity_id: entityId, ...(value !== undefined ? { value } : {}) }];
+ if (this._hass?.callService) {
+ panelLog('bridge service via callService', {
+ entity_id: entityId,
+ command,
+ service: `${serviceDomain}.${serviceName}`,
+ });
+ await this._hass.callService(serviceDomain, serviceName, data);
+ } else {
+ panelLog('bridge service via fetch fallback', {
+ entity_id: entityId,
+ command,
+ service: `${serviceDomain}.${serviceName}`,
+ });
+ const url = new URL(`/api/services/${encodeURIComponent(serviceDomain)}/${encodeURIComponent(serviceName)}`, window.location.origin);
+ const res = await fetch(url.toString(), {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Home Assistant returned HTTP ${res.status}`);
+ }
+ }
+ this._refreshFromHass();
+ return { ok: true };
+ }
+
+ if (method === 'POST' && normalizedAction === 'popup') {
+ const timeoutMinutes = Math.max(1, Number(config.camera?.popup_timeout_minutes || 3));
+ if (String(payload.command || '').toLowerCase() === 'close') {
+ this._popupState = {
+ active: false,
+ sensor_entity_id: null,
+ opened_at: this._popupState.opened_at || null,
+ expires_at: null,
+ };
+ } else if (String(payload.command || '').toLowerCase() === 'open') {
+ this._popupState = {
+ active: true,
+ sensor_entity_id: String(payload.sensor_entity_id || 'debug'),
+ opened_at: Math.floor(Date.now() / 1000),
+ expires_at: Math.floor(Date.now() / 1000) + timeoutMinutes * 60,
+ };
+ this._schedulePopupClose(timeoutMinutes * 60 * 1000);
+ } else if (payload.sensor_entity_id) {
+ const sensor = String(payload.sensor_entity_id || '');
+ const stateValue = String(payload.state || payload.to || '').toLowerCase();
+ if (stateValue === 'on') {
+ this._popupState = {
+ active: true,
+ sensor_entity_id: sensor,
+ opened_at: Math.floor(Date.now() / 1000),
+ expires_at: null,
+ };
+ } else if (stateValue === 'off' && this._popupState.active && this._popupState.sensor_entity_id === sensor) {
+ this._popupState.expires_at = Math.floor(Date.now() / 1000) + 30;
+ this._schedulePopupClose(30 * 1000);
+ }
+ }
+ panelLog('bridge popup updated', {
+ command: String(payload.command || '').toLowerCase() || 'update',
+ sensor_entity_id: payload.sensor_entity_id || null,
+ active: Boolean(this._popupState.active),
+ });
+ const snapshot = this._buildSnapshot(this._selectedRoom());
+ this._emitSnapshot(snapshot);
+ return { ok: true, popup: snapshot.popup };
+ }
+
+ if (method === 'POST' && ['save-settings', 'save-entity-override', 'save-space-override', 'create-room-layout-item', 'save-room-layout-item', 'delete-room-layout-item', 'reorder-room-grid'].includes(normalizedAction)) {
+ if (!entryId) {
+ throw new Error('entry_id is required');
+ }
+ if (!token) {
+ throw new Error('sync token is required');
+ }
+
+ panelLog('bridge config write', {
+ action: normalizedAction,
+ entry_id: entryId,
+ payload_keys: Object.keys(payload || {}),
+ });
+ const url = new URL(`/api/wall_panel/config/${encodeURIComponent(entryId)}`, window.location.origin);
+ const res = await fetch(url.toString(), {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ 'X-Wall-Panel-Token': token,
+ },
+ body: JSON.stringify({
+ action: normalizedAction,
+ payload,
+ }),
+ });
+ const json = await res.json();
+ if (!res.ok || json.ok === false) {
+ throw new Error(json.error || `Request failed: ${res.status}`);
+ }
+ if (json.config) {
+ this._runtimeConfig = normalizeConfig(json.config);
+ }
+ const snapshot = this._buildSnapshot(this._selectedRoom());
+ this._emitSnapshot(snapshot);
+ return json;
+ }
+
+ throw new Error(`Unsupported action: ${action}`);
+ }
+
+ _syncRuntime() {
+ if (!this.isConnected) {
return;
}
-
- if (this._pollTimer) {
- window.clearInterval(this._pollTimer);
- this._pollTimer = null;
- }
-
- this._tryAttachPanel();
- this._pollTimer = window.setInterval(() => {
- this._tryAttachPanel();
- }, 2000);
+ this._ensureShell();
+ this._ensureAssets();
+ this._ensureBridge();
+ this._runtimeConfig = normalizeConfig(this._panelConfig().runtime_config || this._panelConfig().config || this._runtimeConfig || {});
+ const currentRoom = this._selectedRoom();
+ this._refreshRegistryCache().then(() => {
+ const snapshot = this._buildSnapshot(currentRoom);
+ const signature = JSON.stringify([
+ snapshot?.selected_room?.id || snapshot?.selected_space?.id || 'main',
+ Array.isArray(snapshot?.rooms) ? snapshot.rooms.length : Array.isArray(snapshot?.spaces) ? snapshot.spaces.length : 0,
+ Array.isArray(snapshot?.main_entities) ? snapshot.main_entities.length : 0,
+ Boolean(snapshot?.popup?.active),
+ Boolean(snapshot?.ui?.mode === 'ha-native' || snapshot?.ui?.mode === 'ha'),
+ ]);
+ if (signature !== this._debugLastSnapshotSignature) {
+ this._debugLastSnapshotSignature = signature;
+ panelLog('syncRuntime()', panelSnapshotSummary(snapshot));
+ }
+ this._emitSnapshot(snapshot);
+ }).catch((error) => {
+ panelWarn('syncRuntime registry refresh failed', error);
+ const snapshot = this._buildSnapshot(currentRoom);
+ this._emitSnapshot(snapshot);
+ });
}
}
if (!customElements.get('striker-panel-panel')) {
- customElements.define('striker-panel-panel', WallPanelPanel);
+ customElements.define('striker-panel-panel', StrikerPanelPanel);
}
diff --git a/custom_components/wall_panel/helpers.py b/custom_components/wall_panel/helpers.py
index 56b6a23..8d3161f 100755
--- a/custom_components/wall_panel/helpers.py
+++ b/custom_components/wall_panel/helpers.py
@@ -3,7 +3,9 @@
from __future__ import annotations
import json
+import os
from copy import deepcopy
+from pathlib import Path
from typing import Any
from .const import (
@@ -21,6 +23,13 @@ from .const import (
)
+def shared_config_path() -> Path:
+ override = os.getenv("WALL_PANEL_SHARED_CONFIG_PATH", "").strip()
+ if override:
+ return Path(override)
+ return Path("/config/wall_panel/wall_panel_config.json")
+
+
def default_config() -> dict[str, Any]:
return {
"app": {
@@ -79,7 +88,79 @@ def parse_config_json(raw: str) -> dict[str, Any]:
def current_entry_config(entry) -> dict[str, Any]:
- return normalize_config(entry.options.get(CONF_CONFIG))
+ path = shared_config_path()
+ options_config = normalize_config(entry.options.get(CONF_CONFIG))
+
+ def _config_score(config: dict[str, Any]) -> int:
+ score = 0
+ rooms = config.get("rooms", [])
+ if isinstance(rooms, list):
+ score += len(rooms) * 25
+ for room in rooms:
+ if not isinstance(room, dict):
+ continue
+ score += len([value for value in (
+ room.get("name"),
+ room.get("icon"),
+ room.get("area_id"),
+ room.get("floor_id"),
+ room.get("temperature_sensor_entity_id"),
+ ) if isinstance(value, str) and value.strip()])
+ entity_ids = room.get("entity_ids", [])
+ if isinstance(entity_ids, list):
+ score += len([item for item in entity_ids if str(item).strip()])
+ overrides = room.get("entity_overrides", {})
+ if isinstance(overrides, dict):
+ score += len(overrides) * 3
+ layout_items = room.get("layout_items", [])
+ if isinstance(layout_items, list):
+ score += len(layout_items) * 5
+
+ app = config.get("app", {})
+ if isinstance(app, dict):
+ for key in ("title", "main_room_name", "main_room_icon"):
+ value = app.get(key)
+ if isinstance(value, str) and value.strip():
+ score += 3
+ for key in ("main_boiler", "main_print"):
+ value = app.get(key)
+ if isinstance(value, dict) and value:
+ score += len(value) * 2
+ actions = app.get("main_weather_actions", [])
+ if isinstance(actions, list):
+ score += len(actions) * 2
+
+ camera = config.get("camera", {})
+ if isinstance(camera, dict):
+ for key in ("rtsp_url", "stream_url", "poster_url"):
+ value = camera.get(key)
+ if isinstance(value, str) and value.strip():
+ score += 5
+ trigger_entities = camera.get("trigger_entities", [])
+ if isinstance(trigger_entities, list):
+ score += len([item for item in trigger_entities if str(item).strip()])
+
+ return score
+
+ chosen_config = options_config
+ try:
+ if path.is_file():
+ raw = path.read_text(encoding="utf-8")
+ if raw.strip():
+ data = json.loads(raw)
+ if isinstance(data, dict):
+ file_config = normalize_config(data)
+ if _config_score(file_config) >= _config_score(options_config):
+ chosen_config = file_config
+ except (OSError, json.JSONDecodeError, ValueError):
+ pass
+
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(config_to_json(chosen_config) + "\n", encoding="utf-8")
+ except OSError:
+ pass
+ return chosen_config
def current_entry_panel(entry) -> dict[str, Any]:
@@ -238,6 +319,12 @@ def build_patch_payload(payload: dict[str, Any], keys: list[str]) -> dict[str, A
return result
+def save_shared_config(config: dict[str, Any]) -> None:
+ path = shared_config_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(config_to_json(normalize_config(config)) + "\n", encoding="utf-8")
+
+
def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
for key, value in source.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
diff --git a/custom_components/wall_panel/views.py b/custom_components/wall_panel/views.py
index 89d1e7a..854c4fb 100755
--- a/custom_components/wall_panel/views.py
+++ b/custom_components/wall_panel/views.py
@@ -24,6 +24,7 @@ from .helpers import (
normalize_config,
reorder_room_grid,
save_settings,
+ save_shared_config,
update_entity_override,
update_room_layout_item,
update_room_override,
@@ -66,6 +67,11 @@ def _request_token(request: web.Request) -> str:
return request.query.get("token", "").strip()
+def _proxy_cookie_name(entry_id: str) -> str:
+ safe_entry_id = "".join(ch for ch in entry_id if ch.isalnum() or ch in {"-", "_"})
+ return f"wall_panel_proxy_{safe_entry_id}"
+
+
def _authorized(entry, request: web.Request) -> bool:
expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
if not expected:
@@ -74,6 +80,19 @@ def _authorized(entry, request: web.Request) -> bool:
return secrets.compare_digest(_request_token(request), expected)
+def _proxy_authorized(entry, request: web.Request, entry_id: str) -> bool:
+ expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
+ if not expected:
+ return False
+
+ token = _request_token(request)
+ if token and secrets.compare_digest(token, expected):
+ return True
+
+ cookie = request.cookies.get(_proxy_cookie_name(entry_id), "")
+ return bool(cookie) and secrets.compare_digest(cookie, expected)
+
+
def _response(data: Any, status: int = 200) -> web.Response:
if isinstance(data, str):
return web.Response(text=data, status=status, content_type="text/plain; charset=utf-8")
@@ -235,7 +254,7 @@ async def _handle_proxy_request(request: web.Request, entry_id: str, path: str,
entry = _entry_from_hass(hass, entry_id)
if entry is None:
return _response({"ok": False, "error": "Unknown entry"}, 404)
- if not _authorized(entry, request):
+ if not _proxy_authorized(entry, request, entry_id):
_LOGGER.warning("Wall Panel proxy denied for %s: unauthorized", entry_id)
return _response({"ok": False, "error": "Unauthorized"}, 401)
@@ -286,11 +305,26 @@ async def _handle_proxy_request(request: web.Request, entry_id: str, path: str,
response_body = await upstream.read()
_LOGGER.warning("Wall Panel upstream %s %s", upstream.status, upstream_url)
- return web.Response(
+ response = web.Response(
body=response_body,
status=upstream.status,
headers=response_headers,
)
+ expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
+ if expected:
+ # The iframe's HTML may authenticate with ?token=..., but its relative
+ # CSS/JS/API requests will not inherit that query string. A scoped cookie
+ # keeps the whole proxied panel session authorized.
+ response.set_cookie(
+ _proxy_cookie_name(entry_id),
+ expected,
+ path=_proxy_root(entry_id),
+ secure=True,
+ httponly=True,
+ samesite="Lax",
+ max_age=60 * 60 * 24,
+ )
+ return response
async def handle_proxy_request(request: web.Request) -> web.StreamResponse:
@@ -303,6 +337,7 @@ async def handle_proxy_request(request: web.Request) -> web.StreamResponse:
def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> None:
+ save_shared_config(config)
options = dict(entry.options)
options[CONF_CONFIG] = config
hass.config_entries.async_update_entry(entry, options=options)
diff --git a/storage/battery_cache.json b/storage/battery_cache.json
index 0458b7f..81ec26c 100755
--- a/storage/battery_cache.json
+++ b/storage/battery_cache.json
@@ -1,628 +1,456 @@
{
"items": {
"sensor.garage_motion_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 100
},
{
- "timestamp": 1773668185,
- "value": 100
- },
- {
- "timestamp": 1773668401,
- "value": 100
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.garage_light_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 100
},
{
- "timestamp": 1773668185,
- "value": 100
- },
- {
- "timestamp": 1773668401,
- "value": 100
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.garage_door_motion_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 100
},
{
- "timestamp": 1773668185,
- "value": 100
- },
- {
- "timestamp": 1773668401,
- "value": 100
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925032,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.stair_up_motion_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 100
},
{
- "timestamp": 1773668185,
- "value": 100
- },
- {
- "timestamp": 1773668367,
- "value": 100
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.stair_down_motion_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 100
},
{
- "timestamp": 1773668185,
- "value": 100
- },
- {
- "timestamp": 1773668401,
- "value": 100
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.stair_light_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 100
},
{
- "timestamp": 1773668185,
- "value": 100
- },
- {
- "timestamp": 1773668401,
- "value": 100
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.door_sensor_2_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 90
},
{
- "timestamp": 1773668166,
- "value": 90
- },
- {
- "timestamp": 1773668401,
- "value": 90
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 90
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 90
},
"sensor.wleak_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 100
},
{
- "timestamp": 1773668185,
- "value": 100
- },
- {
- "timestamp": 1773668401,
- "value": 100
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.0xa4c138433d675809_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 1
},
{
- "timestamp": 1773668185,
- "value": 1
- },
- {
- "timestamp": 1773668401,
- "value": 1
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 1
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 1
},
"sensor.0xa4c138997cb4fdd1_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 100
},
{
- "timestamp": 1773668158,
- "value": 100
- },
- {
- "timestamp": 1773668379,
- "value": 100
- },
- {
- "timestamp": 1773669193,
+ "timestamp": 1773925068,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.printer_knopka_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 74
},
{
- "timestamp": 1773668185,
- "value": 74
- },
- {
- "timestamp": 1773668401,
- "value": 74
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 74
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 74
},
"sensor.lestnitsa_dvizhenie_2_etazh_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 100
},
{
- "timestamp": 1773668185,
- "value": 100
- },
- {
- "timestamp": 1773668401,
- "value": 100
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.spalnia_knopka_girliand_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 29
},
{
- "timestamp": 1773668185,
- "value": 29
- },
- {
- "timestamp": 1773668401,
- "value": 29
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 29
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 29
},
"sensor.ulitsa_temperatura_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 63
},
{
- "timestamp": 1773668185,
- "value": 63
- },
- {
- "timestamp": 1773668401,
- "value": 63
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 63
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 62
},
"sensor.0x44e2f8fffeb65d8e_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
+ "value": 50
+ },
+ {
+ "timestamp": 1773903513,
"value": 55
},
{
- "timestamp": 1773664955,
- "value": 60
- },
- {
- "timestamp": 1773664975,
+ "timestamp": 1773903534,
"value": 50
},
{
- "timestamp": 1773668185,
- "value": 50
- },
- {
- "timestamp": 1773668401,
- "value": 50
- },
- {
- "timestamp": 1773669214,
- "value": 50
- },
- {
- "timestamp": 1773680612,
- "value": 60
- },
- {
- "timestamp": 1773680632,
+ "timestamp": 1773905684,
"value": 55
},
{
- "timestamp": 1773726922,
- "value": 60
- },
- {
- "timestamp": 1773726944,
- "value": 50
- },
- {
- "timestamp": 1773730991,
- "value": 60
- },
- {
- "timestamp": 1773739180,
+ "timestamp": 1773925068,
"value": 55
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0.1639,
+ "forecast_slope_per_hour": 0.2435,
"forecast_reason": "Заряд не падает",
- "percent": 45
+ "percent": 40
},
"sensor.0x54ef4410009a6a11_battery": {
- "loaded_at": 1774247175,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773642375,
- "value": 95
- },
- {
- "timestamp": 1773646522,
- "value": 95
- },
- {
- "timestamp": 1773647796,
- "value": 95
- },
- {
- "timestamp": 1773648329,
- "value": 93
- },
- {
- "timestamp": 1773651401,
- "value": 94
- },
- {
- "timestamp": 1773654742,
- "value": 95
- },
- {
- "timestamp": 1773663946,
- "value": 94
- },
- {
- "timestamp": 1773668185,
- "value": 94
- },
- {
- "timestamp": 1773668401,
- "value": 94
- },
- {
- "timestamp": 1773669214,
- "value": 94
- },
- {
- "timestamp": 1773673180,
- "value": 95
- },
- {
- "timestamp": 1773680014,
- "value": 93
- },
- {
- "timestamp": 1773683229,
- "value": 95
- },
- {
- "timestamp": 1773686431,
- "value": 96
- },
- {
- "timestamp": 1773689837,
- "value": 93
- },
- {
- "timestamp": 1773692920,
- "value": 91
- },
- {
- "timestamp": 1773696266,
- "value": 93
- },
- {
- "timestamp": 1773699456,
- "value": 94
- },
- {
- "timestamp": 1773702777,
- "value": 96
- },
- {
- "timestamp": 1773706218,
- "value": 95
- },
- {
- "timestamp": 1773709493,
- "value": 91
- },
- {
- "timestamp": 1773712864,
+ "timestamp": 1773851664,
"value": 92
},
{
- "timestamp": 1773716073,
+ "timestamp": 1773852462,
+ "value": 93
+ },
+ {
+ "timestamp": 1773855575,
+ "value": 91
+ },
+ {
+ "timestamp": 1773858820,
+ "value": 92
+ },
+ {
+ "timestamp": 1773862156,
+ "value": 91
+ },
+ {
+ "timestamp": 1773865222,
+ "value": 92
+ },
+ {
+ "timestamp": 1773868448,
+ "value": 91
+ },
+ {
+ "timestamp": 1773871808,
"value": 95
},
{
- "timestamp": 1773719369,
+ "timestamp": 1773875247,
+ "value": 92
+ },
+ {
+ "timestamp": 1773878606,
+ "value": 94
+ },
+ {
+ "timestamp": 1773885141,
+ "value": 92
+ },
+ {
+ "timestamp": 1773891488,
+ "value": 91
+ },
+ {
+ "timestamp": 1773897540,
+ "value": 93
+ },
+ {
+ "timestamp": 1773900649,
+ "value": 92
+ },
+ {
+ "timestamp": 1773907117,
+ "value": 93
+ },
+ {
+ "timestamp": 1773910581,
+ "value": 92
+ },
+ {
+ "timestamp": 1773913641,
+ "value": 91
+ },
+ {
+ "timestamp": 1773917027,
+ "value": 93
+ },
+ {
+ "timestamp": 1773925068,
+ "value": 93
+ },
+ {
+ "timestamp": 1773926597,
+ "value": 92
+ },
+ {
+ "timestamp": 1773933391,
+ "value": 91
+ },
+ {
+ "timestamp": 1773936397,
"value": 93
}
],
- "forecast_minutes_left": 85442,
- "forecast_text": "≈ 59д 8ч до разряда",
- "forecast_slope_per_hour": -0.0646,
- "forecast_reason": null,
+ "forecast_minutes_left": null,
+ "forecast_text": null,
+ "forecast_slope_per_hour": 0.015,
+ "forecast_reason": "Заряд не падает",
"percent": 92
},
"sensor.0x00124b0035558456_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 82
},
{
- "timestamp": 1773668185,
- "value": 82
- },
- {
- "timestamp": 1773668401,
- "value": 82
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 82
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
- "percent": 82
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
+ "percent": 73
},
"sensor.0xa4c13874f5fdfd2a_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
- "value": 91.5
+ "timestamp": 1773851664,
+ "value": 92
},
{
- "timestamp": 1773668185,
- "value": 91.5
- },
- {
- "timestamp": 1773668401,
- "value": 91.5
- },
- {
- "timestamp": 1773669204,
- "value": 91.5
+ "timestamp": 1773925068,
+ "value": 92
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
- "percent": 93.5
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
+ "percent": 93
},
"sensor.0x54ef44100119db20_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 100
},
{
- "timestamp": 1773668185,
- "value": 100
- },
- {
- "timestamp": 1773668401,
- "value": 100
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.door_sensor_spalnya_battery": {
@@ -636,214 +464,206 @@
"percent": 100
},
"sensor.0x0ceff6fffe6cffc4_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
+ "value": 45
+ },
+ {
+ "timestamp": 1773903513,
"value": 50
},
{
- "timestamp": 1773664975,
+ "timestamp": 1773903535,
"value": 45
},
{
- "timestamp": 1773668185,
- "value": 45
- },
- {
- "timestamp": 1773668401,
- "value": 45
- },
- {
- "timestamp": 1773669214,
- "value": 45
- },
- {
- "timestamp": 1773680611,
+ "timestamp": 1773905683,
"value": 50
},
{
- "timestamp": 1773680632,
+ "timestamp": 1773916260,
"value": 45
},
{
- "timestamp": 1773730991,
+ "timestamp": 1773918195,
"value": 50
},
{
- "timestamp": 1773731013,
+ "timestamp": 1773918217,
"value": 45
},
{
- "timestamp": 1773735636,
+ "timestamp": 1773920870,
"value": 50
},
{
- "timestamp": 1773735657,
- "value": 45
+ "timestamp": 1773923525,
+ "value": 55
},
{
- "timestamp": 1773739159,
+ "timestamp": 1773923547,
+ "value": 50
+ },
+ {
+ "timestamp": 1773925068,
"value": 50
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0.0887,
+ "forecast_slope_per_hour": 0.2618,
"forecast_reason": "Заряд не падает",
"percent": 55
},
"sensor.0x0ceff6fffe6cdee0_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 0
},
{
- "timestamp": 1773668185,
- "value": 0
+ "timestamp": 1773916238,
+ "value": 60
},
{
- "timestamp": 1773668401,
- "value": 0
+ "timestamp": 1773918189,
+ "value": 65
},
{
- "timestamp": 1773669214,
- "value": 0
+ "timestamp": 1773918211,
+ "value": 60
+ },
+ {
+ "timestamp": 1773920877,
+ "value": 65
+ },
+ {
+ "timestamp": 1773920891,
+ "value": 60
+ },
+ {
+ "timestamp": 1773923528,
+ "value": 65
+ },
+ {
+ "timestamp": 1773923546,
+ "value": 60
+ },
+ {
+ "timestamp": 1773925068,
+ "value": 60
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
- "percent": 60
+ "forecast_slope_per_hour": 3.1712,
+ "forecast_reason": "Заряд не падает",
+ "percent": 55
},
"sensor.0x705464fffe43dee0_battery": {
- "loaded_at": 1774247175,
+ "loaded_at": 1774456345,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773642375,
- "value": 45
- },
- {
- "timestamp": 1773646522,
- "value": 45
- },
- {
- "timestamp": 1773647796,
- "value": 45
- },
- {
- "timestamp": 1773668185,
- "value": 45
- },
- {
- "timestamp": 1773668401,
- "value": 45
- },
- {
- "timestamp": 1773669214,
- "value": 45
- },
- {
- "timestamp": 1773726943,
+ "timestamp": 1773851545,
"value": 40
+ },
+ {
+ "timestamp": 1773903534,
+ "value": 35
+ },
+ {
+ "timestamp": 1773905684,
+ "value": 40
+ },
+ {
+ "timestamp": 1773911127,
+ "value": 35
+ },
+ {
+ "timestamp": 1773920927,
+ "value": 40
+ },
+ {
+ "timestamp": 1773925068,
+ "value": 40
+ },
+ {
+ "timestamp": 1773932330,
+ "value": 35
}
],
- "forecast_minutes_left": 9734,
- "forecast_text": "≈ 6д 18ч до разряда",
- "forecast_slope_per_hour": -0.2157,
+ "forecast_minutes_left": 13964,
+ "forecast_text": "≈ 9д 16ч до разряда",
+ "forecast_slope_per_hour": -0.1074,
"forecast_reason": null,
- "percent": 35
+ "percent": 25
},
"sensor.0xa4c138259d164c22_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 88.5
},
{
- "timestamp": 1773668171,
- "value": 88.5
- },
- {
- "timestamp": 1773668401,
- "value": 88.5
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925068,
"value": 88.5
}
],
"forecast_minutes_left": null,
"forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
- "percent": 87
+ "forecast_slope_per_hour": null,
+ "forecast_reason": "Недостаточно истории",
+ "percent": 88.5
},
"sensor.0xa4c138fe1cdd2a21_battery": {
- "loaded_at": 1774247175,
+ "loaded_at": 1774456345,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773642375,
- "value": 87.5
- },
- {
- "timestamp": 1773646492,
- "value": 87.5
- },
- {
- "timestamp": 1773647796,
- "value": 87.5
- },
- {
- "timestamp": 1773668172,
- "value": 87.5
- },
- {
- "timestamp": 1773668401,
- "value": 87.5
- },
- {
- "timestamp": 1773669214,
- "value": 87.5
- },
- {
- "timestamp": 1773711258,
+ "timestamp": 1773851545,
"value": 86
+ },
+ {
+ "timestamp": 1773906929,
+ "value": 85.5
+ },
+ {
+ "timestamp": 1773925068,
+ "value": 85.5
}
],
- "forecast_minutes_left": 67706,
- "forecast_text": "≈ 47д до разряда",
- "forecast_slope_per_hour": -0.0753,
+ "forecast_minutes_left": 200295,
+ "forecast_text": "≈ 139д 2ч до разряда",
+ "forecast_slope_per_hour": -0.0264,
"forecast_reason": null,
- "percent": 85
+ "percent": 88
},
"sensor.spalnya_temp_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456464,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851664,
"value": 3
},
{
- "timestamp": 1773668185,
+ "timestamp": 1773910195,
+ "value": 0
+ },
+ {
+ "timestamp": 1773916438,
"value": 3
},
{
- "timestamp": 1773668401,
- "value": 3
- },
- {
- "timestamp": 1773669214,
+ "timestamp": 1773925010,
"value": 3
}
],
@@ -854,30 +674,30 @@
"percent": 0
},
"sensor.kukhnia_temperatura_battery": {
- "loaded_at": 1774264856,
+ "loaded_at": 1774456345,
"history_hours": 4320,
"points": [
{
- "timestamp": 1773660056,
+ "timestamp": 1773851545,
"value": 90
},
{
- "timestamp": 1773668185,
- "value": 90
+ "timestamp": 1773919131,
+ "value": 83
},
{
- "timestamp": 1773668401,
- "value": 90
+ "timestamp": 1773925068,
+ "value": 83
},
{
- "timestamp": 1773669214,
+ "timestamp": 1773926056,
"value": 90
}
],
- "forecast_minutes_left": null,
- "forecast_text": null,
- "forecast_slope_per_hour": 0,
- "forecast_reason": "Нет заметного разряда",
+ "forecast_minutes_left": 25113,
+ "forecast_text": "≈ 17д 10ч до разряда",
+ "forecast_slope_per_hour": -0.215,
+ "forecast_reason": null,
"percent": 90
}
}
diff --git a/storage/popup_state.json b/storage/popup_state.json
index b50645a..4c4983c 100755
--- a/storage/popup_state.json
+++ b/storage/popup_state.json
@@ -1,6 +1,6 @@
{
"active": false,
- "sensor_entity_id": "binary_sensor.barn_all_occupancy",
- "opened_at": 1774445418,
+ "sensor_entity_id": "binary_sensor.doorbell_all_occupancy",
+ "opened_at": 1774456141,
"expires_at": null
}
diff --git a/wall_panel/assets/app.css b/wall_panel/assets/app.css
index 25bfd91..786893a 100755
--- a/wall_panel/assets/app.css
+++ b/wall_panel/assets/app.css
@@ -2115,6 +2115,11 @@ body.is-mobile-ui #camera-modal {
max-width: none;
}
+.main-dashboard__cards .main-dashboard__room-grid {
+ grid-column: 1 / -1;
+ width: 100%;
+}
+
.room-entities-section {
display: grid;
gap: 14px;
diff --git a/wall_panel/assets/app.js b/wall_panel/assets/app.js
index 4f96f47..d8b5602 100755
--- a/wall_panel/assets/app.js
+++ b/wall_panel/assets/app.js
@@ -42,20 +42,210 @@
haSubscribeId: 1,
roomSelectionToken: 0,
snapshotPollTimer: null,
+ haSnapshotListenerInstalled: false,
+ debugLastRenderSignature: '',
};
const els = {};
+ const client = window.StrikerPanelClient || (window.StrikerPanelClient = {});
+ const debugEnabled = (() => {
+ try {
+ if (window.StrikerPanelDebug) return true;
+ const search = new URLSearchParams(window.location.search);
+ if (['1', 'true', 'yes', 'on'].includes(String(search.get('wp_debug') || '').toLowerCase())) {
+ return true;
+ }
+ const stored = window.localStorage?.getItem('striker-panel-debug');
+ if (['1', 'true', 'yes', 'on'].includes(String(stored || '').toLowerCase())) {
+ return true;
+ }
+ } catch (error) {
+ return Boolean(window.StrikerPanelDebug);
+ }
+ return false;
+ })();
+ const debugLog = (...args) => {
+ if (debugEnabled) {
+ console.log('[Striker Panel]', ...args);
+ }
+ };
+ const debugWarn = (...args) => {
+ if (debugEnabled) {
+ console.warn('[Striker Panel]', ...args);
+ }
+ };
+
+ client.renderFromSnapshot = (snapshot) => {
+ if (!snapshot || typeof snapshot !== 'object') {
+ return;
+ }
+ state.snapshot = snapshot;
+ initRefs();
+ state.embedMode = detectEmbeddedContext();
+ syncLayoutState();
+ render();
+ };
+
+ client.refresh = () => {
+ initRefs();
+ state.embedMode = detectEmbeddedContext();
+ syncLayoutState();
+ render();
+ };
function $(id) {
- return document.getElementById(id);
+ const root = client.mountRoot || document;
+ if (!root) return null;
+ if (typeof root.getElementById === 'function') {
+ return root.getElementById(id);
+ }
+ return root.querySelector?.(`#${CSS.escape(id)}`) || null;
}
- function q(sel, root = document) {
- return root.querySelector(sel);
+ function q(sel, root) {
+ const actualRoot = root || client.mountRoot || document;
+ return actualRoot.querySelector(sel);
}
- function qa(sel, root = document) {
- return Array.from(root.querySelectorAll(sel));
+ function qa(sel, root) {
+ const actualRoot = root || client.mountRoot || document;
+ return Array.from(actualRoot.querySelectorAll(sel));
+ }
+
+ function haBridge() {
+ return window.WALL_PANEL_HA_BRIDGE || null;
+ }
+
+ function isHaRuntime() {
+ return Boolean(
+ haBridge()
+ || bootstrap?.ui?.mode === 'ha-native'
+ );
+ }
+
+ function sleep(ms) {
+ return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
+ }
+
+ function snapshotLooksReady(snapshot) {
+ if (!snapshot || typeof snapshot !== 'object') {
+ return false;
+ }
+ const rooms = Array.isArray(snapshot.rooms) ? snapshot.rooms : [];
+ const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : [];
+ if (rooms.length > 0 || spaces.length > 0) {
+ return true;
+ }
+ if (snapshot.selected_room?.id || snapshot.selected_space?.id) {
+ return true;
+ }
+ return false;
+ }
+
+ function snapshotSummary(snapshot) {
+ const selectedRoom = snapshot?.selected_room || snapshot?.selected_space || null;
+ const rooms = Array.isArray(snapshot?.rooms) ? snapshot.rooms : Array.isArray(snapshot?.spaces) ? snapshot.spaces : [];
+ const mainEntities = Array.isArray(snapshot?.main_entities) ? snapshot.main_entities : [];
+ const selectedEntities = Array.isArray(selectedRoom?.entities) ? selectedRoom.entities : [];
+ const popup = snapshot?.popup || {};
+ return {
+ mode: snapshot?.ui?.mode || 'unknown',
+ selected_room_id: selectedRoom?.id || null,
+ selected_room_name: selectedRoom?.name || null,
+ rooms: rooms.length,
+ main_entities: mainEntities.length,
+ selected_room_entities: selectedEntities.length,
+ popup_active: Boolean(popup.active),
+ popup_sensor: popup.sensor_entity_id || null,
+ };
+ }
+
+ function requestSummary(action, params = {}) {
+ const summary = { action };
+ ['space_id', 'room_id', 'entity_id', 'layout_item_id', 'command', 'value', 'hours', 'state', 'edit_mode'].forEach((key) => {
+ if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
+ summary[key] = params[key];
+ }
+ });
+ if (params.payload && typeof params.payload === 'object') {
+ summary.payload_keys = Object.keys(params.payload);
+ }
+ return summary;
+ }
+
+ async function resolveInitialSnapshot() {
+ const bootstrapSnapshot = window.APP_BOOTSTRAP || bootstrap || {};
+ debugLog('resolveInitialSnapshot()', {
+ ha_runtime: isHaRuntime(),
+ bridge_ready: Boolean(haBridge()),
+ bootstrap_ready: snapshotLooksReady(bootstrapSnapshot),
+ });
+ if (!isHaRuntime()) {
+ debugLog('resolveInitialSnapshot -> bootstrap (standalone)', snapshotSummary(bootstrapSnapshot));
+ return bootstrapSnapshot;
+ }
+
+ const waitForHaBridge = async (timeoutMs = 1000) => {
+ const startedAt = Date.now();
+ let bridge = haBridge();
+ while (!bridge && (Date.now() - startedAt) < timeoutMs) {
+ await sleep(50);
+ bridge = haBridge();
+ }
+ return bridge;
+ };
+
+ const tryBridgeSnapshot = async () => {
+ const bridge = await waitForHaBridge();
+ if (!bridge) {
+ debugLog('HA bridge not ready yet');
+ return null;
+ }
+
+ const roomId = state.selectedRoomId || 'main';
+ try {
+ if (typeof bridge.getSnapshot === 'function') {
+ debugLog('request initial snapshot via bridge.getSnapshot()', { room_id: roomId });
+ const snapshot = await bridge.getSnapshot(roomId);
+ if (snapshotLooksReady(snapshot)) {
+ debugLog('initial snapshot received via bridge.getSnapshot()', snapshotSummary(snapshot));
+ return snapshot;
+ }
+ }
+ } catch (error) {
+ debugWarn('initial snapshot bridge.getSnapshot() failed', error);
+ }
+
+ try {
+ if (typeof bridge.request === 'function') {
+ debugLog('request initial snapshot via bridge.request(GET snapshot)', { room_id: roomId });
+ const snapshot = await bridge.request('GET', 'snapshot', { space_id: roomId });
+ if (snapshotLooksReady(snapshot)) {
+ debugLog('initial snapshot received via bridge.request(GET snapshot)', snapshotSummary(snapshot));
+ return snapshot;
+ }
+ }
+ } catch (error) {
+ debugWarn('initial snapshot bridge.request(GET snapshot) failed', error);
+ }
+
+ return null;
+ };
+
+ const firstPass = await tryBridgeSnapshot();
+ if (firstPass) {
+ return firstPass;
+ }
+
+ await sleep(150);
+
+ const secondPass = await tryBridgeSnapshot();
+ if (secondPass) {
+ return secondPass;
+ }
+
+ debugLog('resolveInitialSnapshot -> fallback bootstrap', snapshotSummary(bootstrapSnapshot));
+ return snapshotLooksReady(bootstrapSnapshot) ? bootstrapSnapshot : bootstrapSnapshot;
}
const PRESSABLE_SELECTOR = [
@@ -198,6 +388,7 @@
const wrap = document.createElement('span');
wrap.className = 'icon-node';
+ wrap.appendChild(createIconElement(fallback));
Promise.resolve(getIcon(name)).then((definition) => {
if (!definition || !wrap.isConnected) return;
wrap.replaceChildren(createSvgIcon(definition));
@@ -229,6 +420,7 @@
: source.replace(/^fab:/, 'fa-brands:');
const wrap = document.createElement('span');
wrap.className = 'icon-node';
+ wrap.appendChild(createIconElement(fallback));
const img = document.createElement('img');
img.className = 'icon-node__img';
img.alt = '';
@@ -236,6 +428,10 @@
img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
img.src = `https://api.iconify.design/${mappedSource}.svg`;
+ img.addEventListener('load', () => {
+ if (!img.isConnected || !wrap.isConnected) return;
+ wrap.replaceChildren(img);
+ });
img.addEventListener('error', () => {
if (img.dataset.fallbackApplied === '1') return;
img.dataset.fallbackApplied = '1';
@@ -252,6 +448,7 @@
const wrap = document.createElement('span');
wrap.className = 'icon-node';
+ wrap.appendChild(createIconElement(fallback));
if (source.includes(':')) {
const img = document.createElement('img');
@@ -261,6 +458,10 @@
img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
img.src = `https://api.iconify.design/${source}.svg`;
+ img.addEventListener('load', () => {
+ if (!img.isConnected || !wrap.isConnected) return;
+ wrap.replaceChildren(img);
+ });
img.addEventListener('error', () => {
if (img.dataset.fallbackApplied === '1') return;
img.dataset.fallbackApplied = '1';
@@ -448,6 +649,9 @@
}
function buildUrl(action, params = {}) {
+ if (isHaRuntime()) {
+ return '';
+ }
const url = new URL('api.php', window.location.href);
url.searchParams.set('action', action);
Object.entries(params).forEach(([key, value]) => {
@@ -463,6 +667,12 @@
}
async function apiGet(action, params = {}) {
+ const bridge = haBridge();
+ if (bridge?.request) {
+ debugLog('apiGet via bridge', requestSummary(action, params));
+ return bridge.request('GET', action, params);
+ }
+ debugLog('apiGet via http', requestSummary(action, params));
const res = await fetch(buildUrl(action, params), {
headers: { Accept: 'application/json' },
cache: 'no-store',
@@ -474,6 +684,12 @@
}
async function apiPost(action, payload = {}) {
+ const bridge = haBridge();
+ if (bridge?.request) {
+ debugLog('apiPost via bridge', requestSummary(action, payload));
+ return bridge.request('POST', action, payload);
+ }
+ debugLog('apiPost via http', requestSummary(action, payload));
const res = await fetch(buildUrl(action), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
@@ -487,7 +703,11 @@
}
async function fetchSnapshot(roomId = state.selectedRoomId || 'main') {
- return apiGet('snapshot', { space_id: roomId || 'main' });
+ const response = await apiGet('snapshot', { space_id: roomId || 'main' });
+ if (response && response.ok === true && response.selected_room) {
+ return response;
+ }
+ return response;
}
async function loadSnapshot(roomId = state.selectedRoomId || 'main') {
@@ -1004,6 +1224,87 @@
return q('.main-dashboard__cards', els.dashboardSurface);
}
+ function renderMainRoomSummaryGrid(snapshot) {
+ const rooms = Array.isArray(snapshot?.spaces) ? snapshot.spaces : Array.isArray(snapshot?.rooms) ? snapshot.rooms : [];
+ const batteryRoom = snapshot?.battery_room || null;
+ const cards = document.createElement('div');
+ cards.className = 'room-list__group main-dashboard__room-grid';
+
+ const roomCard = (room, options = {}) => {
+ if (!room) return null;
+ const card = document.createElement('div');
+ card.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${options.hidden ? 'is-hidden-room' : ''}`.trim();
+ card.dataset.roomId = room.id;
+ card.tabIndex = 0;
+ card.setAttribute('role', 'button');
+ card.addEventListener('click', () => setSelectedRoom(room.id));
+ card.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ setSelectedRoom(room.id);
+ }
+ });
+
+ const content = document.createElement('div');
+ content.className = 'room-item__content';
+
+ const icon = document.createElement('div');
+ icon.className = 'room-item__icon';
+ icon.appendChild(createIconElement(room.icon || 'mdi:home-variant'));
+
+ const body = document.createElement('div');
+ body.className = 'room-item__body';
+ const activeCount = room.id === 'batteries'
+ ? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0
+ : Number(room.active_entity_count ?? room.entity_count ?? 0) || 0;
+ const metaText = room.id === 'main'
+ ? 'Главный экран'
+ : room.id === 'batteries'
+ ? (room.battery_summary_text || `${room.entity_count || 0} батареек`)
+ : activeCount > 0
+ ? `${activeCount} ${pluralizeActiveEntities(activeCount)}`
+ : 'Нет активных';
+ body.innerHTML = `
+
${esc(room.name || '')}
+
${esc(metaText)}
+ `;
+ content.append(icon, body);
+
+ const tempBadge = roomTemperatureBadge(snapshot, room);
+ if (tempBadge) {
+ card.classList.add('has-temp');
+ const temp = document.createElement('div');
+ temp.className = 'room-item__temp';
+ temp.textContent = tempBadge;
+ card.appendChild(temp);
+ }
+
+ card.append(content);
+ return card;
+ };
+
+ const orderedRooms = [...rooms]
+ .filter((room) => room && room.id !== 'main' && room.visible !== false && room.id !== 'batteries')
+ .sort((left, right) => {
+ const leftOrder = Number(left.order ?? 9999);
+ const rightOrder = Number(right.order ?? 9999);
+ if (leftOrder !== rightOrder) return leftOrder - rightOrder;
+ return String(left.name || '').localeCompare(String(right.name || ''), 'ru');
+ });
+
+ orderedRooms.forEach((room) => {
+ const card = roomCard(room);
+ if (card) cards.appendChild(card);
+ });
+
+ if (batteryRoom && !isMobileViewport()) {
+ const card = roomCard(batteryRoom);
+ if (card) cards.appendChild(card);
+ }
+
+ return cards;
+ }
+
function currentDashboardCardsContainer() {
const snapshot = state.snapshot || bootstrap;
const room = snapshot.selected_space || snapshot.selected_room || {};
@@ -1613,10 +1914,16 @@
}
function haConnection() {
+ if (isHaRuntime()) {
+ return null;
+ }
return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {};
}
function haWsUrl(baseUrl) {
+ if (isHaRuntime()) {
+ return '';
+ }
if (!baseUrl) return '';
try {
const url = new URL(baseUrl);
@@ -3342,6 +3649,9 @@
}
function renderRoomButtons(snapshot, rooms, batteryRoom = null) {
+ if (!els.roomList) {
+ return;
+ }
els.roomList.innerHTML = '';
const sortedRooms = [...(rooms || [])].sort((left, right) => {
if (left.id === 'main') return -1;
@@ -3466,6 +3776,11 @@
function renderSelectedRoom(snapshot) {
const room = snapshot.selected_space || snapshot.selected_room || {};
+ const setText = (el, value) => {
+ if (el) {
+ el.textContent = value;
+ }
+ };
if (els.contentTop) {
els.contentTop.classList.toggle('is-main', room.id === 'main');
}
@@ -3474,8 +3789,8 @@
}
updateMainPrintStrip(snapshot);
if (room.id === 'batteries') {
- els.selectedRoomEyebrow.textContent = 'Псевдо-комната';
- els.selectedRoomTitle.textContent = room.name || 'Батарейки';
+ setText(els.selectedRoomEyebrow, 'Псевдо-комната');
+ setText(els.selectedRoomTitle, room.name || 'Батарейки');
const total = Number(room.entity_count ?? 0) || 0;
const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0;
const unavailable = Number(room.unavailable_count ?? 0) || 0;
@@ -3490,26 +3805,26 @@
if (unknown > 0) {
summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`);
}
- els.selectedRoomMeta.textContent = summaryParts.length
+ setText(els.selectedRoomMeta, summaryParts.length
? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`
- : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`;
+ : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`);
renderSelectedRoomActions(snapshot);
return;
}
if (room.id !== 'main') {
- els.selectedRoomEyebrow.textContent = 'Пространство';
- els.selectedRoomTitle.textContent = room.name || 'Панель';
+ setText(els.selectedRoomEyebrow, 'Пространство');
+ setText(els.selectedRoomTitle, room.name || 'Панель');
const entities = roomEntities(snapshot, room.id || 'main');
const activeCount = Number(room.active_entity_count ?? entities.length) || 0;
- els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`;
+ setText(els.selectedRoomMeta, `${activeCount} ${pluralizeActiveEntities(activeCount)}`);
renderSelectedRoomActions(snapshot);
return;
}
const entities = roomEntities(snapshot, room.id || 'main');
- els.selectedRoomEyebrow.textContent = '';
- els.selectedRoomTitle.textContent = room.name || 'Панель';
- els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`;
+ setText(els.selectedRoomEyebrow, '');
+ setText(els.selectedRoomTitle, room.name || 'Панель');
+ setText(els.selectedRoomMeta, `${entities.length} ${pluralizeIncludedEntities(entities.length)}`);
renderSelectedRoomActions(snapshot);
}
@@ -3545,23 +3860,32 @@
function renderDashboard(snapshot) {
const room = snapshot.selected_space || snapshot.selected_room || {};
const grid = els.dashboardSurface;
+ if (!grid) {
+ return;
+ }
grid.innerHTML = '';
if (room.id === 'main') {
const layout = document.createElement('div');
layout.className = 'main-dashboard';
- const hero = renderMainHero(snapshot);
-
+ const mainEntities = roomEntities(snapshot, 'main');
const cards = document.createElement('div');
cards.className = 'grid-surface main-dashboard__cards';
- const mainEntities = roomEntities(snapshot, 'main');
- mainEntities.forEach((entity) => {
- cards.appendChild(renderEntityCard(entity, { isMain: true }));
- });
+ if (mainEntities.length) {
+ mainEntities.forEach((entity) => {
+ cards.appendChild(renderEntityCard(entity, { isMain: true }));
+ });
+ }
+
+ const hero = renderMainHero(snapshot);
+
+ layout.append(hero);
+ if (mainEntities.length) {
+ layout.append(cards);
+ }
- layout.append(hero, cards);
grid.appendChild(layout);
return;
}
@@ -3683,8 +4007,10 @@
]);
if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) {
- els.cameraBackdrop.classList.add('is-open');
- els.cameraBackdrop.setAttribute('aria-hidden', 'false');
+ if (els.cameraBackdrop) {
+ els.cameraBackdrop.classList.add('is-open');
+ els.cameraBackdrop.setAttribute('aria-hidden', 'false');
+ }
return;
}
@@ -3695,11 +4021,17 @@
return;
}
- els.cameraPoster.src = popup.poster_url || '';
- els.cameraPoster.alt = popup.sensor_entity_id || 'camera';
- els.cameraBackdrop.classList.add('is-open');
- els.cameraBackdrop.setAttribute('aria-hidden', 'false');
- els.cameraPlaceholder.classList.add('is-visible');
+ if (els.cameraPoster) {
+ els.cameraPoster.src = popup.poster_url || '';
+ els.cameraPoster.alt = popup.sensor_entity_id || 'camera';
+ }
+ if (els.cameraBackdrop) {
+ els.cameraBackdrop.classList.add('is-open');
+ els.cameraBackdrop.setAttribute('aria-hidden', 'false');
+ }
+ if (els.cameraPlaceholder) {
+ els.cameraPlaceholder.classList.add('is-visible');
+ }
const expiresAt = Number(popup.expires_at || 0);
if (expiresAt > 0) {
@@ -3709,11 +4041,15 @@
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
if (remaining > 0) {
- els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`;
+ if (els.cameraCountdown) {
+ els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`;
+ }
return;
}
- els.cameraCountdown.textContent = 'Закрытие...';
+ if (els.cameraCountdown) {
+ els.cameraCountdown.textContent = 'Закрытие...';
+ }
if (closeRequested) {
return;
}
@@ -3736,7 +4072,9 @@
clearInterval(state.popupDismissTimer);
state.popupDismissTimer = setInterval(updateCountdown, 1000);
} else {
- els.cameraCountdown.textContent = '';
+ if (els.cameraCountdown) {
+ els.cameraCountdown.textContent = '';
+ }
clearInterval(state.popupDismissTimer);
state.popupDismissTimer = null;
}
@@ -3759,14 +4097,26 @@
active: false,
};
}
- els.cameraBackdrop.classList.remove('is-open');
- els.cameraBackdrop.setAttribute('aria-hidden', 'true');
- els.cameraStage.innerHTML = '';
- els.cameraStage.appendChild(els.cameraPoster);
- els.cameraStage.appendChild(els.cameraPlaceholder);
- els.cameraPlaceholder.classList.add('is-visible');
- els.cameraPoster.removeAttribute('src');
- els.cameraCountdown.textContent = '';
+ if (els.cameraBackdrop) {
+ els.cameraBackdrop.classList.remove('is-open');
+ els.cameraBackdrop.setAttribute('aria-hidden', 'true');
+ }
+ if (els.cameraStage) {
+ els.cameraStage.innerHTML = '';
+ if (els.cameraPoster) {
+ els.cameraStage.appendChild(els.cameraPoster);
+ }
+ if (els.cameraPlaceholder) {
+ els.cameraStage.appendChild(els.cameraPlaceholder);
+ els.cameraPlaceholder.classList.add('is-visible');
+ }
+ }
+ if (els.cameraPoster) {
+ els.cameraPoster.removeAttribute('src');
+ }
+ if (els.cameraCountdown) {
+ els.cameraCountdown.textContent = '';
+ }
clearInterval(state.popupDismissTimer);
state.popupDismissTimer = null;
destroyStream();
@@ -3916,18 +4266,34 @@
return;
}
+ const renderSignature = JSON.stringify([
+ snapshot?.selected_room?.id || snapshot?.selected_space?.id || 'main',
+ Array.isArray(snapshot?.rooms) ? snapshot.rooms.length : Array.isArray(snapshot?.spaces) ? snapshot.spaces.length : 0,
+ Array.isArray(snapshot?.main_entities) ? snapshot.main_entities.length : 0,
+ Boolean(snapshot?.popup?.active),
+ Boolean(snapshot?.ui?.mode === 'ha-native'),
+ ]);
+ if (renderSignature !== state.debugLastRenderSignature) {
+ state.debugLastRenderSignature = renderSignature;
+ debugLog('render()', snapshotSummary(snapshot));
+ }
+
syncLayoutState();
- renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
- renderSelectedRoom(snapshot);
renderDashboard(snapshot);
+ renderSelectedRoom(snapshot);
+ renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room);
renderPopup(snapshot);
renderEntityPopup(snapshot);
renderTemperatureSensorPopup(snapshot);
const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
- els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
- els.editModeToggle.classList.toggle('is-active', state.editMode);
- els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
+ 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() {
@@ -3939,6 +4305,14 @@
renderPopup(snapshot);
renderEntityPopup(snapshot);
renderTemperatureSensorPopup(snapshot);
+ const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1);
+ if (els.roomsCount) {
+ els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
+ }
+ if (els.editModeToggle) {
+ els.editModeToggle.classList.toggle('is-active', state.editMode);
+ els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
+ }
}
function refreshCurrentRoomLayout(entityId) {
@@ -3968,6 +4342,9 @@
}
const container = els.dashboardSurface;
+ if (!container) {
+ return;
+ }
const order = new Map(roomEntities(snapshot, room.id).map((entity) => [entity.entity_id, Number(entity.order ?? 9999)]));
const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]'));
cards.sort((left, right) => {
@@ -3984,9 +4361,13 @@
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);
- els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : '';
- els.editModeToggle.classList.toggle('is-active', state.editMode);
- els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off';
+ 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() {
@@ -4191,6 +4572,11 @@
}
function wireEvents() {
+ const bind = (el, type, handler, options) => {
+ if (!el) return;
+ el.addEventListener(type, handler, options);
+ };
+
els.selectedRoomBack?.addEventListener('click', () => {
if (!isMobileViewport()) return;
closeEntityPopup();
@@ -4200,14 +4586,14 @@
renderSelectionOnly();
});
- els.cameraBackdrop.addEventListener('click', (event) => {
+ bind(els.cameraBackdrop, 'click', (event) => {
if (event.target === els.cameraBackdrop) {
apiPost('popup', { command: 'close' }).catch(() => {});
hidePopup({ suppressAutoOpen: true });
}
});
- els.cameraModalPanel.addEventListener('click', (event) => {
+ bind(els.cameraModalPanel, 'click', (event) => {
event.stopPropagation();
});
@@ -4224,8 +4610,8 @@
hidePopup({ suppressAutoOpen: true });
};
- els.cameraClose.addEventListener('pointerdown', closeCameraPopup);
- els.cameraClose.addEventListener('click', closeCameraPopup);
+ bind(els.cameraClose, 'pointerdown', closeCameraPopup);
+ bind(els.cameraClose, 'click', closeCameraPopup);
els.entityBackdrop?.addEventListener('click', (event) => {
if (event.target === els.entityBackdrop) {
@@ -4233,7 +4619,7 @@
}
});
- els.entityModalPanel?.addEventListener('click', (event) => {
+ bind(els.entityModalPanel, 'click', (event) => {
event.stopPropagation();
});
@@ -4247,19 +4633,19 @@
}
});
- els.temperatureSensorModalPanel?.addEventListener('click', (event) => {
+ bind(els.temperatureSensorModalPanel, 'click', (event) => {
event.stopPropagation();
});
- els.temperatureSensorClose?.addEventListener('click', () => {
+ bind(els.temperatureSensorClose, 'click', () => {
closeTemperatureSensorPopup();
});
- els.popupDebugButton?.addEventListener('click', () => {
+ bind(els.popupDebugButton, 'click', () => {
showDebugPopup();
});
- els.editModeToggle.addEventListener('click', async () => {
+ bind(els.editModeToggle, 'click', async () => {
state.editMode = !state.editMode;
try {
await apiPost('save-settings', {
@@ -4380,6 +4766,9 @@
}
function startSnapshotPolling() {
+ if (isHaRuntime()) {
+ return;
+ }
const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000));
if (state.snapshotPollTimer) {
clearInterval(state.snapshotPollTimer);
@@ -4396,6 +4785,9 @@
}
function handleHaMessage(message) {
+ if (isHaRuntime()) {
+ return;
+ }
if (!message || typeof message !== 'object') {
return;
}
@@ -4519,6 +4911,11 @@
}
function connectRealtime() {
+ if (isHaRuntime()) {
+ setStatus('HA native mode', 'online');
+ stopSnapshotPolling();
+ return;
+ }
const connection = haConnection();
const baseUrl = connection.base_url || '';
const token = connection.token || '';
@@ -4572,6 +4969,11 @@
}
async function start() {
+ debugLog('start()', {
+ ha_runtime: isHaRuntime(),
+ embed_mode: Boolean(bootstrap?.ui?.embed),
+ mode: bootstrap?.ui?.mode || 'unknown',
+ });
initRefs();
state.embedMode = detectEmbeddedContext();
syncLayoutState();
@@ -4582,6 +4984,19 @@
state.clockTimer = setInterval(updateClock, 1000);
wireEvents();
+ if (!state.haSnapshotListenerInstalled) {
+ state.haSnapshotListenerInstalled = true;
+ window.addEventListener('wall-panel-snapshot-updated', (event) => {
+ const snapshot = event?.detail?.snapshot || event?.detail || null;
+ if (!snapshot || typeof snapshot !== 'object') {
+ return;
+ }
+ debugLog('wall-panel-snapshot-updated', snapshotSummary(snapshot));
+ state.snapshot = snapshot;
+ render();
+ });
+ }
+
const viewportQuery = mobileViewportQuery();
const handleViewportChange = () => {
syncViewportState();
@@ -4593,8 +5008,8 @@
viewportQuery.addListener(handleViewportChange);
}
- const initial = window.APP_BOOTSTRAP || {};
- state.snapshot = initial;
+ state.snapshot = await resolveInitialSnapshot();
+ debugLog('initial snapshot applied', snapshotSummary(state.snapshot || bootstrap));
render();
connectRealtime();
if (!state.snapshotPollTimer) {
@@ -4602,5 +5017,9 @@
}
}
- document.addEventListener('DOMContentLoaded', start);
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', start);
+ } else {
+ start();
+ }
})();