diff --git a/.coveragerc b/.coveragerc index c407c2ab373..4b0a1f8c4a3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1212,6 +1212,7 @@ omit = homeassistant/components/xmpp/notify.py homeassistant/components/xs1/* homeassistant/components/yale_smart_alarm/alarm_control_panel.py + homeassistant/components/yamaha_musiccast/__init__.py homeassistant/components/yamaha_musiccast/media_player.py homeassistant/components/yandex_transport/* homeassistant/components/yeelightsunflower/light.py diff --git a/CODEOWNERS b/CODEOWNERS index 0a5c9503dba..158b375163f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -568,7 +568,7 @@ homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG homeassistant/components/xiaomi_tv/* @simse homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yale_smart_alarm/* @gjohansson-ST -homeassistant/components/yamaha_musiccast/* @jalmeroth +homeassistant/components/yamaha_musiccast/* @vigonotion @micha91 homeassistant/components/yandex_transport/* @rishatik92 @devbis homeassistant/components/yeelight/* @rytilahti @zewelor @shenxn homeassistant/components/yeelightsunflower/* @lindsaymarkward diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index bf270b508d9..d3749da318c 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -1 +1,134 @@ -"""The yamaha_musiccast component.""" +"""The MusicCast integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiomusiccast import MusicCastConnectionException +from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import BRAND, DOMAIN + +PLATFORMS = ["media_player"] + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=60) + + +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)) + coordinator = MusicCastDataUpdateCoordinator(hass, client=client) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry.""" + await hass.config_entries.async_reload(entry.entry_id) + + +class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): + """Class to manage fetching data from the API.""" + + def __init__(self, hass: HomeAssistant, client: MusicCastDevice) -> None: + """Initialize.""" + self.musiccast = client + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self) -> MusicCastData: + """Update data via library.""" + try: + await self.musiccast.fetch() + except MusicCastConnectionException as exception: + raise UpdateFailed() from exception + return self.musiccast.data + + +class MusicCastEntity(CoordinatorEntity): + """Defines a base MusicCast entity.""" + + coordinator: MusicCastDataUpdateCoordinator + + def __init__( + self, + *, + name: str, + icon: str, + coordinator: MusicCastDataUpdateCoordinator, + enabled_default: bool = True, + ) -> None: + """Initialize the MusicCast entity.""" + super().__init__(coordinator) + self._enabled_default = enabled_default + self._icon = icon + self._name = name + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._enabled_default + + +class MusicCastDeviceEntity(MusicCastEntity): + """Defines a MusicCast device entity.""" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this MusicCast device.""" + return DeviceInfo( + connections={ + (CONNECTION_NETWORK_MAC, format_mac(mac)) + for mac in self.coordinator.data.mac_addresses.values() + }, + identifiers={ + ( + DOMAIN, + self.coordinator.data.device_id, + ) + }, + name=self.coordinator.data.network_name, + manufacturer=BRAND, + model=self.coordinator.data.model_name, + sw_version=self.coordinator.data.system_version, + ) diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py new file mode 100644 index 00000000000..06bb212e639 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -0,0 +1,130 @@ +"""Config flow for MusicCast.""" +from __future__ import annotations + +import logging +from urllib.parse import urlparse + +from aiohttp import ClientConnectorError +from aiomusiccast import MusicCastConnectionException, MusicCastDevice +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components import ssdp +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a MusicCast config flow.""" + + VERSION = 1 + + serial_number: str | None = None + host: str + + async def async_step_user( + self, user_input: ConfigType | None = None + ) -> data_entry_flow.FlowResult: + """Handle a flow initiated by the user.""" + # Request user input, unless we are preparing discovery flow + if user_input is None: + return self._show_setup_form() + + host = user_input[CONF_HOST] + serial_number = None + + errors = {} + # Check if device is a MusicCast device + + try: + info = await MusicCastDevice.get_device_info( + host, async_get_clientsession(self.hass) + ) + except (MusicCastConnectionException, ClientConnectorError): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + serial_number = info.get("system_id") + if serial_number is None: + errors["base"] = "no_musiccast_device" + + if not errors: + await self.async_set_unique_id(serial_number, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=host, + data={ + CONF_HOST: host, + "serial": serial_number, + }, + ) + + return self._show_setup_form(errors) + + def _show_setup_form( + self, errors: dict | None = None + ) -> data_entry_flow.FlowResult: + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors or {}, + ) + + async def async_step_ssdp(self, discovery_info) -> data_entry_flow.FlowResult: + """Handle ssdp discoveries.""" + if not await MusicCastDevice.check_yamaha_ssdp( + discovery_info[ssdp.ATTR_SSDP_LOCATION], async_get_clientsession(self.hass) + ): + return self.async_abort(reason="yxc_control_url_missing") + + self.serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL] + self.host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname + await self.async_set_unique_id(self.serial_number) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + self.context.update( + { + "title_placeholders": { + "name": discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host) + } + } + ) + + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None) -> data_entry_flow.FlowResult: + """Allow the user to confirm adding the device.""" + if user_input is not None: + return self.async_create_entry( + title=self.host, + data={ + CONF_HOST: self.host, + "serial": self.serial_number, + }, + ) + + return self.async_show_form(step_id="confirm") + + async def async_step_import(self, import_data: dict) -> data_entry_flow.FlowResult: + """Import data from configuration.yaml into the config flow.""" + res = await self.async_step_user(import_data) + if res["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + _LOGGER.info( + "Successfully imported %s from configuration.yaml", + import_data.get(CONF_HOST), + ) + elif res["type"] == data_entry_flow.RESULT_TYPE_FORM: + _LOGGER.error( + "Could not import %s from configuration.yaml", + import_data.get(CONF_HOST), + ) + return res diff --git a/homeassistant/components/yamaha_musiccast/const.py b/homeassistant/components/yamaha_musiccast/const.py new file mode 100644 index 00000000000..422f93e1562 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/const.py @@ -0,0 +1,31 @@ +"""Constants for the MusicCast integration.""" +from homeassistant.components.media_player.const import ( + REPEAT_MODE_ALL, + REPEAT_MODE_OFF, + REPEAT_MODE_ONE, +) + +DOMAIN = "yamaha_musiccast" + +BRAND = "Yamaha Corporation" + +# Attributes +ATTR_IDENTIFIERS = "identifiers" +ATTR_MANUFACTURER = "manufacturer" +ATTR_MODEL = "model" +ATTR_PLAYLIST = "playlist" +ATTR_PRESET = "preset" +ATTR_SOFTWARE_VERSION = "sw_version" + +DEFAULT_ZONE = "main" +HA_REPEAT_MODE_TO_MC_MAPPING = { + REPEAT_MODE_OFF: "off", + REPEAT_MODE_ONE: "one", + REPEAT_MODE_ALL: "all", +} + +INTERVAL_SECONDS = "interval_seconds" + +MC_REPEAT_MODE_TO_HA_MAPPING = { + val: key for key, val in HA_REPEAT_MODE_TO_MC_MAPPING.items() +} diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 4a0294f444c..1ff4e2efdf4 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -1,8 +1,19 @@ { "domain": "yamaha_musiccast", - "name": "Yamaha MusicCast", + "name": "MusicCast", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", - "requirements": ["pymusiccast==0.1.6"], - "codeowners": ["@jalmeroth"], - "iot_class": "local_polling" -} + "requirements": [ + "aiomusiccast==0.6" + ], + "ssdp": [ + { + "manufacturer": "Yamaha Corporation" + } + ], + "iot_class": "local_push", + "codeowners": [ + "@vigonotion", + "@micha91" + ] +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index b21f6d3a3f4..aab2c8df3d2 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -1,216 +1,402 @@ -"""Support for Yamaha MusicCast Receivers.""" -import logging -import socket +"""Implementation of the musiccast media player.""" +from __future__ import annotations + +import logging -import pymusiccast import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( - MEDIA_TYPE_MUSIC, + REPEAT_MODE_OFF, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, + SUPPORT_REPEAT_SET, + SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOURCE, + SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, STATE_IDLE, - STATE_ON, + STATE_OFF, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN, ) +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType, HomeAssistantType + +from . import MusicCastDataUpdateCoordinator, MusicCastDeviceEntity +from .const import ( + DEFAULT_ZONE, + DOMAIN, + HA_REPEAT_MODE_TO_MC_MAPPING, + INTERVAL_SECONDS, + MC_REPEAT_MODE_TO_HA_MAPPING, +) _LOGGER = logging.getLogger(__name__) -SUPPORTED_FEATURES = ( - SUPPORT_PLAY - | SUPPORT_PAUSE - | SUPPORT_STOP - | SUPPORT_PREVIOUS_TRACK - | SUPPORT_NEXT_TRACK - | SUPPORT_TURN_ON - | SUPPORT_TURN_OFF +MUSIC_PLAYER_SUPPORT = ( + SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_CLEAR_PLAYLIST + | SUPPORT_PLAY + | SUPPORT_SHUFFLE_SET + | SUPPORT_REPEAT_SET + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_SELECT_SOUND_MODE | SUPPORT_SELECT_SOURCE + | SUPPORT_STOP ) -KNOWN_HOSTS_KEY = "data_yamaha_musiccast" -INTERVAL_SECONDS = "interval_seconds" - -DEFAULT_PORT = 5005 -DEFAULT_INTERVAL = 480 - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(INTERVAL_SECONDS, default=DEFAULT_INTERVAL): cv.positive_int, + vol.Optional(CONF_PORT, default=5000): cv.port, + vol.Optional(INTERVAL_SECONDS, default=0): cv.positive_int, } ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Yamaha MusicCast platform.""" +async def async_setup_platform( + hass: HomeAssistantType, + config, + async_add_devices: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Import legacy configurations.""" - known_hosts = hass.data.get(KNOWN_HOSTS_KEY) - if known_hosts is None: - known_hosts = hass.data[KNOWN_HOSTS_KEY] = [] - _LOGGER.debug("known_hosts: %s", known_hosts) - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - interval = config.get(INTERVAL_SECONDS) - - # Get IP of host to prevent duplicates - try: - ipaddr = socket.gethostbyname(host) - except (OSError) as error: - _LOGGER.error("Could not communicate with %s:%d: %s", host, port, error) - return - - if [item for item in known_hosts if item[0] == ipaddr]: - _LOGGER.warning("Host %s:%d already registered", host, port) - return - - if [item for item in known_hosts if item[1] == port]: - _LOGGER.warning("Port %s:%d already registered", host, port) - return - - reg_host = (ipaddr, port) - known_hosts.append(reg_host) - - try: - receiver = pymusiccast.McDevice(ipaddr, udp_port=port, mc_interval=interval) - except pymusiccast.exceptions.YMCInitError as err: - _LOGGER.error(err) - receiver = None - - if receiver: - for zone in receiver.zones: - _LOGGER.debug("Receiver: %s / Port: %d / Zone: %s", receiver, port, zone) - add_entities([YamahaDevice(receiver, receiver.zones[zone])], True) + if hass.config_entries.async_entries(DOMAIN) and config[CONF_HOST] not in [ + entry.data[CONF_HOST] for entry in hass.config_entries.async_entries(DOMAIN) + ]: + _LOGGER.error( + "Configuration in configuration.yaml is not supported anymore. " + "Please add this device using the config flow: %s", + config[CONF_HOST], + ) else: - known_hosts.remove(reg_host) + _LOGGER.warning( + "Configuration in configuration.yaml is deprecated. Use the config flow instead" + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) -class YamahaDevice(MediaPlayerEntity): - """Representation of a Yamaha MusicCast device.""" +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MusicCast sensor based on a config entry.""" + coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - def __init__(self, recv, zone): - """Initialize the Yamaha MusicCast device.""" - self._recv = recv - self._name = recv.name - self._source = None - self._source_list = [] - self._zone = zone - self.mute = False - self.media_status = None - self.media_status_received = None - self.power = STATE_UNKNOWN - self.status = STATE_UNKNOWN - self.volume = 0 - self.volume_max = 0 - self._recv.set_yamaha_device(self) - self._zone.set_yamaha_device(self) + name = coordinator.data.network_name + + media_players: list[Entity] = [] + + for zone in coordinator.data.zones: + zone_name = name if zone == DEFAULT_ZONE else f"{name} {zone}" + + media_players.append( + MusicCastMediaPlayer(zone, zone_name, entry.entry_id, coordinator) + ) + + async_add_entities(media_players) + + +class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): + """The musiccast media player.""" + + def __init__(self, zone_id, name, entry_id, coordinator): + """Initialize the musiccast device.""" + self._player_state = STATE_PLAYING + self._volume_muted = False + self._shuffle = False + self._zone_id = zone_id + + super().__init__( + name=name, + icon="mdi:speaker", + coordinator=coordinator, + ) + + self._volume_min = self.coordinator.data.zones[self._zone_id].min_volume + self._volume_max = self.coordinator.data.zones[self._zone_id].max_volume + + self._cur_track = 0 + self._repeat = REPEAT_MODE_OFF + + async def async_added_to_hass(self): + """Run when this Entity has been added to HA.""" + await super().async_added_to_hass() + # Sensors should also register callbacks to HA when their state changes + self.coordinator.musiccast.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """Entity being removed from hass.""" + await super().async_will_remove_from_hass() + # The opposite of async_added_to_hass. Remove any registered call backs here. + self.coordinator.musiccast.remove_callback(self.async_write_ha_state) @property - def name(self): - """Return the name of the device.""" - return f"{self._name} ({self._zone.zone_id})" + def should_poll(self): + """Push an update after each command.""" + return False + + @property + def _is_netusb(self): + return ( + self.coordinator.data.netusb_input + == self.coordinator.data.zones[self._zone_id].input + ) + + @property + def _is_tuner(self): + return self.coordinator.data.zones[self._zone_id].input == "tuner" @property def state(self): - """Return the state of the device.""" - if self.power == STATE_ON and self.status != STATE_UNKNOWN: - return self.status - return self.power - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self.mute + """Return the state of the player.""" + if self.coordinator.data.zones[self._zone_id].power == "on": + if self._is_netusb and self.coordinator.data.netusb_playback == "pause": + return STATE_PAUSED + if self._is_netusb and self.coordinator.data.netusb_playback == "stop": + return STATE_IDLE + return STATE_PLAYING + return STATE_OFF @property def volume_level(self): - """Volume level of the media player (0..1).""" - return self.volume + """Return the volume level of the media player (0..1).""" + volume = self.coordinator.data.zones[self._zone_id].current_volume + return (volume - self._volume_min) / (self._volume_max - self._volume_min) + + @property + def is_volume_muted(self): + """Return boolean if volume is currently muted.""" + return self.coordinator.data.zones[self._zone_id].mute + + @property + def shuffle(self): + """Boolean if shuffling is enabled.""" + return ( + self.coordinator.data.netusb_shuffle == "on" if self._is_netusb else False + ) + + @property + def sound_mode(self): + """Return the current sound mode.""" + return self.coordinator.data.zones[self._zone_id].sound_program + + @property + def sound_mode_list(self): + """Return a list of available sound modes.""" + return self.coordinator.data.zones[self._zone_id].sound_program_list + + @property + def zone(self): + """Return the zone of the media player.""" + return self._zone_id + + @property + def unique_id(self) -> str: + """Return the unique ID for this media_player.""" + return f"{self.coordinator.data.device_id}_{self._zone_id}" + + async def async_turn_on(self): + """Turn the media player on.""" + await self.coordinator.musiccast.turn_on(self._zone_id) + self.async_write_ha_state() + + async def async_turn_off(self): + """Turn the media player off.""" + await self.coordinator.musiccast.turn_off(self._zone_id) + self.async_write_ha_state() + + async def async_mute_volume(self, mute): + """Mute the volume.""" + + await self.coordinator.musiccast.mute_volume(self._zone_id, mute) + self.async_write_ha_state() + + async def async_set_volume_level(self, volume): + """Set the volume level, range 0..1.""" + await self.coordinator.musiccast.set_volume_level(self._zone_id, volume) + self.async_write_ha_state() + + async def async_media_play(self): + """Send play command.""" + if self._is_netusb: + await self.coordinator.musiccast.netusb_play() + else: + raise HomeAssistantError( + "Service play is not supported for non NetUSB sources." + ) + + async def async_media_pause(self): + """Send pause command.""" + if self._is_netusb: + await self.coordinator.musiccast.netusb_pause() + else: + raise HomeAssistantError( + "Service pause is not supported for non NetUSB sources." + ) + + async def async_media_stop(self): + """Send stop command.""" + if self._is_netusb: + await self.coordinator.musiccast.netusb_pause() + else: + raise HomeAssistantError( + "Service stop is not supported for non NetUSB sources." + ) + + async def async_set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + if self._is_netusb: + await self.coordinator.musiccast.netusb_shuffle(shuffle) + else: + raise HomeAssistantError( + "Service shuffle is not supported for non NetUSB sources." + ) + + async def async_select_sound_mode(self, sound_mode): + """Select sound mode.""" + await self.coordinator.musiccast.select_sound_mode(self._zone_id, sound_mode) + + @property + def media_image_url(self): + """Return the image url of current playing media.""" + return self.coordinator.musiccast.media_image_url if self._is_netusb else None + + @property + def media_title(self): + """Return the title of current playing media.""" + if self._is_netusb: + return self.coordinator.data.netusb_track + if self._is_tuner: + return self.coordinator.musiccast.tuner_media_title + + return None + + @property + def media_artist(self): + """Return the artist of current playing media (Music track only).""" + if self._is_netusb: + return self.coordinator.data.netusb_artist + if self._is_tuner: + return self.coordinator.musiccast.tuner_media_artist + + return None + + @property + def media_album_name(self): + """Return the album of current playing media (Music track only).""" + return self.coordinator.data.netusb_album if self._is_netusb else None + + @property + def repeat(self): + """Return current repeat mode.""" + return ( + MC_REPEAT_MODE_TO_HA_MAPPING.get(self.coordinator.data.netusb_repeat) + if self._is_netusb + else REPEAT_MODE_OFF + ) @property def supported_features(self): - """Flag of features that are supported.""" - return SUPPORTED_FEATURES + """Flag media player features that are supported.""" + return MUSIC_PLAYER_SUPPORT + + async def async_media_previous_track(self): + """Send previous track command.""" + if self._is_netusb: + await self.coordinator.musiccast.netusb_previous_track() + elif self._is_tuner: + await self.coordinator.musiccast.tuner_previous_station() + else: + raise HomeAssistantError( + "Service previous track is not supported for non NetUSB or Tuner sources." + ) + + async def async_media_next_track(self): + """Send next track command.""" + if self._is_netusb: + await self.coordinator.musiccast.netusb_next_track() + elif self._is_tuner: + await self.coordinator.musiccast.tuner_next_station() + else: + raise HomeAssistantError( + "Service next track is not supported for non NetUSB or Tuner sources." + ) + + def clear_playlist(self): + """Clear players playlist.""" + self._cur_track = 0 + self._player_state = STATE_OFF + self.async_write_ha_state() + + async def async_set_repeat(self, repeat): + """Enable/disable repeat mode.""" + if self._is_netusb: + await self.coordinator.musiccast.netusb_repeat( + HA_REPEAT_MODE_TO_MC_MAPPING.get(repeat, "off") + ) + else: + raise HomeAssistantError( + "Service set repeat is not supported for non NetUSB sources." + ) + + async def async_select_source(self, source): + """Select input source.""" + await self.coordinator.musiccast.select_source(self._zone_id, source) @property def source(self): - """Return the current input source.""" - return self._source + """Name of the current input source.""" + return self.coordinator.data.zones[self._zone_id].input @property def source_list(self): """List of available input sources.""" - return self._source_list - - @source_list.setter - def source_list(self, value): - """Set source_list attribute.""" - self._source_list = value - - @property - def media_content_type(self): - """Return the media content type.""" - return MEDIA_TYPE_MUSIC + return self.coordinator.data.zones[self._zone_id].input_list @property def media_duration(self): """Duration of current playing media in seconds.""" - return self.media_status.media_duration if self.media_status else None + if self._is_netusb: + return self.coordinator.data.netusb_total_time - @property - def media_image_url(self): - """Image url of current playing media.""" - return self.media_status.media_image_url if self.media_status else None - - @property - def media_artist(self): - """Artist of current playing media, music track only.""" - return self.media_status.media_artist if self.media_status else None - - @property - def media_album(self): - """Album of current playing media, music track only.""" - return self.media_status.media_album if self.media_status else None - - @property - def media_track(self): - """Track number of current playing media, music track only.""" - return self.media_status.media_track if self.media_status else None - - @property - def media_title(self): - """Title of current playing media.""" - return self.media_status.media_title if self.media_status else None + return None @property def media_position(self): """Position of current playing media in seconds.""" - if self.media_status and self.state in [ - STATE_PLAYING, - STATE_PAUSED, - STATE_IDLE, - ]: - return self.media_status.media_position + if self._is_netusb: + return self.coordinator.data.netusb_play_time + + return None @property def media_position_updated_at(self): @@ -218,74 +404,7 @@ class YamahaDevice(MediaPlayerEntity): Returns value from homeassistant.util.dt.utcnow(). """ - return self.media_status_received if self.media_status else None + if self._is_netusb: + return self.coordinator.data.netusb_play_time_updated - def update(self): - """Get the latest details from the device.""" - _LOGGER.debug("update: %s", self.entity_id) - self._recv.update_status() - self._zone.update_status() - - def update_hass(self): - """Push updates to Home Assistant.""" - if self.entity_id: - _LOGGER.debug("update_hass: pushing updates") - self.schedule_update_ha_state() - return True - - def turn_on(self): - """Turn on specified media player or all.""" - _LOGGER.debug("Turn device: on") - self._zone.set_power(True) - - def turn_off(self): - """Turn off specified media player or all.""" - _LOGGER.debug("Turn device: off") - self._zone.set_power(False) - - def media_play(self): - """Send the media player the command for play/pause.""" - _LOGGER.debug("Play") - self._recv.set_playback("play") - - def media_pause(self): - """Send the media player the command for pause.""" - _LOGGER.debug("Pause") - self._recv.set_playback("pause") - - def media_stop(self): - """Send the media player the stop command.""" - _LOGGER.debug("Stop") - self._recv.set_playback("stop") - - def media_previous_track(self): - """Send the media player the command for prev track.""" - _LOGGER.debug("Previous") - self._recv.set_playback("previous") - - def media_next_track(self): - """Send the media player the command for next track.""" - _LOGGER.debug("Next") - self._recv.set_playback("next") - - def mute_volume(self, mute): - """Send mute command.""" - _LOGGER.debug("Mute volume: %s", mute) - self._zone.set_mute(mute) - - def set_volume_level(self, volume): - """Set volume level, range 0..1.""" - _LOGGER.debug("Volume level: %.2f / %d", volume, volume * self.volume_max) - self._zone.set_volume(volume * self.volume_max) - - def select_source(self, source): - """Send the media player the command to select input source.""" - _LOGGER.debug("select_source: %s", source) - self.status = STATE_UNKNOWN - self._zone.set_input(source) - - def new_media_status(self, status): - """Handle updates of the media status.""" - _LOGGER.debug("new media_status arrived") - self.media_status = status - self.media_status_received = dt_util.utcnow() + return None diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json new file mode 100644 index 00000000000..e2261882222 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "flow_title": "MusicCast: {name}", + "step": { + "user": { + "description": "Set up MusicCast to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "yxc_control_url_missing": "The control URL is not given in the ssdp description." + }, + "error": { + "no_musiccast_device": "This device seems to be no MusicCast Device." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/en.json b/homeassistant/components/yamaha_musiccast/translations/en.json new file mode 100644 index 00000000000..4c7f3b45f0b --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "yxc_control_url_missing": "The control URL is not given in the ssdp description." + }, + "error": { + "no_musiccast_device": "This device seems to be no MusicCast Device." + }, + "flow_title": "MusicCast: {name}", + "step": { + "confirm": { + "description": "Do you want to start set up?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Set up MusicCast to integrate with Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 442d6e9be08..6504de85199 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -289,6 +289,7 @@ FLOWS = [ "xbox", "xiaomi_aqara", "xiaomi_miio", + "yamaha_musiccast", "yeelight", "zerproc", "zha", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 0f6c01a0605..1638d932e89 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -215,5 +215,10 @@ SSDP = { { "manufacturer": "All Automacao Ltda" } + ], + "yamaha_musiccast": [ + { + "manufacturer": "Yamaha Corporation" + } ] } diff --git a/requirements_all.txt b/requirements_all.txt index cec3e65f1f5..c1f622049da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -208,6 +208,9 @@ aiolyric==1.0.7 # homeassistant.components.modern_forms aiomodernforms==0.1.5 +# homeassistant.components.yamaha_musiccast +aiomusiccast==0.6 + # homeassistant.components.keyboard_remote aionotify==0.2.0 @@ -1585,9 +1588,6 @@ pymonoprice==0.3 # homeassistant.components.msteams pymsteams==0.1.12 -# homeassistant.components.yamaha_musiccast -pymusiccast==0.1.6 - # homeassistant.components.myq pymyq==3.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index adf047b3a4a..9704e3520e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -133,6 +133,9 @@ aiolyric==1.0.7 # homeassistant.components.modern_forms aiomodernforms==0.1.5 +# homeassistant.components.yamaha_musiccast +aiomusiccast==0.6 + # homeassistant.components.notion aionotion==1.1.0 diff --git a/tests/components/yamaha_musiccast/__init__.py b/tests/components/yamaha_musiccast/__init__.py new file mode 100644 index 00000000000..d5b10774c10 --- /dev/null +++ b/tests/components/yamaha_musiccast/__init__.py @@ -0,0 +1 @@ +"""Tests for the MusicCast integration.""" diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py new file mode 100644 index 00000000000..6f5709ec7cc --- /dev/null +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -0,0 +1,287 @@ +"""Test config flow.""" + +from unittest.mock import patch + +from aiomusiccast import MusicCastConnectionException +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import ssdp +from homeassistant.components.yamaha_musiccast.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.yamaha_musiccast.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_get_device_info_valid(): + """Mock getting valid device info from musiccast API.""" + with patch( + "aiomusiccast.MusicCastDevice.get_device_info", + return_value={"system_id": "1234567890", "model_name": "MC20"}, + ): + yield + + +@pytest.fixture +def mock_get_device_info_invalid(): + """Mock getting invalid device info from musiccast API.""" + with patch( + "aiomusiccast.MusicCastDevice.get_device_info", + return_value={"type": "no_yamaha"}, + ): + yield + + +@pytest.fixture +def mock_get_device_info_exception(): + """Mock raising an unexpected Exception.""" + with patch( + "aiomusiccast.MusicCastDevice.get_device_info", + side_effect=Exception("mocked error"), + ): + yield + + +@pytest.fixture +def mock_get_device_info_mc_exception(): + """Mock raising an unexpected Exception.""" + with patch( + "aiomusiccast.MusicCastDevice.get_device_info", + side_effect=MusicCastConnectionException("mocked error"), + ): + yield + + +@pytest.fixture +def mock_ssdp_yamaha(): + """Mock that the SSDP detected device is a musiccast device.""" + with patch("aiomusiccast.MusicCastDevice.check_yamaha_ssdp", return_value=True): + yield + + +@pytest.fixture +def mock_ssdp_no_yamaha(): + """Mock that the SSDP detected device is not a musiccast device.""" + with patch("aiomusiccast.MusicCastDevice.check_yamaha_ssdp", return_value=False): + yield + + +# User Flows + + +async def test_user_input_device_not_found(hass, mock_get_device_info_mc_exception): + """Test when user specifies a non-existing device.""" + 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": "none"}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_user_input_non_yamaha_device_found(hass, mock_get_device_info_invalid): + """Test when user specifies an existing device, which does not provide the musiccast API.""" + 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_FORM + assert result2["errors"] == {"base": "no_musiccast_device"} + + +async def test_user_input_device_already_existing(hass, mock_get_device_info_valid): + """Test when user specifies an existing device.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1234567890", + data={CONF_HOST: "192.168.188.18", "model": "MC20", "serial": "1234567890"}, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "192.168.188.18"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_user_input_unknown_error(hass, mock_get_device_info_exception): + """Test when user specifies an existing device, which does not provide the musiccast API.""" + 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_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_user_input_device_found(hass, mock_get_device_info_valid): + """Test when user specifies an existing device.""" + 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", + } + + +async def test_import_device_already_existing(hass, mock_get_device_info_valid): + """Test when the configurations.yaml contains an existing device.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1234567890", + data={CONF_HOST: "192.168.188.18", "model": "MC20", "serial": "1234567890"}, + ) + mock_entry.add_to_hass(hass) + + config = {"platform": "yamaha_musiccast", "host": "192.168.188.18", "port": 5006} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_import_error(hass, mock_get_device_info_exception): + """Test when in the configuration.yaml a device is configured, which cannot be added..""" + config = {"platform": "yamaha_musiccast", "host": "192.168.188.18", "port": 5006} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_import_device_successful(hass, mock_get_device_info_valid): + """Test when the device was imported successfully.""" + config = {"platform": "yamaha_musiccast", "host": "127.0.0.1", "port": 5006} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert isinstance(result["result"], ConfigEntry) + assert result["data"] == { + "host": "127.0.0.1", + "serial": "1234567890", + } + + +# SSDP Flows + + +async def test_ssdp_discovery_failed(hass, mock_ssdp_no_yamaha): + """Test when an SSDP discovered device is not a musiccast device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: "http://127.0.0.1/desc.xml", + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + ssdp.ATTR_UPNP_SERIAL: "123456789", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "yxc_control_url_missing" + + +async def test_ssdp_discovery_successful_add_device(hass, mock_ssdp_yamaha): + """Test when the SSDP discovered device is a musiccast device and the user confirms it.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: "http://127.0.0.1/desc.xml", + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + ssdp.ATTR_UPNP_SERIAL: "1234567890", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + 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", + } + + +async def test_ssdp_discovery_existing_device_update(hass, mock_ssdp_yamaha): + """Test when the SSDP discovered device is a musiccast device, but it already exists with another IP.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1234567890", + data={CONF_HOST: "192.168.188.18", "model": "MC20", "serial": "1234567890"}, + ) + mock_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: "http://127.0.0.1/desc.xml", + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + ssdp.ATTR_UPNP_SERIAL: "1234567890", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert mock_entry.data[CONF_HOST] == "127.0.0.1"