From cedb9ed432ec9730bd98a6d3e1f0d4fbdb8ef8b6 Mon Sep 17 00:00:00 2001 From: Striker72rus Date: Wed, 25 Mar 2026 16:23:11 +0300 Subject: [PATCH] - --- custom_components/wall_panel/__init__.py | 37 +- .../wall_panel/frontend/panel.js | 4 +- custom_components/wall_panel/views.py | 448 ++++++++++-------- storage/popup_state.json | 8 +- 4 files changed, 271 insertions(+), 226 deletions(-) diff --git a/custom_components/wall_panel/__init__.py b/custom_components/wall_panel/__init__.py index ff7ede0..d931c09 100755 --- a/custom_components/wall_panel/__init__.py +++ b/custom_components/wall_panel/__init__.py @@ -2,12 +2,33 @@ from __future__ import annotations +import logging + from homeassistant.core import HomeAssistant -from .const import CONF_CONFIG, DOMAIN +from .const import DOMAIN from .frontend import async_setup_frontend from .helpers import current_entry_config -from .views import WallPanelConfigView, WallPanelPanelView, WallPanelProxyView +from .views import handle_config_request, handle_panel_request, handle_proxy_request + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the integration-level HTTP views.""" + + state = hass.data.setdefault(DOMAIN, {}) + if not state.get("_direct_routes_registered"): + app = hass.http.app + app.router.add_route("*", "/api/wall_panel/config/{entry_id}", handle_config_request) + app.router.add_route("*", "/api/wall_panel/panel", handle_panel_request) + app.router.add_route("*", "/api/wall_panel/proxy/{entry_id}/{path:.*}", handle_proxy_request) + state["_direct_routes_registered"] = True + _LOGGER.warning("Wall Panel direct routes registered") + + _LOGGER.warning("Wall Panel integration views registered") + return True async def async_setup_entry(hass: HomeAssistant, entry) -> bool: @@ -22,18 +43,6 @@ async def async_setup_entry(hass: HomeAssistant, entry) -> bool: panel_url_path = await async_setup_frontend(hass, entry) state[entry.entry_id]["panel_url_path"] = panel_url_path - if not state.get("_config_view_registered"): - hass.http.register_view(WallPanelConfigView) - state["_config_view_registered"] = True - - if not state.get("_panel_view_registered"): - hass.http.register_view(WallPanelPanelView) - state["_panel_view_registered"] = True - - if not state.get("_proxy_view_registered"): - hass.http.register_view(WallPanelProxyView) - state["_proxy_view_registered"] = True - entry.async_on_unload(entry.add_update_listener(_async_options_updated)) return True diff --git a/custom_components/wall_panel/frontend/panel.js b/custom_components/wall_panel/frontend/panel.js index fa6110b..32078f9 100755 --- a/custom_components/wall_panel/frontend/panel.js +++ b/custom_components/wall_panel/frontend/panel.js @@ -170,8 +170,10 @@ class WallPanelPanel extends HTMLElement { iframe.style.border = '0'; iframe.style.display = 'block'; iframe.style.background = 'transparent'; + iframe.addEventListener('load', () => { + this._activePanelUrl = panelUrl; + }); wrap.replaceChildren(iframe); - this._activePanelUrl = panelUrl; } async _tryAttachPanel() { diff --git a/custom_components/wall_panel/views.py b/custom_components/wall_panel/views.py index 9aa604b..6009fd4 100755 --- a/custom_components/wall_panel/views.py +++ b/custom_components/wall_panel/views.py @@ -3,6 +3,7 @@ from __future__ import annotations import secrets +import logging from urllib.parse import urljoin from typing import Any @@ -29,6 +30,9 @@ from .helpers import ( ) +_LOGGER = logging.getLogger(__name__) + + def _entry_from_hass(hass: HomeAssistant, entry_id: str): return hass.data.get(DOMAIN, {}).get(entry_id, {}).get("entry") @@ -77,6 +81,224 @@ def _response(data: Any, status: int = 200) -> web.Response: return web.json_response(data, status=status) +async def _handle_config_request(request: web.Request, entry_id: str) -> web.Response: + """Handle canonical config requests for a specific config entry.""" + + hass = request.app["hass"] + entry = _entry_from_hass(hass, entry_id) + if entry is None: + return _response({"ok": False, "error": "Unknown entry"}, 404) + if not _authorized(entry, request): + return _response({"ok": False, "error": "Unauthorized"}, 401) + + if request.method == "GET": + config = current_entry_config(entry) + return _response({ + "ok": True, + "config": config, + "panel": current_entry_panel(entry), + }) + + payload = await request.json() + if not isinstance(payload, dict): + return _response({"ok": False, "error": "Invalid payload"}, 400) + + config = current_entry_config(entry) + action = str(payload.get("action", "") or "").strip().lower() + action_payload = payload.get("payload") + if not isinstance(action_payload, dict): + action_payload = payload + + if action == "save-settings": + config = save_settings(config, action_payload) + elif action == "save-entity-override": + room_id = str(action_payload.get("room_id", "") or "").strip() + entity_id = str(action_payload.get("entity_id", "") or "").strip() + if not room_id or not entity_id: + return _response({"ok": False, "error": "room_id and entity_id are required"}, 400) + patch = build_patch_payload(action_payload, ["visible", "order", "card_type", "title", "icon"]) + config = update_entity_override(config, room_id, entity_id, patch) + elif action == "save-space-override": + room_id = str(action_payload.get("room_id", "") or "").strip() + if not room_id: + return _response({"ok": False, "error": "room_id is required"}, 400) + patch = build_patch_payload(action_payload, ["visible", "order", "name", "icon", "temperature_sensor_entity_id"]) + config = update_room_override(config, room_id, patch) + elif action == "create-room-layout-item": + room_id = str(action_payload.get("room_id", "") or "").strip() + if not room_id: + return _response({"ok": False, "error": "room_id is required"}, 400) + layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip() + if not layout_item_id: + layout_item_id = f"slot_{secrets.token_hex(12)}" + order = action_payload.get("order") + config = create_room_layout_item(config, room_id, layout_item_id, int(order) if order is not None else None) + _save_entry_config(hass, entry, config) + return _response({ + "ok": True, + "layout_item_id": layout_item_id, + "config": config, + }) + elif action == "save-room-layout-item": + room_id = str(action_payload.get("room_id", "") or "").strip() + layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip() + if not room_id or not layout_item_id: + return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400) + patch = build_patch_payload(action_payload, ["order"]) + config = update_room_layout_item(config, room_id, layout_item_id, patch) + elif action == "delete-room-layout-item": + room_id = str(action_payload.get("room_id", "") or "").strip() + layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip() + if not room_id or not layout_item_id: + return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400) + config = delete_room_layout_item(config, room_id, layout_item_id) + elif action == "reorder-room-grid": + room_id = str(action_payload.get("room_id", "") or "").strip() + entries = action_payload.get("entries", []) + 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: + return _response({"ok": False, "error": "Unknown action"}, 404) + + _save_entry_config(hass, entry, config) + return _response({ + "ok": True, + "config": config, + "panel": current_entry_panel(entry), + }) + + +async def handle_config_request(request: web.Request) -> web.Response: + """Handle canonical config requests for a specific config entry.""" + + entry_id = str(request.match_info.get("entry_id", "") or "").strip() + return await _handle_config_request(request, entry_id) + + +async def handle_panel_request(request: web.Request) -> web.Response: + """Handle the panel config endpoint without requiring an entry id.""" + + hass = request.app["hass"] + entry = _default_entry_from_hass(hass) + if entry is None: + return _response({"ok": False, "error": "Unknown entry"}, 404) + if not _authorized(entry, request): + return _response({"ok": False, "error": "Unauthorized"}, 401) + + if request.method == "GET": + config = current_entry_config(entry) + return _response({ + "ok": True, + "config": config, + "panel": current_entry_panel(entry), + }) + + payload = await request.json() + if not isinstance(payload, dict): + return _response({"ok": False, "error": "Invalid payload"}, 400) + + action = str(payload.get("action", "") or "").strip().lower() + if action != "register-panel": + return _response({"ok": False, "error": "Unknown action"}, 404) + + action_payload = payload.get("payload") + if not isinstance(action_payload, dict): + action_payload = payload + + 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), + }) + + +async def _handle_proxy_request(request: web.Request, entry_id: str, path: str, method: str) -> web.StreamResponse: + """Handle reverse-proxy requests to the PHP panel.""" + + hass = request.app["hass"] + entry = _entry_from_hass(hass, entry_id) + if entry is None: + return _response({"ok": False, "error": "Unknown entry"}, 404) + + base_url = str(entry.options.get(CONF_PANEL_URL, "") or "").strip() + if not base_url: + _LOGGER.warning("Wall Panel proxy denied for %s: panel_url is empty", entry_id) + return _response({"ok": False, "error": "PHP panel URL is not configured"}, 400) + if not base_url.endswith("/"): + base_url += "/" + + upstream_url = urljoin(base_url, path or "") + if request.query_string: + upstream_url = upstream_url + "?" + request.query_string + + _LOGGER.warning("Wall Panel proxy %s %s -> %s", method, request.path_qs, upstream_url) + + body = None + if method not in {"GET", "HEAD"}: + body = await request.read() + + session = async_get_clientsession(hass) + async with session.request( + method, + upstream_url, + headers=_forward_headers(request), + data=body, + allow_redirects=False, + ) as upstream: + response_headers: dict[str, str] = {} + for key, value in upstream.headers.items(): + lower = key.lower() + if lower in { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "content-encoding", + }: + continue + if lower == "location": + response_headers[key] = _rewrite_location(value, base_url, _proxy_root(entry_id)) + continue + response_headers[key] = value + + response_body = await upstream.read() + _LOGGER.warning("Wall Panel upstream %s %s", upstream.status, upstream_url) + return web.Response( + body=response_body, + status=upstream.status, + headers=response_headers, + ) + + +async def handle_proxy_request(request: web.Request) -> web.StreamResponse: + """Handle reverse-proxy requests to the PHP panel.""" + + entry_id = str(request.match_info.get("entry_id", "") or "").strip() + path = str(request.match_info.get("path", "") or "").strip() + method = request.method.upper() + return await _handle_proxy_request(request, entry_id, path, method) + + def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> None: options = dict(entry.options) options[CONF_CONFIG] = config @@ -89,6 +311,7 @@ def _save_entry_panel_url(hass: HomeAssistant, entry, panel_url: str) -> None: 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 + _LOGGER.warning("Wall Panel panel_url updated for %s: %s", entry.entry_id, panel_url) def _proxy_root(entry_id: str) -> str: @@ -126,108 +349,11 @@ class WallPanelConfigView(HomeAssistantView): name = "api:wall_panel:config" requires_auth = False - async def async_get(self, request: web.Request, entry_id: str) -> web.Response: - hass = request.app["hass"] - entry = _entry_from_hass(hass, entry_id) - if entry is None: - return _response({"ok": False, "error": "Unknown entry"}, 404) - if not _authorized(entry, request): - return _response({"ok": False, "error": "Unauthorized"}, 401) + async def get(self, request: web.Request, entry_id: str) -> web.Response: + return await _handle_config_request(request, entry_id) - config = current_entry_config(entry) - 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"] - entry = _entry_from_hass(hass, entry_id) - if entry is None: - return _response({"ok": False, "error": "Unknown entry"}, 404) - if not _authorized(entry, request): - return _response({"ok": False, "error": "Unauthorized"}, 401) - - payload = await request.json() - if not isinstance(payload, dict): - return _response({"ok": False, "error": "Invalid payload"}, 400) - - config = current_entry_config(entry) - action = str(payload.get("action", "") or "").strip().lower() - action_payload = payload.get("payload") - if not isinstance(action_payload, dict): - action_payload = payload - - if action == "save-settings": - config = save_settings(config, action_payload) - elif action == "save-entity-override": - room_id = str(action_payload.get("room_id", "") or "").strip() - entity_id = str(action_payload.get("entity_id", "") or "").strip() - if not room_id or not entity_id: - return _response({"ok": False, "error": "room_id and entity_id are required"}, 400) - patch = build_patch_payload(action_payload, ["visible", "order", "card_type", "title", "icon"]) - config = update_entity_override(config, room_id, entity_id, patch) - elif action == "save-space-override": - room_id = str(action_payload.get("room_id", "") or "").strip() - if not room_id: - return _response({"ok": False, "error": "room_id is required"}, 400) - patch = build_patch_payload(action_payload, ["visible", "order", "name", "icon", "temperature_sensor_entity_id"]) - config = update_room_override(config, room_id, patch) - elif action == "create-room-layout-item": - room_id = str(action_payload.get("room_id", "") or "").strip() - if not room_id: - return _response({"ok": False, "error": "room_id is required"}, 400) - layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip() - if not layout_item_id: - layout_item_id = f"slot_{secrets.token_hex(12)}" - order = action_payload.get("order") - config = create_room_layout_item(config, room_id, layout_item_id, int(order) if order is not None else None) - _save_entry_config(hass, entry, config) - return _response({ - "ok": True, - "layout_item_id": layout_item_id, - "config": config, - }) - elif action == "save-room-layout-item": - room_id = str(action_payload.get("room_id", "") or "").strip() - layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip() - if not room_id or not layout_item_id: - return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400) - patch = build_patch_payload(action_payload, ["order"]) - config = update_room_layout_item(config, room_id, layout_item_id, patch) - elif action == "delete-room-layout-item": - room_id = str(action_payload.get("room_id", "") or "").strip() - layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip() - if not room_id or not layout_item_id: - return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400) - config = delete_room_layout_item(config, room_id, layout_item_id) - elif action == "reorder-room-grid": - room_id = str(action_payload.get("room_id", "") or "").strip() - entries = action_payload.get("entries", []) - 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: - return _response({"ok": False, "error": "Unknown action"}, 404) - - _save_entry_config(hass, entry, config) - return _response({ - "ok": True, - "config": config, - "panel": current_entry_panel(entry), - }) + async def post(self, request: web.Request, entry_id: str) -> web.Response: + return await _handle_config_request(request, entry_id) class WallPanelPanelView(HomeAssistantView): @@ -237,48 +363,11 @@ class WallPanelPanelView(HomeAssistantView): name = "api:wall_panel:panel" requires_auth = False - async def async_get(self, request: web.Request) -> web.Response: - hass = request.app["hass"] - entry = _default_entry_from_hass(hass) - if entry is None: - return _response({"ok": False, "error": "Unknown entry"}, 404) - if not _authorized(entry, request): - return _response({"ok": False, "error": "Unauthorized"}, 401) + async def get(self, request: web.Request) -> web.Response: + return await handle_panel_request(request) - config = current_entry_config(entry) - return _response({ - "ok": True, - "config": config, - "panel": current_entry_panel(entry), - }) - - async def async_post(self, request: web.Request) -> web.Response: - hass = request.app["hass"] - entry = _default_entry_from_hass(hass) - if entry is None: - return _response({"ok": False, "error": "Unknown entry"}, 404) - - payload = await request.json() - if not isinstance(payload, dict): - return _response({"ok": False, "error": "Invalid payload"}, 400) - - action = str(payload.get("action", "") or "").strip().lower() - if action != "register-panel": - return _response({"ok": False, "error": "Unknown action"}, 404) - - action_payload = payload.get("payload") - if not isinstance(action_payload, dict): - action_payload = payload - - 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), - }) + async def post(self, request: web.Request) -> web.Response: + return await handle_panel_request(request) class WallPanelProxyView(HomeAssistantView): @@ -288,72 +377,17 @@ class WallPanelProxyView(HomeAssistantView): name = "api:wall_panel:proxy" requires_auth = True - async def _proxy(self, request: web.Request, entry_id: str, path: str, method: str) -> web.StreamResponse: - hass = request.app["hass"] - entry = _entry_from_hass(hass, entry_id) - if entry is None: - return _response({"ok": False, "error": "Unknown entry"}, 404) + async def get(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse: + return await _handle_proxy_request(request, entry_id, path, "GET") - base_url = str(entry.options.get(CONF_PANEL_URL, "") or "").strip() - if not base_url: - return _response({"ok": False, "error": "PHP panel URL is not configured"}, 400) - if not base_url.endswith("/"): - base_url += "/" + async def post(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse: + return await _handle_proxy_request(request, entry_id, path, "POST") - upstream_url = urljoin(base_url, path or "") - if request.query_string: - upstream_url = upstream_url + "?" + request.query_string + async def put(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse: + return await _handle_proxy_request(request, entry_id, path, "PUT") - body = None - if method not in {"GET", "HEAD"}: - body = await request.read() + async def patch(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse: + return await _handle_proxy_request(request, entry_id, path, "PATCH") - session = async_get_clientsession(hass) - async with session.request( - method, - upstream_url, - headers=_forward_headers(request), - data=body, - allow_redirects=False, - ) as upstream: - response_headers: dict[str, str] = {} - for key, value in upstream.headers.items(): - lower = key.lower() - if lower in { - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailers", - "transfer-encoding", - "upgrade", - "content-encoding", - }: - continue - if lower == "location": - response_headers[key] = _rewrite_location(value, base_url, _proxy_root(entry_id)) - continue - response_headers[key] = value - - response_body = await upstream.read() - return web.Response( - body=response_body, - status=upstream.status, - headers=response_headers, - ) - - async def async_get(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse: - return await self._proxy(request, entry_id, path, "GET") - - async def async_post(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse: - return await self._proxy(request, entry_id, path, "POST") - - async def async_put(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse: - return await self._proxy(request, entry_id, path, "PUT") - - async def async_patch(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse: - return await self._proxy(request, entry_id, path, "PATCH") - - async def async_delete(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse: - return await self._proxy(request, entry_id, path, "DELETE") + async def delete(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse: + return await _handle_proxy_request(request, entry_id, path, "DELETE") diff --git a/storage/popup_state.json b/storage/popup_state.json index 70e4a92..82a565d 100755 --- a/storage/popup_state.json +++ b/storage/popup_state.json @@ -1,6 +1,6 @@ { - "active": false, - "sensor_entity_id": "binary_sensor.doorbell_all_occupancy", - "opened_at": 1774443242, - "expires_at": null + "active": true, + "sensor_entity_id": "binary_sensor.barn_all_occupancy", + "opened_at": 1774444953, + "expires_at": 1774444995 }