Expose media volume as emulated Hue brightness (#4869)

* Allow virtual Hue bridge to set volume level of media_player entities
* Show correct states in all lights view
This commit is contained in:
Albert Lee 2016-12-15 23:47:23 -06:00 committed by Paulus Schoutsen
parent 394d53e748
commit 7b45cf8e59
2 changed files with 136 additions and 38 deletions

View File

@ -6,12 +6,16 @@ from aiohttp import web
from homeassistant import core
from homeassistant.const import (
ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON,
STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET,
STATE_ON, STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_SUPPORTED_FEATURES, SUPPORT_BRIGHTNESS
)
from homeassistant.components.media_player import (
ATTR_MEDIA_VOLUME_LEVEL, ATTR_SUPPORTED_MEDIA_COMMANDS,
SUPPORT_VOLUME_SET,
)
from homeassistant.components.http import HomeAssistantView
_LOGGER = logging.getLogger(__name__)
@ -65,8 +69,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)
number = self.config.entity_id_to_number(entity.entity_id)
json_response[number] = entity_to_json(entity)
json_response[number] = entity_to_json(
entity, state, brightness)
return self.json(json_response)
@ -97,16 +104,9 @@ class HueOneLightStateView(HomeAssistantView):
_LOGGER.error('Entity not exposed: %s', entity_id)
return web.Response(text="Entity not exposed", status=404)
cached_state = self.config.cached_states.get(entity_id, None)
state, brightness = get_entity_state(self.config, entity)
if cached_state is None:
final_state = entity.state == STATE_ON
final_brightness = entity.attributes.get(
ATTR_BRIGHTNESS, 255 if final_state else 0)
else:
final_state, final_brightness = cached_state
json_response = entity_to_json(entity, final_state, final_brightness)
json_response = entity_to_json(entity, state, brightness)
return self.json(json_response)
@ -158,14 +158,24 @@ class HueOneLightChangeView(HomeAssistantView):
result, brightness = parsed
# Choose general HA domain
domain = core.DOMAIN
# Convert the resulting "on" status into the service we need to call
service = SERVICE_TURN_ON if result else SERVICE_TURN_OFF
# Construct what we need to send to the service
data = {ATTR_ENTITY_ID: entity_id}
# Make sure the entity actually supports brightness
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS:
if brightness is not None:
data[ATTR_BRIGHTNESS] = brightness
# If the requested entity is a script add some variables
if entity.domain == "script":
elif entity.domain == "script":
data['variables'] = {
'requested_state': STATE_ON if result else STATE_OFF
}
@ -173,8 +183,16 @@ class HueOneLightChangeView(HomeAssistantView):
if brightness is not None:
data['variables']['requested_level'] = brightness
elif brightness is not None:
data[ATTR_BRIGHTNESS] = brightness
# If the requested entity is a media player, convert to volume
elif entity.domain == "media_player":
media_commands = entity.attributes.get(
ATTR_SUPPORTED_MEDIA_COMMANDS, 0)
if media_commands & SUPPORT_VOLUME_SET == SUPPORT_VOLUME_SET:
if brightness is not None:
domain = entity.domain
service = SERVICE_VOLUME_SET
# Convert 0-100 to 0.0-1.0
data[ATTR_MEDIA_VOLUME_LEVEL] = brightness / 100.0
if entity.domain in config.off_maps_to_on_domains:
# Map the off command to on
@ -187,9 +205,14 @@ class HueOneLightChangeView(HomeAssistantView):
# as the actual requested command.
config.cached_states[entity_id] = (result, brightness)
# Perform the requested action
yield from hass.services.async_call(core.DOMAIN, service, data,
blocking=True)
# Separate call to turn on needed
if domain != core.DOMAIN:
hass.async_add_job(hass.services.async_call(
core.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id},
blocking=True))
hass.async_add_job(hass.services.async_call(
domain, service, data, blocking=True))
json_response = \
[create_hue_success_response(entity_id, HUE_API_STATE_ON, result)]
@ -219,23 +242,23 @@ def parse_hue_api_put_light_body(request_json, entity):
result = False
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))
except ValueError:
return None
# Make sure the entity actually supports brightness
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS:
try:
# Clamp brightness from 0 to 255
brightness = \
max(0, min(int(request_json[HUE_API_STATE_BRI]), 255))
except ValueError:
return None
report_brightness = True
result = (brightness > 0)
elif entity.domain.lower() == "script":
# Convert 0-255 to 0-100
level = int(request_json[HUE_API_STATE_BRI]) / 255 * 100
elif entity.domain == "script" or entity.domain == "media_player":
# Convert 0-255 to 0-100
level = brightness / 255 * 100
brightness = round(level)
report_brightness = True
result = True
@ -243,14 +266,34 @@ def parse_hue_api_put_light_body(request_json, entity):
return (result, brightness) if report_brightness else (result, None)
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)
if cached_state is None:
final_state = entity.state != STATE_OFF
final_brightness = entity.attributes.get(
ATTR_BRIGHTNESS, 255 if final_state else 0)
# Make sure the entity actually supports brightness
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS:
pass
elif entity.domain == "media_player":
level = entity.attributes.get(
ATTR_MEDIA_VOLUME_LEVEL, 1.0 if final_state else 0.0)
# Convert 0.0-1.0 to 0-255
final_brightness = round(min(1.0, level) * 255)
else:
final_state, final_brightness = cached_state
return (final_state, final_brightness)
def entity_to_json(entity, is_on=None, brightness=None):
"""Convert an entity to its Hue bridge JSON representation."""
if is_on is None:
is_on = entity.state == STATE_ON
if brightness is None:
brightness = 255 if is_on else 0
name = entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name)
return {

View File

@ -7,7 +7,9 @@ import requests
from homeassistant import bootstrap, const, core
import homeassistant.components as core_components
from homeassistant.components import emulated_hue, http, light, script
from homeassistant.components import (
emulated_hue, http, light, script, media_player
)
from homeassistant.const import STATE_ON, STATE_OFF
from homeassistant.components.emulated_hue.hue_api import (
HUE_API_STATE_ON, HUE_API_STATE_BRI)
@ -73,6 +75,14 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase):
}
})
bootstrap.setup_component(cls.hass, media_player.DOMAIN, {
'media_player': [
{
'platform': 'demo',
}
]
})
cls.hass.start()
# Kitchen light is explicitly excluded from being exposed
@ -111,6 +121,10 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase):
self.assertTrue('light.bed_light' in result_json)
self.assertTrue('script.set_kitchen_light' in result_json)
self.assertTrue('light.kitchen_lights' not in result_json)
self.assertTrue('media_player.living_room' in result_json)
self.assertTrue('media_player.bedroom' in result_json)
self.assertTrue('media_player.walkman' in result_json)
self.assertTrue('media_player.lounge_room' in result_json)
def test_get_light_state(self):
"""Test the getting of light state."""
@ -128,6 +142,21 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase):
self.assertEqual(office_json['state'][HUE_API_STATE_ON], True)
self.assertEqual(office_json['state'][HUE_API_STATE_BRI], 127)
# Check all lights view
result = requests.get(
BRIDGE_URL_BASE.format('/api/username/lights'), timeout=5)
self.assertEqual(result.status_code, 200)
self.assertTrue('application/json' in result.headers['content-type'])
result_json = result.json()
self.assertTrue('light.ceiling_lights' in result_json)
self.assertEqual(
result_json['light.ceiling_lights']['state'][HUE_API_STATE_BRI],
127,
)
# Turn bedroom light off
self.hass.services.call(
light.DOMAIN, const.SERVICE_TURN_OFF,
@ -204,15 +233,38 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase):
self.assertEqual(script_result.status_code, 200)
self.assertEqual(len(script_result_json), 2)
# Wait until script is complete before continuing
self.hass.block_till_done()
kitchen_light = self.hass.states.get('light.kitchen_lights')
self.assertEqual(kitchen_light.state, 'on')
self.assertEqual(
kitchen_light.attributes[light.ATTR_BRIGHTNESS],
level)
def test_put_light_state_media_player(self):
"""Test turning on media player and setting volume."""
# Turn the music player off first
self.hass.services.call(
media_player.DOMAIN, const.SERVICE_TURN_OFF,
{const.ATTR_ENTITY_ID: 'media_player.walkman'},
blocking=True)
# Emulated hue converts 0.0-1.0 to 0-255.
level = 0.25
brightness = round(level * 255)
mp_result = self.perform_put_light_state(
'media_player.walkman', True, brightness)
mp_result_json = mp_result.json()
self.assertEqual(mp_result.status_code, 200)
self.assertEqual(len(mp_result_json), 2)
walkman = self.hass.states.get('media_player.walkman')
self.assertEqual(walkman.state, 'playing')
self.assertEqual(
walkman.attributes[media_player.ATTR_MEDIA_VOLUME_LEVEL],
level)
# pylint: disable=invalid-name
def test_put_with_form_urlencoded_content_type(self):
"""Test the form with urlencoded content."""
@ -352,4 +404,7 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase):
result = requests.put(
url, data=json.dumps(data), timeout=5, headers=req_headers)
# Wait until state change is complete before continuing
self.hass.block_till_done()
return result