Add service select scene to Yamaha Hifi media player (#36564)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
StevusPrimus 2020-06-08 19:31:58 +02:00 committed by GitHub
parent 3adfb86a19
commit 5975ec340b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 219 additions and 103 deletions

View File

@ -1,3 +1,4 @@
"""Constants for the Yamaha component.""" """Constants for the Yamaha component."""
DOMAIN = "yamaha" DOMAIN = "yamaha"
SERVICE_ENABLE_OUTPUT = "enable_output" SERVICE_ENABLE_OUTPUT = "enable_output"
SERVICE_SELECT_SCENE = "select_scene"

View File

@ -22,7 +22,6 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_SET, SUPPORT_VOLUME_SET,
) )
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST, CONF_HOST,
CONF_NAME, CONF_NAME,
STATE_IDLE, STATE_IDLE,
@ -30,15 +29,17 @@ from homeassistant.const import (
STATE_ON, STATE_ON,
STATE_PLAYING, STATE_PLAYING,
) )
import homeassistant.helpers.config_validation as cv from homeassistant.helpers import config_validation as cv, entity_platform
from .const import DOMAIN, SERVICE_ENABLE_OUTPUT from .const import SERVICE_ENABLE_OUTPUT, SERVICE_SELECT_SCENE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ATTR_ENABLED = "enabled" ATTR_ENABLED = "enabled"
ATTR_PORT = "port" ATTR_PORT = "port"
ATTR_SCENE = "scene"
CONF_SOURCE_IGNORE = "source_ignore" CONF_SOURCE_IGNORE = "source_ignore"
CONF_SOURCE_NAMES = "source_names" CONF_SOURCE_NAMES = "source_names"
CONF_ZONE_IGNORE = "zone_ignore" CONF_ZONE_IGNORE = "zone_ignore"
@ -47,12 +48,6 @@ CONF_ZONE_NAMES = "zone_names"
DATA_YAMAHA = "yamaha_known_receivers" DATA_YAMAHA = "yamaha_known_receivers"
DEFAULT_NAME = "Yamaha Receiver" DEFAULT_NAME = "Yamaha Receiver"
MEDIA_PLAYER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.comp_entity_ids})
ENABLE_OUTPUT_SCHEMA = MEDIA_PLAYER_SCHEMA.extend(
{vol.Required(ATTR_ENABLED): cv.boolean, vol.Required(ATTR_PORT): cv.string}
)
SUPPORT_YAMAHA = ( SUPPORT_YAMAHA = (
SUPPORT_VOLUME_SET SUPPORT_VOLUME_SET
| SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_MUTE
@ -79,78 +74,94 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
) )
def setup_platform(hass, config, add_entities, discovery_info=None): class YamahaConfigInfo:
"""Set up the Yamaha platform.""" """Configuration Info for Yamaha Receivers."""
# Keep track of configured receivers so that we don't end up def __init__(self, config: None, discovery_info: None):
# discovering a receiver dynamically that we have static config """Initialize the Configuration Info for Yamaha Receiver."""
# for. Map each device from its zone_id to an instance since self.name = config.get(CONF_NAME)
# YamahaDevice is not hashable (thus not possible to add to a set). self.host = config.get(CONF_HOST)
if hass.data.get(DATA_YAMAHA) is None: self.ctrl_url = f"http://{self.host}:80/YamahaRemoteControl/ctrl"
hass.data[DATA_YAMAHA] = {} self.source_ignore = config.get(CONF_SOURCE_IGNORE)
self.source_names = config.get(CONF_SOURCE_NAMES)
self.zone_ignore = config.get(CONF_ZONE_IGNORE)
self.zone_names = config.get(CONF_ZONE_NAMES)
self.from_discovery = False
if discovery_info is not None:
self.name = discovery_info.get("name")
self.model = discovery_info.get("model_name")
self.ctrl_url = discovery_info.get("control_url")
self.desc_url = discovery_info.get("description_url")
self.zone_ignore = []
self.from_discovery = True
name = config.get(CONF_NAME)
host = config.get(CONF_HOST)
source_ignore = config.get(CONF_SOURCE_IGNORE)
source_names = config.get(CONF_SOURCE_NAMES)
zone_ignore = config.get(CONF_ZONE_IGNORE)
zone_names = config.get(CONF_ZONE_NAMES)
if discovery_info is not None: def _discovery(config_info):
name = discovery_info.get("name") """Discover receivers from configuration in the network."""
model = discovery_info.get("model_name") if config_info.from_discovery:
ctrl_url = discovery_info.get("control_url")
desc_url = discovery_info.get("description_url")
receivers = rxv.RXV( receivers = rxv.RXV(
ctrl_url, model_name=model, friendly_name=name, unit_desc_url=desc_url config_info.ctrl_url,
model_name=config_info.model,
friendly_name=config_info.name,
unit_desc_url=config_info.desc_url,
).zone_controllers() ).zone_controllers()
_LOGGER.debug("Receivers: %s", receivers) _LOGGER.debug("Receivers: %s", receivers)
# when we are dynamically discovered config is empty elif config_info.host is None:
zone_ignore = []
elif host is None:
receivers = [] receivers = []
for recv in rxv.find(): for recv in rxv.find():
receivers.extend(recv.zone_controllers()) receivers.extend(recv.zone_controllers())
else: else:
ctrl_url = f"http://{host}:80/YamahaRemoteControl/ctrl" receivers = rxv.RXV(config_info.ctrl_url, config_info.name).zone_controllers()
receivers = rxv.RXV(ctrl_url, name).zone_controllers()
devices = [] return receivers
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Yamaha platform."""
# Keep track of configured receivers so that we don't end up
# discovering a receiver dynamically that we have static config
# for. Map each device from its zone_id .
known_zones = hass.data.setdefault(DATA_YAMAHA, set())
# Get the Infos for configuration from config (YAML) or Discovery
config_info = YamahaConfigInfo(config=config, discovery_info=discovery_info)
# Async check if the Receivers are there in the network
receivers = await hass.async_add_executor_job(_discovery, config_info)
entities = []
for receiver in receivers: for receiver in receivers:
if receiver.zone in zone_ignore: if receiver.zone in config_info.zone_ignore:
continue continue
device = YamahaDevice(name, receiver, source_ignore, source_names, zone_names) entity = YamahaDevice(
config_info.name,
receiver,
config_info.source_ignore,
config_info.source_names,
config_info.zone_names,
)
# Only add device if it's not already added # Only add device if it's not already added
if device.zone_id not in hass.data[DATA_YAMAHA]: if entity.zone_id not in known_zones:
hass.data[DATA_YAMAHA][device.zone_id] = device known_zones.add(entity.zone_id)
devices.append(device) entities.append(entity)
else: else:
_LOGGER.debug("Ignoring duplicate receiver: %s", name) _LOGGER.debug("Ignoring duplicate receiver: %s", config_info.name)
def service_handler(service): async_add_entities(entities)
"""Handle for services."""
entity_ids = service.data.get(ATTR_ENTITY_ID)
devices = [ # Register Service 'select_scene'
device platform = entity_platform.current_platform.get()
for device in hass.data[DATA_YAMAHA].values() platform.async_register_entity_service(
if not entity_ids or device.entity_id in entity_ids SERVICE_SELECT_SCENE, {vol.Required(ATTR_SCENE): cv.string}, "set_scene",
] )
# Register Service 'enable_output'
for device in devices: platform.async_register_entity_service(
port = service.data[ATTR_PORT] SERVICE_ENABLE_OUTPUT,
enabled = service.data[ATTR_ENABLED] {vol.Required(ATTR_ENABLED): cv.boolean, vol.Required(ATTR_PORT): cv.string},
"enable_output",
device.enable_output(port, enabled)
device.schedule_update_ha_state(True)
hass.services.register(
DOMAIN, SERVICE_ENABLE_OUTPUT, service_handler, schema=ENABLE_OUTPUT_SCHEMA
) )
add_entities(devices)
class YamahaDevice(MediaPlayerEntity): class YamahaDevice(MediaPlayerEntity):
@ -350,7 +361,6 @@ class YamahaDevice(MediaPlayerEntity):
Yamaha to direct play certain kinds of media. media_type is Yamaha to direct play certain kinds of media. media_type is
treated as the input type that we are setting, and media id is treated as the input type that we are setting, and media id is
specific to it. specific to it.
For the NET RADIO mediatype the format for ``media_id`` is a For the NET RADIO mediatype the format for ``media_id`` is a
"path" in your vtuner hierarchy. For instance: "path" in your vtuner hierarchy. For instance:
``Bookmarks>Internet>Radio Paradise``. The separators are ``Bookmarks>Internet>Radio Paradise``. The separators are
@ -358,12 +368,10 @@ class YamahaDevice(MediaPlayerEntity):
scenes. There is a looping construct built into the yamaha scenes. There is a looping construct built into the yamaha
library to do this with a fallback timeout if the vtuner library to do this with a fallback timeout if the vtuner
service is unresponsive. service is unresponsive.
NOTE: this might take a while, because the only API interface NOTE: this might take a while, because the only API interface
for setting the net radio station emulates button pressing and for setting the net radio station emulates button pressing and
navigating through the net radio menu hierarchy. And each sub navigating through the net radio menu hierarchy. And each sub
menu must be fetched by the receiver from the vtuner service. menu must be fetched by the receiver from the vtuner service.
""" """
if media_type == "NET RADIO": if media_type == "NET RADIO":
self.receiver.net_radio(media_id) self.receiver.net_radio(media_id)
@ -372,6 +380,13 @@ class YamahaDevice(MediaPlayerEntity):
"""Enable or disable an output port..""" """Enable or disable an output port.."""
self.receiver.enable_output(port, enabled) self.receiver.enable_output(port, enabled)
def set_scene(self, scene):
"""Set the current scene."""
try:
self.receiver.scene = scene
except AssertionError:
_LOGGER.warning("Scene '%s' does not exist!", scene)
def select_sound_mode(self, sound_mode): def select_sound_mode(self, sound_mode):
"""Set Sound Mode for Receiver..""" """Set Sound Mode for Receiver.."""
self.receiver.surround_program = sound_mode self.receiver.surround_program = sound_mode

View File

@ -10,3 +10,12 @@ enable_output:
enabled: enabled:
description: Boolean indicating if port should be enabled or not. description: Boolean indicating if port should be enabled or not.
example: true example: true
select_scene:
description: "Select a scene on the receiver"
fields:
entity_id:
description: Name(s) of entities to enable/disable port on.
example: "media_player.yamaha"
scene:
description: Name of the scene. Standard for RX-V437 is 'BD/DVD Movie Viewing', 'TV Viewing', 'NET Audio Listening' or 'Radio Listening'
example: "TV Viewing"

View File

@ -1,12 +1,15 @@
"""The tests for the Yamaha Media player platform.""" """The tests for the Yamaha Media player platform."""
import unittest import pytest
import homeassistant.components.media_player as mp import homeassistant.components.media_player as mp
from homeassistant.components.yamaha import media_player as yamaha from homeassistant.components.yamaha import media_player as yamaha
from homeassistant.setup import setup_component from homeassistant.components.yamaha.const import DOMAIN
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.setup import async_setup_component
from tests.async_mock import MagicMock, patch from tests.async_mock import MagicMock, PropertyMock, call, patch
from tests.common import get_test_home_assistant
CONFIG = {"media_player": {"platform": "yamaha", "host": "127.0.0.1"}}
def _create_zone_mock(name, url): def _create_zone_mock(name, url):
@ -23,54 +26,142 @@ class FakeYamahaDevice:
"""Initialize the fake Yamaha device.""" """Initialize the fake Yamaha device."""
self.ctrl_url = ctrl_url self.ctrl_url = ctrl_url
self.name = name self.name = name
self.zones = zones or [] self._zones = zones or []
def zone_controllers(self): def zone_controllers(self):
"""Return controllers for all available zones.""" """Return controllers for all available zones."""
return self.zones return self._zones
class TestYamahaMediaPlayer(unittest.TestCase): @pytest.fixture(name="main_zone")
"""Test the Yamaha media player.""" def main_zone_fixture():
"""Mock the main zone."""
return _create_zone_mock("Main zone", "http://main")
def setUp(self):
"""Set up things to be run when tests are started.""" @pytest.fixture(name="device")
self.hass = get_test_home_assistant() def device_fixture(main_zone):
self.main_zone = _create_zone_mock("Main zone", "http://main") """Mock the yamaha device."""
self.device = FakeYamahaDevice( device = FakeYamahaDevice("http://receiver", "Receiver", zones=[main_zone])
"http://receiver", "Receiver", zones=[self.main_zone] with patch("rxv.RXV", return_value=device):
yield device
async def test_setup_host(hass, device, main_zone):
"""Test set up integration with host."""
assert await async_setup_component(hass, mp.DOMAIN, CONFIG)
await hass.async_block_till_done()
state = hass.states.get("media_player.yamaha_receiver_main_zone")
assert state is not None
assert state.state == "off"
async def test_setup_no_host(hass, device, main_zone):
"""Test set up integration without host."""
with patch("rxv.find", return_value=[device]):
assert await async_setup_component(
hass, mp.DOMAIN, {"media_player": {"platform": "yamaha"}}
) )
await hass.async_block_till_done()
def tearDown(self): state = hass.states.get("media_player.yamaha_receiver_main_zone")
"""Stop everything that was started."""
self.hass.stop()
def enable_output(self, port, enabled): assert state is not None
"""Enable output on a specific port.""" assert state.state == "off"
data = {
"entity_id": "media_player.yamaha_receiver_main_zone",
"port": port,
"enabled": enabled,
}
self.hass.services.call(yamaha.DOMAIN, yamaha.SERVICE_ENABLE_OUTPUT, data, True)
def create_receiver(self, mock_rxv): async def test_setup_discovery(hass, device, main_zone):
"""Create a mocked receiver.""" """Test set up integration via discovery."""
mock_rxv.return_value = self.device discovery_info = {
"name": "Yamaha Receiver",
"model_name": "Yamaha",
"control_url": "http://receiver",
"description_url": "http://receiver/description",
}
await async_load_platform(
hass, mp.DOMAIN, "yamaha", discovery_info, {mp.DOMAIN: {}}
)
await hass.async_block_till_done()
config = {"media_player": {"platform": "yamaha", "host": "127.0.0.1"}} state = hass.states.get("media_player.yamaha_receiver_main_zone")
assert setup_component(self.hass, mp.DOMAIN, config) assert state is not None
self.hass.block_till_done() assert state.state == "off"
@patch("rxv.RXV")
def test_enable_output(self, mock_rxv):
"""Test enabling and disabling outputs."""
self.create_receiver(mock_rxv)
self.enable_output("hdmi1", True) async def test_setup_zone_ignore(hass, device, main_zone):
self.main_zone.enable_output.assert_called_with("hdmi1", True) """Test set up integration without host."""
assert await async_setup_component(
hass,
mp.DOMAIN,
{
"media_player": {
"platform": "yamaha",
"host": "127.0.0.1",
"zone_ignore": "Main zone",
}
},
)
await hass.async_block_till_done()
self.enable_output("hdmi2", False) state = hass.states.get("media_player.yamaha_receiver_main_zone")
self.main_zone.enable_output.assert_called_with("hdmi2", False)
assert state is None
async def test_enable_output(hass, device, main_zone):
"""Test enable output service."""
assert await async_setup_component(hass, mp.DOMAIN, CONFIG)
await hass.async_block_till_done()
port = "hdmi1"
enabled = True
data = {
"entity_id": "media_player.yamaha_receiver_main_zone",
"port": port,
"enabled": enabled,
}
await hass.services.async_call(DOMAIN, yamaha.SERVICE_ENABLE_OUTPUT, data, True)
assert main_zone.enable_output.call_count == 1
assert main_zone.enable_output.call_args == call(port, enabled)
async def test_select_scene(hass, device, main_zone, caplog):
"""Test select scene service."""
scene_prop = PropertyMock(return_value=None)
type(main_zone).scene = scene_prop
assert await async_setup_component(hass, mp.DOMAIN, CONFIG)
await hass.async_block_till_done()
scene = "TV Viewing"
data = {
"entity_id": "media_player.yamaha_receiver_main_zone",
"scene": scene,
}
await hass.services.async_call(DOMAIN, yamaha.SERVICE_SELECT_SCENE, data, True)
assert scene_prop.call_count == 1
assert scene_prop.call_args == call(scene)
scene = "BD/DVD Movie Viewing"
data["scene"] = scene
await hass.services.async_call(DOMAIN, yamaha.SERVICE_SELECT_SCENE, data, True)
assert scene_prop.call_count == 2
assert scene_prop.call_args == call(scene)
scene_prop.side_effect = AssertionError()
missing_scene = "Missing scene"
data["scene"] = missing_scene
await hass.services.async_call(DOMAIN, yamaha.SERVICE_SELECT_SCENE, data, True)
assert f"Scene '{missing_scene}' does not exist!" in caplog.text