__init__.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  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. hass.config_entries.async_update_entry(
  43. entry,
  44. data={
  45. CONF_DEVICE_ID: config[CONF_DEVICE_ID],
  46. CONF_LOCAL_KEY: config[CONF_LOCAL_KEY],
  47. CONF_HOST: config[CONF_HOST],
  48. },
  49. version=2,
  50. )
  51. if entry.version == 2:
  52. # CONF_TYPE is not configurable, move from options to main config.
  53. config = {**entry.data, **entry.options, "name": entry.title}
  54. opts = {**entry.options}
  55. # Ensure type has been migrated. Some users are reporting errors which
  56. # suggest it was removed completely. But that is probably due to
  57. # overwriting options without CONF_TYPE.
  58. if config.get(CONF_TYPE, CONF_TYPE_AUTO) == CONF_TYPE_AUTO:
  59. device = setup_device(hass, config)
  60. config[CONF_TYPE] = await device.async_inferred_type()
  61. if config[CONF_TYPE] is None:
  62. _LOGGER.error(
  63. "Unable to determine type for device %s",
  64. config[CONF_DEVICE_ID],
  65. )
  66. return False
  67. opts.pop(CONF_TYPE, None)
  68. hass.config_entries.async_update_entry(
  69. entry,
  70. data={
  71. CONF_DEVICE_ID: config[CONF_DEVICE_ID],
  72. CONF_LOCAL_KEY: config[CONF_LOCAL_KEY],
  73. CONF_HOST: config[CONF_HOST],
  74. CONF_TYPE: config[CONF_TYPE],
  75. },
  76. options={**opts},
  77. version=3,
  78. )
  79. if entry.version == 3:
  80. # Migrate to filename based config_type, to avoid needing to
  81. # parse config files to find the right one.
  82. config = {**entry.data, **entry.options, "name": entry.title}
  83. config_type = get_config(config[CONF_TYPE]).config_type
  84. # Special case for kogan_switch. Consider also v2.
  85. if config_type == "smartplugv1":
  86. device = setup_device(hass, config)
  87. config_type = await device.async_inferred_type()
  88. if config_type != "smartplugv2":
  89. config_type = "smartplugv1"
  90. hass.config_entries.async_update_entry(
  91. entry,
  92. data={
  93. CONF_DEVICE_ID: config[CONF_DEVICE_ID],
  94. CONF_LOCAL_KEY: config[CONF_LOCAL_KEY],
  95. CONF_HOST: config[CONF_HOST],
  96. CONF_TYPE: config_type,
  97. },
  98. version=4,
  99. )
  100. if entry.version <= 5:
  101. # Migrate unique ids of existing entities to new format
  102. old_id = entry.unique_id
  103. conf_file = get_config(entry.data[CONF_TYPE])
  104. if conf_file is None:
  105. _LOGGER.error(NOT_FOUND, entry.data[CONF_TYPE])
  106. return False
  107. @callback
  108. def update_unique_id(entity_entry):
  109. """Update the unique id of an entity entry."""
  110. e = conf_file.primary_entity
  111. if e.entity != entity_entry.platform:
  112. for e in conf_file.secondary_entities():
  113. if e.entity == entity_entry.platform:
  114. break
  115. if e.entity == entity_entry.platform:
  116. new_id = e.unique_id(old_id)
  117. if new_id != old_id:
  118. _LOGGER.info(
  119. "Migrating %s unique_id %s to %s",
  120. e.entity,
  121. old_id,
  122. new_id,
  123. )
  124. return {
  125. "new_unique_id": entity_entry.unique_id.replace(
  126. old_id,
  127. new_id,
  128. )
  129. }
  130. await async_migrate_entries(hass, entry.entry_id, update_unique_id)
  131. hass.config_entries.async_update_entry(entry, version=6)
  132. if entry.version <= 8:
  133. # Deprecated entities are removed, trim the config back to required
  134. # config only
  135. conf = {**entry.data, **entry.options}
  136. hass.config_entries.async_update_entry(
  137. entry,
  138. 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. },
  144. options={},
  145. version=9,
  146. )
  147. if entry.version <= 9:
  148. # Added protocol_version, default to auto
  149. conf = {**entry.data, **entry.options}
  150. hass.config_entries.async_update_entry(
  151. entry,
  152. data={
  153. CONF_DEVICE_ID: conf[CONF_DEVICE_ID],
  154. CONF_LOCAL_KEY: conf[CONF_LOCAL_KEY],
  155. CONF_HOST: conf[CONF_HOST],
  156. CONF_TYPE: conf[CONF_TYPE],
  157. CONF_PROTOCOL_VERSION: "auto",
  158. },
  159. options={},
  160. version=10,
  161. )
  162. if entry.version <= 10:
  163. conf = entry.data | entry.options
  164. hass.config_entries.async_update_entry(
  165. entry,
  166. data={
  167. CONF_DEVICE_ID: conf[CONF_DEVICE_ID],
  168. CONF_LOCAL_KEY: conf[CONF_LOCAL_KEY],
  169. CONF_HOST: conf[CONF_HOST],
  170. CONF_TYPE: conf[CONF_TYPE],
  171. CONF_PROTOCOL_VERSION: conf[CONF_PROTOCOL_VERSION],
  172. CONF_POLL_ONLY: False,
  173. },
  174. options={},
  175. version=11,
  176. )
  177. if entry.version <= 11:
  178. # Migrate unique ids of existing entities to new format
  179. device_id = entry.unique_id
  180. conf_file = get_config(entry.data[CONF_TYPE])
  181. if conf_file is None:
  182. _LOGGER.error(
  183. NOT_FOUND,
  184. entry.data[CONF_TYPE],
  185. )
  186. return False
  187. @callback
  188. def update_unique_id12(entity_entry):
  189. """Update the unique id of an entity entry."""
  190. old_id = entity_entry.unique_id
  191. platform = entity_entry.entity_id.split(".", 1)[0]
  192. e = conf_file.primary_entity
  193. if e.name:
  194. expect_id = f"{device_id}-{slugify(e.name)}"
  195. else:
  196. expect_id = device_id
  197. if e.entity != platform or expect_id != old_id:
  198. for e in conf_file.secondary_entities():
  199. if e.name:
  200. expect_id = f"{device_id}-{slugify(e.name)}"
  201. else:
  202. expect_id = device_id
  203. if e.entity == platform and expect_id == old_id:
  204. break
  205. if e.entity == platform and expect_id == old_id:
  206. new_id = e.unique_id(device_id)
  207. if new_id != old_id:
  208. _LOGGER.info(
  209. "Migrating %s unique_id %s to %s",
  210. e.entity,
  211. old_id,
  212. new_id,
  213. )
  214. return {
  215. "new_unique_id": entity_entry.unique_id.replace(
  216. old_id,
  217. new_id,
  218. )
  219. }
  220. await async_migrate_entries(hass, entry.entry_id, update_unique_id12)
  221. hass.config_entries.async_update_entry(entry, version=12)
  222. if entry.version <= 12:
  223. # Migrate unique ids of existing entities to new format taking into
  224. # account device_class if name is missing.
  225. device_id = entry.unique_id
  226. conf_file = get_config(entry.data[CONF_TYPE])
  227. if conf_file is None:
  228. _LOGGER.error(
  229. NOT_FOUND,
  230. entry.data[CONF_TYPE],
  231. )
  232. return False
  233. @callback
  234. def update_unique_id13(entity_entry):
  235. """Update the unique id of an entity entry."""
  236. old_id = entity_entry.unique_id
  237. platform = entity_entry.entity_id.split(".", 1)[0]
  238. # if unique_id ends with platform name, then this may have
  239. # changed with the addition of device_class.
  240. if old_id.endswith(platform):
  241. e = conf_file.primary_entity
  242. if e.entity != platform or e.name:
  243. for e in conf_file.secondary_entities():
  244. if e.entity == platform and not e.name:
  245. break
  246. if e.entity == platform and not e.name:
  247. new_id = e.unique_id(device_id)
  248. if new_id != old_id:
  249. _LOGGER.info(
  250. "Migrating %s unique_id %s to %s",
  251. e.entity,
  252. old_id,
  253. new_id,
  254. )
  255. return {
  256. "new_unique_id": entity_entry.unique_id.replace(
  257. old_id,
  258. new_id,
  259. )
  260. }
  261. else:
  262. replacements = {
  263. "sensor_co2": "sensor_carbon_dioxide",
  264. "sensor_co": "sensor_carbon_monoxide",
  265. "sensor_pm2_5": "sensor_pm25",
  266. "sensor_pm_10": "sensor_pm10",
  267. "sensor_pm_1_0": "sensor_pm1",
  268. "sensor_pm_2_5": "sensor_pm25",
  269. "sensor_tvoc": "sensor_volatile_organic_compounds",
  270. "sensor_current_humidity": "sensor_humidity",
  271. "sensor_current_temperature": "sensor_temperature",
  272. }
  273. for suffix, new_suffix in replacements.items():
  274. if old_id.endswith(suffix):
  275. e = conf_file.primary_entity
  276. if e.entity != platform or e.name:
  277. for e in conf_file.secondary_entities():
  278. if e.entity == platform and not e.name:
  279. break
  280. if e.entity == platform and not e.name:
  281. new_id = e.unique_id(device_id)
  282. if new_id.endswith(new_suffix):
  283. _LOGGER.info(
  284. "Migrating %s unique_id %s to %s",
  285. e.entity,
  286. old_id,
  287. new_id,
  288. )
  289. return {
  290. "new_unique_id": entity_entry.unique_id.replace(
  291. old_id,
  292. new_id,
  293. )
  294. }
  295. await async_migrate_entries(hass, entry.entry_id, update_unique_id13)
  296. hass.config_entries.async_update_entry(entry, version=13)
  297. if entry.version == 13 and entry.minor_version < 2:
  298. # Migrate unique ids of existing entities to new id taking into
  299. # account translation_key, and standardising naming
  300. device_id = entry.unique_id
  301. conf_file = get_config(entry.data[CONF_TYPE])
  302. if conf_file is None:
  303. _LOGGER.error(
  304. NOT_FOUND,
  305. entry.data[CONF_TYPE],
  306. )
  307. return False
  308. @callback
  309. def update_unique_id13_2(entity_entry):
  310. """Update the unique id of an entity entry."""
  311. old_id = entity_entry.unique_id
  312. platform = entity_entry.entity_id.split(".", 1)[0]
  313. # Standardistion of entity naming to use translation_key
  314. replacements = {
  315. # special meaning of None to handle _full and _empty variants
  316. "binary_sensor_tank": None,
  317. "binary_sensor_tank_full_or_missing": "binary_sensor_tank_full",
  318. "binary_sensor_water_tank_full": "binary_sensor_tank_full",
  319. "binary_sensor_low_water": "binary_sensor_tank_empty",
  320. "binary_sensor_water_tank_empty": "binary_sensor_tank_empty",
  321. "binary_sensor_fault": "binary_sensor_problem",
  322. "binary_sensor_error": "binary_sensor_problem",
  323. "binary_sensor_fault_alarm": "binary_sensor_problem",
  324. "binary_sensor_errors": "binary_sensor_problem",
  325. "binary_sensor_defrosting": "binary_sensor_defrost",
  326. "binary_sensor_anti_frost": "binary_sensor_defrost",
  327. "binary_sensor_anti_freeze": "binary_sensor_defrost",
  328. "binary_sensor_low_battery": "binary_sensor_battery",
  329. "binary_sensor_low_battery_alarm": "binary_sensor_battery",
  330. "select_temperature_units": "select_temperature_unit",
  331. "select_display_temperature_unit": "select_temperature_unit",
  332. "select_display_unit": "select_temperature_unit",
  333. "select_display_units": "select_temperature_unit",
  334. "select_temperature_display_units": "select_temperature_unit",
  335. "switch_defrost": "switch_anti_frost",
  336. "switch_frost_protection": "switch_anti_frost",
  337. }
  338. for suffix, new_suffix in replacements.items():
  339. if old_id.endswith(suffix):
  340. e = conf_file.primary_entity
  341. if e.entity != platform or e.name:
  342. for e in conf_file.secondary_entities():
  343. if e.entity == platform and not e.name:
  344. break
  345. if e.entity == platform and not e.name:
  346. new_id = e.unique_id(device_id)
  347. if (new_suffix and new_id.endswith(new_suffix)) or (
  348. new_suffix is None and suffix in new_id
  349. ):
  350. _LOGGER.info(
  351. "Migrating %s unique_id %s to %s",
  352. e.entity,
  353. old_id,
  354. new_id,
  355. )
  356. return {
  357. "new_unique_id": entity_entry.unique_id.replace(
  358. old_id,
  359. new_id,
  360. )
  361. }
  362. await async_migrate_entries(hass, entry.entry_id, update_unique_id13_2)
  363. hass.config_entries.async_update_entry(entry, minor_version=2)
  364. if entry.version == 13 and entry.minor_version < 3:
  365. # Migrate unique ids of existing entities to new id taking into
  366. # account translation_key, and standardising naming
  367. device_id = entry.unique_id
  368. conf_file = get_config(entry.data[CONF_TYPE])
  369. if conf_file is None:
  370. _LOGGER.error(
  371. NOT_FOUND,
  372. entry.data[CONF_TYPE],
  373. )
  374. return False
  375. @callback
  376. def update_unique_id13_3(entity_entry):
  377. """Update the unique id of an entity entry."""
  378. old_id = entity_entry.unique_id
  379. platform = entity_entry.entity_id.split(".", 1)[0]
  380. # Standardistion of entity naming to use translation_key
  381. replacements = {
  382. "light_front_display": "light_display",
  383. "light_lcd_brightness": "light_display",
  384. "light_coal_bed": "light_logs",
  385. "light_ember": "light_embers",
  386. "light_led_indicator": "light_indicator",
  387. "light_status_indicator": "light_indicator",
  388. "light_indicator_light": "light_indicator",
  389. "light_indicators": "light_indicator",
  390. "light_night_light": "light_nightlight",
  391. "number_tiemout_period": "number_timeout_period",
  392. "sensor_remaining_time": "sensor_time_remaining",
  393. "sensor_timer_remain": "sensor_time_remaining",
  394. "sensor_timer": "sensor_time_remaining",
  395. "sensor_timer_countdown": "sensor_time_remaining",
  396. "sensor_timer_remaining": "sensor_time_remaining",
  397. "sensor_time_left": "sensor_time_remaining",
  398. "sensor_timer_minutes_left": "sensor_time_remaining",
  399. "sensor_timer_time_left": "sensor_time_remaining",
  400. "sensor_auto_shutoff_time_remaining": "sensor_time_remaining",
  401. "sensor_warm_time_remaining": "sensor_time_remaining",
  402. "sensor_run_time_remaining": "sensor_time_remaining",
  403. "switch_ioniser": "switch_ionizer",
  404. "switch_run_uv_cycle": "switch_uv_sterilization",
  405. "switch_uv_light": "switch_uv_sterilization",
  406. "switch_ihealth": "switch_uv_sterilization",
  407. "switch_uv_lamp": "switch_uv_sterilization",
  408. "switch_anti_freeze": "switch_anti_frost",
  409. }
  410. for suffix, new_suffix in replacements.items():
  411. if old_id.endswith(suffix):
  412. e = conf_file.primary_entity
  413. if e.entity != platform or e.name:
  414. for e in conf_file.secondary_entities():
  415. if e.entity == platform and not e.name:
  416. break
  417. if e.entity == platform and not e.name:
  418. new_id = e.unique_id(device_id)
  419. if new_suffix and new_id.endswith(new_suffix):
  420. _LOGGER.info(
  421. "Migrating %s unique_id %s to %s",
  422. e.entity,
  423. old_id,
  424. new_id,
  425. )
  426. return {
  427. "new_unique_id": entity_entry.unique_id.replace(
  428. old_id,
  429. new_id,
  430. )
  431. }
  432. await async_migrate_entries(hass, entry.entry_id, update_unique_id13_3)
  433. hass.config_entries.async_update_entry(entry, minor_version=3)
  434. return True
  435. async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
  436. _LOGGER.debug(
  437. "Setting up entry for device: %s",
  438. get_device_id(entry.data),
  439. )
  440. config = {**entry.data, **entry.options, "name": entry.title}
  441. try:
  442. setup_device(hass, config)
  443. except Exception as e:
  444. raise ConfigEntryNotReady("tuya-local device not ready") from e
  445. device_conf = get_config(entry.data[CONF_TYPE])
  446. if device_conf is None:
  447. _LOGGER.error(NOT_FOUND, config[CONF_TYPE])
  448. return False
  449. entities = set()
  450. e = device_conf.primary_entity
  451. entities.add(e.entity)
  452. for e in device_conf.secondary_entities():
  453. entities.add(e.entity)
  454. await hass.config_entries.async_forward_entry_setups(entry, entities)
  455. entry.add_update_listener(async_update_entry)
  456. return True
  457. async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
  458. _LOGGER.debug("Unloading entry for device: %s", get_device_id(entry.data))
  459. config = entry.data
  460. data = hass.data[DOMAIN][get_device_id(config)]
  461. device_conf = get_config(config[CONF_TYPE])
  462. if device_conf is None:
  463. _LOGGER.error(NOT_FOUND, config[CONF_TYPE])
  464. return False
  465. entities = {}
  466. e = device_conf.primary_entity
  467. if e.config_id in data:
  468. entities[e.entity] = True
  469. for e in device_conf.secondary_entities():
  470. if e.config_id in data:
  471. entities[e.entity] = True
  472. for e in entities:
  473. await hass.config_entries.async_forward_entry_unload(entry, e)
  474. await async_delete_device(hass, config)
  475. del hass.data[DOMAIN][get_device_id(config)]
  476. return True
  477. async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry):
  478. _LOGGER.debug("Updating entry for device: %s", get_device_id(entry.data))
  479. await async_unload_entry(hass, entry)
  480. await async_setup_entry(hass, entry)