Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The Flux LED/MagicLight integration."""
2 
3 from __future__ import annotations
4 
5 from datetime import timedelta
6 import logging
7 from typing import Any, Final, cast
8 
9 from flux_led import DeviceType
10 from flux_led.aio import AIOWifiLedBulb
11 from flux_led.const import ATTR_ID, WhiteChannelType
12 from flux_led.scanner import FluxLEDDiscovery
13 
14 from homeassistant.config_entries import ConfigEntry
15 from homeassistant.const import CONF_HOST, Platform
16 from homeassistant.core import HomeAssistant, callback
17 from homeassistant.exceptions import ConfigEntryNotReady
18 from homeassistant.helpers import (
19  config_validation as cv,
20  device_registry as dr,
21  entity_registry as er,
22 )
24  async_dispatcher_connect,
25  async_dispatcher_send,
26 )
27 from homeassistant.helpers.event import (
28  async_track_time_change,
29  async_track_time_interval,
30 )
31 from homeassistant.helpers.typing import ConfigType
32 
33 from .const import (
34  CONF_WHITE_CHANNEL_TYPE,
35  DISCOVER_SCAN_TIMEOUT,
36  DOMAIN,
37  FLUX_LED_DISCOVERY,
38  FLUX_LED_DISCOVERY_SIGNAL,
39  FLUX_LED_EXCEPTIONS,
40  SIGNAL_STATE_UPDATED,
41 )
42 from .coordinator import FluxLedUpdateCoordinator
43 from .discovery import (
44  async_build_cached_discovery,
45  async_clear_discovery_cache,
46  async_discover_device,
47  async_discover_devices,
48  async_get_discovery,
49  async_trigger_discovery,
50  async_update_entry_from_discovery,
51 )
52 from .util import mac_matches_by_one
53 
54 _LOGGER = logging.getLogger(__name__)
55 
56 PLATFORMS_BY_TYPE: Final = {
57  DeviceType.Bulb: [
58  Platform.BUTTON,
59  Platform.LIGHT,
60  Platform.NUMBER,
61  Platform.SELECT,
62  Platform.SENSOR,
63  Platform.SWITCH,
64  ],
65  DeviceType.Switch: [
66  Platform.BUTTON,
67  Platform.SELECT,
68  Platform.SENSOR,
69  Platform.SWITCH,
70  ],
71 }
72 DISCOVERY_INTERVAL: Final = timedelta(minutes=15)
73 REQUEST_REFRESH_DELAY: Final = 1.5
74 NAME_TO_WHITE_CHANNEL_TYPE: Final = {
75  option.name.lower(): option for option in WhiteChannelType
76 }
77 
78 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
79 
80 
81 @callback
83  host: str, discovery: FluxLEDDiscovery | None
84 ) -> AIOWifiLedBulb:
85  """Create a AIOWifiLedBulb from a host."""
86  return AIOWifiLedBulb(host, discovery=discovery)
87 
88 
89 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
90  """Set up the flux_led component."""
91  domain_data = hass.data.setdefault(DOMAIN, {})
92  domain_data[FLUX_LED_DISCOVERY] = []
93 
94  @callback
95  def _async_start_background_discovery(*_: Any) -> None:
96  """Run discovery in the background."""
97  hass.async_create_background_task(
98  _async_discovery(), "flux_led-discovery", eager_start=True
99  )
100 
101  async def _async_discovery(*_: Any) -> None:
103  hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT)
104  )
105 
106  _async_start_background_discovery()
108  hass,
109  _async_start_background_discovery,
110  DISCOVERY_INTERVAL,
111  cancel_on_shutdown=True,
112  )
113  return True
114 
115 
116 async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None:
117  """Migrate entities when the mac address gets discovered."""
118 
119  @callback
120  def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None:
121  if not (unique_id := entry.unique_id):
122  return None
123  entry_id = entry.entry_id
124  entity_unique_id = entity_entry.unique_id
125  entity_mac = entity_unique_id[: len(unique_id)]
126  new_unique_id = None
127  if entity_unique_id.startswith(entry_id):
128  # Old format {entry_id}....., New format {unique_id}....
129  new_unique_id = f"{unique_id}{entity_unique_id.removeprefix(entry_id)}"
130  elif (
131  ":" in entity_mac
132  and entity_mac != unique_id
133  and mac_matches_by_one(entity_mac, unique_id)
134  ):
135  # Old format {dhcp_mac}....., New format {discovery_mac}....
136  new_unique_id = f"{unique_id}{entity_unique_id[len(unique_id):]}"
137  else:
138  return None
139  _LOGGER.debug(
140  "Migrating unique_id from [%s] to [%s]",
141  entity_unique_id,
142  new_unique_id,
143  )
144  return {"new_unique_id": new_unique_id}
145 
146  await er.async_migrate_entries(hass, entry.entry_id, _async_migrator)
147 
148 
149 async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
150  """Handle options update."""
151  coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
152  if entry.title != coordinator.title:
153  await hass.config_entries.async_reload(entry.entry_id)
154 
155 
156 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
157  """Set up Flux LED/MagicLight from a config entry."""
158  host = entry.data[CONF_HOST]
159  discovery_cached = True
160  if discovery := async_get_discovery(hass, host):
161  discovery_cached = False
162  else:
163  discovery = async_build_cached_discovery(entry)
164  device: AIOWifiLedBulb = async_wifi_bulb_for_host(host, discovery=discovery)
165  signal = SIGNAL_STATE_UPDATED.format(device.ipaddr)
166  device.discovery = discovery
167  if white_channel_type := entry.data.get(CONF_WHITE_CHANNEL_TYPE):
168  device.white_channel_channel_type = NAME_TO_WHITE_CHANNEL_TYPE[
169  white_channel_type
170  ]
171 
172  @callback
173  def _async_state_changed(*_: Any) -> None:
174  _LOGGER.debug("%s: Device state updated: %s", device.ipaddr, device.raw_state)
175  async_dispatcher_send(hass, signal)
176 
177  try:
178  await device.async_setup(_async_state_changed)
179  except FLUX_LED_EXCEPTIONS as ex:
180  raise ConfigEntryNotReady(
181  str(ex) or f"Timed out trying to connect to {device.ipaddr}"
182  ) from ex
183 
184  # UDP probe after successful connect only
185  if discovery_cached:
186  if directed_discovery := await async_discover_device(hass, host):
187  device.discovery = discovery = directed_discovery
188  discovery_cached = False
189 
190  if entry.unique_id and discovery.get(ATTR_ID):
191  mac = dr.format_mac(cast(str, discovery[ATTR_ID]))
192  if not mac_matches_by_one(mac, entry.unique_id):
193  # The device is offline and another flux_led device is now using the ip address
194  raise ConfigEntryNotReady(
195  f"Unexpected device found at {host}; Expected {entry.unique_id}, found"
196  f" {mac}"
197  )
198 
199  if not discovery_cached:
200  # Only update the entry once we have verified the unique id
201  # is either missing or we have verified it matches
203  hass, entry, discovery, device.model_num, True
204  )
205 
206  await _async_migrate_unique_ids(hass, entry)
207 
208  coordinator = FluxLedUpdateCoordinator(hass, device, entry)
209  hass.data[DOMAIN][entry.entry_id] = coordinator
210  platforms = PLATFORMS_BY_TYPE[device.device_type]
211  await hass.config_entries.async_forward_entry_setups(entry, platforms)
212 
213  async def _async_sync_time(*args: Any) -> None:
214  """Set the time every morning at 02:40:30."""
215  await device.async_set_time()
216 
217  await _async_sync_time() # set at startup
218  entry.async_on_unload(async_track_time_change(hass, _async_sync_time, 3, 40, 30))
219 
220  # There must not be any awaits between here and the return
221  # to avoid a race condition where the add_update_listener is not
222  # in place in time for the check in async_update_entry_from_discovery
223  entry.async_on_unload(entry.add_update_listener(_async_update_listener))
224 
225  async def _async_handle_discovered_device() -> None:
226  """Handle device discovery."""
227  # Force a refresh if the device is now available
228  if not coordinator.last_update_success:
229  coordinator.force_next_update = True
230  await coordinator.async_refresh()
231 
232  entry.async_on_unload(
234  hass,
235  FLUX_LED_DISCOVERY_SIGNAL.format(entry_id=entry.entry_id),
236  _async_handle_discovered_device,
237  )
238  )
239  return True
240 
241 
242 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
243  """Unload a config entry."""
244  device: AIOWifiLedBulb = hass.data[DOMAIN][entry.entry_id].device
245  platforms = PLATFORMS_BY_TYPE[device.device_type]
246  if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms):
247  # Make sure we probe the device again in case something has changed externally
248  async_clear_discovery_cache(hass, entry.data[CONF_HOST])
249  del hass.data[DOMAIN][entry.entry_id]
250  await device.async_stop()
251  return unload_ok
ElkSystem|None async_discover_device(HomeAssistant hass, str host)
Definition: discovery.py:78
bool async_update_entry_from_discovery(HomeAssistant hass, config_entries.ConfigEntry entry, ElkSystem device)
Definition: discovery.py:30
None async_clear_discovery_cache(HomeAssistant hass, str host)
Definition: discovery.py:164
FluxLEDDiscovery async_build_cached_discovery(ConfigEntry entry)
Definition: discovery.py:66
FluxLEDDiscovery|None async_get_discovery(HomeAssistant hass, str host)
Definition: discovery.py:154
bool mac_matches_by_one(str formatted_mac_1, str formatted_mac_2)
Definition: util.py:30
None _async_migrate_unique_ids(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:116
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:242
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:89
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:156
None _async_update_listener(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:149
AIOWifiLedBulb async_wifi_bulb_for_host(str host, FluxLEDDiscovery|None discovery)
Definition: __init__.py:84
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
CALLBACK_TYPE async_track_time_change(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, Any|None hour=None, Any|None minute=None, Any|None second=None)
Definition: event.py:1904
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679