diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 71592e92c17..1d82c38d088 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -22,11 +22,13 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, + EVENT_HOMEASSISTANT_START, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from .const import ( @@ -47,6 +49,8 @@ MAP_STATUS = { } DATA_SOUNDTOUCH = "soundtouch" +ATTR_SOUNDTOUCH_GROUP = "soundtouch_group" +ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone" SOUNDTOUCH_PLAY_EVERYWHERE = vol.Schema({vol.Required("master"): cv.entity_id}) @@ -103,7 +107,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): remote_config = {"id": "ha.component.soundtouch", "host": host, "port": port} bose_soundtouch_entity = SoundTouchDevice(None, remote_config) hass.data[DATA_SOUNDTOUCH].append(bose_soundtouch_entity) - add_entities([bose_soundtouch_entity]) + add_entities([bose_soundtouch_entity], True) else: name = config.get(CONF_NAME) remote_config = { @@ -113,7 +117,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): } bose_soundtouch_entity = SoundTouchDevice(name, remote_config) hass.data[DATA_SOUNDTOUCH].append(bose_soundtouch_entity) - add_entities([bose_soundtouch_entity]) + add_entities([bose_soundtouch_entity], True) def service_handle(service): """Handle the applying of a service.""" @@ -191,9 +195,10 @@ class SoundTouchDevice(MediaPlayerDevice): self._name = self._device.config.name else: self._name = name - self._status = self._device.status() - self._volume = self._device.volume() + self._status = None + self._volume = None self._config = config + self._zone = None @property def config(self): @@ -209,6 +214,7 @@ class SoundTouchDevice(MediaPlayerDevice): """Retrieve the latest data.""" self._status = self._device.status() self._volume = self._device.volume() + self._zone = self.get_zone_info() @property def volume_level(self): @@ -317,6 +323,18 @@ class SoundTouchDevice(MediaPlayerDevice): """Album name of current playing media.""" return self._status.album + async def async_added_to_hass(self): + """Populate zone info which requires entity_id.""" + + @callback + def async_update_on_start(event): + """Schedule an update when all platform entities have been added.""" + self.async_schedule_update_ha_state(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_update_on_start + ) + def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" _LOGGER.debug("Starting media with media_id: %s", media_id) @@ -369,7 +387,13 @@ class SoundTouchDevice(MediaPlayerDevice): _LOGGER.info( "Removing slaves from zone with master %s", self._device.config.name ) - self._device.remove_zone_slave([slave.device for slave in slaves]) + # SoundTouch API seems to have a bug and won't remove slaves if there are + # more than one in the payload. Therefore we have to loop over all slaves + # and remove them individually + for slave in slaves: + # make sure to not try to remove the master (aka current device) + if slave.entity_id != self.entity_id: + self._device.remove_zone_slave([slave.device]) def add_zone_slave(self, slaves): """ @@ -387,3 +411,62 @@ class SoundTouchDevice(MediaPlayerDevice): "Adding slaves to zone with master %s", self._device.config.name ) self._device.add_zone_slave([slave.device for slave in slaves]) + + @property + def device_state_attributes(self): + """Return entity specific state attributes.""" + attributes = {} + + if self._zone and "master" in self._zone: + attributes[ATTR_SOUNDTOUCH_ZONE] = self._zone + # Compatibility with how other components expose their groups (like SONOS). + # First entry is the master, others are slaves + group_members = [self._zone["master"]] + self._zone["slaves"] + attributes[ATTR_SOUNDTOUCH_GROUP] = group_members + + return attributes + + def get_zone_info(self): + """Return the current zone info.""" + zone_status = self._device.zone_status() + if not zone_status: + return None + + # Due to a bug in the SoundTouch API itself client devices do NOT return their + # siblings as part of the "slaves" list. Only the master has the full list of + # slaves for some reason. To compensate for this shortcoming we have to fetch + # the zone info from the master when the current device is a slave until this is + # fixed in the SoundTouch API or libsoundtouch, or of course until somebody has a + # better idea on how to fix this + if zone_status.is_master: + return self._build_zone_info(self.entity_id, zone_status.slaves) + + master_instance = self._get_instance_by_ip(zone_status.master_ip) + master_zone_status = master_instance.device.zone_status() + return self._build_zone_info( + master_instance.entity_id, master_zone_status.slaves + ) + + def _get_instance_by_ip(self, ip_address): + """Search and return a SoundTouchDevice instance by it's IP address.""" + for instance in self.hass.data[DATA_SOUNDTOUCH]: + if instance and instance.config["host"] == ip_address: + return instance + return None + + def _build_zone_info(self, master, zone_slaves): + """Build the exposed zone attributes.""" + slaves = [] + + for slave in zone_slaves: + slave_instance = self._get_instance_by_ip(slave.device_ip) + if slave_instance: + slaves.append(slave_instance.entity_id) + + attributes = { + "master": master, + "is_master": master == self.entity_id, + "slaves": slaves, + } + + return attributes diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py index b18f9efda97..e69cec12ba3 100644 --- a/tests/components/soundtouch/test_media_player.py +++ b/tests/components/soundtouch/test_media_player.py @@ -19,7 +19,11 @@ from homeassistant.components.media_player.const import ( ) from homeassistant.components.soundtouch import media_player as soundtouch from homeassistant.components.soundtouch.const import DOMAIN -from homeassistant.components.soundtouch.media_player import DATA_SOUNDTOUCH +from homeassistant.components.soundtouch.media_player import ( + ATTR_SOUNDTOUCH_GROUP, + ATTR_SOUNDTOUCH_ZONE, + DATA_SOUNDTOUCH, +) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING from homeassistant.helpers.discovery import async_load_platform from homeassistant.setup import async_setup_component @@ -154,9 +158,9 @@ def _mocked_presets(*args, **kwargs): class MockPreset(Preset): """Mock preset.""" - def __init__(self, id): + def __init__(self, id_): """Init the class.""" - self._id = id + self._id = id_ self._name = "preset" @@ -318,8 +322,8 @@ async def test_playing_media(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.state == STATE_PLAYING @@ -336,8 +340,8 @@ async def test_playing_unknown_media(mocked_status, mocked_volume, hass, one_dev await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.state == STATE_PLAYING @@ -349,8 +353,8 @@ async def test_playing_radio(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.state == STATE_PLAYING @@ -363,8 +367,8 @@ async def test_get_volume_level(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.attributes["volume_level"] == 0.12 @@ -376,8 +380,8 @@ async def test_get_state_off(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.state == STATE_OFF @@ -389,8 +393,8 @@ async def test_get_state_pause(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.state == STATE_PAUSED @@ -402,8 +406,8 @@ async def test_is_muted(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.attributes["is_volume_muted"] @@ -414,8 +418,8 @@ async def test_media_commands(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.attributes["supported_features"] == 18365 @@ -429,13 +433,13 @@ async def test_should_turn_off( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "turn_off", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_power_off.call_count == 1 @@ -448,13 +452,13 @@ async def test_should_turn_on( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "turn_on", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_power_on.call_count == 1 @@ -466,13 +470,13 @@ async def test_volume_up( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "volume_up", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_volume.call_count == 2 + assert mocked_volume.call_count == 3 assert mocked_volume_up.call_count == 1 @@ -484,13 +488,13 @@ async def test_volume_down( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "volume_down", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_volume.call_count == 2 + assert mocked_volume.call_count == 3 assert mocked_volume_down.call_count == 1 @@ -502,8 +506,8 @@ async def test_set_volume_level( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -511,7 +515,7 @@ async def test_set_volume_level( {"entity_id": "media_player.soundtouch_1", "volume_level": 0.17}, True, ) - assert mocked_volume.call_count == 2 + assert mocked_volume.call_count == 3 mocked_set_volume.assert_called_with(17) @@ -521,8 +525,8 @@ async def test_mute(mocked_mute, mocked_status, mocked_volume, hass, one_device) await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -530,7 +534,7 @@ async def test_mute(mocked_mute, mocked_status, mocked_volume, hass, one_device) {"entity_id": "media_player.soundtouch_1", "is_volume_muted": True}, True, ) - assert mocked_volume.call_count == 2 + assert mocked_volume.call_count == 3 assert mocked_mute.call_count == 1 @@ -540,13 +544,13 @@ async def test_play(mocked_play, mocked_status, mocked_volume, hass, one_device) await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "media_play", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_play.call_count == 1 @@ -556,13 +560,13 @@ async def test_pause(mocked_pause, mocked_status, mocked_volume, hass, one_devic await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "media_pause", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_pause.call_count == 1 @@ -574,8 +578,8 @@ async def test_play_pause( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -583,7 +587,7 @@ async def test_play_pause( {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_play_pause.call_count == 1 @@ -601,8 +605,8 @@ async def test_next_previous_track( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -610,7 +614,7 @@ async def test_next_previous_track( {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_next_track.call_count == 1 await hass.services.async_call( @@ -619,7 +623,7 @@ async def test_next_previous_track( {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 3 + assert mocked_status.call_count == 4 assert mocked_previous_track.call_count == 1 @@ -632,8 +636,8 @@ async def test_play_media( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -670,8 +674,8 @@ async def test_play_media_url( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -695,8 +699,8 @@ async def test_play_everywhere( await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) assert mocked_device.call_count == 2 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + assert mocked_status.call_count == 4 + assert mocked_volume.call_count == 4 # one master, one slave => create zone await hass.services.async_call( @@ -740,8 +744,8 @@ async def test_create_zone( await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) assert mocked_device.call_count == 2 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + assert mocked_status.call_count == 4 + assert mocked_volume.call_count == 4 # one master, one slave => create zone await hass.services.async_call( @@ -783,8 +787,8 @@ async def test_remove_zone_slave( await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) assert mocked_device.call_count == 2 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + assert mocked_status.call_count == 4 + assert mocked_volume.call_count == 4 # remove one slave await hass.services.async_call( @@ -826,8 +830,8 @@ async def test_add_zone_slave( await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) assert mocked_device.call_count == 2 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + assert mocked_status.call_count == 4 + assert mocked_volume.call_count == 4 # add one slave await hass.services.async_call( @@ -858,3 +862,43 @@ async def test_add_zone_slave( True, ) assert mocked_add_zone_slave.call_count == 1 + + +@patch("libsoundtouch.device.SoundTouchDevice.create_zone") +async def test_zone_attributes( + mocked_create_zone, mocked_status, mocked_volume, hass, two_zones, +): + """Test play everywhere.""" + mocked_device = two_zones + await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) + + assert mocked_device.call_count == 2 + assert mocked_status.call_count == 4 + assert mocked_volume.call_count == 4 + + entity_1_state = hass.states.get("media_player.soundtouch_1") + assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"] + assert ( + entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] + == "media_player.soundtouch_1" + ) + assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["slaves"] == [ + "media_player.soundtouch_2" + ] + assert entity_1_state.attributes[ATTR_SOUNDTOUCH_GROUP] == [ + "media_player.soundtouch_1", + "media_player.soundtouch_2", + ] + entity_2_state = hass.states.get("media_player.soundtouch_2") + assert not entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"] + assert ( + entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] + == "media_player.soundtouch_1" + ) + assert entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["slaves"] == [ + "media_player.soundtouch_2" + ] + assert entity_2_state.attributes[ATTR_SOUNDTOUCH_GROUP] == [ + "media_player.soundtouch_1", + "media_player.soundtouch_2", + ]