__init__.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. """
  2. Platform for Goldair WiFi-connected heaters and panels.
  3. Based on sean6541/tuya-homeassistant for service call logic, and TarxBoy's
  4. investigation into Goldair's tuyapi statuses
  5. https://github.com/codetheweb/tuyapi/issues/31.
  6. """
  7. from time import time
  8. from threading import Timer, Lock
  9. import logging
  10. import json
  11. import voluptuous as vol
  12. import homeassistant.helpers.config_validation as cv
  13. from homeassistant.const import (CONF_NAME, CONF_HOST, ATTR_TEMPERATURE, TEMP_CELSIUS)
  14. from homeassistant.components.climate import ATTR_OPERATION_MODE
  15. from homeassistant.helpers.discovery import load_platform
  16. VERSION = '0.0.1'
  17. REQUIREMENTS = ['pytuya==7.0']
  18. _LOGGER = logging.getLogger(__name__)
  19. DOMAIN = 'goldair_climate'
  20. DATA_GOLDAIR_CLIMATE = 'data_goldair_climate'
  21. CONF_DEVICE_ID = 'device_id'
  22. CONF_LOCAL_KEY = 'local_key'
  23. CONF_TYPE = 'type'
  24. CONF_TYPE_HEATER = 'heater'
  25. CONF_CLIMATE = 'climate'
  26. CONF_SENSOR = 'sensor'
  27. CONF_CHILD_LOCK = 'child_lock'
  28. CONF_DISPLAY_LIGHT = 'display_light'
  29. ATTR_ON = 'on'
  30. ATTR_TARGET_TEMPERATURE = 'target_temperature'
  31. ATTR_CHILD_LOCK = 'child_lock'
  32. ATTR_FAULT = 'fault'
  33. ATTR_POWER_LEVEL = 'power_level'
  34. ATTR_TIMER_MINUTES = 'timer_minutes'
  35. ATTR_TIMER_ON = 'timer_on'
  36. ATTR_DISPLAY_ON = 'display_on'
  37. ATTR_POWER_MODE = 'power_mode'
  38. ATTR_ECO_TARGET_TEMPERATURE = 'eco_' + ATTR_TARGET_TEMPERATURE
  39. STATE_COMFORT = 'Comfort'
  40. STATE_ECO = 'Eco'
  41. STATE_ANTI_FREEZE = 'Anti-freeze'
  42. GOLDAIR_PROPERTY_TO_DPS_ID = {
  43. ATTR_ON: '1',
  44. ATTR_TARGET_TEMPERATURE: '2',
  45. ATTR_TEMPERATURE: '3',
  46. ATTR_OPERATION_MODE: '4',
  47. ATTR_CHILD_LOCK: '6',
  48. ATTR_FAULT: '12',
  49. ATTR_POWER_LEVEL: '101',
  50. ATTR_TIMER_MINUTES: '102',
  51. ATTR_TIMER_ON: '103',
  52. ATTR_DISPLAY_ON: '104',
  53. ATTR_POWER_MODE: '105',
  54. ATTR_ECO_TARGET_TEMPERATURE: '106'
  55. }
  56. GOLDAIR_MODE_TO_DPS_MODE = {
  57. STATE_COMFORT: 'C',
  58. STATE_ECO: 'ECO',
  59. STATE_ANTI_FREEZE: 'AF'
  60. }
  61. GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL = {
  62. 'Stop': 'stop',
  63. '1': '1',
  64. '2': '2',
  65. '3': '3',
  66. '4': '4',
  67. '5': '5',
  68. 'Auto': 'auto'
  69. }
  70. GOLDAIR_POWER_MODES = ['auto', 'user']
  71. PLATFORM_SCHEMA = vol.Schema({
  72. vol.Required(CONF_NAME): cv.string,
  73. vol.Required(CONF_HOST): cv.string,
  74. vol.Required(CONF_DEVICE_ID): cv.string,
  75. vol.Required(CONF_LOCAL_KEY): cv.string,
  76. vol.Required(CONF_TYPE): vol.In([CONF_TYPE_HEATER]),
  77. vol.Optional(CONF_CLIMATE, default=True): cv.boolean,
  78. vol.Optional(CONF_SENSOR, default=False): cv.boolean,
  79. vol.Optional(CONF_DISPLAY_LIGHT, default=False): cv.boolean,
  80. vol.Optional(CONF_CHILD_LOCK, default=False): cv.boolean
  81. })
  82. CONFIG_SCHEMA = vol.Schema({
  83. DOMAIN: vol.All(cv.ensure_list, [PLATFORM_SCHEMA])
  84. }, extra=vol.ALLOW_EXTRA)
  85. def setup(hass, config):
  86. hass.data[DOMAIN] = {}
  87. for device_config in config.get(DOMAIN, []):
  88. host = device_config.get(CONF_HOST)
  89. device = GoldairHeaterDevice(
  90. device_config.get(CONF_NAME),
  91. device_config.get(CONF_DEVICE_ID),
  92. device_config.get(CONF_HOST),
  93. device_config.get(CONF_LOCAL_KEY)
  94. )
  95. hass.data[DOMAIN][host] = device
  96. if device_config.get(CONF_TYPE) == CONF_TYPE_HEATER:
  97. discovery_info = {'host': host, 'type': 'heater'}
  98. if device_config.get(CONF_CLIMATE):
  99. load_platform(hass, 'climate', DOMAIN, discovery_info, config)
  100. if device_config.get(CONF_SENSOR):
  101. load_platform(hass, 'sensor', DOMAIN, discovery_info, config)
  102. if device_config.get(CONF_DISPLAY_LIGHT):
  103. load_platform(hass, 'light', DOMAIN, discovery_info, config)
  104. if device_config.get(CONF_CHILD_LOCK):
  105. load_platform(hass, 'lock', DOMAIN, discovery_info, config)
  106. return True
  107. class GoldairHeaterDevice(object):
  108. def __init__(self, name, dev_id, address, local_key):
  109. """
  110. Represents a Goldair Heater device.
  111. Args:
  112. dev_id (str): The device id.
  113. address (str): The network address.
  114. local_key (str): The encryption key.
  115. """
  116. import pytuya
  117. self._name = name
  118. self._api = pytuya.Device(dev_id, address, local_key, 'device')
  119. self._fixed_properties = {}
  120. self._reset_cached_state()
  121. self._TEMPERATURE_UNIT = TEMP_CELSIUS
  122. self._TEMPERATURE_STEP = 1
  123. self._TEMPERATURE_LIMITS = {
  124. STATE_COMFORT: {
  125. 'min': 5,
  126. 'max': 35
  127. },
  128. STATE_ECO: {
  129. 'min': 5,
  130. 'max': 21
  131. }
  132. }
  133. # API calls to update Goldair heaters are asynchronous and non-blocking. This means
  134. # you can send a change and immediately request an updated state (like HA does),
  135. # but because it has not yet finished processing you will be returned the old state.
  136. # The solution is to keep a temporary list of changed properties that we can overlay
  137. # onto the state while we wait for the board to update its switches.
  138. self._FAKE_IT_TIL_YOU_MAKE_IT_TIMEOUT = 10
  139. self._CACHE_TIMEOUT = 20
  140. self._CONNECTION_ATTEMPTS = 2
  141. self._lock = Lock()
  142. @property
  143. def name(self):
  144. return self._name
  145. @property
  146. def is_on(self):
  147. return self._get_cached_state()[ATTR_ON]
  148. def turn_on(self):
  149. self._set_properties({ATTR_ON: True})
  150. def turn_off(self):
  151. self._set_properties({ATTR_ON: False})
  152. @property
  153. def temperature_unit(self):
  154. return self._TEMPERATURE_UNIT
  155. @property
  156. def target_temperature(self):
  157. state = self._get_cached_state()
  158. if self.operation_mode == STATE_COMFORT:
  159. return state[ATTR_TARGET_TEMPERATURE]
  160. elif self.operation_mode == STATE_ECO:
  161. return state[ATTR_ECO_TARGET_TEMPERATURE]
  162. else:
  163. return None
  164. @property
  165. def target_temperature_step(self):
  166. return self._TEMPERATURE_STEP
  167. @property
  168. def min_target_teperature(self):
  169. if self.operation_mode and self.operation_mode != STATE_ANTI_FREEZE:
  170. return self._TEMPERATURE_LIMITS[self.operation_mode]['min']
  171. else:
  172. return None
  173. @property
  174. def max_target_temperature(self):
  175. if self.operation_mode and self.operation_mode != STATE_ANTI_FREEZE:
  176. return self._TEMPERATURE_LIMITS[self.operation_mode]['max']
  177. else:
  178. return None
  179. def set_target_temperature(self, target_temperature):
  180. target_temperature = int(round(target_temperature))
  181. operation_mode = self.operation_mode
  182. if operation_mode == STATE_ANTI_FREEZE:
  183. raise ValueError('You cannot set the temperature in Anti-freeze mode.')
  184. limits = self._TEMPERATURE_LIMITS[operation_mode]
  185. if not limits['min'] <= target_temperature <= limits['max']:
  186. raise ValueError(
  187. f'Target temperature ({target_temperature}) must be between '
  188. f'{limits["min"]} and {limits["max"]}'
  189. )
  190. if operation_mode == STATE_COMFORT:
  191. self._set_properties({ATTR_TARGET_TEMPERATURE: target_temperature})
  192. elif operation_mode == STATE_ECO:
  193. self._set_properties({ATTR_ECO_TARGET_TEMPERATURE: target_temperature})
  194. @property
  195. def current_temperature(self):
  196. return self._get_cached_state()[ATTR_TEMPERATURE]
  197. @property
  198. def operation_mode(self):
  199. return self._get_cached_state()[ATTR_OPERATION_MODE]
  200. @property
  201. def operation_mode_list(self):
  202. return list(GOLDAIR_MODE_TO_DPS_MODE.keys())
  203. def set_operation_mode(self, new_mode):
  204. if new_mode not in GOLDAIR_MODE_TO_DPS_MODE:
  205. raise ValueError(f'Invalid mode: {new_mode}')
  206. self._set_properties({ATTR_OPERATION_MODE: new_mode})
  207. @property
  208. def is_child_locked(self):
  209. return self._get_cached_state()[ATTR_CHILD_LOCK]
  210. def enable_child_lock(self):
  211. self._set_properties({ATTR_CHILD_LOCK: True})
  212. def disable_child_lock(self):
  213. self._set_properties({ATTR_CHILD_LOCK: False})
  214. @property
  215. def is_faulted(self):
  216. return self._get_cached_state()[ATTR_FAULT]
  217. @property
  218. def power_level(self):
  219. power_mode = self._get_cached_state()[ATTR_POWER_MODE]
  220. if power_mode == 'user':
  221. return self._get_cached_state()[ATTR_POWER_LEVEL]
  222. elif power_mode == 'auto':
  223. return 'Auto'
  224. else:
  225. return None
  226. @property
  227. def power_level_list(self):
  228. return list(GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL.keys())
  229. def set_power_level(self, new_level):
  230. if new_level not in GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL.keys():
  231. raise ValueError(f'Invalid power level: {new_level}')
  232. self._set_properties({ATTR_POWER_LEVEL: new_level})
  233. @property
  234. def timer_timeout_in_minutes(self):
  235. return self._get_cached_state()[ATTR_TIMER_MINUTES]
  236. @property
  237. def is_timer_on(self):
  238. return self._get_cached_state()[ATTR_TIMER_ON]
  239. def start_timer(self, minutes):
  240. self._set_properties({
  241. ATTR_TIMER_ON: True,
  242. ATTR_TIMER_MINUTES: minutes
  243. })
  244. def stop_timer(self):
  245. self._set_properties({ATTR_TIMER_ON: False})
  246. @property
  247. def is_display_on(self):
  248. return self._get_cached_state()[ATTR_DISPLAY_ON]
  249. def turn_display_on(self):
  250. self._set_properties({ATTR_DISPLAY_ON: True})
  251. def turn_display_off(self):
  252. self._set_properties({ATTR_DISPLAY_ON: False})
  253. @property
  254. def power_mode(self):
  255. return self._get_cached_state()[ATTR_POWER_MODE]
  256. def set_power_mode(self, new_mode):
  257. if new_mode not in GOLDAIR_POWER_MODES:
  258. raise ValueError(f'Invalid user mode: {new_mode}')
  259. self._set_properties({ATTR_POWER_MODE: new_mode})
  260. @property
  261. def eco_target_temperature(self):
  262. return self._get_cached_state()[ATTR_ECO_TARGET_TEMPERATURE]
  263. def set_eco_target_temperature(self, eco_target_temperature):
  264. self._set_properties({ATTR_ECO_TARGET_TEMPERATURE: eco_target_temperature})
  265. def set_fixed_properties(self, fixed_properties):
  266. self._fixed_properties = fixed_properties
  267. set_fixed_properties = Timer(10, lambda: self._set_properties(self._fixed_properties))
  268. set_fixed_properties.start()
  269. def refresh(self):
  270. now = time()
  271. cached_state = self._get_cached_state()
  272. if now - cached_state['updated_at'] >= self._CACHE_TIMEOUT:
  273. self._retry_on_failed_connection(lambda: self._refresh_cached_state(), 'Failed to refresh device state.')
  274. def _reset_cached_state(self):
  275. self._cached_state = {
  276. ATTR_ON: None,
  277. ATTR_TARGET_TEMPERATURE: None,
  278. ATTR_TEMPERATURE: None,
  279. ATTR_OPERATION_MODE: None,
  280. ATTR_CHILD_LOCK: None,
  281. ATTR_FAULT: None,
  282. ATTR_POWER_LEVEL: None,
  283. ATTR_TIMER_MINUTES: None,
  284. ATTR_TIMER_ON: None,
  285. ATTR_DISPLAY_ON: None,
  286. ATTR_POWER_MODE: None,
  287. ATTR_ECO_TARGET_TEMPERATURE: None,
  288. 'updated_at': 0
  289. }
  290. self._pending_updates = {}
  291. def _refresh_cached_state(self):
  292. new_state = self._api.status()
  293. self._update_cached_state_from_dps(new_state['dps'])
  294. _LOGGER.info(f'refreshed device state: {json.dumps(new_state)}')
  295. _LOGGER.debug(f'new cache state: {json.dumps(self._cached_state)}')
  296. _LOGGER.debug(f'new cache state (including pending properties): {json.dumps(self._get_cached_state())}')
  297. def _set_properties(self, properties):
  298. if len(properties) == 0:
  299. return
  300. self._add_properties_to_pending_updates(properties)
  301. self._debounce_sending_updates()
  302. def _add_properties_to_pending_updates(self, properties):
  303. now = time()
  304. properties = {**properties, **self._fixed_properties}
  305. pending_updates = self._get_pending_updates()
  306. for key, value in properties.items():
  307. pending_updates[key] = {
  308. 'value': value,
  309. 'updated_at': now
  310. }
  311. _LOGGER.debug(f'new pending updates: {json.dumps(self._pending_updates)}')
  312. def _debounce_sending_updates(self):
  313. try:
  314. self._debounce.cancel()
  315. except AttributeError:
  316. pass
  317. self._debounce = Timer(1, self._send_pending_updates)
  318. self._debounce.start()
  319. def _send_pending_updates(self):
  320. pending_properties = self._get_pending_properties()
  321. new_state = GoldairHeaterDevice._generate_dps_payload_for_properties(pending_properties)
  322. payload = self._api.generate_payload('set', new_state)
  323. _LOGGER.debug(f'sending updated properties: {json.dumps(pending_properties)}')
  324. _LOGGER.info(f'sending dps update: {json.dumps(new_state)}')
  325. self._retry_on_failed_connection(lambda: self._send_payload(payload), 'Failed to update device state.')
  326. def _send_payload(self, payload):
  327. try:
  328. self._lock.acquire()
  329. self._api._send_receive(payload)
  330. self._cached_state['updated_at'] = 0
  331. now = time()
  332. pending_updates = self._get_pending_updates()
  333. for key, value in pending_updates.items():
  334. pending_updates[key]['updated_at'] = now
  335. finally:
  336. self._lock.release()
  337. def _retry_on_failed_connection(self, func, error_message):
  338. for i in range(self._CONNECTION_ATTEMPTS):
  339. try:
  340. func()
  341. except:
  342. if i + 1 == self._CONNECTION_ATTEMPTS:
  343. self._reset_cached_state()
  344. _LOGGER.error(error_message)
  345. def _get_cached_state(self):
  346. cached_state = self._cached_state.copy()
  347. _LOGGER.debug(f'pending updates: {json.dumps(self._get_pending_updates())}')
  348. return {**cached_state, **self._get_pending_properties()}
  349. def _get_pending_properties(self):
  350. return {key: info['value'] for key, info in self._get_pending_updates().items()}
  351. def _get_pending_updates(self):
  352. now = time()
  353. self._pending_updates = {key: value for key, value in self._pending_updates.items()
  354. if now - value['updated_at'] < self._FAKE_IT_TIL_YOU_MAKE_IT_TIMEOUT}
  355. return self._pending_updates
  356. def _update_cached_state_from_dps(self, dps):
  357. now = time()
  358. for key, dps_id in GOLDAIR_PROPERTY_TO_DPS_ID.items():
  359. if dps_id in dps:
  360. value = dps[dps_id]
  361. if dps_id == GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_OPERATION_MODE]:
  362. self._cached_state[key] = GoldairHeaterDevice._get_key_for_value(GOLDAIR_MODE_TO_DPS_MODE, value)
  363. elif dps_id == GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]:
  364. self._cached_state[key] = GoldairHeaterDevice._get_key_for_value(GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL, value)
  365. else:
  366. self._cached_state[key] = value
  367. self._cached_state['updated_at'] = now
  368. @staticmethod
  369. def _generate_dps_payload_for_properties(properties):
  370. dps = {}
  371. for key, dps_id in GOLDAIR_PROPERTY_TO_DPS_ID.items():
  372. if key in properties:
  373. value = properties[key]
  374. if dps_id == GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_OPERATION_MODE]:
  375. dps[dps_id] = GOLDAIR_MODE_TO_DPS_MODE[value]
  376. elif dps_id == GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]:
  377. dps[dps_id] = GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL[value]
  378. else:
  379. dps[dps_id] = value
  380. return dps
  381. @staticmethod
  382. def _get_key_for_value(obj, value):
  383. keys = list(obj.keys())
  384. values = list(obj.values())
  385. return keys[values.index(value)]