diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 4c329cac28f..44a9c6e53ef 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -4,42 +4,42 @@ import logging from aiohttp import web from homeassistant import core -from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, STATE_ON, - STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, ATTR_SUPPORTED_FEATURES -) -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS -) +from homeassistant.components import ( + climate, cover, fan, light, media_player, scene, script) from homeassistant.components.climate.const import ( - SERVICE_SET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE -) -from homeassistant.components.media_player.const import ( - ATTR_MEDIA_VOLUME_LEVEL, SUPPORT_VOLUME_SET, -) -from homeassistant.components.fan import ( - ATTR_SPEED, SUPPORT_SET_SPEED, SPEED_OFF, SPEED_LOW, - SPEED_MEDIUM, SPEED_HIGH -) - + SERVICE_SET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE) from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, SERVICE_SET_COVER_POSITION, - SUPPORT_SET_POSITION -) - -from homeassistant.components import ( - climate, cover, fan, media_player, light, script, scene -) - + SUPPORT_SET_POSITION) +from homeassistant.components.fan import ( + ATTR_SPEED, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, + SUPPORT_SET_SPEED) from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.const import KEY_REAL_IP +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR) +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_VOLUME_LEVEL, SUPPORT_VOLUME_SET) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, + HTTP_BAD_REQUEST, HTTP_NOT_FOUND, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, + SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET, STATE_OFF, STATE_ON) from homeassistant.util.network import is_local _LOGGER = logging.getLogger(__name__) HUE_API_STATE_ON = 'on' HUE_API_STATE_BRI = 'bri' +HUE_API_STATE_HUE = 'hue' +HUE_API_STATE_SAT = 'sat' + +HUE_API_STATE_HUE_MAX = 65535.0 +HUE_API_STATE_SAT_MAX = 254.0 +HUE_API_STATE_BRI_MAX = 255.0 + +STATE_BRIGHTNESS = HUE_API_STATE_BRI +STATE_HUE = HUE_API_STATE_HUE +STATE_SATURATION = HUE_API_STATE_SAT class HueUsernameView(HomeAssistantView): @@ -140,11 +140,11 @@ class HueAllLightsStateView(HomeAssistantView): for entity in hass.states.async_all(): if self.config.is_entity_exposed(entity): - state, brightness = get_entity_state(self.config, entity) + state = get_entity_state(self.config, entity) number = self.config.entity_id_to_number(entity.entity_id) - json_response[number] = entity_to_json( - self.config, entity, state, brightness) + json_response[number] = entity_to_json(self.config, + entity, state) return self.json(json_response) @@ -179,9 +179,9 @@ class HueOneLightStateView(HomeAssistantView): _LOGGER.error('Entity not exposed: %s', entity_id) return web.Response(text="Entity not exposed", status=404) - state, brightness = get_entity_state(self.config, entity) + state = get_entity_state(self.config, entity) - json_response = entity_to_json(self.config, entity, state, brightness) + json_response = entity_to_json(self.config, entity, state) return self.json(json_response) @@ -234,8 +234,6 @@ class HueOneLightChangeView(HomeAssistantView): _LOGGER.error('Unable to parse data: %s', request_json) return web.Response(text="Bad request", status=400) - result, brightness = parsed - # Choose general HA domain domain = core.DOMAIN @@ -243,7 +241,7 @@ class HueOneLightChangeView(HomeAssistantView): turn_on_needed = False # Convert the resulting "on" status into the service we need to call - service = SERVICE_TURN_ON if result else SERVICE_TURN_OFF + service = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF # Construct what we need to send to the service data = {ATTR_ENTITY_ID: entity_id} @@ -252,18 +250,32 @@ class HueOneLightChangeView(HomeAssistantView): entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if entity.domain == light.DOMAIN: - if entity_features & SUPPORT_BRIGHTNESS: - if brightness is not None: - data[ATTR_BRIGHTNESS] = brightness + if parsed[STATE_ON]: + if entity_features & SUPPORT_BRIGHTNESS: + if parsed[STATE_BRIGHTNESS] is not None: + data[ATTR_BRIGHTNESS] = parsed[STATE_BRIGHTNESS] + if entity_features & SUPPORT_COLOR: + if parsed[STATE_HUE] is not None: + if parsed[STATE_SATURATION]: + sat = parsed[STATE_SATURATION] + else: + sat = 0 + hue = parsed[STATE_HUE] + + # Convert hs values to hass hs values + sat = int((sat / HUE_API_STATE_SAT_MAX) * 100) + hue = int((hue / HUE_API_STATE_HUE_MAX) * 360) + + data[ATTR_HS_COLOR] = (hue, sat) # If the requested entity is a script add some variables elif entity.domain == script.DOMAIN: data['variables'] = { - 'requested_state': STATE_ON if result else STATE_OFF + 'requested_state': STATE_ON if parsed[STATE_ON] else STATE_OFF } - if brightness is not None: - data['variables']['requested_level'] = brightness + if parsed[STATE_BRIGHTNESS] is not None: + data['variables']['requested_level'] = parsed[STATE_BRIGHTNESS] # If the requested entity is a climate, set the temperature elif entity.domain == climate.DOMAIN: @@ -272,20 +284,21 @@ class HueOneLightChangeView(HomeAssistantView): service = None if entity_features & SUPPORT_TARGET_TEMPERATURE: - if brightness is not None: + if parsed[STATE_BRIGHTNESS] is not None: domain = entity.domain service = SERVICE_SET_TEMPERATURE - data[ATTR_TEMPERATURE] = brightness + data[ATTR_TEMPERATURE] = parsed[STATE_BRIGHTNESS] # If the requested entity is a media player, convert to volume elif entity.domain == media_player.DOMAIN: if entity_features & SUPPORT_VOLUME_SET: - if brightness is not None: + if parsed[STATE_BRIGHTNESS] is not None: turn_on_needed = True domain = entity.domain service = SERVICE_VOLUME_SET # Convert 0-100 to 0.0-1.0 - data[ATTR_MEDIA_VOLUME_LEVEL] = brightness / 100.0 + data[ATTR_MEDIA_VOLUME_LEVEL] = \ + parsed[STATE_BRIGHTNESS] / 100.0 # If the requested entity is a cover, convert to open_cover/close_cover elif entity.domain == cover.DOMAIN: @@ -296,17 +309,18 @@ class HueOneLightChangeView(HomeAssistantView): service = SERVICE_CLOSE_COVER if entity_features & SUPPORT_SET_POSITION: - if brightness is not None: + if parsed[STATE_BRIGHTNESS] is not None: domain = entity.domain service = SERVICE_SET_COVER_POSITION - data[ATTR_POSITION] = brightness + data[ATTR_POSITION] = parsed[STATE_BRIGHTNESS] # If the requested entity is a fan, convert to speed elif entity.domain == fan.DOMAIN: if entity_features & SUPPORT_SET_SPEED: - if brightness is not None: + if parsed[STATE_BRIGHTNESS] is not None: domain = entity.domain # Convert 0-100 to a fan speed + brightness = parsed[STATE_BRIGHTNESS] if brightness == 0: data[ATTR_SPEED] = SPEED_OFF elif 0 < brightness <= 33.3: @@ -325,7 +339,7 @@ class HueOneLightChangeView(HomeAssistantView): # 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] = (result, brightness) + config.cached_states[entity_id] = parsed # Separate call to turn on needed if turn_on_needed: @@ -338,73 +352,120 @@ class HueOneLightChangeView(HomeAssistantView): domain, service, data, blocking=True)) json_response = \ - [create_hue_success_response(entity_id, HUE_API_STATE_ON, result)] + [create_hue_success_response( + entity_id, HUE_API_STATE_ON, parsed[STATE_ON])] - if brightness is not None: + if parsed[STATE_BRIGHTNESS] is not None: json_response.append(create_hue_success_response( - entity_id, HUE_API_STATE_BRI, brightness)) + entity_id, HUE_API_STATE_BRI, parsed[STATE_BRIGHTNESS])) + if parsed[STATE_HUE] is not None: + json_response.append(create_hue_success_response( + entity_id, HUE_API_STATE_HUE, parsed[STATE_HUE])) + if parsed[STATE_SATURATION] is not None: + json_response.append(create_hue_success_response( + entity_id, HUE_API_STATE_SAT, parsed[STATE_SATURATION])) return self.json(json_response) def parse_hue_api_put_light_body(request_json, entity): """Parse the body of a request to change the state of a light.""" + data = { + STATE_BRIGHTNESS: None, + STATE_HUE: None, + STATE_ON: False, + STATE_SATURATION: None, + } + + # Make sure the entity actually supports brightness + entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if HUE_API_STATE_ON in request_json: if not isinstance(request_json[HUE_API_STATE_ON], bool): return None - if request_json['on']: + if request_json[HUE_API_STATE_ON]: # Echo requested device be turned on - brightness = None - report_brightness = False - result = True + data[STATE_BRIGHTNESS] = None + data[STATE_ON] = True else: # Echo requested device be turned off - brightness = None - report_brightness = False - result = False + data[STATE_BRIGHTNESS] = None + data[STATE_ON] = False + + if HUE_API_STATE_HUE in request_json: + try: + # Clamp brightness from 0 to 65535 + data[STATE_HUE] = \ + max(0, min(int(request_json[HUE_API_STATE_HUE]), + HUE_API_STATE_HUE_MAX)) + except ValueError: + return None + + if HUE_API_STATE_SAT in request_json: + try: + # Clamp saturation from 0 to 254 + data[STATE_SATURATION] = \ + max(0, min(int(request_json[HUE_API_STATE_SAT]), + HUE_API_STATE_SAT_MAX)) + except ValueError: + return None if HUE_API_STATE_BRI in request_json: try: # Clamp brightness from 0 to 255 - brightness = \ - max(0, min(int(request_json[HUE_API_STATE_BRI]), 255)) + data[STATE_BRIGHTNESS] = \ + max(0, min(int(request_json[HUE_API_STATE_BRI]), + HUE_API_STATE_BRI_MAX)) except ValueError: return None - # Make sure the entity actually supports brightness - entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if entity.domain == light.DOMAIN: - if entity_features & SUPPORT_BRIGHTNESS: - report_brightness = True - result = (brightness > 0) + data[STATE_ON] = (data[STATE_BRIGHTNESS] > 0) + if not entity_features & SUPPORT_BRIGHTNESS: + data[STATE_BRIGHTNESS] = None elif entity.domain == scene.DOMAIN: - brightness = None - report_brightness = False - result = True + data[STATE_BRIGHTNESS] = None + data[STATE_ON] = True elif entity.domain in [ script.DOMAIN, media_player.DOMAIN, fan.DOMAIN, cover.DOMAIN, climate.DOMAIN]: # Convert 0-255 to 0-100 - level = brightness / 255 * 100 - brightness = round(level) - report_brightness = True - result = True + level = (data[STATE_BRIGHTNESS] / HUE_API_STATE_BRI_MAX) * 100 + data[STATE_BRIGHTNESS] = round(level) + data[STATE_ON] = True - return (result, brightness) if report_brightness else (result, None) + return data 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) + data = { + STATE_BRIGHTNESS: None, + STATE_HUE: None, + STATE_ON: False, + STATE_SATURATION: None + } if cached_state is None: - final_state = entity.state != STATE_OFF - final_brightness = entity.attributes.get( - ATTR_BRIGHTNESS, 255 if final_state else 0) + data[STATE_ON] = entity.state != STATE_OFF + if data[STATE_ON]: + data[STATE_BRIGHTNESS] = entity.attributes.get(ATTR_BRIGHTNESS) + hue_sat = entity.attributes.get(ATTR_HS_COLOR, None) + if hue_sat is not None: + hue = hue_sat[0] + sat = hue_sat[1] + # convert hass hs values back to hue hs values + data[STATE_HUE] = int((hue / 360.0) * HUE_API_STATE_HUE_MAX) + data[STATE_SATURATION] = \ + int((sat / 100.0) * HUE_API_STATE_SAT_MAX) + else: + data[STATE_BRIGHTNESS] = 0 + data[STATE_HUE] = 0 + data[STATE_SATURATION] = 0 # Make sure the entity actually supports brightness entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -416,41 +477,53 @@ def get_entity_state(config, entity): elif entity.domain == climate.DOMAIN: temperature = entity.attributes.get(ATTR_TEMPERATURE, 0) # Convert 0-100 to 0-255 - final_brightness = round(temperature * 255 / 100) + data[STATE_BRIGHTNESS] = round(temperature * 255 / 100) elif entity.domain == media_player.DOMAIN: level = entity.attributes.get( - ATTR_MEDIA_VOLUME_LEVEL, 1.0 if final_state else 0.0) + ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0) # Convert 0.0-1.0 to 0-255 - final_brightness = round(min(1.0, level) * 255) + data[STATE_BRIGHTNESS] = \ + round(min(1.0, level) * HUE_API_STATE_BRI_MAX) elif entity.domain == fan.DOMAIN: speed = entity.attributes.get(ATTR_SPEED, 0) # Convert 0.0-1.0 to 0-255 - final_brightness = 0 + data[STATE_BRIGHTNESS] = 0 if speed == SPEED_LOW: - final_brightness = 85 + data[STATE_BRIGHTNESS] = 85 elif speed == SPEED_MEDIUM: - final_brightness = 170 + data[STATE_BRIGHTNESS] = 170 elif speed == SPEED_HIGH: - final_brightness = 255 + data[STATE_BRIGHTNESS] = 255 elif entity.domain == cover.DOMAIN: level = entity.attributes.get(ATTR_CURRENT_POSITION, 0) - final_brightness = round(level / 100 * 255) + data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX) else: - final_state, final_brightness = cached_state + data = cached_state # Make sure brightness is valid - if final_brightness is None: - final_brightness = 255 if final_state else 0 + if data[STATE_BRIGHTNESS] is None: + data[STATE_BRIGHTNESS] = 255 if data[STATE_ON] else 0 + # Make sure hue/saturation are valid + if (data[STATE_HUE] is None) or (data[STATE_SATURATION] is None): + data[STATE_HUE] = 0 + data[STATE_SATURATION] = 0 - return (final_state, final_brightness) + # If the light is off, set the color to off + if data[STATE_BRIGHTNESS] == 0: + data[STATE_HUE] = 0 + data[STATE_SATURATION] = 0 + + return data -def entity_to_json(config, entity, is_on=None, brightness=None): +def entity_to_json(config, entity, state): """Convert an entity to its Hue bridge JSON representation.""" return { 'state': { - HUE_API_STATE_ON: is_on, - HUE_API_STATE_BRI: brightness, + HUE_API_STATE_ON: state[STATE_ON], + HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + HUE_API_STATE_HUE: state[STATE_HUE], + HUE_API_STATE_SAT: state[STATE_SATURATION], 'reachable': True }, 'type': 'Dimmable light', diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 08001b0ebab..3348fdfe87b 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -13,7 +13,8 @@ from homeassistant.components import ( fan, http, light, script, emulated_hue, media_player, cover, climate) from homeassistant.components.emulated_hue import Config from homeassistant.components.emulated_hue.hue_api import ( - HUE_API_STATE_ON, HUE_API_STATE_BRI, HueUsernameView, HueOneLightStateView, + HUE_API_STATE_ON, HUE_API_STATE_BRI, HUE_API_STATE_HUE, HUE_API_STATE_SAT, + HueUsernameView, HueOneLightStateView, HueAllLightsStateView, HueOneLightChangeView, HueAllGroupsStateView) from homeassistant.const import STATE_ON, STATE_OFF @@ -221,12 +222,13 @@ def test_discover_lights(hue_client): @asyncio.coroutine def test_get_light_state(hass_hue, hue_client): """Test the getting of light state.""" - # Turn office light on and set to 127 brightness + # Turn office light on and set to 127 brightness, and set light color yield from hass_hue.services.async_call( light.DOMAIN, const.SERVICE_TURN_ON, { const.ATTR_ENTITY_ID: 'light.ceiling_lights', - light.ATTR_BRIGHTNESS: 127 + light.ATTR_BRIGHTNESS: 127, + light.ATTR_RGB_COLOR: (1, 2, 7) }, blocking=True) @@ -235,6 +237,8 @@ def test_get_light_state(hass_hue, hue_client): assert office_json['state'][HUE_API_STATE_ON] is True assert office_json['state'][HUE_API_STATE_BRI] == 127 + assert office_json['state'][HUE_API_STATE_HUE] == 41869 + assert office_json['state'][HUE_API_STATE_SAT] == 217 # Check all lights view result = yield from hue_client.get('/api/username/lights') @@ -261,6 +265,8 @@ def test_get_light_state(hass_hue, hue_client): assert office_json['state'][HUE_API_STATE_ON] is False assert office_json['state'][HUE_API_STATE_BRI] == 0 + assert office_json['state'][HUE_API_STATE_HUE] == 0 + assert office_json['state'][HUE_API_STATE_SAT] == 0 # Make sure bedroom light isn't accessible yield from perform_get_light_state( @@ -287,6 +293,19 @@ def test_put_light_state(hass_hue, hue_client): assert ceiling_lights.state == STATE_ON assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 153 + # update light state through api + yield from perform_put_light_state( + hass_hue, hue_client, + 'light.ceiling_lights', True, + hue=4369, saturation=127, brightness=123) + + # go through api to get the state back + ceiling_json = yield from perform_get_light_state( + hue_client, 'light.ceiling_lights', 200) + assert ceiling_json['state'][HUE_API_STATE_BRI] == 123 + assert ceiling_json['state'][HUE_API_STATE_HUE] == 4369 + assert ceiling_json['state'][HUE_API_STATE_SAT] == 127 + # Go through the API to turn it off ceiling_result = yield from perform_put_light_state( hass_hue, hue_client, @@ -302,6 +321,11 @@ def test_put_light_state(hass_hue, hue_client): # Check to make sure the state changed ceiling_lights = hass_hue.states.get('light.ceiling_lights') assert ceiling_lights.state == STATE_OFF + ceiling_json = yield from perform_get_light_state( + hue_client, 'light.ceiling_lights', 200) + assert ceiling_json['state'][HUE_API_STATE_BRI] == 0 + assert ceiling_json['state'][HUE_API_STATE_HUE] == 0 + assert ceiling_json['state'][HUE_API_STATE_SAT] == 0 # Make sure we can't change the bedroom light state bedroom_result = yield from perform_put_light_state( @@ -706,7 +730,8 @@ def perform_get_light_state(client, entity_id, expected_status): @asyncio.coroutine def perform_put_light_state(hass_hue, client, entity_id, is_on, - brightness=None, content_type='application/json'): + brightness=None, content_type='application/json', + hue=None, saturation=None): """Test the setting of a light state.""" req_headers = {'Content-Type': content_type} @@ -714,6 +739,10 @@ def perform_put_light_state(hass_hue, client, entity_id, is_on, if brightness is not None: data[HUE_API_STATE_BRI] = brightness + if hue is not None: + data[HUE_API_STATE_HUE] = hue + if saturation is not None: + data[HUE_API_STATE_SAT] = saturation result = yield from client.put( '/api/username/lights/{}/state'.format(entity_id), headers=req_headers,