Yamaha Musiccast Media Browser feature (#54864)

This commit is contained in:
micha91 2021-08-19 20:42:11 +02:00 committed by GitHub
parent 4ae2a26aa3
commit 6eadc0c303
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 241 additions and 19 deletions

View File

@ -7,6 +7,7 @@ import logging
from aiomusiccast import MusicCastConnectionException
from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice
from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
@ -19,7 +20,7 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed,
)
from .const import BRAND, DOMAIN
from .const import BRAND, CONF_SERIAL, CONF_UPNP_DESC, DOMAIN
PLATFORMS = ["media_player"]
@ -27,10 +28,42 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60)
async def get_upnp_desc(hass: HomeAssistant, host: str):
"""Get the upnp description URL for a given host, using the SSPD scanner."""
ssdp_entries = ssdp.async_get_discovery_info_by_st(hass, "upnp:rootdevice")
matches = [w for w in ssdp_entries if w.get("_host", "") == host]
upnp_desc = None
for match in matches:
if match.get(ssdp.ATTR_SSDP_LOCATION):
upnp_desc = match[ssdp.ATTR_SSDP_LOCATION]
break
if not upnp_desc:
_LOGGER.warning(
"The upnp_description was not found automatically, setting a default one"
)
upnp_desc = f"http://{host}:49154/MediaRenderer/desc.xml"
return upnp_desc
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up MusicCast from a config entry."""
client = MusicCastDevice(entry.data[CONF_HOST], async_get_clientsession(hass))
if entry.data.get(CONF_UPNP_DESC) is None:
hass.config_entries.async_update_entry(
entry,
data={
CONF_HOST: entry.data[CONF_HOST],
CONF_SERIAL: entry.data["serial"],
CONF_UPNP_DESC: await get_upnp_desc(hass, entry.data[CONF_HOST]),
},
)
client = MusicCastDevice(
entry.data[CONF_HOST],
async_get_clientsession(hass),
entry.data[CONF_UPNP_DESC],
)
coordinator = MusicCastDataUpdateCoordinator(hass, client=client)
await coordinator.async_config_entry_first_refresh()

View File

@ -15,7 +15,8 @@ from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from . import get_upnp_desc
from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN
_LOGGER = logging.getLogger(__name__)
@ -27,6 +28,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
serial_number: str | None = None
host: str
upnp_description: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@ -64,7 +66,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
title=host,
data={
CONF_HOST: host,
"serial": serial_number,
CONF_SERIAL: serial_number,
CONF_UPNP_DESC: await get_upnp_desc(self.hass, host),
},
)
@ -89,8 +92,14 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
self.serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL]
self.host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname
self.upnp_description = discovery_info[ssdp.ATTR_SSDP_LOCATION]
await self.async_set_unique_id(self.serial_number)
self._abort_if_unique_id_configured({CONF_HOST: self.host})
self._abort_if_unique_id_configured(
{
CONF_HOST: self.host,
CONF_UPNP_DESC: self.upnp_description,
}
)
self.context.update(
{
"title_placeholders": {
@ -108,7 +117,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
title=self.host,
data={
CONF_HOST: self.host,
"serial": self.serial_number,
CONF_SERIAL: self.serial_number,
CONF_UPNP_DESC: self.upnp_description,
},
)

View File

@ -1,6 +1,8 @@
"""Constants for the MusicCast integration."""
from homeassistant.components.media_player.const import (
MEDIA_CLASS_DIRECTORY,
MEDIA_CLASS_TRACK,
REPEAT_MODE_ALL,
REPEAT_MODE_OFF,
REPEAT_MODE_ONE,
@ -17,6 +19,9 @@ ATTR_MC_LINK = "mc_link"
ATTR_MAIN_SYNC = "main_sync"
ATTR_MC_LINK_SOURCES = [ATTR_MC_LINK, ATTR_MAIN_SYNC]
CONF_UPNP_DESC = "upnp_description"
CONF_SERIAL = "serial"
DEFAULT_ZONE = "main"
HA_REPEAT_MODE_TO_MC_MAPPING = {
REPEAT_MODE_OFF: "off",
@ -31,3 +36,9 @@ INTERVAL_SECONDS = "interval_seconds"
MC_REPEAT_MODE_TO_HA_MAPPING = {
val: key for key, val in HA_REPEAT_MODE_TO_MC_MAPPING.items()
}
MEDIA_CLASS_MAPPING = {
"track": MEDIA_CLASS_TRACK,
"directory": MEDIA_CLASS_DIRECTORY,
"categories": MEDIA_CLASS_DIRECTORY,
}

View File

@ -4,13 +4,16 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast",
"requirements": [
"aiomusiccast==0.8.2"
"aiomusiccast==0.9.1"
],
"ssdp": [
{
"manufacturer": "Yamaha Corporation"
}
],
"dependencies": [
"ssdp"
],
"iot_class": "local_push",
"codeowners": [
"@vigonotion",

View File

@ -3,17 +3,26 @@ from __future__ import annotations
import logging
from aiomusiccast import MusicCastGroupException
from aiomusiccast import MusicCastGroupException, MusicCastMediaContent
from aiomusiccast.features import ZoneFeature
import voluptuous as vol
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
from homeassistant.components.media_player import (
PLATFORM_SCHEMA,
BrowseMedia,
MediaPlayerEntity,
)
from homeassistant.components.media_player.const import (
MEDIA_CLASS_DIRECTORY,
MEDIA_CLASS_TRACK,
MEDIA_TYPE_MUSIC,
REPEAT_MODE_OFF,
SUPPORT_BROWSE_MEDIA,
SUPPORT_GROUPING,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
SUPPORT_PLAY_MEDIA,
SUPPORT_PREVIOUS_TRACK,
SUPPORT_REPEAT_SET,
SUPPORT_SELECT_SOUND_MODE,
@ -51,22 +60,19 @@ from .const import (
HA_REPEAT_MODE_TO_MC_MAPPING,
INTERVAL_SECONDS,
MC_REPEAT_MODE_TO_HA_MAPPING,
MEDIA_CLASS_MAPPING,
NULL_GROUP,
)
_LOGGER = logging.getLogger(__name__)
MUSIC_PLAYER_BASE_SUPPORT = (
SUPPORT_PAUSE
| SUPPORT_PLAY
| SUPPORT_SHUFFLE_SET
SUPPORT_SHUFFLE_SET
| SUPPORT_REPEAT_SET
| SUPPORT_PREVIOUS_TRACK
| SUPPORT_NEXT_TRACK
| SUPPORT_SELECT_SOUND_MODE
| SUPPORT_SELECT_SOURCE
| SUPPORT_STOP
| SUPPORT_GROUPING
| SUPPORT_PLAY_MEDIA
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@ -198,6 +204,16 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
def _is_tuner(self):
return self.coordinator.data.zones[self._zone_id].input == "tuner"
@property
def media_content_id(self):
"""Return the content ID of current playing media."""
return None
@property
def media_content_type(self):
"""Return the content type of current playing media."""
return MEDIA_TYPE_MUSIC
@property
def state(self):
"""Return the state of the player."""
@ -308,6 +324,88 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
"Service shuffle is not supported for non NetUSB sources."
)
async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None:
"""Play media."""
if self.state == STATE_OFF:
await self.async_turn_on()
if media_id:
parts = media_id.split(":")
if parts[0] == "list":
index = parts[3]
if index == "-1":
index = "0"
await self.coordinator.musiccast.play_list_media(index, self._zone_id)
return
if parts[0] == "presets":
index = parts[1]
await self.coordinator.musiccast.recall_netusb_preset(
self._zone_id, index
)
return
if parts[0] == "http":
await self.coordinator.musiccast.play_url_media(
self._zone_id, media_id, "HomeAssistant"
)
return
raise HomeAssistantError(
"Only presets, media from media browser and http URLs are supported"
)
async def async_browse_media(self, media_content_type=None, media_content_id=None):
"""Implement the websocket media browsing helper."""
if self.state == STATE_OFF:
raise HomeAssistantError(
"The device has to be turned on to be able to browse media."
)
if media_content_id:
media_content_path = media_content_id.split(":")
media_content_provider = await MusicCastMediaContent.browse_media(
self.coordinator.musiccast, self._zone_id, media_content_path, 24
)
else:
media_content_provider = MusicCastMediaContent.categories(
self.coordinator.musiccast, self._zone_id
)
def get_content_type(item):
if item.can_play:
return MEDIA_CLASS_TRACK
return MEDIA_CLASS_DIRECTORY
children = [
BrowseMedia(
title=child.title,
media_class=MEDIA_CLASS_MAPPING.get(child.content_type),
media_content_id=child.content_id,
media_content_type=get_content_type(child),
can_play=child.can_play,
can_expand=child.can_browse,
thumbnail=child.thumbnail,
)
for child in media_content_provider.children
]
overview = BrowseMedia(
title=media_content_provider.title,
media_class=MEDIA_CLASS_MAPPING.get(media_content_provider.content_type),
media_content_id=media_content_provider.content_id,
media_content_type=get_content_type(media_content_provider),
can_play=False,
can_expand=media_content_provider.can_browse,
children=children,
)
return overview
async def async_select_sound_mode(self, sound_mode):
"""Select sound mode."""
await self.coordinator.musiccast.select_sound_mode(self._zone_id, sound_mode)
@ -366,6 +464,18 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
if ZoneFeature.MUTE in zone.features:
supported_features |= SUPPORT_VOLUME_MUTE
if self._is_netusb or self._is_tuner:
supported_features |= SUPPORT_PREVIOUS_TRACK
supported_features |= SUPPORT_NEXT_TRACK
if self._is_netusb:
supported_features |= SUPPORT_PAUSE
supported_features |= SUPPORT_PLAY
supported_features |= SUPPORT_STOP
if self.state != STATE_OFF:
supported_features |= SUPPORT_BROWSE_MEDIA
return supported_features
async def async_media_previous_track(self):

View File

@ -216,7 +216,7 @@ aiolyric==1.0.7
aiomodernforms==0.1.8
# homeassistant.components.yamaha_musiccast
aiomusiccast==0.8.2
aiomusiccast==0.9.1
# homeassistant.components.keyboard_remote
aionotify==0.2.0

View File

@ -140,7 +140,7 @@ aiolyric==1.0.7
aiomodernforms==0.1.8
# homeassistant.components.yamaha_musiccast
aiomusiccast==0.8.2
aiomusiccast==0.9.1
# homeassistant.components.notion
aionotion==3.0.2

View File

@ -77,6 +77,30 @@ def mock_ssdp_no_yamaha():
yield
@pytest.fixture
def mock_valid_discovery_information():
"""Mock that the ssdp scanner returns a useful upnp description."""
with patch(
"homeassistant.components.ssdp.async_get_discovery_info_by_st",
return_value=[
{
"ssdp_location": "http://127.0.0.1:9000/MediaRenderer/desc.xml",
"_host": "127.0.0.1",
}
],
):
yield
@pytest.fixture
def mock_empty_discovery_information():
"""Mock that the ssdp scanner returns no upnp description."""
with patch(
"homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[]
):
yield
# User Flows
@ -150,7 +174,9 @@ async def test_user_input_unknown_error(hass, mock_get_device_info_exception):
assert result2["errors"] == {"base": "unknown"}
async def test_user_input_device_found(hass, mock_get_device_info_valid):
async def test_user_input_device_found(
hass, mock_get_device_info_valid, mock_valid_discovery_information
):
"""Test when user specifies an existing device."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -167,6 +193,30 @@ async def test_user_input_device_found(hass, mock_get_device_info_valid):
assert result2["data"] == {
"host": "127.0.0.1",
"serial": "1234567890",
"upnp_description": "http://127.0.0.1:9000/MediaRenderer/desc.xml",
}
async def test_user_input_device_found_no_ssdp(
hass, mock_get_device_info_valid, mock_empty_discovery_information
):
"""Test when user specifies an existing device, which no discovery data are present for."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "127.0.0.1"},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert isinstance(result2["result"], ConfigEntry)
assert result2["data"] == {
"host": "127.0.0.1",
"serial": "1234567890",
"upnp_description": "http://127.0.0.1:49154/MediaRenderer/desc.xml",
}
@ -201,7 +251,9 @@ async def test_import_error(hass, mock_get_device_info_exception):
assert result["errors"] == {"base": "unknown"}
async def test_import_device_successful(hass, mock_get_device_info_valid):
async def test_import_device_successful(
hass, mock_get_device_info_valid, mock_valid_discovery_information
):
"""Test when the device was imported successfully."""
config = {"platform": "yamaha_musiccast", "host": "127.0.0.1", "port": 5006}
@ -214,6 +266,7 @@ async def test_import_device_successful(hass, mock_get_device_info_valid):
assert result["data"] == {
"host": "127.0.0.1",
"serial": "1234567890",
"upnp_description": "http://127.0.0.1:9000/MediaRenderer/desc.xml",
}
@ -262,6 +315,7 @@ async def test_ssdp_discovery_successful_add_device(hass, mock_ssdp_yamaha):
assert result2["data"] == {
"host": "127.0.0.1",
"serial": "1234567890",
"upnp_description": "http://127.0.0.1/desc.xml",
}
@ -285,3 +339,4 @@ async def test_ssdp_discovery_existing_device_update(hass, mock_ssdp_yamaha):
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert mock_entry.data[CONF_HOST] == "127.0.0.1"
assert mock_entry.data["upnp_description"] == "http://127.0.0.1/desc.xml"