diagnostics.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. """Diagnostics support for tuya-local."""
  2. from __future__ import annotations
  3. from typing import Any
  4. from homeassistant.components.diagnostics import REDACTED
  5. from homeassistant.config_entries import ConfigEntry
  6. from homeassistant.const import CONF_HOST
  7. from homeassistant.core import HomeAssistant, callback
  8. from homeassistant.helpers import entity_registry as er
  9. from homeassistant.helpers.device_registry import DeviceEntry
  10. from tinytuya import __version__ as tinytuya_version
  11. from .const import (
  12. API_PROTOCOL_VERSIONS,
  13. CONF_DEVICE_CID,
  14. CONF_PROTOCOL_VERSION,
  15. CONF_TYPE,
  16. DOMAIN,
  17. )
  18. from .device import TuyaLocalDevice
  19. from .helpers.config import get_device_id
  20. async def async_get_config_entry_diagnostics(
  21. hass: HomeAssistant, entry: ConfigEntry
  22. ) -> dict[str, Any]:
  23. """Return diagnostics for a config entry."""
  24. return _async_get_diagnostics(hass, entry)
  25. async def async_get_device_diagnostics(
  26. hass: HomeAssistant, entry: ConfigEntry, device_entry: DeviceEntry
  27. ) -> dict[str, Any]:
  28. """Return diagnostics for a device entry."""
  29. return _async_get_diagnostics(hass, entry, device_entry)
  30. @callback
  31. def _async_get_diagnostics(
  32. hass: HomeAssistant,
  33. entry: ConfigEntry,
  34. device_entry: DeviceEntry | None = None,
  35. ) -> dict[str, Any]:
  36. """Return diagnostics for a tuya-local config entry."""
  37. hass_data = hass.data[DOMAIN][get_device_id(entry.data)]
  38. hostname = entry.data.get(CONF_HOST, "")
  39. data = {
  40. "name": entry.title,
  41. "type": entry.data[CONF_TYPE],
  42. "device_id": REDACTED,
  43. "device_cid": REDACTED if entry.data.get(CONF_DEVICE_CID, "") != "" else "",
  44. "local_key": REDACTED,
  45. "host": REDACTED
  46. if hostname != "" and hostname.casefold() != "auto"
  47. else hostname,
  48. "protocol_version": entry.data[CONF_PROTOCOL_VERSION],
  49. "tinytuya_version": tinytuya_version,
  50. }
  51. # The DeviceEntry also has interesting looking data, but this
  52. # integration does not publish anything to it other than some hardcoded
  53. # values that don't change between devices. Augment it with runtime info
  54. data |= _async_device_as_dict(hass, hass_data["device"], device_entry)
  55. return data
  56. def redact_dps(device: TuyaLocalDevice, dps: dict[str, Any]) -> dict[str, Any]:
  57. """Redact any sensitive data from a list of dps"""
  58. sensitive = []
  59. for entity in device._children:
  60. for dp in entity._config.dps():
  61. if dp.sensitive:
  62. sensitive.append(dp.id)
  63. return {k: (REDACTED if k in sensitive else v) for (k, v) in dps.items()}
  64. def redact_entity(
  65. device: TuyaLocalDevice,
  66. entity_unique_id: str | None,
  67. state_dict: dict[str, Any],
  68. ) -> dict[str, Any]:
  69. """Redact any sensitive dps from an entity's state.
  70. Sensitive dps the entity publishes as extra attributes are redacted by
  71. name. A sensitive dp consumed by the platform itself, such as a text
  72. entity's `value`, is reported as the state instead, so the state is
  73. redacted only when it actually carries that dp's value - blanking it
  74. unconditionally would discard useful states such as a camera's
  75. `recording`, whose sensitive `snapshot` dp is never the state.
  76. """
  77. names = []
  78. values = []
  79. redacted = dict(state_dict)
  80. # Context is not useful information
  81. redacted.pop("context", None)
  82. # Redact entity_picture in case it is sensitive
  83. if "entity_picture" in redacted.get("attributes"):
  84. redacted["attributes"] = {
  85. **redacted["attributes"],
  86. "entity_picture": REDACTED,
  87. }
  88. for entity in device._children:
  89. if entity._config.unique_id(device.unique_id) != entity_unique_id:
  90. continue
  91. for dp in entity._config.dps():
  92. if not dp.sensitive:
  93. continue
  94. names.append(dp.name)
  95. value = dp.get_value(device)
  96. if value is not None:
  97. values.append(str(value))
  98. if not names:
  99. return redacted
  100. if isinstance(redacted.get("attributes"), dict):
  101. redacted["attributes"] = {
  102. k: (REDACTED if k in names else v)
  103. for (k, v) in redacted["attributes"].items()
  104. }
  105. if "state" in redacted and str(redacted["state"]) in values:
  106. redacted["state"] = REDACTED
  107. return redacted
  108. @callback
  109. def _async_device_as_dict(
  110. hass: HomeAssistant,
  111. device: TuyaLocalDevice,
  112. device_entry: DeviceEntry | None = None,
  113. ) -> dict[str, Any]:
  114. """Represent a Tuya Local device as a dictionary."""
  115. # Base device information, without sensitive information
  116. data = {
  117. "name": device.name,
  118. "api_version_set": device._api.version,
  119. "api_version_used": (
  120. "none"
  121. if device._api_protocol_version_index is None
  122. else API_PROTOCOL_VERSIONS[device._api_protocol_version_index]
  123. ),
  124. "api_working": device._api_protocol_working,
  125. "status": device._api.dps_cache,
  126. "cached_state": redact_dps(device, device._cached_state),
  127. "pending_state": redact_dps(device, device._pending_updates),
  128. "connected": device._running,
  129. "force_dps": device._force_dps,
  130. }
  131. if device_entry:
  132. data["home_assistant"] = {
  133. "name": device_entry.name,
  134. "name_by_user": device_entry.name_by_user,
  135. "disabled": device_entry.disabled,
  136. "disabled_by": device_entry.disabled_by,
  137. "entities": [],
  138. }
  139. hass_entities = er.async_entries_for_device(
  140. er.async_get(hass),
  141. device_id=device_entry.id,
  142. include_disabled_entities=True,
  143. )
  144. for entity_entry in hass_entities:
  145. state = hass.states.get(entity_entry.entity_id)
  146. state_dict = None
  147. if state:
  148. state_dict = redact_entity(
  149. device,
  150. entity_entry.unique_id,
  151. state.as_dict(),
  152. )
  153. data["home_assistant"]["entities"].append(
  154. {
  155. "disabled": entity_entry.disabled,
  156. "disabled_by": entity_entry.disabled_by,
  157. "entity_category": entity_entry.entity_category,
  158. "device_class": entity_entry.device_class,
  159. "original_device_class": entity_entry.original_device_class,
  160. "icon": entity_entry.icon,
  161. "unit_of_measurement": entity_entry.unit_of_measurement,
  162. "state": state_dict,
  163. }
  164. )
  165. return data