"""Support for Songpal-enabled (Sony) media devices.""" import asyncio from collections import OrderedDict import logging import async_timeout from songpal import ( ConnectChange, ContentChange, Device, PowerChange, SongpalException, VolumeChange, ) import voluptuous as vol from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_platform, ) from homeassistant.helpers.typing import HomeAssistantType from .const import CONF_ENDPOINT, DOMAIN, SET_SOUND_SETTING _LOGGER = logging.getLogger(__name__) PARAM_NAME = "name" PARAM_VALUE = "value" SUPPORT_SONGPAL = ( SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF ) INITIAL_RETRY_DELAY = 10 async def async_setup_platform( hass: HomeAssistantType, config: dict, async_add_entities, discovery_info=None ) -> None: """Set up from legacy configuration file. Obsolete.""" _LOGGER.error( "Configuring Songpal through media_player platform is no longer supported. Convert to songpal platform or UI configuration" ) async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up songpal media player.""" name = config_entry.data[CONF_NAME] endpoint = config_entry.data[CONF_ENDPOINT] device = Device(endpoint) try: async with async_timeout.timeout( 10 ): # set timeout to avoid blocking the setup process await device.get_supported_methods() except (SongpalException, asyncio.TimeoutError) as ex: _LOGGER.warning("[%s(%s)] Unable to connect", name, endpoint) _LOGGER.debug("Unable to get methods from songpal: %s", ex) raise PlatformNotReady from ex songpal_entity = SongpalEntity(name, device) async_add_entities([songpal_entity], True) platform = entity_platform.current_platform.get() platform.async_register_entity_service( SET_SOUND_SETTING, {vol.Required(PARAM_NAME): cv.string, vol.Required(PARAM_VALUE): cv.string}, "async_set_sound_setting", ) class SongpalEntity(MediaPlayerEntity): """Class representing a Songpal device.""" def __init__(self, name, device): """Init.""" self._name = name self._dev = device self._sysinfo = None self._model = None self._state = False self._available = False self._initialized = False self._volume_control = None self._volume_min = 0 self._volume_max = 1 self._volume = 0 self._is_muted = False self._active_source = None self._sources = {} @property def should_poll(self): """Return True if the device should be polled.""" return False async def async_added_to_hass(self): """Run when entity is added to hass.""" await self.async_activate_websocket() async def async_will_remove_from_hass(self): """Run when entity will be removed from hass.""" await self._dev.stop_listen_notifications() async def async_activate_websocket(self): """Activate websocket for listening if wanted.""" _LOGGER.info("Activating websocket connection") async def _volume_changed(volume: VolumeChange): _LOGGER.debug("Volume changed: %s", volume) self._volume = volume.volume self._is_muted = volume.mute self.async_write_ha_state() async def _source_changed(content: ContentChange): _LOGGER.debug("Source changed: %s", content) if content.is_input: self._active_source = self._sources[content.uri] _LOGGER.debug("New active source: %s", self._active_source) self.async_write_ha_state() else: _LOGGER.debug("Got non-handled content change: %s", content) async def _power_changed(power: PowerChange): _LOGGER.debug("Power changed: %s", power) self._state = power.status self.async_write_ha_state() async def _try_reconnect(connect: ConnectChange): _LOGGER.warning( "[%s(%s)] Got disconnected, trying to reconnect", self.name, self._dev.endpoint, ) _LOGGER.debug("Disconnected: %s", connect.exception) self._available = False self.async_write_ha_state() # Try to reconnect forever, a successful reconnect will initialize # the websocket connection again. delay = INITIAL_RETRY_DELAY while not self._available: _LOGGER.debug("Trying to reconnect in %s seconds", delay) await asyncio.sleep(delay) try: await self._dev.get_supported_methods() except SongpalException as ex: _LOGGER.debug("Failed to reconnect: %s", ex) delay = min(2 * delay, 300) else: # We need to inform HA about the state in case we are coming # back from a disconnected state. await self.async_update_ha_state(force_refresh=True) self.hass.loop.create_task(self._dev.listen_notifications()) _LOGGER.warning( "[%s(%s)] Connection reestablished", self.name, self._dev.endpoint ) self._dev.on_notification(VolumeChange, _volume_changed) self._dev.on_notification(ContentChange, _source_changed) self._dev.on_notification(PowerChange, _power_changed) self._dev.on_notification(ConnectChange, _try_reconnect) async def handle_stop(event): await self._dev.stop_listen_notifications() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop) self.hass.loop.create_task(self._dev.listen_notifications()) @property def name(self): """Return name of the device.""" return self._name @property def unique_id(self): """Return a unique ID.""" return self._sysinfo.macAddr @property def device_info(self): """Return the device info.""" return { "connections": {(dr.CONNECTION_NETWORK_MAC, self._sysinfo.macAddr)}, "identifiers": {(DOMAIN, self.unique_id)}, "manufacturer": "Sony Corporation", "name": self.name, "sw_version": self._sysinfo.version, "model": self._model, } @property def available(self): """Return availability of the device.""" return self._available async def async_set_sound_setting(self, name, value): """Change a setting on the device.""" _LOGGER.debug("Calling set_sound_setting with %s: %s", name, value) await self._dev.set_sound_settings(name, value) async def async_update(self): """Fetch updates from the device.""" try: if self._sysinfo is None: self._sysinfo = await self._dev.get_system_info() if self._model is None: interface_info = await self._dev.get_interface_information() self._model = interface_info.modelName volumes = await self._dev.get_volume_information() if not volumes: _LOGGER.error("Got no volume controls, bailing out") self._available = False return if len(volumes) > 1: _LOGGER.debug("Got %s volume controls, using the first one", volumes) volume = volumes[0] _LOGGER.debug("Current volume: %s", volume) self._volume_max = volume.maxVolume self._volume_min = volume.minVolume self._volume = volume.volume self._volume_control = volume self._is_muted = self._volume_control.is_muted status = await self._dev.get_power() self._state = status.status _LOGGER.debug("Got state: %s", status) inputs = await self._dev.get_inputs() _LOGGER.debug("Got ins: %s", inputs) self._sources = OrderedDict() for input_ in inputs: self._sources[input_.uri] = input_ if input_.active: self._active_source = input_ _LOGGER.debug("Active source: %s", self._active_source) self._available = True except SongpalException as ex: _LOGGER.error("Unable to update: %s", ex) self._available = False async def async_select_source(self, source): """Select source.""" for out in self._sources.values(): if out.title == source: await out.activate() return _LOGGER.error("Unable to find output: %s", source) @property def source_list(self): """Return list of available sources.""" return [src.title for src in self._sources.values()] @property def state(self): """Return current state.""" if self._state: return STATE_ON return STATE_OFF @property def source(self): """Return currently active source.""" # Avoid a KeyError when _active_source is not (yet) populated return getattr(self._active_source, "title", None) @property def volume_level(self): """Return volume level.""" volume = self._volume / self._volume_max return volume async def async_set_volume_level(self, volume): """Set volume level.""" volume = int(volume * self._volume_max) _LOGGER.debug("Setting volume to %s", volume) return await self._volume_control.set_volume(volume) async def async_volume_up(self): """Set volume up.""" return await self._volume_control.set_volume(self._volume + 1) async def async_volume_down(self): """Set volume down.""" return await self._volume_control.set_volume(self._volume - 1) async def async_turn_on(self): """Turn the device on.""" return await self._dev.set_power(True) async def async_turn_off(self): """Turn the device off.""" return await self._dev.set_power(False) async def async_mute_volume(self, mute): """Mute or unmute the device.""" _LOGGER.debug("Set mute: %s", mute) return await self._volume_control.set_mute(mute) @property def is_volume_muted(self): """Return whether the device is muted.""" return self._is_muted @property def supported_features(self): """Return supported features.""" return SUPPORT_SONGPAL