1 """Config flow to configure Nest.
3 This configuration flow supports the following:
4 - SDM API with Web OAuth flow with redirect back to Home Assistant
5 - Legacy Nest API auth flow with where user enters an auth code manually
7 NestFlowHandler is an implementation of AbstractOAuth2FlowHandler with
8 some overrides to custom steps inserted in the middle of the flow.
11 from __future__
import annotations
13 from collections.abc
import Iterable, Mapping
15 from typing
import TYPE_CHECKING, Any
17 from google_nest_sdm.admin_client
import (
19 EligibleSubscriptions,
22 from google_nest_sdm.exceptions
import ApiException
23 from google_nest_sdm.structure
import Structure
24 import voluptuous
as vol
32 CONF_CLOUD_PROJECT_ID,
34 CONF_SUBSCRIBER_ID_IMPORTED,
35 CONF_SUBSCRIPTION_NAME,
43 DATA_FLOW_IMPL =
"nest_flow_implementation"
44 SUBSCRIPTION_FORMAT =
"projects/{cloud_project_id}/subscriptions/home-assistant-{rnd}"
45 SUBSCRIPTION_RAND_LENGTH = 10
47 MORE_INFO_URL =
"https://www.home-assistant.io/integrations/nest/#configuration"
50 CLOUD_CONSOLE_URL =
"https://console.cloud.google.com/home/dashboard"
52 "https://console.cloud.google.com/apis/library/smartdevicemanagement.googleapis.com"
54 PUBSUB_API_URL =
"https://console.cloud.google.com/apis/library/pubsub.googleapis.com"
57 DEVICE_ACCESS_CONSOLE_URL =
"https://console.nest.google.com/device-access/"
59 DEVICE_ACCESS_CONSOLE_EDIT_URL = (
60 "https://console.nest.google.com/device-access/project/{project_id}/information"
62 CREATE_NEW_SUBSCRIPTION_KEY =
"create_new_subscription"
64 _LOGGER = logging.getLogger(__name__)
68 """Create a new subscription id."""
70 return SUBSCRIPTION_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd)
74 """Pick a user friendly config title based on the Google Home name(s)."""
76 structure.info.custom_name
77 for structure
in structures
78 if structure.info
and structure.info.custom_name
82 return ", ".join(names)
86 config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
88 """Config flow to handle authentication for both APIs."""
94 """Initialize NestFlowHandler."""
96 self._data: dict[str, Any] = {DATA_SDM: {}}
101 self._eligible_subscriptions: EligibleSubscriptions |
None =
None
106 return logging.getLogger(__name__)
110 """Extra data that needs to be appended to the authorize url."""
112 "scope":
" ".join(SDM_SCOPES),
114 "access_type":
"offline",
119 """Generate a url for the user to authorize based on user input."""
120 project_id = self._data.
get(CONF_PROJECT_ID)
122 authorize_url = OAUTH2_AUTHORIZE.format(project_id=project_id)
123 return f
"{authorize_url}{query}"
126 """Complete OAuth setup and finish pubsub or finish."""
127 _LOGGER.debug(
"Finishing post-oauth configuration")
129 _LOGGER.debug(
"self.source=%s", self.
sourcesource)
131 _LOGGER.debug(
"Skipping Pub/Sub configuration")
136 self, entry_data: Mapping[str, Any]
137 ) -> ConfigFlowResult:
138 """Perform reauth upon an API authentication error."""
139 _LOGGER.debug(
"async_step_reauth %s", self.
sourcesource)
140 self._data.
update(entry_data)
145 self, user_input: dict[str, Any] |
None =
None
146 ) -> ConfigFlowResult:
147 """Confirm reauth dialog."""
148 if user_input
is None:
149 return self.async_show_form(step_id=
"reauth_confirm")
153 self, user_input: dict[str, Any] |
None =
None
154 ) -> ConfigFlowResult:
155 """Handle a flow initialized by the user."""
156 self._data[DATA_SDM] = {}
157 if self.
sourcesource == SOURCE_REAUTH:
164 self, user_input: dict[str, Any] |
None =
None
165 ) -> ConfigFlowResult:
166 """Handle initial step in app credentials flow."""
167 implementations = await config_entry_oauth2_flow.async_get_implementations(
168 self.hass, self.
DOMAINDOMAIN
178 if user_input
is not None:
179 return self.async_abort(reason=
"missing_credentials")
180 return self.async_show_form(
181 step_id=
"create_cloud_project",
182 description_placeholders={
183 "cloud_console_url": CLOUD_CONSOLE_URL,
184 "sdm_api_url": SDM_API_URL,
185 "pubsub_api_url": PUBSUB_API_URL,
186 "more_info_url": MORE_INFO_URL,
191 self, user_input: dict |
None =
None
192 ) -> ConfigFlowResult:
193 """Handle cloud project in user input."""
194 if user_input
is not None:
195 self._data.
update(user_input)
197 return self.async_show_form(
198 step_id=
"cloud_project",
199 data_schema=vol.Schema(
201 vol.Required(CONF_CLOUD_PROJECT_ID): str,
204 description_placeholders={
205 "cloud_console_url": CLOUD_CONSOLE_URL,
206 "more_info_url": MORE_INFO_URL,
211 self, user_input: dict |
None =
None
212 ) -> ConfigFlowResult:
213 """Collect device access project from user input."""
215 if user_input
is not None:
216 project_id = user_input[CONF_PROJECT_ID]
217 if project_id == self._data[CONF_CLOUD_PROJECT_ID]:
219 "Device Access Project ID and Cloud Project ID must not be the"
220 " same, see documentation"
222 errors[CONF_PROJECT_ID] =
"wrong_project_id"
224 self._data.
update(user_input)
225 await self.async_set_unique_id(project_id)
226 self._abort_if_unique_id_configured()
229 return self.async_show_form(
230 step_id=
"device_project",
231 data_schema=vol.Schema(
233 vol.Required(CONF_PROJECT_ID): str,
236 description_placeholders={
237 "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL,
238 "more_info_url": MORE_INFO_URL,
244 self, user_input: dict[str, Any] |
None =
None
245 ) -> ConfigFlowResult:
246 """Configure and the pre-requisites to configure Pub/Sub topics and subscriptions."""
249 **(user_input
if user_input
is not None else {}),
251 cloud_project_id = data.get(CONF_CLOUD_PROJECT_ID,
"").strip()
252 device_access_project_id = data[CONF_PROJECT_ID]
254 errors: dict[str, str] = {}
256 access_token = self._data[
"token"][
"access_token"]
258 self.hass, access_token=access_token, cloud_project_id=cloud_project_id
261 eligible_topics = await self.
_admin_client_admin_client.list_eligible_topics(
262 device_access_project_id=device_access_project_id
264 except ApiException
as err:
265 _LOGGER.error(
"Error listing eligible Pub/Sub topics: %s", err)
266 errors[
"base"] =
"pubsub_api_error"
268 if not eligible_topics.topic_names:
269 errors[
"base"] =
"no_pubsub_topics"
271 self._data[CONF_CLOUD_PROJECT_ID] = cloud_project_id
275 return self.async_show_form(
277 data_schema=vol.Schema(
279 vol.Required(CONF_CLOUD_PROJECT_ID, default=cloud_project_id): str,
282 description_placeholders={
283 "url": CLOUD_CONSOLE_URL,
284 "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL,
285 "more_info_url": MORE_INFO_URL,
291 self, user_input: dict[str, Any] |
None =
None
292 ) -> ConfigFlowResult:
293 """Configure and create Pub/Sub topic."""
296 if user_input
is not None:
297 self._data.
update(user_input)
300 return self.async_show_form(
301 step_id=
"pubsub_topic",
302 data_schema=vol.Schema(
304 vol.Optional(CONF_TOPIC_NAME, default=topics[0]): vol.In(topics),
307 description_placeholders={
308 "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL,
309 "more_info_url": MORE_INFO_URL,
314 self, user_input: dict[str, Any] |
None =
None
315 ) -> ConfigFlowResult:
316 """Configure and create Pub/Sub subscription."""
320 if user_input
is not None:
321 subscription_name = user_input[CONF_SUBSCRIPTION_NAME]
322 if subscription_name == CREATE_NEW_SUBSCRIPTION_KEY:
323 topic_name = self._data[CONF_TOPIC_NAME]
325 self._data[CONF_CLOUD_PROJECT_ID]
328 "Creating subscription %s on topic %s",
337 except ApiException
as err:
338 _LOGGER.error(
"Error creatingPub/Sub subscription: %s", err)
339 errors[
"base"] =
"pubsub_api_error"
341 user_input[CONF_SUBSCRIPTION_NAME] = subscription_name
344 user_input[CONF_SUBSCRIBER_ID_IMPORTED] =
True
347 self._data.
update(user_input)
348 subscriber = api.new_subscriber_with_token(
350 self._data[
"token"][
"access_token"],
351 self._data[CONF_PROJECT_ID],
355 device_manager = await subscriber.async_get_device_manager()
356 except ApiException
as err:
358 _LOGGER.debug(
"Error fetching structures: %s", err)
361 device_manager.structures.values()
367 eligible_subscriptions = (
368 await self.
_admin_client_admin_client.list_eligible_subscriptions(
369 expected_topic_name=self._data[CONF_TOPIC_NAME],
372 except ApiException
as err:
374 "Error talking to API to list eligible Pub/Sub subscriptions: %s", err
376 errors[
"base"] =
"pubsub_api_error"
378 subscriptions.update(
379 {name: name
for name
in eligible_subscriptions.subscription_names}
381 subscriptions[CREATE_NEW_SUBSCRIPTION_KEY] =
"Create New"
382 return self.async_show_form(
383 step_id=
"pubsub_subscription",
384 data_schema=vol.Schema(
387 CONF_SUBSCRIPTION_NAME,
388 default=next(iter(subscriptions)),
389 ): vol.In(subscriptions),
392 description_placeholders={
393 "topic": self._data[CONF_TOPIC_NAME],
394 "more_info_url": MORE_INFO_URL,
400 """Create an entry for the SDM flow."""
401 _LOGGER.debug(
"Creating/updating configuration entry")
403 if self.
sourcesource == SOURCE_REAUTH:
404 return self.async_update_reload_and_abort(
405 self._get_reauth_entry(),
408 title = self.flow_impl.name
411 return self.async_create_entry(title=title, data=self._data)
ConfigFlowResult async_step_pubsub(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_device_project(self, dict|None user_input=None)
dict[str, str] extra_authorize_data(self)
ConfigFlowResult async_step_pubsub_topic(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_create_cloud_project(self, dict[str, Any]|None user_input=None)
ConfigFlowResult _async_finish(self)
ConfigFlowResult async_step_cloud_project(self, dict|None user_input=None)
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
logging.Logger logger(self)
ConfigFlowResult async_step_pubsub_subscription(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_reauth_confirm(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
str async_generate_authorize_url(self)
ConfigFlowResult async_oauth_create_entry(self, dict[str, Any] data)
web.Response get(self, web.Request request, str config_key)
IssData update(pyiss.ISS iss)
str _generate_subscription_id(str cloud_project_id)
str|None generate_config_title(Iterable[Structure] structures)
str get_random_string(int length=10)