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 import MusicCastConnectionException
from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice
from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -19,7 +20,7 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed, UpdateFailed,
) )
from .const import BRAND, DOMAIN from .const import BRAND, CONF_SERIAL, CONF_UPNP_DESC, DOMAIN
PLATFORMS = ["media_player"] PLATFORMS = ["media_player"]
@ -27,10 +28,42 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60) 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up MusicCast from a config entry.""" """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) coordinator = MusicCastDataUpdateCoordinator(hass, client=client)
await coordinator.async_config_entry_first_refresh() 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.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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__) _LOGGER = logging.getLogger(__name__)
@ -27,6 +28,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
serial_number: str | None = None serial_number: str | None = None
host: str host: str
upnp_description: str | None = None
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -64,7 +66,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
title=host, title=host,
data={ data={
CONF_HOST: host, 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.serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL]
self.host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname 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) 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( self.context.update(
{ {
"title_placeholders": { "title_placeholders": {
@ -108,7 +117,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
title=self.host, title=self.host,
data={ data={
CONF_HOST: self.host, 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.""" """Constants for the MusicCast integration."""
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
MEDIA_CLASS_DIRECTORY,
MEDIA_CLASS_TRACK,
REPEAT_MODE_ALL, REPEAT_MODE_ALL,
REPEAT_MODE_OFF, REPEAT_MODE_OFF,
REPEAT_MODE_ONE, REPEAT_MODE_ONE,
@ -17,6 +19,9 @@ ATTR_MC_LINK = "mc_link"
ATTR_MAIN_SYNC = "main_sync" ATTR_MAIN_SYNC = "main_sync"
ATTR_MC_LINK_SOURCES = [ATTR_MC_LINK, ATTR_MAIN_SYNC] ATTR_MC_LINK_SOURCES = [ATTR_MC_LINK, ATTR_MAIN_SYNC]
CONF_UPNP_DESC = "upnp_description"
CONF_SERIAL = "serial"
DEFAULT_ZONE = "main" DEFAULT_ZONE = "main"
HA_REPEAT_MODE_TO_MC_MAPPING = { HA_REPEAT_MODE_TO_MC_MAPPING = {
REPEAT_MODE_OFF: "off", REPEAT_MODE_OFF: "off",
@ -31,3 +36,9 @@ INTERVAL_SECONDS = "interval_seconds"
MC_REPEAT_MODE_TO_HA_MAPPING = { MC_REPEAT_MODE_TO_HA_MAPPING = {
val: key for key, val in HA_REPEAT_MODE_TO_MC_MAPPING.items() 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, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast",
"requirements": [ "requirements": [
"aiomusiccast==0.8.2" "aiomusiccast==0.9.1"
], ],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "Yamaha Corporation" "manufacturer": "Yamaha Corporation"
} }
], ],
"dependencies": [
"ssdp"
],
"iot_class": "local_push", "iot_class": "local_push",
"codeowners": [ "codeowners": [
"@vigonotion", "@vigonotion",

View File

@ -3,17 +3,26 @@ from __future__ import annotations
import logging import logging
from aiomusiccast import MusicCastGroupException from aiomusiccast import MusicCastGroupException, MusicCastMediaContent
from aiomusiccast.features import ZoneFeature from aiomusiccast.features import ZoneFeature
import voluptuous as vol 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 ( from homeassistant.components.media_player.const import (
MEDIA_CLASS_DIRECTORY,
MEDIA_CLASS_TRACK,
MEDIA_TYPE_MUSIC,
REPEAT_MODE_OFF, REPEAT_MODE_OFF,
SUPPORT_BROWSE_MEDIA,
SUPPORT_GROUPING, SUPPORT_GROUPING,
SUPPORT_NEXT_TRACK, SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE, SUPPORT_PAUSE,
SUPPORT_PLAY, SUPPORT_PLAY,
SUPPORT_PLAY_MEDIA,
SUPPORT_PREVIOUS_TRACK, SUPPORT_PREVIOUS_TRACK,
SUPPORT_REPEAT_SET, SUPPORT_REPEAT_SET,
SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOUND_MODE,
@ -51,22 +60,19 @@ from .const import (
HA_REPEAT_MODE_TO_MC_MAPPING, HA_REPEAT_MODE_TO_MC_MAPPING,
INTERVAL_SECONDS, INTERVAL_SECONDS,
MC_REPEAT_MODE_TO_HA_MAPPING, MC_REPEAT_MODE_TO_HA_MAPPING,
MEDIA_CLASS_MAPPING,
NULL_GROUP, NULL_GROUP,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
MUSIC_PLAYER_BASE_SUPPORT = ( MUSIC_PLAYER_BASE_SUPPORT = (
SUPPORT_PAUSE SUPPORT_SHUFFLE_SET
| SUPPORT_PLAY
| SUPPORT_SHUFFLE_SET
| SUPPORT_REPEAT_SET | SUPPORT_REPEAT_SET
| SUPPORT_PREVIOUS_TRACK
| SUPPORT_NEXT_TRACK
| SUPPORT_SELECT_SOUND_MODE | SUPPORT_SELECT_SOUND_MODE
| SUPPORT_SELECT_SOURCE | SUPPORT_SELECT_SOURCE
| SUPPORT_STOP
| SUPPORT_GROUPING | SUPPORT_GROUPING
| SUPPORT_PLAY_MEDIA
) )
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@ -198,6 +204,16 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
def _is_tuner(self): def _is_tuner(self):
return self.coordinator.data.zones[self._zone_id].input == "tuner" 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 @property
def state(self): def state(self):
"""Return the state of the player.""" """Return the state of the player."""
@ -308,6 +324,88 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
"Service shuffle is not supported for non NetUSB sources." "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): async def async_select_sound_mode(self, sound_mode):
"""Select sound mode.""" """Select sound mode."""
await self.coordinator.musiccast.select_sound_mode(self._zone_id, 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: if ZoneFeature.MUTE in zone.features:
supported_features |= SUPPORT_VOLUME_MUTE 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 return supported_features
async def async_media_previous_track(self): async def async_media_previous_track(self):

View File

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

View File

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

View File

@ -77,6 +77,30 @@ def mock_ssdp_no_yamaha():
yield 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 # User Flows
@ -150,7 +174,9 @@ async def test_user_input_unknown_error(hass, mock_get_device_info_exception):
assert result2["errors"] == {"base": "unknown"} 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.""" """Test when user specifies an existing device."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} 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"] == { assert result2["data"] == {
"host": "127.0.0.1", "host": "127.0.0.1",
"serial": "1234567890", "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"} 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.""" """Test when the device was imported successfully."""
config = {"platform": "yamaha_musiccast", "host": "127.0.0.1", "port": 5006} 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"] == { assert result["data"] == {
"host": "127.0.0.1", "host": "127.0.0.1",
"serial": "1234567890", "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"] == { assert result2["data"] == {
"host": "127.0.0.1", "host": "127.0.0.1",
"serial": "1234567890", "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["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
assert mock_entry.data[CONF_HOST] == "127.0.0.1" assert mock_entry.data[CONF_HOST] == "127.0.0.1"
assert mock_entry.data["upnp_description"] == "http://127.0.0.1/desc.xml"