core/homeassistant/components/soundtouch/media_player.py

517 lines
16 KiB
Python
Raw Normal View History

"""Support for interface with a Bose Soundtouch."""
import logging
import re
2018-09-09 12:26:06 +00:00
from libsoundtouch import soundtouch_device
from libsoundtouch.utils import Source
import voluptuous as vol
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
from homeassistant.components.media_player.const import (
2019-07-31 19:25:30 +00:00
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
SUPPORT_PLAY_MEDIA,
SUPPORT_PREVIOUS_TRACK,
SUPPORT_SELECT_SOURCE,
2019-07-31 19:25:30 +00:00
SUPPORT_TURN_OFF,
SUPPORT_TURN_ON,
SUPPORT_VOLUME_MUTE,
SUPPORT_VOLUME_SET,
SUPPORT_VOLUME_STEP,
)
2018-09-09 12:26:06 +00:00
from homeassistant.const import (
2019-07-31 19:25:30 +00:00
CONF_HOST,
CONF_NAME,
CONF_PORT,
EVENT_HOMEASSISTANT_START,
2019-07-31 19:25:30 +00:00
STATE_OFF,
STATE_PAUSED,
STATE_PLAYING,
STATE_UNAVAILABLE,
)
from homeassistant.core import callback
2018-09-09 12:26:06 +00:00
import homeassistant.helpers.config_validation as cv
from .const import (
DOMAIN,
SERVICE_ADD_ZONE_SLAVE,
SERVICE_CREATE_ZONE,
SERVICE_PLAY_EVERYWHERE,
SERVICE_REMOVE_ZONE_SLAVE,
)
_LOGGER = logging.getLogger(__name__)
MAP_STATUS = {
"PLAY_STATE": STATE_PLAYING,
"BUFFERING_STATE": STATE_PLAYING,
"PAUSE_STATE": STATE_PAUSED,
2019-07-31 19:25:30 +00:00
"STOP_STATE": STATE_OFF,
}
DATA_SOUNDTOUCH = "soundtouch"
ATTR_SOUNDTOUCH_GROUP = "soundtouch_group"
ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone"
2019-07-31 19:25:30 +00:00
SOUNDTOUCH_PLAY_EVERYWHERE = vol.Schema({vol.Required("master"): cv.entity_id})
2019-07-31 19:25:30 +00:00
SOUNDTOUCH_CREATE_ZONE_SCHEMA = vol.Schema(
{vol.Required("master"): cv.entity_id, vol.Required("slaves"): cv.entity_ids}
)
2019-07-31 19:25:30 +00:00
SOUNDTOUCH_ADD_ZONE_SCHEMA = vol.Schema(
{vol.Required("master"): cv.entity_id, vol.Required("slaves"): cv.entity_ids}
)
2019-07-31 19:25:30 +00:00
SOUNDTOUCH_REMOVE_ZONE_SCHEMA = vol.Schema(
{vol.Required("master"): cv.entity_id, vol.Required("slaves"): cv.entity_ids}
)
2019-07-31 19:25:30 +00:00
DEFAULT_NAME = "Bose Soundtouch"
DEFAULT_PORT = 8090
2019-07-31 19:25:30 +00:00
SUPPORT_SOUNDTOUCH = (
SUPPORT_PAUSE
| SUPPORT_VOLUME_STEP
| SUPPORT_VOLUME_MUTE
| SUPPORT_PREVIOUS_TRACK
| SUPPORT_NEXT_TRACK
| SUPPORT_TURN_OFF
| SUPPORT_VOLUME_SET
| SUPPORT_TURN_ON
| SUPPORT_PLAY
| SUPPORT_PLAY_MEDIA
| SUPPORT_SELECT_SOURCE
2019-07-31 19:25:30 +00:00
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
}
)
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Bose Soundtouch platform."""
if DATA_SOUNDTOUCH not in hass.data:
hass.data[DATA_SOUNDTOUCH] = []
if discovery_info:
2019-07-31 19:25:30 +00:00
host = discovery_info["host"]
port = int(discovery_info["port"])
# if device already exists by config
2019-07-31 19:25:30 +00:00
if host in [device.config["host"] for device in hass.data[DATA_SOUNDTOUCH]]:
return
2019-07-31 19:25:30 +00:00
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], True)
else:
name = config.get(CONF_NAME)
remote_config = {
2019-07-31 19:25:30 +00:00
"id": "ha.component.soundtouch",
"port": config.get(CONF_PORT),
"host": config.get(CONF_HOST),
}
bose_soundtouch_entity = SoundTouchDevice(name, remote_config)
hass.data[DATA_SOUNDTOUCH].append(bose_soundtouch_entity)
add_entities([bose_soundtouch_entity], True)
def service_handle(service):
"""Handle the applying of a service."""
2019-07-31 19:25:30 +00:00
master_device_id = service.data.get("master")
slaves_ids = service.data.get("slaves")
slaves = []
if slaves_ids:
2019-07-31 19:25:30 +00:00
slaves = [
device
for device in hass.data[DATA_SOUNDTOUCH]
if device.entity_id in slaves_ids
]
master = next(
[
device
for device in hass.data[DATA_SOUNDTOUCH]
if device.entity_id == master_device_id
].__iter__(),
None,
)
if master is None:
2019-07-31 19:25:30 +00:00
_LOGGER.warning(
"Unable to find master with entity_id: %s", str(master_device_id)
)
return
if service.service == SERVICE_PLAY_EVERYWHERE:
2019-07-31 19:25:30 +00:00
slaves = [
d for d in hass.data[DATA_SOUNDTOUCH] if d.entity_id != master_device_id
]
master.create_zone(slaves)
elif service.service == SERVICE_CREATE_ZONE:
master.create_zone(slaves)
elif service.service == SERVICE_REMOVE_ZONE_SLAVE:
master.remove_zone_slave(slaves)
elif service.service == SERVICE_ADD_ZONE_SLAVE:
master.add_zone_slave(slaves)
2019-07-31 19:25:30 +00:00
hass.services.register(
DOMAIN,
SERVICE_PLAY_EVERYWHERE,
service_handle,
schema=SOUNDTOUCH_PLAY_EVERYWHERE,
)
hass.services.register(
DOMAIN,
SERVICE_CREATE_ZONE,
service_handle,
schema=SOUNDTOUCH_CREATE_ZONE_SCHEMA,
)
hass.services.register(
DOMAIN,
SERVICE_REMOVE_ZONE_SLAVE,
service_handle,
schema=SOUNDTOUCH_REMOVE_ZONE_SCHEMA,
)
hass.services.register(
DOMAIN,
SERVICE_ADD_ZONE_SLAVE,
service_handle,
schema=SOUNDTOUCH_ADD_ZONE_SCHEMA,
)
class SoundTouchDevice(MediaPlayerEntity):
"""Representation of a SoundTouch Bose device."""
def __init__(self, name, config):
"""Create Soundtouch Entity."""
2019-07-31 19:25:30 +00:00
self._device = soundtouch_device(config["host"], config["port"])
if name is None:
self._name = self._device.config.name
else:
self._name = name
self._status = None
self._volume = None
self._config = config
self._zone = None
@property
def config(self):
"""Return specific soundtouch configuration."""
return self._config
@property
def device(self):
"""Return Soundtouch device."""
return self._device
def update(self):
"""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):
"""Volume level of the media player (0..1)."""
return self._volume.actual / 100
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def state(self):
"""Return the state of the device."""
2019-07-31 19:25:30 +00:00
if self._status.source == "STANDBY":
return STATE_OFF
return MAP_STATUS.get(self._status.play_status, STATE_UNAVAILABLE)
@property
def source(self):
"""Name of the current input source."""
return self._status.source
@property
def source_list(self):
"""List of available input sources."""
return [
Source.AUX.value,
Source.BLUETOOTH.value,
]
@property
def is_volume_muted(self):
"""Boolean if volume is currently muted."""
return self._volume.muted
@property
def supported_features(self):
"""Flag media player features that are supported."""
return SUPPORT_SOUNDTOUCH
def turn_off(self):
"""Turn off media player."""
self._device.power_off()
def turn_on(self):
"""Turn on media player."""
self._device.power_on()
def volume_up(self):
"""Volume up the media player."""
self._device.volume_up()
def volume_down(self):
"""Volume down media player."""
self._device.volume_down()
def set_volume_level(self, volume):
"""Set volume level, range 0..1."""
self._device.set_volume(int(volume * 100))
def mute_volume(self, mute):
"""Send mute command."""
self._device.mute()
def media_play_pause(self):
"""Simulate play pause media player."""
self._device.play_pause()
def media_play(self):
"""Send play command."""
self._device.play()
def media_pause(self):
"""Send media pause command to media player."""
self._device.pause()
def media_next_track(self):
"""Send next track command."""
self._device.next_track()
def media_previous_track(self):
"""Send the previous track command."""
self._device.previous_track()
@property
def media_image_url(self):
"""Image url of current playing media."""
return self._status.image
@property
def media_title(self):
"""Title of current playing media."""
if self._status.station_name is not None:
return self._status.station_name
if self._status.artist is not None:
return f"{self._status.artist} - {self._status.track}"
return None
@property
def media_duration(self):
"""Duration of current playing media in seconds."""
return self._status.duration
@property
def media_artist(self):
"""Artist of current playing media."""
return self._status.artist
@property
def media_track(self):
"""Artist of current playing media."""
return self._status.track
@property
def media_album_name(self):
"""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)
2019-07-31 19:25:30 +00:00
if re.match(r"http?://", str(media_id)):
# URL
_LOGGER.debug("Playing URL %s", str(media_id))
self._device.play_url(str(media_id))
else:
# Preset
presets = self._device.presets()
2019-07-31 19:25:30 +00:00
preset = next(
[
preset for preset in presets if preset.preset_id == str(media_id)
].__iter__(),
None,
)
if preset is not None:
_LOGGER.debug("Playing preset: %s", preset.name)
self._device.select_preset(preset)
else:
_LOGGER.warning("Unable to find preset with id %s", media_id)
def select_source(self, source):
"""Select input source."""
if source == Source.AUX.value:
_LOGGER.debug("Selecting source AUX")
self._device.select_source_aux()
elif source == Source.BLUETOOTH.value:
_LOGGER.debug("Selecting source Bluetooth")
self._device.select_source_bluetooth()
else:
_LOGGER.warning("Source %s is not supported", source)
def create_zone(self, slaves):
"""
Create a zone (multi-room) and play on selected devices.
:param slaves: slaves on which to play
"""
if not slaves:
_LOGGER.warning("Unable to create zone without slaves")
else:
2019-07-31 19:25:30 +00:00
_LOGGER.info("Creating zone with master %s", self._device.config.name)
self._device.create_zone([slave.device for slave in slaves])
def remove_zone_slave(self, slaves):
"""
Remove slave(s) from and existing zone (multi-room).
Zone must already exist and slaves array can not be empty.
Note: If removing last slave, the zone will be deleted and you'll have
to create a new one. You will not be able to add a new slave anymore
:param slaves: slaves to remove from the zone
"""
if not slaves:
_LOGGER.warning("Unable to find slaves to remove")
else:
2019-07-31 19:25:30 +00:00
_LOGGER.info(
"Removing slaves from zone with master %s", self._device.config.name
)
# 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):
"""
Add slave(s) to and existing zone (multi-room).
Zone must already exist and slaves array can not be empty.
:param slaves:slaves to add
"""
if not slaves:
_LOGGER.warning("Unable to find slaves to add")
else:
2019-07-31 19:25:30 +00:00
_LOGGER.info(
"Adding slaves to zone with master %s", self._device.config.name
)
self._device.add_zone_slave([slave.device for slave in slaves])
@property
def extra_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.
# In addition to this shortcoming, libsoundtouch seems to report the "is_master"
# property wrong on some slaves, so the only reliable way to detect if the current
# devices is the master, is by comparing the master_id of the zone with the device_id
if zone_status.master_id == self._device.config.device_id:
return self._build_zone_info(self.entity_id, zone_status.slaves)
# The master device has to be searched by it's ID and not IP since libsoundtouch / BOSE API
# do not return the IP of the master for some slave objects/responses
master_instance = self._get_instance_by_id(zone_status.master_id)
if master_instance is not None:
master_zone_status = master_instance.device.zone_status()
return self._build_zone_info(
master_instance.entity_id, master_zone_status.slaves
)
# We should never end up here since this means we haven't found a master device to get the
# correct zone info from. In this case, assume current device is master
return self._build_zone_info(self.entity_id, 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 _get_instance_by_id(self, instance_id):
"""Search and return a SoundTouchDevice instance by it's ID (aka MAC address)."""
for instance in self.hass.data[DATA_SOUNDTOUCH]:
if instance and instance.device.config.device_id == instance_id:
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