Home Assistant Unofficial Reference 2024.12.1
discovery.py
Go to the documentation of this file.
1 """The Flux LED/MagicLight integration discovery."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Mapping
7 import logging
8 from typing import Any, Final
9 
10 from flux_led.aioscanner import AIOBulbScanner
11 from flux_led.const import (
12  ATTR_ID,
13  ATTR_IPADDR,
14  ATTR_MODEL,
15  ATTR_MODEL_DESCRIPTION,
16  ATTR_MODEL_INFO,
17  ATTR_MODEL_NUM,
18  ATTR_REMOTE_ACCESS_ENABLED,
19  ATTR_REMOTE_ACCESS_HOST,
20  ATTR_REMOTE_ACCESS_PORT,
21  ATTR_VERSION_NUM,
22 )
23 from flux_led.models_db import get_model_description
24 from flux_led.scanner import FluxLEDDiscovery
25 
26 from homeassistant import config_entries
27 from homeassistant.components import network
28 from homeassistant.config_entries import ConfigEntry, ConfigEntryState
29 from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME
30 from homeassistant.core import HomeAssistant, callback
31 from homeassistant.helpers import device_registry as dr, discovery_flow
32 from homeassistant.util.async_ import create_eager_task
33 from homeassistant.util.network import is_ip_address
34 
35 from .const import (
36  CONF_MINOR_VERSION,
37  CONF_MODEL_DESCRIPTION,
38  CONF_MODEL_INFO,
39  CONF_MODEL_NUM,
40  CONF_REMOTE_ACCESS_ENABLED,
41  CONF_REMOTE_ACCESS_HOST,
42  CONF_REMOTE_ACCESS_PORT,
43  DIRECTED_DISCOVERY_TIMEOUT,
44  DOMAIN,
45  FLUX_LED_DISCOVERY,
46 )
47 from .util import format_as_flux_mac, mac_matches_by_one
48 
49 _LOGGER = logging.getLogger(__name__)
50 
51 
52 CONF_TO_DISCOVERY: Final = {
53  CONF_HOST: ATTR_IPADDR,
54  CONF_REMOTE_ACCESS_ENABLED: ATTR_REMOTE_ACCESS_ENABLED,
55  CONF_REMOTE_ACCESS_HOST: ATTR_REMOTE_ACCESS_HOST,
56  CONF_REMOTE_ACCESS_PORT: ATTR_REMOTE_ACCESS_PORT,
57  CONF_MINOR_VERSION: ATTR_VERSION_NUM,
58  CONF_MODEL: ATTR_MODEL,
59  CONF_MODEL_NUM: ATTR_MODEL_NUM,
60  CONF_MODEL_INFO: ATTR_MODEL_INFO,
61  CONF_MODEL_DESCRIPTION: ATTR_MODEL_DESCRIPTION,
62 }
63 
64 
65 @callback
66 def async_build_cached_discovery(entry: ConfigEntry) -> FluxLEDDiscovery:
67  """When discovery is unavailable, load it from the config entry."""
68  data = entry.data
69  return FluxLEDDiscovery(
70  ipaddr=data[CONF_HOST],
71  model=data.get(CONF_MODEL),
72  id=format_as_flux_mac(entry.unique_id),
73  model_num=data.get(CONF_MODEL_NUM),
74  version_num=data.get(CONF_MINOR_VERSION),
75  firmware_date=None,
76  model_info=data.get(CONF_MODEL_INFO),
77  model_description=data.get(CONF_MODEL_DESCRIPTION),
78  remote_access_enabled=data.get(CONF_REMOTE_ACCESS_ENABLED),
79  remote_access_host=data.get(CONF_REMOTE_ACCESS_HOST),
80  remote_access_port=data.get(CONF_REMOTE_ACCESS_PORT),
81  )
82 
83 
84 @callback
86  device: FluxLEDDiscovery, model_num: int | None = None
87 ) -> str:
88  """Convert a flux_led discovery to a human readable name."""
89  if (mac_address := device[ATTR_ID]) is None:
90  return device[ATTR_IPADDR]
91  short_mac = mac_address[-6:]
92  if device[ATTR_MODEL_DESCRIPTION]:
93  return f"{device[ATTR_MODEL_DESCRIPTION]} {short_mac}"
94  if model_num is not None:
95  return f"{get_model_description(model_num, None)} {short_mac}"
96  return f"{device[ATTR_MODEL]} {short_mac}"
97 
98 
99 @callback
101  current_data: Mapping[str, Any],
102  data_updates: dict[str, Any],
103  device: FluxLEDDiscovery,
104 ) -> None:
105  """Copy discovery data into config entry data."""
106  for conf_key, discovery_key in CONF_TO_DISCOVERY.items():
107  if (
108  device.get(discovery_key) is not None
109  and conf_key
110  not in data_updates # Prefer the model num from TCP instead of UDP
111  and current_data.get(conf_key) != device[discovery_key] # type: ignore[literal-required]
112  ):
113  data_updates[conf_key] = device[discovery_key] # type: ignore[literal-required]
114 
115 
116 @callback
118  hass: HomeAssistant,
120  device: FluxLEDDiscovery,
121  model_num: int | None,
122  allow_update_mac: bool,
123 ) -> bool:
124  """Update a config entry from a flux_led discovery."""
125  data_updates: dict[str, Any] = {}
126  mac_address = device[ATTR_ID]
127  assert mac_address is not None
128  updates: dict[str, Any] = {}
129  formatted_mac = dr.format_mac(mac_address)
130  if not entry.unique_id or (
131  allow_update_mac
132  and entry.unique_id != formatted_mac
133  and mac_matches_by_one(formatted_mac, entry.unique_id)
134  ):
135  updates["unique_id"] = formatted_mac
136  if model_num and entry.data.get(CONF_MODEL_NUM) != model_num:
137  data_updates[CONF_MODEL_NUM] = model_num
138  async_populate_data_from_discovery(entry.data, data_updates, device)
139  if is_ip_address(entry.title):
140  updates["title"] = async_name_from_discovery(device, model_num)
141  title_matches_name = entry.title == entry.data.get(CONF_NAME)
142  if data_updates or title_matches_name:
143  updates["data"] = {**entry.data, **data_updates}
144  if title_matches_name:
145  del updates["data"][CONF_NAME]
146  # If the title has changed and the config entry is loaded, a listener is
147  # in place, and we should not reload
148  if updates and not ("title" in updates and entry.state is ConfigEntryState.LOADED):
149  return hass.config_entries.async_update_entry(entry, **updates)
150  return False
151 
152 
153 @callback
154 def async_get_discovery(hass: HomeAssistant, host: str) -> FluxLEDDiscovery | None:
155  """Check if a device was already discovered via a broadcast discovery."""
156  discoveries: list[FluxLEDDiscovery] = hass.data[DOMAIN][FLUX_LED_DISCOVERY]
157  for discovery in discoveries:
158  if discovery[ATTR_IPADDR] == host:
159  return discovery
160  return None
161 
162 
163 @callback
164 def async_clear_discovery_cache(hass: HomeAssistant, host: str) -> None:
165  """Clear the host from the discovery cache."""
166  domain_data = hass.data[DOMAIN]
167  discoveries: list[FluxLEDDiscovery] = domain_data[FLUX_LED_DISCOVERY]
168  domain_data[FLUX_LED_DISCOVERY] = [
169  discovery for discovery in discoveries if discovery[ATTR_IPADDR] != host
170  ]
171 
172 
174  hass: HomeAssistant, timeout: int, address: str | None = None
175 ) -> list[FluxLEDDiscovery]:
176  """Discover flux led devices."""
177  if address:
178  targets = [address]
179  else:
180  targets = [
181  str(broadcast_address)
182  for broadcast_address in await network.async_get_ipv4_broadcast_addresses(
183  hass
184  )
185  ]
186 
187  scanner = AIOBulbScanner()
188  for idx, discovered in enumerate(
189  await asyncio.gather(
190  *[
191  create_eager_task(
192  scanner.async_scan(timeout=timeout, address=target_address)
193  )
194  for target_address in targets
195  ],
196  return_exceptions=True,
197  )
198  ):
199  if isinstance(discovered, Exception):
200  _LOGGER.debug("Scanning %s failed with error: %s", targets[idx], discovered)
201  continue
202 
203  if not address:
204  return scanner.getBulbInfo()
205 
206  return [
207  device for device in scanner.getBulbInfo() if device[ATTR_IPADDR] == address
208  ]
209 
210 
212  hass: HomeAssistant, host: str
213 ) -> FluxLEDDiscovery | None:
214  """Direct discovery at a single ip instead of broadcast."""
215  # If we are missing the unique_id we should be able to fetch it
216  # from the device by doing a directed discovery at the host only
217  for device in await async_discover_devices(hass, DIRECTED_DISCOVERY_TIMEOUT, host):
218  if device[ATTR_IPADDR] == host:
219  return device
220  return None
221 
222 
223 @callback
225  hass: HomeAssistant,
226  discovered_devices: list[FluxLEDDiscovery],
227 ) -> None:
228  """Trigger config flows for discovered devices."""
229  for device in discovered_devices:
230  discovery_flow.async_create_flow(
231  hass,
232  DOMAIN,
233  context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
234  data={**device},
235  )
None async_clear_discovery_cache(HomeAssistant hass, str host)
Definition: discovery.py:164
FluxLEDDiscovery|None async_discover_device(HomeAssistant hass, str host)
Definition: discovery.py:213
FluxLEDDiscovery async_build_cached_discovery(ConfigEntry entry)
Definition: discovery.py:66
FluxLEDDiscovery|None async_get_discovery(HomeAssistant hass, str host)
Definition: discovery.py:154
str async_name_from_discovery(FluxLEDDiscovery device, int|None model_num=None)
Definition: discovery.py:87
bool async_update_entry_from_discovery(HomeAssistant hass, config_entries.ConfigEntry entry, FluxLEDDiscovery device, int|None model_num, bool allow_update_mac)
Definition: discovery.py:123
list[FluxLEDDiscovery] async_discover_devices(HomeAssistant hass, int timeout, str|None address=None)
Definition: discovery.py:175
None async_populate_data_from_discovery(Mapping[str, Any] current_data, dict[str, Any] data_updates, FluxLEDDiscovery device)
Definition: discovery.py:104
None async_trigger_discovery(HomeAssistant hass, list[FluxLEDDiscovery] discovered_devices)
Definition: discovery.py:227
bool mac_matches_by_one(str formatted_mac_1, str formatted_mac_2)
Definition: util.py:30
str|None format_as_flux_mac(str|None mac)
Definition: util.py:21
bool is_ip_address(str address)
Definition: network.py:63