climate.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. """
  2. Setup for different kinds of Tuya climate devices
  3. """
  4. import logging
  5. from homeassistant.components.climate import (
  6. ClimateEntity,
  7. ClimateEntityFeature,
  8. HVACAction,
  9. HVACMode,
  10. )
  11. from homeassistant.components.climate.const import (
  12. ATTR_CURRENT_HUMIDITY,
  13. ATTR_CURRENT_TEMPERATURE,
  14. ATTR_FAN_MODE,
  15. ATTR_HUMIDITY,
  16. ATTR_HVAC_ACTION,
  17. ATTR_HVAC_MODE,
  18. ATTR_PRESET_MODE,
  19. ATTR_SWING_HORIZONTAL_MODE,
  20. ATTR_SWING_MODE,
  21. ATTR_TARGET_TEMP_HIGH,
  22. ATTR_TARGET_TEMP_LOW,
  23. DEFAULT_MAX_HUMIDITY,
  24. DEFAULT_MAX_TEMP,
  25. DEFAULT_MIN_HUMIDITY,
  26. DEFAULT_MIN_TEMP,
  27. )
  28. from homeassistant.const import (
  29. ATTR_TEMPERATURE,
  30. PRECISION_TENTHS,
  31. PRECISION_WHOLE,
  32. UnitOfTemperature,
  33. )
  34. from .device import TuyaLocalDevice
  35. from .entity import TuyaLocalEntity, unit_from_ascii
  36. from .helpers.config import async_tuya_setup_platform
  37. from .helpers.device_config import TuyaEntityConfig
  38. _LOGGER = logging.getLogger(__name__)
  39. async def async_setup_entry(hass, config_entry, async_add_entities):
  40. config = {**config_entry.data, **config_entry.options}
  41. await async_tuya_setup_platform(
  42. hass,
  43. async_add_entities,
  44. config,
  45. "climate",
  46. TuyaLocalClimate,
  47. )
  48. def validate_temp_unit(unit):
  49. unit = unit_from_ascii(unit)
  50. try:
  51. return UnitOfTemperature(unit)
  52. except ValueError:
  53. if unit:
  54. _LOGGER.warning("%s is not a valid temperature unit", unit)
  55. class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
  56. """Representation of a Tuya Climate entity."""
  57. def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
  58. """
  59. Initialise the climate device.
  60. Args:
  61. device (TuyaLocalDevice): The device API instance.
  62. config (TuyaEntityConfig): The entity config.
  63. """
  64. super().__init__()
  65. dps_map = self._init_begin(device, config)
  66. self._current_temperature_dps = dps_map.pop(
  67. ATTR_CURRENT_TEMPERATURE,
  68. None,
  69. )
  70. self._current_humidity_dps = dps_map.pop(ATTR_CURRENT_HUMIDITY, None)
  71. self._fan_mode_dps = dps_map.pop(ATTR_FAN_MODE, None)
  72. self._humidity_dps = dps_map.pop(ATTR_HUMIDITY, None)
  73. self._hvac_mode_dps = dps_map.pop(ATTR_HVAC_MODE, None)
  74. self._hvac_action_dps = dps_map.pop(ATTR_HVAC_ACTION, None)
  75. self._preset_mode_dps = dps_map.pop(ATTR_PRESET_MODE, None)
  76. self._swing_horizontal_mode_dps = dps_map.pop(
  77. ATTR_SWING_HORIZONTAL_MODE,
  78. None,
  79. )
  80. self._swing_mode_dps = dps_map.pop(ATTR_SWING_MODE, None)
  81. self._temperature_dps = dps_map.pop(ATTR_TEMPERATURE, None)
  82. self._temp_high_dps = dps_map.pop(ATTR_TARGET_TEMP_HIGH, None)
  83. self._temp_low_dps = dps_map.pop(ATTR_TARGET_TEMP_LOW, None)
  84. self._unit_dps = dps_map.pop("temperature_unit", None)
  85. self._mintemp_dps = dps_map.pop("min_temperature", None)
  86. self._maxtemp_dps = dps_map.pop("max_temperature", None)
  87. self._init_end(dps_map)
  88. if self._fan_mode_dps:
  89. self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
  90. if self._humidity_dps:
  91. self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
  92. if self._preset_mode_dps:
  93. self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
  94. if self._swing_mode_dps:
  95. if self._swing_mode_dps.values(device):
  96. self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
  97. if self._swing_horizontal_mode_dps:
  98. if self._swing_horizontal_mode_dps.values(device):
  99. self._attr_supported_features |= (
  100. ClimateEntityFeature.SWING_HORIZONTAL_MODE
  101. )
  102. if self._temp_high_dps and self._temp_low_dps:
  103. self._attr_supported_features |= (
  104. ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
  105. )
  106. elif self._temperature_dps is not None:
  107. self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
  108. if HVACMode.OFF in self.hvac_modes:
  109. self._attr_supported_features |= ClimateEntityFeature.TURN_OFF
  110. if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
  111. self._attr_supported_features |= ClimateEntityFeature.TURN_ON
  112. @property
  113. def temperature_unit(self):
  114. """Return the unit of measurement."""
  115. # If there is a separate DPS that returns the units, use that
  116. if self._unit_dps:
  117. unit = validate_temp_unit(self._unit_dps.get_value(self._device))
  118. # Only return valid units
  119. if unit:
  120. return unit
  121. # If there unit attribute configured in the temperature dps, use that
  122. if self._temperature_dps and self._temperature_dps.unit:
  123. unit = validate_temp_unit(self._temperature_dps.unit)
  124. if unit:
  125. return unit
  126. if self._temp_high_dps and self._temp_high_dps.unit:
  127. unit = validate_temp_unit(self._temp_high_dps.unit)
  128. if unit:
  129. return unit
  130. if self._temp_low_dps and self._temp_low_dps.unit:
  131. unit = validate_temp_unit(self._temp_low_dps.unit)
  132. if unit is not None:
  133. return unit
  134. if self._current_temperature_dps and self._current_temperature_dps.unit:
  135. unit = validate_temp_unit(self._current_temperature_dps.unit)
  136. if unit:
  137. return unit
  138. # Return the default unit
  139. return UnitOfTemperature.CELSIUS
  140. @property
  141. def precision(self):
  142. """Return the precision of the temperature setting."""
  143. # unlike sensor, this is a decimal of the smallest unit that can be
  144. # represented, not a number of decimal places.
  145. dp = self._temperature_dps or self._temp_high_dps
  146. temp = dp.scale(self._device) if dp else 1
  147. current = (
  148. self._current_temperature_dps.scale(self._device)
  149. if self._current_temperature_dps
  150. else 1
  151. )
  152. if max(temp, current) > 1.0:
  153. return PRECISION_TENTHS
  154. return PRECISION_WHOLE
  155. @property
  156. def target_temperature(self):
  157. """Return the currently set target temperature."""
  158. if self._temperature_dps is None:
  159. raise NotImplementedError()
  160. return self._temperature_dps.get_value(self._device)
  161. @property
  162. def target_temperature_high(self):
  163. """Return the currently set high target temperature."""
  164. if self._temp_high_dps is None:
  165. raise NotImplementedError()
  166. return self._temp_high_dps.get_value(self._device)
  167. @property
  168. def target_temperature_low(self):
  169. """Return the currently set low target temperature."""
  170. if self._temp_low_dps is None:
  171. raise NotImplementedError()
  172. return self._temp_low_dps.get_value(self._device)
  173. @property
  174. def target_temperature_step(self):
  175. """Return the supported step of target temperature."""
  176. dps = self._temperature_dps
  177. if dps is None:
  178. dps = self._temp_high_dps
  179. if dps is None:
  180. dps = self._temp_low_dps
  181. if dps is None:
  182. return 1
  183. return dps.step(self._device)
  184. @property
  185. def min_temp(self):
  186. """Return the minimum supported target temperature."""
  187. # if a separate min_temperature dps is specified, the device tells us.
  188. if self._mintemp_dps is not None:
  189. m = self._mintemp_dps.get_value(self._device)
  190. if m is not None:
  191. return m
  192. if self._temperature_dps is None:
  193. if self._temp_low_dps is None:
  194. return None
  195. r = self._temp_low_dps.range(self._device)
  196. else:
  197. r = self._temperature_dps.range(self._device)
  198. return DEFAULT_MIN_TEMP if r is None else r[0]
  199. @property
  200. def max_temp(self):
  201. """Return the maximum supported target temperature."""
  202. # if a separate max_temperature dps is specified, the device tells us.
  203. if self._maxtemp_dps is not None:
  204. m = self._maxtemp_dps.get_value(self._device)
  205. if m is not None:
  206. return m
  207. if self._temperature_dps is None:
  208. if self._temp_high_dps is None:
  209. return None
  210. r = self._temp_high_dps.range(self._device)
  211. else:
  212. r = self._temperature_dps.range(self._device)
  213. return DEFAULT_MAX_TEMP if r is None else r[1]
  214. async def async_set_temperature(self, **kwargs):
  215. """Set new target temperature."""
  216. if kwargs.get(ATTR_PRESET_MODE) is not None:
  217. _LOGGER.info(
  218. "%s setting temperature: setting preset mode to %s",
  219. self._config.config_id,
  220. kwargs.get(ATTR_PRESET_MODE),
  221. )
  222. await self.async_set_preset_mode(kwargs.get(ATTR_PRESET_MODE))
  223. if kwargs.get(ATTR_TEMPERATURE) is not None:
  224. _LOGGER.info(
  225. "%s setting temperature to %s",
  226. self._config.config_id,
  227. kwargs.get(ATTR_TEMPERATURE),
  228. )
  229. await self.async_set_target_temperature(
  230. kwargs.get(ATTR_TEMPERATURE),
  231. )
  232. high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
  233. low = kwargs.get(ATTR_TARGET_TEMP_LOW)
  234. if high is not None or low is not None:
  235. _LOGGER.info(
  236. "%s setting temperature range to %s - %s",
  237. self._config.config_id,
  238. low,
  239. high,
  240. )
  241. await self.async_set_target_temperature_range(low, high)
  242. async def async_set_target_temperature(self, target_temperature):
  243. if self._temperature_dps is None:
  244. raise NotImplementedError()
  245. await self._temperature_dps.async_set_value(
  246. self._device,
  247. target_temperature,
  248. )
  249. async def async_set_target_temperature_range(self, low, high):
  250. """Set the target temperature range."""
  251. dps_map = {}
  252. if low is not None and self._temp_low_dps is not None:
  253. dps_map.update(
  254. self._temp_low_dps.get_values_to_set(self._device, low, dps_map),
  255. )
  256. if high is not None and self._temp_high_dps is not None:
  257. dps_map.update(
  258. self._temp_high_dps.get_values_to_set(self._device, high, dps_map),
  259. )
  260. if dps_map:
  261. await self._device.async_set_properties(dps_map)
  262. @property
  263. def current_temperature(self):
  264. """Return the current measured temperature."""
  265. if self._current_temperature_dps:
  266. temp = self._current_temperature_dps.get_value(self._device)
  267. if self._current_temperature_dps.suggested_display_precision is not None:
  268. # Round the value to the suggested precision
  269. temp = round(
  270. temp, self._current_temperature_dps.suggested_display_precision
  271. )
  272. return temp
  273. @property
  274. def target_humidity(self):
  275. """Return the currently set target humidity."""
  276. if self._humidity_dps is None:
  277. raise NotImplementedError()
  278. return self._humidity_dps.get_value(self._device)
  279. @property
  280. def min_humidity(self):
  281. """Return the minimum supported target humidity."""
  282. if self._humidity_dps is None:
  283. return None
  284. r = self._humidity_dps.range(self._device)
  285. return DEFAULT_MIN_HUMIDITY if r is None else r[0]
  286. @property
  287. def max_humidity(self):
  288. """Return the maximum supported target humidity."""
  289. if self._humidity_dps is None:
  290. return None
  291. r = self._humidity_dps.range(self._device)
  292. return DEFAULT_MAX_HUMIDITY if r is None else r[1]
  293. async def async_set_humidity(self, humidity: int):
  294. if self._humidity_dps is None:
  295. raise NotImplementedError()
  296. _LOGGER.info(
  297. "%s setting humidity to %s",
  298. self._config.config_id,
  299. humidity,
  300. )
  301. await self._humidity_dps.async_set_value(self._device, humidity)
  302. @property
  303. def current_humidity(self):
  304. """Return the current measured humidity."""
  305. if self._current_humidity_dps:
  306. return self._current_humidity_dps.get_value(self._device)
  307. @property
  308. def hvac_action(self):
  309. """Return the current HVAC action."""
  310. if self._hvac_action_dps is None:
  311. return None
  312. if self.hvac_mode is HVACMode.OFF:
  313. return HVACAction.OFF
  314. action = self._hvac_action_dps.get_value(self._device)
  315. try:
  316. return HVACAction(action) if action else None
  317. except ValueError:
  318. _LOGGER.warning(
  319. "%s/%s: Unrecognised HVAC Action %s ignored",
  320. self._config._device.config,
  321. self.name or "climate",
  322. action,
  323. )
  324. @property
  325. def hvac_mode(self):
  326. """Return current HVAC mode."""
  327. if self._hvac_mode_dps is None:
  328. return HVACMode.AUTO
  329. hvac_mode = self._hvac_mode_dps.get_value(self._device)
  330. try:
  331. return HVACMode(hvac_mode) if hvac_mode else None
  332. except ValueError:
  333. _LOGGER.warning(
  334. "%s/%s: Unrecognised HVAC Mode of %s ignored",
  335. self._config._device.config,
  336. self.name or "climate",
  337. hvac_mode,
  338. )
  339. @property
  340. def hvac_modes(self):
  341. """Return available HVAC modes."""
  342. if self._hvac_mode_dps is None:
  343. return [HVACMode.AUTO]
  344. else:
  345. return self._hvac_mode_dps.values(self._device)
  346. async def async_set_hvac_mode(self, hvac_mode):
  347. """Set new HVAC mode."""
  348. if self._hvac_mode_dps is None:
  349. raise NotImplementedError()
  350. _LOGGER.info(
  351. "%s setting HVAC mode to %s",
  352. self._config.config_id,
  353. hvac_mode,
  354. )
  355. await self._hvac_mode_dps.async_set_value(self._device, hvac_mode)
  356. async def async_turn_on(self):
  357. """Turn on the climate device."""
  358. # Bypass the usual dps mapping to switch the power dp directly
  359. # this way the hvac_mode will be kept when toggling off and on.
  360. if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
  361. _LOGGER.info("%s turning on", self._config.config_id)
  362. await self._device.async_set_property(self._hvac_mode_dps.id, True)
  363. else:
  364. await super().async_turn_on()
  365. async def async_turn_off(self):
  366. """Turn off the climate device."""
  367. # Bypass the usual dps mapping to switch the power dp directly
  368. # this way the hvac_mode will be kept when toggling off and on.
  369. if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
  370. _LOGGER.info("%s turning off", self._config.config_id)
  371. await self._device.async_set_property(
  372. self._hvac_mode_dps.id,
  373. False,
  374. )
  375. else:
  376. await super().async_turn_off()
  377. @property
  378. def preset_mode(self):
  379. """Return the current preset mode."""
  380. if self._preset_mode_dps is None:
  381. raise NotImplementedError()
  382. return self._preset_mode_dps.get_value(self._device)
  383. @property
  384. def preset_modes(self):
  385. """Return the list of presets that this device supports."""
  386. if self._preset_mode_dps:
  387. return self._preset_mode_dps.values(self._device)
  388. async def async_set_preset_mode(self, preset_mode):
  389. """Set the preset mode."""
  390. if self._preset_mode_dps is None:
  391. raise NotImplementedError()
  392. _LOGGER.info(
  393. "%s setting preset mode to %s",
  394. self._config.config_id,
  395. preset_mode,
  396. )
  397. await self._preset_mode_dps.async_set_value(self._device, preset_mode)
  398. @property
  399. def swing_mode(self):
  400. """Return the current swing mode."""
  401. if self._swing_mode_dps is None:
  402. raise NotImplementedError()
  403. return self._swing_mode_dps.get_value(self._device)
  404. @property
  405. def swing_modes(self):
  406. """Return the list of swing modes that this device supports."""
  407. if self._swing_mode_dps:
  408. return self._swing_mode_dps.values(self._device)
  409. async def async_set_swing_mode(self, swing_mode):
  410. """Set the preset mode."""
  411. if self._swing_mode_dps is None:
  412. raise NotImplementedError()
  413. _LOGGER.info(
  414. "%s setting swing mode to %s",
  415. self._config.config_id,
  416. swing_mode,
  417. )
  418. await self._swing_mode_dps.async_set_value(self._device, swing_mode)
  419. @property
  420. def swing_horizontal_mode(self):
  421. """Return the current horizontal swing mode."""
  422. if self._swing_horizontal_mode_dps is None:
  423. raise NotImplementedError()
  424. return self._swing_horizontal_mode_dps.get_value(self._device)
  425. @property
  426. def swing_horizontal_modes(self):
  427. """Return the list of swing modes that this device supports."""
  428. if self._swing_horizontal_mode_dps:
  429. return self._swing_horizontal_mode_dps.values(self._device)
  430. async def async_set_swing_horizontal_mode(self, swing_mode):
  431. """Set the preset mode."""
  432. if self._swing_horizontal_mode_dps is None:
  433. raise NotImplementedError()
  434. _LOGGER.info(
  435. "%s setting horizontal swing mode to %s",
  436. self._config.config_id,
  437. swing_mode,
  438. )
  439. await self._swing_horizontal_mode_dps.async_set_value(
  440. self._device,
  441. swing_mode,
  442. )
  443. @property
  444. def fan_mode(self):
  445. """Return the current fan mode."""
  446. if self._fan_mode_dps is None:
  447. raise NotImplementedError()
  448. return self._fan_mode_dps.get_value(self._device)
  449. @property
  450. def fan_modes(self):
  451. """Return the list of fan modes that this device supports."""
  452. if self._fan_mode_dps:
  453. return self._fan_mode_dps.values(self._device)
  454. async def async_set_fan_mode(self, fan_mode):
  455. """Set the fan mode."""
  456. if self._fan_mode_dps is None:
  457. raise NotImplementedError()
  458. _LOGGER.info(
  459. "%s setting fan mode to %s",
  460. self._config.config_id,
  461. fan_mode,
  462. )
  463. await self._fan_mode_dps.async_set_value(self._device, fan_mode)