Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for Tuya Smart devices."""
2 
3 from __future__ import annotations
4 
5 import logging
6 from typing import Any, NamedTuple
7 
8 from tuya_sharing import (
9  CustomerDevice,
10  Manager,
11  SharingDeviceListener,
12  SharingTokenListener,
13 )
14 
15 from homeassistant.config_entries import ConfigEntry
16 from homeassistant.core import HomeAssistant, callback
17 from homeassistant.exceptions import ConfigEntryAuthFailed
18 from homeassistant.helpers import device_registry as dr
19 from homeassistant.helpers.dispatcher import dispatcher_send
20 
21 from .const import (
22  CONF_APP_TYPE,
23  CONF_ENDPOINT,
24  CONF_TERMINAL_ID,
25  CONF_TOKEN_INFO,
26  CONF_USER_CODE,
27  DOMAIN,
28  LOGGER,
29  PLATFORMS,
30  TUYA_CLIENT_ID,
31  TUYA_DISCOVERY_NEW,
32  TUYA_HA_SIGNAL_UPDATE_ENTITY,
33 )
34 
35 # Suppress logs from the library, it logs unneeded on error
36 logging.getLogger("tuya_sharing").setLevel(logging.CRITICAL)
37 
38 type TuyaConfigEntry = ConfigEntry[HomeAssistantTuyaData]
39 
40 
41 class HomeAssistantTuyaData(NamedTuple):
42  """Tuya data stored in the Home Assistant data object."""
43 
44  manager: Manager
45  listener: SharingDeviceListener
46 
47 
48 async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool:
49  """Async setup hass config entry."""
50  if CONF_APP_TYPE in entry.data:
51  raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.")
52 
53  token_listener = TokenListener(hass, entry)
54  manager = Manager(
55  TUYA_CLIENT_ID,
56  entry.data[CONF_USER_CODE],
57  entry.data[CONF_TERMINAL_ID],
58  entry.data[CONF_ENDPOINT],
59  entry.data[CONF_TOKEN_INFO],
60  token_listener,
61  )
62 
63  listener = DeviceListener(hass, manager)
64  manager.add_device_listener(listener)
65 
66  # Get all devices from Tuya
67  try:
68  await hass.async_add_executor_job(manager.update_device_cache)
69  except Exception as exc:
70  # While in general, we should avoid catching broad exceptions,
71  # we have no other way of detecting this case.
72  if "sign invalid" in str(exc):
73  msg = "Authentication failed. Please re-authenticate"
74  raise ConfigEntryAuthFailed(msg) from exc
75  raise
76 
77  # Connection is successful, store the manager & listener
78  entry.runtime_data = HomeAssistantTuyaData(manager=manager, listener=listener)
79 
80  # Cleanup device registry
81  await cleanup_device_registry(hass, manager)
82 
83  # Register known device IDs
84  device_registry = dr.async_get(hass)
85  for device in manager.device_map.values():
86  device_registry.async_get_or_create(
87  config_entry_id=entry.entry_id,
88  identifiers={(DOMAIN, device.id)},
89  manufacturer="Tuya",
90  name=device.name,
91  model=f"{device.product_name} (unsupported)",
92  model_id=device.product_id,
93  )
94 
95  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
96  # If the device does not register any entities, the device does not need to subscribe
97  # So the subscription is here
98  await hass.async_add_executor_job(manager.refresh_mq)
99  return True
100 
101 
102 async def cleanup_device_registry(hass: HomeAssistant, device_manager: Manager) -> None:
103  """Remove deleted device registry entry if there are no remaining entities."""
104  device_registry = dr.async_get(hass)
105  for dev_id, device_entry in list(device_registry.devices.items()):
106  for item in device_entry.identifiers:
107  if item[0] == DOMAIN and item[1] not in device_manager.device_map:
108  device_registry.async_remove_device(dev_id)
109  break
110 
111 
112 async def async_unload_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool:
113  """Unloading the Tuya platforms."""
114  if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
115  tuya = entry.runtime_data
116  if tuya.manager.mq is not None:
117  tuya.manager.mq.stop()
118  tuya.manager.remove_device_listener(tuya.listener)
119  return unload_ok
120 
121 
122 async def async_remove_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> None:
123  """Remove a config entry.
124 
125  This will revoke the credentials from Tuya.
126  """
127  manager = Manager(
128  TUYA_CLIENT_ID,
129  entry.data[CONF_USER_CODE],
130  entry.data[CONF_TERMINAL_ID],
131  entry.data[CONF_ENDPOINT],
132  entry.data[CONF_TOKEN_INFO],
133  )
134  await hass.async_add_executor_job(manager.unload)
135 
136 
137 class DeviceListener(SharingDeviceListener):
138  """Device Update Listener."""
139 
140  def __init__(
141  self,
142  hass: HomeAssistant,
143  manager: Manager,
144  ) -> None:
145  """Init DeviceListener."""
146  self.hasshass = hass
147  self.managermanager = manager
148 
150  self, device: CustomerDevice, updated_status_properties: list[str] | None
151  ) -> None:
152  """Update device status."""
153  LOGGER.debug(
154  "Received update for device %s: %s (updated properties: %s)",
155  device.id,
156  self.managermanager.device_map[device.id].status,
157  updated_status_properties,
158  )
160  self.hasshass,
161  f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}",
162  updated_status_properties,
163  )
164 
165  def add_device(self, device: CustomerDevice) -> None:
166  """Add device added listener."""
167  # Ensure the device isn't present stale
168  self.hasshass.add_job(self.async_remove_deviceasync_remove_device, device.id)
169 
170  dispatcher_send(self.hasshass, TUYA_DISCOVERY_NEW, [device.id])
171 
172  def remove_device(self, device_id: str) -> None:
173  """Add device removed listener."""
174  self.hasshass.add_job(self.async_remove_deviceasync_remove_device, device_id)
175 
176  @callback
177  def async_remove_device(self, device_id: str) -> None:
178  """Remove device from Home Assistant."""
179  LOGGER.debug("Remove device: %s", device_id)
180  device_registry = dr.async_get(self.hasshass)
181  device_entry = device_registry.async_get_device(
182  identifiers={(DOMAIN, device_id)}
183  )
184  if device_entry is not None:
185  device_registry.async_remove_device(device_entry.id)
186 
187 
188 class TokenListener(SharingTokenListener):
189  """Token listener for upstream token updates."""
190 
191  def __init__(
192  self,
193  hass: HomeAssistant,
194  entry: TuyaConfigEntry,
195  ) -> None:
196  """Init TokenListener."""
197  self.hasshass = hass
198  self.entryentry = entry
199 
200  def update_token(self, token_info: dict[str, Any]) -> None:
201  """Update token info in config entry."""
202  data = {
203  **self.entryentry.data,
204  CONF_TOKEN_INFO: {
205  "t": token_info["t"],
206  "uid": token_info["uid"],
207  "expire_time": token_info["expire_time"],
208  "access_token": token_info["access_token"],
209  "refresh_token": token_info["refresh_token"],
210  },
211  }
212 
213  @callback
214  def async_update_entry() -> None:
215  """Update config entry."""
216  self.hasshass.config_entries.async_update_entry(self.entryentry, data=data)
217 
218  self.hasshass.add_job(async_update_entry)
None update_device(self, CustomerDevice device, list[str]|None updated_status_properties)
Definition: __init__.py:151
None __init__(self, HomeAssistant hass, Manager manager)
Definition: __init__.py:144
None async_remove_device(self, str device_id)
Definition: __init__.py:177
None add_device(self, CustomerDevice device)
Definition: __init__.py:165
None remove_device(self, str device_id)
Definition: __init__.py:172
None update_token(self, dict[str, Any] token_info)
Definition: __init__.py:200
None __init__(self, HomeAssistant hass, TuyaConfigEntry entry)
Definition: __init__.py:195
None async_update_entry(HomeAssistant hass, PhilipsTVConfigEntry entry)
Definition: __init__.py:64
bool async_unload_entry(HomeAssistant hass, TuyaConfigEntry entry)
Definition: __init__.py:112
None async_remove_entry(HomeAssistant hass, TuyaConfigEntry entry)
Definition: __init__.py:122
bool async_setup_entry(HomeAssistant hass, TuyaConfigEntry entry)
Definition: __init__.py:48
None cleanup_device_registry(HomeAssistant hass, Manager device_manager)
Definition: __init__.py:102
None dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:137