From 333806e9cde8a1c4cb6fe772e853ebe8399a40c1 Mon Sep 17 00:00:00 2001 From: Striker72rus Date: Wed, 25 Mar 2026 15:01:44 +0300 Subject: [PATCH] - --- README.md | 2 + custom_components/wall_panel/frontend.py | 3 + .../wall_panel/frontend/panel.js | 165 +++++++++++++++--- custom_components/wall_panel/views.py | 26 ++- lib/config.php | 73 ++++++++ run.sh | 2 + storage/popup_state.json | 2 +- wall_panel/README.md | 2 + wall_panel/lib/config.php | 73 ++++++++ wall_panel/run.sh | 2 + 10 files changed, 318 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index a1e4ed6..0d281d2 100755 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ Add-on использует persistent config file: Внутренний runtime cache add-on хранит в `/data/wall_panel`. +Если Home Assistant открыт по `https://`, custom component ждёт ingress URL add-on и встраивает его только после того, как add-on сам его зарегистрирует. Это убирает mixed content и не требует внешнего `http://`-доступа. + ### Старый embed-режим Отдельный PHP-доступ по-прежнему работает: diff --git a/custom_components/wall_panel/frontend.py b/custom_components/wall_panel/frontend.py index a302fbd..e19d25d 100755 --- a/custom_components/wall_panel/frontend.py +++ b/custom_components/wall_panel/frontend.py @@ -14,6 +14,7 @@ from .const import ( CONF_REQUIRE_ADMIN, CONF_SIDEBAR_ICON, CONF_SIDEBAR_TITLE, + CONF_SYNC_TOKEN, DEFAULT_FRONTEND_URL_PATH, DEFAULT_SIDEBAR_ICON, DEFAULT_SIDEBAR_TITLE, @@ -41,6 +42,7 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str: sidebar_icon = str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON).strip() require_admin = bool(entry.options.get(CONF_REQUIRE_ADMIN, False)) panel_url = str(entry.options.get(CONF_PANEL_URL, "") or "").strip() + sync_token = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip() async_register_built_in_panel( hass, @@ -58,6 +60,7 @@ async def async_setup_frontend(hass: HomeAssistant, entry) -> str: "panel_url": panel_url, "panel_url_path": panel_url_path, "entry_id": entry.entry_id, + "sync_token": sync_token, }, } }, diff --git a/custom_components/wall_panel/frontend/panel.js b/custom_components/wall_panel/frontend/panel.js index 0858a3f..42b38f8 100755 --- a/custom_components/wall_panel/frontend/panel.js +++ b/custom_components/wall_panel/frontend/panel.js @@ -4,6 +4,8 @@ class WallPanelPanel extends HTMLElement { this._hass = null; this._panel = null; this._narrow = false; + this._pollTimer = null; + this._activePanelUrl = ''; this.attachShadow({ mode: 'open' }); } @@ -26,6 +28,13 @@ class WallPanelPanel extends HTMLElement { this._render(); } + disconnectedCallback() { + if (this._pollTimer) { + window.clearInterval(this._pollTimer); + this._pollTimer = null; + } + } + _resolveUrl(rawUrl) { const value = String(rawUrl || '').trim(); if (!value) { @@ -46,18 +55,57 @@ class WallPanelPanel extends HTMLElement { } } - _render() { + _panelConfig() { + const config = this._panel?.config || {}; + const customConfig = config._panel_custom || {}; + return customConfig.config || customConfig || config; + } + + _configUrl() { + const payload = this._panelConfig(); + const entryId = String(payload.entry_id || '').trim(); + if (!entryId) { + return ''; + } + return `/api/wall_panel/config/${encodeURIComponent(entryId)}`; + } + + async _fetchPanelUrl() { + const payload = this._panelConfig(); + const configUrl = this._configUrl(); + const syncToken = String(payload.sync_token || '').trim(); + if (!configUrl || !syncToken) { + return ''; + } + + try { + const response = await fetch(configUrl, { + method: 'GET', + headers: { + 'X-Wall-Panel-Token': syncToken, + Accept: 'application/json', + }, + credentials: 'same-origin', + cache: 'no-store', + }); + + if (!response.ok) { + return ''; + } + + const data = await response.json(); + const panelUrl = String(data?.panel?.panel_url || data?.panel_url || '').trim(); + return panelUrl; + } catch (error) { + return ''; + } + } + + _renderMessage(title, body, extra = '') { if (!this.shadowRoot) { return; } - const config = this._panel?.config || {}; - const customConfig = config._panel_custom || {}; - const payload = customConfig.config || customConfig || config; - const panelUrl = this._resolveUrl( - payload.panel_url || config.panel_url || customConfig.panel_url || '' - ); - this.shadowRoot.innerHTML = ` -
+
+
+
+ ${title} +
${body}
+ ${extra} +
+
+
`; + } - const wrap = this.shadowRoot.querySelector('.wrap'); - if (!wrap) { + _renderIframe(panelUrl) { + if (!this.shadowRoot) { return; } - if (!panelUrl) { - wrap.innerHTML = ` -
-
- Wall Panel is not configured -
Set the PHP panel URL in the integration options.
-
-
- `; + const wrap = this.shadowRoot.querySelector('.wrap'); + if (!wrap) { return; } @@ -124,7 +176,62 @@ class WallPanelPanel extends HTMLElement { 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'; wrap.replaceChildren(iframe); + this._activePanelUrl = panelUrl; + } + + async _tryAttachPanel() { + const payload = this._panelConfig(); + const initialUrl = this._resolveUrl( + payload.panel_url || payload.ingress_url || '' + ); + + if (initialUrl && initialUrl === this._activePanelUrl) { + return; + } + + if (initialUrl && !(window.location.protocol === 'https:' && initialUrl.startsWith('http://'))) { + this._renderIframe(initialUrl); + return; + } + + const panelUrl = this._resolveUrl(await this._fetchPanelUrl()); + if (panelUrl && panelUrl === this._activePanelUrl) { + return; + } + + if (panelUrl && !(window.location.protocol === 'https:' && panelUrl.startsWith('http://'))) { + this._renderIframe(panelUrl); + return; + } + + const configUrl = this._configUrl(); + this._renderMessage( + 'Waiting for Wall Panel', + 'The add-on will publish a secure HTTPS ingress URL here.', + configUrl ? `${configUrl}` : '' + ); + } + + _render() { + if (!this.shadowRoot) { + return; + } + + if (this._pollTimer) { + window.clearInterval(this._pollTimer); + this._pollTimer = null; + } + + this._tryAttachPanel(); + this._pollTimer = window.setInterval(() => { + this._tryAttachPanel(); + }, 2000); } } diff --git a/custom_components/wall_panel/views.py b/custom_components/wall_panel/views.py index a8b4893..794db1d 100755 --- a/custom_components/wall_panel/views.py +++ b/custom_components/wall_panel/views.py @@ -9,12 +9,13 @@ from aiohttp import web from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant -from .const import CONF_CONFIG, CONF_SYNC_TOKEN, DOMAIN +from .const import CONF_CONFIG, CONF_PANEL_URL, CONF_SYNC_TOKEN, DOMAIN from .helpers import ( build_patch_payload, config_to_json, create_room_layout_item, current_entry_config, + current_entry_panel, delete_room_layout_item, normalize_config, reorder_room_grid, @@ -63,6 +64,13 @@ def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> No hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["config"] = config +def _save_entry_panel_url(hass: HomeAssistant, entry, panel_url: str) -> None: + options = dict(entry.options) + options[CONF_PANEL_URL] = panel_url + hass.config_entries.async_update_entry(entry, options=options) + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["panel_url"] = panel_url + + class WallPanelConfigView(HomeAssistantView): """Serve and update the canonical Wall Panel config.""" @@ -79,7 +87,11 @@ class WallPanelConfigView(HomeAssistantView): return _response({"ok": False, "error": "Unauthorized"}, 401) config = current_entry_config(entry) - return _response(config) + return _response({ + "ok": True, + "config": config, + "panel": current_entry_panel(entry), + }) async def async_post(self, request: web.Request, entry_id: str) -> web.Response: hass = request.app["hass"] @@ -148,6 +160,15 @@ class WallPanelConfigView(HomeAssistantView): if not room_id or not isinstance(entries, list): return _response({"ok": False, "error": "room_id and entries are required"}, 400) config = reorder_room_grid(config, room_id, entries) + elif action == "register-panel": + panel_url = str(action_payload.get("panel_url", "") or "").strip() + if not panel_url: + return _response({"ok": False, "error": "panel_url is required"}, 400) + _save_entry_panel_url(hass, entry, panel_url) + return _response({ + "ok": True, + "panel": current_entry_panel(entry), + }) elif isinstance(payload.get("config"), dict): config = normalize_config(payload["config"]) else: @@ -157,4 +178,5 @@ class WallPanelConfigView(HomeAssistantView): return _response({ "ok": True, "config": config, + "panel": current_entry_panel(entry), }) diff --git a/lib/config.php b/lib/config.php index 0c60441..f7cb050 100755 --- a/lib/config.php +++ b/lib/config.php @@ -168,6 +168,11 @@ function app_remote_sync_enabled(array $config): bool return $syncUrl !== '' && $syncToken !== ''; } +function app_panel_registration_cache_path(): string +{ + return app_storage_path('panel_registration_cache.json'); +} + function app_remote_sync_cache_path(): string { return app_storage_path('ha_sync_cache.json'); @@ -258,6 +263,74 @@ function app_merge_synced_config(array $baseConfig, array $syncedConfig): array return $merged; } +function app_supervisor_addon_slug(): string +{ + $override = trim((string)getenv('WALL_PANEL_ADDON_SLUG')); + if ($override !== '') { + return $override; + } + + return 'wall_panel'; +} + +function app_supervisor_ingress_url(): string +{ + $token = trim((string)getenv('SUPERVISOR_TOKEN')); + if ($token === '') { + return ''; + } + + $url = 'http://supervisor/addons/' . rawurlencode(app_supervisor_addon_slug()) . '/info'; + $response = app_http_json_request('GET', $url, [ + 'Authorization: Bearer ' . $token, + ], null, 10, false, false); + + if (!is_array($response)) { + return ''; + } + + return trim((string)($response['ingress_url'] ?? '')); +} + +function app_register_ingress_url(array $config): void +{ + if (!app_remote_sync_enabled($config)) { + return; + } + + $ingressUrl = app_supervisor_ingress_url(); + if ($ingressUrl === '') { + return; + } + + $syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? '')); + $syncToken = trim((string)($config['home_assistant']['sync_token'] ?? '')); + $cacheKey = hash('sha256', $ingressUrl . '|' . $syncUrl . '|' . $syncToken); + + $cachePath = app_panel_registration_cache_path(); + $cache = app_load_json_file($cachePath, []); + if (trim((string)($cache['cache_key'] ?? '')) === $cacheKey) { + return; + } + + $response = app_http_json_request('POST', $syncUrl, [ + 'X-Wall-Panel-Token: ' . $syncToken, + ], [ + 'action' => 'register-panel', + 'payload' => [ + 'panel_url' => $ingressUrl, + ], + ], max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10)), (bool)($config['home_assistant']['sync_verify_ssl'] ?? true), false); + + if (is_array($response) && (bool)($response['ok'] ?? false)) { + app_save_json_file($cachePath, [ + 'cache_key' => $cacheKey, + 'ingress_url' => $ingressUrl, + 'registered_at' => time(), + ]); + } +} + function app_load_remote_config(array $config, bool $refresh = false): array|null { if (!app_remote_sync_enabled($config)) { diff --git a/run.sh b/run.sh index a4e925c..d6da18f 100755 --- a/run.sh +++ b/run.sh @@ -16,4 +16,6 @@ export WALL_PANEL_CONFIG_PATH="$CONFIG_PATH" export WALL_PANEL_STORAGE_DIR="$STORAGE_DIR" export WALL_PANEL_RUNTIME_MODE="addon" +php -r 'require "/app/lib/bootstrap.php"; $config = app_load_config(); app_register_ingress_url($config);' >/dev/null 2>&1 || true & + exec php -S "0.0.0.0:${PORT}" -t "$DOCROOT" diff --git a/storage/popup_state.json b/storage/popup_state.json index f24652b..b9e994f 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": 1774436558, + "opened_at": 1774439958, "expires_at": null } diff --git a/wall_panel/README.md b/wall_panel/README.md index 51da1fa..2583272 100755 --- a/wall_panel/README.md +++ b/wall_panel/README.md @@ -84,6 +84,8 @@ Add-on использует persistent config file: Внутренний runtime cache add-on хранит в `/data/wall_panel`. +Если Home Assistant открыт по `https://`, custom component ждёт ingress URL add-on и встраивает его только после того, как add-on сам его зарегистрирует. Это убирает mixed content и не требует внешнего `http://`-доступа. + ### Старый embed-режим Отдельный PHP-доступ по-прежнему работает: diff --git a/wall_panel/lib/config.php b/wall_panel/lib/config.php index 0c60441..f7cb050 100755 --- a/wall_panel/lib/config.php +++ b/wall_panel/lib/config.php @@ -168,6 +168,11 @@ function app_remote_sync_enabled(array $config): bool return $syncUrl !== '' && $syncToken !== ''; } +function app_panel_registration_cache_path(): string +{ + return app_storage_path('panel_registration_cache.json'); +} + function app_remote_sync_cache_path(): string { return app_storage_path('ha_sync_cache.json'); @@ -258,6 +263,74 @@ function app_merge_synced_config(array $baseConfig, array $syncedConfig): array return $merged; } +function app_supervisor_addon_slug(): string +{ + $override = trim((string)getenv('WALL_PANEL_ADDON_SLUG')); + if ($override !== '') { + return $override; + } + + return 'wall_panel'; +} + +function app_supervisor_ingress_url(): string +{ + $token = trim((string)getenv('SUPERVISOR_TOKEN')); + if ($token === '') { + return ''; + } + + $url = 'http://supervisor/addons/' . rawurlencode(app_supervisor_addon_slug()) . '/info'; + $response = app_http_json_request('GET', $url, [ + 'Authorization: Bearer ' . $token, + ], null, 10, false, false); + + if (!is_array($response)) { + return ''; + } + + return trim((string)($response['ingress_url'] ?? '')); +} + +function app_register_ingress_url(array $config): void +{ + if (!app_remote_sync_enabled($config)) { + return; + } + + $ingressUrl = app_supervisor_ingress_url(); + if ($ingressUrl === '') { + return; + } + + $syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? '')); + $syncToken = trim((string)($config['home_assistant']['sync_token'] ?? '')); + $cacheKey = hash('sha256', $ingressUrl . '|' . $syncUrl . '|' . $syncToken); + + $cachePath = app_panel_registration_cache_path(); + $cache = app_load_json_file($cachePath, []); + if (trim((string)($cache['cache_key'] ?? '')) === $cacheKey) { + return; + } + + $response = app_http_json_request('POST', $syncUrl, [ + 'X-Wall-Panel-Token: ' . $syncToken, + ], [ + 'action' => 'register-panel', + 'payload' => [ + 'panel_url' => $ingressUrl, + ], + ], max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10)), (bool)($config['home_assistant']['sync_verify_ssl'] ?? true), false); + + if (is_array($response) && (bool)($response['ok'] ?? false)) { + app_save_json_file($cachePath, [ + 'cache_key' => $cacheKey, + 'ingress_url' => $ingressUrl, + 'registered_at' => time(), + ]); + } +} + function app_load_remote_config(array $config, bool $refresh = false): array|null { if (!app_remote_sync_enabled($config)) { diff --git a/wall_panel/run.sh b/wall_panel/run.sh index 5576d23..928aa3f 100755 --- a/wall_panel/run.sh +++ b/wall_panel/run.sh @@ -16,4 +16,6 @@ export WALL_PANEL_CONFIG_PATH="$CONFIG_PATH" export WALL_PANEL_STORAGE_DIR="$STORAGE_DIR" export WALL_PANEL_RUNTIME_MODE="addon" +php -r 'require "/app/lib/bootstrap.php"; $config = app_load_config(); app_register_ingress_url($config);' >/dev/null 2>&1 || true & + exec php -S "0.0.0.0:${PORT}" -t "$DOCROOT"