Home Assistant Unofficial Reference 2024.12.1
coordinator.py
Go to the documentation of this file.
1 """Update coordinator for Bravia TV integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Awaitable, Callable, Coroutine, Iterable
6 from datetime import datetime, timedelta
7 from functools import wraps
8 import logging
9 from types import MappingProxyType
10 from typing import Any, Concatenate, Final
11 
12 from pybravia import (
13  BraviaAuthError,
14  BraviaClient,
15  BraviaConnectionError,
16  BraviaConnectionTimeout,
17  BraviaError,
18  BraviaNotFound,
19  BraviaTurnedOff,
20 )
21 
22 from homeassistant.components.media_player import MediaType
23 from homeassistant.const import CONF_CLIENT_ID, CONF_PIN
24 from homeassistant.core import HomeAssistant
25 from homeassistant.exceptions import ConfigEntryAuthFailed
26 from homeassistant.helpers.debounce import Debouncer
27 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
28 
29 from .const import (
30  CONF_NICKNAME,
31  CONF_USE_PSK,
32  DOMAIN,
33  LEGACY_CLIENT_ID,
34  NICKNAME_PREFIX,
35  SourceType,
36 )
37 
38 _LOGGER = logging.getLogger(__name__)
39 
40 SCAN_INTERVAL: Final = timedelta(seconds=10)
41 
42 
43 def catch_braviatv_errors[_BraviaTVCoordinatorT: BraviaTVCoordinator, **_P](
44  func: Callable[Concatenate[_BraviaTVCoordinatorT, _P], Awaitable[None]],
45 ) -> Callable[Concatenate[_BraviaTVCoordinatorT, _P], Coroutine[Any, Any, None]]:
46  """Catch Bravia errors."""
47 
48  @wraps(func)
49  async def wrapper(
50  self: _BraviaTVCoordinatorT,
51  *args: _P.args,
52  **kwargs: _P.kwargs,
53  ) -> None:
54  """Catch Bravia errors and log message."""
55  try:
56  await func(self, *args, **kwargs)
57  except BraviaError as err:
58  _LOGGER.error("Command error: %s", err)
59  await self.async_request_refresh()
60 
61  return wrapper
62 
63 
65  """Representation of a Bravia TV Coordinator."""
66 
67  def __init__(
68  self,
69  hass: HomeAssistant,
70  client: BraviaClient,
71  config: MappingProxyType[str, Any],
72  ) -> None:
73  """Initialize Bravia TV Client."""
74 
75  self.clientclient = client
76  self.pinpin = config[CONF_PIN]
77  self.use_pskuse_psk = config.get(CONF_USE_PSK, False)
78  self.client_idclient_id = config.get(CONF_CLIENT_ID, LEGACY_CLIENT_ID)
79  self.nicknamenickname = config.get(CONF_NICKNAME, NICKNAME_PREFIX)
80  self.sourcesource: str | None = None
81  self.source_listsource_list: list[str] = []
82  self.source_mapsource_map: dict[str, dict] = {}
83  self.media_titlemedia_title: str | None = None
84  self.media_channelmedia_channel: str | None = None
85  self.media_content_idmedia_content_id: str | None = None
86  self.media_content_typemedia_content_type: MediaType | None = None
87  self.media_urimedia_uri: str | None = None
88  self.media_durationmedia_duration: int | None = None
89  self.media_positionmedia_position: int | None = None
90  self.media_position_updated_atmedia_position_updated_at: datetime | None = None
91  self.volume_levelvolume_level: float | None = None
92  self.volume_targetvolume_target: str | None = None
93  self.volume_mutedvolume_muted = False
94  self.is_onis_on = False
95  self.connectedconnected = False
96  self.skipped_updatesskipped_updates = 0
97 
98  super().__init__(
99  hass,
100  _LOGGER,
101  name=DOMAIN,
102  update_interval=SCAN_INTERVAL,
103  request_refresh_debouncer=Debouncer(
104  hass, _LOGGER, cooldown=1.0, immediate=False
105  ),
106  )
107 
109  self,
110  sources: list[dict],
111  source_type: SourceType,
112  add_to_list: bool = False,
113  sort_by: str | None = None,
114  ) -> None:
115  """Extend source map and source list."""
116  if sort_by:
117  sources = sorted(sources, key=lambda d: d.get(sort_by, ""))
118  for item in sources:
119  title = item.get("title")
120  uri = item.get("uri")
121  if not title or not uri:
122  continue
123  self.source_mapsource_map[uri] = {**item, "type": source_type}
124  if add_to_list and title not in self.source_listsource_list:
125  self.source_listsource_list.append(title)
126 
127  async def _async_update_data(self) -> None:
128  """Connect and fetch data."""
129  try:
130  if not self.connectedconnected:
131  try:
132  if self.use_pskuse_psk:
133  await self.clientclient.connect(psk=self.pinpin)
134  else:
135  await self.clientclient.connect(
136  pin=self.pinpin,
137  clientid=self.client_idclient_id,
138  nickname=self.nicknamenickname,
139  )
140  self.connectedconnected = True
141  except BraviaAuthError as err:
142  raise ConfigEntryAuthFailed from err
143 
144  power_status = await self.clientclient.get_power_status()
145  self.is_onis_on = power_status == "active"
146  self.skipped_updatesskipped_updates = 0
147 
148  if self.is_onis_on is False:
149  return
150 
151  if not self.source_mapsource_map:
152  await self.async_update_sourcesasync_update_sources()
153  await self.async_update_volumeasync_update_volume()
154  await self.async_update_playingasync_update_playing()
155  except BraviaNotFound as err:
156  if self.skipped_updatesskipped_updates < 10:
157  self.connectedconnected = False
158  self.skipped_updatesskipped_updates += 1
159  _LOGGER.debug("Update skipped, Bravia API service is reloading")
160  return
161  raise UpdateFailed("Error communicating with device") from err
162  except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff):
163  self.is_onis_on = False
164  self.connectedconnected = False
165  _LOGGER.debug("Update skipped, Bravia TV is off")
166  except BraviaError as err:
167  self.is_onis_on = False
168  self.connectedconnected = False
169  raise UpdateFailed("Error communicating with device") from err
170 
171  async def async_update_volume(self) -> None:
172  """Update volume information."""
173  volume_info = await self.clientclient.get_volume_info()
174  if (volume_level := volume_info.get("volume")) is not None:
175  self.volume_levelvolume_level = volume_level / 100
176  self.volume_mutedvolume_muted = volume_info.get("mute", False)
177  self.volume_targetvolume_target = volume_info.get("target")
178 
179  async def async_update_playing(self) -> None:
180  """Update current playing information."""
181  playing_info = await self.clientclient.get_playing_info()
182  self.media_titlemedia_title = playing_info.get("title")
183  self.media_urimedia_uri = playing_info.get("uri")
184  self.media_durationmedia_duration = playing_info.get("durationSec")
185  self.media_channelmedia_channel = None
186  self.media_content_idmedia_content_id = None
187  self.media_content_typemedia_content_type = None
188  self.sourcesource = None
189  if start_datetime := playing_info.get("startDateTime"):
190  start_datetime = datetime.fromisoformat(start_datetime)
191  current_datetime = datetime.now().replace(tzinfo=start_datetime.tzinfo)
192  self.media_positionmedia_position = int(
193  (current_datetime - start_datetime).total_seconds()
194  )
195  self.media_position_updated_atmedia_position_updated_at = datetime.now()
196  else:
197  self.media_positionmedia_position = None
198  self.media_position_updated_atmedia_position_updated_at = None
199  if self.media_urimedia_uri:
200  self.media_content_idmedia_content_id = self.media_urimedia_uri
201  if self.media_urimedia_uri[:8] == "extInput":
202  self.sourcesource = playing_info.get("title")
203  if self.media_urimedia_uri[:2] == "tv":
204  self.media_content_idmedia_content_id = playing_info.get("dispNum")
205  self.media_titlemedia_title = (
206  playing_info.get("programTitle") or self.media_content_idmedia_content_id
207  )
208  self.media_channelmedia_channel = playing_info.get("title") or self.media_content_idmedia_content_id
209  self.media_content_typemedia_content_type = MediaType.CHANNEL
210  if not playing_info:
211  self.media_titlemedia_title = "Smart TV"
212  self.media_content_typemedia_content_type = MediaType.APP
213 
214  async def async_update_sources(self) -> None:
215  """Update all sources."""
216  self.source_listsource_list = []
217  self.source_mapsource_map = {}
218 
219  inputs = await self.clientclient.get_external_status()
220  self._sources_extend_sources_extend(inputs, SourceType.INPUT, add_to_list=True)
221 
222  apps = await self.clientclient.get_app_list()
223  self._sources_extend_sources_extend(apps, SourceType.APP, sort_by="title")
224 
225  channels = await self.clientclient.get_content_list_all("tv")
226  self._sources_extend_sources_extend(channels, SourceType.CHANNEL)
227 
228  async def async_source_start(self, uri: str, source_type: SourceType | str) -> None:
229  """Select source by uri."""
230  if source_type == SourceType.APP:
231  await self.clientclient.set_active_app(uri)
232  else:
233  await self.clientclient.set_play_content(uri)
234 
235  async def async_source_find(
236  self, query: str, source_type: SourceType | str
237  ) -> None:
238  """Find and select source by query."""
239  if query.startswith(("extInput:", "tv:", "com.sony.dtv.")):
240  return await self.async_source_startasync_source_start(query, source_type)
241  coarse_uri = None
242  is_numeric_search = source_type == SourceType.CHANNEL and query.isnumeric()
243  for uri, item in self.source_mapsource_map.items():
244  if item["type"] == source_type:
245  if is_numeric_search:
246  num = item.get("dispNum")
247  if num and int(query) == int(num):
248  return await self.async_source_startasync_source_start(uri, source_type)
249  else:
250  title: str = item["title"]
251  if query.lower() == title.lower():
252  return await self.async_source_startasync_source_start(uri, source_type)
253  if query.lower() in title.lower():
254  coarse_uri = uri
255  if coarse_uri:
256  return await self.async_source_startasync_source_start(coarse_uri, source_type)
257  raise ValueError(f"Not found {source_type}: {query}")
258 
259  @catch_braviatv_errors
260  async def async_turn_on(self) -> None:
261  """Turn the device on."""
262  await self.clientclient.turn_on()
263 
264  @catch_braviatv_errors
265  async def async_turn_off(self) -> None:
266  """Turn off device."""
267  await self.clientclient.turn_off()
268 
269  @catch_braviatv_errors
270  async def async_set_volume_level(self, volume: float) -> None:
271  """Set volume level, range 0..1."""
272  await self.clientclient.volume_level(round(volume * 100))
273 
274  @catch_braviatv_errors
275  async def async_volume_up(self) -> None:
276  """Send volume up command to device."""
277  await self.clientclient.volume_up()
278 
279  @catch_braviatv_errors
280  async def async_volume_down(self) -> None:
281  """Send volume down command to device."""
282  await self.clientclient.volume_down()
283 
284  @catch_braviatv_errors
285  async def async_volume_mute(self, mute: bool) -> None:
286  """Send mute command to device."""
287  await self.clientclient.volume_mute()
288 
289  @catch_braviatv_errors
290  async def async_media_play(self) -> None:
291  """Send play command to device."""
292  await self.clientclient.play()
293 
294  @catch_braviatv_errors
295  async def async_media_pause(self) -> None:
296  """Send pause command to device."""
297  await self.clientclient.pause()
298 
299  @catch_braviatv_errors
300  async def async_media_stop(self) -> None:
301  """Send stop command to device."""
302  await self.clientclient.stop()
303 
304  @catch_braviatv_errors
305  async def async_media_next_track(self) -> None:
306  """Send next track command."""
307  if self.media_content_typemedia_content_type == MediaType.CHANNEL:
308  await self.clientclient.channel_up()
309  else:
310  await self.clientclient.next_track()
311 
312  @catch_braviatv_errors
313  async def async_media_previous_track(self) -> None:
314  """Send previous track command."""
315  if self.media_content_typemedia_content_type == MediaType.CHANNEL:
316  await self.clientclient.channel_down()
317  else:
318  await self.clientclient.previous_track()
319 
320  @catch_braviatv_errors
321  async def async_play_media(
322  self, media_type: MediaType | str, media_id: str, **kwargs: Any
323  ) -> None:
324  """Play a piece of media."""
325  if media_type not in (MediaType.APP, MediaType.CHANNEL):
326  raise ValueError(f"Invalid media type: {media_type}")
327  await self.async_source_findasync_source_find(media_id, media_type)
328 
329  @catch_braviatv_errors
330  async def async_select_source(self, source: str) -> None:
331  """Set the input source."""
332  await self.async_source_findasync_source_find(source, SourceType.INPUT)
333 
334  @catch_braviatv_errors
335  async def async_send_command(self, command: Iterable[str], repeats: int) -> None:
336  """Send command to device."""
337  for _ in range(repeats):
338  for cmd in command:
339  response = await self.clientclient.send_command(cmd)
340  if not response:
341  commands = await self.clientclient.get_command_list()
342  commands_keys = ", ".join(commands.keys())
343  # Logging an error instead of raising a ValueError
344  # https://github.com/home-assistant/core/pull/77329#discussion_r955768245
345  _LOGGER.error(
346  "Unsupported command: %s, list of available commands: %s",
347  cmd,
348  commands_keys,
349  )
350 
351  @catch_braviatv_errors
352  async def async_reboot_device(self) -> None:
353  """Send command to reboot the device."""
354  await self.clientclient.reboot()
355 
356  @catch_braviatv_errors
357  async def async_terminate_apps(self) -> None:
358  """Send command to terminate all applications."""
359  await self.clientclient.terminate_apps()
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
Definition: coordinator.py:323
None async_source_find(self, str query, SourceType|str source_type)
Definition: coordinator.py:237
None __init__(self, HomeAssistant hass, BraviaClient client, MappingProxyType[str, Any] config)
Definition: coordinator.py:72
None async_send_command(self, Iterable[str] command, int repeats)
Definition: coordinator.py:335
None async_source_start(self, str uri, SourceType|str source_type)
Definition: coordinator.py:228
None _sources_extend(self, list[dict] sources, SourceType source_type, bool add_to_list=False, str|None sort_by=None)
Definition: coordinator.py:114