Getting soundField on soundbar that doesn't support it crash raise an exception, so it make the whole components unavailable. As there is no simple way to know if soundField is supported, I just get all sound settings, and then pick soundField one if present. If not present, then return None to make it continue, it will just have to effect to display no sound mode and not able to select one (Exactly what we want).
428 lines
15 KiB
Python
428 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.info("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)
|