From f23fcfcd9b24f9f8a29c3391738136d4bb1e5863 Mon Sep 17 00:00:00 2001 From: Xiaonan Shen Date: Tue, 27 Oct 2020 21:47:11 +0800 Subject: [PATCH] Fix yeelight connection issue (#40251) Co-authored-by: Paulus Schoutsen --- homeassistant/components/yeelight/__init__.py | 68 ++++++++++++------ .../components/yeelight/binary_sensor.py | 13 +--- .../components/yeelight/config_flow.py | 55 ++++++++------ homeassistant/components/yeelight/light.py | 27 ++----- .../components/yeelight/strings.json | 2 +- tests/components/yeelight/__init__.py | 18 +++-- .../components/yeelight/test_binary_sensor.py | 4 +- tests/components/yeelight/test_config_flow.py | 53 ++++++++++---- tests/components/yeelight/test_init.py | 72 +++++++++++++++++-- tests/components/yeelight/test_light.py | 44 ++++++------ 10 files changed, 230 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index f5403062faa..ae9d75de54f 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -7,7 +7,7 @@ from typing import Optional import voluptuous as vol from yeelight import Bulb, BulbException, discover_bulbs -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_DEVICES, CONF_HOST, @@ -180,8 +180,8 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Yeelight from a config entry.""" - async def _initialize(host: str) -> None: - device = await _async_setup_device(hass, host, entry.options) + async def _initialize(host: str, capabilities: Optional[dict] = None) -> None: + device = await _async_setup_device(hass, host, entry, capabilities) hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id][DATA_DEVICE] = device for component in PLATFORMS: hass.async_create_task( @@ -252,20 +252,33 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): async def _async_setup_device( hass: HomeAssistant, host: str, - config: dict, + entry: ConfigEntry, + capabilities: Optional[dict], ) -> None: + # Get model from config and capabilities + model = entry.options.get(CONF_MODEL) + if not model and capabilities is not None: + model = capabilities.get("model") + # Set up device - bulb = Bulb(host, model=config.get(CONF_MODEL) or None) - capabilities = await hass.async_add_executor_job(bulb.get_capabilities) - if capabilities is None: # timeout - _LOGGER.error("Failed to get capabilities from %s", host) - raise ConfigEntryNotReady - device = YeelightDevice(hass, host, config, bulb) + bulb = Bulb(host, model=model or None) + if capabilities is None: + capabilities = await hass.async_add_executor_job(bulb.get_capabilities) + + device = YeelightDevice(hass, host, entry.options, bulb, capabilities) await hass.async_add_executor_job(device.update) await device.async_setup() return device +@callback +def _async_unique_name(capabilities: dict) -> str: + """Generate name from capabilities.""" + model = capabilities["model"] + unique_id = capabilities["id"] + return f"yeelight_{model}_{unique_id}" + + async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) @@ -332,7 +345,7 @@ class YeelightScanner: """Register callback function.""" host = self._seen.get(unique_id) if host is not None: - self._hass.async_add_job(callback_func(host)) + self._hass.async_create_task(callback_func(host)) else: self._callbacks[unique_id] = callback_func if len(self._callbacks) == 1: @@ -351,18 +364,25 @@ class YeelightScanner: class YeelightDevice: """Represents single Yeelight device.""" - def __init__(self, hass, host, config, bulb): + def __init__(self, hass, host, config, bulb, capabilities): """Initialize device.""" self._hass = hass self._config = config self._host = host - unique_id = bulb.capabilities.get("id") - self._name = config.get(CONF_NAME) or f"yeelight_{bulb.model}_{unique_id}" self._bulb_device = bulb + self._capabilities = capabilities or {} self._device_type = None self._available = False self._remove_time_tracker = None + self._name = host # Default name is host + if capabilities: + # Generate name from model and id when capabilities is available + self._name = _async_unique_name(capabilities) + if config.get(CONF_NAME): + # Override default name when name is set in config + self._name = config[CONF_NAME] + @property def bulb(self): """Return bulb device.""" @@ -396,7 +416,7 @@ class YeelightDevice: @property def fw_version(self): """Return the firmware version.""" - return self._bulb_device.capabilities.get("fw_ver") + return self._capabilities.get("fw_ver") @property def is_nightlight_supported(self) -> bool: @@ -449,11 +469,6 @@ class YeelightDevice: return self._device_type - @property - def unique_id(self) -> Optional[str]: - """Return a unique ID.""" - return self.bulb.capabilities.get("id") - def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None, power_mode=None): """Turn on device.""" try: @@ -532,15 +547,24 @@ class YeelightDevice: class YeelightEntity(Entity): """Represents single Yeelight entity.""" - def __init__(self, device: YeelightDevice): + def __init__(self, device: YeelightDevice, entry: ConfigEntry): """Initialize the entity.""" self._device = device + self._unique_id = entry.entry_id + if entry.unique_id is not None: + # Use entry unique id (device id) whenever possible + self._unique_id = entry.unique_id + + @property + def unique_id(self) -> str: + """Return the unique ID.""" + return self._unique_id @property def device_info(self) -> dict: """Return the device info.""" return { - "identifiers": {(DOMAIN, self._device.unique_id)}, + "identifiers": {(DOMAIN, self._unique_id)}, "name": self._device.name, "manufacturer": "Yeelight", "model": self._device.model, diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 6d9de45c837..731751b66f3 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -1,6 +1,5 @@ """Sensor platform support for yeelight.""" import logging -from typing import Optional from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry @@ -19,7 +18,7 @@ async def async_setup_entry( device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE] if device.is_nightlight_supported: _LOGGER.debug("Adding nightlight mode sensor for %s", device.name) - async_add_entities([YeelightNightlightModeSensor(device)]) + async_add_entities([YeelightNightlightModeSensor(device, config_entry)]) class YeelightNightlightModeSensor(YeelightEntity, BinarySensorEntity): @@ -35,16 +34,6 @@ class YeelightNightlightModeSensor(YeelightEntity, BinarySensorEntity): ) ) - @property - def unique_id(self) -> Optional[str]: - """Return a unique ID.""" - unique = self._device.unique_id - - if unique: - return unique + "-nightlight_sensor" - - return None - @property def name(self): """Return the name of the sensor.""" diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 84f1bbdd975..52f27932403 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -18,6 +18,7 @@ from . import ( CONF_SAVE_ON_CHANGE, CONF_TRANSITION, NIGHTLIGHT_SWITCH_TYPE_LIGHT, + _async_unique_name, ) from . import DOMAIN # pylint:disable=unused-import @@ -38,7 +39,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the config flow.""" - self._capabilities = None self._discovered_devices = {} async def async_step_user(self, user_input=None): @@ -49,7 +49,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await self._async_try_connect(user_input[CONF_HOST]) return self.async_create_entry( - title=self._async_default_name(), + title=user_input[CONF_HOST], data=user_input, ) except CannotConnect: @@ -59,9 +59,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: return await self.async_step_pick_device() + user_input = user_input or {} return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Optional(CONF_HOST): str}), + data_schema=vol.Schema( + {vol.Optional(CONF_HOST, default=user_input.get(CONF_HOST, "")): str} + ), errors=errors, ) @@ -69,9 +72,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the step to pick discovered device.""" if user_input is not None: unique_id = user_input[CONF_DEVICE] - self._capabilities = self._discovered_devices[unique_id] + capabilities = self._discovered_devices[unique_id] + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() return self.async_create_entry( - title=self._async_default_name(), + title=_async_unique_name(capabilities), data={CONF_ID: unique_id}, ) @@ -122,25 +127,32 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_try_connect(self, host): """Set up with options.""" + for entry in self._async_current_entries(): + if entry.data.get(CONF_HOST) == host: + raise AlreadyConfigured + bulb = yeelight.Bulb(host) try: capabilities = await self.hass.async_add_executor_job(bulb.get_capabilities) if capabilities is None: # timeout - _LOGGER.error("Failed to get capabilities from %s: timeout", host) - raise CannotConnect + _LOGGER.debug("Failed to get capabilities from %s: timeout", host) + else: + _LOGGER.debug("Get capabilities: %s", capabilities) + await self.async_set_unique_id(capabilities["id"]) + self._abort_if_unique_id_configured() + return except OSError as err: - _LOGGER.error("Failed to get capabilities from %s: %s", host, err) - raise CannotConnect from err - _LOGGER.debug("Get capabilities: %s", capabilities) - self._capabilities = capabilities - await self.async_set_unique_id(capabilities["id"]) - self._abort_if_unique_id_configured() + _LOGGER.debug("Failed to get capabilities from %s: %s", host, err) + # Ignore the error since get_capabilities uses UDP discovery packet + # which does not work in all network environments - @callback - def _async_default_name(self): - model = self._capabilities["model"] - unique_id = self._capabilities["id"] - return f"yeelight_{model}_{unique_id}" + # Fallback to get properties + try: + await self.hass.async_add_executor_job(bulb.get_properties) + except yeelight.BulbException as err: + _LOGGER.error("Failed to get properties from %s: %s", host, err) + raise CannotConnect from err + _LOGGER.debug("Get properties: %s", bulb.last_properties) class OptionsFlowHandler(config_entries.OptionsFlow): @@ -153,11 +165,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_init(self, user_input=None): """Handle the initial step.""" if user_input is not None: - # keep the name from imported entries - options = { - CONF_NAME: self._config_entry.options.get(CONF_NAME), - **user_input, - } + options = {**self._config_entry.options} + options.update(user_input) return self.async_create_entry(title="", data=options) options = self._config_entry.options diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 93dbc5bcb07..06cf91faf27 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,7 +1,6 @@ """Light platform support for yeelight.""" from functools import partial import logging -from typing import Optional import voluptuous as vol import yeelight @@ -241,7 +240,7 @@ async def async_setup_entry( device_type = device.type def _lights_setup_helper(klass): - lights.append(klass(device, custom_effects=custom_effects)) + lights.append(klass(device, config_entry, custom_effects=custom_effects)) if device_type == BulbType.White: _lights_setup_helper(YeelightGenericLight) @@ -382,9 +381,9 @@ def _async_setup_services(hass: HomeAssistant): class YeelightGenericLight(YeelightEntity, LightEntity): """Representation of a Yeelight generic light.""" - def __init__(self, device, custom_effects=None): + def __init__(self, device, entry, custom_effects=None): """Initialize the Yeelight light.""" - super().__init__(device) + super().__init__(device, entry) self.config = device.config @@ -418,12 +417,6 @@ class YeelightGenericLight(YeelightEntity, LightEntity): ) ) - @property - def unique_id(self) -> Optional[str]: - """Return a unique ID.""" - - return self.device.unique_id - @property def supported_features(self) -> int: """Flag supported features.""" @@ -852,14 +845,10 @@ class YeelightNightLightMode(YeelightGenericLight): """Representation of a Yeelight when in nightlight mode.""" @property - def unique_id(self) -> Optional[str]: + def unique_id(self) -> str: """Return a unique ID.""" unique = super().unique_id - - if unique: - return unique + "-nightlight" - - return None + return f"{unique}-nightlight" @property def name(self) -> str: @@ -945,12 +934,10 @@ class YeelightAmbientLight(YeelightColorLightWithoutNightlightSwitch): self._light_type = LightType.Ambient @property - def unique_id(self) -> Optional[str]: + def unique_id(self) -> str: """Return a unique ID.""" unique = super().unique_id - - if unique: - return unique + "-ambilight" + return f"{unique}-ambilight" @property def name(self) -> str: diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index 7fd3062ef87..4e9060ebdb7 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -36,4 +36,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index a2f5b935947..9f811586a77 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -1,5 +1,5 @@ """Tests for the Yeelight integration.""" -from yeelight import BulbType +from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS from homeassistant.components.yeelight import ( @@ -8,6 +8,7 @@ from homeassistant.components.yeelight import ( CONF_SAVE_ON_CHANGE, DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, + YeelightScanner, ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME @@ -27,7 +28,8 @@ CAPABILITIES = { "name": "", } -NAME = f"yeelight_{MODEL}_{ID}" +NAME = "name" +UNIQUE_NAME = f"yeelight_{MODEL}_{ID}" MODULE = "homeassistant.components.yeelight" MODULE_CONFIG_FLOW = f"{MODULE}.config_flow" @@ -53,9 +55,10 @@ PROPERTIES = { "current_brightness": "30", } -ENTITY_BINARY_SENSOR = f"binary_sensor.{NAME}_nightlight" -ENTITY_LIGHT = f"light.{NAME}" -ENTITY_NIGHTLIGHT = f"light.{NAME}_nightlight" +ENTITY_BINARY_SENSOR = f"binary_sensor.{UNIQUE_NAME}_nightlight" +ENTITY_LIGHT = f"light.{UNIQUE_NAME}" +ENTITY_NIGHTLIGHT = f"light.{UNIQUE_NAME}_nightlight" +ENTITY_AMBILIGHT = f"light.{UNIQUE_NAME}_ambilight" YAML_CONFIGURATION = { DOMAIN: { @@ -80,6 +83,9 @@ def _mocked_bulb(cannot_connect=False): type(bulb).get_capabilities = MagicMock( return_value=None if cannot_connect else CAPABILITIES ) + type(bulb).get_properties = MagicMock( + side_effect=BulbException if cannot_connect else None + ) type(bulb).get_model_specs = MagicMock(return_value=_MODEL_SPECS[MODEL]) bulb.capabilities = CAPABILITIES @@ -92,6 +98,8 @@ def _mocked_bulb(cannot_connect=False): def _patch_discovery(prefix, no_device=False): + YeelightScanner._scanner = None # Clear class scanner to reset hass + def _mocked_discovery(timeout=2, interface=False): if no_device: return [] diff --git a/tests/components/yeelight/test_binary_sensor.py b/tests/components/yeelight/test_binary_sensor.py index b3281168077..8b2ec835722 100644 --- a/tests/components/yeelight/test_binary_sensor.py +++ b/tests/components/yeelight/test_binary_sensor.py @@ -4,10 +4,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_component from homeassistant.setup import async_setup_component -from . import ENTITY_BINARY_SENSOR, MODULE, PROPERTIES, YAML_CONFIGURATION, _mocked_bulb +from . import MODULE, NAME, PROPERTIES, YAML_CONFIGURATION, _mocked_bulb from tests.async_mock import patch +ENTITY_BINARY_SENSOR = f"binary_sensor.{NAME}_nightlight" + async def test_nightlight(hass: HomeAssistant): """Test nightlight sensor.""" diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 921011a510d..10191a5f6c7 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -25,6 +25,7 @@ from . import ( MODULE, MODULE_CONFIG_FLOW, NAME, + UNIQUE_NAME, _mocked_bulb, _patch_discovery, ) @@ -33,7 +34,6 @@ from tests.async_mock import MagicMock, patch from tests.common import MockConfigEntry DEFAULT_CONFIG = { - CONF_NAME: NAME, CONF_MODEL: "", CONF_TRANSITION: DEFAULT_TRANSITION, CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC, @@ -67,9 +67,8 @@ async def test_discovery(hass: HomeAssistant): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DEVICE: ID} ) - assert result3["type"] == "create_entry" - assert result3["title"] == NAME + assert result3["title"] == UNIQUE_NAME assert result3["data"] == {CONF_ID: ID} await hass.async_block_till_done() mock_setup.assert_called_once() @@ -126,6 +125,7 @@ async def test_import(hass: HomeAssistant): DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config ) type(mocked_bulb).get_capabilities.assert_called_once() + type(mocked_bulb).get_properties.assert_called_once() assert result["type"] == "abort" assert result["reason"] == "cannot_connect" @@ -203,7 +203,9 @@ async def test_manual(hass: HomeAssistant): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) + await hass.async_block_till_done() assert result4["type"] == "create_entry" + assert result4["title"] == IP_ADDRESS assert result4["data"] == {CONF_HOST: IP_ADDRESS} # Duplicate @@ -221,7 +223,9 @@ async def test_manual(hass: HomeAssistant): async def test_options(hass: HomeAssistant): """Test options flow.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}) + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS, CONF_NAME: NAME} + ) config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() @@ -230,16 +234,14 @@ async def test_options(hass: HomeAssistant): await hass.async_block_till_done() config = { + CONF_NAME: NAME, CONF_MODEL: "", CONF_TRANSITION: DEFAULT_TRANSITION, CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC, CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE, CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH, } - assert config_entry.options == { - CONF_NAME: "", - **config, - } + assert config_entry.options == config assert hass.states.get(f"light.{NAME}_nightlight") is None result = await hass.config_entries.options.async_init(config_entry.entry_id) @@ -247,15 +249,40 @@ async def test_options(hass: HomeAssistant): assert result["step_id"] == "init" config[CONF_NIGHTLIGHT_SWITCH] = True + user_input = {**config} + user_input.pop(CONF_NAME) with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): result2 = await hass.config_entries.options.async_configure( - result["flow_id"], config + result["flow_id"], user_input ) await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["data"] == { - CONF_NAME: "", - **config, - } + assert result2["data"] == config assert result2["data"] == config_entry.options assert hass.states.get(f"light.{NAME}_nightlight") is not None + + +async def test_manual_no_capabilities(hass: HomeAssistant): + """Test manually setup without successful get_capabilities.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + mocked_bulb = _mocked_bulb() + type(mocked_bulb).get_capabilities = MagicMock(return_value=None) + with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb), patch( + f"{MODULE}.async_setup", return_value=True + ), patch( + f"{MODULE}.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + type(mocked_bulb).get_capabilities.assert_called_once() + type(mocked_bulb).get_properties.assert_called_once() + assert result["type"] == "create_entry" + assert result["data"] == {CONF_HOST: IP_ADDRESS} diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 004efed8deb..86f5bad65da 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -1,19 +1,27 @@ """Test Yeelight.""" +from yeelight import BulbType + from homeassistant.components.yeelight import ( + CONF_NIGHTLIGHT_SWITCH, CONF_NIGHTLIGHT_SWITCH_TYPE, DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.setup import async_setup_component from . import ( CONFIG_ENTRY_DATA, + ENTITY_AMBILIGHT, + ENTITY_BINARY_SENSOR, + ENTITY_LIGHT, + ENTITY_NIGHTLIGHT, + ID, IP_ADDRESS, MODULE, MODULE_CONFIG_FLOW, - NAME, _mocked_bulb, _patch_discovery, ) @@ -32,13 +40,13 @@ async def test_setup_discovery(hass: HomeAssistant): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(f"binary_sensor.{NAME}_nightlight") is not None - assert hass.states.get(f"light.{NAME}") is not None + assert hass.states.get(ENTITY_BINARY_SENSOR) is not None + assert hass.states.get(ENTITY_LIGHT) is not None # Unload assert await hass.config_entries.async_unload(config_entry.entry_id) - assert hass.states.get(f"binary_sensor.{NAME}_nightlight") is None - assert hass.states.get(f"light.{NAME}") is None + assert hass.states.get(ENTITY_BINARY_SENSOR) is None + assert hass.states.get(ENTITY_LIGHT) is None async def test_setup_import(hass: HomeAssistant): @@ -67,3 +75,57 @@ async def test_setup_import(hass: HomeAssistant): assert hass.states.get(f"binary_sensor.{name}_nightlight") is not None assert hass.states.get(f"light.{name}") is not None assert hass.states.get(f"light.{name}_nightlight") is not None + + +async def test_unique_ids_device(hass: HomeAssistant): + """Test Yeelight unique IDs from yeelight device IDs.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + **CONFIG_ENTRY_DATA, + CONF_NIGHTLIGHT_SWITCH: True, + }, + unique_id=ID, + ) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + mocked_bulb.bulb_type = BulbType.WhiteTempMood + with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + er = await entity_registry.async_get_registry(hass) + assert er.async_get(ENTITY_BINARY_SENSOR).unique_id == ID + assert er.async_get(ENTITY_LIGHT).unique_id == ID + assert er.async_get(ENTITY_NIGHTLIGHT).unique_id == f"{ID}-nightlight" + assert er.async_get(ENTITY_AMBILIGHT).unique_id == f"{ID}-ambilight" + + +async def test_unique_ids_entry(hass: HomeAssistant): + """Test Yeelight unique IDs from entry IDs.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + **CONFIG_ENTRY_DATA, + CONF_NIGHTLIGHT_SWITCH: True, + }, + ) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + mocked_bulb.bulb_type = BulbType.WhiteTempMood + with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + er = await entity_registry.async_get_registry(hass) + assert er.async_get(ENTITY_BINARY_SENSOR).unique_id == config_entry.entry_id + assert er.async_get(ENTITY_LIGHT).unique_id == config_entry.entry_id + assert ( + er.async_get(ENTITY_NIGHTLIGHT).unique_id + == f"{config_entry.entry_id}-nightlight" + ) + assert ( + er.async_get(ENTITY_AMBILIGHT).unique_id == f"{config_entry.entry_id}-ambilight" + ) diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 8e8916ce303..e6fe16255eb 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -71,8 +71,9 @@ from homeassistant.components.yeelight.light import ( YEELIGHT_MONO_EFFECT_LIST, YEELIGHT_TEMP_ONLY_EFFECT_LIST, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_ID, CONF_NAME +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.setup import async_setup_component from homeassistant.util.color import ( color_hs_to_RGB, @@ -90,6 +91,7 @@ from . import ( MODULE, NAME, PROPERTIES, + UNIQUE_NAME, _mocked_bulb, _patch_discovery, ) @@ -97,15 +99,21 @@ from . import ( from tests.async_mock import MagicMock, patch from tests.common import MockConfigEntry +CONFIG_ENTRY_DATA = { + CONF_HOST: IP_ADDRESS, + CONF_TRANSITION: DEFAULT_TRANSITION, + CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC, + CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE, + CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH, +} + async def test_services(hass: HomeAssistant, caplog): """Test Yeelight services.""" config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_ID: "", - CONF_HOST: IP_ADDRESS, - CONF_TRANSITION: DEFAULT_TRANSITION, + **CONFIG_ENTRY_DATA, CONF_MODE_MUSIC: True, CONF_SAVE_ON_CHANGE: True, CONF_NIGHTLIGHT_SWITCH: True, @@ -299,17 +307,13 @@ async def test_device_types(hass: HomeAssistant): model, target_properties, nightlight_properties=None, - name=NAME, + name=UNIQUE_NAME, entity_id=ENTITY_LIGHT, ): config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_ID: "", - CONF_HOST: IP_ADDRESS, - CONF_TRANSITION: DEFAULT_TRANSITION, - CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC, - CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE, + **CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False, }, ) @@ -329,6 +333,8 @@ async def test_device_types(hass: HomeAssistant): await hass.config_entries.async_unload(config_entry.entry_id) await config_entry.async_remove(hass) + registry = await entity_registry.async_get_registry(hass) + registry.async_clear_config_entry(config_entry.entry_id) # nightlight if nightlight_properties is None: @@ -336,11 +342,7 @@ async def test_device_types(hass: HomeAssistant): config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_ID: "", - CONF_HOST: IP_ADDRESS, - CONF_TRANSITION: DEFAULT_TRANSITION, - CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC, - CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE, + **CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: True, }, ) @@ -358,6 +360,7 @@ async def test_device_types(hass: HomeAssistant): await hass.config_entries.async_unload(config_entry.entry_id) await config_entry.async_remove(hass) + registry.async_clear_config_entry(config_entry.entry_id) bright = round(255 * int(PROPERTIES["bright"]) / 100) current_brightness = round(255 * int(PROPERTIES["current_brightness"]) / 100) @@ -486,7 +489,7 @@ async def test_device_types(hass: HomeAssistant): "rgb_color": bg_rgb_color, "xy_color": bg_xy_color, }, - name=f"{NAME} ambilight", + name=f"{UNIQUE_NAME} ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", ) @@ -518,14 +521,7 @@ async def test_effects(hass: HomeAssistant): config_entry = MockConfigEntry( domain=DOMAIN, - data={ - CONF_ID: "", - CONF_HOST: IP_ADDRESS, - CONF_TRANSITION: DEFAULT_TRANSITION, - CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC, - CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE, - CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH, - }, + data=CONFIG_ENTRY_DATA, ) config_entry.add_to_hass(hass)