mirror of https://github.com/home-assistant/core
429 lines
15 KiB
Python
429 lines
15 KiB
Python
"""Support for Songpal-enabled (Sony) media devices."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections import OrderedDict
|
|
import logging
|
|
|
|
from songpal import (
|
|
ConnectChange,
|
|
ContentChange,
|
|
Device,
|
|
PowerChange,
|
|
SettingChange,
|
|
SongpalException,
|
|
VolumeChange,
|
|
)
|
|
from songpal.containers import Setting
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.media_player import (
|
|
MediaPlayerDeviceClass,
|
|
MediaPlayerEntity,
|
|
MediaPlayerEntityFeature,
|
|
MediaPlayerState,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import PlatformNotReady
|
|
from homeassistant.helpers import (
|
|
config_validation as cv,
|
|
device_registry as dr,
|
|
entity_platform,
|
|
)
|
|
from homeassistant.helpers.device_registry import DeviceInfo
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
|
|
|
from .const import CONF_ENDPOINT, DOMAIN, ERROR_REQUEST_RETRY, SET_SOUND_SETTING
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PARAM_NAME = "name"
|
|
PARAM_VALUE = "value"
|
|
|
|
INITIAL_RETRY_DELAY = 10
|
|
|
|
|
|
async def async_setup_platform(
|
|
hass: HomeAssistant,
|
|
config: ConfigType,
|
|
async_add_entities: AddEntitiesCallback,
|
|
discovery_info: DiscoveryInfoType | None = 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: HomeAssistant,
|
|
config_entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up songpal media player."""
|
|
name = config_entry.data[CONF_NAME]
|
|
endpoint = config_entry.data[CONF_ENDPOINT]
|
|
|
|
device = Device(endpoint)
|
|
try:
|
|
async with asyncio.timeout(
|
|
10
|
|
): # set timeout to avoid blocking the setup process
|
|
await device.get_supported_methods()
|
|
except (SongpalException, 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.async_get_current_platform()
|
|
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."""
|
|
|
|
_attr_should_poll = False
|
|
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
|
|
_attr_supported_features = (
|
|
MediaPlayerEntityFeature.VOLUME_SET
|
|
| MediaPlayerEntityFeature.VOLUME_STEP
|
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
|
| MediaPlayerEntityFeature.SELECT_SOURCE
|
|
| MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
|
| MediaPlayerEntityFeature.TURN_ON
|
|
| MediaPlayerEntityFeature.TURN_OFF
|
|
)
|
|
_attr_has_entity_name = True
|
|
_attr_name = None
|
|
|
|
def __init__(self, name, device):
|
|
"""Init."""
|
|
self._name = name
|
|
self._dev = device
|
|
self._sysinfo = None
|
|
self._model = None
|
|
|
|
self._state = False
|
|
self._attr_available = False
|
|
self._initialized = False
|
|
|
|
self._volume_control = None
|
|
self._volume_min = 0
|
|
self._volume_max = 1
|
|
self._volume = 0
|
|
self._attr_is_volume_muted = False
|
|
|
|
self._active_source = None
|
|
self._sources = {}
|
|
self._active_sound_mode = None
|
|
self._sound_modes = {}
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Run when entity is added to hass."""
|
|
await self.async_activate_websocket()
|
|
|
|
async def async_will_remove_from_hass(self) -> None:
|
|
"""Run when entity will be removed from hass."""
|
|
await self._dev.stop_listen_notifications()
|
|
|
|
async def _get_sound_modes_info(self):
|
|
"""Get available sound modes and the active one."""
|
|
for settings in await self._dev.get_sound_settings():
|
|
if settings.target == "soundField":
|
|
break
|
|
else:
|
|
return None, {}
|
|
|
|
if isinstance(settings, Setting):
|
|
settings = [settings]
|
|
|
|
sound_modes = {}
|
|
active_sound_mode = None
|
|
for setting in settings:
|
|
cur = setting.currentValue
|
|
for opt in setting.candidate:
|
|
if not opt.isAvailable:
|
|
continue
|
|
if opt.value == cur:
|
|
active_sound_mode = opt.value
|
|
sound_modes[opt.value] = opt
|
|
|
|
_LOGGER.debug("Got sound modes: %s", sound_modes)
|
|
_LOGGER.debug("Active sound mode: %s", active_sound_mode)
|
|
|
|
return active_sound_mode, sound_modes
|
|
|
|
async def async_activate_websocket(self):
|
|
"""Activate websocket for listening if wanted."""
|
|
_LOGGER.debug("Activating websocket connection")
|
|
|
|
async def _volume_changed(volume: VolumeChange):
|
|
_LOGGER.debug("Volume changed: %s", volume)
|
|
self._volume = volume.volume
|
|
self._attr_is_volume_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 _setting_changed(setting: SettingChange):
|
|
_LOGGER.debug("Setting changed: %s", setting)
|
|
|
|
if setting.target == "soundField":
|
|
self._active_sound_mode = setting.currentValue
|
|
_LOGGER.debug("New active sound mode: %s", self._active_sound_mode)
|
|
self.async_write_ha_state()
|
|
else:
|
|
_LOGGER.debug("Got non-handled setting change: %s", setting)
|
|
|
|
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._attr_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._attr_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(SettingChange, _setting_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 unique_id(self):
|
|
"""Return a unique ID."""
|
|
return self._sysinfo.macAddr or self._sysinfo.wirelessMacAddr
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
"""Return the device info."""
|
|
connections = set()
|
|
if self._sysinfo.macAddr:
|
|
connections.add((dr.CONNECTION_NETWORK_MAC, self._sysinfo.macAddr))
|
|
if self._sysinfo.wirelessMacAddr:
|
|
connections.add((dr.CONNECTION_NETWORK_MAC, self._sysinfo.wirelessMacAddr))
|
|
return DeviceInfo(
|
|
connections=connections,
|
|
identifiers={(DOMAIN, self.unique_id)},
|
|
manufacturer="Sony Corporation",
|
|
model=self._model,
|
|
name=self._name,
|
|
sw_version=self._sysinfo.version,
|
|
)
|
|
|
|
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) -> None:
|
|
"""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._attr_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._attr_is_volume_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._active_sound_mode,
|
|
self._sound_modes,
|
|
) = await self._get_sound_modes_info()
|
|
|
|
self._attr_available = True
|
|
|
|
except SongpalException as ex:
|
|
_LOGGER.error("Unable to update: %s", ex)
|
|
self._attr_available = False
|
|
|
|
async def async_select_source(self, source: str) -> None:
|
|
"""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()]
|
|
|
|
async def async_select_sound_mode(self, sound_mode: str) -> None:
|
|
"""Select sound mode."""
|
|
for mode in self._sound_modes.values():
|
|
if mode.title == sound_mode:
|
|
await self._dev.set_sound_settings("soundField", mode.value)
|
|
return
|
|
|
|
_LOGGER.error("Unable to find sound mode: %s", sound_mode)
|
|
|
|
@property
|
|
def sound_mode_list(self) -> list[str] | None:
|
|
"""Return list of available sound modes.
|
|
|
|
When active mode is None it means that sound mode is unavailable on the sound bar.
|
|
Can be due to incompatible sound bar or the sound bar is in a mode that does not
|
|
support sound mode changes.
|
|
"""
|
|
if not self._active_sound_mode:
|
|
return None
|
|
return [sound_mode.title for sound_mode in self._sound_modes.values()]
|
|
|
|
@property
|
|
def state(self) -> MediaPlayerState:
|
|
"""Return current state."""
|
|
if self._state:
|
|
return MediaPlayerState.ON
|
|
return MediaPlayerState.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 sound_mode(self) -> str | None:
|
|
"""Return currently active sound_mode."""
|
|
active_sound_mode = self._sound_modes.get(self._active_sound_mode)
|
|
return active_sound_mode.title if active_sound_mode else None
|
|
|
|
@property
|
|
def volume_level(self):
|
|
"""Return volume level."""
|
|
return self._volume / self._volume_max
|
|
|
|
async def async_set_volume_level(self, volume: float) -> None:
|
|
"""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) -> None:
|
|
"""Set volume up."""
|
|
return await self._volume_control.set_volume(self._volume + 1)
|
|
|
|
async def async_volume_down(self) -> None:
|
|
"""Set volume down."""
|
|
return await self._volume_control.set_volume(self._volume - 1)
|
|
|
|
async def async_turn_on(self) -> None:
|
|
"""Turn the device on."""
|
|
try:
|
|
await self._dev.set_power(True)
|
|
except SongpalException as ex:
|
|
if ex.code == ERROR_REQUEST_RETRY:
|
|
_LOGGER.debug(
|
|
"Swallowing %s, the device might be already in the wanted state", ex
|
|
)
|
|
return
|
|
raise
|
|
|
|
async def async_turn_off(self) -> None:
|
|
"""Turn the device off."""
|
|
try:
|
|
await self._dev.set_power(False)
|
|
except SongpalException as ex:
|
|
if ex.code == ERROR_REQUEST_RETRY:
|
|
_LOGGER.debug(
|
|
"Swallowing %s, the device might be already in the wanted state", ex
|
|
)
|
|
return
|
|
raise
|
|
|
|
async def async_mute_volume(self, mute: bool) -> None:
|
|
"""Mute or unmute the device."""
|
|
_LOGGER.debug("Set mute: %s", mute)
|
|
return await self._volume_control.set_mute(mute)
|