4
0

__init__.py 22 KB

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