"""Support for interface with a Bose Soundtouch.""" import logging import re 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 ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) 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 ( 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, "STOP_STATE": STATE_OFF, } DATA_SOUNDTOUCH = "soundtouch" ATTR_SOUNDTOUCH_GROUP = "soundtouch_group" ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone" SOUNDTOUCH_PLAY_EVERYWHERE = vol.Schema({vol.Required("master"): cv.entity_id}) SOUNDTOUCH_CREATE_ZONE_SCHEMA = vol.Schema( {vol.Required("master"): cv.entity_id, vol.Required("slaves"): cv.entity_ids} ) SOUNDTOUCH_ADD_ZONE_SCHEMA = vol.Schema( {vol.Required("master"): cv.entity_id, vol.Required("slaves"): cv.entity_ids} ) SOUNDTOUCH_REMOVE_ZONE_SCHEMA = vol.Schema( {vol.Required("master"): cv.entity_id, vol.Required("slaves"): cv.entity_ids} ) DEFAULT_NAME = "Bose Soundtouch" DEFAULT_PORT = 8090 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 ) 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: host = discovery_info["host"] port = int(discovery_info["port"]) # if device already exists by config if host in [device.config["host"] for device in hass.data[DATA_SOUNDTOUCH]]: return 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 = { "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.""" master_device_id = service.data.get("master") slaves_ids = service.data.get("slaves") slaves = [] if slaves_ids: 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: _LOGGER.warning( "Unable to find master with entity_id: %s", str(master_device_id) ) return if service.service == SERVICE_PLAY_EVERYWHERE: 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) 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.""" 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.""" 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) 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() 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: _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: _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: _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 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. # 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