Files
2026-09-08 00:31:25 +03:00

242 lines
8.4 KiB
Python

"""Register the House Plan application panel with strict lifecycle ownership."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from typing import Literal
from homeassistant.components import frontend, panel_custom
from homeassistant.core import HomeAssistant, callback
from .const import (
DOMAIN,
PANEL_COMPONENT_NAME,
PANEL_FRONTEND_URL,
PANEL_ICON,
PANEL_TITLE,
PANEL_URL,
PANEL_URL_PATH,
VERSION,
)
from .frontend_registration import async_register_frontend_static_path
from .store import HouseplanConfigEntry
_LOGGER = logging.getLogger(__name__)
PANEL_REGISTRATION_KEY = "panel_registration"
PANEL_GENERATION_KEY = "panel_generation"
PanelStatus = Literal[
"not_attempted",
"registered",
"missing_asset",
"collision",
"static_error",
"registration_error",
"ownership_error",
"removed",
]
@dataclass(slots=True)
class PanelRegistrationState:
"""Observable state for one config-entry setup generation."""
panel_file_present: bool
generation: int
panel_static_path_registered: bool = False
panel_status: PanelStatus = "not_attempted"
panel_url: str | None = None
panel_module_url: str | None = None
panel_error: str | None = None
_owned_panel: object | None = field(default=None, repr=False)
def _safe_error(phase: str, err: BaseException) -> str:
"""Return a support-safe fingerprint without an exception message."""
return f"{phase}:{type(err).__name__}"
def _panel_registry(hass: HomeAssistant) -> Mapping[str, object] | None:
"""Read HA's in-memory panel registry without mutating private storage."""
key = getattr(frontend, "DATA_PANELS", None)
if not isinstance(key, str):
return None
panels = hass.data.get(key)
return panels if isinstance(panels, Mapping) else None
def _is_current(hass: HomeAssistant, state: PanelRegistrationState) -> bool:
"""Return whether state still owns the current setup generation."""
domain_data = hass.data.get(DOMAIN, {})
return (
domain_data.get(PANEL_REGISTRATION_KEY) is state
and domain_data.get(PANEL_GENERATION_KEY) == state.generation
)
@callback
def _remove_owned_panel(hass: HomeAssistant, state: PanelRegistrationState) -> None:
"""Remove only the exact registry object owned by this generation."""
if not _is_current(hass, state) or state._owned_panel is None:
return
panels = _panel_registry(hass)
if panels is None:
# An unknown registry shape is not authority to remove a route by name.
return
if panels.get(PANEL_URL_PATH) is not state._owned_panel:
# The route was removed or replaced outside this integration. It is no
# longer ours, and a later cleanup must not reconsider that identity.
state._owned_panel = None
state.panel_status = "removed"
state.panel_url = None
state.panel_module_url = None
return
try:
# Keep the HA 2024.6-compatible two-argument call. In particular, do
# not pass the newer warn_if_unknown keyword.
frontend.async_remove_panel(hass, PANEL_URL_PATH)
except Exception as err: # noqa: BLE001 - unload remains fail-soft
fingerprint = _safe_error("remove", err)
state.panel_error = fingerprint
_LOGGER.warning("Could not remove the owned House Plan panel (%s)", fingerprint)
return
state._owned_panel = None
state.panel_status = "removed"
state.panel_url = None
state.panel_module_url = None
@callback
def remove_panel_registration(hass: HomeAssistant) -> None:
"""Idempotently clean up the current owned panel, if one still exists."""
state = get_panel_registration_state(hass)
if state is not None:
_remove_owned_panel(hass, state)
async def async_setup_panel_registration(
hass: HomeAssistant,
entry: HouseplanConfigEntry,
panel_path: Path,
) -> PanelRegistrationState:
"""Register the panel last without making integration setup depend on it."""
domain_data = hass.data.setdefault(DOMAIN, {})
previous = domain_data.get(PANEL_REGISTRATION_KEY)
if isinstance(previous, PanelRegistrationState):
# Normally entry unload already did this. Keeping setup self-contained
# also makes a direct repeated setup deterministic and duplicate-free.
_remove_owned_panel(hass, previous)
old_generation = domain_data.get(PANEL_GENERATION_KEY)
generation = old_generation + 1 if isinstance(old_generation, int) else 1
domain_data[PANEL_GENERATION_KEY] = generation
panel_file_present = panel_path.is_file()
state = PanelRegistrationState(
panel_file_present=panel_file_present,
generation=generation,
)
domain_data[PANEL_REGISTRATION_KEY] = state
if not panel_file_present:
state.panel_status = "missing_asset"
_LOGGER.warning("houseplan-panel.js was not found next to the integration")
return state
static_outcome = await async_register_frontend_static_path(
hass, PANEL_FRONTEND_URL, panel_path
)
state.panel_static_path_registered = static_outcome.registered
if not static_outcome.registered:
state.panel_status = "static_error"
state.panel_error = static_outcome.last_error
_LOGGER.warning(
"Could not register the House Plan panel static path (%s)",
static_outcome.last_error or "static_path:UnknownError",
)
return state
module_url = f"{PANEL_FRONTEND_URL}?v={VERSION}"
try:
await panel_custom.async_register_panel(
hass=hass,
frontend_url_path=PANEL_URL_PATH,
webcomponent_name=PANEL_COMPONENT_NAME,
sidebar_title=PANEL_TITLE,
sidebar_icon=PANEL_ICON,
module_url=module_url,
embed_iframe=False,
trust_external=False,
require_admin=False,
)
except asyncio.CancelledError:
raise
except ValueError as err:
state.panel_error = _safe_error("registration", err)
panels = _panel_registry(hass)
if panels is not None and PANEL_URL_PATH in panels:
state.panel_status = "collision"
_LOGGER.warning(
"House Plan cannot register /%s because that panel route is "
"already in use; open House Plan in System Health for details",
PANEL_URL_PATH,
)
else:
state.panel_status = "registration_error"
_LOGGER.warning(
"Could not register the House Plan panel (%s)", state.panel_error
)
return state
except Exception as err: # noqa: BLE001 - the card/backend remain available
state.panel_status = "registration_error"
state.panel_error = _safe_error("registration", err)
_LOGGER.warning(
"Could not register the House Plan panel (%s)", state.panel_error
)
return state
panels = _panel_registry(hass)
owned_panel = panels.get(PANEL_URL_PATH) if panels is not None else None
if owned_panel is None:
# The public register API has no ownership return value. If the exact
# object cannot be captured now, leaving a route until a later blind
# cleanup would be unsafe. Remove the just-created route immediately.
state.panel_status = "ownership_error"
state.panel_error = "ownership:Unavailable"
try:
frontend.async_remove_panel(hass, PANEL_URL_PATH)
except Exception as err: # noqa: BLE001 - setup must still succeed
_LOGGER.warning(
"Could not clean up an unowned House Plan panel (%s)",
_safe_error("ownership_remove", err),
)
_LOGGER.warning(
"House Plan panel ownership could not be verified; the panel was removed"
)
return state
state._owned_panel = owned_panel
state.panel_status = "registered"
state.panel_url = PANEL_URL
state.panel_module_url = module_url
entry.async_on_unload(partial(_remove_owned_panel, hass, state))
return state
@callback
def get_panel_registration_state(
hass: HomeAssistant,
) -> PanelRegistrationState | None:
"""Return the current panel state for System Health."""
state = hass.data.get(DOMAIN, {}).get(PANEL_REGISTRATION_KEY)
return state if isinstance(state, PanelRegistrationState) else None