diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 1408cb56709..79249ae8c44 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -2,19 +2,12 @@ from __future__ import annotations import asyncio -import contextlib -from datetime import timedelta -from ipaddress import IPv4Address, IPv6Address import logging -from urllib.parse import urlparse -from async_upnp_client.search import SsdpSearchListener import voluptuous as vol from yeelight import BulbException -from yeelight.aio import KEY_CONNECTED, AsyncBulb +from yeelight.aio import AsyncBulb -from homeassistant import config_entries -from homeassistant.components import network from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady from homeassistant.const import ( CONF_DEVICES, @@ -25,82 +18,45 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType +from .const import ( + ACTION_OFF, + ACTION_RECOVER, + ACTION_STAY, + ATTR_ACTION, + ATTR_COUNT, + ATTR_TRANSITIONS, + CONF_CUSTOM_EFFECTS, + CONF_DETECTED_MODEL, + CONF_FLOW_PARAMS, + CONF_MODE_MUSIC, + CONF_MODEL, + CONF_NIGHTLIGHT_SWITCH, + CONF_NIGHTLIGHT_SWITCH_TYPE, + CONF_SAVE_ON_CHANGE, + CONF_TRANSITION, + DATA_CONFIG_ENTRIES, + DATA_CUSTOM_EFFECTS, + DATA_DEVICE, + DEFAULT_MODE_MUSIC, + DEFAULT_NAME, + DEFAULT_NIGHTLIGHT_SWITCH, + DEFAULT_SAVE_ON_CHANGE, + DEFAULT_TRANSITION, + DOMAIN, + NIGHTLIGHT_SWITCH_TYPE_LIGHT, + PLATFORMS, + YEELIGHT_HSV_TRANSACTION, + YEELIGHT_RGB_TRANSITION, + YEELIGHT_SLEEP_TRANSACTION, + YEELIGHT_TEMPERATURE_TRANSACTION, +) +from .device import YeelightDevice, async_format_id +from .scanner import YeelightScanner + _LOGGER = logging.getLogger(__name__) -STATE_CHANGE_TIME = 0.40 # seconds -POWER_STATE_CHANGE_TIME = 1 # seconds - -# -# These models do not transition correctly when turning on, and -# yeelight is no longer updating the firmware on older devices -# -# https://github.com/home-assistant/core/issues/58315 -# -# The problem can be worked around by always setting the brightness -# even when the bulb is reporting the brightness is already at the -# desired level. -# -MODELS_WITH_DELAYED_ON_TRANSITION = { - "color", # YLDP02YL -} - -DOMAIN = "yeelight" -DATA_YEELIGHT = DOMAIN -DATA_UPDATED = "yeelight_{}_data_updated" - -DEFAULT_NAME = "Yeelight" -DEFAULT_TRANSITION = 350 -DEFAULT_MODE_MUSIC = False -DEFAULT_SAVE_ON_CHANGE = False -DEFAULT_NIGHTLIGHT_SWITCH = False - -CONF_MODEL = "model" -CONF_DETECTED_MODEL = "detected_model" -CONF_TRANSITION = "transition" -CONF_SAVE_ON_CHANGE = "save_on_change" -CONF_MODE_MUSIC = "use_music_mode" -CONF_FLOW_PARAMS = "flow_params" -CONF_CUSTOM_EFFECTS = "custom_effects" -CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type" -CONF_NIGHTLIGHT_SWITCH = "nightlight_switch" - -DATA_CONFIG_ENTRIES = "config_entries" -DATA_CUSTOM_EFFECTS = "custom_effects" -DATA_DEVICE = "device" -DATA_REMOVE_INIT_DISPATCHER = "remove_init_dispatcher" -DATA_PLATFORMS_LOADED = "platforms_loaded" - -ATTR_COUNT = "count" -ATTR_ACTION = "action" -ATTR_TRANSITIONS = "transitions" -ATTR_MODE_MUSIC = "music_mode" - -ACTION_RECOVER = "recover" -ACTION_STAY = "stay" -ACTION_OFF = "off" - -ACTIVE_MODE_NIGHTLIGHT = 1 -ACTIVE_COLOR_FLOWING = 1 - -NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light" - -DISCOVERY_INTERVAL = timedelta(seconds=60) -SSDP_TARGET = ("239.255.255.250", 1982) -SSDP_ST = "wifi_bulb" -DISCOVERY_ATTEMPTS = 3 -DISCOVERY_SEARCH_INTERVAL = timedelta(seconds=2) -DISCOVERY_TIMEOUT = 8 - - -YEELIGHT_RGB_TRANSITION = "RGBTransition" -YEELIGHT_HSV_TRANSACTION = "HSVTransition" -YEELIGHT_TEMPERATURE_TRANSACTION = "TemperatureTransition" -YEELIGHT_SLEEP_TRANSACTION = "SleepTransition" YEELIGHT_FLOW_TRANSITION_SCHEMA = { vol.Optional(ATTR_COUNT, default=0): cv.positive_int, @@ -155,31 +111,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -UPDATE_REQUEST_PROPERTIES = [ - "power", - "main_power", - "bright", - "ct", - "rgb", - "hue", - "sat", - "color_mode", - "flowing", - "bg_power", - "bg_lmode", - "bg_flowing", - "bg_ct", - "bg_bright", - "bg_hue", - "bg_sat", - "bg_rgb", - "nl_br", - "active_mode", -] - - -PLATFORMS = ["binary_sensor", "light"] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Yeelight bulbs.""" @@ -299,410 +230,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -@callback -def async_format_model(model: str) -> str: - """Generate a more human readable model.""" - return model.replace("_", " ").title() - - -@callback -def async_format_id(id_: str) -> str: - """Generate a more human readable id.""" - return hex(int(id_, 16)) if id_ else "None" - - -@callback -def async_format_model_id(model: str, id_: str) -> str: - """Generate a more human readable name.""" - return f"{async_format_model(model)} {async_format_id(id_)}" - - -@callback -def _async_unique_name(capabilities: dict) -> str: - """Generate name from capabilities.""" - model_id = async_format_model_id(capabilities["model"], capabilities["id"]) - return f"Yeelight {model_id}" - - async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -class YeelightScanner: - """Scan for Yeelight devices.""" - - _scanner = None - - @classmethod - @callback - def async_get(cls, hass: HomeAssistant): - """Get scanner instance.""" - if cls._scanner is None: - cls._scanner = cls(hass) - return cls._scanner - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize class.""" - self._hass = hass - self._host_discovered_events = {} - self._unique_id_capabilities = {} - self._host_capabilities = {} - self._track_interval = None - self._listeners = [] - self._connected_events = [] - - async def async_setup(self): - """Set up the scanner.""" - if self._connected_events: - await self._async_wait_connected() - return - - for idx, source_ip in enumerate(await self._async_build_source_set()): - self._connected_events.append(asyncio.Event()) - - def _wrap_async_connected_idx(idx): - """Create a function to capture the idx cell variable.""" - - async def _async_connected(): - self._connected_events[idx].set() - - return _async_connected - - self._listeners.append( - SsdpSearchListener( - async_callback=self._async_process_entry, - service_type=SSDP_ST, - target=SSDP_TARGET, - source_ip=source_ip, - async_connect_callback=_wrap_async_connected_idx(idx), - ) - ) - - results = await asyncio.gather( - *(listener.async_start() for listener in self._listeners), - return_exceptions=True, - ) - failed_listeners = [] - for idx, result in enumerate(results): - if not isinstance(result, Exception): - continue - _LOGGER.warning( - "Failed to setup listener for %s: %s", - self._listeners[idx].source_ip, - result, - ) - failed_listeners.append(self._listeners[idx]) - self._connected_events[idx].set() - - for listener in failed_listeners: - self._listeners.remove(listener) - - await self._async_wait_connected() - self._track_interval = async_track_time_interval( - self._hass, self.async_scan, DISCOVERY_INTERVAL - ) - self.async_scan() - - async def _async_wait_connected(self): - """Wait for the listeners to be up and connected.""" - await asyncio.gather(*(event.wait() for event in self._connected_events)) - - async def _async_build_source_set(self) -> set[IPv4Address]: - """Build the list of ssdp sources.""" - adapters = await network.async_get_adapters(self._hass) - sources: set[IPv4Address] = set() - if network.async_only_default_interface_enabled(adapters): - sources.add(IPv4Address("0.0.0.0")) - return sources - - return { - source_ip - for source_ip in await network.async_get_enabled_source_ips(self._hass) - if not source_ip.is_loopback and not isinstance(source_ip, IPv6Address) - } - - async def async_discover(self): - """Discover bulbs.""" - _LOGGER.debug("Yeelight discover with interval %s", DISCOVERY_SEARCH_INTERVAL) - await self.async_setup() - for _ in range(DISCOVERY_ATTEMPTS): - self.async_scan() - await asyncio.sleep(DISCOVERY_SEARCH_INTERVAL.total_seconds()) - return self._unique_id_capabilities.values() - - @callback - def async_scan(self, *_): - """Send discovery packets.""" - _LOGGER.debug("Yeelight scanning") - for listener in self._listeners: - listener.async_search() - - async def async_get_capabilities(self, host): - """Get capabilities via SSDP.""" - if host in self._host_capabilities: - return self._host_capabilities[host] - - host_event = asyncio.Event() - self._host_discovered_events.setdefault(host, []).append(host_event) - await self.async_setup() - - for listener in self._listeners: - listener.async_search((host, SSDP_TARGET[1])) - - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(host_event.wait(), timeout=DISCOVERY_TIMEOUT) - - self._host_discovered_events[host].remove(host_event) - return self._host_capabilities.get(host) - - def _async_discovered_by_ssdp(self, response): - @callback - def _async_start_flow(*_): - asyncio.create_task( - self._hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=response, - ) - ) - - # Delay starting the flow in case the discovery is the result - # of another discovery - async_call_later(self._hass, 1, _async_start_flow) - - async def _async_process_entry(self, response): - """Process a discovery.""" - _LOGGER.debug("Discovered via SSDP: %s", response) - unique_id = response["id"] - host = urlparse(response["location"]).hostname - current_entry = self._unique_id_capabilities.get(unique_id) - # Make sure we handle ip changes - if not current_entry or host != urlparse(current_entry["location"]).hostname: - _LOGGER.debug("Yeelight discovered with %s", response) - self._async_discovered_by_ssdp(response) - self._host_capabilities[host] = response - self._unique_id_capabilities[unique_id] = response - for event in self._host_discovered_events.get(host, []): - event.set() - - -def update_needs_bg_power_workaround(data): - """Check if a push update needs the bg_power workaround. - - Some devices will push the incorrect state for bg_power. - - To work around this any time we are pushed an update - with bg_power, we force poll state which will be correct. - """ - return "bg_power" in data - - -class YeelightDevice: - """Represents single Yeelight device.""" - - def __init__(self, hass, host, config, bulb): - """Initialize device.""" - self._hass = hass - self._config = config - self._host = host - self._bulb_device = bulb - self.capabilities = {} - self._device_type = None - self._available = True - self._initialized = False - self._name = None - - @property - def bulb(self): - """Return bulb device.""" - return self._bulb_device - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def config(self): - """Return device config.""" - return self._config - - @property - def host(self): - """Return hostname.""" - return self._host - - @property - def available(self): - """Return true is device is available.""" - return self._available - - @callback - def async_mark_unavailable(self): - """Set unavailable on api call failure due to a network issue.""" - self._available = False - - @property - def model(self): - """Return configured/autodetected device model.""" - return self._bulb_device.model or self.capabilities.get("model") - - @property - def fw_version(self): - """Return the firmware version.""" - return self.capabilities.get("fw_ver") - - @property - def is_nightlight_supported(self) -> bool: - """ - Return true / false if nightlight is supported. - - Uses brightness as it appears to be supported in both ceiling and other lights. - """ - return self._nightlight_brightness is not None - - @property - def is_nightlight_enabled(self) -> bool: - """Return true / false if nightlight is currently enabled.""" - # Only ceiling lights have active_mode, from SDK docs: - # active_mode 0: daylight mode / 1: moonlight mode (ceiling light only) - if self._active_mode is not None: - return int(self._active_mode) == ACTIVE_MODE_NIGHTLIGHT - - if self._nightlight_brightness is not None: - return int(self._nightlight_brightness) > 0 - - return False - - @property - def is_color_flow_enabled(self) -> bool: - """Return true / false if color flow is currently running.""" - return self._color_flow and int(self._color_flow) == ACTIVE_COLOR_FLOWING - - @property - def _active_mode(self): - return self.bulb.last_properties.get("active_mode") - - @property - def _color_flow(self): - return self.bulb.last_properties.get("flowing") - - @property - def _nightlight_brightness(self): - return self.bulb.last_properties.get("nl_br") - - @property - def type(self): - """Return bulb type.""" - if not self._device_type: - self._device_type = self.bulb.bulb_type - - return self._device_type - - async def _async_update_properties(self): - """Read new properties from the device.""" - try: - await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES) - self._available = True - if not self._initialized: - self._initialized = True - except OSError as ex: - if self._available: # just inform once - _LOGGER.error( - "Unable to update device %s, %s: %s", self._host, self.name, ex - ) - self._available = False - except asyncio.TimeoutError as ex: - _LOGGER.debug( - "timed out while trying to update device %s, %s: %s", - self._host, - self.name, - ex, - ) - except BulbException as ex: - _LOGGER.debug( - "Unable to update device %s, %s: %s", self._host, self.name, ex - ) - - async def async_setup(self): - """Fetch capabilities and setup name if available.""" - scanner = YeelightScanner.async_get(self._hass) - self.capabilities = await scanner.async_get_capabilities(self._host) or {} - if self.capabilities: - self._bulb_device.set_capabilities(self.capabilities) - if name := self._config.get(CONF_NAME): - # Override default name when name is set in config - self._name = name - elif self.capabilities: - # Generate name from model and id when capabilities is available - self._name = _async_unique_name(self.capabilities) - else: - self._name = self._host # Default name is host - - async def async_update(self, force=False): - """Update device properties and send data updated signal.""" - if not force and self._initialized and self._available: - # No need to poll unless force, already connected - return - await self._async_update_properties() - async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) - - async def _async_forced_update(self, _now): - """Call a forced update.""" - await self.async_update(True) - - @callback - def async_update_callback(self, data): - """Update push from device.""" - was_available = self._available - self._available = data.get(KEY_CONNECTED, True) - if update_needs_bg_power_workaround(data) or ( - not was_available and self._available - ): - # On reconnect the properties may be out of sync - # - # If the device drops the connection right away, we do not want to - # do a property resync via async_update since its about - # to be called when async_setup_entry reaches the end of the - # function - # - async_call_later(self._hass, STATE_CHANGE_TIME, self._async_forced_update) - async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) - - -class YeelightEntity(Entity): - """Represents single Yeelight entity.""" - - _attr_should_poll = False - - def __init__(self, device: YeelightDevice, entry: ConfigEntry) -> None: - """Initialize the entity.""" - self._device = device - self._unique_id = entry.unique_id or entry.entry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._unique_id)}, - name=self._device.name, - manufacturer="Yeelight", - model=self._device.model, - sw_version=self._device.fw_version, - ) - - @property - def unique_id(self) -> str: - """Return the unique ID.""" - return self._unique_id - - @property - def available(self) -> bool: - """Return if bulb is available.""" - return self._device.available - - async def async_update(self) -> None: - """Update the entity.""" - await self._device.async_update() - - async def _async_get_device( hass: HomeAssistant, host: str, entry: ConfigEntry ) -> YeelightDevice: diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 185bb504a1b..89eb910f942 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -6,7 +6,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN, YeelightEntity +from .const import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN +from .entity import YeelightEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index d876f35a2dc..ba1ab4d24c7 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from . import ( +from .const import ( CONF_DETECTED_MODEL, CONF_MODE_MUSIC, CONF_MODEL, @@ -25,12 +25,14 @@ from . import ( CONF_TRANSITION, DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, - YeelightScanner, +) +from .device import ( _async_unique_name, async_format_id, async_format_model, async_format_model_id, ) +from .scanner import YeelightScanner MODEL_UNKNOWN = "unknown" diff --git a/homeassistant/components/yeelight/const.py b/homeassistant/components/yeelight/const.py new file mode 100644 index 00000000000..2b494bf9ef2 --- /dev/null +++ b/homeassistant/components/yeelight/const.py @@ -0,0 +1,103 @@ +"""Support for Xiaomi Yeelight WiFi color bulb.""" + +from datetime import timedelta + +DOMAIN = "yeelight" + + +STATE_CHANGE_TIME = 0.40 # seconds +POWER_STATE_CHANGE_TIME = 1 # seconds + + +# +# These models do not transition correctly when turning on, and +# yeelight is no longer updating the firmware on older devices +# +# https://github.com/home-assistant/core/issues/58315 +# +# The problem can be worked around by always setting the brightness +# even when the bulb is reporting the brightness is already at the +# desired level. +# +MODELS_WITH_DELAYED_ON_TRANSITION = { + "color", # YLDP02YL +} + +DATA_UPDATED = "yeelight_{}_data_updated" + +DEFAULT_NAME = "Yeelight" +DEFAULT_TRANSITION = 350 +DEFAULT_MODE_MUSIC = False +DEFAULT_SAVE_ON_CHANGE = False +DEFAULT_NIGHTLIGHT_SWITCH = False + +CONF_MODEL = "model" +CONF_DETECTED_MODEL = "detected_model" +CONF_TRANSITION = "transition" + +CONF_SAVE_ON_CHANGE = "save_on_change" +CONF_MODE_MUSIC = "use_music_mode" +CONF_FLOW_PARAMS = "flow_params" +CONF_CUSTOM_EFFECTS = "custom_effects" +CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type" +CONF_NIGHTLIGHT_SWITCH = "nightlight_switch" + +DATA_CONFIG_ENTRIES = "config_entries" +DATA_CUSTOM_EFFECTS = "custom_effects" +DATA_DEVICE = "device" +DATA_REMOVE_INIT_DISPATCHER = "remove_init_dispatcher" +DATA_PLATFORMS_LOADED = "platforms_loaded" + +ATTR_COUNT = "count" +ATTR_ACTION = "action" +ATTR_TRANSITIONS = "transitions" +ATTR_MODE_MUSIC = "music_mode" + +ACTION_RECOVER = "recover" +ACTION_STAY = "stay" +ACTION_OFF = "off" + +ACTIVE_MODE_NIGHTLIGHT = 1 +ACTIVE_COLOR_FLOWING = 1 + + +NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light" + +DISCOVERY_INTERVAL = timedelta(seconds=60) +SSDP_TARGET = ("239.255.255.250", 1982) +SSDP_ST = "wifi_bulb" +DISCOVERY_ATTEMPTS = 3 +DISCOVERY_SEARCH_INTERVAL = timedelta(seconds=2) +DISCOVERY_TIMEOUT = 8 + + +YEELIGHT_RGB_TRANSITION = "RGBTransition" +YEELIGHT_HSV_TRANSACTION = "HSVTransition" +YEELIGHT_TEMPERATURE_TRANSACTION = "TemperatureTransition" +YEELIGHT_SLEEP_TRANSACTION = "SleepTransition" + + +UPDATE_REQUEST_PROPERTIES = [ + "power", + "main_power", + "bright", + "ct", + "rgb", + "hue", + "sat", + "color_mode", + "flowing", + "bg_power", + "bg_lmode", + "bg_flowing", + "bg_ct", + "bg_bright", + "bg_hue", + "bg_sat", + "bg_rgb", + "nl_br", + "active_mode", +] + + +PLATFORMS = ["binary_sensor", "light"] diff --git a/homeassistant/components/yeelight/device.py b/homeassistant/components/yeelight/device.py new file mode 100644 index 00000000000..5f70866b229 --- /dev/null +++ b/homeassistant/components/yeelight/device.py @@ -0,0 +1,233 @@ +"""Support for Xiaomi Yeelight WiFi color bulb.""" +from __future__ import annotations + +import asyncio +import logging + +from yeelight import BulbException +from yeelight.aio import KEY_CONNECTED + +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later + +from .const import ( + ACTIVE_COLOR_FLOWING, + ACTIVE_MODE_NIGHTLIGHT, + DATA_UPDATED, + STATE_CHANGE_TIME, + UPDATE_REQUEST_PROPERTIES, +) +from .scanner import YeelightScanner + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_format_model(model: str) -> str: + """Generate a more human readable model.""" + return model.replace("_", " ").title() + + +@callback +def async_format_id(id_: str) -> str: + """Generate a more human readable id.""" + return hex(int(id_, 16)) if id_ else "None" + + +@callback +def async_format_model_id(model: str, id_: str) -> str: + """Generate a more human readable name.""" + return f"{async_format_model(model)} {async_format_id(id_)}" + + +@callback +def _async_unique_name(capabilities: dict) -> str: + """Generate name from capabilities.""" + model_id = async_format_model_id(capabilities["model"], capabilities["id"]) + return f"Yeelight {model_id}" + + +def update_needs_bg_power_workaround(data): + """Check if a push update needs the bg_power workaround. + + Some devices will push the incorrect state for bg_power. + + To work around this any time we are pushed an update + with bg_power, we force poll state which will be correct. + """ + return "bg_power" in data + + +class YeelightDevice: + """Represents single Yeelight device.""" + + def __init__(self, hass, host, config, bulb): + """Initialize device.""" + self._hass = hass + self._config = config + self._host = host + self._bulb_device = bulb + self.capabilities = {} + self._device_type = None + self._available = True + self._initialized = False + self._name = None + + @property + def bulb(self): + """Return bulb device.""" + return self._bulb_device + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def config(self): + """Return device config.""" + return self._config + + @property + def host(self): + """Return hostname.""" + return self._host + + @property + def available(self): + """Return true is device is available.""" + return self._available + + @callback + def async_mark_unavailable(self): + """Set unavailable on api call failure due to a network issue.""" + self._available = False + + @property + def model(self): + """Return configured/autodetected device model.""" + return self._bulb_device.model or self.capabilities.get("model") + + @property + def fw_version(self): + """Return the firmware version.""" + return self.capabilities.get("fw_ver") + + @property + def is_nightlight_supported(self) -> bool: + """ + Return true / false if nightlight is supported. + + Uses brightness as it appears to be supported in both ceiling and other lights. + """ + return self._nightlight_brightness is not None + + @property + def is_nightlight_enabled(self) -> bool: + """Return true / false if nightlight is currently enabled.""" + # Only ceiling lights have active_mode, from SDK docs: + # active_mode 0: daylight mode / 1: moonlight mode (ceiling light only) + if self._active_mode is not None: + return int(self._active_mode) == ACTIVE_MODE_NIGHTLIGHT + + if self._nightlight_brightness is not None: + return int(self._nightlight_brightness) > 0 + + return False + + @property + def is_color_flow_enabled(self) -> bool: + """Return true / false if color flow is currently running.""" + return self._color_flow and int(self._color_flow) == ACTIVE_COLOR_FLOWING + + @property + def _active_mode(self): + return self.bulb.last_properties.get("active_mode") + + @property + def _color_flow(self): + return self.bulb.last_properties.get("flowing") + + @property + def _nightlight_brightness(self): + return self.bulb.last_properties.get("nl_br") + + @property + def type(self): + """Return bulb type.""" + if not self._device_type: + self._device_type = self.bulb.bulb_type + + return self._device_type + + async def _async_update_properties(self): + """Read new properties from the device.""" + try: + await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES) + self._available = True + if not self._initialized: + self._initialized = True + except OSError as ex: + if self._available: # just inform once + _LOGGER.error( + "Unable to update device %s, %s: %s", self._host, self.name, ex + ) + self._available = False + except asyncio.TimeoutError as ex: + _LOGGER.debug( + "timed out while trying to update device %s, %s: %s", + self._host, + self.name, + ex, + ) + except BulbException as ex: + _LOGGER.debug( + "Unable to update device %s, %s: %s", self._host, self.name, ex + ) + + async def async_setup(self): + """Fetch capabilities and setup name if available.""" + scanner = YeelightScanner.async_get(self._hass) + self.capabilities = await scanner.async_get_capabilities(self._host) or {} + if self.capabilities: + self._bulb_device.set_capabilities(self.capabilities) + if name := self._config.get(CONF_NAME): + # Override default name when name is set in config + self._name = name + elif self.capabilities: + # Generate name from model and id when capabilities is available + self._name = _async_unique_name(self.capabilities) + else: + self._name = self._host # Default name is host + + async def async_update(self, force=False): + """Update device properties and send data updated signal.""" + if not force and self._initialized and self._available: + # No need to poll unless force, already connected + return + await self._async_update_properties() + async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) + + async def _async_forced_update(self, _now): + """Call a forced update.""" + await self.async_update(True) + + @callback + def async_update_callback(self, data): + """Update push from device.""" + was_available = self._available + self._available = data.get(KEY_CONNECTED, True) + if update_needs_bg_power_workaround(data) or ( + not was_available and self._available + ): + # On reconnect the properties may be out of sync + # + # If the device drops the connection right away, we do not want to + # do a property resync via async_update since its about + # to be called when async_setup_entry reaches the end of the + # function + # + async_call_later(self._hass, STATE_CHANGE_TIME, self._async_forced_update) + async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) diff --git a/homeassistant/components/yeelight/entity.py b/homeassistant/components/yeelight/entity.py new file mode 100644 index 00000000000..53211115dd6 --- /dev/null +++ b/homeassistant/components/yeelight/entity.py @@ -0,0 +1,40 @@ +"""Support for Xiaomi Yeelight WiFi color bulb.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN +from .device import YeelightDevice + + +class YeelightEntity(Entity): + """Represents single Yeelight entity.""" + + _attr_should_poll = False + + def __init__(self, device: YeelightDevice, entry: ConfigEntry) -> None: + """Initialize the entity.""" + self._device = device + self._unique_id = entry.unique_id or entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + name=self._device.name, + manufacturer="Yeelight", + model=self._device.model, + sw_version=self._device.fw_version, + ) + + @property + def unique_id(self) -> str: + """Return the unique ID.""" + return self._unique_id + + @property + def available(self) -> bool: + """Return if bulb is available.""" + return self._device.available + + async def async_update(self) -> None: + """Update the entity.""" + await self._device.async_update() diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 838e56f5d99..75735beed34 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -47,7 +47,8 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, ) -from . import ( +from . import YEELIGHT_FLOW_TRANSITION_SCHEMA +from .const import ( ACTION_RECOVER, ATTR_ACTION, ATTR_COUNT, @@ -65,9 +66,8 @@ from . import ( DOMAIN, MODELS_WITH_DELAYED_ON_TRANSITION, POWER_STATE_CHANGE_TIME, - YEELIGHT_FLOW_TRANSITION_SCHEMA, - YeelightEntity, ) +from .entity import YeelightEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py new file mode 100644 index 00000000000..a331db8d4ed --- /dev/null +++ b/homeassistant/components/yeelight/scanner.py @@ -0,0 +1,185 @@ +"""Support for Xiaomi Yeelight WiFi color bulb.""" +from __future__ import annotations + +import asyncio +import contextlib +from ipaddress import IPv4Address, IPv6Address +import logging +from urllib.parse import urlparse + +from async_upnp_client.search import SsdpSearchListener + +from homeassistant import config_entries +from homeassistant.components import network +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_call_later, async_track_time_interval + +from .const import ( + DISCOVERY_ATTEMPTS, + DISCOVERY_INTERVAL, + DISCOVERY_SEARCH_INTERVAL, + DISCOVERY_TIMEOUT, + DOMAIN, + SSDP_ST, + SSDP_TARGET, +) + +_LOGGER = logging.getLogger(__name__) + + +class YeelightScanner: + """Scan for Yeelight devices.""" + + _scanner = None + + @classmethod + @callback + def async_get(cls, hass: HomeAssistant): + """Get scanner instance.""" + if cls._scanner is None: + cls._scanner = cls(hass) + return cls._scanner + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize class.""" + self._hass = hass + self._host_discovered_events = {} + self._unique_id_capabilities = {} + self._host_capabilities = {} + self._track_interval = None + self._listeners = [] + self._connected_events = [] + + async def async_setup(self): + """Set up the scanner.""" + if self._connected_events: + await self._async_wait_connected() + return + + for idx, source_ip in enumerate(await self._async_build_source_set()): + self._connected_events.append(asyncio.Event()) + + def _wrap_async_connected_idx(idx): + """Create a function to capture the idx cell variable.""" + + async def _async_connected(): + self._connected_events[idx].set() + + return _async_connected + + self._listeners.append( + SsdpSearchListener( + async_callback=self._async_process_entry, + service_type=SSDP_ST, + target=SSDP_TARGET, + source_ip=source_ip, + async_connect_callback=_wrap_async_connected_idx(idx), + ) + ) + + results = await asyncio.gather( + *(listener.async_start() for listener in self._listeners), + return_exceptions=True, + ) + failed_listeners = [] + for idx, result in enumerate(results): + if not isinstance(result, Exception): + continue + _LOGGER.warning( + "Failed to setup listener for %s: %s", + self._listeners[idx].source_ip, + result, + ) + failed_listeners.append(self._listeners[idx]) + self._connected_events[idx].set() + + for listener in failed_listeners: + self._listeners.remove(listener) + + await self._async_wait_connected() + self._track_interval = async_track_time_interval( + self._hass, self.async_scan, DISCOVERY_INTERVAL + ) + self.async_scan() + + async def _async_wait_connected(self): + """Wait for the listeners to be up and connected.""" + await asyncio.gather(*(event.wait() for event in self._connected_events)) + + async def _async_build_source_set(self) -> set[IPv4Address]: + """Build the list of ssdp sources.""" + adapters = await network.async_get_adapters(self._hass) + sources: set[IPv4Address] = set() + if network.async_only_default_interface_enabled(adapters): + sources.add(IPv4Address("0.0.0.0")) + return sources + + return { + source_ip + for source_ip in await network.async_get_enabled_source_ips(self._hass) + if not source_ip.is_loopback and not isinstance(source_ip, IPv6Address) + } + + async def async_discover(self): + """Discover bulbs.""" + _LOGGER.debug("Yeelight discover with interval %s", DISCOVERY_SEARCH_INTERVAL) + await self.async_setup() + for _ in range(DISCOVERY_ATTEMPTS): + self.async_scan() + await asyncio.sleep(DISCOVERY_SEARCH_INTERVAL.total_seconds()) + return self._unique_id_capabilities.values() + + @callback + def async_scan(self, *_): + """Send discovery packets.""" + _LOGGER.debug("Yeelight scanning") + for listener in self._listeners: + listener.async_search() + + async def async_get_capabilities(self, host): + """Get capabilities via SSDP.""" + if host in self._host_capabilities: + return self._host_capabilities[host] + + host_event = asyncio.Event() + self._host_discovered_events.setdefault(host, []).append(host_event) + await self.async_setup() + + for listener in self._listeners: + listener.async_search((host, SSDP_TARGET[1])) + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(host_event.wait(), timeout=DISCOVERY_TIMEOUT) + + self._host_discovered_events[host].remove(host_event) + return self._host_capabilities.get(host) + + def _async_discovered_by_ssdp(self, response): + @callback + def _async_start_flow(*_): + asyncio.create_task( + self._hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=response, + ) + ) + + # Delay starting the flow in case the discovery is the result + # of another discovery + async_call_later(self._hass, 1, _async_start_flow) + + async def _async_process_entry(self, response): + """Process a discovery.""" + _LOGGER.debug("Discovered via SSDP: %s", response) + unique_id = response["id"] + host = urlparse(response["location"]).hostname + current_entry = self._unique_id_capabilities.get(unique_id) + # Make sure we handle ip changes + if not current_entry or host != urlparse(current_entry["location"]).hostname: + _LOGGER.debug("Yeelight discovered with %s", response) + self._async_discovered_by_ssdp(response) + self._host_capabilities[host] = response + self._unique_id_capabilities[unique_id] = response + for event in self._host_discovered_events.get(host, []): + event.set() diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 2fa9c029d92..a93cea7a94c 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -8,7 +8,7 @@ from async_upnp_client.search import SsdpSearchListener from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS -from homeassistant.components import yeelight as hass_yeelight, zeroconf +from homeassistant.components import zeroconf from homeassistant.components.yeelight import ( CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH_TYPE, @@ -16,6 +16,7 @@ from homeassistant.components.yeelight import ( DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, YeelightScanner, + scanner, ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import callback @@ -185,16 +186,14 @@ def _patch_discovery(no_device=False, capabilities=None): ) return patch( - "homeassistant.components.yeelight.SsdpSearchListener", + "homeassistant.components.yeelight.scanner.SsdpSearchListener", new=_generate_fake_ssdp_listener, ) def _patch_discovery_interval(): - return patch.object( - hass_yeelight, "DISCOVERY_SEARCH_INTERVAL", timedelta(seconds=0) - ) + return patch.object(scanner, "DISCOVERY_SEARCH_INTERVAL", timedelta(seconds=0)) def _patch_discovery_timeout(): - return patch.object(hass_yeelight, "DISCOVERY_TIMEOUT", 0.0001) + return patch.object(scanner, "DISCOVERY_TIMEOUT", 0.0001) diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index a983c4a2127..8628e9620e9 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -5,7 +5,8 @@ import pytest from homeassistant import config_entries from homeassistant.components import dhcp, zeroconf -from homeassistant.components.yeelight import ( +from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect +from homeassistant.components.yeelight.const import ( CONF_DETECTED_MODEL, CONF_MODE_MUSIC, CONF_MODEL, @@ -21,7 +22,6 @@ from homeassistant.components.yeelight import ( DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, ) -from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 39e31aa91cf..13c71d656bb 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -7,7 +7,7 @@ import pytest from yeelight import BulbException, BulbType from yeelight.aio import KEY_CONNECTED -from homeassistant.components.yeelight import ( +from homeassistant.components.yeelight.const import ( CONF_DETECTED_MODEL, CONF_NIGHTLIGHT_SWITCH, CONF_NIGHTLIGHT_SWITCH_TYPE, diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 4377efe129f..059a47b53a1 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -36,7 +36,7 @@ from homeassistant.components.light import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.components.yeelight import ( +from homeassistant.components.yeelight.const import ( ATTR_COUNT, ATTR_MODE_MUSIC, ATTR_TRANSITIONS,