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 = ` -
+${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"