diff --git a/.coveragerc b/.coveragerc index 92ad9555d5d..1d37b7bc055 100644 --- a/.coveragerc +++ b/.coveragerc @@ -972,7 +972,6 @@ omit = homeassistant/components/yale_smart_alarm/alarm_control_panel.py homeassistant/components/yamaha_musiccast/media_player.py homeassistant/components/yandex_transport/* - homeassistant/components/yeelight/* homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py homeassistant/components/zabbix/* diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7030b066848..ee6d4a77fcd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -995,6 +995,9 @@ wolf_smartset==0.1.4 # homeassistant.components.zestimate xmltodict==0.12.0 +# homeassistant.components.yeelight +yeelight==0.5.2 + # homeassistant.components.zeroconf zeroconf==0.28.0 diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py new file mode 100644 index 00000000000..7f1f7d7d236 --- /dev/null +++ b/tests/components/yeelight/__init__.py @@ -0,0 +1,87 @@ +"""Tests for the Yeelight integration.""" +from yeelight import BulbType +from yeelight.main import _MODEL_SPECS + +from homeassistant.components.yeelight import ( + CONF_MODE_MUSIC, + CONF_NIGHTLIGHT_SWITCH_TYPE, + CONF_SAVE_ON_CHANGE, + DOMAIN, + NIGHTLIGHT_SWITCH_TYPE_LIGHT, +) +from homeassistant.const import CONF_DEVICES, CONF_NAME + +from tests.async_mock import MagicMock + +IP_ADDRESS = "192.168.1.239" +MODEL = "color" +ID = "0x000000000015243f" +FW_VER = "18" + +CAPABILITIES = { + "id": ID, + "model": MODEL, + "fw_ver": FW_VER, + "support": "get_prop set_default set_power toggle set_bright start_cf stop_cf" + " set_scene cron_add cron_get cron_del set_ct_abx set_rgb", + "name": "", +} + +NAME = f"yeelight_{MODEL}_{ID}" + +MODULE = "homeassistant.components.yeelight" +MODULE_CONFIG_FLOW = f"{MODULE}.config_flow" + +PROPERTIES = { + "power": "on", + "main_power": "on", + "bright": "50", + "ct": "4000", + "rgb": "16711680", + "hue": "100", + "sat": "35", + "color_mode": "1", + "flowing": "0", + "bg_power": "on", + "bg_lmode": "1", + "bg_flowing": "0", + "bg_ct": "5000", + "bg_bright": "80", + "bg_rgb": "16711680", + "nl_br": "23", + "active_mode": "0", + "current_brightness": "30", +} + +ENTITY_BINARY_SENSOR = f"binary_sensor.{NAME}_nightlight" +ENTITY_LIGHT = f"light.{NAME}" +ENTITY_NIGHTLIGHT = f"light.{NAME}_nightlight" + +YAML_CONFIGURATION = { + DOMAIN: { + CONF_DEVICES: { + IP_ADDRESS: { + CONF_NAME: NAME, + CONF_NIGHTLIGHT_SWITCH_TYPE: NIGHTLIGHT_SWITCH_TYPE_LIGHT, + CONF_MODE_MUSIC: True, + CONF_SAVE_ON_CHANGE: True, + } + } + } +} + + +def _mocked_bulb(cannot_connect=False): + bulb = MagicMock() + type(bulb).get_capabilities = MagicMock( + return_value=None if cannot_connect else CAPABILITIES + ) + type(bulb).get_model_specs = MagicMock(return_value=_MODEL_SPECS[MODEL]) + + bulb.capabilities = CAPABILITIES + bulb.model = MODEL + bulb.bulb_type = BulbType.Color + bulb.last_properties = PROPERTIES + bulb.music_mode = False + + return bulb diff --git a/tests/components/yeelight/test_binary_sensor.py b/tests/components/yeelight/test_binary_sensor.py new file mode 100644 index 00000000000..bf20a7ec5b0 --- /dev/null +++ b/tests/components/yeelight/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Test the Yeelight binary sensor.""" +from homeassistant.components.yeelight import DOMAIN +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 tests.async_mock import patch + + +async def test_nightlight(hass: HomeAssistant): + """Test nightlight sensor.""" + mocked_bulb = _mocked_bulb() + with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION) + await hass.async_block_till_done() + + # active_mode + assert hass.states.get(ENTITY_BINARY_SENSOR).state == "off" + + # nl_br + properties = {**PROPERTIES} + properties.pop("active_mode") + mocked_bulb.last_properties = properties + await entity_component.async_update_entity(hass, ENTITY_BINARY_SENSOR) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == "on" + + # default + properties.pop("nl_br") + await entity_component.async_update_entity(hass, ENTITY_BINARY_SENSOR) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == "off" diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py new file mode 100644 index 00000000000..c44c343e51b --- /dev/null +++ b/tests/components/yeelight/test_light.py @@ -0,0 +1,546 @@ +"""Test the Yeelight light.""" +import logging + +from yeelight import ( + BulbException, + BulbType, + HSVTransition, + LightType, + PowerMode, + RGBTransition, + SceneClass, + SleepTransition, + TemperatureTransition, + transitions, +) +from yeelight.flow import Flow +from yeelight.main import _MODEL_SPECS + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_KELVIN, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + FLASH_LONG, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.yeelight import ( + ATTR_COUNT, + ATTR_TRANSITIONS, + CONF_CUSTOM_EFFECTS, + CONF_FLOW_PARAMS, + CONF_NIGHTLIGHT_SWITCH_TYPE, + DEFAULT_TRANSITION, + DOMAIN, + NIGHTLIGHT_SWITCH_TYPE_LIGHT, + YEELIGHT_HSV_TRANSACTION, + YEELIGHT_RGB_TRANSITION, + YEELIGHT_SLEEP_TRANSACTION, + YEELIGHT_TEMPERATURE_TRANSACTION, +) +from homeassistant.components.yeelight.light import ( + ATTR_MINUTES, + ATTR_MODE, + EFFECT_DISCO, + EFFECT_FACEBOOK, + EFFECT_FAST_RANDOM_LOOP, + EFFECT_STOP, + EFFECT_TWITTER, + EFFECT_WHATSAPP, + SERVICE_SET_AUTO_DELAY_OFF_SCENE, + SERVICE_SET_COLOR_FLOW_SCENE, + SERVICE_SET_COLOR_SCENE, + SERVICE_SET_COLOR_TEMP_SCENE, + SERVICE_SET_HSV_SCENE, + SERVICE_SET_MODE, + SERVICE_START_FLOW, + SUPPORT_YEELIGHT, + SUPPORT_YEELIGHT_RGB, + SUPPORT_YEELIGHT_WHITE_TEMP, + YEELIGHT_COLOR_EFFECT_LIST, + YEELIGHT_MONO_EFFECT_LIST, + YEELIGHT_TEMP_ONLY_EFFECT_LIST, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICES, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.color import ( + color_hs_to_RGB, + color_hs_to_xy, + color_RGB_to_hs, + color_RGB_to_xy, + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) + +from . import ( + CAPABILITIES, + ENTITY_LIGHT, + ENTITY_NIGHTLIGHT, + MODULE, + NAME, + PROPERTIES, + YAML_CONFIGURATION, + _mocked_bulb, +) + +from tests.async_mock import MagicMock, patch + + +async def test_services(hass: HomeAssistant, caplog): + """Test Yeelight services.""" + mocked_bulb = _mocked_bulb() + with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION) + await hass.async_block_till_done() + + async def _async_test_service(service, data, method, payload=None, domain=DOMAIN): + err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) + + # success + mocked_method = MagicMock() + setattr(type(mocked_bulb), method, mocked_method) + await hass.services.async_call(domain, service, data, blocking=True) + if payload is None: + mocked_method.assert_called_once() + elif type(payload) == list: + mocked_method.assert_called_once_with(*payload) + else: + mocked_method.assert_called_once_with(**payload) + assert ( + len([x for x in caplog.records if x.levelno == logging.ERROR]) == err_count + ) + + # failure + mocked_method = MagicMock(side_effect=BulbException) + setattr(type(mocked_bulb), method, mocked_method) + await hass.services.async_call(domain, service, data, blocking=True) + assert ( + len([x for x in caplog.records if x.levelno == logging.ERROR]) + == err_count + 1 + ) + + # turn_on + brightness = 100 + color_temp = 200 + transition = 1 + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_BRIGHTNESS: brightness, + ATTR_COLOR_TEMP: color_temp, + ATTR_FLASH: FLASH_LONG, + ATTR_EFFECT: EFFECT_STOP, + ATTR_TRANSITION: transition, + }, + blocking=True, + ) + mocked_bulb.turn_on.assert_called_once_with( + duration=transition * 1000, + light_type=LightType.Main, + power_mode=PowerMode.NORMAL, + ) + mocked_bulb.turn_on.reset_mock() + mocked_bulb.start_music.assert_called_once() + mocked_bulb.set_brightness.assert_called_once_with( + brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main + ) + mocked_bulb.set_color_temp.assert_called_once_with( + color_temperature_mired_to_kelvin(color_temp), + duration=transition * 1000, + light_type=LightType.Main, + ) + mocked_bulb.start_flow.assert_called_once() # flash + mocked_bulb.stop_flow.assert_called_once_with(light_type=LightType.Main) + + # turn_on nightlight + await _async_test_service( + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_NIGHTLIGHT}, + "turn_on", + payload={ + "duration": DEFAULT_TRANSITION, + "light_type": LightType.Main, + "power_mode": PowerMode.MOONLIGHT, + }, + domain="light", + ) + + # turn_off + await _async_test_service( + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITION: transition}, + "turn_off", + domain="light", + payload={"duration": transition * 1000, "light_type": LightType.Main}, + ) + + # set_mode + mode = "rgb" + await _async_test_service( + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MODE: "rgb"}, + "set_power_mode", + [PowerMode[mode.upper()]], + ) + + # start_flow + await _async_test_service( + SERVICE_START_FLOW, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_TRANSITIONS: [{YEELIGHT_TEMPERATURE_TRANSACTION: [1900, 2000, 60]}], + }, + "start_flow", + ) + + # set_color_scene + await _async_test_service( + SERVICE_SET_COLOR_SCENE, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_RGB_COLOR: [10, 20, 30], + ATTR_BRIGHTNESS: 50, + }, + "set_scene", + [SceneClass.COLOR, 10, 20, 30, 50], + ) + + # set_hsv_scene + await _async_test_service( + SERVICE_SET_HSV_SCENE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_HS_COLOR: [180, 50], ATTR_BRIGHTNESS: 50}, + "set_scene", + [SceneClass.HSV, 180, 50, 50], + ) + + # set_color_temp_scene + await _async_test_service( + SERVICE_SET_COLOR_TEMP_SCENE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_KELVIN: 4000, ATTR_BRIGHTNESS: 50}, + "set_scene", + [SceneClass.CT, 4000, 50], + ) + + # set_color_flow_scene + await _async_test_service( + SERVICE_SET_COLOR_FLOW_SCENE, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_TRANSITIONS: [{YEELIGHT_TEMPERATURE_TRANSACTION: [1900, 2000, 60]}], + }, + "set_scene", + ) + + # set_auto_delay_off_scene + await _async_test_service( + SERVICE_SET_AUTO_DELAY_OFF_SCENE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MINUTES: 1, ATTR_BRIGHTNESS: 50}, + "set_scene", + [SceneClass.AUTO_DELAY_OFF, 50, 1], + ) + + # test _cmd wrapper error handler + err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) + type(mocked_bulb).turn_on = MagicMock() + type(mocked_bulb).set_brightness = MagicMock(side_effect=BulbException) + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 50}, + blocking=True, + ) + assert ( + len([x for x in caplog.records if x.levelno == logging.ERROR]) == err_count + 1 + ) + + +async def test_device_types(hass: HomeAssistant): + """Test different device types.""" + properties = {**PROPERTIES} + properties.pop("active_mode") + properties["color_mode"] = "3" + + def _create_mocked_bulb(bulb_type, model, unique_id): + capabilities = {**CAPABILITIES} + capabilities["id"] = f"yeelight.{unique_id}" + mocked_bulb = _mocked_bulb() + mocked_bulb.bulb_type = bulb_type + mocked_bulb.last_properties = properties + mocked_bulb.capabilities = capabilities + model_specs = _MODEL_SPECS.get(model) + type(mocked_bulb).get_model_specs = MagicMock(return_value=model_specs) + return mocked_bulb + + types = { + "default": (None, "mono"), + "white": (BulbType.White, "mono"), + "color": (BulbType.Color, "color"), + "white_temp": (BulbType.WhiteTemp, "ceiling1"), + "white_temp_mood": (BulbType.WhiteTempMood, "ceiling4"), + "ambient": (BulbType.WhiteTempMood, "ceiling4"), + } + + devices = {} + mocked_bulbs = [] + unique_id = 0 + for name, (bulb_type, model) in types.items(): + devices[f"{name}.yeelight"] = {CONF_NAME: name} + devices[f"{name}_nightlight.yeelight"] = { + CONF_NAME: f"{name}_nightlight", + CONF_NIGHTLIGHT_SWITCH_TYPE: NIGHTLIGHT_SWITCH_TYPE_LIGHT, + } + mocked_bulbs.append(_create_mocked_bulb(bulb_type, model, unique_id)) + mocked_bulbs.append(_create_mocked_bulb(bulb_type, model, unique_id + 1)) + unique_id += 2 + + with patch(f"{MODULE}.Bulb", side_effect=mocked_bulbs): + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DEVICES: devices}}) + await hass.async_block_till_done() + + async def _async_test( + name, + bulb_type, + model, + target_properties, + nightlight_properties=None, + entity_name=None, + entity_id=None, + ): + if entity_id is None: + entity_id = f"light.{name}" + state = hass.states.get(entity_id) + assert state.state == "on" + target_properties["friendly_name"] = entity_name or name + target_properties["flowing"] = False + target_properties["night_light"] = True + assert dict(state.attributes) == target_properties + + # nightlight + if nightlight_properties is None: + return + name += "_nightlight" + entity_id = f"light.{name}" + assert hass.states.get(entity_id).state == "off" + state = hass.states.get(f"{entity_id}_nightlight") + assert state.state == "on" + nightlight_properties["friendly_name"] = f"{name} nightlight" + nightlight_properties["icon"] = "mdi:weather-night" + nightlight_properties["flowing"] = False + nightlight_properties["night_light"] = True + assert dict(state.attributes) == nightlight_properties + + bright = round(255 * int(PROPERTIES["bright"]) / 100) + current_brightness = round(255 * int(PROPERTIES["current_brightness"]) / 100) + ct = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"])) + hue = int(PROPERTIES["hue"]) + sat = int(PROPERTIES["sat"]) + hs_color = (round(hue / 360 * 65536, 3), round(sat / 100 * 255, 3)) + rgb_color = color_hs_to_RGB(*hs_color) + xy_color = color_hs_to_xy(*hs_color) + bg_bright = round(255 * int(PROPERTIES["bg_bright"]) / 100) + bg_ct = color_temperature_kelvin_to_mired(int(PROPERTIES["bg_ct"])) + bg_rgb = int(PROPERTIES["bg_rgb"]) + bg_rgb_color = ((bg_rgb >> 16) & 0xFF, (bg_rgb >> 8) & 0xFF, bg_rgb & 0xFF) + bg_hs_color = color_RGB_to_hs(*bg_rgb_color) + bg_xy_color = color_RGB_to_xy(*bg_rgb_color) + nl_br = round(255 * int(PROPERTIES["nl_br"]) / 100) + + # Default + await _async_test( + "default", + None, + "mono", + { + "effect_list": YEELIGHT_MONO_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": bright, + }, + ) + + # White + await _async_test( + "white", + BulbType.White, + "mono", + { + "effect_list": YEELIGHT_MONO_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": bright, + }, + ) + + # Color + model_specs = _MODEL_SPECS["color"] + await _async_test( + "color", + BulbType.Color, + "color", + { + "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT_RGB, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": current_brightness, + "color_temp": ct, + "hs_color": hs_color, + "rgb_color": rgb_color, + "xy_color": xy_color, + }, + {"supported_features": 0}, + ) + + # WhiteTemp + model_specs = _MODEL_SPECS["ceiling1"] + await _async_test( + "white_temp", + BulbType.WhiteTemp, + "ceiling1", + { + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT_WHITE_TEMP, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": current_brightness, + "color_temp": ct, + }, + { + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": nl_br, + }, + ) + + # WhiteTempMood + model_specs = _MODEL_SPECS["ceiling4"] + await _async_test( + "white_temp_mood", + BulbType.WhiteTempMood, + "ceiling4", + { + "friendly_name": NAME, + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "flowing": False, + "night_light": True, + "supported_features": SUPPORT_YEELIGHT_WHITE_TEMP, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": current_brightness, + "color_temp": ct, + }, + { + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": nl_br, + }, + ) + await _async_test( + "ambient", + BulbType.WhiteTempMood, + "ceiling4", + { + "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT_RGB, + "min_mireds": color_temperature_kelvin_to_mired(6500), + "max_mireds": color_temperature_kelvin_to_mired(1700), + "brightness": bg_bright, + "color_temp": bg_ct, + "hs_color": bg_hs_color, + "rgb_color": bg_rgb_color, + "xy_color": bg_xy_color, + }, + entity_name="ambient ambilight", + entity_id="light.ambient_ambilight", + ) + + +async def test_effects(hass: HomeAssistant): + """Test effects.""" + yaml_configuration = { + DOMAIN: { + CONF_DEVICES: YAML_CONFIGURATION[DOMAIN][CONF_DEVICES], + CONF_CUSTOM_EFFECTS: [ + { + CONF_NAME: "mock_effect", + CONF_FLOW_PARAMS: { + ATTR_COUNT: 3, + ATTR_TRANSITIONS: [ + {YEELIGHT_HSV_TRANSACTION: [300, 50, 500, 50]}, + {YEELIGHT_RGB_TRANSITION: [100, 100, 100, 300, 30]}, + {YEELIGHT_TEMPERATURE_TRANSACTION: [3000, 200, 20]}, + {YEELIGHT_SLEEP_TRANSACTION: [800]}, + ], + }, + }, + ], + } + } + + mocked_bulb = _mocked_bulb() + with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + assert await async_setup_component(hass, DOMAIN, yaml_configuration) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_LIGHT).attributes.get( + "effect_list" + ) == YEELIGHT_COLOR_EFFECT_LIST + ["mock_effect"] + + async def _async_test_effect(name, target=None, called=True): + mocked_start_flow = MagicMock() + type(mocked_bulb).start_flow = mocked_start_flow + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_EFFECT: name}, + blocking=True, + ) + if not called: + return + mocked_start_flow.assert_called_once() + if target is None: + return + args, _ = mocked_start_flow.call_args + flow = args[0] + assert flow.count == target.count + assert flow.action == target.action + assert str(flow.transitions) == str(target.transitions) + + effects = { + "mock_effect": Flow( + count=3, + transitions=[ + HSVTransition(300, 50, 500, 50), + RGBTransition(100, 100, 100, 300, 30), + TemperatureTransition(3000, 200, 20), + SleepTransition(800), + ], + ), + EFFECT_DISCO: Flow(transitions=transitions.disco()), + EFFECT_FAST_RANDOM_LOOP: None, + EFFECT_WHATSAPP: Flow(count=2, transitions=transitions.pulse(37, 211, 102)), + EFFECT_FACEBOOK: Flow(count=2, transitions=transitions.pulse(59, 89, 152)), + EFFECT_TWITTER: Flow(count=2, transitions=transitions.pulse(0, 172, 237)), + } + + for name, target in effects.items(): + await _async_test_effect(name, target) + await _async_test_effect("not_existed", called=False)