Yamaha Musiccast Media Browser feature (#54864)
parent
4ae2a26aa3
commit
6eadc0c303
|
@ -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()
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue