Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/analytics/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,7 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
removed_devices: set[str] = set()

# Get device list
for device_entry in (*dev_reg.devices, *dev_reg.child_devices.values()):
for device_entry in (*dev_reg.devices, *dev_reg.child_devices):
config_entry = hass.config_entries.async_get_entry(device_entry.config_entry_id)

if config_entry is None:
Expand Down
11 changes: 9 additions & 2 deletions homeassistant/components/cloud/alexa_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ async def _async_prefs_updated(self, prefs: CloudPreferences) -> None:

# State reporting is reported as a property on entities.
# So when we change it, we need to sync all entities.
await self.async_sync_entities()
await self._async_sync_entities_unless_relink_needed()
return

# Nothing to do if no Alexa related things have changed
Expand All @@ -396,7 +396,14 @@ async def _async_prefs_updated(self, prefs: CloudPreferences) -> None:
):
return

await self.async_sync_entities()
await self._async_sync_entities_unless_relink_needed()

async def _async_sync_entities_unless_relink_needed(self) -> None:
"""Sync entities, tolerating an account with no linked Alexa skill."""
try:
await self.async_sync_entities()
except alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink:
await self.set_authorized(False)

@callback
def _async_exposed_entities_updated(self) -> None:
Expand Down
23 changes: 20 additions & 3 deletions homeassistant/components/config/device_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,7 @@ def websocket_list_devices(
inner = b",".join(
[
entry.json_repr
for container in (registry._devices, registry.child_devices) # noqa: SLF001
for entry in container.values()
for entry in (*registry.devices, *registry.child_devices)
if entry.json_repr is not None
]
)
Expand Down Expand Up @@ -179,8 +178,26 @@ def websocket_update_device(
# Convert labels to a set
msg["labels"] = set(msg["labels"])

device_id = msg["device_id"]

# A composite device id has no single underlying device to update; reject it.
if (
registry.async_get(
device_id, include_main_devices=False, include_child_devices=False
)
is not None
):
connection.send_error(
msg_id, websocket_api.ERR_NOT_ALLOWED, "Cannot update a composite device"
)
return
if (
device := registry.async_get(device_id, include_composite_devices=False)
) is None:
connection.send_error(msg_id, websocket_api.ERR_NOT_FOUND, "Device not found")
return

entry: dr.AnyDeviceEntry | None
device = registry.async_get(msg["device_id"], include_composite_devices=False)
if isinstance(device, dr.ChildDeviceEntry):
entry = registry.async_update_child_device(**msg)
else:
Expand Down
19 changes: 9 additions & 10 deletions homeassistant/components/hassio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
)

from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import RefreshToken, User
from homeassistant.auth.models import User
from homeassistant.components import frontend
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.onboarding import async_is_onboarded
Expand Down Expand Up @@ -49,7 +49,7 @@
update,
)
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState
from .addon_panel import async_setup_addon_panel
from .addon_panel import async_setup_addon_panel, async_setup_addon_panel_coordinator
from .auth import async_setup_auth_view
from .config import HassioConfigStore, StoredHassioConfig
from .config_entry import async_get_hassio_entry
Expand Down Expand Up @@ -412,11 +412,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
translation_key="supervisor_update_pending",
)

# Get or create a refresh token for the Supervisor user
if user.refresh_tokens:
refresh_token = list(user.refresh_tokens.values())[0]
else:
refresh_token = await hass.auth.async_create_refresh_token(user)
# Supervisor authenticates through its dedicated Unix socket.
for refresh_token in list(user.refresh_tokens.values()):
hass.auth.async_remove_refresh_token(refresh_token)

# Set up coordinators — these can raise ConfigEntryNotReady.
# Register listeners only after all refreshes succeed to avoid accumulation
Expand All @@ -426,6 +424,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg)
await coordinator.async_config_entry_first_refresh()
hass.data[MAIN_COORDINATOR] = coordinator
entry.async_on_unload(async_setup_addon_panel_coordinator(hass, coordinator))

jobs_coordinator = SupervisorJobsCoordinator(hass, entry)
await jobs_coordinator.async_config_entry_first_refresh()
Expand Down Expand Up @@ -491,15 +490,15 @@ async def push_config(_: Event | None) -> None:

entry.async_on_unload(hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config))

async def update_hass_api(refresh_token: RefreshToken) -> None:
async def update_hass_api() -> None:
"""Update Home Assistant API data on Hass.io."""
# hass.config.api is always set here: hassio depends on http, and the
# http integration assigns hass.config.api during its async_setup.
assert hass.config.api is not None
options = HomeAssistantOptions(
ssl=hass.config.api.use_ssl,
port=hass.config.api.port,
refresh_token=refresh_token.token,
refresh_token=None,
)

try:
Expand All @@ -511,7 +510,7 @@ async def update_hass_api(refresh_token: RefreshToken) -> None:

# Push initial config to Supervisor and refresh issues state
await asyncio.gather(
update_hass_api(refresh_token),
update_hass_api(),
push_config(None),
issues_coordinator.async_refresh(),
)
Expand Down
92 changes: 62 additions & 30 deletions homeassistant/components/hassio/addon_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,52 @@

