cloud.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import logging
  2. from typing import Any
  3. from homeassistant.core import HomeAssistant
  4. from tuya_sharing import (
  5. CustomerDevice,
  6. LoginControl,
  7. Manager,
  8. SharingDeviceListener,
  9. SharingTokenListener,
  10. )
  11. from .const import (
  12. CONF_DEVICE_CID,
  13. CONF_ENDPOINT,
  14. CONF_LOCAL_KEY,
  15. CONF_TERMINAL_ID,
  16. DOMAIN,
  17. TUYA_CLIENT_ID,
  18. TUYA_RESPONSE_CODE,
  19. TUYA_RESPONSE_MSG,
  20. TUYA_RESPONSE_QR_CODE,
  21. TUYA_RESPONSE_RESULT,
  22. TUYA_RESPONSE_SUCCESS,
  23. TUYA_SCHEMA,
  24. )
  25. _LOGGER = logging.getLogger(__name__)
  26. HUB_CATEGORIES = [
  27. "wgsxj", # Gateway camera
  28. "lyqwg", # Router
  29. "bywg", # IoT edge gateway
  30. "zigbee", # Gateway
  31. "wg2", # Gateway
  32. "dgnzk", # Multi-function controller
  33. "videohub", # Videohub
  34. "xnwg", # Virtual gateway
  35. "qtyycp", # Voice gateway composite solution
  36. "alexa_yywg", # Gateway with Alexa
  37. "gywg", # Industrial gateway
  38. "cnwg", # Energy gateway
  39. "wnykq", # Smart IR
  40. "wfcon", # new type of Zigbee gateway
  41. ]
  42. class Cloud:
  43. """Optional Tuya cloud interface for getting device information."""
  44. def __init__(self, hass: HomeAssistant):
  45. self.__login_control = LoginControl()
  46. self.__authentication = {}
  47. self.__user_code = None
  48. self.__qr_code = None
  49. self.__hass = hass
  50. self.__error_code = None
  51. self.__error_msg = None
  52. # Restore cached authentication
  53. if cached := self.__hass.data[DOMAIN].get("auth_cache"):
  54. self.__authentication = cached
  55. async def async_get_qr_code(self, user_code: str | None = None) -> bool:
  56. """Get QR code from Tuya server for user code authentication."""
  57. if not user_code:
  58. user_code = self.__user_code
  59. if not user_code:
  60. _LOGGER.error("Cannot get QR code without a user code")
  61. return False, {TUYA_RESPONSE_MSG: "QR code requires a user code"}
  62. response = await self.__hass.async_add_executor_job(
  63. self.__login_control.qr_code,
  64. TUYA_CLIENT_ID,
  65. TUYA_SCHEMA,
  66. user_code,
  67. )
  68. if response.get(TUYA_RESPONSE_SUCCESS, False):
  69. self.__user_code = user_code
  70. self.__qr_code = response[TUYA_RESPONSE_RESULT][TUYA_RESPONSE_QR_CODE]
  71. return self.__qr_code
  72. _LOGGER.error("Failed to get QR code: %s", response)
  73. self.__error_code = response.get(TUYA_RESPONSE_CODE, {})
  74. self.__error_msg = response.get(TUYA_RESPONSE_MSG, "Unknown error")
  75. return False
  76. async def async_login(self) -> bool:
  77. """Login to the Tuya cloud."""
  78. if not self.__user_code or not self.__qr_code:
  79. _LOGGER.warning("Login attempted without successful QR scan")
  80. return False, {}
  81. success, info = await self.__hass.async_add_executor_job(
  82. self.__login_control.login_result,
  83. self.__qr_code,
  84. TUYA_CLIENT_ID,
  85. self.__user_code,
  86. )
  87. if success:
  88. self.__authentication = {
  89. "user_code": self.__user_code,
  90. "terminal_id": info[CONF_TERMINAL_ID],
  91. "endpoint": info[CONF_ENDPOINT],
  92. "token_info": {
  93. "t": info["t"],
  94. "uid": info["uid"],
  95. "expire_time": info["expire_time"],
  96. "access_token": info["access_token"],
  97. "refresh_token": info["refresh_token"],
  98. },
  99. }
  100. self.__hass.data[DOMAIN]["auth_cache"] = self.__authentication
  101. else:
  102. _LOGGER.warning("Login failed: %s", info)
  103. self.__error_code = info.get(TUYA_RESPONSE_CODE, {})
  104. self.__error_msg = info.get(TUYA_RESPONSE_MSG, "Unknown error")
  105. # Ensure expired authentication is cleared on next attempt
  106. self.__hass.data[DOMAIN]["auth_cache"] = None
  107. self.__authentication = {}
  108. return success
  109. async def async_get_devices(self) -> dict[str, Any]:
  110. """Get all devices associated with the account."""
  111. token_listener = TokenListener(self.__hass)
  112. manager = Manager(
  113. TUYA_CLIENT_ID,
  114. self.__authentication["user_code"],
  115. self.__authentication["terminal_id"],
  116. self.__authentication["endpoint"],
  117. self.__authentication["token_info"],
  118. token_listener,
  119. )
  120. listener = DeviceListener(self.__hass, manager)
  121. manager.add_device_listener(listener)
  122. # Get all devices from Tuya cloud
  123. await self.__hass.async_add_executor_job(manager.update_device_cache)
  124. # Register known device IDs
  125. cloud_devices = {}
  126. domain_data = self.__hass.data.get(DOMAIN)
  127. for device in manager.device_map.values():
  128. cloud_device = {
  129. "category": device.category,
  130. "id": device.id,
  131. "ip": device.ip,
  132. CONF_LOCAL_KEY: device.local_key
  133. if hasattr(device, CONF_LOCAL_KEY)
  134. else "",
  135. "name": device.name,
  136. "node_id": device.node_id if hasattr(device, "node_id") else "",
  137. "online": device.online,
  138. "product_id": device.product_id,
  139. "product_name": device.product_name,
  140. "uid": device.uid,
  141. "uuid": device.uuid,
  142. "support_local": device.support_local,
  143. CONF_DEVICE_CID: None,
  144. "version": None,
  145. "is_hub": (
  146. device.category in HUB_CATEGORIES
  147. or not hasattr(device, "local_key")
  148. ),
  149. }
  150. _LOGGER.debug("Found device: %s", cloud_device["product_name"])
  151. existing_id = domain_data.get(cloud_device["id"]) if domain_data else None
  152. existing_uuid = (
  153. domain_data.get(cloud_device["uuid"]) if domain_data else None
  154. )
  155. existing = existing_id or existing_uuid
  156. cloud_device["exists"] = existing and existing.get("device")
  157. if hasattr(device, "node_id"):
  158. index = "/".join(
  159. [
  160. cloud_device["id"],
  161. cloud_device["node_id"],
  162. ]
  163. )
  164. else:
  165. index = cloud_device["id"]
  166. cloud_devices[index] = cloud_device
  167. return cloud_devices
  168. async def async_get_datamodel(self, device_id) -> dict[str, Any] | None:
  169. """Get the data model for the specified device (QueryThingsDataModel)."""
  170. token_listener = TokenListener(self.__hass)
  171. manager = Manager(
  172. TUYA_CLIENT_ID,
  173. self.__authentication["user_code"],
  174. self.__authentication["terminal_id"],
  175. self.__authentication["endpoint"],
  176. self.__authentication["token_info"],
  177. token_listener,
  178. )
  179. response = await self.__hass.async_add_executor_job(
  180. manager.customer_api.get,
  181. f"/v1.0/m/life/devices/{device_id}/status",
  182. )
  183. _LOGGER.debug("Datamodel response: %s", response)
  184. if response.get("result"):
  185. response = response["result"]
  186. transform = []
  187. for entry in response.get("dpStatusRelationDTOS"):
  188. if entry["supportLocal"]:
  189. transform.append(
  190. {
  191. "id": entry["dpId"],
  192. "name": entry["dpCode"],
  193. "type": entry["valueType"],
  194. "format": entry["valueDesc"],
  195. "enumMap": entry["enumMappingMap"],
  196. }
  197. )
  198. return transform
  199. def logout(self) -> None:
  200. """Logout from the Tuya cloud."""
  201. _LOGGER.debug("Logging out from Tuya cloud")
  202. # Clear authentication cache
  203. self.__hass.data[DOMAIN]["auth_cache"] = None
  204. self.__authentication = {}
  205. @property
  206. def is_authenticated(self) -> bool:
  207. """Is the cloud account authenticated?"""
  208. return True if self.__authentication else False
  209. @property
  210. def last_error(self) -> dict[str, Any] | None:
  211. """The last cloud error code and message, if any."""
  212. if self.__error_code is not None:
  213. return {
  214. TUYA_RESPONSE_MSG: self.__error_msg,
  215. TUYA_RESPONSE_CODE: self.__error_code,
  216. }
  217. class DeviceListener(SharingDeviceListener):
  218. """Device update listener."""
  219. def __init__(
  220. self,
  221. hass: HomeAssistant,
  222. manager: Manager,
  223. ):
  224. self.__hass = hass
  225. self._manager = manager
  226. def update_device(
  227. self,
  228. device: CustomerDevice,
  229. updated_status_properties: list[str] | None,
  230. ) -> None:
  231. """Device status has updated."""
  232. _LOGGER.debug(
  233. "Received update for device %s: %s (properties %s)",
  234. device.id,
  235. self._manager.device_map[device.id].status,
  236. updated_status_properties,
  237. )
  238. def add_device(self, device: CustomerDevice) -> None:
  239. """A new device has been added."""
  240. _LOGGER.debug(
  241. "Received add device %s: %s",
  242. device.id,
  243. self._manager.device_map[device.id].status,
  244. )
  245. def remove_device(self, device_id: str) -> None:
  246. """A device has been removed."""
  247. _LOGGER.debug(
  248. "Received remove device %s: %s",
  249. device_id,
  250. self._manager.device_map[device_id].status,
  251. )
  252. class TokenListener(SharingTokenListener):
  253. """Listener for upstream token updates.
  254. This is only needed to get some debug output when tokens are refreshed."""
  255. def __init__(self, hass: HomeAssistant):
  256. self.__hass = hass
  257. def update_token(self, token_info: dict[str, Any]) -> None:
  258. """Update the token information."""
  259. _LOGGER.debug("Token updated")