diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 3a8275e98f0..0de8428b0dc 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -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() diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index 9645be3ddc8..f4ad455fb04 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -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, }, ) diff --git a/homeassistant/components/yamaha_musiccast/const.py b/homeassistant/components/yamaha_musiccast/const.py index d7daaab4117..55ce3920fa1 100644 --- a/homeassistant/components/yamaha_musiccast/const.py +++ b/homeassistant/components/yamaha_musiccast/const.py @@ -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, +} diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 46fae870e5e..bd614e368dc 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -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", diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index d08ba798bd8..5081a716357 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -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): diff --git a/requirements_all.txt b/requirements_all.txt index 676539b7b01..244b4e13188 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8665228d24f..6557b0d6ce9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 6f5709ec7cc..08900b1dfad 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -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"