goldair_heater.py 16 KB

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