Add soundtouch attributes exposing multiroom zone info (#28298)

* [soundtouch] workaround for API bug when removing multiple slaves from a zone at once

* [soundtouch] added additional attributes exposing multiroom zone info

* Fix update with slave entities

* Add zone attributes test

* Fix and clean up tests

* Fix typo

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/32489/head
Paulus Schoutsen 2020-03-04 17:53:15 -08:00 committed by GitHub
parent 2316f7ace4
commit b848c97211
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 194 additions and 67 deletions

View File

@ -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

View File

@ -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",
]