diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 8705848c1c0..b8912ad3269 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -27,6 +27,7 @@ from __future__ import annotations import asyncio from collections import OrderedDict +from collections.abc import Iterable from enum import Enum import logging import os @@ -34,10 +35,12 @@ from typing import Any import async_timeout from google_nest_sdm.exceptions import ( + ApiException, AuthException, ConfigurationException, SubscriberException, ) +from google_nest_sdm.structure import InfoTrait, Structure import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -128,6 +131,17 @@ class UnexpectedStateError(HomeAssistantError): """Raised when the config flow is invoked in a 'should not happen' case.""" +def generate_config_title(structures: Iterable[Structure]) -> str | None: + """Pick a user friendly config title based on the Google Home name(s).""" + names: list[str] = [] + for structure in structures: + if (trait := structure.traits.get(InfoTrait.NAME)) and trait.custom_name: + names.append(trait.custom_name) + if not names: + return None + return ", ".join(names) + + class NestFlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): @@ -141,6 +155,8 @@ class NestFlowHandler( super().__init__() self._reauth = False self._data: dict[str, Any] = {DATA_SDM: {}} + # Possible name to use for config entry based on the Google Home name + self._structure_config_title: str | None = None @property def config_mode(self) -> ConfigMode: @@ -298,8 +314,18 @@ class NestFlowHandler( except SubscriberException as err: _LOGGER.error("Error creating subscription: %s", err) errors[CONF_CLOUD_PROJECT_ID] = "subscriber_error" - if not errors: + + try: + device_manager = await subscriber.async_get_device_manager() + except ApiException as err: + # Generating a user friendly home name is best effort + _LOGGER.debug("Error fetching structures: %s", err) + else: + self._structure_config_title = generate_config_title( + device_manager.structures.values() + ) + self._data.update( { CONF_SUBSCRIBER_ID: subscriber_id, @@ -339,7 +365,10 @@ class NestFlowHandler( ) await self.hass.config_entries.async_reload(entry.entry_id) return self.async_abort(reason="reauth_successful") - return await super().async_oauth_create_entry(self._data) + title = self.flow_impl.name + if self._structure_config_title: + title = self._structure_config_title + return self.async_create_entry(title=title, data=self._data) async def async_step_init( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index 18c5824de84..697adc6afcd 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -1,13 +1,14 @@ """Test the Google Nest Device Access config flow.""" import copy -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from google_nest_sdm.exceptions import ( AuthException, ConfigurationException, SubscriberException, ) +from google_nest_sdm.structure import Structure import pytest from homeassistant import config_entries, setup @@ -562,3 +563,163 @@ async def test_pubsub_subscriber_config_entry_reauth(hass, oauth, subscriber): assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN assert entry.data["subscriber_id"] == SUBSCRIBER_ID assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + + +async def test_config_entry_title_from_home(hass, oauth, subscriber): + """Test that the Google Home name is used for the config entry title.""" + + device_manager = await subscriber.async_get_device_manager() + device_manager.add_structure( + Structure.MakeStructure( + { + "name": f"enterprise/{PROJECT_ID}/structures/some-structure-id", + "traits": { + "sdm.structures.traits.Info": { + "customName": "Example Home", + }, + }, + } + ) + ) + + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + assert entry.title == "Example Home" + assert "token" in entry.data + assert "subscriber_id" in entry.data + assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + + +async def test_config_entry_title_multiple_homes(hass, oauth, subscriber): + """Test handling of multiple Google Homes authorized.""" + + device_manager = await subscriber.async_get_device_manager() + device_manager.add_structure( + Structure.MakeStructure( + { + "name": f"enterprise/{PROJECT_ID}/structures/id-1", + "traits": { + "sdm.structures.traits.Info": { + "customName": "Example Home #1", + }, + }, + } + ) + ) + device_manager.add_structure( + Structure.MakeStructure( + { + "name": f"enterprise/{PROJECT_ID}/structures/id-2", + "traits": { + "sdm.structures.traits.Info": { + "customName": "Example Home #2", + }, + }, + } + ) + ) + + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + assert entry.title == "Example Home #1, Example Home #2" + + +async def test_title_failure_fallback(hass, oauth): + """Test exception handling when determining the structure names.""" + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + + mock_subscriber = AsyncMock(FakeSubscriber) + mock_subscriber.async_get_device_manager.side_effect = AuthException() + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=mock_subscriber, + ): + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + assert entry.title == "OAuth for Apps" + assert "token" in entry.data + assert "subscriber_id" in entry.data + assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + + +async def test_structure_missing_trait(hass, oauth, subscriber): + """Test handling the case where a structure has no name set.""" + + device_manager = await subscriber.async_get_device_manager() + device_manager.add_structure( + Structure.MakeStructure( + { + "name": f"enterprise/{PROJECT_ID}/structures/id-1", + # Missing Info trait + "traits": {}, + } + ) + ) + + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + # Fallback to default name + assert entry.title == "OAuth for Apps"