Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Yeelight integration."""
2 
3 from __future__ import annotations
4 
5 import logging
6 from typing import Any, Self
7 from urllib.parse import urlparse
8 
9 import voluptuous as vol
10 import yeelight
11 from yeelight.aio import AsyncBulb
12 from yeelight.main import get_known_models
13 
14 from homeassistant.components import dhcp, onboarding, ssdp, zeroconf
15 from homeassistant.config_entries import (
16  ConfigEntry,
17  ConfigEntryState,
18  ConfigFlow,
19  ConfigFlowResult,
20  OptionsFlow,
21 )
22 from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME
23 from homeassistant.core import callback
24 from homeassistant.exceptions import HomeAssistantError
26 from homeassistant.helpers.typing import VolDictType
27 
28 from .const import (
29  CONF_DETECTED_MODEL,
30  CONF_MODE_MUSIC,
31  CONF_NIGHTLIGHT_SWITCH,
32  CONF_NIGHTLIGHT_SWITCH_TYPE,
33  CONF_SAVE_ON_CHANGE,
34  CONF_TRANSITION,
35  DOMAIN,
36  NIGHTLIGHT_SWITCH_TYPE_LIGHT,
37 )
38 from .device import (
39  _async_unique_name,
40  async_format_id,
41  async_format_model,
42  async_format_model_id,
43 )
44 from .scanner import YeelightScanner
45 
46 MODEL_UNKNOWN = "unknown"
47 
48 _LOGGER = logging.getLogger(__name__)
49 
50 
51 class YeelightConfigFlow(ConfigFlow, domain=DOMAIN):
52  """Handle a config flow for Yeelight."""
53 
54  VERSION = 1
55 
56  _discovered_ip: str = ""
57  _discovered_model: str
58 
59  @staticmethod
60  @callback
62  config_entry: ConfigEntry,
63  ) -> OptionsFlowHandler:
64  """Return the options flow."""
65  return OptionsFlowHandler()
66 
67  def __init__(self) -> None:
68  """Initialize the config flow."""
69  self._discovered_devices: dict[str, Any] = {}
70 
71  async def async_step_homekit(
72  self, discovery_info: zeroconf.ZeroconfServiceInfo
73  ) -> ConfigFlowResult:
74  """Handle discovery from homekit."""
75  self._discovered_ip_discovered_ip = discovery_info.host
76  return await self._async_handle_discovery_async_handle_discovery()
77 
78  async def async_step_dhcp(
79  self, discovery_info: dhcp.DhcpServiceInfo
80  ) -> ConfigFlowResult:
81  """Handle discovery from dhcp."""
82  self._discovered_ip_discovered_ip = discovery_info.ip
83  return await self._async_handle_discovery_async_handle_discovery()
84 
86  self, discovery_info: zeroconf.ZeroconfServiceInfo
87  ) -> ConfigFlowResult:
88  """Handle discovery from zeroconf."""
89  self._discovered_ip_discovered_ip = discovery_info.host
90  await self.async_set_unique_idasync_set_unique_id(f"{int(discovery_info.name[-26:-18]):#018x}")
91  return await self._async_handle_discovery_with_unique_id_async_handle_discovery_with_unique_id()
92 
93  async def async_step_ssdp(
94  self, discovery_info: ssdp.SsdpServiceInfo
95  ) -> ConfigFlowResult:
96  """Handle discovery from ssdp."""
97  self._discovered_ip_discovered_ip = urlparse(discovery_info.ssdp_headers["location"]).hostname
98  await self.async_set_unique_idasync_set_unique_id(discovery_info.ssdp_headers["id"])
99  return await self._async_handle_discovery_with_unique_id_async_handle_discovery_with_unique_id()
100 
101  async def _async_handle_discovery_with_unique_id(self) -> ConfigFlowResult:
102  """Handle any discovery with a unique id."""
103  for entry in self._async_current_entries_async_current_entries(include_ignore=False):
104  if entry.unique_id != self.unique_idunique_id and self.unique_idunique_id != entry.data.get(
105  CONF_ID
106  ):
107  continue
108  reload = entry.state == ConfigEntryState.SETUP_RETRY
109  if entry.data.get(CONF_HOST) != self._discovered_ip_discovered_ip:
110  self.hass.config_entries.async_update_entry(
111  entry, data={**entry.data, CONF_HOST: self._discovered_ip_discovered_ip}
112  )
113  reload = entry.state in (
114  ConfigEntryState.SETUP_RETRY,
115  ConfigEntryState.LOADED,
116  )
117  if reload:
118  self.hass.config_entries.async_schedule_reload(entry.entry_id)
119  return self.async_abortasync_abortasync_abort(reason="already_configured")
120  return await self._async_handle_discovery_async_handle_discovery()
121 
122  async def _async_handle_discovery(self) -> ConfigFlowResult:
123  """Handle any discovery."""
124  if self.hass.config_entries.flow.async_has_matching_flow(self):
125  return self.async_abortasync_abortasync_abort(reason="already_in_progress")
126  self._async_abort_entries_match_async_abort_entries_match({CONF_HOST: self._discovered_ip_discovered_ip})
127 
128  try:
129  self._discovered_model_discovered_model = await self._async_try_connect_async_try_connect(
130  self._discovered_ip_discovered_ip, raise_on_progress=True
131  )
132  except CannotConnect:
133  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
134 
135  if not self.unique_idunique_id:
136  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
137 
138  self._abort_if_unique_id_configured_abort_if_unique_id_configured(
139  updates={CONF_HOST: self._discovered_ip_discovered_ip}, reload_on_update=False
140  )
141  return await self.async_step_discovery_confirmasync_step_discovery_confirm()
142 
143  def is_matching(self, other_flow: Self) -> bool:
144  """Return True if other_flow is matching this flow."""
145  return other_flow._discovered_ip == self._discovered_ip_discovered_ip # noqa: SLF001
146 
148  self, user_input: dict[str, Any] | None = None
149  ) -> ConfigFlowResult:
150  """Confirm discovery."""
151  if user_input is not None or not onboarding.async_is_onboarded(self.hass):
152  return self.async_create_entryasync_create_entryasync_create_entry(
153  title=async_format_model_id(self._discovered_model_discovered_model, self.unique_idunique_id),
154  data={
155  CONF_ID: self.unique_idunique_id,
156  CONF_HOST: self._discovered_ip_discovered_ip,
157  CONF_MODEL: self._discovered_model_discovered_model,
158  },
159  )
160 
161  self._set_confirm_only_set_confirm_only()
162  placeholders = {
163  "id": async_format_id(self.unique_idunique_id),
164  "model": async_format_model(self._discovered_model_discovered_model),
165  "host": self._discovered_ip_discovered_ip,
166  }
167  self.context["title_placeholders"] = placeholders
168  return self.async_show_formasync_show_formasync_show_form(
169  step_id="discovery_confirm", description_placeholders=placeholders
170  )
171 
172  async def async_step_user(
173  self, user_input: dict[str, Any] | None = None
174  ) -> ConfigFlowResult:
175  """Handle the initial step."""
176  errors = {}
177  if user_input is not None:
178  if not user_input.get(CONF_HOST):
179  return await self.async_step_pick_deviceasync_step_pick_device()
180  try:
181  model = await self._async_try_connect_async_try_connect(
182  user_input[CONF_HOST], raise_on_progress=False
183  )
184  except CannotConnect:
185  errors["base"] = "cannot_connect"
186  else:
187  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
188  return self.async_create_entryasync_create_entryasync_create_entry(
189  title=async_format_model_id(model, self.unique_idunique_id),
190  data={
191  CONF_HOST: user_input[CONF_HOST],
192  CONF_ID: self.unique_idunique_id,
193  CONF_MODEL: model,
194  },
195  )
196 
197  user_input = user_input or {}
198  return self.async_show_formasync_show_formasync_show_form(
199  step_id="user",
200  data_schema=vol.Schema(
201  {vol.Optional(CONF_HOST, default=user_input.get(CONF_HOST, "")): str}
202  ),
203  errors=errors,
204  )
205 
207  self, user_input: dict[str, str] | None = None
208  ) -> ConfigFlowResult:
209  """Handle the step to pick discovered device."""
210  if user_input is not None:
211  unique_id = user_input[CONF_DEVICE]
212  capabilities = self._discovered_devices[unique_id]
213  await self.async_set_unique_idasync_set_unique_id(unique_id, raise_on_progress=False)
214  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
215  host = urlparse(capabilities["location"]).hostname
216  return self.async_create_entryasync_create_entryasync_create_entry(
217  title=_async_unique_name(capabilities),
218  data={
219  CONF_ID: unique_id,
220  CONF_HOST: host,
221  CONF_MODEL: capabilities["model"],
222  },
223  )
224 
225  configured_devices = {
226  entry.data[CONF_ID]
227  for entry in self._async_current_entries_async_current_entries()
228  if entry.data[CONF_ID]
229  }
230  devices_name = {}
231  scanner = YeelightScanner.async_get(self.hass)
232  devices = await scanner.async_discover()
233  # Run 3 times as packets can get lost
234  for capabilities in devices:
235  unique_id = capabilities["id"]
236  if unique_id in configured_devices:
237  continue # ignore configured devices
238  model = capabilities["model"]
239  host = urlparse(capabilities["location"]).hostname
240  model_id = async_format_model_id(model, unique_id)
241  name = f"{model_id} ({host})"
242  self._discovered_devices[unique_id] = capabilities
243  devices_name[unique_id] = name
244 
245  # Check if there is at least one device
246  if not devices_name:
247  return self.async_abortasync_abortasync_abort(reason="no_devices_found")
248  return self.async_show_formasync_show_formasync_show_form(
249  step_id="pick_device",
250  data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}),
251  )
252 
253  async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
254  """Handle import step."""
255  host = import_data[CONF_HOST]
256  try:
257  await self._async_try_connect_async_try_connect(host, raise_on_progress=False)
258  except CannotConnect:
259  _LOGGER.error("Failed to import %s: cannot connect", host)
260  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
261  if CONF_NIGHTLIGHT_SWITCH_TYPE in import_data:
262  import_data[CONF_NIGHTLIGHT_SWITCH] = (
263  import_data.pop(CONF_NIGHTLIGHT_SWITCH_TYPE)
264  == NIGHTLIGHT_SWITCH_TYPE_LIGHT
265  )
266  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
267  return self.async_create_entryasync_create_entryasync_create_entry(title=import_data[CONF_NAME], data=import_data)
268 
270  self, host: str, raise_on_progress: bool = True
271  ) -> str:
272  """Set up with options."""
273  self._async_abort_entries_match_async_abort_entries_match({CONF_HOST: host})
274 
275  scanner = YeelightScanner.async_get(self.hass)
276  capabilities = await scanner.async_get_capabilities(host)
277  if capabilities is None: # timeout
278  _LOGGER.debug("Failed to get capabilities from %s: timeout", host)
279  else:
280  _LOGGER.debug("Get capabilities: %s", capabilities)
281  await self.async_set_unique_idasync_set_unique_id(
282  capabilities["id"], raise_on_progress=raise_on_progress
283  )
284  return capabilities["model"]
285  # Fallback to get properties
286  bulb = AsyncBulb(host)
287  try:
288  await bulb.async_listen(lambda _: True)
289  await bulb.async_get_properties()
290  await bulb.async_stop_listening()
291  except (TimeoutError, yeelight.BulbException) as err:
292  _LOGGER.debug("Failed to get properties from %s: %s", host, err)
293  raise CannotConnect from err
294  _LOGGER.debug("Get properties: %s", bulb.last_properties)
295  return MODEL_UNKNOWN
296 
297 
299  """Handle a option flow for Yeelight."""
300 
301  async def async_step_init(
302  self, user_input: dict[str, Any] | None = None
303  ) -> ConfigFlowResult:
304  """Handle the initial step."""
305  data = self.config_entryconfig_entryconfig_entry.data
306  options = self.config_entryconfig_entryconfig_entry.options
307  detected_model = data.get(CONF_DETECTED_MODEL)
308  model = options[CONF_MODEL] or detected_model
309 
310  if user_input is not None:
311  return self.async_create_entryasync_create_entry(
312  title="", data={CONF_MODEL: model, **options, **user_input}
313  )
314 
315  schema_dict: VolDictType = {}
316  known_models = get_known_models()
317  if is_unknown_model := model not in known_models:
318  known_models.insert(0, model)
319 
320  if is_unknown_model or model != detected_model:
321  schema_dict.update(
322  {
323  vol.Optional(CONF_MODEL, default=model): vol.In(known_models),
324  }
325  )
326  schema_dict.update(
327  {
328  vol.Required(
329  CONF_TRANSITION, default=options[CONF_TRANSITION]
330  ): cv.positive_int,
331  vol.Required(CONF_MODE_MUSIC, default=options[CONF_MODE_MUSIC]): bool,
332  vol.Required(
333  CONF_SAVE_ON_CHANGE, default=options[CONF_SAVE_ON_CHANGE]
334  ): bool,
335  vol.Required(
336  CONF_NIGHTLIGHT_SWITCH, default=options[CONF_NIGHTLIGHT_SWITCH]
337  ): bool,
338  }
339  )
340 
341  return self.async_show_formasync_show_form(
342  step_id="init",
343  data_schema=vol.Schema(schema_dict),
344  )
345 
346 
348  """Error to indicate we cannot connect."""
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:303
str _async_try_connect(self, str host, bool raise_on_progress=True)
Definition: config_flow.py:271
ConfigFlowResult async_step_dhcp(self, dhcp.DhcpServiceInfo discovery_info)
Definition: config_flow.py:80
ConfigFlowResult async_step_import(self, dict[str, Any] import_data)
Definition: config_flow.py:253
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:174
ConfigFlowResult async_step_homekit(self, zeroconf.ZeroconfServiceInfo discovery_info)
Definition: config_flow.py:73
OptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:63
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
Definition: config_flow.py:87
ConfigFlowResult async_step_discovery_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:149
ConfigFlowResult async_step_pick_device(self, dict[str, str]|None user_input=None)
Definition: config_flow.py:208
ConfigFlowResult async_step_ssdp(self, ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:95
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
ConfigEntry|None async_set_unique_id(self, str|None unique_id=None, *bool raise_on_progress=True)
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)
list[ConfigEntry] _async_current_entries(self, bool|None include_ignore=None)
ConfigFlowResult async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
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)
_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_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)
str _async_unique_name(dict capabilities)
Definition: device.py:47
str async_format_model_id(str model, str|None id_)
Definition: device.py:41