from homeassistant.components import frontend
from homeassistant.components.http import HomeAssistantView, require_admin
from homeassistant.const import EVENT_HOMEASSISTANT_START
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback

from .const import MAIN_COORDINATOR
from .coordinator import HassioMainDataUpdateCoordinator
from .handler import get_supervisor_client

_LOGGER = logging.getLogger(__name__)


def async_setup_addon_panel(hass: HomeAssistant) -> None:
"""Add-on Ingress Panel setup."""
hassio_addon_panel = HassIOAddonPanel(hass)
hass.http.register_view(hassio_addon_panel)
"""Register the add-on panel push API view."""
hass.http.register_view(HassIOAddonPanel(hass))

# Handle existing panels on startup
async def _async_panel_start_handler(event: Event) -> None:
"""Process all existing panels on startup."""
# Check if there are panels to register
if not (panels := await hassio_addon_panel.get_panels()):
return

# Register available panels
for addon, data in panels.items():
if not data.enable:
continue
_register_panel(hass, addon, data)
@callback
def async_setup_addon_panel_coordinator(
hass: HomeAssistant, coordinator: HassioMainDataUpdateCoordinator
) -> CALLBACK_TYPE:
"""Reconcile add-on panels registered with the frontend against coordinator data.

hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_panel_start_handler)
Registers the panels present after the coordinator's first refresh, then keeps
the frontend in sync with coordinator.data.panels on every following update:
periodic refreshes, a refresh triggered by a Supervisor restart, and a post/
delete pushed by Supervisor and cached via coordinator.async_push_panel /
coordinator.async_push_panel_removal.

Returns a function that unsubscribes from the coordinator.
"""
registered: set[str] = set()

@callback
def _async_reconcile_panels() -> None:
"""Register or remove panels to match the coordinator's cached data."""
panels = coordinator.data.panels
wanted = {addon for addon, panel in panels.items() if panel.enable}

for addon in wanted - registered:
_register_panel(hass, addon, panels[addon])
for addon in registered - wanted:
frontend.async_remove_panel(hass, addon, warn_if_unknown=False)

registered.clear()
registered.update(wanted)

_async_reconcile_panels()
return coordinator.async_add_listener(_async_reconcile_panels)


class HassIOAddonPanel(HomeAssistantView):
Expand All @@ -52,34 +71,46 @@ def __init__(self, hass: HomeAssistant) -> None:
@require_admin
async def post(self, request: web.Request, addon: str) -> web.Response:
"""Handle new add-on panel requests."""
panels = await self.get_panels()
# Supervisor calls this endpoint because an add-on's panel state just
# changed, so fetch it fresh instead of relying on the coordinator's
# cache, which may still hold the value from before this change.
try:
panels = await self.client.ingress.panels()
except SupervisorError as err:
_LOGGER.error("Can't read panel info: %s", err)
return web.Response(status=HTTPStatus.BAD_REQUEST)

# Panel exists for add-on slug
if addon not in panels or not panels[addon].enable:
_LOGGER.error("Panel is not enabled for %s", addon)
return web.Response(status=HTTPStatus.BAD_REQUEST)

# Register panel
_register_panel(self.hass, addon, panels[addon])
if (coordinator := self.hass.data.get(MAIN_COORDINATOR)) is not None:
# Update the cache; the coordinator listener registers it with the frontend.
coordinator.async_push_panel(addon, panels[addon])
else:
_register_panel(self.hass, addon, panels[addon])
return web.Response()

@require_admin
async def delete(self, request: web.Request, addon: str) -> web.Response:
"""Handle remove add-on panel requests."""
frontend.async_remove_panel(self.hass, addon)
if (coordinator := self.hass.data.get(MAIN_COORDINATOR)) is not None:
# Update the cache; the coordinator listener removes it from the frontend.
coordinator.async_push_panel_removal(addon)
else:
frontend.async_remove_panel(self.hass, addon, warn_if_unknown=False)
return web.Response()

async def get_panels(self) -> dict[str, IngressPanel]:
"""Return panels add-on info data."""
try:
return await self.client.ingress.panels()
except SupervisorError as err:
_LOGGER.error("Can't read panel info: %s", err)
return {}

def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel) -> None:
"""Helper to register the panel.

def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel):
"""Helper to register the panel."""
Uses update=True so this is idempotent: a config entry reload can run this
for a panel the frontend still has registered from before the reload, and
the push API's early-startup fallback can register one before the
coordinator's own reconciliation runs for the first time.
"""
frontend.async_register_built_in_panel(
hass,
"app",
Expand All @@ -88,4 +119,5 @@ def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel):
sidebar_icon=data.icon,
require_admin=data.admin,
config={"addon": addon},
update=True,
)
1 change: 1 addition & 0 deletions homeassistant/components/hassio/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
EVENT_ISSUE_CHANGED = "issue_changed"
EVENT_ISSUE_REMOVED = "issue_removed"
EVENT_JOB = "job"
EVENT_STORE_RELOADED = "store_reloaded"

UPDATE_KEY_SUPERVISOR = "supervisor"
STARTUP_COMPLETE = "complete"
Expand Down
Loading
Loading