climate.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  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. # Disable HA's backwards compatibility auto creation of turn_on/off
  89. # we explicitly define our own so this should have no effect, but
  90. # the deprecation notices in HA use this flag rather than properly
  91. # checking whether we are falling back on the auto-generation.
  92. self._enable_turn_on_off_backwards_compatibility = False
  93. if self._fan_mode_dps:
  94. self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
  95. if self._humidity_dps:
  96. self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
  97. if self._preset_mode_dps:
  98. self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
  99. if self._swing_mode_dps:
  100. self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
  101. if self._swing_horizontal_mode_dps:
  102. self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
  103. if self._temp_high_dps and self._temp_low_dps:
  104. self._attr_supported_features |= (
  105. ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
  106. )
  107. elif self._temperature_dps is not None:
  108. self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
  109. if HVACMode.OFF in self.hvac_modes:
  110. self._attr_supported_features |= ClimateEntityFeature.TURN_OFF
  111. if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
  112. self._attr_supported_features |= ClimateEntityFeature.TURN_ON
  113. @property
  114. def temperature_unit(self):
  115. """Return the unit of measurement."""
  116. # If there is a separate DPS that returns the units, use that
  117. if self._unit_dps:
  118. unit = validate_temp_unit(self._unit_dps.get_value(self._device))
  119. # Only return valid units
  120. if unit:
  121. return unit
  122. # If there unit attribute configured in the temperature dps, use that
  123. if self._temperature_dps and self._temperature_dps.unit:
  124. unit = validate_temp_unit(self._temperature_dps.unit)
  125. if unit:
  126. return unit
  127. if self._temp_high_dps and self._temp_high_dps.unit:
  128. unit = validate_temp_unit(self._temp_high_dps.unit)
  129. if unit:
  130. return unit
  131. if self._temp_low_dps and self._temp_low_dps.unit:
  132. unit = validate_temp_unit(self._temp_low_dps.unit)
  133. if unit is not None:
  134. return unit
  135. if self._current_temperature_dps and self._current_temperature_dps.unit:
  136. unit = validate_temp_unit(self._current_temperature_dps.unit)
  137. if unit:
  138. return unit
  139. # Return the default unit
  140. return UnitOfTemperature.CELSIUS
  141. @property
  142. def precision(self):
  143. """Return the precision of the temperature setting."""
  144. # unlike sensor, this is a decimal of the smallest unit that can be
  145. # represented, not a number of decimal places.
  146. dp = self._temperature_dps or self._temp_high_dps
  147. temp = dp.scale(self._device) if dp else 1
  148. current = (
  149. self._current_temperature_dps.scale(self._device)
  150. if self._current_temperature_dps
  151. else 1
  152. )
  153. if max(temp, current) > 1.0:
  154. return PRECISION_TENTHS
  155. return PRECISION_WHOLE
  156. @property
  157. def target_temperature(self):
  158. """Return the currently set target temperature."""
  159. if self._temperature_dps is None:
  160. raise NotImplementedError()
  161. return self._temperature_dps.get_value(self._device)
  162. @property
  163. def target_temperature_high(self):
  164. """Return the currently set high target temperature."""
  165. if self._temp_high_dps is None:
  166. raise NotImplementedError()
  167. return self._temp_high_dps.get_value(self._device)
  168. @property
  169. def target_temperature_low(self):
  170. """Return the currently set low target temperature."""
  171. if self._temp_low_dps is None:
  172. raise NotImplementedError()
  173. return self._temp_low_dps.get_value(self._device)
  174. @property
  175. def target_temperature_step(self):
  176. """Return the supported step of target temperature."""
  177. dps = self._temperature_dps
  178. if dps is None:
  179. dps = self._temp_high_dps
  180. if dps is None:
  181. dps = self._temp_low_dps
  182. if dps is None:
  183. return 1
  184. return dps.step(self._device)
  185. @property
  186. def min_temp(self):
  187. """Return the minimum supported target temperature."""
  188. # if a separate min_temperature dps is specified, the device tells us.
  189. if self._mintemp_dps is not None:
  190. min = self._mintemp_dps.get_value(self._device)
  191. if min is not None:
  192. return min
  193. if self._temperature_dps is None:
  194. if self._temp_low_dps is None:
  195. return None
  196. r = self._temp_low_dps.range(self._device)
  197. else:
  198. r = self._temperature_dps.range(self._device)
  199. return DEFAULT_MIN_TEMP if r is None else r[0]
  200. @property
  201. def max_temp(self):
  202. """Return the maximum supported target temperature."""
  203. # if a separate max_temperature dps is specified, the device tells us.
  204. if self._maxtemp_dps is not None:
  205. max = self._maxtemp_dps.get_value(self._device)
  206. if max is not None:
  207. return max
  208. if self._temperature_dps is None:
  209. if self._temp_high_dps is None:
  210. return None
  211. r = self._temp_high_dps.range(self._device)
  212. else:
  213. r = self._temperature_dps.range(self._device)
  214. return DEFAULT_MAX_TEMP if r is None else r[1]
  215. async def async_set_temperature(self, **kwargs):
  216. """Set new target temperature."""
  217. if kwargs.get(ATTR_PRESET_MODE) is not None:
  218. await self.async_set_preset_mode(kwargs.get(ATTR_PRESET_MODE))
  219. if kwargs.get(ATTR_TEMPERATURE) is not None:
  220. await self.async_set_target_temperature(
  221. kwargs.get(ATTR_TEMPERATURE),
  222. )
  223. high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
  224. low = kwargs.get(ATTR_TARGET_TEMP_LOW)
  225. if high is not None or low is not None:
  226. await self.async_set_target_temperature_range(low, high)
  227. async def async_set_target_temperature(self, target_temperature):
  228. if self._temperature_dps is None:
  229. raise NotImplementedError()
  230. await self._temperature_dps.async_set_value(
  231. self._device,
  232. target_temperature,
  233. )
  234. async def async_set_target_temperature_range(self, low, high):
  235. """Set the target temperature range."""
  236. dps_map = {}
  237. if low is not None and self._temp_low_dps is not None:
  238. dps_map.update(
  239. self._temp_low_dps.get_values_to_set(self._device, low),
  240. )
  241. if high is not None and self._temp_high_dps is not None:
  242. dps_map.update(
  243. self._temp_high_dps.get_values_to_set(self._device, high),
  244. )
  245. if dps_map:
  246. await self._device.async_set_properties(dps_map)
  247. @property
  248. def current_temperature(self):
  249. """Return the current measured temperature."""
  250. if self._current_temperature_dps:
  251. return self._current_temperature_dps.get_value(self._device)
  252. @property
  253. def target_humidity(self):
  254. """Return the currently set target humidity."""
  255. if self._humidity_dps is None:
  256. raise NotImplementedError()
  257. return self._humidity_dps.get_value(self._device)
  258. @property
  259. def min_humidity(self):
  260. """Return the minimum supported target humidity."""
  261. if self._humidity_dps is None:
  262. return None
  263. r = self._humidity_dps.range(self._device)
  264. return DEFAULT_MIN_HUMIDITY if r is None else r[0]
  265. @property
  266. def max_humidity(self):
  267. """Return the maximum supported target humidity."""
  268. if self._humidity_dps is None:
  269. return None
  270. r = self._humidity_dps.range(self._device)
  271. return DEFAULT_MAX_HUMIDITY if r is None else r[1]
  272. async def async_set_humidity(self, humidity: int):
  273. if self._humidity_dps is None:
  274. raise NotImplementedError()
  275. await self._humidity_dps.async_set_value(self._device, humidity)
  276. @property
  277. def current_humidity(self):
  278. """Return the current measured humidity."""
  279. if self._current_humidity_dps:
  280. return self._current_humidity_dps.get_value(self._device)
  281. @property
  282. def hvac_action(self):
  283. """Return the current HVAC action."""
  284. if self._hvac_action_dps is None:
  285. return None
  286. if self.hvac_mode is HVACMode.OFF:
  287. return HVACAction.OFF
  288. action = self._hvac_action_dps.get_value(self._device)
  289. try:
  290. return HVACAction(action) if action else None
  291. except ValueError:
  292. _LOGGER.warning(
  293. "%s/%s: Unrecognised HVAC Action %s ignored",
  294. self._config._device.config,
  295. self.name or "climate",
  296. action,
  297. )
  298. @property
  299. def hvac_mode(self):
  300. """Return current HVAC mode."""
  301. if self._hvac_mode_dps is None:
  302. return HVACMode.AUTO
  303. hvac_mode = self._hvac_mode_dps.get_value(self._device)
  304. try:
  305. return HVACMode(hvac_mode) if hvac_mode else None
  306. except ValueError:
  307. _LOGGER.warning(
  308. "%s/%s: Unrecognised HVAC Mode of %s ignored",
  309. self._config._device.config,
  310. self.name or "climate",
  311. hvac_mode,
  312. )
  313. @property
  314. def hvac_modes(self):
  315. """Return available HVAC modes."""
  316. if self._hvac_mode_dps is None:
  317. return [HVACMode.AUTO]
  318. else:
  319. return self._hvac_mode_dps.values(self._device)
  320. async def async_set_hvac_mode(self, hvac_mode):
  321. """Set new HVAC mode."""
  322. if self._hvac_mode_dps is None:
  323. raise NotImplementedError()
  324. await self._hvac_mode_dps.async_set_value(self._device, hvac_mode)
  325. async def async_turn_on(self):
  326. """Turn on the climate device."""
  327. # Bypass the usual dps mapping to switch the power dp directly
  328. # this way the hvac_mode will be kept when toggling off and on.
  329. if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
  330. await self._device.async_set_property(self._hvac_mode_dps.id, True)
  331. else:
  332. await super().async_turn_on()
  333. async def async_turn_off(self):
  334. """Turn off the climate device."""
  335. # Bypass the usual dps mapping to switch the power dp directly
  336. # this way the hvac_mode will be kept when toggling off and on.
  337. if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
  338. await self._device.async_set_property(
  339. self._hvac_mode_dps.id,
  340. False,
  341. )
  342. else:
  343. await super().async_turn_off()
  344. @property
  345. def preset_mode(self):
  346. """Return the current preset mode."""
  347. if self._preset_mode_dps is None:
  348. raise NotImplementedError()
  349. return self._preset_mode_dps.get_value(self._device)
  350. @property
  351. def preset_modes(self):
  352. """Return the list of presets that this device supports."""
  353. if self._preset_mode_dps:
  354. return self._preset_mode_dps.values(self._device)
  355. async def async_set_preset_mode(self, preset_mode):
  356. """Set the preset mode."""
  357. if self._preset_mode_dps is None:
  358. raise NotImplementedError()
  359. await self._preset_mode_dps.async_set_value(self._device, preset_mode)
  360. @property
  361. def swing_mode(self):
  362. """Return the current swing mode."""
  363. if self._swing_mode_dps is None:
  364. raise NotImplementedError()
  365. return self._swing_mode_dps.get_value(self._device)
  366. @property
  367. def swing_modes(self):
  368. """Return the list of swing modes that this device supports."""
  369. if self._swing_mode_dps:
  370. return self._swing_mode_dps.values(self._device)
  371. async def async_set_swing_mode(self, swing_mode):
  372. """Set the preset mode."""
  373. if self._swing_mode_dps is None:
  374. raise NotImplementedError()
  375. await self._swing_mode_dps.async_set_value(self._device, swing_mode)
  376. @property
  377. def swing_horizontal_mode(self):
  378. """Return the current horizontal swing mode."""
  379. if self._swing_horizontal_mode_dps is None:
  380. raise NotImplementedError()
  381. return self._swing_horizontal_mode_dps.get_value(self._device)
  382. @property
  383. def swing_horizontal_modes(self):
  384. """Return the list of swing modes that this device supports."""
  385. if self._swing_horizontal_mode_dps:
  386. return self._swing_horizontal_mode_dps.values(self._device)
  387. async def async_set_swing_horizontal_mode(self, swing_mode):
  388. """Set the preset mode."""
  389. if self._swing_horizontal_mode_dps is None:
  390. raise NotImplementedError()
  391. await self._swing_horizontal_mode_dps.async_set_value(
  392. self._device,
  393. swing_mode,
  394. )
  395. @property
  396. def fan_mode(self):
  397. """Return the current fan mode."""
  398. if self._fan_mode_dps is None:
  399. raise NotImplementedError()
  400. return self._fan_mode_dps.get_value(self._device)
  401. @property
  402. def fan_modes(self):
  403. """Return the list of fan modes that this device supports."""
  404. if self._fan_mode_dps:
  405. return self._fan_mode_dps.values(self._device)
  406. async def async_set_fan_mode(self, fan_mode):
  407. """Set the fan mode."""
  408. if self._fan_mode_dps is None:
  409. raise NotImplementedError()
  410. await self._fan_mode_dps.async_set_value(self._device, fan_mode)