__init__.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. """
  2. Platform for Tuya WiFi-connected devices.
  3. Based on nikrolls/homeassistant-goldair-climate for Goldair branded devices.
  4. Based on sean6541/tuya-homeassistant for service call logic, and TarxBoy's
  5. investigation into Goldair's tuyapi statuses
  6. https://github.com/codetheweb/tuyapi/issues/31.
  7. """
  8. import logging
  9. from homeassistant.config_entries import ConfigEntry
  10. from homeassistant.const import CONF_HOST
  11. from homeassistant.core import HomeAssistant, callback
  12. from homeassistant.exceptions import ConfigEntryNotReady
  13. from homeassistant.helpers.entity_registry import async_migrate_entries
  14. from homeassistant.util import slugify
  15. from .const import (
  16. CONF_DEVICE_ID,
  17. CONF_LOCAL_KEY,
  18. CONF_POLL_ONLY,
  19. CONF_PROTOCOL_VERSION,
  20. CONF_TYPE,
  21. DOMAIN,
  22. )
  23. from .device import async_delete_device, get_device_id, setup_device
  24. from .helpers.device_config import get_config
  25. _LOGGER = logging.getLogger(__name__)
  26. NOT_FOUND = "Configuration file for %s not found"
  27. async def async_migrate_entry(hass, entry: ConfigEntry):
  28. """Migrate to latest config format."""
  29. CONF_TYPE_AUTO = "auto"
  30. if entry.version == 1:
  31. # Removal of Auto detection.
  32. config = {**entry.data, **entry.options, "name": entry.title}
  33. if config[CONF_TYPE] == CONF_TYPE_AUTO:
  34. device = setup_device(hass, config)
  35. config[CONF_TYPE] = await device.async_inferred_type()
  36. if config[CONF_TYPE] is None:
  37. _LOGGER.error(
  38. "Unable to determine type for device %s",
  39. config[CONF_DEVICE_ID],
  40. )
  41. return False
  42. entry.data = {
  43. CONF_DEVICE_ID: config[CONF_DEVICE_ID],
  44. CONF_LOCAL_KEY: config[CONF_LOCAL_KEY],
  45. CONF_HOST: config[CONF_HOST],
  46. }
  47. entry.version = 2
  48. if entry.version == 2:
  49. # CONF_TYPE is not configurable, move from options to main config.
  50. config = {**entry.data, **entry.options, "name": entry.title}
  51. opts = {**entry.options}
  52. # Ensure type has been migrated. Some users are reporting errors which
  53. # suggest it was removed completely. But that is probably due to
  54. # overwriting options without CONF_TYPE.
  55. if config.get(CONF_TYPE, CONF_TYPE_AUTO) == CONF_TYPE_AUTO:
  56. device = setup_device(hass, config)
  57. config[CONF_TYPE] = await device.async_inferred_type()
  58. if config[CONF_TYPE] is None:
  59. _LOGGER.error(
  60. "Unable to determine type for device %s",
  61. config[CONF_DEVICE_ID],
  62. )
  63. return False
  64. entry.data = {
  65. CONF_DEVICE_ID: config[CONF_DEVICE_ID],
  66. CONF_LOCAL_KEY: config[CONF_LOCAL_KEY],
  67. CONF_HOST: config[CONF_HOST],
  68. CONF_TYPE: config[CONF_TYPE],
  69. }
  70. opts.pop(CONF_TYPE, None)
  71. entry.options = {**opts}
  72. entry.version = 3
  73. if entry.version == 3:
  74. # Migrate to filename based config_type, to avoid needing to
  75. # parse config files to find the right one.
  76. config = {**entry.data, **entry.options, "name": entry.title}
  77. config_type = get_config(config[CONF_TYPE]).config_type
  78. # Special case for kogan_switch. Consider also v2.
  79. if config_type == "smartplugv1":
  80. device = setup_device(hass, config)
  81. config_type = await device.async_inferred_type()
  82. if config_type != "smartplugv2":
  83. config_type = "smartplugv1"
  84. entry.data = {
  85. CONF_DEVICE_ID: config[CONF_DEVICE_ID],
  86. CONF_LOCAL_KEY: config[CONF_LOCAL_KEY],
  87. CONF_HOST: config[CONF_HOST],
  88. CONF_TYPE: config_type,
  89. }
  90. entry.version = 4
  91. if entry.version <= 5:
  92. # Migrate unique ids of existing entities to new format
  93. old_id = entry.unique_id
  94. conf_file = get_config(entry.data[CONF_TYPE])
  95. if conf_file is None:
  96. _LOGGER.error(NOT_FOUND, entry.data[CONF_TYPE])
  97. return False
  98. @callback
  99. def update_unique_id(entity_entry):
  100. """Update the unique id of an entity entry."""
  101. e = conf_file.primary_entity
  102. if e.entity != entity_entry.platform:
  103. for e in conf_file.secondary_entities():
  104. if e.entity == entity_entry.platform:
  105. break
  106. if e.entity == entity_entry.platform:
  107. new_id = e.unique_id(old_id)
  108. if new_id != old_id:
  109. _LOGGER.info(
  110. "Migrating %s unique_id %s to %s",
  111. e.entity,
  112. old_id,
  113. new_id,
  114. )
  115. return {
  116. "new_unique_id": entity_entry.unique_id.replace(
  117. old_id,
  118. new_id,
  119. )
  120. }
  121. await async_migrate_entries(hass, entry.entry_id, update_unique_id)
  122. entry.version = 6
  123. if entry.version <= 8:
  124. # Deprecated entities are removed, trim the config back to required
  125. # config only
  126. conf = {**entry.data, **entry.options}
  127. entry.data = {
  128. CONF_DEVICE_ID: conf[CONF_DEVICE_ID],
  129. CONF_LOCAL_KEY: conf[CONF_LOCAL_KEY],
  130. CONF_HOST: conf[CONF_HOST],
  131. CONF_TYPE: conf[CONF_TYPE],
  132. }
  133. entry.options = {}
  134. entry.version = 9
  135. if entry.version <= 9:
  136. # Added protocol_version, default to auto
  137. conf = {**entry.data, **entry.options}
  138. entry.data = {
  139. CONF_DEVICE_ID: conf[CONF_DEVICE_ID],
  140. CONF_LOCAL_KEY: conf[CONF_LOCAL_KEY],
  141. CONF_HOST: conf[CONF_HOST],
  142. CONF_TYPE: conf[CONF_TYPE],
  143. CONF_PROTOCOL_VERSION: "auto",
  144. }
  145. entry.options = {}
  146. entry.version = 10
  147. if entry.version <= 10:
  148. conf = entry.data | entry.options
  149. entry.data = {
  150. CONF_DEVICE_ID: conf[CONF_DEVICE_ID],
  151. CONF_LOCAL_KEY: conf[CONF_LOCAL_KEY],
  152. CONF_HOST: conf[CONF_HOST],
  153. CONF_TYPE: conf[CONF_TYPE],
  154. CONF_PROTOCOL_VERSION: "auto",
  155. CONF_POLL_ONLY: False,
  156. }
  157. entry.options = {}
  158. entry.version = 11
  159. if entry.version <= 11:
  160. # Migrate unique ids of existing entities to new format
  161. device_id = entry.unique_id
  162. conf_file = get_config(entry.data[CONF_TYPE])
  163. if conf_file is None:
  164. _LOGGER.error(
  165. NOT_FOUND,
  166. entry.data[CONF_TYPE],
  167. )
  168. return False
  169. @callback
  170. def update_unique_id12(entity_entry):
  171. """Update the unique id of an entity entry."""
  172. old_id = entity_entry.unique_id
  173. platform = entity_entry.entity_id.split(".", 1)[0]
  174. e = conf_file.primary_entity
  175. if e.name:
  176. expect_id = f"{device_id}-{slugify(e.name)}"
  177. else:
  178. expect_id = device_id
  179. if e.entity != platform or expect_id != old_id:
  180. for e in conf_file.secondary_entities():
  181. if e.name:
  182. expect_id = f"{device_id}-{slugify(e.name)}"
  183. else:
  184. expect_id = device_id
  185. if e.entity == platform and expect_id == old_id:
  186. break
  187. if e.entity == platform and expect_id == old_id:
  188. new_id = e.unique_id(device_id)
  189. if new_id != old_id:
  190. _LOGGER.info(
  191. "Migrating %s unique_id %s to %s",
  192. e.entity,
  193. old_id,
  194. new_id,
  195. )
  196. return {
  197. "new_unique_id": entity_entry.unique_id.replace(
  198. old_id,
  199. new_id,
  200. )
  201. }
  202. await async_migrate_entries(hass, entry.entry_id, update_unique_id12)
  203. entry.version = 12
  204. if entry.version <= 12:
  205. # Migrate unique ids of existing entities to new format taking into
  206. # account device_class if name is missing.
  207. device_id = entry.unique_id
  208. conf_file = get_config(entry.data[CONF_TYPE])
  209. if conf_file is None:
  210. _LOGGER.error(
  211. NOT_FOUND,
  212. entry.data[CONF_TYPE],
  213. )
  214. return False
  215. @callback
  216. def update_unique_id13(entity_entry):
  217. """Update the unique id of an entity entry."""
  218. old_id = entity_entry.unique_id
  219. platform = entity_entry.entity_id.split(".", 1)[0]
  220. # if unique_id ends with platform name, then this may have
  221. # changed with the addition of device_class.
  222. if old_id.endswith(platform):
  223. e = conf_file.primary_entity
  224. if e.entity != platform or e.name:
  225. for e in conf_file.secondary_entities():
  226. if e.entity == platform and not e.name:
  227. break
  228. if e.entity == platform and not e.name:
  229. new_id = e.unique_id(device_id)
  230. if new_id != old_id:
  231. _LOGGER.info(
  232. "Migrating %s unique_id %s to %s",
  233. e.entity,
  234. old_id,
  235. new_id,
  236. )
  237. return {
  238. "new_unique_id": entity_entry.unique_id.replace(
  239. old_id,
  240. new_id,
  241. )
  242. }
  243. else:
  244. replacements = {
  245. "sensor_co2": "sensor_carbon_dioxide",
  246. "sensor_co": "sensor_carbon_monoxide",
  247. "sensor_pm2_5": "sensor_pm25",
  248. "sensor_pm_10": "sensor_pm10",
  249. "sensor_pm_1_0": "sensor_pm1",
  250. "sensor_pm_2_5": "sensor_pm25",
  251. "sensor_tvoc": "sensor_volatile_organic_compounds",
  252. "sensor_current_humidity": "sensor_humidity",
  253. "sensor_current_temperature": "sensor_temperature",
  254. }
  255. for suffix, new_suffix in replacements.items():
  256. if old_id.endswith(suffix):
  257. e = conf_file.primary_entity
  258. if e.entity != platform or e.name:
  259. for e in conf_file.secondary_entities():
  260. if e.entity == platform and not e.name:
  261. break
  262. if e.entity == platform and not e.name:
  263. new_id = e.unique_id(device_id)
  264. if new_id.endswith(new_suffix):
  265. _LOGGER.info(
  266. "Migrating %s unique_id %s to %s",
  267. e.entity,
  268. old_id,
  269. new_id,
  270. )
  271. return {
  272. "new_unique_id": entity_entry.unique_id.replace(
  273. old_id,
  274. new_id,
  275. )
  276. }
  277. await async_migrate_entries(hass, entry.entry_id, update_unique_id13)
  278. entry.version = 13
  279. if entry.version == 13 and entry.minor_version < 2:
  280. # Migrate unique ids of existing entities to new id taking into
  281. # account translation_key, and standardising naming
  282. device_id = entry.unique_id
  283. conf_file = get_config(entry.data[CONF_TYPE])
  284. if conf_file is None:
  285. _LOGGER.error(
  286. NOT_FOUND,
  287. entry.data[CONF_TYPE],
  288. )
  289. return False
  290. @callback
  291. def update_unique_id13_2(entity_entry):
  292. """Update the unique id of an entity entry."""
  293. old_id = entity_entry.unique_id
  294. platform = entity_entry.entity_id.split(".", 1)[0]
  295. # Standardistion of entity naming to use translation_key
  296. replacements = {
  297. # special meaning of None to handle _full and _empty variants
  298. "binary_sensor_tank": None,
  299. "binary_sensor_tank_full_or_missing": "binary_sensor_tank_full",
  300. "binary_sensor_water_tank_full": "binary_sensor_tank_full",
  301. "binary_sensor_low_water": "binary_sensor_tank_empty",
  302. "binary_sensor_water_tank_empty": "binary_sensor_tank_empty",
  303. "binary_sensor_fault": "binary_sensor_problem",
  304. "binary_sensor_error": "binary_sensor_problem",
  305. "binary_sensor_fault_alarm": "binary_sensor_problem",
  306. "binary_sensor_errors": "binary_sensor_problem",
  307. "binary_sensor_defrosting": "binary_sensor_defrost",
  308. "binary_sensor_anti_frost": "binary_sensor_defrost",
  309. "binary_sensor_anti_freeze": "binary_sensor_defrost",
  310. "binary_sensor_low_battery": "binary_sensor_battery",
  311. "binary_sensor_low_battery_alarm": "binary_sensor_battery",
  312. "select_temperature_units": "select_temperature_unit",
  313. "select_display_temperature_unit": "select_temperature_unit",
  314. "select_display_unit": "select_temperature_unit",
  315. "select_display_units": "select_temperature_unit",
  316. "select_temperature_display_units": "select_temperature_unit",
  317. "switch_defrost": "switch_anti_frost",
  318. "switch_frost_protection": "switch_anti_frost",
  319. }
  320. for suffix, new_suffix in replacements.items():
  321. if old_id.endswith(suffix):
  322. e = conf_file.primary_entity
  323. if e.entity != platform or e.name:
  324. for e in conf_file.secondary_entities():
  325. if e.entity == platform and not e.name:
  326. break
  327. if e.entity == platform and not e.name:
  328. new_id = e.unique_id(device_id)
  329. if (new_suffix and new_id.endswith(new_suffix)) or (
  330. new_suffix is None and suffix in new_id
  331. ):
  332. _LOGGER.info(
  333. "Migrating %s unique_id %s to %s",
  334. e.entity,
  335. old_id,
  336. new_id,
  337. )
  338. return {
  339. "new_unique_id": entity_entry.unique_id.replace(
  340. old_id,
  341. new_id,
  342. )
  343. }
  344. await async_migrate_entries(hass, entry.entry_id, update_unique_id13_2)
  345. entry.minor_version = 2
  346. return True
  347. async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
  348. _LOGGER.debug(
  349. "Setting up entry for device: %s",
  350. get_device_id(entry.data),
  351. )
  352. config = {**entry.data, **entry.options, "name": entry.title}
  353. try:
  354. setup_device(hass, config)
  355. except Exception as e:
  356. raise ConfigEntryNotReady("tuya-local device not ready") from e
  357. device_conf = get_config(entry.data[CONF_TYPE])
  358. if device_conf is None:
  359. _LOGGER.error(NOT_FOUND, config[CONF_TYPE])
  360. return False
  361. entities = set()
  362. e = device_conf.primary_entity
  363. entities.add(e.entity)
  364. for e in device_conf.secondary_entities():
  365. entities.add(e.entity)
  366. await hass.config_entries.async_forward_entry_setups(entry, entities)
  367. entry.add_update_listener(async_update_entry)
  368. return True
  369. async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
  370. _LOGGER.debug("Unloading entry for device: %s", get_device_id(entry.data))
  371. config = entry.data
  372. data = hass.data[DOMAIN][get_device_id(config)]
  373. device_conf = get_config(config[CONF_TYPE])
  374. if device_conf is None:
  375. _LOGGER.error(NOT_FOUND, config[CONF_TYPE])
  376. return False
  377. entities = {}
  378. e = device_conf.primary_entity
  379. if e.config_id in data:
  380. entities[e.entity] = True
  381. for e in device_conf.secondary_entities():
  382. if e.config_id in data:
  383. entities[e.entity] = True
  384. for e in entities:
  385. await hass.config_entries.async_forward_entry_unload(entry, e)
  386. await async_delete_device(hass, config)
  387. del hass.data[DOMAIN][get_device_id(config)]
  388. return True
  389. async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry):
  390. _LOGGER.debug("Updating entry for device: %s", get_device_id(entry.data))
  391. await async_unload_entry(hass, entry)
  392. await async_setup_entry(hass, entry)