diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 0d5211773ca76c..14a9f14e1a0235 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -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: diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 4a019672dc4911..feba03668c4c83 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -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 @@ -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: diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index f2934b4dd62360..52e5ac63ac01c6 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -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 ] ) @@ -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: diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 373c0b0c99da80..59c14de4460e2c 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -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 @@ -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 @@ -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 @@ -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() @@ -491,7 +490,7 @@ 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. @@ -499,7 +498,7 @@ async def update_hass_api(refresh_token: RefreshToken) -> None: options = HomeAssistantOptions( ssl=hass.config.api.use_ssl, port=hass.config.api.port, - refresh_token=refresh_token.token, + refresh_token=None, ) try: @@ -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(), ) diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 314b6ebd6d7f01..31b087a64bbcbf 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -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): @@ -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", @@ -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, ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 47ddf0fa50d107..c2526117bde389 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -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" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index aa5c99dc9d0560..20b57e3d7f8aa2 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -16,6 +16,7 @@ HomeAssistantInfo, HomeAssistantStats, HostInfo, + IngressPanel, InstalledAddon, InstalledAddonComplete, Issue as SupervisorIssue, @@ -83,6 +84,7 @@ EVENT_ISSUE_CHANGED, EVENT_ISSUE_REMOVED, EVENT_JOB, + EVENT_STORE_RELOADED, EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, @@ -776,6 +778,7 @@ class HassioMainData: host: HostInfo mounts: dict[str, CIFSMountResponse | NFSMountResponse] os: OSInfo | None + panels: dict[str, IngressPanel] def to_dict(self) -> dict[str, Any]: """Return a dictionary representation of the data.""" @@ -785,6 +788,7 @@ def to_dict(self) -> dict[str, Any]: "host": self.host.to_dict(), "mounts": {name: mount.to_dict() for name, mount in self.mounts.items()}, "os": self.os.to_dict() if self.os is not None else None, + "panels": {slug: panel.to_dict() for slug, panel in self.panels.items()}, } @@ -1296,6 +1300,23 @@ def __init__( self.dev_reg = dev_reg self._addon_info_subscriptions: defaultdict[str, set[str]] = defaultdict(set) self.supervisor_client = get_supervisor_client(hass) + self._dispatcher_disconnect = async_dispatcher_connect( + hass, EVENT_SUPERVISOR_EVENT, self._supervisor_event + ) + + @callback + def _supervisor_event(self, event: dict[str, Any]) -> None: + """Refresh add-on data when Supervisor reloads the store.""" + if event.get(ATTR_WS_EVENT) != EVENT_STORE_RELOADED: + return + # Without listeners there are no add-on entities to keep in sync. + # Scheduled polling is paused in that case as well, so don't let + # store reload events trigger refreshes either. + if not self._listeners: + return + self.config_entry.async_create_task( + self.hass, self.async_refresh_after_store_reload() + ) @override async def _async_update_data(self) -> HassioAddonData: @@ -1462,6 +1483,12 @@ async def force_addon_info_data_refresh(self, addon_slug: str) -> None: addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {}) addon_info_cache[slug] = info + @override + async def async_shutdown(self) -> None: + """Shut down and clean up when config entry unloaded.""" + await super().async_shutdown() + self._dispatcher_disconnect() + class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): """Class to retrieve Hass.io status.""" @@ -1502,6 +1529,25 @@ def _supervisor_event(self, event: dict[str, Any]) -> None: ): self.config_entry.async_create_task(self.hass, self.async_request_refresh()) + @callback + def async_push_panel(self, addon: str, panel: IngressPanel) -> None: + """Apply a Supervisor panel push to cached data without touching refresh state.""" + self.data = replace(self.data, panels={**self.data.panels, addon: panel}) + self.async_update_listeners() + + @callback + def async_push_panel_removal(self, addon: str) -> None: + """Apply a Supervisor panel removal push to cached data.""" + if addon not in self.data.panels: + return + self.data = replace( + self.data, + panels={ + slug: panel for slug, panel in self.data.panels.items() if slug != addon + }, + ) + self.async_update_listeners() + @override async def _async_update_data(self) -> HassioMainData: """Update data via library.""" @@ -1511,7 +1557,7 @@ async def _async_update_data(self) -> HassioMainData: try: # Cast is required here because asyncio.gather only has overloads to # maintain typing for 6 arguments. It falls back to list[] - # after that which is what mypy sees here since we have 7 API calls. + # after that which is what mypy sees here since we have 8 API calls. ( info, core_info, @@ -1520,6 +1566,7 @@ async def _async_update_data(self) -> HassioMainData: host_info, store_info, network_info, + panels_info, ) = cast( tuple[ RootInfo, @@ -1529,6 +1576,7 @@ async def _async_update_data(self) -> HassioMainData: HostInfo, StoreInfo, NetworkInfo, + dict[str, IngressPanel], ], await asyncio.gather( client.info(), @@ -1538,6 +1586,7 @@ async def _async_update_data(self) -> HassioMainData: client.host.info(), client.store.info(), client.network.info(), + client.ingress.panels(), ), ) mounts_info = await client.mounts.info() @@ -1552,6 +1601,7 @@ async def _async_update_data(self) -> HassioMainData: host=host_info, mounts={mount.name: mount for mount in mounts_info.mounts}, os=os_info if self.is_hass_os else None, + panels=panels_info, ) # Update hass.data for legacy accessor functions diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 3fec165459e2b8..3f945f75c32193 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -20,7 +20,6 @@ from .config_entry import async_get_hassio_entry, async_get_update_options from .const import ( - ADDONS_COORDINATOR, ATTR_DATA, ATTR_ENDPOINT, ATTR_METHOD, @@ -59,10 +58,6 @@ r")$" ) -# Endpoint that reloads the add-on store. Afterwards the add-on update -# entities must be refreshed so they don't report stale update information. -STORE_RELOAD_ENDPOINT = "/store/reload" - _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -163,15 +158,6 @@ async def websocket_supervisor_api( # sensitive information and the frontend does not require it for ingress. if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command): data.pop("options", None) - # Await so the frontend only sees the reload finish once the add-on - # update entities reflect the reloaded store. - if ( - command == STORE_RELOAD_ENDPOINT - and msg[ATTR_METHOD] == "post" - and (coordinator := hass.data.get(ADDONS_COORDINATOR)) - ): - await coordinator.async_refresh_after_store_reload() - connection.send_result(msg[WS_ID], data) diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 557301ea9f988a..f4f4fe4caf205c 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -441,7 +441,6 @@ async def async_cleanup_device_registry( entity_registry = er.async_get(hass) if ( device_id - and device_id not in device_registry.deleted_devices and config_entry_id and (device := device_registry.async_get(device_id)) is not None # Only remove the device if it is owned by the MQTT config entry diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 0f8eb82f48a6ef..01a80d163d8a57 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -20,7 +20,7 @@ ) from music_assistant_models.errors import MediaNotFoundError from music_assistant_models.event import MassEvent -from music_assistant_models.media_items import ItemMapping, MediaItemType, Track +from music_assistant_models.media_items import ItemMapping, MediaItemType from music_assistant_models.player_queue import PlayerQueue from homeassistant.components import media_source @@ -139,7 +139,6 @@ def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: self._attr_icon = self.player.icon.replace("mdi-", "mdi:") self._set_supported_features() self._attr_device_class = MediaPlayerDeviceClass.SPEAKER - self._prev_time: float = 0 self._source_list_mapping: dict[str, str] = {} self._sound_mode_list_mapping: dict[str, str] = {} @@ -148,23 +147,6 @@ async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() - # we subscribe to player queue time update but we only - # accept a state change on big time jumps (e.g. seeking) - async def queue_time_updated(event: MassEvent) -> None: - if event.object_id != self.player.active_source: - return - if abs((self._prev_time or 0) - event.data) > 5: - await self.async_on_update() - self.async_write_ha_state() - self._prev_time = event.data - - self.async_on_remove( - self.mass.subscribe( - queue_time_updated, - EventType.QUEUE_TIME_UPDATED, - ) - ) - # we subscribe to the player config changed event to update # the supported features of the player async def player_config_changed(event: MassEvent) -> None: @@ -669,88 +651,49 @@ def _update_media_image_url( def _update_media_attributes( self, player: Player, queue: PlayerQueue | None ) -> None: - """Update media attributes for the active queue item.""" - self._attr_media_artist = None - self._attr_media_album_artist = None - self._attr_media_album_name = None - self._attr_media_title = None - self._attr_media_content_id = None - self._attr_media_duration = None - self._attr_media_position = None - self._attr_media_position_updated_at = None - - if queue is None and player.current_media: - # player has some external source active - self._attr_media_content_id = player.current_media.uri + """Update media attributes from the player's current media.""" + # shuffle and repeat are queue concepts and not part of current_media + if queue is not None: + self._attr_app_id = DOMAIN + self._attr_shuffle = queue.shuffle_enabled + self._attr_repeat = REPEAT_MODE_MAPPING_TO_HA.get(queue.repeat_mode) + else: self._attr_app_id = player.active_source - self._attr_media_title = player.current_media.title - self._attr_media_artist = player.current_media.artist - self._attr_media_album_name = player.current_media.album - self._attr_media_duration = player.current_media.duration - # shuffle and repeat are not (yet) supported for external sources self._attr_shuffle = None self._attr_repeat = None - self._attr_media_position = int(player.elapsed_time or 0) - self._attr_media_position_updated_at = ( - utc_from_timestamp(player.elapsed_time_last_updated) - if player.elapsed_time_last_updated - else None - ) - self._prev_time = player.elapsed_time or 0 - return - - if queue is None: - # player has no MA queue active - self._attr_source = player.active_source - self._attr_app_id = player.active_source - return - - # player has an MA queue active (either its own queue or some group queue) - self._attr_app_id = DOMAIN - self._attr_shuffle = queue.shuffle_enabled - self._attr_repeat = REPEAT_MODE_MAPPING_TO_HA.get(queue.repeat_mode) - if not (cur_item := queue.current_item): - # queue is empty - return - self._attr_media_content_id = queue.current_item.uri - self._attr_media_duration = queue.current_item.duration - self._attr_media_position = int(queue.elapsed_time) - self._attr_media_position_updated_at = utc_from_timestamp( - queue.elapsed_time_last_updated + # the server resolves current_media for every playback scenario + current_media = player.current_media + self._attr_media_content_id = ( + current_media.uri if current_media is not None else None + ) + self._attr_media_title = ( + current_media.title if current_media is not None else None + ) + self._attr_media_artist = ( + current_media.artist if current_media is not None else None + ) + self._attr_media_album_name = ( + current_media.album if current_media is not None else None + ) + self._attr_media_album_artist = ( + current_media.album_artist if current_media is not None else None + ) + self._attr_media_duration = ( + current_media.duration if current_media is not None else None ) - self._prev_time = queue.elapsed_time - - # handle stream title (radio station icy metadata) - if (stream_details := cur_item.streamdetails) and stream_details.stream_title: - self._attr_media_album_name = cur_item.name - if " - " in stream_details.stream_title: - stream_title_parts = stream_details.stream_title.split(" - ", 1) - self._attr_media_title = stream_title_parts[1] - self._attr_media_artist = stream_title_parts[0] - else: - self._attr_media_title = stream_details.stream_title - return - - if not (media_item := cur_item.media_item): - # queue is not playing a regular media item (edge case?!) - self._attr_media_title = cur_item.name - return - # queue is playing regular media item - self._attr_media_title = media_item.name - # for tracks we can extract more info - if media_item.media_type == MediaType.TRACK: - if TYPE_CHECKING: - assert isinstance(media_item, Track) - self._attr_media_artist = media_item.artist_str - if media_item.version: - self._attr_media_title += f" ({media_item.version})" - if media_item.album: - self._attr_media_album_name = media_item.album.name - self._attr_media_album_artist = getattr( - media_item.album, "artist_str", None - ) + # the server pushes a fresh position anchor on jumps (e.g. seeking) + if current_media is not None and current_media.elapsed_time is not None: + self._attr_media_position = int(current_media.elapsed_time) + self._attr_media_position_updated_at = ( + utc_from_timestamp(current_media.elapsed_time_last_updated) + if current_media.elapsed_time_last_updated is not None + else None + ) + else: + self._attr_media_position = None + self._attr_media_position_updated_at = None def _convert_queueoption_to_media_player_enqueue( self, queue_option: MediaPlayerEnqueue | QueueOption | None diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index d51bf3e75c40e3..91d54831b29f1a 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -252,10 +252,10 @@ def _remove_device(device_id: DeviceTuple) -> None: def _updated_device(event: Event[EventDeviceRegistryUpdatedData]) -> None: if event.data["action"] != "remove": return - device_entry = device_registry.deleted_devices[event.data["device_id"]] - if entry.entry_id not in device_entry.config_entries: + device = event.data["device"] + if device["config_entry_id"] != entry.entry_id: return - device_id = get_device_tuple_from_identifiers(device_entry.identifiers) + device_id = get_device_tuple_from_identifiers(device["identifiers"]) if device_id: _remove_device(device_id) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index a2b96fe7ea1715..11b656ed4bfe39 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==13.30.0"], + "requirements": ["aioshelly==13.31.0"], "zeroconf": [ { "name": "shelly*", diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 6ee4115aa62ada..f7de84233e3f65 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1787,8 +1787,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): _devices: ActiveDeviceRegistryItems devices: Collection[DeviceEntry] - child_devices: ChildDeviceRegistryItems - deleted_devices: DeletedDeviceRegistryItems + _child_devices: ChildDeviceRegistryItems + child_devices: Collection[ChildDeviceEntry] + _deleted_devices: DeletedDeviceRegistryItems _device_data: dict[str, DeviceEntry] _child_device_data: dict[str, ChildDeviceEntry] @@ -1809,6 +1810,22 @@ def __init__(self, hass: HomeAssistant) -> None: serialize_in_event_loop=False, ) + @property + def deleted_devices(self) -> DeletedDeviceRegistryItems: + """Return the deleted devices container (deprecated). + + Can be removed in release 2027.9. + """ + report_usage( + "accesses `device_registry.deleted_devices`, which is deprecated and " + "an internal implementation detail of the device registry", + breaks_in_ha_version="2027.9.0", + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, + ) + return self._deleted_devices + @overload def async_get( self, @@ -2003,7 +2020,7 @@ def async_get_child_device_by_identifier( Identifiers are unique within a config entry, so the lookup cannot be ambiguous. """ - return self.child_devices.get_entry( + return self._child_devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) @@ -2333,7 +2350,7 @@ def async_get_or_create( # noqa: C901 # We do not allow registering a device without parent_device_id if the # identifiers match an existing child. if ( - matched_child_device := self.child_devices.get_entry( + matched_child_device := self._child_devices.get_entry( identifiers=identifiers, config_entry_id=config_entry_id ) ) is not None: @@ -2389,7 +2406,7 @@ def async_get_or_create( # noqa: C901 if device is None: is_new = True - deleted_device = self.deleted_devices.get_entry( + deleted_device = self._deleted_devices.get_entry( connections=connections, identifiers=identifiers, config_entry_id=config_entry_id, @@ -2400,7 +2417,7 @@ def async_get_or_create( # noqa: C901 # rather than create a fresh device. Matching on the recorded domain keeps # a chance identifier/connection collision from restoring another # integration's device. - deleted_device = self.deleted_devices.get_orphaned_entry( + deleted_device = self._deleted_devices.get_orphaned_entry( identifiers, connections, config_entry.domain ) if deleted_device is None: @@ -2427,7 +2444,7 @@ def async_get_or_create( # noqa: C901 ) else: - self.deleted_devices.pop(deleted_device.id) + self._deleted_devices.pop(deleted_device.id) device = deleted_device.to_device_entry( config_entry, # Interpret not specifying a subentry as None @@ -2673,7 +2690,7 @@ def async_get_or_create_child( f"parent device {parent.id}", ) - child_device = self.child_devices.get_entry( + child_device = self._child_devices.get_entry( identifiers=identifiers, config_entry_id=config_entry_id ) @@ -2681,7 +2698,7 @@ def async_get_or_create_child( # owned by another child device. for identifier in sorted(identifiers): if ( - other_child := self.child_devices.get_entry( + other_child := self._child_devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) ) is not None and ( @@ -2738,14 +2755,14 @@ def async_get_or_create_child( if child_device is None: is_new = True - deleted_device = self.deleted_devices.get_entry( + deleted_device = self._deleted_devices.get_entry( identifiers=identifiers, config_entry_id=config_entry_id, ) if deleted_device is None: # Fall back to an orphan (its owning config entry was removed), as # for a full device - deleted_device = self.deleted_devices.get_orphaned_entry( + deleted_device = self._deleted_devices.get_orphaned_entry( identifiers, None, domain ) if deleted_device is None: @@ -2767,7 +2784,7 @@ def async_get_or_create_child( parent_device_id=parent.id, ) else: - self.deleted_devices.pop(deleted_device.id) + self._deleted_devices.pop(deleted_device.id) child_device = deleted_device.to_child_device_entry( config_entry, effective_config_subentry_id, @@ -2777,7 +2794,7 @@ def async_get_or_create_child( ) disabled_by = UNDEFINED - self.child_devices[child_device.id] = child_device + self._child_devices[child_device.id] = child_device self._async_purge_colliding_deleted_devices(child_device, identifiers, set()) @@ -2814,7 +2831,7 @@ def _async_validate_device_to_child_conversion( raise DeviceInfoError( config_entry.domain, device_info, "a device can't be its own parent" ) - if self.child_devices.get_children_for_device_id(device.id): + if self._child_devices.get_children_for_device_id(device.id): raise DeviceInfoError( config_entry.domain, device_info, @@ -2907,7 +2924,7 @@ def _async_convert_device_to_child( parent_device_id=parent.id, ) del self._devices[device.id] - self.child_devices[child_device.id] = child_device + self._child_devices[child_device.id] = child_device # A via_device_id must not resolve to a child device; detach inbound via # links to the converted device, as async_remove_device does, before firing @@ -3146,7 +3163,7 @@ def _async_update_device( # noqa: C901 # A parent with child devices can't move (enforced again below); reject # here before mutating the runtime-only sibling pending moves, so the # rejected move leaves no partial state behind. - if self.child_devices.get_children_for_device_id(device_id): + if self._child_devices.get_children_for_device_id(device_id): raise HomeAssistantError( f"Can't move device {device_id}: it has child devices" ) @@ -3215,7 +3232,7 @@ def _async_update_device( # noqa: C901 # supported. if ( is_move or "config_subentry_id" in new_values - ) and self.child_devices.get_children_for_device_id(device_id): + ) and self._child_devices.get_children_for_device_id(device_id): raise HomeAssistantError( f"Can't move device {device_id}: it has child devices" ) @@ -3384,13 +3401,13 @@ def _async_update_device( # noqa: C901 match_identifiers = added_identifiers match_connections = added_connections # A deleted device holding an identity the device now owns can never restore - for deleted_device_id in self.deleted_devices.get_colliding_device_ids( + for deleted_device_id in self._deleted_devices.get_colliding_device_ids( match_identifiers or set(), match_connections or set(), config_entry_id=effective_config_entry_id, exclude_device_id=None, ): - del self.deleted_devices[deleted_device_id] + del self._deleted_devices[deleted_device_id] # If its only run time attributes (suggested_area) # that do not get saved we do not want to write @@ -3417,7 +3434,7 @@ def _async_update_device( # noqa: C901 # async_config_entry_disabled_by_changed, which iterates all the config # entry's devices. if "disabled_by" in old_values and ( - children := self.child_devices.get_children_for_device_id(device_id) + children := self._child_devices.get_children_for_device_id(device_id) ): if new.disabled_by is None: for child in children: @@ -3447,7 +3464,7 @@ def _async_update_child_device( new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, ) -> ChildDeviceEntry | None: """Private update child device attributes.""" - old = self.child_devices[child_device_id] + old = self._child_devices[child_device_id] new_values: dict[str, Any] = {} # Dict with new key/value pairs old_values: dict[str, Any] = {} # Dict with old key/value pairs @@ -3572,17 +3589,17 @@ def _async_update_child_device( self.hass.verify_event_loop_thread("device_registry._async_update_child_device") new = attr.evolve(old, **new_values) - self.child_devices[child_device_id] = new + self._child_devices[child_device_id] = new # A deleted device holding an identity the child device now owns can never # restore - for deleted_device_id in self.deleted_devices.get_colliding_device_ids( + for deleted_device_id in self._deleted_devices.get_colliding_device_ids( added_identifiers or set(), set(), config_entry_id=old.config_entry_id, exclude_device_id=None, ): - del self.deleted_devices[deleted_device_id] + del self._deleted_devices[deleted_device_id] self.async_schedule_save() @@ -3883,7 +3900,7 @@ def _async_purge_colliding_deleted_devices( elif not device.has_composite_identifiers: identifiers = device.identifiers | identifiers connections = device.connections | connections - colliding = self.deleted_devices.get_colliding_device_ids( + colliding = self._deleted_devices.get_colliding_device_ids( identifiers, connections, config_entry_id=device.config_entry_id, @@ -3898,7 +3915,7 @@ def _async_purge_colliding_deleted_devices( deleted_device_id, device.id, ) - del self.deleted_devices[deleted_device_id] + del self._deleted_devices[deleted_device_id] self.async_schedule_save() @callback @@ -3960,7 +3977,7 @@ def _validate_identifiers( ) and existing_device.id != device_id: raise DeviceIdentifierCollisionError(identifiers, existing_device) if ( - existing_child_device := self.child_devices.get_entry( + existing_child_device := self._child_devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) ) is not None: @@ -3982,7 +3999,7 @@ def _validate_child_identifiers( """ for identifier in identifiers: if ( - existing_child_device := self.child_devices.get_entry( + existing_child_device := self._child_devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) ) and existing_child_device.id != child_device_id: @@ -4050,11 +4067,11 @@ def async_remove_device(self, device_id: str) -> None: return self.hass.verify_event_loop_thread("device_registry.async_remove_device") # Removing the parent removes its child devices - for child in self.child_devices.get_children_for_device_id(device_id): + for child in self._child_devices.get_children_for_device_id(device_id): self._async_remove_child_device(child) device = self._devices.pop(device_id) config_entry = self.hass.config_entries.async_get_entry(device.config_entry_id) - self.deleted_devices[device_id] = DeletedDeviceEntry( + self._deleted_devices[device_id] = DeletedDeviceEntry( area_id=device.area_id, config_entry_id=device.config_entry_id, config_subentry_id=device.config_subentry_id, @@ -4084,11 +4101,11 @@ def async_remove_device(self, device_id: str) -> None: def _async_remove_child_device(self, child_device: ChildDeviceEntry) -> None: """Remove a child device from the device registry.""" self.hass.verify_event_loop_thread("device_registry.async_remove_device") - del self.child_devices[child_device.id] + del self._child_devices[child_device.id] config_entry = self.hass.config_entries.async_get_entry( child_device.config_entry_id ) - self.deleted_devices[child_device.id] = DeletedDeviceEntry( + self._deleted_devices[child_device.id] = DeletedDeviceEntry( area_id=child_device.area_id, config_entry_id=child_device.config_entry_id, config_subentry_id=child_device.config_subentry_id, @@ -4263,8 +4280,9 @@ def get_optional_enum[_EnumT: StrEnum]( self._devices = devices self.devices = _DeprecatedDeviceRegistryItemsView(self._devices) - self.child_devices = child_devices - self.deleted_devices = deleted_devices + self._child_devices = child_devices + self.child_devices = self._child_devices.values() + self._deleted_devices = deleted_devices self._device_data = devices.data self._child_device_data = child_devices.data @@ -4290,11 +4308,12 @@ def _data_to_save(self) -> dict[str, Any]: entry.as_storage_fragment for entry in list(self._devices.values()) ], "child_devices": [ - entry.as_storage_fragment for entry in list(self.child_devices.values()) + entry.as_storage_fragment + for entry in list(self._child_devices.values()) ], "deleted_devices": [ entry.as_storage_fragment - for entry in list(self.deleted_devices.values()) + for entry in list(self._deleted_devices.values()) ], } @@ -4322,7 +4341,7 @@ def _async_orphan_deleted_device( # device from the same integration is orphaned, drop any existing orphan # it overlaps so the newest one wins deterministically instead of shadowing # it. - for existing in list(self.deleted_devices.values()): + for existing in list(self._deleted_devices.values()): if ( existing.config_entry_id is None and existing.domain == domain @@ -4331,8 +4350,8 @@ def _async_orphan_deleted_device( or existing.identifiers & deleted_device.identifiers ) ): - del self.deleted_devices[existing.id] - self.deleted_devices[deleted_device.id] = attr.evolve( + del self._deleted_devices[existing.id] + self._deleted_devices[deleted_device.id] = attr.evolve( deleted_device, config_entry_id=None, config_subentry_id=None, @@ -4358,7 +4377,7 @@ def async_clear_config_entry( self.async_remove_device(device.id) # Child devices share their parent's config entry, so the loop above removes # them through the parent cascade; guard against store corruption anyway. - for child_device in self.child_devices.get_devices_for_config_entry_id( + for child_device in self._child_devices.get_devices_for_config_entry_id( config_entry_id ): self.async_remove_device(child_device.id) @@ -4381,7 +4400,7 @@ def async_clear_config_entry( and pending_move.config_entry_id == config_entry_id ): self._devices[device.id] = attr.evolve(device, pending_move=None) - for deleted_device in list(self.deleted_devices.values()): + for deleted_device in list(self._deleted_devices.values()): if deleted_device.config_entry_id != config_entry_id: continue self._async_orphan_deleted_device(deleted_device, domain, now_time) @@ -4399,7 +4418,7 @@ def async_clear_config_subentry( self.async_remove_device(device.id) # Child devices share their parent's subentry, so the loop above removes them # through the parent cascade; guard against store corruption anyway. - for child_device in self.child_devices.get_devices_for_config_entry_id( + for child_device in self._child_devices.get_devices_for_config_entry_id( config_entry_id ): if child_device.config_subentry_id != config_subentry_id: @@ -4416,7 +4435,7 @@ def async_clear_config_subentry( and pending_move.config_subentry_id == config_subentry_id ): self._devices[device.id] = attr.evolve(device, pending_move=None) - for deleted_device in list(self.deleted_devices.values()): + for deleted_device in list(self._deleted_devices.values()): if ( deleted_device.config_entry_id != config_entry_id or deleted_device.config_subentry_id != config_subentry_id @@ -4432,7 +4451,7 @@ def async_purge_expired_orphaned_devices(self) -> None: growing without bound. """ now_time = time.time() - for deleted_device in list(self.deleted_devices.values()): + for deleted_device in list(self._deleted_devices.values()): if deleted_device.orphaned_timestamp is None: continue @@ -4440,19 +4459,19 @@ def async_purge_expired_orphaned_devices(self) -> None: deleted_device.orphaned_timestamp + ORPHANED_DEVICE_KEEP_SECONDS < now_time ): - del self.deleted_devices[deleted_device.id] + del self._deleted_devices[deleted_device.id] @callback def async_clear_area_id(self, area_id: str) -> None: """Clear area id from registry entries.""" for device in self._devices.get_devices_for_area_id(area_id): self._async_update_device(device.id, area_id=None) - for child_device in self.child_devices.get_devices_for_area_id(area_id): + for child_device in self._child_devices.get_devices_for_area_id(area_id): self._async_update_child_device(child_device.id, area_id=None) - for deleted_device in list(self.deleted_devices.values()): + for deleted_device in list(self._deleted_devices.values()): if deleted_device.area_id != area_id: continue - self.deleted_devices[deleted_device.id] = attr.evolve( + self._deleted_devices[deleted_device.id] = attr.evolve( deleted_device, area_id=None ) self.async_schedule_save() @@ -4462,14 +4481,14 @@ def async_clear_label_id(self, label_id: str) -> None: """Clear label from registry entries.""" for device in self._devices.get_devices_for_label(label_id): self._async_update_device(device.id, labels=device.labels - {label_id}) - for child_device in self.child_devices.get_devices_for_label(label_id): + for child_device in self._child_devices.get_devices_for_label(label_id): self._async_update_child_device( child_device.id, labels=child_device.labels - {label_id} ) - for deleted_device in list(self.deleted_devices.values()): + for deleted_device in list(self._deleted_devices.values()): if label_id not in deleted_device.labels: continue - self.deleted_devices[deleted_device.id] = attr.evolve( + self._deleted_devices[deleted_device.id] = attr.evolve( deleted_device, labels=deleted_device.labels - {label_id} ) self.async_schedule_save() @@ -4557,11 +4576,13 @@ def async_entries_for_area( """ devices = registry._devices.get_devices_for_area_id(area_id) # noqa: SLF001 entries: list[AnyDeviceEntry] = list(devices) - entries.extend(registry.child_devices.get_devices_for_area_id(area_id)) + entries.extend( + registry._child_devices.get_devices_for_area_id(area_id) # noqa: SLF001 + ) for device in devices: entries.extend( child_device - for child_device in registry.child_devices.get_children_for_device_id( + for child_device in registry._child_devices.get_children_for_device_id( # noqa: SLF001 device.id ) if child_device.area_id is None @@ -4600,7 +4621,9 @@ def async_entries_for_label( entries: list[AnyDeviceEntry] = list( registry._devices.get_devices_for_label(label_id) # noqa: SLF001 ) - entries.extend(registry.child_devices.get_devices_for_label(label_id)) + entries.extend( + registry._child_devices.get_devices_for_label(label_id) # noqa: SLF001 + ) return entries @@ -4619,7 +4642,9 @@ def async_entries_for_parent_device( registry: DeviceRegistry, parent_device_id: str ) -> list[ChildDeviceEntry]: """Return the child device entries of a parent device.""" - return registry.child_devices.get_children_for_device_id(parent_device_id) + return registry._child_devices.get_children_for_device_id( # noqa: SLF001 + parent_device_id + ) @callback @@ -4627,7 +4652,9 @@ def async_child_entries_for_config_entry( registry: DeviceRegistry, config_entry_id: str ) -> list[ChildDeviceEntry]: """Return child device entries that match a config entry.""" - return registry.child_devices.get_devices_for_config_entry_id(config_entry_id) + return registry._child_devices.get_devices_for_config_entry_id( # noqa: SLF001 + config_entry_id + ) @callback @@ -4730,7 +4757,7 @@ def async_cleanup( # A child device shares its parent's (valid) config entry, and the remove cascade # makes a child without its parent impossible; guard against store corruption anyway. - for child_device in list(dev_reg.child_devices.values()): + for child_device in list(dev_reg.child_devices): if child_device.parent_device_id not in dev_reg._devices: # noqa: SLF001 _LOGGER.error( "Removing child device %s: its parent device %s is not in the " diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index bd9c062c03a320..e4150b8c624dfa 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -179,18 +179,16 @@ def _resolve_referenced_devices( selected.referenced_devices.add(split_device.id) selected.referenced_devices.update( child_device.id - for child_device in ( - dev_reg.child_devices.get_children_for_device_id( - split_device.id - ) + for child_device in dr.async_entries_for_parent_device( + dev_reg, split_device.id ) ) else: selected.referenced_devices.add(device_id) selected.referenced_devices.update( child_device.id - for child_device in dev_reg.child_devices.get_children_for_device_id( - device_id + for child_device in dr.async_entries_for_parent_device( + dev_reg, device_id ) ) diff --git a/homeassistant/helpers/template/extensions/devices.py b/homeassistant/helpers/template/extensions/devices.py index b1312b8f61e928..91a4a3d887d34b 100644 --- a/homeassistant/helpers/template/extensions/devices.py +++ b/homeassistant/helpers/template/extensions/devices.py @@ -84,9 +84,8 @@ def device_id(self, entity_id_or_device_name: str) -> str | None: dev_reg = dr.async_get(self.hass) return next( ( - device_id - for container in (dev_reg._devices, dev_reg.child_devices) # noqa: SLF001 - for device_id, device in container.items() + device.id + for device in (*dev_reg.devices, *dev_reg.child_devices) if (name := device.name_by_user or device.name) and (str(entity_id_or_device_name) == name) ), diff --git a/requirements_all.txt b/requirements_all.txt index f6ae7245ea1a48..9d0f4095398b44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -420,7 +420,7 @@ aiorussound==5.0.2 aioruuvigateway==0.1.0 # homeassistant.components.shelly -aioshelly==13.30.0 +aioshelly==13.31.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 20f883fc870df8..097fe91b667d40 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -249,7 +249,7 @@ def test_entities_areas_area_inherited_from_parent(hass: HomeAssistant) -> None: }, ) # The child has no area of its own and inherits the parent's area. - device_registry.child_devices["mock-child-id"] = ChildDeviceEntry( + device_registry._child_devices["mock-child-id"] = ChildDeviceEntry( config_entry_id="mock-config-entry", id="mock-child-id", parent_device_id="mock-parent-id", diff --git a/tests/common.py b/tests/common.py index cbf4a742c31d75..db50800b25635d 100644 --- a/tests/common.py +++ b/tests/common.py @@ -762,13 +762,14 @@ def mock_device_registry( registry._devices = dr.ActiveDeviceRegistryItems() registry.devices = registry._devices.values() registry._device_data = registry._devices.data - registry.child_devices = dr.ChildDeviceRegistryItems() - registry._child_device_data = registry.child_devices.data + registry._child_devices = dr.ChildDeviceRegistryItems() + registry.child_devices = registry._child_devices.values() + registry._child_device_data = registry._child_devices.data if mock_entries is None: mock_entries = {} for key, entry in mock_entries.items(): registry._devices[key] = entry - registry.deleted_devices = dr.DeletedDeviceRegistryItems() + registry._deleted_devices = dr.DeletedDeviceRegistryItems() hass.data[dr.DATA_REGISTRY] = registry return registry diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 8ab862eef9522d..19e3741d1df51a 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -905,3 +905,56 @@ async def test_alexa_config_migrate_expose_entity_prefs_default( assert async_get_entity_settings(hass, water_heater.entity_id) == { "cloud.alexa": {"should_expose": False} } + + +@pytest.mark.parametrize( + "lib_exception", + [ + pytest.param( + AlexaApiNeedsRelinkError("RefreshTokenNotFound"), id="needs_relink" + ), + pytest.param(AlexaApiNoTokenError("OtherReason"), id="no_token"), + ], +) +async def test_alexa_config_prefs_update_without_linked_skill( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, + lib_exception: Exception, +) -> None: + """Test updating prefs when the Alexa skill was never linked. + + A freshly registered account has no Alexa refresh token, so syncing + entities must not raise out of the preferences listener. + """ + assert await async_setup_component(hass, "homeassistant", {}) + expose_new(hass, True) + entity_entry = entity_registry.async_get_or_create( + "fan", "test", "unique", suggested_object_id="test_fan" + ) + hass.states.async_set(entity_entry.entity_id, "off") + + await cloud_prefs.async_update(alexa_enabled=False, alexa_report_state=False) + conf = alexa_config.CloudAlexaConfig( + hass, + ALEXA_SCHEMA({}), + "mock-user-id", + cloud_prefs, + Mock( + servicehandlers_server="example", + auth=Mock(async_check_token=AsyncMock()), + websession=async_get_clientsession(hass), + alexa_api=Mock(access_token=AsyncMock(side_effect=lib_exception)), + ), + ) + await conf.async_initialize() + await conf.set_authorized(True) + assert conf.authorized is True + + await cloud_prefs.async_update(alexa_enabled=True) + await hass.async_block_till_done() + + # The sync could not authenticate, so the skill is marked as needing a relink. + assert conf.authorized is False + assert "RequireRelink" not in caplog.text diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 67acc84ee11271..a3a5227bc9f9b0 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -388,6 +388,82 @@ async def test_update_device_labels( assert getattr(device, key) == value +async def test_update_device_unknown_device( + hass: HomeAssistant, + client: MockHAClientWebSocket, +) -> None: + """Test updating an unknown device returns an error.""" + await client.send_json_auto_id( + { + "type": "config/device_registry/update", + "device_id": "does_not_exist", + "name_by_user": "Test Friendly Name", + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + assert msg["error"]["message"] == "Device not found" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_update_device_composite( + hass: HomeAssistant, + client: MockHAClientWebSocket, + hass_storage: dict[str, Any], +) -> None: + """Test updating a pre-migration composite device id is rejected.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + + composite_id = "compositea000000000000000000000" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Composite spanning two config entries; splitting it on load removes + # the composite device, so composite_id no longer refers to a device + _storage_device_v1_12( + composite_id, + [entry_1.entry_id, entry_2.entry_id], + entry_1.entry_id, + "a", + ), + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + # pylint: disable-next=home-assistant-tests-registry-fixtures + registry = dr.async_get(hass) + assert registry.async_get(composite_id) is not None + assert registry.async_get(composite_id, include_composite_devices=False) is None + + await client.send_json_auto_id( + { + "type": "config/device_registry/update", + "device_id": composite_id, + "name_by_user": "Test Friendly Name", + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_allowed" + assert msg["error"]["message"] == "Cannot update a composite device" + + # The update was not fanned out to the underlying split devices + for split in registry.async_get_devices_for_composite_device_id(composite_id): + assert split.name_by_user is None + + _DEPRECATION_WARNING = ( "The websocket command config/device_registry/remove_config_entry is " "deprecated and will be removed in Home Assistant 2027.9" diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index a1439058057d79..7e20a621a8b470 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -10,7 +10,6 @@ from aiohttp.test_utils import TestClient import pytest -from homeassistant.components.hassio.const import DATA_HASSIO_SUPERVISOR_USER from homeassistant.components.hassio.handler import HassIO from homeassistant.components.http.config import _DEFAULT_CONFIG as HTTP_DEFAULT_CONFIG from homeassistant.components.http.const import CONF_SERVER_PORT @@ -71,16 +70,23 @@ async def hassio_client_supervisor( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, hassio_stubs: None, -) -> TestClient: +) -> AsyncGenerator[TestClient]: """Return an authenticated HTTP client.""" - hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] - assert hassio_user.refresh_tokens - refresh_token = next(iter(hassio_user.refresh_tokens.values())) - access_token = hass.auth.async_create_access_token(refresh_token) - return await aiohttp_client( - hass.http.app, - headers={"Authorization": f"Bearer {access_token}"}, - ) + with ( + patch( + "homeassistant.components.hassio.auth.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.http.ban.is_supervisor_unix_socket_request", + return_value=True, + ), + ): + yield await aiohttp_client(hass.http.app) @pytest.fixture @@ -91,11 +97,21 @@ def hass_supervisor_ws_client( """Return a websocket client authenticated as the Supervisor user.""" async def create_client() -> WebSocketGenerator: - hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] - assert hassio_user.refresh_tokens - refresh_token = next(iter(hassio_user.refresh_tokens.values())) - access_token = hass.auth.async_create_access_token(refresh_token) - return await hass_ws_client(hass, access_token=access_token) + with ( + patch( + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.http.ban.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.websocket_api.http.is_supervisor_unix_socket_request", + return_value=True, + ), + ): + return await hass_ws_client(hass, supervisor_unix_socket=True) return create_client diff --git a/tests/components/hassio/test_addon_panel.py b/tests/components/hassio/test_addon_panel.py index ca5fde3bc58abf..5930b48f239422 100644 --- a/tests/components/hassio/test_addon_panel.py +++ b/tests/components/hassio/test_addon_panel.py @@ -1,19 +1,26 @@ """Test add-on panel.""" +from datetime import timedelta from http import HTTPStatus import os from unittest.mock import AsyncMock, patch +from aiohasupervisor import SupervisorError from aiohasupervisor.models import IngressPanel import pytest from homeassistant.components.hassio import DOMAIN -from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED +from homeassistant.components.hassio.const import ( + MAIN_COORDINATOR, + REQUEST_REFRESH_DELAY, +) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockUser -from tests.typing import ClientSessionGenerator +from tests.common import MockUser, async_fire_time_changed +from tests.typing import ClientSessionGenerator, WebSocketGenerator MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @@ -24,10 +31,15 @@ def mock_all(all_setup_requests: None) -> None: @pytest.mark.usefixtures("supervisor_client") -async def test_hassio_addon_panel_startup( +async def test_hassio_addon_panel_registered_on_setup( hass: HomeAssistant, ingress_panels: AsyncMock ) -> None: - """Test startup and panel setup after event.""" + """Test enabled panels are registered as part of config entry setup. + + Regression test for https://github.com/home-assistant/supervisor/issues/7015: + registration must not depend on the one-shot EVENT_HOMEASSISTANT_START handler + that used to swallow Supervisor timeouts and never retry. + """ ingress_panels.return_value = { "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), "test2": IngressPanel( @@ -35,24 +47,145 @@ async def test_hassio_addon_panel_startup( ), } + with ( + patch( + "homeassistant.components.hassio.addon_panel._register_panel" + ) as mock_panel, + patch.dict(os.environ, MOCK_ENVIRON), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + mock_panel.assert_called_once_with( + hass, + "test1", + IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + ) + + +@pytest.mark.usefixtures("supervisor_client") +async def test_hassio_addon_panel_registration( + hass: HomeAssistant, ingress_panels: AsyncMock +) -> None: + """Test panel registration calls frontend.async_register_built_in_panel.""" + ingress_panels.return_value = { + "test_addon": IngressPanel( + enable=True, title="Test Addon", icon="mdi:test-tube", admin=True + ), + } + + with ( + patch( + "homeassistant.components.hassio.addon_panel.frontend.async_register_built_in_panel" + ) as mock_register, + patch.dict(os.environ, MOCK_ENVIRON), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + mock_register.assert_any_call( + hass, + "app", + frontend_url_path="test_addon", + sidebar_title="Test Addon", + sidebar_icon="mdi:test-tube", + require_admin=True, + config={"addon": "test_addon"}, + update=True, + ) + + +async def test_hassio_addon_panel_setup_retries_after_transient_error( + hass: HomeAssistant, ingress_panels: AsyncMock +) -> None: + """Test a transient Supervisor error fetching panels causes setup to retry. + + Regression test for https://github.com/home-assistant/supervisor/issues/7015: + previously a timeout fetching panels at startup was logged and swallowed, + leaving panels missing forever with no retry. Panel data is now fetched as + part of the main coordinator's first refresh, so a transient failure causes + the whole config entry setup to retry until Supervisor is reachable again. + """ + ingress_panels.side_effect = SupervisorError("Timeout connecting to Supervisor") + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert result + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is ConfigEntryState.SETUP_RETRY + + ingress_panels.side_effect = None + ingress_panels.return_value = { + "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + } + with patch( - "homeassistant.components.hassio.addon_panel._register_panel", + "homeassistant.components.hassio.addon_panel._register_panel" ) as mock_panel: - with patch.dict(os.environ, MOCK_ENVIRON): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + mock_panel.assert_called_once_with( + hass, + "test1", + IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + ) - ingress_panels.assert_not_called() - mock_panel.assert_not_called() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) +async def test_hassio_addon_panel_recovers_after_supervisor_restart( + hass: HomeAssistant, + hass_supervisor_ws_client: WebSocketGenerator, + ingress_panels: AsyncMock, +) -> None: + """Test panels are refreshed when Supervisor reports it has restarted. + + Regression test for the "Supervisor restarts while Core keeps running" + scenario: Supervisor fires a supervisor_update/startup:complete event on + every one of its own (re)starts, which the main coordinator already listens + for and uses to trigger a refresh. + """ + ingress_panels.return_value = {} + + with ( + patch( + "homeassistant.components.hassio.addon_panel._register_panel" + ) as mock_panel, + patch.dict(os.environ, MOCK_ENVIRON), + ): + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + + mock_panel.assert_not_called() + + ingress_panels.return_value = { + "test1": IngressPanel( + enable=True, title="Test", icon="mdi:test", admin=False + ), + } + + client = await hass_supervisor_ws_client() + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "supervisor_update", + "update_key": "supervisor", + "data": {"startup": "complete"}, + }, + } + ) + await client.receive_json() + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DELAY + 1) + ) await hass.async_block_till_done() - ingress_panels.assert_called_once() - assert mock_panel.called - mock_panel.assert_called_with( + mock_panel.assert_called_once_with( hass, "test1", IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), @@ -60,10 +193,10 @@ async def test_hassio_addon_panel_startup( @pytest.mark.usefixtures("supervisor_client") -async def test_hassio_addon_panel_api( +async def test_hassio_addon_panel_api_post( hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock ) -> None: - """Test panel api after event.""" + """Test posting a panel push registers it via the coordinator cache.""" ingress_panels.return_value = { "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), "test2": IngressPanel( @@ -75,37 +208,77 @@ async def test_hassio_addon_panel_api( await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() + hass_client = await hass_client() + with patch( - "homeassistant.components.hassio.addon_panel._register_panel", + "homeassistant.components.hassio.addon_panel._register_panel" ) as mock_panel: - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() + # Panel is not enabled yet according to Supervisor + resp = await hass_client.post("/api/hassio_push/panel/test2") + assert resp.status == HTTPStatus.BAD_REQUEST + mock_panel.assert_not_called() - ingress_panels.assert_called_once() - assert mock_panel.called - mock_panel.assert_called_with( + # Supervisor enables the panel and pushes the change + ingress_panels.return_value["test2"] = IngressPanel( + enable=True, title="Test 2", icon="mdi:test2", admin=True + ) + resp = await hass_client.post("/api/hassio_push/panel/test2") + assert resp.status == HTTPStatus.OK + mock_panel.assert_called_once_with( hass, - "test1", - IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + "test2", + IngressPanel(enable=True, title="Test 2", icon="mdi:test2", admin=True), ) - hass_client = await hass_client() + # Posting again for an already-registered, unchanged panel is a no-op + mock_panel.reset_mock() + resp = await hass_client.post("/api/hassio_push/panel/test1") + assert resp.status == HTTPStatus.OK + mock_panel.assert_not_called() - resp = await hass_client.post("/api/hassio_push/panel/test2") - assert resp.status == HTTPStatus.BAD_REQUEST +@pytest.mark.usefixtures("supervisor_client") +async def test_hassio_addon_panel_api_before_coordinator_ready( + hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock +) -> None: + """Test panel push api falls back to a fresh Supervisor call before setup completes. + + Other callers besides Supervisor may rely on this API before the config + entry (and its main coordinator) finishes setting up, so it must keep + working via a direct Supervisor call and frontend registration instead of + failing with a 503. + """ + ingress_panels.return_value = { + "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + } + + with patch.dict(os.environ, MOCK_ENVIRON): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + hass_client = await hass_client() + + # Simulate the main coordinator not being ready yet + del hass.data[MAIN_COORDINATOR] + + with patch( + "homeassistant.components.hassio.addon_panel._register_panel" + ) as mock_panel: resp = await hass_client.post("/api/hassio_push/panel/test1") assert resp.status == HTTPStatus.OK - assert mock_panel.call_count == 2 - - mock_panel.assert_called_with( + mock_panel.assert_called_once_with( hass, "test1", IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), ) + with patch( + "homeassistant.components.hassio.addon_panel.frontend.async_remove_panel" + ) as mock_remove: + resp = await hass_client.delete("/api/hassio_push/panel/test1") + assert resp.status == HTTPStatus.OK + mock_remove.assert_called_once_with(hass, "test1", warn_if_unknown=False) + @pytest.mark.usefixtures("supervisor_client") async def test_hassio_addon_panel_api_non_admin( @@ -123,21 +296,12 @@ async def test_hassio_addon_panel_api_non_admin( await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() + hass_admin_user.groups = [] + hass_client = await hass_client() + with patch( - "homeassistant.components.hassio.addon_panel._register_panel", + "homeassistant.components.hassio.addon_panel._register_panel" ) as mock_panel: - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - ingress_panels.assert_called_once() - mock_panel.assert_called_once() - - mock_panel.reset_mock() - hass_admin_user.groups = [] - hass_client = await hass_client() - # Both should return unauthorized regardless of enabled as the endpoint requires # admin and the user is not admin resp = await hass_client.post("/api/hassio_push/panel/test2") @@ -149,47 +313,11 @@ async def test_hassio_addon_panel_api_non_admin( mock_panel.assert_not_called() -@pytest.mark.usefixtures("supervisor_client") -async def test_hassio_addon_panel_registration( - hass: HomeAssistant, ingress_panels: AsyncMock -) -> None: - """Test panel registration calls frontend.async_register_built_in_panel.""" - ingress_panels.return_value = { - "test_addon": IngressPanel( - enable=True, title="Test Addon", icon="mdi:test-tube", admin=True - ), - } - - with patch.dict(os.environ, MOCK_ENVIRON): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - with patch( - "homeassistant.components.hassio.addon_panel.frontend.async_register_built_in_panel" - ) as mock_register: - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - # Verify that async_register_built_in_panel was called with correct arguments - # for our test addon - mock_register.assert_any_call( - hass, - "app", - frontend_url_path="test_addon", - sidebar_title="Test Addon", - sidebar_icon="mdi:test-tube", - require_admin=True, - config={"addon": "test_addon"}, - ) - - @pytest.mark.usefixtures("supervisor_client") async def test_hassio_addon_panel_api_delete( hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock ) -> None: - """Test panel api delete.""" + """Test panel api delete removes it via the coordinator cache.""" ingress_panels.return_value = { "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), } @@ -204,7 +332,7 @@ async def test_hassio_addon_panel_api_delete( ) as mock_remove: resp = await hass_client.delete("/api/hassio_push/panel/test1") assert resp.status == HTTPStatus.OK - mock_remove.assert_called_once_with(hass, "test1") + mock_remove.assert_called_once_with(hass, "test1", warn_if_unknown=False) @pytest.mark.usefixtures("supervisor_client") diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 26b293b778d2d1..220f104f64e6ee 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,7 +5,7 @@ import os from pathlib import PurePath from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, call, patch +from unittest.mock import AsyncMock, Mock, call, patch from uuid import uuid4 from aiohasupervisor import SupervisorBadRequestError, SupervisorError @@ -174,7 +174,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 assert get_core_info(hass)["version_latest"] == "1.0.0" assert is_hassio(hass) @@ -310,9 +310,9 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY) + HomeAssistantOptions(ssl=False, port=9999, refresh_token=None) ) @@ -326,7 +326,7 @@ async def test_setup_api_push_api_data_error( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 assert "Failed to update Home Assistant options in Supervisor: boom" in caplog.text @@ -347,9 +347,9 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY) + HomeAssistantOptions(ssl=False, port=9999, refresh_token=None) ) @@ -362,23 +362,16 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=ANY) - ) - refresh_token = ( - supervisor_client.homeassistant.set_options.mock_calls[0].args[0].refresh_token + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] assert hassio_user.system_generated assert len(hassio_user.groups) == 1 assert hassio_user.groups[0].id == GROUP_ID_ADMIN assert hassio_user.name == "Supervisor" - for token in hassio_user.refresh_tokens.values(): - if token.token == refresh_token: - break - else: - pytest.fail("refresh token not found") + assert not hassio_user.refresh_tokens async def test_setup_adds_admin_group_to_user(hass: HomeAssistant) -> None: @@ -399,6 +392,7 @@ async def test_setup_adds_admin_group_to_user(hass: HomeAssistant) -> None: assert result assert user.is_admin + assert not user.refresh_tokens async def test_setup_migrate_user_name(hass: HomeAssistant) -> None: @@ -418,6 +412,7 @@ async def test_setup_migrate_user_name(hass: HomeAssistant) -> None: assert result assert user.name == "Supervisor" + assert not user.refresh_tokens async def test_setup_api_existing_hassio_user( @@ -425,7 +420,10 @@ async def test_setup_api_existing_hassio_user( ) -> None: """Test setup uses the user from config entry data.""" user = await hass.auth.async_create_system_user("Hass.io test") - token = await hass.auth.async_create_refresh_token(user) + refresh_tokens = [ + await hass.auth.async_create_refresh_token(user) for _ in range(2) + ] + access_token = hass.auth.async_create_access_token(refresh_tokens[0]) config_entry = MockConfigEntry( domain=DOMAIN, data={ENTRY_DATA_USER: user.id}, @@ -438,10 +436,12 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token) + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) + assert not user.refresh_tokens + assert hass.auth.async_validate_access_token(access_token) is None async def test_setup_migrates_legacy_hassio_store_to_config_entry( @@ -451,7 +451,7 @@ async def test_setup_migrates_legacy_hassio_store_to_config_entry( ) -> None: """Test setup migrates legacy hassio store user/options into config entry.""" user = await hass.auth.async_create_system_user("Hass.io test") - token = await hass.auth.async_create_refresh_token(user) + await hass.auth.async_create_refresh_token(user) config_entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) @@ -483,10 +483,11 @@ async def test_setup_migrates_legacy_hassio_store_to_config_entry( assert entry.options[OPTION_ADD_ON_BACKUP_RETAIN_COPIES] == 2 assert entry.options[OPTION_CORE_BACKUP_BEFORE_UPDATE] is True - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token) + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) + assert not user.refresh_tokens async def test_setup_migrates_legacy_options_over_default_entry_options( @@ -496,7 +497,7 @@ async def test_setup_migrates_legacy_options_over_default_entry_options( ) -> None: """Test legacy update options override default config entry options.""" user = await hass.auth.async_create_system_user("Hass.io test") - token = await hass.auth.async_create_refresh_token(user) + await hass.auth.async_create_refresh_token(user) config_entry = MockConfigEntry( domain=DOMAIN, @@ -533,8 +534,9 @@ async def test_setup_migrates_legacy_options_over_default_entry_options( assert entry.options[OPTION_CORE_BACKUP_BEFORE_UPDATE] is True supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token) + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) + assert not user.refresh_tokens async def test_setup_core_push_config( @@ -548,7 +550,7 @@ async def test_setup_core_push_config( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.supervisor.set_options.assert_called_once_with( SupervisorOptions(timezone="testzone") ) @@ -573,7 +575,7 @@ async def test_setup_core_push_config_error( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 assert "Failed to update Supervisor options: boom" in caplog.text @@ -589,7 +591,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 async def test_fail_setup_without_environ_var(hass: HomeAssistant) -> None: @@ -1320,7 +1322,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done(wait_background_tasks=True) assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 assert len(mock_setup_entry.mock_calls) == 1 @@ -2040,6 +2042,15 @@ async def test_supervisor_issues_not_set_on_coordinator_failure( If a coordinator first-refresh raises ConfigEntryNotReady the issues listener must not be registered, preventing accumulation across retries. """ + user = await hass.auth.async_create_system_user("Hass.io test") + refresh_token = await hass.auth.async_create_refresh_token(user) + access_token = hass.auth.async_create_access_token(refresh_token) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ENTRY_DATA_USER: user.id}, + unique_id=DOMAIN, + ) + config_entry.add_to_hass(hass) supervisor_root_info.side_effect = SupervisorError() with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component(hass, DOMAIN, {}) @@ -2048,3 +2059,5 @@ async def test_supervisor_issues_not_set_on_coordinator_failure( entry = hass.config_entries.async_entries("hassio")[0] assert entry.state is ConfigEntryState.SETUP_RETRY assert DATA_KEY_SUPERVISOR_ISSUES not in hass.data + assert not user.refresh_tokens + assert hass.auth.async_validate_access_token(access_token) is None diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 972634199f1518..7ba8de4a9308bf 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -375,14 +375,12 @@ async def test_websocket_non_admin_user( assert msg["error"]["message"] == "Unauthorized" -async def test_websocket_store_reload_refreshes_update_entities( +async def test_store_reloaded_event_refreshes_update_entities( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, supervisor_client: AsyncMock, addons_list: AsyncMock, ) -> None: - """Test add-on update entities refresh after a store reload via the API proxy.""" + """Test add-on update entities refresh on a Supervisor store_reloaded event.""" addons_list.return_value = [ replace( addons_list.return_value[0], @@ -406,25 +404,45 @@ async def test_websocket_store_reload_refreshes_update_entities( version_latest="2.0.1", ) ] - aioclient_mock.post( - "http://127.0.0.1/store/reload", json={"result": "ok", "data": {}} - ) - websocket_client = await hass_ws_client(hass) - await websocket_client.send_json_auto_id( - { - WS_TYPE: WS_TYPE_API, - ATTR_ENDPOINT: "/store/reload", - ATTR_METHOD: "post", - } + async_dispatcher_send( + hass, + EVENT_SUPERVISOR_EVENT, + {"event": "store_reloaded", "data": {"repositories": ["core"]}}, ) - msg = await websocket_client.receive_json() - assert msg["success"] + await hass.async_block_till_done() assert hass.states.get("update.test_update").state == "on" + # Supervisor already reloaded the store, so we must not reload it again. supervisor_client.store.reload.assert_not_called() +async def test_store_reloaded_event_ignored_without_listeners( + hass: HomeAssistant, + addons_list: AsyncMock, +) -> None: + """Test a store_reloaded event does not refresh without add-on entities.""" + addons_list.return_value = [] + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component(hass, DOMAIN, {"hassio": {}}) + await hass.async_block_till_done() + + # Without add-on entities the coordinator has no listeners, + # so the event must not trigger an add-on data fetch. + addons_list.reset_mock() + async_dispatcher_send( + hass, + EVENT_SUPERVISOR_EVENT, + {"event": "store_reloaded", "data": {"repositories": ["core"]}}, + ) + await hass.async_block_till_done() + + addons_list.assert_not_called() + + async def test_update_addon( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 2ddf5a718553be..70a8b18d354e19 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -374,16 +374,19 @@ "active_source": "test_group_player_1", "active_group": null, "current_media": { - "uri": "http://192.168.1.1:8097/single/test_group_player_1/5d95dc5be77e4f7eb4939f62cfef527b.flac?ts=1730313038", - "media_type": "unknown", - "title": null, - "artist": null, - "album": null, + "uri": "spotify://track/3YRCqOhFifThpSRFJ1VWFM", + "media_type": "track", + "title": "November Rain", + "artist": "Guns N' Roses", + "album": "Use Your Illusion I", + "album_artist": "Guns N' Roses", "image_url": null, - "duration": null, + "duration": 536, "queue_id": "test_group_player_1", "queue_item_id": "5d95dc5be77e4f7eb4939f62cfef527b", - "custom_data": null + "custom_data": null, + "elapsed_time": 232, + "elapsed_time_last_updated": 1730313109.5659513 }, "synced_to": null, "enabled_by_default": true, diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index 95810a6612e05b..b6c985dd8c3878 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -55,7 +55,6 @@ : 'spotify://track/5d95dc5be77e4f7eb4939f62cfef527b', : , : 300, - : 0, : 'Test Track', : 'Spotify Connect', : , @@ -122,6 +121,7 @@ : 'mdi:speaker-multiple', : False, 'mass_player_type': 'group', + : "Guns N' Roses", : 'Use Your Illusion I', : "Guns N' Roses", : 'spotify://track/3YRCqOhFifThpSRFJ1VWFM', diff --git a/tests/components/tractive/test_config_flow.py b/tests/components/tractive/test_config_flow.py index 49428b4931e29b..b2af9c13d8dd01 100644 --- a/tests/components/tractive/test_config_flow.py +++ b/tests/components/tractive/test_config_flow.py @@ -140,9 +140,17 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: ) first_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("aiotractive.api.API.user_id", return_value="USERID"): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/conftest.py b/tests/conftest.py index a65030266e4bda..665d019330a21a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -959,22 +959,31 @@ def hass_ws_client( """Websocket client fixture connected to websocket server.""" async def create_client( - hass: HomeAssistant = hass, access_token: str | None = hass_access_token + hass: HomeAssistant = hass, + access_token: str | None = hass_access_token, + supervisor_unix_socket: bool = False, ) -> MockHAClientWebSocket: - """Create a websocket client.""" + """Create a client, skipping token auth for Supervisor Unix sockets.""" assert await async_setup_component(hass, "websocket_api", {}) client = await aiohttp_client(hass.http.app) websocket = await client.ws_connect(URL) auth_resp = await websocket.receive_json() - assert auth_resp["type"] == TYPE_AUTH_REQUIRED - - if access_token is None: - await websocket.send_json({"type": TYPE_AUTH, "access_token": "incorrect"}) + if supervisor_unix_socket: + assert auth_resp["type"] == TYPE_AUTH_OK else: - await websocket.send_json({"type": TYPE_AUTH, "access_token": access_token}) + assert auth_resp["type"] == TYPE_AUTH_REQUIRED + + if access_token is None: + await websocket.send_json( + {"type": TYPE_AUTH, "access_token": "incorrect"} + ) + else: + await websocket.send_json( + {"type": TYPE_AUTH, "access_token": access_token} + ) - auth_ok = await websocket.receive_json() - assert auth_ok["type"] == TYPE_AUTH_OK + auth_ok = await websocket.receive_json() + assert auth_ok["type"] == TYPE_AUTH_OK def _get_next_id() -> Generator[int]: i = 0 diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index d541e5413c5367..2f732a57482c34 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -453,7 +453,7 @@ async def test_loading_from_storage( await dr.async_load(hass) registry = dr.async_get(hass) assert len(registry.devices) == 1 - assert len(registry.deleted_devices) == 1 + assert len(registry._deleted_devices) == 1 # A stored child device is loaded, with disabled_by "device" restored to the enum loaded_child = registry.async_get("childdeviceid", include_main_devices=False) @@ -462,7 +462,7 @@ async def test_loading_from_storage( assert loaded_child.disabled_by is dr.DeviceEntryDisabler.DEVICE assert loaded_child.identifiers == {("test", "strip_outlet_1")} - assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( + assert registry._deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( area_id="12345A", config_entry_id=mock_config_entry.entry_id, config_subentry_id=None, @@ -604,7 +604,7 @@ async def test_migration_from_1_1( ) assert entry.id == "abcdefghijklm" - deleted_entry = registry.deleted_devices["deletedid"] + deleted_entry = registry._deleted_devices["deletedid"] assert deleted_entry.disabled_by is UNDEFINED # Update to trigger a store @@ -1669,7 +1669,7 @@ async def test_migration_from_1_10( identifiers={("serial", "123456ABCDEF")}, ) assert entry.id == "abcdefghijklm" - deleted_entry = registry.deleted_devices.get_entry( + deleted_entry = registry._deleted_devices.get_entry( connections=set(), identifiers={("serial", "123456ABCDAB")}, ) @@ -1812,7 +1812,7 @@ async def test_migration_from_1_11( identifiers={("serial", "123456ABCDEF")}, ) assert entry.id == "abcdefghijklm" - deleted_entry = registry.deleted_devices.get_entry( + deleted_entry = registry._deleted_devices.get_entry( connections=set(), identifiers={("serial", "123456ABCDAB")}, ) @@ -2901,7 +2901,7 @@ async def test_move_to_config_entry_clears_target_entry_deleted_device( # Leave a deleted device owned by entry_b with the shared identity device_registry.async_remove_device(device_b.id) - assert device_b.id in device_registry.deleted_devices + assert device_b.id in device_registry._deleted_devices # Move device_a into entry_b, retaining its identity device_registry.async_update_device( @@ -2910,7 +2910,7 @@ async def test_move_to_config_entry_clears_target_entry_deleted_device( assert device_registry.async_get(device_a.id).config_entry_id == entry_b.entry_id # The deleted device entry_b held for the same identity is cleared, not left immortal - assert device_b.id not in device_registry.deleted_devices + assert device_b.id not in device_registry._deleted_devices async def test_get_or_create_via_device_and_via_device_id_raises_cleanly( @@ -3001,7 +3001,7 @@ async def test_reregister_restores_orphan( # Removing the config entry orphans the deleted device (config_entry_id=None) device_registry.async_clear_config_entry(entry.entry_id, clear_domain) - orphan = device_registry.deleted_devices[device.id] + orphan = device_registry._deleted_devices[device.id] assert orphan.config_entry_id is None assert orphan.domain == "light" @@ -3032,7 +3032,7 @@ async def test_orphan_not_restored_for_other_domain( config_entry_id=entry.entry_id, identifiers={("light", "1")} ) device_registry.async_clear_config_entry(entry.entry_id, entry.domain) - assert device_registry.deleted_devices[device.id].domain == "light" + assert device_registry._deleted_devices[device.id].domain == "light" # A different integration registering a device with the same identifiers gets a fresh # device, and the orphan is left intact for its own integration to restore later @@ -3042,7 +3042,7 @@ async def test_orphan_not_restored_for_other_domain( config_entry_id=other_entry.entry_id, identifiers={("light", "1")} ) assert fresh.id != device.id - assert device.id in device_registry.deleted_devices + assert device.id in device_registry._deleted_devices async def test_orphaning_replaces_colliding_same_domain_orphan( @@ -3071,12 +3071,12 @@ async def test_orphaning_replaces_colliding_same_domain_orphan( ) device_registry.async_clear_config_entry(entry_1.entry_id, entry_1.domain) - assert device_1.id in device_registry.deleted_devices + assert device_1.id in device_registry._deleted_devices device_registry.async_clear_config_entry(entry_2.entry_id, entry_2.domain) # The newer orphan replaces the stale one it collides with on the shared connection - assert device_1.id not in device_registry.deleted_devices - assert device_2.id in device_registry.deleted_devices + assert device_1.id not in device_registry._deleted_devices + assert device_2.id in device_registry._deleted_devices # Re-adding under the same domain restores the surviving orphan entry_3 = MockConfigEntry(domain="hue") @@ -3104,7 +3104,7 @@ async def test_orphaned_domain_survives_store_round_trip( await flush_store(device_registry._store) await registry2.async_load() - assert registry2.deleted_devices[device.id].domain == "hue" + assert registry2._deleted_devices[device.id].domain == "hue" async def test_orphan_keeps_domain_when_config_entry_removed( @@ -3126,7 +3126,7 @@ async def test_orphan_keeps_domain_when_config_entry_removed( await hass.config_entries.async_remove(entry.entry_id) - orphan = device_registry.deleted_devices[device.id] + orphan = device_registry._deleted_devices[device.id] assert orphan.config_entry_id is None assert orphan.domain == "hue" @@ -3189,7 +3189,7 @@ async def test_domainless_orphan_not_restored( # orphans over without one) with patch.object(hass.config_entries, "async_get_entry", return_value=None): device_registry.async_clear_config_entry(entry_1.entry_id) - assert device_registry.deleted_devices[device_1.id].domain is None + assert device_registry._deleted_devices[device_1.id].domain is None # Re-registering the shared identifier does not restore the domain-less orphan entry_2 = MockConfigEntry(domain="hue") @@ -3199,7 +3199,7 @@ async def test_domainless_orphan_not_restored( ) assert fresh.id != device_1.id # The un-restored orphan lingers until the periodic purge - assert device_1.id in device_registry.deleted_devices + assert device_1.id in device_registry._deleted_devices async def test_clear_config_subentry_removes_device_with_pending_move( @@ -3796,9 +3796,9 @@ async def test_migration_splits_deleted_device_with_multiple_config_entries( registry = dr.async_get(hass) # Split into one deleted device per config entry, each keeping identity/customizations - assert len(registry.deleted_devices) == 2 - assert "deletedcomposite0000000000000" not in registry.deleted_devices - by_entry = {d.config_entry_id: d for d in registry.deleted_devices.values()} + assert len(registry._deleted_devices) == 2 + assert "deletedcomposite0000000000000" not in registry._deleted_devices + by_entry = {d.config_entry_id: d for d in registry._deleted_devices.values()} assert set(by_entry) == {entry_a.entry_id, entry_b.entry_id} for deleted in by_entry.values(): assert deleted.identifiers == {("domain_a", "1")} @@ -3883,21 +3883,21 @@ async def test_deleted_device_removing_config_entries( device_registry.async_remove_device(entry.id) device_registry.async_remove_device(entry2.id) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 2 + assert len(device_registry._deleted_devices) == 2 device_registry.async_clear_config_entry(config_entry_1.entry_id) # Deleted devices are kept but orphaned (config entry cleared) so they can be purged - assert len(device_registry.deleted_devices) == 2 - assert device_registry.deleted_devices[entry.id].config_entry_id is None + assert len(device_registry._deleted_devices) == 2 + assert device_registry._deleted_devices[entry.id].config_entry_id is None assert ( - device_registry.deleted_devices[entry2.id].config_entry_id + device_registry._deleted_devices[entry2.id].config_entry_id == config_entry_2.entry_id ) device_registry.async_clear_config_entry(config_entry_2.entry_id) - assert len(device_registry.deleted_devices) == 2 - assert device_registry.deleted_devices[entry2.id].config_entry_id is None + assert len(device_registry._deleted_devices) == 2 + assert device_registry._deleted_devices[entry2.id].config_entry_id is None async def test_removing_config_subentries( @@ -3990,17 +3990,17 @@ async def test_deleted_device_removing_config_subentries( device_registry.async_remove_device(entry.id) device_registry.async_remove_device(entry2.id) - assert len(device_registry.deleted_devices) == 2 + assert len(device_registry._deleted_devices) == 2 device_registry.async_clear_config_subentry( config_entry.entry_id, "mock-subentry-id-1" ) # Only the deleted device on the cleared subentry is orphaned - assert len(device_registry.deleted_devices) == 2 - assert device_registry.deleted_devices[entry.id].config_entry_id is None + assert len(device_registry._deleted_devices) == 2 + assert device_registry._deleted_devices[entry.id].config_entry_id is None assert ( - device_registry.deleted_devices[entry2.id].config_entry_id + device_registry._deleted_devices[entry2.id].config_entry_id == config_entry.entry_id ) @@ -4441,6 +4441,53 @@ async def test_devices_membership_by_entry_supported_by_id_deprecated( assert caplog.text.count(what) == 1 +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(RuntimeError), + 1, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_deleted_devices_deprecated( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test accessing `DeviceRegistry.deleted_devices` is deprecated. + + It logs for custom integrations and raises for core and core integrations. + """ + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + ) + device_registry.async_remove_device(entry.id) + what = "accesses `device_registry.deleted_devices`" + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + deleted_devices = device_registry.deleted_devices + # Custom integrations still receive the underlying container. + assert entry.id in deleted_devices + + assert caplog.text.count(what) == expected_log + + @pytest.mark.parametrize( ("integration_frame_path", "expectation", "expected_log"), [ @@ -5097,7 +5144,7 @@ async def test_loading_saving_data( # different config entry, so it is a separate device (identifiers/connections are # unique per config entry) assert len(device_registry.devices) == 5 - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 orig_via = device_registry.async_update_device( orig_via.id, @@ -5113,7 +5160,7 @@ async def test_loading_saving_data( # Ensure same order assert list(device_registry._devices) == list(registry2._devices) - assert list(device_registry.deleted_devices) == list(registry2.deleted_devices) + assert list(device_registry._deleted_devices) == list(registry2._deleted_devices) new_via = registry2.async_get_device(identifiers={("hue", "0123")}) new_light = registry2.async_get_device(identifiers={("hue", "456")}) @@ -5931,8 +5978,8 @@ async def test_create_reflects_config_entry_disabled_state( # Restoring a deleted device from a legacy store without a recorded # disabled_by is reconciled the same way device_registry.async_remove_device(device.id) - deleted_entry = device_registry.deleted_devices[device.id] - device_registry.deleted_devices[device.id] = attr.evolve( + deleted_entry = device_registry._deleted_devices[device.id] + device_registry._deleted_devices[device.id] = attr.evolve( deleted_entry, disabled_by=UNDEFINED ) restored = device_registry.async_get_or_create( @@ -6710,12 +6757,12 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices( device_registry.async_clear_config_entry(config_entry.entry_id) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 3 + assert len(device_registry._deleted_devices) == 3 dr.async_cleanup(hass, device_registry, entity_registry) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 3 + assert len(device_registry._deleted_devices) == 3 future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 @@ -6723,7 +6770,7 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices( dr.async_cleanup(hass, device_registry, entity_registry) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 async def test_cleanup_startup(hass: HomeAssistant) -> None: @@ -6822,12 +6869,12 @@ async def test_restore_device( ) assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 device_registry.async_remove_device(entry.id) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 # This will create a new device entry2 = device_registry.async_get_or_create( @@ -6905,7 +6952,7 @@ async def test_restore_device( assert entry.id == entry3.id assert entry.id != entry2.id assert len(device_registry.devices) == 2 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 assert isinstance(entry3.config_entries, set) assert isinstance(entry3.connections, set) @@ -6985,7 +7032,7 @@ async def test_restore_device_reflects_reregistered_identity( identifiers=stored_identifiers, ) device_registry.async_remove_device(entry.id) - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 restored = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, @@ -7033,7 +7080,7 @@ async def test_deleted_device_to_device_entry_uses_reregistered_identity( identifiers={("bridgeid", "0123")}, ) device_registry.async_remove_device(entry.id) - deleted_device = device_registry.deleted_devices[entry.id] + deleted_device = device_registry._deleted_devices[entry.id] restored = deleted_device.to_device_entry( mock_config_entry, @@ -7090,15 +7137,15 @@ async def test_restore_migrated_device_disabled_by( ) assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 device_registry.async_remove_device(entry.id) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 - deleted_entry = device_registry.deleted_devices[entry.id] - device_registry.deleted_devices[entry.id] = attr.evolve( + deleted_entry = device_registry._deleted_devices[entry.id] + device_registry._deleted_devices[entry.id] = attr.evolve( deleted_entry, disabled_by=UNDEFINED ) @@ -7148,7 +7195,7 @@ async def test_restore_migrated_device_disabled_by( assert entry.id == entry3.id assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 assert isinstance(entry3.config_entries, set) assert isinstance(entry3.connections, set) @@ -7259,20 +7306,20 @@ async def test_restore_disabled_by( ) assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 device_registry.async_remove_device(entry.id) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 # Simulate the disabled_by flag the device had when it was deleted. The # device may have been deleted before the config entry's disabled state # last changed - deleted devices are not updated when a config entry is # enabled or disabled, so the stored flag can contradict the entry's # current disabled state. - deleted_entry = device_registry.deleted_devices[entry.id] - device_registry.deleted_devices[entry.id] = attr.evolve( + deleted_entry = device_registry._deleted_devices[entry.id] + device_registry._deleted_devices[entry.id] = attr.evolve( deleted_entry, disabled_by=device_disabled_by_deleted ) @@ -7322,7 +7369,7 @@ async def test_restore_disabled_by( assert entry.id == entry3.id assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 assert isinstance(entry3.config_entries, set) assert isinstance(entry3.connections, set) @@ -8291,10 +8338,10 @@ async def test_device_registry_deleted_device_collision( manufacturer="manufacturer", model="model", ) - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 device_registry.async_remove_device(device1.id) - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 device2 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -8302,13 +8349,13 @@ async def test_device_registry_deleted_device_collision( manufacturer="manufacturer", model="model", ) - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 device_registry.async_update_device( device2.id, merge_connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")}, ) - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 async def test_update_device_no_connections_or_identifiers( @@ -8752,7 +8799,7 @@ async def test_legacy_duplicate_fully_stripped_device_removed( assert registered.id == "device" assert registered.identifiers == {("test", "device"), ("test", "shared")} assert device_registry.async_get("stale") is None - assert "stale" not in device_registry.deleted_devices + assert "stale" not in device_registry._deleted_devices assert ( device_registry.async_get_device(identifiers={("test", "shared")}).id == "device" @@ -8851,7 +8898,7 @@ def _stored_device( ) assert registered.id == "new" assert registry.async_get("old") is None - assert "old" not in registry.deleted_devices + assert "old" not in registry._deleted_devices # The reconciled state is persisted await flush_store(registry._store) @@ -8882,13 +8929,13 @@ async def test_registration_purges_same_entry_deleted_duplicates( ), }, ) - device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_shadowed"] = _mock_deleted_device( "deleted_shadowed", entry.entry_id, {("test", "shared"), ("test", "other")} ) - device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_winner"] = _mock_deleted_device( "deleted_winner", entry.entry_id, {("test", "shared")} ) - device_registry.deleted_devices["deleted_other_entry"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_other_entry"] = _mock_deleted_device( "deleted_other_entry", other_entry.entry_id, {("test", "shared")} ) @@ -8897,9 +8944,9 @@ async def test_registration_purges_same_entry_deleted_duplicates( ) assert registered.id == "device" - assert "deleted_winner" not in device_registry.deleted_devices - assert "deleted_shadowed" not in device_registry.deleted_devices - assert "deleted_other_entry" in device_registry.deleted_devices + assert "deleted_winner" not in device_registry._deleted_devices + assert "deleted_shadowed" not in device_registry._deleted_devices + assert "deleted_other_entry" in device_registry._deleted_devices # The purge is persisted await flush_store(device_registry._store) assert [ @@ -8915,10 +8962,10 @@ async def test_restore_purges_same_entry_deleted_duplicate( entry = MockConfigEntry(domain="test") entry.add_to_hass(hass) device_registry = mock_device_registry(hass) - device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_shadowed"] = _mock_deleted_device( "deleted_shadowed", entry.entry_id, {("test", "shared")} ) - device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_winner"] = _mock_deleted_device( "deleted_winner", entry.entry_id, {("test", "shared")} ) @@ -8927,8 +8974,8 @@ async def test_restore_purges_same_entry_deleted_duplicate( ) assert restored.id == "deleted_winner" - assert "deleted_winner" not in device_registry.deleted_devices - assert "deleted_shadowed" not in device_registry.deleted_devices + assert "deleted_winner" not in device_registry._deleted_devices + assert "deleted_shadowed" not in device_registry._deleted_devices assert len(device_registry.devices) == 1 @@ -8945,10 +8992,10 @@ async def test_add_identifier_prunes_shadowed_deleted_duplicates( device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={("test", "device")} ) - device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_shadowed"] = _mock_deleted_device( "deleted_shadowed", entry.entry_id, {("test", "shared")} ) - device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_winner"] = _mock_deleted_device( "deleted_winner", entry.entry_id, {("test", "shared"), ("test", "other")} ) @@ -8956,8 +9003,8 @@ async def test_add_identifier_prunes_shadowed_deleted_duplicates( device.id, merge_identifiers={("test", "shared")} ) - assert "deleted_winner" not in device_registry.deleted_devices - assert "deleted_shadowed" not in device_registry.deleted_devices + assert "deleted_winner" not in device_registry._deleted_devices + assert "deleted_shadowed" not in device_registry._deleted_devices async def test_via_device_id_to_removed_stale_duplicate_raises( @@ -10657,8 +10704,8 @@ async def test_remove_parent_cascades_to_children( assert device_registry.async_get(parent.id) is None assert device_registry.async_get(child_device.id) is None assert not device_registry.child_devices - assert child_device.id in device_registry.deleted_devices - assert parent.id in device_registry.deleted_devices + assert child_device.id in device_registry._deleted_devices + assert parent.id in device_registry._deleted_devices await hass.async_block_till_done() assert [event.data for event in remove_events] == [ @@ -11076,7 +11123,7 @@ async def test_link_device_info_matching_child_raises( # The child device is left untouched: not converted, no new device created assert len(device_registry.devices) == 1 assert len(device_registry.child_devices) == 1 - assert device_registry.child_devices[child_device.id] == child_device + assert device_registry._child_devices[child_device.id] == child_device assert child_device.identifiers == {("test", "strip_outlet_1")} @@ -12373,7 +12420,7 @@ async def test_recreate_child_clears_stale_config_entry_disable( _, child_device = _create_parent_and_child( device_registry, mock_config_entry.entry_id ) - device_registry.child_devices[child_device.id] = attr.evolve( + device_registry._child_devices[child_device.id] = attr.evolve( device_registry.async_get(child_device.id, include_main_devices=False), disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, ) @@ -12404,13 +12451,13 @@ async def test_update_child_identifiers_purges_colliding_deleted_device( name="Ghost", ) device_registry.async_remove_device(ghost.id) - assert ghost.id in device_registry.deleted_devices + assert ghost.id in device_registry._deleted_devices device_registry.async_update_child_device( child_device.id, new_identifiers={("test", "strip_outlet_1"), ("test", "ghost")}, ) - assert ghost.id not in device_registry.deleted_devices + assert ghost.id not in device_registry._deleted_devices @pytest.mark.usefixtures("hass")