Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for Songpal-enabled (Sony) media devices."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections import OrderedDict
7 import logging
8 
9 from songpal import (
10  ConnectChange,
11  ContentChange,
12  Device,
13  PowerChange,
14  SettingChange,
15  SongpalException,
16  VolumeChange,
17 )
18 from songpal.containers import Setting
19 import voluptuous as vol
20 
22  MediaPlayerDeviceClass,
23  MediaPlayerEntity,
24  MediaPlayerEntityFeature,
25  MediaPlayerState,
26 )
27 from homeassistant.config_entries import ConfigEntry
28 from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP
29 from homeassistant.core import HomeAssistant
30 from homeassistant.exceptions import PlatformNotReady
31 from homeassistant.helpers import (
32  config_validation as cv,
33  device_registry as dr,
34  entity_platform,
35 )
36 from homeassistant.helpers.device_registry import DeviceInfo
37 from homeassistant.helpers.entity_platform import AddEntitiesCallback
38 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
39 
40 from .const import CONF_ENDPOINT, DOMAIN, ERROR_REQUEST_RETRY, SET_SOUND_SETTING
41 
42 _LOGGER = logging.getLogger(__name__)
43 
44 PARAM_NAME = "name"
45 PARAM_VALUE = "value"
46 
47 INITIAL_RETRY_DELAY = 10
48 
49 
51  hass: HomeAssistant,
52  config: ConfigType,
53  async_add_entities: AddEntitiesCallback,
54  discovery_info: DiscoveryInfoType | None = None,
55 ) -> None:
56  """Set up from legacy configuration file. Obsolete."""
57  _LOGGER.error(
58  "Configuring Songpal through media_player platform is no longer supported."
59  " Convert to songpal platform or UI configuration"
60  )
61 
62 
64  hass: HomeAssistant,
65  config_entry: ConfigEntry,
66  async_add_entities: AddEntitiesCallback,
67 ) -> None:
68  """Set up songpal media player."""
69  name = config_entry.data[CONF_NAME]
70  endpoint = config_entry.data[CONF_ENDPOINT]
71 
72  device = Device(endpoint)
73  try:
74  async with asyncio.timeout(
75  10
76  ): # set timeout to avoid blocking the setup process
77  await device.get_supported_methods()
78  except (SongpalException, TimeoutError) as ex:
79  _LOGGER.warning("[%s(%s)] Unable to connect", name, endpoint)
80  _LOGGER.debug("Unable to get methods from songpal: %s", ex)
81  raise PlatformNotReady from ex
82 
83  songpal_entity = SongpalEntity(name, device)
84  async_add_entities([songpal_entity], True)
85 
86  platform = entity_platform.async_get_current_platform()
87  platform.async_register_entity_service(
88  SET_SOUND_SETTING,
89  {vol.Required(PARAM_NAME): cv.string, vol.Required(PARAM_VALUE): cv.string},
90  "async_set_sound_setting",
91  )
92 
93 
95  """Class representing a Songpal device."""
96 
97  _attr_should_poll = False
98  _attr_device_class = MediaPlayerDeviceClass.RECEIVER
99  _attr_supported_features = (
100  MediaPlayerEntityFeature.VOLUME_SET
101  | MediaPlayerEntityFeature.VOLUME_STEP
102  | MediaPlayerEntityFeature.VOLUME_MUTE
103  | MediaPlayerEntityFeature.SELECT_SOURCE
104  | MediaPlayerEntityFeature.SELECT_SOUND_MODE
105  | MediaPlayerEntityFeature.TURN_ON
106  | MediaPlayerEntityFeature.TURN_OFF
107  )
108  _attr_has_entity_name = True
109  _attr_name = None
110 
111  def __init__(self, name, device):
112  """Init."""
113  self._name_name = name
114  self._dev_dev = device
115  self._sysinfo_sysinfo = None
116  self._model_model = None
117 
118  self._state_state = False
119  self._attr_available_attr_available = False
120  self._initialized_initialized = False
121 
122  self._volume_control_volume_control = None
123  self._volume_min_volume_min = 0
124  self._volume_max_volume_max = 1
125  self._volume_volume = 0
126  self._attr_is_volume_muted_attr_is_volume_muted = False
127 
128  self._active_source_active_source = None
129  self._sources_sources = {}
130  self._active_sound_mode_active_sound_mode = None
131  self._sound_modes_sound_modes = {}
132 
133  async def async_added_to_hass(self) -> None:
134  """Run when entity is added to hass."""
135  await self.async_activate_websocketasync_activate_websocket()
136 
137  async def async_will_remove_from_hass(self) -> None:
138  """Run when entity will be removed from hass."""
139  await self._dev_dev.stop_listen_notifications()
140 
141  async def _get_sound_modes_info(self):
142  """Get available sound modes and the active one."""
143  for settings in await self._dev_dev.get_sound_settings():
144  if settings.target == "soundField":
145  break
146  else:
147  return None, {}
148 
149  if isinstance(settings, Setting):
150  settings = [settings]
151 
152  sound_modes = {}
153  active_sound_mode = None
154  for setting in settings:
155  cur = setting.currentValue
156  for opt in setting.candidate:
157  if not opt.isAvailable:
158  continue
159  if opt.value == cur:
160  active_sound_mode = opt.value
161  sound_modes[opt.value] = opt
162 
163  _LOGGER.debug("Got sound modes: %s", sound_modes)
164  _LOGGER.debug("Active sound mode: %s", active_sound_mode)
165 
166  return active_sound_mode, sound_modes
167 
168  async def async_activate_websocket(self):
169  """Activate websocket for listening if wanted."""
170  _LOGGER.debug("Activating websocket connection")
171 
172  async def _volume_changed(volume: VolumeChange):
173  _LOGGER.debug("Volume changed: %s", volume)
174  self._volume_volume = volume.volume
175  self._attr_is_volume_muted_attr_is_volume_muted = volume.mute
176  self.async_write_ha_stateasync_write_ha_state()
177 
178  async def _source_changed(content: ContentChange):
179  _LOGGER.debug("Source changed: %s", content)
180  if content.is_input:
181  self._active_source_active_source = self._sources_sources[content.uri]
182  _LOGGER.debug("New active source: %s", self._active_source_active_source)
183  self.async_write_ha_stateasync_write_ha_state()
184  else:
185  _LOGGER.debug("Got non-handled content change: %s", content)
186 
187  async def _setting_changed(setting: SettingChange):
188  _LOGGER.debug("Setting changed: %s", setting)
189 
190  if setting.target == "soundField":
191  self._active_sound_mode_active_sound_mode = setting.currentValue
192  _LOGGER.debug("New active sound mode: %s", self._active_sound_mode_active_sound_mode)
193  self.async_write_ha_stateasync_write_ha_state()
194  else:
195  _LOGGER.debug("Got non-handled setting change: %s", setting)
196 
197  async def _power_changed(power: PowerChange):
198  _LOGGER.debug("Power changed: %s", power)
199  self._state_state = power.status
200  self.async_write_ha_stateasync_write_ha_state()
201 
202  async def _try_reconnect(connect: ConnectChange):
203  _LOGGER.warning(
204  "[%s(%s)] Got disconnected, trying to reconnect",
205  self.namename,
206  self._dev_dev.endpoint,
207  )
208  _LOGGER.debug("Disconnected: %s", connect.exception)
209  self._attr_available_attr_available = False
210  self.async_write_ha_stateasync_write_ha_state()
211 
212  # Try to reconnect forever, a successful reconnect will initialize
213  # the websocket connection again.
214  delay = INITIAL_RETRY_DELAY
215  while not self._attr_available_attr_available:
216  _LOGGER.debug("Trying to reconnect in %s seconds", delay)
217  await asyncio.sleep(delay)
218 
219  try:
220  await self._dev_dev.get_supported_methods()
221  except SongpalException as ex:
222  _LOGGER.debug("Failed to reconnect: %s", ex)
223  delay = min(2 * delay, 300)
224  else:
225  # We need to inform HA about the state in case we are coming
226  # back from a disconnected state.
227  await self.async_update_ha_stateasync_update_ha_state(force_refresh=True)
228 
229  self.hasshass.loop.create_task(self._dev_dev.listen_notifications())
230  _LOGGER.warning(
231  "[%s(%s)] Connection reestablished", self.namename, self._dev_dev.endpoint
232  )
233 
234  self._dev_dev.on_notification(VolumeChange, _volume_changed)
235  self._dev_dev.on_notification(ContentChange, _source_changed)
236  self._dev_dev.on_notification(PowerChange, _power_changed)
237  self._dev_dev.on_notification(SettingChange, _setting_changed)
238  self._dev_dev.on_notification(ConnectChange, _try_reconnect)
239 
240  async def handle_stop(event):
241  await self._dev_dev.stop_listen_notifications()
242 
243  self.hasshass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop)
244 
245  self.hasshass.loop.create_task(self._dev_dev.listen_notifications())
246 
247  @property
248  def unique_id(self):
249  """Return a unique ID."""
250  return self._sysinfo_sysinfo.macAddr or self._sysinfo_sysinfo.wirelessMacAddr
251 
252  @property
253  def device_info(self) -> DeviceInfo:
254  """Return the device info."""
255  connections = set()
256  if self._sysinfo_sysinfo.macAddr:
257  connections.add((dr.CONNECTION_NETWORK_MAC, self._sysinfo_sysinfo.macAddr))
258  if self._sysinfo_sysinfo.wirelessMacAddr:
259  connections.add((dr.CONNECTION_NETWORK_MAC, self._sysinfo_sysinfo.wirelessMacAddr))
260  return DeviceInfo(
261  connections=connections,
262  identifiers={(DOMAIN, self.unique_idunique_idunique_id)},
263  manufacturer="Sony Corporation",
264  model=self._model_model,
265  name=self._name_name,
266  sw_version=self._sysinfo_sysinfo.version,
267  )
268 
269  async def async_set_sound_setting(self, name, value):
270  """Change a setting on the device."""
271  _LOGGER.debug("Calling set_sound_setting with %s: %s", name, value)
272  await self._dev_dev.set_sound_settings(name, value)
273 
274  async def async_update(self) -> None:
275  """Fetch updates from the device."""
276  try:
277  if self._sysinfo_sysinfo is None:
278  self._sysinfo_sysinfo = await self._dev_dev.get_system_info()
279 
280  if self._model_model is None:
281  interface_info = await self._dev_dev.get_interface_information()
282  self._model_model = interface_info.modelName
283 
284  volumes = await self._dev_dev.get_volume_information()
285  if not volumes:
286  _LOGGER.error("Got no volume controls, bailing out")
287  self._attr_available_attr_available = False
288  return
289 
290  if len(volumes) > 1:
291  _LOGGER.debug("Got %s volume controls, using the first one", volumes)
292 
293  volume = volumes[0]
294  _LOGGER.debug("Current volume: %s", volume)
295 
296  self._volume_max_volume_max = volume.maxVolume
297  self._volume_min_volume_min = volume.minVolume
298  self._volume_volume = volume.volume
299  self._volume_control_volume_control = volume
300  self._attr_is_volume_muted_attr_is_volume_muted = self._volume_control_volume_control.is_muted
301 
302  status = await self._dev_dev.get_power()
303  self._state_state = status.status
304  _LOGGER.debug("Got state: %s", status)
305 
306  inputs = await self._dev_dev.get_inputs()
307  _LOGGER.debug("Got ins: %s", inputs)
308 
309  self._sources_sources = OrderedDict()
310  for input_ in inputs:
311  self._sources_sources[input_.uri] = input_
312  if input_.active:
313  self._active_source_active_source = input_
314 
315  _LOGGER.debug("Active source: %s", self._active_source_active_source)
316 
317  (
318  self._active_sound_mode_active_sound_mode,
319  self._sound_modes_sound_modes,
320  ) = await self._get_sound_modes_info_get_sound_modes_info()
321 
322  self._attr_available_attr_available = True
323 
324  except SongpalException as ex:
325  _LOGGER.error("Unable to update: %s", ex)
326  self._attr_available_attr_available = False
327 
328  async def async_select_source(self, source: str) -> None:
329  """Select source."""
330  for out in self._sources_sources.values():
331  if out.title == source:
332  await out.activate()
333  return
334 
335  _LOGGER.error("Unable to find output: %s", source)
336 
337  @property
338  def source_list(self):
339  """Return list of available sources."""
340  return [src.title for src in self._sources_sources.values()]
341 
342  async def async_select_sound_mode(self, sound_mode: str) -> None:
343  """Select sound mode."""
344  for mode in self._sound_modes_sound_modes.values():
345  if mode.title == sound_mode:
346  await self._dev_dev.set_sound_settings("soundField", mode.value)
347  return
348 
349  _LOGGER.error("Unable to find sound mode: %s", sound_mode)
350 
351  @property
352  def sound_mode_list(self) -> list[str] | None:
353  """Return list of available sound modes.
354 
355  When active mode is None it means that sound mode is unavailable on the sound bar.
356  Can be due to incompatible sound bar or the sound bar is in a mode that does not
357  support sound mode changes.
358  """
359  if not self._active_sound_mode_active_sound_mode:
360  return None
361  return [sound_mode.title for sound_mode in self._sound_modes_sound_modes.values()]
362 
363  @property
364  def state(self) -> MediaPlayerState:
365  """Return current state."""
366  if self._state_state:
367  return MediaPlayerState.ON
368  return MediaPlayerState.OFF
369 
370  @property
371  def source(self):
372  """Return currently active source."""
373  # Avoid a KeyError when _active_source is not (yet) populated
374  return getattr(self._active_source_active_source, "title", None)
375 
376  @property
377  def sound_mode(self) -> str | None:
378  """Return currently active sound_mode."""
379  active_sound_mode = self._sound_modes_sound_modes.get(self._active_sound_mode_active_sound_mode)
380  return active_sound_mode.title if active_sound_mode else None
381 
382  @property
383  def volume_level(self):
384  """Return volume level."""
385  return self._volume_volume / self._volume_max_volume_max
386 
387  async def async_set_volume_level(self, volume: float) -> None:
388  """Set volume level."""
389  volume = int(volume * self._volume_max_volume_max)
390  _LOGGER.debug("Setting volume to %s", volume)
391  return await self._volume_control_volume_control.set_volume(volume)
392 
393  async def async_volume_up(self) -> None:
394  """Set volume up."""
395  return await self._volume_control_volume_control.set_volume(self._volume_volume + 1)
396 
397  async def async_volume_down(self) -> None:
398  """Set volume down."""
399  return await self._volume_control_volume_control.set_volume(self._volume_volume - 1)
400 
401  async def async_turn_on(self) -> None:
402  """Turn the device on."""
403  try:
404  await self._dev_dev.set_power(True)
405  except SongpalException as ex:
406  if ex.code == ERROR_REQUEST_RETRY:
407  _LOGGER.debug(
408  "Swallowing %s, the device might be already in the wanted state", ex
409  )
410  return
411  raise
412 
413  async def async_turn_off(self) -> None:
414  """Turn the device off."""
415  try:
416  await self._dev_dev.set_power(False)
417  except SongpalException as ex:
418  if ex.code == ERROR_REQUEST_RETRY:
419  _LOGGER.debug(
420  "Swallowing %s, the device might be already in the wanted state", ex
421  )
422  return
423  raise
424 
425  async def async_mute_volume(self, mute: bool) -> None:
426  """Mute or unmute the device."""
427  _LOGGER.debug("Set mute: %s", mute)
428  return await self._volume_control_volume_control.set_mute(mute)
None async_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:942
str|UndefinedType|None name(self)
Definition: entity.py:738
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: media_player.py:67
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: media_player.py:55