"""Support for LIFX.""" from __future__ import annotations import asyncio from collections.abc import Iterable from datetime import datetime, timedelta import socket from typing import Any from aiolifx.aiolifx import Light from aiolifx.connection import LIFXConnection import voluptuous as vol from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STARTED, Platform, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType from .const import _LOGGER, DATA_LIFX_MANAGER, DOMAIN, TARGET_ANY from .coordinator import LIFXUpdateCoordinator from .discovery import async_discover_devices, async_trigger_discovery from .manager import LIFXManager from .migration import async_migrate_entities_devices, async_migrate_legacy_entries from .util import async_entry_is_legacy, async_get_legacy_entry CONF_SERVER = "server" CONF_BROADCAST = "broadcast" INTERFACE_SCHEMA = vol.Schema( { vol.Optional(CONF_SERVER): cv.string, vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_BROADCAST): cv.string, } ) CONFIG_SCHEMA = vol.All( cv.deprecated(DOMAIN), vol.Schema( { DOMAIN: { LIGHT_DOMAIN: vol.Schema(vol.All(cv.ensure_list, [INTERFACE_SCHEMA])) } }, extra=vol.ALLOW_EXTRA, ), ) PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT] DISCOVERY_INTERVAL = timedelta(minutes=15) MIGRATION_INTERVAL = timedelta(minutes=5) DISCOVERY_COOLDOWN = 5 async def async_legacy_migration( hass: HomeAssistant, legacy_entry: ConfigEntry, discovered_devices: Iterable[Light], ) -> bool: """Migrate config entries.""" existing_serials = { entry.unique_id for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id and not async_entry_is_legacy(entry) } # device.mac_addr is not the mac_address, its the serial number hosts_by_serial = {device.mac_addr: device.ip_addr for device in discovered_devices} missing_discovery_count = async_migrate_legacy_entries( hass, hosts_by_serial, existing_serials, legacy_entry ) if missing_discovery_count: _LOGGER.info( "Migration in progress, waiting to discover %s device(s)", missing_discovery_count, ) return False _LOGGER.debug( "Migration successful, removing legacy entry %s", legacy_entry.entry_id ) await hass.config_entries.async_remove(legacy_entry.entry_id) return True class LIFXDiscoveryManager: """Manage discovery and migration.""" def __init__(self, hass: HomeAssistant, migrating: bool) -> None: """Init the manager.""" self.hass = hass self.lock = asyncio.Lock() self.migrating = migrating self._cancel_discovery: CALLBACK_TYPE | None = None @callback def async_setup_discovery_interval(self) -> None: """Set up discovery at an interval.""" if self._cancel_discovery: self._cancel_discovery() self._cancel_discovery = None discovery_interval = ( MIGRATION_INTERVAL if self.migrating else DISCOVERY_INTERVAL ) _LOGGER.debug( "LIFX starting discovery with interval: %s and migrating: %s", discovery_interval, self.migrating, ) self._cancel_discovery = async_track_time_interval( self.hass, self.async_discovery, discovery_interval ) async def async_discovery(self, *_: Any) -> None: """Discovery and migrate LIFX devics.""" migrating_was_in_progress = self.migrating async with self.lock: discovered = await async_discover_devices(self.hass) if legacy_entry := async_get_legacy_entry(self.hass): migration_complete = await async_legacy_migration( self.hass, legacy_entry, discovered ) if migration_complete and migrating_was_in_progress: self.migrating = False _LOGGER.debug( "LIFX migration complete, switching to normal discovery interval: %s", DISCOVERY_INTERVAL, ) self.async_setup_discovery_interval() if discovered: async_trigger_discovery(self.hass, discovered) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIFX component.""" hass.data[DOMAIN] = {} migrating = bool(async_get_legacy_entry(hass)) discovery_manager = LIFXDiscoveryManager(hass, migrating) @callback def _async_delayed_discovery(now: datetime) -> None: """Start an untracked task to discover devices. We do not want the discovery task to block startup. """ asyncio.create_task(discovery_manager.async_discovery()) # Let the system settle a bit before starting discovery # to reduce the risk we miss devices because the event # loop is blocked at startup. discovery_manager.async_setup_discovery_interval() async_call_later(hass, DISCOVERY_COOLDOWN, _async_delayed_discovery) hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, discovery_manager.async_discovery ) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up LIFX from a config entry.""" if async_entry_is_legacy(entry): return True if legacy_entry := async_get_legacy_entry(hass): # If the legacy entry still exists, harvest the entities # that are moving to this config entry. async_migrate_entities_devices(hass, legacy_entry.entry_id, entry) assert entry.unique_id is not None domain_data = hass.data[DOMAIN] if DATA_LIFX_MANAGER not in domain_data: manager = LIFXManager(hass) domain_data[DATA_LIFX_MANAGER] = manager manager.async_setup() host = entry.data[CONF_HOST] connection = LIFXConnection(host, TARGET_ANY) try: await connection.async_setup() except socket.gaierror as ex: connection.async_stop() raise ConfigEntryNotReady(f"Could not resolve {host}: {ex}") from ex coordinator = LIFXUpdateCoordinator(hass, connection, entry.title) coordinator.async_setup() try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: connection.async_stop() raise domain_data[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if async_entry_is_legacy(entry): return True domain_data = hass.data[DOMAIN] if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): coordinator: LIFXUpdateCoordinator = domain_data.pop(entry.entry_id) coordinator.connection.async_stop() # Only the DATA_LIFX_MANAGER left, remove it. if len(domain_data) == 1: manager: LIFXManager = domain_data.pop(DATA_LIFX_MANAGER) manager.async_unload() return unload_ok