Home Assistant Unofficial Reference 2024.12.1
entity.py
Go to the documentation of this file.
1 """Common code for tplink."""
2 
3 from __future__ import annotations
4 
5 from abc import ABC, abstractmethod
6 from collections.abc import Awaitable, Callable, Coroutine, Mapping
7 from dataclasses import dataclass, replace
8 import logging
9 from typing import Any, Concatenate
10 
11 from kasa import (
12  AuthenticationError,
13  Device,
14  DeviceType,
15  Feature,
16  KasaException,
17  TimeoutError,
18 )
19 
20 from homeassistant.const import EntityCategory
21 from homeassistant.core import HomeAssistant, callback
22 from homeassistant.exceptions import HomeAssistantError
23 from homeassistant.helpers import device_registry as dr
24 from homeassistant.helpers.device_registry import DeviceInfo
25 from homeassistant.helpers.entity import EntityDescription
26 from homeassistant.helpers.typing import UNDEFINED, UndefinedType
27 from homeassistant.helpers.update_coordinator import CoordinatorEntity
28 
29 from . import get_device_name, legacy_device_id
30 from .const import (
31  ATTR_CURRENT_A,
32  ATTR_CURRENT_POWER_W,
33  ATTR_TODAY_ENERGY_KWH,
34  ATTR_TOTAL_ENERGY_KWH,
35  DOMAIN,
36  PRIMARY_STATE_ID,
37 )
38 from .coordinator import TPLinkDataUpdateCoordinator
39 from .deprecate import DeprecatedInfo, async_check_create_deprecated
40 
41 _LOGGER = logging.getLogger(__name__)
42 
43 # Mapping from upstream category to homeassistant category
44 FEATURE_CATEGORY_TO_ENTITY_CATEGORY = {
45  Feature.Category.Config: EntityCategory.CONFIG,
46  Feature.Category.Info: EntityCategory.DIAGNOSTIC,
47  Feature.Category.Debug: EntityCategory.DIAGNOSTIC,
48 }
49 
50 # Skips creating entities for primary features supported by a specialized platform.
51 # For example, we do not need a separate "state" switch for light bulbs.
52 DEVICETYPES_WITH_SPECIALIZED_PLATFORMS = {
53  DeviceType.Bulb,
54  DeviceType.LightStrip,
55  DeviceType.Dimmer,
56  DeviceType.Fan,
57  DeviceType.Thermostat,
58 }
59 
60 # Primary features to always include even when the device type has its own platform
61 FEATURES_ALLOW_LIST = {
62  # lights have current_consumption and a specialized platform
63  "current_consumption"
64 }
65 
66 
67 # Features excluded due to future platform additions
68 EXCLUDED_FEATURES = {
69  # update
70  "current_firmware_version",
71  "available_firmware_version",
72  "update_available",
73  "check_latest_firmware",
74  # siren
75  "alarm",
76 }
77 
78 
79 LEGACY_KEY_MAPPING = {
80  "current": ATTR_CURRENT_A,
81  "current_consumption": ATTR_CURRENT_POWER_W,
82  "consumption_today": ATTR_TODAY_ENERGY_KWH,
83  "consumption_total": ATTR_TOTAL_ENERGY_KWH,
84 }
85 
86 
87 @dataclass(frozen=True, kw_only=True)
89  """Base class for a TPLink feature based entity description."""
90 
91  deprecated_info: DeprecatedInfo | None = None
92 
93 
94 def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P](
95  func: Callable[Concatenate[_T, _P], Awaitable[None]],
96 ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
97  """Define a wrapper to raise HA errors and refresh after."""
98 
99  async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
100  try:
101  await func(self, *args, **kwargs)
102  except AuthenticationError as ex:
103  self.coordinator.config_entry.async_start_reauth(self.hass)
104  raise HomeAssistantError(
105  translation_domain=DOMAIN,
106  translation_key="device_authentication",
107  translation_placeholders={
108  "func": func.__name__,
109  "exc": str(ex),
110  },
111  ) from ex
112  except TimeoutError as ex:
113  raise HomeAssistantError(
114  translation_domain=DOMAIN,
115  translation_key="device_timeout",
116  translation_placeholders={
117  "func": func.__name__,
118  "exc": str(ex),
119  },
120  ) from ex
121  except KasaException as ex:
122  raise HomeAssistantError(
123  translation_domain=DOMAIN,
124  translation_key="device_error",
125  translation_placeholders={
126  "func": func.__name__,
127  "exc": str(ex),
128  },
129  ) from ex
130  await self.coordinator.async_request_refresh()
131 
132  return _async_wrap
133 
134 
135 class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], ABC):
136  """Common base class for all coordinated tplink entities."""
137 
138  _attr_has_entity_name = True
139  _device: Device
140 
141  def __init__(
142  self,
143  device: Device,
144  coordinator: TPLinkDataUpdateCoordinator,
145  *,
146  feature: Feature | None = None,
147  parent: Device | None = None,
148  ) -> None:
149  """Initialize the entity."""
150  super().__init__(coordinator)
151  self._device: Device = device
152  self._feature_feature = feature
153 
154  registry_device = device
155  device_name = get_device_name(device, parent=parent)
156  if parent and parent.device_type is not Device.Type.Hub:
157  if not feature or feature.id == PRIMARY_STATE_ID:
158  # Entity will be added to parent if not a hub and no feature
159  # parameter (i.e. core platform like Light, Fan) or the feature
160  # is the primary state
161  registry_device = parent
162  device_name = get_device_name(registry_device)
163  else:
164  # Prefix the device name with the parent name unless it is a
165  # hub attached device. Sensible default for child devices like
166  # strip plugs or the ks240 where the child alias makes more
167  # sense in the context of the parent. i.e. Hall Ceiling Fan &
168  # Bedroom Ceiling Fan; Child device aliases will be Ceiling Fan
169  # and Dimmer Switch for both so should be distinguished by the
170  # parent name.
171  device_name = f"{get_device_name(parent)} {get_device_name(device, parent=parent)}"
172 
173  self._attr_device_info_attr_device_info = DeviceInfo(
174  identifiers={(DOMAIN, str(registry_device.device_id))},
175  manufacturer="TP-Link",
176  model=registry_device.model,
177  name=device_name,
178  sw_version=registry_device.hw_info["sw_ver"],
179  hw_version=registry_device.hw_info["hw_ver"],
180  )
181 
182  if (
183  parent is not None
184  and parent != registry_device
185  and parent.device_type is not Device.Type.WallSwitch
186  ):
187  self._attr_device_info_attr_device_info["via_device"] = (DOMAIN, parent.device_id)
188  else:
189  self._attr_device_info_attr_device_info["connections"] = {
190  (dr.CONNECTION_NETWORK_MAC, device.mac)
191  }
192 
193  self._attr_unique_id_attr_unique_id = self._get_unique_id_get_unique_id()
194 
195  self._async_call_update_attrs_async_call_update_attrs()
196 
197  def _get_unique_id(self) -> str:
198  """Return unique ID for the entity."""
199  return legacy_device_id(self._device)
200 
201  @abstractmethod
202  @callback
203  def _async_update_attrs(self) -> None:
204  """Platforms implement this to update the entity internals."""
205  raise NotImplementedError
206 
207  @callback
208  def _async_call_update_attrs(self) -> None:
209  """Call update_attrs and make entity unavailable on errors."""
210  try:
211  self._async_update_attrs_async_update_attrs()
212  except Exception as ex: # noqa: BLE001
213  if self._attr_available_attr_available:
214  _LOGGER.warning(
215  "Unable to read data for %s %s: %s",
216  self._device,
217  self.entity_id,
218  ex,
219  )
220  self._attr_available_attr_available = False
221  else:
222  self._attr_available_attr_available = True
223 
224  @callback
225  def _handle_coordinator_update(self) -> None:
226  """Handle updated data from the coordinator."""
227  self._async_call_update_attrs_async_call_update_attrs()
229 
230  @property
231  def available(self) -> bool:
232  """Return if entity is available."""
233  return self.coordinator.last_update_success and self._attr_available_attr_available
234 
235 
237  """Common base class for all coordinated tplink feature entities."""
238 
239  entity_description: TPLinkFeatureEntityDescription
240  _feature: Feature
241 
242  def __init__(
243  self,
244  device: Device,
245  coordinator: TPLinkDataUpdateCoordinator,
246  *,
247  feature: Feature,
248  description: TPLinkFeatureEntityDescription,
249  parent: Device | None = None,
250  ) -> None:
251  """Initialize the entity."""
252  self.entity_descriptionentity_description = description
253  super().__init__(device, coordinator, parent=parent, feature=feature)
254 
255  def _get_unique_id(self) -> str:
256  """Return unique ID for the entity."""
257  return self._get_feature_unique_id_get_feature_unique_id(self._device, self.entity_descriptionentity_description)
258 
259  @staticmethod
261  device: Device, entity_description: TPLinkFeatureEntityDescription
262  ) -> str:
263  """Return unique ID for the entity."""
264  key = entity_description.key
265  # The unique id for the state feature in the switch platform is the
266  # device_id
267  if key == PRIMARY_STATE_ID:
268  return legacy_device_id(device)
269 
270  # Historically the legacy device emeter attributes which are now
271  # replaced with features used slightly different keys. This ensures
272  # that those entities are not orphaned. Returns the mapped key or the
273  # provided key if not mapped.
274  key = LEGACY_KEY_MAPPING.get(key, key)
275  return f"{legacy_device_id(device)}_{key}"
276 
277  @classmethod
278  def _category_for_feature(cls, feature: Feature | None) -> EntityCategory | None:
279  """Return entity category for a feature."""
280  # Main controls have no category
281  if feature is None or feature.category is Feature.Category.Primary:
282  return None
283 
284  if (
285  entity_category := FEATURE_CATEGORY_TO_ENTITY_CATEGORY.get(feature.category)
286  ) is None:
287  _LOGGER.error(
288  "Unhandled category %s, fallback to DIAGNOSTIC", feature.category
289  )
290  entity_category = EntityCategory.DIAGNOSTIC
291 
292  return entity_category
293 
294  @classmethod
295  def _description_for_feature[_D: EntityDescription](
296  cls,
297  feature: Feature,
298  descriptions: Mapping[str, _D],
299  *,
300  device: Device,
301  parent: Device | None = None,
302  ) -> _D | None:
303  """Return description object for the given feature.
304 
305  This is responsible for setting the common parameters & deciding
306  based on feature id which additional parameters are passed.
307  """
308 
309  if descriptions and (desc := descriptions.get(feature.id)):
310  translation_key: str | None = feature.id
311  # HA logic is to name entities based on the following logic:
312  # _attr_name > translation.name > description.name
313  # > device_class (if base platform supports).
314  name: str | None | UndefinedType = UNDEFINED
315 
316  # The state feature gets the device name or the child device
317  # name if it's a child device
318  if feature.id == PRIMARY_STATE_ID:
319  translation_key = None
320  # if None will use device name
321  name = get_device_name(device, parent=parent) if parent else None
322 
323  return replace(
324  desc,
325  translation_key=translation_key,
326  name=name, # if undefined will use translation key
327  entity_category=cls._category_for_feature_category_for_feature(feature),
328  # enabled_default can be overridden to False in the description
329  entity_registry_enabled_default=feature.category
330  is not Feature.Category.Debug
331  and desc.entity_registry_enabled_default,
332  )
333 
334  _LOGGER.debug(
335  "Device feature: %s (%s) needs an entity description defined in HA",
336  feature.name,
337  feature.id,
338  )
339  return None
340 
341  @classmethod
342  def _entities_for_device[
343  _E: CoordinatedTPLinkFeatureEntity,
344  _D: TPLinkFeatureEntityDescription,
345  ](
346  cls,
347  hass: HomeAssistant,
348  device: Device,
349  coordinator: TPLinkDataUpdateCoordinator,
350  *,
351  feature_type: Feature.Type,
352  entity_class: type[_E],
353  descriptions: Mapping[str, _D],
354  parent: Device | None = None,
355  ) -> list[_E]:
356  """Return a list of entities to add.
357 
358  This filters out unwanted features to avoid creating unnecessary entities
359  for device features that are implemented by specialized platforms like light.
360  """
361  entities: list[_E] = [
362  entity_class(
363  device,
364  coordinator,
365  feature=feat,
366  description=desc,
367  parent=parent,
368  )
369  for feat in device.features.values()
370  if feat.type == feature_type
371  and feat.id not in EXCLUDED_FEATURES
372  and (
373  feat.category is not Feature.Category.Primary
374  or device.device_type not in DEVICETYPES_WITH_SPECIALIZED_PLATFORMS
375  or feat.id in FEATURES_ALLOW_LIST
376  )
377  and (
378  desc := cls._description_for_feature(
379  feat, descriptions, device=device, parent=parent
380  )
381  )
383  hass,
384  cls._get_feature_unique_id_get_feature_unique_id(device, desc),
385  desc,
386  )
387  ]
388  return entities
389 
390  @classmethod
391  def entities_for_device_and_its_children[
392  _E: CoordinatedTPLinkFeatureEntity,
393  _D: TPLinkFeatureEntityDescription,
394  ](
395  cls,
396  hass: HomeAssistant,
397  device: Device,
398  coordinator: TPLinkDataUpdateCoordinator,
399  *,
400  feature_type: Feature.Type,
401  entity_class: type[_E],
402  descriptions: Mapping[str, _D],
403  child_coordinators: list[TPLinkDataUpdateCoordinator] | None = None,
404  ) -> list[_E]:
405  """Create entities for device and its children.
406 
407  This is a helper that calls *_entities_for_device* for the device and its children.
408  """
409  entities: list[_E] = []
410  # Add parent entities before children so via_device id works.
411  entities.extend(
412  cls._entities_for_device(
413  hass,
414  device,
415  coordinator=coordinator,
416  feature_type=feature_type,
417  entity_class=entity_class,
418  descriptions=descriptions,
419  )
420  )
421  if device.children:
422  _LOGGER.debug("Initializing device with %s children", len(device.children))
423  for idx, child in enumerate(device.children):
424  # HS300 does not like too many concurrent requests and its
425  # emeter data requires a request for each socket, so we receive
426  # separate coordinators.
427  if child_coordinators:
428  child_coordinator = child_coordinators[idx]
429  else:
430  child_coordinator = coordinator
431  entities.extend(
432  cls._entities_for_device(
433  hass,
434  child,
435  coordinator=child_coordinator,
436  feature_type=feature_type,
437  entity_class=entity_class,
438  descriptions=descriptions,
439  parent=device,
440  )
441  )
442 
443  return entities
bool async_check_create_deprecated(HomeAssistant hass, Platform platform, str unique_id, RingEntityDescription entity_description)
Definition: entity.py:97