diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 739baf0c425..b84e64e6cc6 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -2,6 +2,7 @@ import asyncio import hashlib import logging +import time from homeassistant import core from homeassistant.components import ( @@ -66,10 +67,16 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util.network import is_local _LOGGER = logging.getLogger(__name__) +# How long to wait for a state change to happen +STATE_CHANGE_WAIT_TIMEOUT = 5.0 +# How long an entry state's cache will be valid for in seconds. +STATE_CACHED_TIMEOUT = 2.0 + STATE_BRIGHTNESS = "bri" STATE_COLORMODE = "colormode" STATE_HUE = "hue" @@ -515,13 +522,6 @@ class HueOneLightChangeView(HomeAssistantView): if entity.domain in config.off_maps_to_on_domains: service = SERVICE_TURN_ON - # Caching is required because things like scripts and scenes won't - # report as "off" to Alexa if an "off" command is received, because - # they'll map to "on". Thus, instead of reporting its actual - # status, we report what Alexa will want to see, which is the same - # as the actual requested command. - config.cached_states[entity_id] = parsed - # Separate call to turn on needed if turn_on_needed: hass.async_create_task( @@ -534,10 +534,18 @@ class HueOneLightChangeView(HomeAssistantView): ) if service is not None: + state_will_change = parsed[STATE_ON] != (entity.state != STATE_OFF) + hass.async_create_task( hass.services.async_call(domain, service, data, blocking=True) ) + if state_will_change: + # Wait for the state to change. + await wait_for_state_change_or_timeout( + hass, entity_id, STATE_CACHED_TIMEOUT + ) + # Create success responses for all received keys json_response = [ create_hue_success_response( @@ -556,16 +564,40 @@ class HueOneLightChangeView(HomeAssistantView): create_hue_success_response(entity_number, val, parsed[key]) ) - # Echo fetches the state immediately after the PUT method returns. - # Waiting for a short time allows the changes to propagate. - await asyncio.sleep(0.25) + if entity.domain in config.off_maps_to_on_domains: + # Caching is required because things like scripts and scenes won't + # report as "off" to Alexa if an "off" command is received, because + # they'll map to "on". Thus, instead of reporting its actual + # status, we report what Alexa will want to see, which is the same + # as the actual requested command. + config.cached_states[entity_id] = [parsed, None] + else: + config.cached_states[entity_id] = [parsed, time.time()] return self.json(json_response) def get_entity_state(config, entity): """Retrieve and convert state and brightness values for an entity.""" - cached_state = config.cached_states.get(entity.entity_id, None) + cached_state_entry = config.cached_states.get(entity.entity_id, None) + cached_state = None + + # Check if we have a cached entry, and if so if it hasn't expired. + if cached_state_entry is not None: + entry_state, entry_time = cached_state_entry + if entry_time is None: + # Handle the case where the entity is listed in config.off_maps_to_on_domains. + cached_state = entry_state + elif time.time() - entry_time < STATE_CACHED_TIMEOUT and entry_state[ + STATE_ON + ] == (entity.state != STATE_OFF): + # We only want to use the cache if the actual state of the entity + # is in sync so that it can be detected as an error by Alexa. + cached_state = entry_state + else: + # Remove the now stale cached entry. + config.cached_states.pop(entity.entity_id) + data = { STATE_ON: False, STATE_BRIGHTNESS: None, @@ -791,3 +823,21 @@ def hue_brightness_to_hass(value): def hass_to_hue_brightness(value): """Convert hass brightness 0..255 to hue 1..254 scale.""" return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) + + +async def wait_for_state_change_or_timeout(hass, entity_id, timeout): + """Wait for an entity to change state.""" + ev = asyncio.Event() + + @core.callback + def _async_event_changed(_): + ev.set() + + unsub = async_track_state_change_event(hass, [entity_id], _async_event_changed) + + try: + await asyncio.wait_for(ev.wait(), timeout=STATE_CHANGE_WAIT_TIMEOUT) + except asyncio.TimeoutError: + pass + finally: + unsub() diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 99940e47133..510aa0ef8ee 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1,4 +1,5 @@ """The tests for the emulated Hue component.""" +import asyncio from datetime import timedelta from ipaddress import ip_address import json @@ -18,9 +19,10 @@ from homeassistant.components import ( media_player, script, ) -from homeassistant.components.emulated_hue import Config +from homeassistant.components.emulated_hue import Config, hue_api from homeassistant.components.emulated_hue.hue_api import ( HUE_API_STATE_BRI, + HUE_API_STATE_CT, HUE_API_STATE_HUE, HUE_API_STATE_ON, HUE_API_STATE_SAT, @@ -43,14 +45,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) +from homeassistant.core import callback import homeassistant.util.dt as dt_util from tests.async_mock import patch -from tests.common import ( - async_fire_time_changed, - async_mock_service, - get_test_instance_port, -) +from tests.common import async_fire_time_changed, get_test_instance_port HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() @@ -77,6 +76,8 @@ ENTITY_IDS_BY_NUMBER = { "16": "humidifier.humidifier", "17": "humidifier.dehumidifier", "18": "humidifier.hygrostat", + "19": "scene.light_on", + "20": "scene.light_off", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} @@ -164,6 +165,28 @@ def hass_hue(loop, hass): ) ) + # setup a dummy scene + loop.run_until_complete( + setup.async_setup_component( + hass, + "scene", + { + "scene": [ + { + "id": "light_on", + "name": "Light on", + "entities": {"light.kitchen_lights": {"state": "on"}}, + }, + { + "id": "light_off", + "name": "Light off", + "entities": {"light.kitchen_lights": {"state": "off"}}, + }, + ] + }, + ) + ) + # create a lamp without brightness support hass.states.async_set("light.no_brightness", "on", {}) @@ -197,6 +220,9 @@ def hue_client(loop, hass_hue, aiohttp_client): "humidifier.dehumidifier": {emulated_hue.CONF_ENTITY_HIDDEN: False}, # No expose setting (use default of not exposed) "climate.nosetting": {}, + # Expose scenes + "scene.light_on": {emulated_hue.CONF_ENTITY_HIDDEN: False}, + "scene.light_off": {emulated_hue.CONF_ENTITY_HIDDEN: False}, }, }, ) @@ -243,6 +269,8 @@ async def test_discover_lights(hue_client): assert "00:78:eb:f8:d5:0c:14:85-e7" in devices # humidifier.humidifier assert "00:67:19:bd:ea:e4:2d:ef-22" in devices # humidifier.dehumidifier assert "00:61:bf:ab:08:b1:a6:18-43" not in devices # humidifier.hygrostat + assert "00:62:5c:3e:df:58:40:01-43" in devices # scene.light_on + assert "00:1c:72:08:ed:09:e7:89-77" in devices # scene.light_off async def test_light_without_brightness_supported(hass_hue, hue_client): @@ -258,9 +286,18 @@ async def test_light_without_brightness_supported(hass_hue, hue_client): async def test_light_without_brightness_can_be_turned_off(hass_hue, hue_client): """Test that light without brightness can be turned off.""" hass_hue.states.async_set("light.no_brightness", "on", {}) + turn_off_calls = [] # Check if light can be turned off - turn_off_calls = async_mock_service(hass_hue, light.DOMAIN, SERVICE_TURN_OFF) + @callback + def mock_service_call(call): + """Mock service call.""" + turn_off_calls.append(call) + hass_hue.states.async_set("light.no_brightness", "off", {}) + + hass_hue.services.async_register( + light.DOMAIN, SERVICE_TURN_OFF, mock_service_call, schema=None + ) no_brightness_result = await perform_put_light_state( hass_hue, hue_client, "light.no_brightness", False @@ -286,7 +323,17 @@ async def test_light_without_brightness_can_be_turned_on(hass_hue, hue_client): hass_hue.states.async_set("light.no_brightness", "off", {}) # Check if light can be turned on - turn_on_calls = async_mock_service(hass_hue, light.DOMAIN, SERVICE_TURN_ON) + turn_on_calls = [] + + @callback + def mock_service_call(call): + """Mock service call.""" + turn_on_calls.append(call) + hass_hue.states.async_set("light.no_brightness", "on", {}) + + hass_hue.services.async_register( + light.DOMAIN, SERVICE_TURN_ON, mock_service_call, schema=None + ) no_brightness_result = await perform_put_light_state( hass_hue, @@ -423,7 +470,7 @@ async def test_discover_config(hue_client): async def test_get_light_state(hass_hue, hue_client): """Test the getting of light state.""" - # Turn office light on and set to 127 brightness, and set light color + # Turn ceiling lights on and set to 127 brightness, and set light color await hass_hue.services.async_call( light.DOMAIN, const.SERVICE_TURN_ON, @@ -591,6 +638,23 @@ async def test_put_light_state(hass, hass_hue, hue_client): ) assert kitchen_result.status == HTTP_UNAUTHORIZED + # Turn the ceiling lights on first and color temp. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_COLOR_TEMP: 20}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, hue_client, "light.ceiling_lights", True, color_temp=50 + ) + + assert ( + hass_hue.states.get("light.ceiling_lights").attributes[light.ATTR_COLOR_TEMP] + == 50 + ) + async def test_put_light_state_script(hass, hass_hue, hue_client): """Test the setting of script variables.""" @@ -832,6 +896,71 @@ async def test_put_light_state_fan(hass_hue, hue_client): assert living_room_fan.state == "on" assert living_room_fan.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + # Check setting the brightness of a fan to 0, 33%, 66% and 100% will respectively turn it off, low, medium or high + # We also check non-cached GET value to exercise the code. + await perform_put_light_state( + hass_hue, hue_client, "fan.living_room_fan", True, brightness=0 + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_OFF + ) + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + True, + brightness=round(33 * 254 / 100), + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_LOW + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTP_OK + ) + assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 33 + + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + True, + brightness=round(66 * 254 / 100), + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_MEDIUM + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTP_OK + ) + assert ( + round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 67 + ) # small rounding error in inverse operation + + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + True, + brightness=round(100 * 254 / 100), + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_HIGH + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTP_OK + ) + assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 100 + # pylint: disable=invalid-name async def test_put_with_form_urlencoded_content_type(hass_hue, hue_client): @@ -952,9 +1081,8 @@ async def perform_put_test_on_ceiling_lights( assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 56 -async def perform_get_light_state(client, entity_id, expected_status): +async def perform_get_light_state_by_number(client, entity_number, expected_status): """Test the getting of a light state.""" - entity_number = ENTITY_NUMBERS_BY_ID[entity_id] result = await client.get(f"/api/username/lights/{entity_number}") assert result.status == expected_status @@ -967,6 +1095,14 @@ async def perform_get_light_state(client, entity_id, expected_status): return None +async def perform_get_light_state(client, entity_id, expected_status): + """Test the getting of a light state.""" + entity_number = ENTITY_NUMBERS_BY_ID[entity_id] + return await perform_get_light_state_by_number( + client, entity_number, expected_status + ) + + async def perform_put_light_state( hass_hue, client, @@ -976,11 +1112,16 @@ async def perform_put_light_state( content_type="application/json", hue=None, saturation=None, + color_temp=None, + with_state=True, ): """Test the setting of a light state.""" req_headers = {"Content-Type": content_type} - data = {HUE_API_STATE_ON: is_on} + data = {} + + if with_state: + data[HUE_API_STATE_ON] = is_on if brightness is not None: data[HUE_API_STATE_BRI] = brightness @@ -988,6 +1129,8 @@ async def perform_put_light_state( data[HUE_API_STATE_HUE] = hue if saturation is not None: data[HUE_API_STATE_SAT] = saturation + if color_temp is not None: + data[HUE_API_STATE_CT] = color_temp entity_number = ENTITY_NUMBERS_BY_ID[entity_id] result = await client.put( @@ -1042,3 +1185,291 @@ async def test_unauthorized_user_blocked(hue_client): result_json = await result.json() assert result_json[0]["error"]["description"] == "unauthorized user" + + +async def test_put_then_get_cached_properly(hass, hass_hue, hue_client): + """Test the setting of light states and an immediate readback reads the same values.""" + + # Turn the bedroom light on first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_BRIGHTNESS: 153}, + blocking=True, + ) + + ceiling_lights = hass_hue.states.get("light.ceiling_lights") + assert ceiling_lights.state == STATE_ON + assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 153 + + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + hue=4369, + saturation=127, + brightness=254, + ) + + # Check that a Hue brightness level of 254 becomes 255 in HA realm. + assert ( + hass.states.get("light.ceiling_lights").attributes[light.ATTR_BRIGHTNESS] == 255 + ) + + # Make sure that the GET response is the same as the PUT response within 2 seconds if the service call is successful and the state doesn't change. + # We simulate a long latence for the actual setting of the entity by forcibly sitting different values directly. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_BRIGHTNESS: 153}, + blocking=True, + ) + + # go through api to get the state back, the value returned should match those set in the last PUT request. + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + assert ceiling_json["state"][HUE_API_STATE_HUE] == 4369 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 127 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 254 + + # Make sure that the GET response does not use the cache if PUT response within 2 seconds if the service call is Unsuccessful and the state does not change. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + # go through api to get the state back + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + # Now it should be the real value as the state of the entity has changed to OFF. + assert ceiling_json["state"][HUE_API_STATE_HUE] == 0 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 0 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 1 + + # Ensure we read the actual value after exceeding the timeout time. + + # Turn the bedroom light back on first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + hue=4369, + saturation=127, + brightness=254, + ) + + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + { + const.ATTR_ENTITY_ID: "light.ceiling_lights", + light.ATTR_BRIGHTNESS: 127, + light.ATTR_RGB_COLOR: (1, 2, 7), + }, + blocking=True, + ) + + # go through api to get the state back, the value returned should match those set in the last PUT request. + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + # With no wait, we must be reading what we set via the PUT call. + assert ceiling_json["state"][HUE_API_STATE_HUE] == 4369 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 127 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 254 + + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + + # go through api to get the state back, the value returned should now match the actual values. + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + # Once we're after the cached duration, we should see the real value. + assert ceiling_json["state"][HUE_API_STATE_HUE] == 41869 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 217 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 127 + + +async def test_put_than_get_when_service_call_fails(hass, hass_hue, hue_client): + """Test putting and getting the light state when the service call fails.""" + + # Turn the bedroom light off first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + turn_on_calls = [] + + # Now break the turn on service + @callback + def mock_service_call(call): + """Mock service call.""" + turn_on_calls.append(call) + + hass_hue.services.async_register( + light.DOMAIN, SERVICE_TURN_ON, mock_service_call, schema=None + ) + + ceiling_lights = hass_hue.states.get("light.ceiling_lights") + assert ceiling_lights.state == STATE_OFF + + with patch.object(hue_api, "STATE_CHANGE_WAIT_TIMEOUT", 0.000001): + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + hue=4369, + saturation=127, + brightness=254, + ) + + # Ensure we did not actually turn on + assert hass.states.get("light.ceiling_lights").state == STATE_OFF + + # go through api to get the state back, the value returned should NOT match those set in the last PUT request + # as the waiting to check the state change timed out + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + assert ceiling_json["state"][HUE_API_STATE_ON] is False + + +async def test_get_invalid_entity(hass, hass_hue, hue_client): + """Test the setting of light states and an immediate readback reads the same values.""" + + # Check that we get an error with an invalid entity number. + await perform_get_light_state_by_number(hue_client, 999, HTTP_NOT_FOUND) + + +async def test_put_light_state_scene(hass, hass_hue, hue_client): + """Test the setting of scene variables.""" + # Turn the kitchen lights off first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.kitchen_lights"}, + blocking=True, + ) + + scene_result = await perform_put_light_state( + hass_hue, hue_client, "scene.light_on", True + ) + + scene_result_json = await scene_result.json() + assert scene_result.status == HTTP_OK + assert len(scene_result_json) == 1 + + assert hass_hue.states.get("light.kitchen_lights").state == STATE_ON + + # Set the brightness on the entity; changing a scene brightness via the hue API will do nothing. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.kitchen_lights", light.ATTR_BRIGHTNESS: 127}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, hue_client, "scene.light_on", True, brightness=254 + ) + + assert hass_hue.states.get("light.kitchen_lights").state == STATE_ON + assert ( + hass_hue.states.get("light.kitchen_lights").attributes[light.ATTR_BRIGHTNESS] + == 127 + ) + + await perform_put_light_state(hass_hue, hue_client, "scene.light_off", True) + assert hass_hue.states.get("light.kitchen_lights").state == STATE_OFF + + +async def test_only_change_contrast(hass, hass_hue, hue_client): + """Test when only changing the contrast of a light state.""" + + # Turn the kitchen lights off first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + brightness=254, + with_state=False, + ) + + # Check that only setting the contrast will also turn on the light. + # TODO: It should be noted that a real Hue hub will not allow to change the brightness if the underlying entity is off. + # giving the error: [{"error":{"type":201,"address":"/lights/20/state/bri","description":"parameter, bri, is not modifiable. Device is set to off."}}] + # emulated_hue however will always turn on the light. + ceiling_lights = hass_hue.states.get("light.ceiling_lights") + assert ceiling_lights.state == STATE_ON + assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 255 + + +async def test_only_change_hue_or_saturation(hass, hass_hue, hue_client): + """Test setting either the hue or the saturation but not both.""" + + # TODO: The handling of this appears wrong, as setting only one will set the other to 0. + # The return values also appear wrong. + + # Turn the ceiling lights on first and set hue and saturation. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_HS_COLOR: (10, 10)}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, hue_client, "light.ceiling_lights", True, hue=4369 + ) + + assert hass_hue.states.get("light.ceiling_lights").attributes[ + light.ATTR_HS_COLOR + ] == (24, 0) + + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_HS_COLOR: (10, 10)}, + blocking=True, + ) + await perform_put_light_state( + hass_hue, hue_client, "light.ceiling_lights", True, saturation=10 + ) + + assert hass_hue.states.get("light.ceiling_lights").attributes[ + light.ATTR_HS_COLOR + ] == (0, 3)