Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for PurpleAir integration."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Mapping
7 from copy import deepcopy
8 from dataclasses import dataclass, field
9 from typing import Any, cast
10 
11 from aiopurpleair import API
12 from aiopurpleair.endpoints.sensors import NearbySensorResult
13 from aiopurpleair.errors import InvalidApiKeyError, PurpleAirError
14 import voluptuous as vol
15 
16 from homeassistant.config_entries import (
17  ConfigEntry,
18  ConfigFlow,
19  ConfigFlowResult,
20  OptionsFlow,
21 )
22 from homeassistant.const import (
23  CONF_API_KEY,
24  CONF_LATITUDE,
25  CONF_LONGITUDE,
26  CONF_SHOW_ON_MAP,
27 )
28 from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
29 from homeassistant.helpers import (
30  aiohttp_client,
31  config_validation as cv,
32  device_registry as dr,
33  entity_registry as er,
34 )
35 from homeassistant.helpers.event import async_track_state_change_event
37  SelectOptionDict,
38  SelectSelector,
39  SelectSelectorConfig,
40  SelectSelectorMode,
41 )
42 
43 from .const import CONF_SENSOR_INDICES, DOMAIN, LOGGER
44 
45 CONF_DISTANCE = "distance"
46 CONF_NEARBY_SENSOR_OPTIONS = "nearby_sensor_options"
47 CONF_SENSOR_DEVICE_ID = "sensor_device_id"
48 CONF_SENSOR_INDEX = "sensor_index"
49 
50 DEFAULT_DISTANCE = 5
51 
52 API_KEY_SCHEMA = vol.Schema(
53  {
54  vol.Required(CONF_API_KEY): cv.string,
55  }
56 )
57 
58 
59 @callback
60 def async_get_api(hass: HomeAssistant, api_key: str) -> API:
61  """Get an aiopurpleair API object."""
62  session = aiohttp_client.async_get_clientsession(hass)
63  return API(api_key, session=session)
64 
65 
66 @callback
67 def async_get_coordinates_schema(hass: HomeAssistant) -> vol.Schema:
68  """Define a schema for searching for sensors near a coordinate pair."""
69  return vol.Schema(
70  {
71  vol.Inclusive(
72  CONF_LATITUDE, "coords", default=hass.config.latitude
73  ): cv.latitude,
74  vol.Inclusive(
75  CONF_LONGITUDE, "coords", default=hass.config.longitude
76  ): cv.longitude,
77  vol.Optional(CONF_DISTANCE, default=DEFAULT_DISTANCE): cv.positive_int,
78  }
79  )
80 
81 
82 @callback
84  nearby_sensor_results: list[NearbySensorResult],
85 ) -> list[SelectOptionDict]:
86  """Return a set of nearby sensors as SelectOptionDict objects."""
87  return [
89  value=str(result.sensor.sensor_index), label=cast(str, result.sensor.name)
90  )
91  for result in nearby_sensor_results
92  ]
93 
94 
95 @callback
96 def async_get_nearby_sensors_schema(options: list[SelectOptionDict]) -> vol.Schema:
97  """Define a schema for selecting a sensor from a list."""
98  return vol.Schema(
99  {
100  vol.Required(CONF_SENSOR_INDEX): SelectSelector(
101  SelectSelectorConfig(options=options, mode=SelectSelectorMode.DROPDOWN)
102  )
103  }
104  )
105 
106 
107 @callback
109  hass: HomeAssistant, config_entry: ConfigEntry
110 ) -> list[SelectOptionDict]:
111  """Return a set of already-configured sensors as SelectOptionDict objects."""
112  device_registry = dr.async_get(hass)
113  return [
114  SelectOptionDict(value=device_entry.id, label=cast(str, device_entry.name))
115  for device_entry in device_registry.devices.get_devices_for_config_entry_id(
116  config_entry.entry_id
117  )
118  ]
119 
120 
121 @callback
122 def async_get_remove_sensor_schema(sensors: list[SelectOptionDict]) -> vol.Schema:
123  """Define a schema removing a sensor."""
124  return vol.Schema(
125  {
126  vol.Required(CONF_SENSOR_DEVICE_ID): SelectSelector(
127  SelectSelectorConfig(options=sensors, mode=SelectSelectorMode.DROPDOWN)
128  )
129  }
130  )
131 
132 
133 @dataclass
135  """Define a validation result."""
136 
137  data: Any = None
138  errors: dict[str, Any] = field(default_factory=dict)
139 
140 
141 async def async_validate_api_key(hass: HomeAssistant, api_key: str) -> ValidationResult:
142  """Validate an API key.
143 
144  This method returns a dictionary of errors (if appropriate).
145  """
146  api = async_get_api(hass, api_key)
147  errors = {}
148 
149  try:
150  await api.async_check_api_key()
151  except InvalidApiKeyError:
152  errors["base"] = "invalid_api_key"
153  except PurpleAirError as err:
154  LOGGER.error("PurpleAir error while checking API key: %s", err)
155  errors["base"] = "unknown"
156  except Exception as err: # noqa: BLE001
157  LOGGER.exception("Unexpected exception while checking API key: %s", err)
158  errors["base"] = "unknown"
159 
160  if errors:
161  return ValidationResult(errors=errors)
162 
163  return ValidationResult(data=None)
164 
165 
167  hass: HomeAssistant,
168  api_key: str,
169  latitude: float,
170  longitude: float,
171  distance: float,
172 ) -> ValidationResult:
173  """Validate coordinates."""
174  api = async_get_api(hass, api_key)
175  errors = {}
176 
177  try:
178  nearby_sensor_results = await api.sensors.async_get_nearby_sensors(
179  ["name"], latitude, longitude, distance, limit_results=5
180  )
181  except PurpleAirError as err:
182  LOGGER.error("PurpleAir error while getting nearby sensors: %s", err)
183  errors["base"] = "unknown"
184  except Exception as err: # noqa: BLE001
185  LOGGER.exception("Unexpected exception while getting nearby sensors: %s", err)
186  errors["base"] = "unknown"
187  else:
188  if not nearby_sensor_results:
189  errors["base"] = "no_sensors_near_coordinates"
190 
191  if errors:
192  return ValidationResult(errors=errors)
193 
194  return ValidationResult(data=nearby_sensor_results)
195 
196 
197 class PurpleAirConfigFlow(ConfigFlow, domain=DOMAIN):
198  """Handle a config flow for PurpleAir."""
199 
200  VERSION = 1
201 
202  def __init__(self) -> None:
203  """Initialize."""
204  self._flow_data_flow_data: dict[str, Any] = {}
205 
206  @staticmethod
207  @callback
209  config_entry: ConfigEntry,
210  ) -> PurpleAirOptionsFlowHandler:
211  """Define the config flow to handle options."""
213 
215  self, user_input: dict[str, Any] | None = None
216  ) -> ConfigFlowResult:
217  """Handle the discovery of sensors near a latitude/longitude."""
218  if user_input is None:
219  return self.async_show_formasync_show_formasync_show_form(
220  step_id="by_coordinates",
221  data_schema=async_get_coordinates_schema(self.hass),
222  )
223 
224  validation = await async_validate_coordinates(
225  self.hass,
226  self._flow_data_flow_data[CONF_API_KEY],
227  user_input[CONF_LATITUDE],
228  user_input[CONF_LONGITUDE],
229  user_input[CONF_DISTANCE],
230  )
231  if validation.errors:
232  return self.async_show_formasync_show_formasync_show_form(
233  step_id="by_coordinates",
234  data_schema=async_get_coordinates_schema(self.hass),
235  errors=validation.errors,
236  )
237 
238  self._flow_data_flow_data[CONF_NEARBY_SENSOR_OPTIONS] = async_get_nearby_sensors_options(
239  validation.data
240  )
241 
242  return await self.async_step_choose_sensorasync_step_choose_sensor()
243 
245  self, user_input: dict[str, Any] | None = None
246  ) -> ConfigFlowResult:
247  """Handle the selection of a sensor."""
248  if user_input is None:
249  options = self._flow_data_flow_data.pop(CONF_NEARBY_SENSOR_OPTIONS)
250  return self.async_show_formasync_show_formasync_show_form(
251  step_id="choose_sensor",
252  data_schema=async_get_nearby_sensors_schema(options),
253  )
254 
255  return self.async_create_entryasync_create_entryasync_create_entry(
256  title=self._flow_data_flow_data[CONF_API_KEY][:5],
257  data=self._flow_data_flow_data,
258  # Note that we store the sensor indices in options so that later on, we can
259  # add/remove additional sensors via an options flow:
260  options={CONF_SENSOR_INDICES: [int(user_input[CONF_SENSOR_INDEX])]},
261  )
262 
263  async def async_step_reauth(
264  self, entry_data: Mapping[str, Any]
265  ) -> ConfigFlowResult:
266  """Handle configuration by re-auth."""
267  return await self.async_step_reauth_confirmasync_step_reauth_confirm()
268 
270  self, user_input: dict[str, Any] | None = None
271  ) -> ConfigFlowResult:
272  """Handle the re-auth step."""
273  if user_input is None:
274  return self.async_show_formasync_show_formasync_show_form(
275  step_id="reauth_confirm", data_schema=API_KEY_SCHEMA
276  )
277 
278  api_key = user_input[CONF_API_KEY]
279 
280  validation = await async_validate_api_key(self.hass, api_key)
281  if validation.errors:
282  return self.async_show_formasync_show_formasync_show_form(
283  step_id="reauth_confirm",
284  data_schema=API_KEY_SCHEMA,
285  errors=validation.errors,
286  )
287 
288  return self.async_update_reload_and_abortasync_update_reload_and_abort(
289  self._get_reauth_entry_get_reauth_entry(), data={CONF_API_KEY: api_key}
290  )
291 
292  async def async_step_user(
293  self, user_input: dict[str, Any] | None = None
294  ) -> ConfigFlowResult:
295  """Handle the initial step."""
296  if user_input is None:
297  return self.async_show_formasync_show_formasync_show_form(step_id="user", data_schema=API_KEY_SCHEMA)
298 
299  api_key = user_input[CONF_API_KEY]
300 
301  self._async_abort_entries_match_async_abort_entries_match({CONF_API_KEY: api_key})
302 
303  validation = await async_validate_api_key(self.hass, api_key)
304  if validation.errors:
305  return self.async_show_formasync_show_formasync_show_form(
306  step_id="user",
307  data_schema=API_KEY_SCHEMA,
308  errors=validation.errors,
309  )
310 
311  self._flow_data_flow_data = {CONF_API_KEY: api_key}
312  return await self.async_step_by_coordinatesasync_step_by_coordinates()
313 
314 
316  """Handle a PurpleAir options flow."""
317 
318  def __init__(self) -> None:
319  """Initialize."""
320  self._flow_data: dict[str, Any] = {}
321 
322  @property
323  def settings_schema(self) -> vol.Schema:
324  """Return the settings schema."""
325  return vol.Schema(
326  {
327  vol.Optional(
328  CONF_SHOW_ON_MAP,
329  description={
330  "suggested_value": self.config_entryconfig_entryconfig_entry.options.get(
331  CONF_SHOW_ON_MAP
332  )
333  },
334  ): bool
335  }
336  )
337 
339  self, user_input: dict[str, Any] | None = None
340  ) -> ConfigFlowResult:
341  """Add a sensor."""
342  if user_input is None:
343  return self.async_show_formasync_show_form(
344  step_id="add_sensor",
345  data_schema=async_get_coordinates_schema(self.hass),
346  )
347 
348  validation = await async_validate_coordinates(
349  self.hass,
350  self.config_entryconfig_entryconfig_entry.data[CONF_API_KEY],
351  user_input[CONF_LATITUDE],
352  user_input[CONF_LONGITUDE],
353  user_input[CONF_DISTANCE],
354  )
355 
356  if validation.errors:
357  return self.async_show_formasync_show_form(
358  step_id="add_sensor",
359  data_schema=async_get_coordinates_schema(self.hass),
360  errors=validation.errors,
361  )
362 
363  self._flow_data[CONF_NEARBY_SENSOR_OPTIONS] = async_get_nearby_sensors_options(
364  validation.data
365  )
366 
367  return await self.async_step_choose_sensorasync_step_choose_sensor()
368 
370  self, user_input: dict[str, Any] | None = None
371  ) -> ConfigFlowResult:
372  """Choose a sensor."""
373  if user_input is None:
374  options = self._flow_data.pop(CONF_NEARBY_SENSOR_OPTIONS)
375  return self.async_show_formasync_show_form(
376  step_id="choose_sensor",
377  data_schema=async_get_nearby_sensors_schema(options),
378  )
379 
380  sensor_index = int(user_input[CONF_SENSOR_INDEX])
381 
382  if sensor_index in self.config_entryconfig_entryconfig_entry.options[CONF_SENSOR_INDICES]:
383  return self.async_abortasync_abort(reason="already_configured")
384 
385  options = deepcopy({**self.config_entryconfig_entryconfig_entry.options})
386  options[CONF_SENSOR_INDICES].append(sensor_index)
387  return self.async_create_entryasync_create_entry(data=options)
388 
389  async def async_step_init(
390  self, user_input: dict[str, Any] | None = None
391  ) -> ConfigFlowResult:
392  """Manage the options."""
393  return self.async_show_menuasync_show_menu(
394  step_id="init",
395  menu_options=["add_sensor", "remove_sensor", "settings"],
396  )
397 
399  self, user_input: dict[str, Any] | None = None
400  ) -> ConfigFlowResult:
401  """Remove a sensor."""
402  if user_input is None:
403  return self.async_show_formasync_show_form(
404  step_id="remove_sensor",
405  data_schema=async_get_remove_sensor_schema(
406  async_get_remove_sensor_options(self.hass, self.config_entryconfig_entryconfig_entry)
407  ),
408  )
409 
410  device_registry = dr.async_get(self.hass)
411  entity_registry = er.async_get(self.hass)
412 
413  device_id = user_input[CONF_SENSOR_DEVICE_ID]
414  device_entry = cast(dr.DeviceEntry, device_registry.async_get(device_id))
415 
416  # Determine the entity entries that belong to this device.
417  entity_entries = er.async_entries_for_device(
418  entity_registry, device_id, include_disabled_entities=True
419  )
420 
421  device_entities_removed_event = asyncio.Event()
422 
423  @callback
424  def async_device_entity_state_changed(
425  _: Event[EventStateChangedData],
426  ) -> None:
427  """Listen and respond when all device entities are removed."""
428  if all(
429  self.hass.states.get(entity_entry.entity_id) is None
430  for entity_entry in entity_entries
431  ):
432  device_entities_removed_event.set()
433 
434  # Track state changes for this device's entities and when they're removed,
435  # finish the flow:
436  cancel_state_track = async_track_state_change_event(
437  self.hass,
438  [entity_entry.entity_id for entity_entry in entity_entries],
439  async_device_entity_state_changed,
440  )
441  device_registry.async_update_device(
442  device_id, remove_config_entry_id=self.config_entryconfig_entryconfig_entry.entry_id
443  )
444  await device_entities_removed_event.wait()
445 
446  # Once we're done, we can cancel the state change tracker callback:
447  cancel_state_track()
448 
449  # Build new config entry options:
450  removed_sensor_index = next(
451  sensor_index
452  for sensor_index in self.config_entryconfig_entryconfig_entry.options[CONF_SENSOR_INDICES]
453  if (DOMAIN, str(sensor_index)) in device_entry.identifiers
454  )
455  options = deepcopy({**self.config_entryconfig_entryconfig_entry.options})
456  options[CONF_SENSOR_INDICES].remove(removed_sensor_index)
457 
458  return self.async_create_entryasync_create_entry(data=options)
459 
461  self, user_input: dict[str, Any] | None = None
462  ) -> ConfigFlowResult:
463  """Manage settings."""
464  if user_input is None:
465  return self.async_show_formasync_show_form(
466  step_id="settings", data_schema=self.settings_schemasettings_schema
467  )
468 
469  options = deepcopy({**self.config_entryconfig_entryconfig_entry.options})
470  return self.async_create_entryasync_create_entry(data=options | user_input)
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:265
PurpleAirOptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:210
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:294
ConfigFlowResult async_step_reauth_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:271
ConfigFlowResult async_step_choose_sensor(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:246
ConfigFlowResult async_step_by_coordinates(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:216
ConfigFlowResult async_step_add_sensor(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:340
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:391
ConfigFlowResult async_step_remove_sensor(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:400
ConfigFlowResult async_step_settings(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:462
ConfigFlowResult async_step_choose_sensor(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:371
ConfigFlowResult async_create_entry(self, *str title, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None, Mapping[str, Any]|None options=None)
ConfigFlowResult async_update_reload_and_abort(self, ConfigEntry entry, *str|None|UndefinedType unique_id=UNDEFINED, str|UndefinedType title=UNDEFINED, Mapping[str, Any]|UndefinedType data=UNDEFINED, Mapping[str, Any]|UndefinedType data_updates=UNDEFINED, Mapping[str, Any]|UndefinedType options=UNDEFINED, str|UndefinedType reason=UNDEFINED, bool reload_even_if_entry_is_unchanged=True)
None _async_abort_entries_match(self, dict[str, Any]|None match_dict=None)
ConfigFlowResult async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
None config_entry(self, ConfigEntry value)
str
_FlowResultT async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_show_menu(self, *str|None step_id=None, Container[str] menu_options, Mapping[str, str]|None description_placeholders=None)
_FlowResultT async_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
bool remove(self, _T matcher)
Definition: match.py:214
vol.Schema async_get_remove_sensor_schema(list[SelectOptionDict] sensors)
Definition: config_flow.py:122
ValidationResult async_validate_coordinates(HomeAssistant hass, str api_key, float latitude, float longitude, float distance)
Definition: config_flow.py:172
list[SelectOptionDict] async_get_remove_sensor_options(HomeAssistant hass, ConfigEntry config_entry)
Definition: config_flow.py:110
vol.Schema async_get_coordinates_schema(HomeAssistant hass)
Definition: config_flow.py:67
ValidationResult async_validate_api_key(HomeAssistant hass, str api_key)
Definition: config_flow.py:141
list[SelectOptionDict] async_get_nearby_sensors_options(list[NearbySensorResult] nearby_sensor_results)
Definition: config_flow.py:85
API async_get_api(HomeAssistant hass, str api_key)
Definition: config_flow.py:60
vol.Schema async_get_nearby_sensors_schema(list[SelectOptionDict] options)
Definition: config_flow.py:96
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314