__init__.py 22 KB

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