This commit is contained in:
Striker72rus 2026-03-25 16:23:11 +03:00
parent f664f325af
commit cedb9ed432
4 changed files with 271 additions and 226 deletions

View File

@ -2,12 +2,33 @@
from __future__ import annotations from __future__ import annotations
import logging
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import CONF_CONFIG, DOMAIN from .const import DOMAIN
from .frontend import async_setup_frontend from .frontend import async_setup_frontend
from .helpers import current_entry_config from .helpers import current_entry_config
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: 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) panel_url_path = await async_setup_frontend(hass, entry)
state[entry.entry_id]["panel_url_path"] = panel_url_path 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)) entry.async_on_unload(entry.add_update_listener(_async_options_updated))
return True return True

View File

@ -170,8 +170,10 @@ class WallPanelPanel extends HTMLElement {
iframe.style.border = '0'; iframe.style.border = '0';
iframe.style.display = 'block'; iframe.style.display = 'block';
iframe.style.background = 'transparent'; iframe.style.background = 'transparent';
iframe.addEventListener('load', () => {
this._activePanelUrl = panelUrl;
});
wrap.replaceChildren(iframe); wrap.replaceChildren(iframe);
this._activePanelUrl = panelUrl;
} }
async _tryAttachPanel() { async _tryAttachPanel() {

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import secrets import secrets
import logging
from urllib.parse import urljoin from urllib.parse import urljoin
from typing import Any from typing import Any
@ -29,6 +30,9 @@ from .helpers import (
) )
_LOGGER = logging.getLogger(__name__)
def _entry_from_hass(hass: HomeAssistant, entry_id: str): def _entry_from_hass(hass: HomeAssistant, entry_id: str):
return hass.data.get(DOMAIN, {}).get(entry_id, {}).get("entry") 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) 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: def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> None:
options = dict(entry.options) options = dict(entry.options)
options[CONF_CONFIG] = config 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 options[CONF_PANEL_URL] = panel_url
hass.config_entries.async_update_entry(entry, options=options) hass.config_entries.async_update_entry(entry, options=options)
hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["panel_url"] = panel_url 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: def _proxy_root(entry_id: str) -> str:
@ -126,108 +349,11 @@ class WallPanelConfigView(HomeAssistantView):
name = "api:wall_panel:config" name = "api:wall_panel:config"
requires_auth = False requires_auth = False
async def async_get(self, request: web.Request, entry_id: str) -> web.Response: async def get(self, request: web.Request, entry_id: str) -> web.Response:
hass = request.app["hass"] return await _handle_config_request(request, entry_id)
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)
config = current_entry_config(entry) async def post(self, request: web.Request, entry_id: str) -> web.Response:
return _response({ return await _handle_config_request(request, entry_id)
"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),
})
class WallPanelPanelView(HomeAssistantView): class WallPanelPanelView(HomeAssistantView):
@ -237,48 +363,11 @@ class WallPanelPanelView(HomeAssistantView):
name = "api:wall_panel:panel" name = "api:wall_panel:panel"
requires_auth = False requires_auth = False
async def async_get(self, request: web.Request) -> web.Response: async def get(self, request: web.Request) -> web.Response:
hass = request.app["hass"] return await handle_panel_request(request)
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)
config = current_entry_config(entry) async def post(self, request: web.Request) -> web.Response:
return _response({ return await handle_panel_request(request)
"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),
})
class WallPanelProxyView(HomeAssistantView): class WallPanelProxyView(HomeAssistantView):
@ -288,72 +377,17 @@ class WallPanelProxyView(HomeAssistantView):
name = "api:wall_panel:proxy" name = "api:wall_panel:proxy"
requires_auth = True requires_auth = True
async def _proxy(self, request: web.Request, entry_id: str, path: str, method: str) -> web.StreamResponse: async def get(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse:
hass = request.app["hass"] return await _handle_proxy_request(request, entry_id, path, "GET")
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() async def post(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse:
if not base_url: return await _handle_proxy_request(request, entry_id, path, "POST")
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 "") async def put(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse:
if request.query_string: return await _handle_proxy_request(request, entry_id, path, "PUT")
upstream_url = upstream_url + "?" + request.query_string
body = None async def patch(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse:
if method not in {"GET", "HEAD"}: return await _handle_proxy_request(request, entry_id, path, "PATCH")
body = await request.read()
session = async_get_clientsession(hass) async def delete(self, request: web.Request, entry_id: str, path: str = "") -> web.StreamResponse:
async with session.request( return await _handle_proxy_request(request, entry_id, path, "DELETE")
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")

View File

@ -1,6 +1,6 @@
{ {
"active": false, "active": true,
"sensor_entity_id": "binary_sensor.doorbell_all_occupancy", "sensor_entity_id": "binary_sensor.barn_all_occupancy",
"opened_at": 1774443242, "opened_at": 1774444953,
"expires_at": null "expires_at": 1774444995
} }