Просмотр исходного кода

WIP: Asynchronicity and supporting config flow

Nik Rolls 5 лет назад
Родитель
Сommit
289a93be8e

+ 110 - 210
custom_components/goldair_climate/__init__.py

@@ -5,246 +5,146 @@ Based on sean6541/tuya-homeassistant for service call logic, and TarxBoy's
 investigation into Goldair's tuyapi statuses
 https://github.com/codetheweb/tuyapi/issues/31.
 """
-from time import time
-from threading import Timer, Lock
-import logging
-import json
 import voluptuous as vol
 
 import homeassistant.helpers.config_validation as cv
-from homeassistant.const import (CONF_NAME, CONF_HOST, TEMP_CELSIUS)
-from homeassistant.helpers.discovery import load_platform
+from homeassistant.const import (CONF_NAME, CONF_HOST)
+from homeassistant.helpers.discovery import async_load_platform
 
-VERSION = '0.0.8'
+from .const import (
+    CONF_DEVICE_ID, CONF_LOCAL_KEY, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN,
+    CONF_CLIMATE, CONF_DISPLAY_LIGHT, CONF_CHILD_LOCK
+)
+from .device import GoldairTuyaDevice
+
+import logging
 
 _LOGGER = logging.getLogger(__name__)
 
+VERSION = '0.0.8'
+
 DOMAIN = 'goldair_climate'
 DATA_GOLDAIR_CLIMATE = 'data_goldair_climate'
 
-API_PROTOCOL_VERSIONS = [3.3, 3.1]
-
-CONF_DEVICE_ID = 'device_id'
-CONF_LOCAL_KEY = 'local_key'
-CONF_TYPE = 'type'
-CONF_TYPE_HEATER = 'heater'
-CONF_TYPE_DEHUMIDIFIER = 'dehumidifier'
-CONF_TYPE_FAN = 'fan'
-CONF_CLIMATE = 'climate'
-CONF_DISPLAY_LIGHT = 'display_light'
-CONF_CHILD_LOCK = 'child_lock'
-
-PLATFORM_SCHEMA = vol.Schema({
-    vol.Required(CONF_NAME): cv.string,
-    vol.Required(CONF_HOST): cv.string,
-    vol.Required(CONF_DEVICE_ID): cv.string,
-    vol.Required(CONF_LOCAL_KEY): cv.string,
-    vol.Required(CONF_TYPE): vol.In([CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN]),
-    vol.Optional(CONF_CLIMATE, default=True): cv.boolean,
-    vol.Optional(CONF_DISPLAY_LIGHT, default=False): cv.boolean,
-    vol.Optional(CONF_CHILD_LOCK, default=False): cv.boolean,
-})
+INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
+    {'key': CONF_NAME, 'type': cv.string, 'required': True},
+    {'key': CONF_HOST, 'type': cv.string, 'required': True},
+    {'key': CONF_DEVICE_ID, 'type': cv.string, 'required': True, 'fixed': True},
+    {'key': CONF_LOCAL_KEY, 'type': cv.string, 'required': True},
+    {
+        'key': CONF_TYPE,
+        'type': vol.In([CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN]),
+        'required': True,
+        'fixed': True
+    },
+    {'key': CONF_CLIMATE, 'type': cv.boolean, 'required': False, 'default': True},
+    {'key': CONF_DISPLAY_LIGHT, 'type': cv.boolean, 'required': False, 'default': False},
+    {'key': CONF_CHILD_LOCK, 'type': cv.boolean, 'required': False, 'default': False}
+]
+
+
+def individual_config_schema(defaults={}, exclude_fixed=False):
+    output = {}
+
+    for prop in INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE:
+        if exclude_fixed and prop.get('fixed'):
+            continue
+
+        default = defaults.get(prop['key'], prop.get('default'))
+        if default is not None:
+            key = vol.Required(prop['key'], default=default) if prop['required'] else vol.Optional(prop['key'],
+                                                                                                   default=default)
+            output[key] = prop['type']
+        else:
+            key = vol.Required(prop['key']) if prop['required'] else vol.Optional(prop['key'])
+            output[key] = prop['type']
+
+    return output
+
 
 CONFIG_SCHEMA = vol.Schema({
-    DOMAIN: vol.All(cv.ensure_list, [PLATFORM_SCHEMA])
+    DOMAIN: vol.All(cv.ensure_list, [vol.Schema(individual_config_schema())])
 }, extra=vol.ALLOW_EXTRA)
 
 
-def setup(hass, config):
+async def async_setup(hass, config):
     hass.data[DOMAIN] = {}
+
     for device_config in config.get(DOMAIN, []):
-        host = device_config.get(CONF_HOST)
+        setup_device(hass, device_config)
 
-        device = GoldairTuyaDevice(
-            device_config.get(CONF_NAME),
-            device_config.get(CONF_DEVICE_ID),
-            device_config.get(CONF_HOST),
-            device_config.get(CONF_LOCAL_KEY)
-        )
-        hass.data[DOMAIN][host] = device
-        discovery_info = {CONF_HOST: host, CONF_TYPE: device_config.get(CONF_TYPE)}
+        discovery_info = {
+            CONF_DEVICE_ID: device_config.get(CONF_DEVICE_ID),
+            CONF_TYPE: device_config.get(CONF_TYPE)
+        }
 
         if device_config.get(CONF_CLIMATE) == True:
-            load_platform(hass, 'climate', DOMAIN, discovery_info, config)
+            hass.async_create_task(
+                async_load_platform(hass, 'climate', DOMAIN, discovery_info, config)
+            )
         if device_config.get(CONF_DISPLAY_LIGHT) == True:
-            load_platform(hass, 'light', DOMAIN, discovery_info, config)
+            hass.async_create_task(
+                async_load_platform(hass, 'light', DOMAIN, discovery_info, config)
+            )
         if device_config.get(CONF_CHILD_LOCK) == True:
-            load_platform(hass, 'lock', DOMAIN, discovery_info, config)
+            hass.async_create_task(
+                async_load_platform(hass, 'lock', DOMAIN, discovery_info, config)
+            )
 
     return True
 
 
-class GoldairTuyaDevice(object):
-    def __init__(self, name, dev_id, address, local_key):
-        """
-        Represents a Goldair Tuya-based device.
-
-        Args:
-            dev_id (str): The device id.
-            address (str): The network address.
-            local_key (str): The encryption key.
-        """
-        import pytuya
-        self._name = name
-        self._api_protocol_version_index = None
-        self._api = pytuya.Device(dev_id, address, local_key, 'device')
-        self._rotate_api_protocol_version()
-
-        self._fixed_properties = {}
-        self._reset_cached_state()
-
-        self._TEMPERATURE_UNIT = TEMP_CELSIUS
-
-        # API calls to update Goldair heaters are asynchronous and non-blocking. This means
-        # you can send a change and immediately request an updated state (like HA does),
-        # but because it has not yet finished processing you will be returned the old state.
-        # The solution is to keep a temporary list of changed properties that we can overlay
-        # onto the state while we wait for the board to update its switches.
-        self._FAKE_IT_TIL_YOU_MAKE_IT_TIMEOUT = 10
-        self._CACHE_TIMEOUT = 20
-        self._CONNECTION_ATTEMPTS = 4
-        self._lock = Lock()
-
-    @property
-    def name(self):
-        return self._name
-
-    @property
-    def temperature_unit(self):
-        return self._TEMPERATURE_UNIT
-
-    def set_fixed_properties(self, fixed_properties):
-        self._fixed_properties = fixed_properties
-        set_fixed_properties = Timer(10, lambda: self._set_properties(self._fixed_properties))
-        set_fixed_properties.start()
-
-    def refresh(self):
-        now = time()
-        cached_state = self._get_cached_state()
-        if now - cached_state['updated_at'] >= self._CACHE_TIMEOUT:
-            self._cached_state['updated_at'] = time()
-            self._retry_on_failed_connection(lambda: self._refresh_cached_state(), f'Failed to refresh device state for {self.name}.')
-
-    def get_property(self, dps_id):
-        cached_state = self._get_cached_state()
-        if dps_id in cached_state:
-            return cached_state[dps_id]
-        else:
-            return None
+async def async_setup_entry(hass, entry):
+    config = {**entry.data, **entry.options}
+    setup_device(hass, config)
 
-    def set_property(self, dps_id, value):
-        self._set_properties({dps_id: value})
+    if config[CONF_CLIMATE] == True:
+        hass.async_create_task(
+            hass.config_entries.async_forward_entry_setup(entry, 'climate')
+        )
+    if config[CONF_DISPLAY_LIGHT] == True:
+        hass.async_create_task(
+            hass.config_entries.async_forward_entry_setup(entry, 'light')
+        )
+    if config[CONF_CHILD_LOCK] == True:
+        hass.async_create_task(
+            hass.config_entries.async_forward_entry_setup(entry, 'lock')
+        )
 
-    def anticipate_property_value(self, dps_id, value):
-        """
-        Update a value in the cached state only. This is good for when you know the device will reflect a new state in
-        the next update, but don't want to wait for that update for the device to represent this state.
+    entry.add_update_listener(async_update_entry)
 
-        The anticipated value will be cleared with the next update.
-        """
-        self._cached_state[dps_id] = value
+    return True
+
+
+async def async_unload_entry(hass, entry):
+    config = entry.data
+    delete_device(hass, config)
+
+    if config[CONF_CLIMATE] == True:
+        await hass.config_entries.async_forward_entry_unload(entry, 'climate')
+    if config[CONF_DISPLAY_LIGHT] == True:
+        await hass.config_entries.async_forward_entry_unload(entry, 'light')
+    if config[CONF_CHILD_LOCK] == True:
+        await hass.config_entries.async_forward_entry_unload(entry, 'lock')
+
+    return True
+
+
+async def async_update_entry(hass, entry):
+    await async_unload_entry(hass, entry)
+    await async_setup_entry(hass, entry)
 
-    def _reset_cached_state(self):
-        self._cached_state = {
-            'updated_at': 0
-        }
-        self._pending_updates = {}
-
-    def _refresh_cached_state(self):
-        new_state = self._api.status()
-        self._cached_state = new_state['dps']
-        self._cached_state['updated_at'] = time()
-        _LOGGER.info(f'refreshed device state: {json.dumps(new_state)}')
-        _LOGGER.debug(f'new cache state (including pending properties): {json.dumps(self._get_cached_state())}')
-
-    def _set_properties(self, properties):
-        if len(properties) == 0:
-            return
-
-        self._add_properties_to_pending_updates(properties)
-        self._debounce_sending_updates()
-
-    def _add_properties_to_pending_updates(self, properties):
-        now = time()
-        properties = {**properties, **self._fixed_properties}
-
-        pending_updates = self._get_pending_updates()
-        for key, value in properties.items():
-            pending_updates[key] = {
-                'value': value,
-                'updated_at': now
-            }
-
-        _LOGGER.debug(f'new pending updates: {json.dumps(self._pending_updates)}')
-
-    def _debounce_sending_updates(self):
-        try:
-            self._debounce.cancel()
-        except AttributeError:
-            pass
-        self._debounce = Timer(1, self._send_pending_updates)
-        self._debounce.start()
-
-    def _send_pending_updates(self):
-        pending_properties = self._get_pending_properties()
-        payload = self._api.generate_payload('set', pending_properties)
-
-        _LOGGER.info(f'sending dps update: {json.dumps(pending_properties)}')
-
-        self._retry_on_failed_connection(lambda: self._send_payload(payload), 'Failed to update device state.')
-
-    def _send_payload(self, payload):
-        try:
-            self._lock.acquire()
-            self._api._send_receive(payload)
-            self._cached_state['updated_at'] = 0
-            now = time()
-            pending_updates = self._get_pending_updates()
-            for key, value in pending_updates.items():
-                pending_updates[key]['updated_at'] = now
-        finally:
-            self._lock.release()
-
-    def _retry_on_failed_connection(self, func, error_message):
-        for i in range(self._CONNECTION_ATTEMPTS):
-            try:
-                func()
-            except:
-                if i + 1 == self._CONNECTION_ATTEMPTS:
-                    self._reset_cached_state()
-                    _LOGGER.error(error_message)
-                else:
-                    self._rotate_api_protocol_version()
-
-    def _get_cached_state(self):
-        cached_state = self._cached_state.copy()
-        _LOGGER.debug(f'pending updates: {json.dumps(self._get_pending_updates())}')
-        return {**cached_state, **self._get_pending_properties()}
-
-    def _get_pending_properties(self):
-        return {key: info['value'] for key, info in self._get_pending_updates().items()}
-
-    def _get_pending_updates(self):
-        now = time()
-        self._pending_updates = {key: value for key, value in self._pending_updates.items()
-                                 if now - value['updated_at'] < self._FAKE_IT_TIL_YOU_MAKE_IT_TIMEOUT}
-        return self._pending_updates
-
-    def _rotate_api_protocol_version(self):
-        if self._api_protocol_version_index is None:
-            self._api_protocol_version_index = 0
-        else:
-            self._api_protocol_version_index += 1
 
-        if self._api_protocol_version_index >= len(API_PROTOCOL_VERSIONS):
-            self._api_protocol_version_index = 0
+def setup_device(hass, config):
+    device = GoldairTuyaDevice(
+        config.get(CONF_NAME),
+        config.get(CONF_DEVICE_ID),
+        config.get(CONF_HOST),
+        config.get(CONF_LOCAL_KEY),
+        hass
+    )
+    hass.data[DOMAIN][config.get(CONF_DEVICE_ID)] = device
 
-        new_version = API_PROTOCOL_VERSIONS[self._api_protocol_version_index]
-        _LOGGER.info(f'Setting protocol version for {self.name} to {new_version}.')
-        self._api.set_version(new_version)
 
-    @staticmethod
-    def get_key_for_value(obj, value):
-        keys = list(obj.keys())
-        values = list(obj.values())
-        return keys[values.index(value)]
+def delete_device(hass, config):
+    del hass.data[DOMAIN][config[CONF_DEVICE_ID]]

+ 19 - 12
custom_components/goldair_climate/climate.py

@@ -1,21 +1,28 @@
 """
 Setup for different kinds of Goldair climate devices
 """
-from homeassistant.const import CONF_HOST
-from custom_components.goldair_climate import (
-    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN
-)
-from custom_components.goldair_climate.heater.climate import GoldairHeater
-from custom_components.goldair_climate.dehumidifier.climate import GoldairDehumidifier
-from custom_components.goldair_climate.fan.climate import GoldairFan
+from . import DOMAIN
+from .const import (CONF_DEVICE_ID, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN)
+from .heater.climate import GoldairHeater
+from .dehumidifier.climate import GoldairDehumidifier
+from .fan.climate import GoldairFan
 
 
-def setup_platform(hass, config, add_devices, discovery_info=None):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
     """Set up the Goldair climate device according to its type."""
-    device = hass.data[DOMAIN][discovery_info[CONF_HOST]]
+    device = hass.data[DOMAIN][discovery_info[CONF_DEVICE_ID]]
     if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
-        add_devices([GoldairHeater(device)])
+        async_add_entities([GoldairHeater(device)])
     elif discovery_info[CONF_TYPE] == CONF_TYPE_DEHUMIDIFIER:
-        add_devices([GoldairDehumidifier(device)])
+        async_add_entities([GoldairDehumidifier(device)])
     elif discovery_info[CONF_TYPE] == CONF_TYPE_FAN:
-        add_devices([GoldairFan(device)])
+        async_add_entities([GoldairFan(device)])
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    discovery_info = {
+        CONF_DEVICE_ID: config[CONF_DEVICE_ID],
+        CONF_TYPE: config[CONF_TYPE]
+    }
+    await async_setup_platform(hass, {}, async_add_entities, discovery_info)

+ 62 - 0
custom_components/goldair_climate/config_flow.py

@@ -0,0 +1,62 @@
+import voluptuous as vol
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant import config_entries
+from homeassistant.core import callback
+from homeassistant.const import (CONF_NAME, CONF_HOST)
+from . import (DOMAIN, individual_config_schema)
+from .const import (CONF_DEVICE_ID, CONF_TYPE)
+
+
+def cv_schema_to_config_schema(schema):
+    config_schema = {}
+    for key, value in schema.items():
+        if value == cv.string:
+            config_schema[key] = str
+        elif value == cv.boolean:
+            config_schema[key] = bool
+        else:
+            config_schema[key] = value
+    return vol.Schema(config_schema)
+
+
+class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
+    VERSION = 1
+    CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+    async def async_step_user(self, user_input=None):
+        if user_input is not None:
+            await self.async_set_unique_id(user_input[CONF_DEVICE_ID])
+            self._abort_if_unique_id_configured()
+            return self.async_create_entry(
+                title=user_input[CONF_NAME], data=user_input
+            )
+
+        return self.async_show_form(
+            step_id='user',
+            data_schema=cv_schema_to_config_schema(individual_config_schema())
+        )
+
+    @staticmethod
+    @callback
+    def async_get_options_flow(config_entry):
+        return OptionsFlowHandler(config_entry)
+
+
+class OptionsFlowHandler(config_entries.OptionsFlow):
+    def __init__(self, config_entry):
+        """Initialize options flow."""
+        self.config_entry = config_entry
+
+    async def async_step_init(self, user_input=None):
+        """Manage the options."""
+        if user_input is not None:
+            return self.async_create_entry(
+                title=user_input[CONF_NAME], data=user_input
+            )
+
+        config = {**self.config_entry.data, **self.config_entry.options}
+        return self.async_show_form(
+            step_id='init',
+            data_schema=cv_schema_to_config_schema(individual_config_schema(defaults=config, exclude_fixed=True))
+        )

+ 11 - 0
custom_components/goldair_climate/const.py

@@ -0,0 +1,11 @@
+CONF_DEVICE_ID = 'device_id'
+CONF_LOCAL_KEY = 'local_key'
+CONF_TYPE = 'type'
+CONF_TYPE_HEATER = 'heater'
+CONF_TYPE_DEHUMIDIFIER = 'dehumidifier'
+CONF_TYPE_FAN = 'fan'
+CONF_CLIMATE = 'climate'
+CONF_DISPLAY_LIGHT = 'display_light'
+CONF_CHILD_LOCK = 'child_lock'
+
+API_PROTOCOL_VERSIONS = [3.3, 3.1]

+ 20 - 64
custom_components/goldair_climate/dehumidifier/climate.py

@@ -6,59 +6,15 @@ from homeassistant.const import (
 )
 from homeassistant.components.climate import ClimateDevice
 from homeassistant.components.climate.const import (
-    ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_PRESET_MODE,
-    FAN_OFF, FAN_LOW, FAN_HIGH,
-    HVAC_MODE_OFF, HVAC_MODE_DRY,
-    SUPPORT_TARGET_HUMIDITY, SUPPORT_PRESET_MODE, SUPPORT_FAN_MODE
+    ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_PRESET_MODE, FAN_LOW, FAN_HIGH, SUPPORT_TARGET_HUMIDITY,
+    SUPPORT_PRESET_MODE, SUPPORT_FAN_MODE
 )
-from custom_components.goldair_climate import GoldairTuyaDevice
-
-ATTR_TARGET_HUMIDITY = 'target_humidity'
-ATTR_AIR_CLEAN_ON = 'air_clean_on'
-ATTR_CHILD_LOCK = 'child_lock'
-ATTR_FAULT = 'fault'
-ATTR_DISPLAY_ON = 'display_on'
-
-PRESET_NORMAL = 'Normal'
-PRESET_LOW = 'Low'
-PRESET_HIGH = 'High'
-PRESET_DRY_CLOTHES = 'Dry clothes'
-PRESET_AIR_CLEAN = 'Air clean'
-
-FAULT_NONE = 'No fault'
-FAULT_TANK = 'Tank full or missing'
-
-PROPERTY_TO_DPS_ID = {
-    ATTR_HVAC_MODE: '1',
-    ATTR_PRESET_MODE: '2',
-    ATTR_TARGET_HUMIDITY: '4',
-    ATTR_AIR_CLEAN_ON: '5',
-    ATTR_FAN_MODE: '6',
-    ATTR_CHILD_LOCK: '7',
-    ATTR_FAULT: '11',
-    ATTR_DISPLAY_ON: '102',
-    ATTR_TEMPERATURE: '103',
-    ATTR_HUMIDITY: '104'
-}
-
-HVAC_MODE_TO_DPS_MODE = {
-    HVAC_MODE_OFF: False,
-    HVAC_MODE_DRY: True
-}
-PRESET_MODE_TO_DPS_MODE = {
-    PRESET_NORMAL: '0',
-    PRESET_LOW: '1',
-    PRESET_HIGH: '2',
-    PRESET_DRY_CLOTHES: '3'
-}
-FAN_MODE_TO_DPS_MODE = {
-    FAN_LOW: '1',
-    FAN_HIGH: '3'
-}
-FAULT_CODE_TO_DPS_CODE = {
-    FAULT_NONE: 0,
-    FAULT_TANK: 8
-}
+from .const import (
+    ATTR_TARGET_HUMIDITY, ATTR_AIR_CLEAN_ON, ATTR_FAULT, PRESET_NORMAL, PRESET_LOW, PRESET_HIGH, PRESET_DRY_CLOTHES,
+    PRESET_AIR_CLEAN, FAULT_NONE, PROPERTY_TO_DPS_ID, HVAC_MODE_TO_DPS_MODE, PRESET_MODE_TO_DPS_MODE,
+    FAN_MODE_TO_DPS_MODE, FAULT_CODE_TO_DPS_CODE
+)
+from ..device import GoldairTuyaDevice
 
 SUPPORT_FLAGS = SUPPORT_TARGET_HUMIDITY | SUPPORT_PRESET_MODE | SUPPORT_FAN_MODE
 
@@ -116,12 +72,12 @@ class GoldairDehumidifier(ClimateDevice):
         """Return the current target humidity."""
         return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_TARGET_HUMIDITY])
 
-    def set_humidity(self, humidity):
+    async def async_set_humidity(self, humidity):
         """Set the device's target humidity."""
         if self.preset_mode in [PRESET_AIR_CLEAN, PRESET_DRY_CLOTHES]:
             raise ValueError('Humidity can only be changed while in Normal, Low or High preset modes.')
         humidity = int(self._HUMIDITY_STEP * round(float(humidity) / self._HUMIDITY_STEP))
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_TARGET_HUMIDITY], humidity)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_TARGET_HUMIDITY], humidity)
 
     @property
     def temperature_unit(self):
@@ -158,10 +114,10 @@ class GoldairDehumidifier(ClimateDevice):
         """Return the list of available HVAC modes."""
         return list(HVAC_MODE_TO_DPS_MODE.keys())
 
-    def set_hvac_mode(self, hvac_mode):
+    async def async_set_hvac_mode(self, hvac_mode):
         """Set new HVAC mode."""
         dps_mode = HVAC_MODE_TO_DPS_MODE[hvac_mode]
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE], dps_mode)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE], dps_mode)
 
     @property
     def preset_mode(self):
@@ -181,15 +137,15 @@ class GoldairDehumidifier(ClimateDevice):
         """Return the list of available preset modes."""
         return list(PRESET_MODE_TO_DPS_MODE.keys()) + [PRESET_AIR_CLEAN]
 
-    def set_preset_mode(self, preset_mode):
+    async def async_set_preset_mode(self, preset_mode):
         """Set new preset mode."""
         if preset_mode == PRESET_AIR_CLEAN:
-            self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON], True)
+            await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON], True)
             self._device.anticipate_property_value(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], FAN_HIGH)
         else:
             dps_mode = PRESET_MODE_TO_DPS_MODE[preset_mode]
-            self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON], False)
-            self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE], dps_mode)
+            await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON], False)
+            await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE], dps_mode)
             if preset_mode == PRESET_LOW:
                 self._device.anticipate_property_value(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], FAN_LOW)
             elif preset_mode in [PRESET_HIGH, PRESET_DRY_CLOTHES]:
@@ -225,7 +181,7 @@ class GoldairDehumidifier(ClimateDevice):
         else:
             return []
 
-    def set_fan_mode(self, fan_mode):
+    async def async_set_fan_mode(self, fan_mode):
         """Set new fan mode."""
         if self.preset_mode != PRESET_NORMAL:
             raise ValueError('Fan mode can only be changed while in Normal preset mode.')
@@ -234,7 +190,7 @@ class GoldairDehumidifier(ClimateDevice):
             raise ValueError(f'Invalid fan mode: {fan_mode}')
 
         dps_mode = FAN_MODE_TO_DPS_MODE[fan_mode]
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], dps_mode)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], dps_mode)
 
     @property
     def fault(self):
@@ -245,5 +201,5 @@ class GoldairDehumidifier(ClimateDevice):
         else:
             return GoldairTuyaDevice.get_key_for_value(FAULT_CODE_TO_DPS_CODE, fault)
 
-    def update(self):
-        self._device.refresh()
+    async def async_update(self):
+        await self._device.async_refresh()

+ 51 - 0
custom_components/goldair_climate/dehumidifier/const.py

@@ -0,0 +1,51 @@
+from homeassistant.const import ATTR_TEMPERATURE
+from homeassistant.components.climate.const import (
+    ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_PRESET_MODE, FAN_LOW, FAN_HIGH, HVAC_MODE_OFF, HVAC_MODE_DRY
+)
+
+ATTR_TARGET_HUMIDITY = 'target_humidity'
+ATTR_AIR_CLEAN_ON = 'air_clean_on'
+ATTR_CHILD_LOCK = 'child_lock'
+ATTR_FAULT = 'fault'
+ATTR_DISPLAY_ON = 'display_on'
+
+PRESET_NORMAL = 'Normal'
+PRESET_LOW = 'Low'
+PRESET_HIGH = 'High'
+PRESET_DRY_CLOTHES = 'Dry clothes'
+PRESET_AIR_CLEAN = 'Air clean'
+
+FAULT_NONE = 'No fault'
+FAULT_TANK = 'Tank full or missing'
+
+PROPERTY_TO_DPS_ID = {
+    ATTR_HVAC_MODE: '1',
+    ATTR_PRESET_MODE: '2',
+    ATTR_TARGET_HUMIDITY: '4',
+    ATTR_AIR_CLEAN_ON: '5',
+    ATTR_FAN_MODE: '6',
+    ATTR_CHILD_LOCK: '7',
+    ATTR_FAULT: '11',
+    ATTR_DISPLAY_ON: '102',
+    ATTR_TEMPERATURE: '103',
+    ATTR_HUMIDITY: '104'
+}
+
+HVAC_MODE_TO_DPS_MODE = {
+    HVAC_MODE_OFF: False,
+    HVAC_MODE_DRY: True
+}
+PRESET_MODE_TO_DPS_MODE = {
+    PRESET_NORMAL: '0',
+    PRESET_LOW: '1',
+    PRESET_HIGH: '2',
+    PRESET_DRY_CLOTHES: '3'
+}
+FAN_MODE_TO_DPS_MODE = {
+    FAN_LOW: '1',
+    FAN_HIGH: '3'
+}
+FAULT_CODE_TO_DPS_CODE = {
+    FAULT_NONE: 0,
+    FAULT_TANK: 8
+}

+ 11 - 8
custom_components/goldair_climate/dehumidifier/light.py

@@ -3,8 +3,8 @@ Platform to control the LED display light on Goldair WiFi-connected dehumidifier
 """
 from homeassistant.components.light import Light
 from homeassistant.const import STATE_UNAVAILABLE
-from custom_components.goldair_climate import GoldairTuyaDevice
-from custom_components.goldair_climate.dehumidifier.climate import (
+from ..device import GoldairTuyaDevice
+from .const import (
     ATTR_DISPLAY_ON, PROPERTY_TO_DPS_ID, HVAC_MODE_TO_DPS_MODE
 )
 from homeassistant.components.climate import (
@@ -42,15 +42,18 @@ class GoldairDehumidifierLedDisplayLight(Light):
         else:
             return dps_display_on
 
-    def turn_on(self):
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], True)
+    async def async_turn_on(self):
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], True)
 
-    def turn_off(self):
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], False)
+    async def async_turn_off(self):
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], False)
 
-    def toggle(self):
+    async def async_toggle(self):
         dps_hvac_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE])
         dps_display_on = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON])
 
         if dps_hvac_mode != HVAC_MODE_TO_DPS_MODE[HVAC_MODE_OFF]:
-            self.turn_on() if not dps_display_on else self.turn_off()
+            await self.turn_on() if not dps_display_on else await self.turn_off()
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 9 - 6
custom_components/goldair_climate/dehumidifier/lock.py

@@ -3,8 +3,8 @@ Platform to control the child lock on Goldair WiFi-connected dehumidifiers.
 """
 from homeassistant.components.lock import (STATE_LOCKED, STATE_UNLOCKED, LockDevice)
 from homeassistant.const import STATE_UNAVAILABLE
-from custom_components.goldair_climate import GoldairTuyaDevice
-from custom_components.goldair_climate.dehumidifier.climate import (
+from ..device import GoldairTuyaDevice
+from .const import (
     ATTR_CHILD_LOCK, PROPERTY_TO_DPS_ID
 )
 
@@ -41,10 +41,13 @@ class GoldairDehumidifierChildLock(LockDevice):
         """Return the a boolean representing whether the child lock is on or not."""
         return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK])
 
-    def lock(self, **kwargs):
+    async def async_lock(self, **kwargs):
         """Turn on the child lock."""
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK], True)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK], True)
 
-    def unlock(self, **kwargs):
+    async def async_unlock(self, **kwargs):
         """Turn off the child lock."""
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK], False)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK], False)
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 199 - 0
custom_components/goldair_climate/device.py

@@ -0,0 +1,199 @@
+"""
+API for Goldair Tuya devices.
+"""
+
+from time import time
+from threading import Timer, Lock
+import logging
+import json
+
+from homeassistant.const import TEMP_CELSIUS
+from .const import API_PROTOCOL_VERSIONS
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class GoldairTuyaDevice(object):
+    def __init__(self, name, dev_id, address, local_key, hass):
+        """
+        Represents a Goldair Tuya-based device.
+
+        Args:
+            dev_id (str): The device id.
+            address (str): The network address.
+            local_key (str): The encryption key.
+        """
+        import pytuya
+        self._name = name
+        self._api_protocol_version_index = None
+        self._api = pytuya.Device(dev_id, address, local_key, 'device')
+        self._rotate_api_protocol_version()
+
+        self._fixed_properties = {}
+        self._reset_cached_state()
+
+        self._TEMPERATURE_UNIT = TEMP_CELSIUS
+        self._hass = hass
+
+        # API calls to update Goldair heaters are asynchronous and non-blocking. This means
+        # you can send a change and immediately request an updated state (like HA does),
+        # but because it has not yet finished processing you will be returned the old state.
+        # The solution is to keep a temporary list of changed properties that we can overlay
+        # onto the state while we wait for the board to update its switches.
+        self._FAKE_IT_TIL_YOU_MAKE_IT_TIMEOUT = 10
+        self._CACHE_TIMEOUT = 20
+        self._CONNECTION_ATTEMPTS = 4
+        self._lock = Lock()
+
+    @property
+    def name(self):
+        return self._name
+
+    @property
+    def temperature_unit(self):
+        return self._TEMPERATURE_UNIT
+
+    def set_fixed_properties(self, fixed_properties):
+        self._fixed_properties = fixed_properties
+        set_fixed_properties = Timer(10, lambda: self._set_properties(self._fixed_properties))
+        set_fixed_properties.start()
+
+    def refresh(self):
+        now = time()
+        cached_state = self._get_cached_state()
+        if now - cached_state['updated_at'] >= self._CACHE_TIMEOUT:
+            self._cached_state['updated_at'] = time()
+            self._retry_on_failed_connection(
+                lambda: self._refresh_cached_state(),
+                f'Failed to refresh device state for {self.name}.'
+            )
+
+    async def async_refresh(self):
+        await self._hass.async_add_executor_job(self.refresh)
+
+    def get_property(self, dps_id):
+        cached_state = self._get_cached_state()
+        if dps_id in cached_state:
+            return cached_state[dps_id]
+        else:
+            return None
+
+    def set_property(self, dps_id, value):
+        self._set_properties({dps_id: value})
+
+    async def async_set_property(self, dps_id, value):
+        await self._hass.async_add_executor_job(self.set_property, dps_id, value)
+
+    def anticipate_property_value(self, dps_id, value):
+        """
+        Update a value in the cached state only. This is good for when you know the device will reflect a new state in
+        the next update, but don't want to wait for that update for the device to represent this state.
+
+        The anticipated value will be cleared with the next update.
+        """
+        self._cached_state[dps_id] = value
+
+    def _reset_cached_state(self):
+        self._cached_state = {
+            'updated_at': 0
+        }
+        self._pending_updates = {}
+
+    def _refresh_cached_state(self):
+        new_state = self._api.status()
+        self._cached_state = new_state['dps']
+        self._cached_state['updated_at'] = time()
+        _LOGGER.info(f'refreshed device state: {json.dumps(new_state)}')
+        _LOGGER.debug(f'new cache state (including pending properties): {json.dumps(self._get_cached_state())}')
+
+    def _set_properties(self, properties):
+        if len(properties) == 0:
+            return
+
+        self._add_properties_to_pending_updates(properties)
+        self._debounce_sending_updates()
+
+    def _add_properties_to_pending_updates(self, properties):
+        now = time()
+        properties = {**properties, **self._fixed_properties}
+
+        pending_updates = self._get_pending_updates()
+        for key, value in properties.items():
+            pending_updates[key] = {
+                'value': value,
+                'updated_at': now
+            }
+
+        _LOGGER.debug(f'new pending updates: {json.dumps(self._pending_updates)}')
+
+    def _debounce_sending_updates(self):
+        try:
+            self._debounce.cancel()
+        except AttributeError:
+            pass
+        self._debounce = Timer(1, self._send_pending_updates)
+        self._debounce.start()
+
+    def _send_pending_updates(self):
+        pending_properties = self._get_pending_properties()
+        payload = self._api.generate_payload('set', pending_properties)
+
+        _LOGGER.info(f'sending dps update: {json.dumps(pending_properties)}')
+
+        self._retry_on_failed_connection(lambda: self._send_payload(payload), 'Failed to update device state.')
+
+    def _send_payload(self, payload):
+        try:
+            self._lock.acquire()
+            self._api._send_receive(payload)
+            self._cached_state['updated_at'] = 0
+            now = time()
+            pending_updates = self._get_pending_updates()
+            for key, value in pending_updates.items():
+                pending_updates[key]['updated_at'] = now
+        finally:
+            self._lock.release()
+
+    def _retry_on_failed_connection(self, func, error_message):
+        for i in range(self._CONNECTION_ATTEMPTS):
+            try:
+                func()
+            except:
+                if i + 1 == self._CONNECTION_ATTEMPTS:
+                    self._reset_cached_state()
+                    _LOGGER.error(error_message)
+                else:
+                    self._rotate_api_protocol_version()
+
+    def _get_cached_state(self):
+        cached_state = self._cached_state.copy()
+        _LOGGER.debug(f'pending updates: {json.dumps(self._get_pending_updates())}')
+        return {**cached_state, **self._get_pending_properties()}
+
+    def _get_pending_properties(self):
+        return {key: info['value'] for key, info in self._get_pending_updates().items()}
+
+    def _get_pending_updates(self):
+        now = time()
+        self._pending_updates = {key: value for key, value in self._pending_updates.items()
+                                 if now - value['updated_at'] < self._FAKE_IT_TIL_YOU_MAKE_IT_TIMEOUT}
+        return self._pending_updates
+
+    def _rotate_api_protocol_version(self):
+        if self._api_protocol_version_index is None:
+            self._api_protocol_version_index = 0
+        else:
+            self._api_protocol_version_index += 1
+
+        if self._api_protocol_version_index >= len(API_PROTOCOL_VERSIONS):
+            self._api_protocol_version_index = 0
+
+        new_version = API_PROTOCOL_VERSIONS[self._api_protocol_version_index]
+        _LOGGER.info(f'Setting protocol version for {self.name} to {new_version}.')
+        self._api.set_version(new_version)
+
+    @staticmethod
+    def get_key_for_value(obj, value):
+        keys = list(obj.keys())
+        values = list(obj.values())
+        return keys[values.index(value)]

+ 17 - 50
custom_components/goldair_climate/fan/climate.py

@@ -6,46 +6,13 @@ from homeassistant.const import (
 )
 from homeassistant.components.climate import ClimateDevice
 from homeassistant.components.climate.const import (
-    ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_FAN_MODE, ATTR_SWING_MODE,
-    HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY,
-    PRESET_ECO, PRESET_SLEEP,
-    SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE, SUPPORT_SWING_MODE,
-    SWING_OFF, SWING_HORIZONTAL
+    ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_FAN_MODE, ATTR_SWING_MODE, SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE,
+    SUPPORT_SWING_MODE
+)
+from ..device import GoldairTuyaDevice
+from .const import (
+    PROPERTY_TO_DPS_ID, HVAC_MODE_TO_DPS_MODE, PRESET_MODE_TO_DPS_MODE, SWING_MODE_TO_DPS_MODE, FAN_MODES
 )
-from custom_components.goldair_climate import GoldairTuyaDevice
-
-ATTR_TARGET_TEMPERATURE = 'target_temperature'
-ATTR_DISPLAY_ON = 'display_on'
-
-PRESET_NORMAL = 'normal'
-
-PROPERTY_TO_DPS_ID = {
-    ATTR_HVAC_MODE: '1',
-    ATTR_FAN_MODE: '2',
-    ATTR_PRESET_MODE: '3',
-    ATTR_SWING_MODE: '8',
-    ATTR_DISPLAY_ON: '101'
-}
-
-HVAC_MODE_TO_DPS_MODE = {
-    HVAC_MODE_OFF: False,
-    HVAC_MODE_FAN_ONLY: True
-}
-PRESET_MODE_TO_DPS_MODE = {
-    PRESET_NORMAL: 'normal',
-    PRESET_ECO: 'nature',
-    PRESET_SLEEP: 'sleep'
-}
-SWING_MODE_TO_DPS_MODE = {
-    SWING_OFF: False,
-    SWING_HORIZONTAL: True
-}
-FAN_MODES = {
-    PRESET_NORMAL: {1: '1', 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11',
-                    12: '12'},
-    PRESET_ECO: {1: '4', 2: '8', 3: '12'},
-    PRESET_SLEEP: {1: '4', 2: '8', 3: '12'}
-}
 
 SUPPORT_FLAGS = SUPPORT_FAN_MODE | SUPPORT_PRESET_MODE | SUPPORT_SWING_MODE
 
@@ -97,10 +64,10 @@ class GoldairFan(ClimateDevice):
         """Return the list of available HVAC modes."""
         return list(HVAC_MODE_TO_DPS_MODE.keys())
 
-    def set_hvac_mode(self, hvac_mode):
+    async def async_set_hvac_mode(self, hvac_mode):
         """Set new HVAC mode."""
         dps_mode = HVAC_MODE_TO_DPS_MODE[hvac_mode]
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE], dps_mode)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE], dps_mode)
 
     @property
     def preset_mode(self):
@@ -116,10 +83,10 @@ class GoldairFan(ClimateDevice):
         """Return the list of available preset modes."""
         return list(PRESET_MODE_TO_DPS_MODE.keys())
 
-    def set_preset_mode(self, preset_mode):
+    async def async_set_preset_mode(self, preset_mode):
         """Set new preset mode."""
         dps_mode = PRESET_MODE_TO_DPS_MODE[preset_mode]
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE], dps_mode)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE], dps_mode)
 
     @property
     def swing_mode(self):
@@ -135,16 +102,16 @@ class GoldairFan(ClimateDevice):
         """Return the list of available swing modes."""
         return list(SWING_MODE_TO_DPS_MODE.keys())
 
-    def set_swing_mode(self, swing_mode):
+    async def async_set_swing_mode(self, swing_mode):
         """Set new swing mode."""
         dps_mode = SWING_MODE_TO_DPS_MODE[swing_mode]
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_SWING_MODE], dps_mode)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_SWING_MODE], dps_mode)
 
     @property
     def fan_mode(self):
         """Return current fan mode: 1-12"""
         dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE])
-        if dps_mode is not None and self.preset_mode is not None:
+        if dps_mode is not None and self.preset_mode is not None and dps_mode in FAN_MODES[self.preset_mode].values():
             return GoldairTuyaDevice.get_key_for_value(FAN_MODES[self.preset_mode], dps_mode)
         else:
             return None
@@ -157,11 +124,11 @@ class GoldairFan(ClimateDevice):
         else:
             return []
 
-    def set_fan_mode(self, fan_mode):
+    async def async_set_fan_mode(self, fan_mode):
         """Set new fan mode."""
         if self.preset_mode is not None:
             dps_mode = FAN_MODES[self.preset_mode][int(fan_mode)]
-            self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], dps_mode)
+            await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], dps_mode)
 
-    def update(self):
-        self._device.refresh()
+    async def async_update(self):
+        await self._device.async_refresh()

+ 39 - 0
custom_components/goldair_climate/fan/const.py

@@ -0,0 +1,39 @@
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_FAN_MODE, ATTR_SWING_MODE,
+    HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY,
+    PRESET_ECO, PRESET_SLEEP,
+    SWING_OFF, SWING_HORIZONTAL
+)
+
+ATTR_TARGET_TEMPERATURE = 'target_temperature'
+ATTR_DISPLAY_ON = 'display_on'
+
+PRESET_NORMAL = 'normal'
+
+PROPERTY_TO_DPS_ID = {
+    ATTR_HVAC_MODE: '1',
+    ATTR_FAN_MODE: '2',
+    ATTR_PRESET_MODE: '3',
+    ATTR_SWING_MODE: '8',
+    ATTR_DISPLAY_ON: '101'
+}
+
+HVAC_MODE_TO_DPS_MODE = {
+    HVAC_MODE_OFF: False,
+    HVAC_MODE_FAN_ONLY: True
+}
+PRESET_MODE_TO_DPS_MODE = {
+    PRESET_NORMAL: 'normal',
+    PRESET_ECO: 'nature',
+    PRESET_SLEEP: 'sleep'
+}
+SWING_MODE_TO_DPS_MODE = {
+    SWING_OFF: False,
+    SWING_HORIZONTAL: True
+}
+FAN_MODES = {
+    PRESET_NORMAL: {1: '1', 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11',
+                    12: '12'},
+    PRESET_ECO: {1: '4', 2: '8', 3: '12'},
+    PRESET_SLEEP: {1: '4', 2: '8', 3: '12'}
+}

+ 11 - 8
custom_components/goldair_climate/fan/light.py

@@ -3,8 +3,8 @@ Platform to control the LED display light on Goldair WiFi-connected fans and pan
 """
 from homeassistant.components.light import Light
 from homeassistant.const import STATE_UNAVAILABLE
-from custom_components.goldair_climate import GoldairTuyaDevice
-from custom_components.goldair_climate.fan.climate import (
+from ..device import GoldairTuyaDevice
+from .const import (
     ATTR_DISPLAY_ON, PROPERTY_TO_DPS_ID, HVAC_MODE_TO_DPS_MODE
 )
 from homeassistant.components.climate import (
@@ -42,15 +42,18 @@ class GoldairFanLedDisplayLight(Light):
         else:
             return dps_display_on
 
-    def turn_on(self):
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], True)
+    async def async_turn_on(self):
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], True)
 
-    def turn_off(self):
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], False)
+    async def async_turn_off(self):
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], False)
 
-    def toggle(self):
+    async def async_toggle(self):
         dps_hvac_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE])
         dps_display_on = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON])
 
         if dps_hvac_mode != HVAC_MODE_TO_DPS_MODE[HVAC_MODE_OFF]:
-            self.turn_on() if not dps_display_on else self.turn_off()
+            await self.async_turn_on() if not dps_display_on else await self.async_turn_off()
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 20 - 62
custom_components/goldair_climate/heater/climate.py

@@ -7,56 +7,14 @@ from homeassistant.const import (
 from homeassistant.components.climate import ClimateDevice
 from homeassistant.components.climate.const import (
     ATTR_HVAC_MODE, ATTR_PRESET_MODE,
-    HVAC_MODE_OFF, HVAC_MODE_HEAT,
     SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE, SUPPORT_SWING_MODE
 )
-from custom_components.goldair_climate import GoldairTuyaDevice
-
-ATTR_TARGET_TEMPERATURE = 'target_temperature'
-ATTR_CHILD_LOCK = 'child_lock'
-ATTR_FAULT = 'fault'
-ATTR_POWER_MODE_AUTO = 'auto'
-ATTR_POWER_MODE_USER = 'user'
-ATTR_POWER_LEVEL = 'power_level'
-ATTR_DISPLAY_ON = 'display_on'
-ATTR_POWER_MODE = 'power_mode'
-ATTR_ECO_TARGET_TEMPERATURE = 'eco_' + ATTR_TARGET_TEMPERATURE
-
-STATE_COMFORT = 'Comfort'
-STATE_ECO = 'Eco'
-STATE_ANTI_FREEZE = 'Anti-freeze'
-
-PROPERTY_TO_DPS_ID = {
-    ATTR_HVAC_MODE: '1',
-    ATTR_TARGET_TEMPERATURE: '2',
-    ATTR_TEMPERATURE: '3',
-    ATTR_PRESET_MODE: '4',
-    ATTR_CHILD_LOCK: '6',
-    ATTR_FAULT: '12',
-    ATTR_POWER_LEVEL: '101',
-    ATTR_DISPLAY_ON: '104',
-    ATTR_POWER_MODE: '105',
-    ATTR_ECO_TARGET_TEMPERATURE: '106'
-}
-
-HVAC_MODE_TO_DPS_MODE = {
-    HVAC_MODE_OFF: False,
-    HVAC_MODE_HEAT: True
-}
-PRESET_MODE_TO_DPS_MODE = {
-    STATE_COMFORT: 'C',
-    STATE_ECO: 'ECO',
-    STATE_ANTI_FREEZE: 'AF'
-}
-POWER_LEVEL_TO_DPS_LEVEL = {
-    'Stop': 'stop',
-    '1': '1',
-    '2': '2',
-    '3': '3',
-    '4': '4',
-    '5': '5',
-    'Auto': 'auto'
-}
+from ..device import GoldairTuyaDevice
+from .const import (
+    ATTR_TARGET_TEMPERATURE, ATTR_POWER_MODE_AUTO, ATTR_POWER_MODE_USER, ATTR_POWER_LEVEL, ATTR_POWER_MODE,
+    ATTR_ECO_TARGET_TEMPERATURE, STATE_COMFORT, STATE_ECO, STATE_ANTI_FREEZE, PROPERTY_TO_DPS_ID, HVAC_MODE_TO_DPS_MODE,
+    PRESET_MODE_TO_DPS_MODE, POWER_LEVEL_TO_DPS_LEVEL
+)
 
 SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE | SUPPORT_SWING_MODE
 
@@ -136,14 +94,14 @@ class GoldairHeater(ClimateDevice):
         else:
             return None
 
-    def set_temperature(self, **kwargs):
+    async def async_set_temperature(self, **kwargs):
         """Set new target temperatures."""
         if kwargs.get(ATTR_PRESET_MODE) is not None:
-            self.set_preset_mode(kwargs.get(ATTR_PRESET_MODE))
+            await self.async_set_preset_mode(kwargs.get(ATTR_PRESET_MODE))
         if kwargs.get(ATTR_TEMPERATURE) is not None:
-            self.set_target_temperature(kwargs.get(ATTR_TEMPERATURE))
+            await self.async_set_target_temperature(kwargs.get(ATTR_TEMPERATURE))
 
-    def set_target_temperature(self, target_temperature):
+    async def async_set_target_temperature(self, target_temperature):
         target_temperature = int(round(target_temperature))
         preset_mode = self.preset_mode
 
@@ -158,9 +116,9 @@ class GoldairHeater(ClimateDevice):
             )
 
         if preset_mode == STATE_COMFORT:
-            self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE], target_temperature)
+            await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE], target_temperature)
         elif preset_mode == STATE_ECO:
-            self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_ECO_TARGET_TEMPERATURE], target_temperature)
+            await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_ECO_TARGET_TEMPERATURE], target_temperature)
 
     @property
     def current_temperature(self):
@@ -182,10 +140,10 @@ class GoldairHeater(ClimateDevice):
         """Return the list of available HVAC modes."""
         return list(HVAC_MODE_TO_DPS_MODE.keys())
 
-    def set_hvac_mode(self, hvac_mode):
+    async def async_set_hvac_mode(self, hvac_mode):
         """Set new HVAC mode."""
         dps_mode = HVAC_MODE_TO_DPS_MODE[hvac_mode]
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE], dps_mode)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE], dps_mode)
 
     @property
     def preset_mode(self):
@@ -201,10 +159,10 @@ class GoldairHeater(ClimateDevice):
         """Return the list of available preset modes."""
         return list(PRESET_MODE_TO_DPS_MODE.keys())
 
-    def set_preset_mode(self, preset_mode):
+    async def async_set_preset_mode(self, preset_mode):
         """Set new preset mode."""
         dps_mode = PRESET_MODE_TO_DPS_MODE[preset_mode]
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE], dps_mode)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE], dps_mode)
 
     @property
     def swing_mode(self):
@@ -222,13 +180,13 @@ class GoldairHeater(ClimateDevice):
         """List of power levels."""
         return list(POWER_LEVEL_TO_DPS_LEVEL.keys())
 
-    def set_swing_mode(self, swing_mode):
+    async def async_set_swing_mode(self, swing_mode):
         """Set new power level."""
         new_level = swing_mode
         if new_level not in POWER_LEVEL_TO_DPS_LEVEL.keys():
             raise ValueError(f'Invalid power level: {new_level}')
         dps_level = POWER_LEVEL_TO_DPS_LEVEL[new_level]
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL], dps_level)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL], dps_level)
 
-    def update(self):
-        self._device.refresh()
+    async def async_update(self):
+        await self._device.async_refresh()

+ 50 - 0
custom_components/goldair_climate/heater/const.py

@@ -0,0 +1,50 @@
+from homeassistant.const import ATTR_TEMPERATURE
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE, ATTR_PRESET_MODE, HVAC_MODE_OFF, HVAC_MODE_HEAT
+)
+
+ATTR_TARGET_TEMPERATURE = 'target_temperature'
+ATTR_CHILD_LOCK = 'child_lock'
+ATTR_FAULT = 'fault'
+ATTR_POWER_MODE_AUTO = 'auto'
+ATTR_POWER_MODE_USER = 'user'
+ATTR_POWER_LEVEL = 'power_level'
+ATTR_DISPLAY_ON = 'display_on'
+ATTR_POWER_MODE = 'power_mode'
+ATTR_ECO_TARGET_TEMPERATURE = 'eco_' + ATTR_TARGET_TEMPERATURE
+
+STATE_COMFORT = 'Comfort'
+STATE_ECO = 'Eco'
+STATE_ANTI_FREEZE = 'Anti-freeze'
+
+PROPERTY_TO_DPS_ID = {
+    ATTR_HVAC_MODE: '1',
+    ATTR_TARGET_TEMPERATURE: '2',
+    ATTR_TEMPERATURE: '3',
+    ATTR_PRESET_MODE: '4',
+    ATTR_CHILD_LOCK: '6',
+    ATTR_FAULT: '12',
+    ATTR_POWER_LEVEL: '101',
+    ATTR_DISPLAY_ON: '104',
+    ATTR_POWER_MODE: '105',
+    ATTR_ECO_TARGET_TEMPERATURE: '106'
+}
+
+HVAC_MODE_TO_DPS_MODE = {
+    HVAC_MODE_OFF: False,
+    HVAC_MODE_HEAT: True
+}
+PRESET_MODE_TO_DPS_MODE = {
+    STATE_COMFORT: 'C',
+    STATE_ECO: 'ECO',
+    STATE_ANTI_FREEZE: 'AF'
+}
+POWER_LEVEL_TO_DPS_LEVEL = {
+    'Stop': 'stop',
+    '1': '1',
+    '2': '2',
+    '3': '3',
+    '4': '4',
+    '5': '5',
+    'Auto': 'auto'
+}

+ 11 - 8
custom_components/goldair_climate/heater/light.py

@@ -3,8 +3,8 @@ Platform to control the LED display light on Goldair WiFi-connected heaters and
 """
 from homeassistant.components.light import Light
 from homeassistant.const import STATE_UNAVAILABLE
-from custom_components.goldair_climate import GoldairTuyaDevice
-from custom_components.goldair_climate.heater.climate import (
+from ..device import GoldairTuyaDevice
+from .const import (
     ATTR_DISPLAY_ON, PROPERTY_TO_DPS_ID, HVAC_MODE_TO_DPS_MODE
 )
 from homeassistant.components.climate import (
@@ -42,15 +42,18 @@ class GoldairHeaterLedDisplayLight(Light):
         else:
             return dps_display_on
 
-    def turn_on(self):
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], True)
+    async def async_turn_on(self):
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], True)
 
-    def turn_off(self):
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], False)
+    async def async_turn_off(self):
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], False)
 
-    def toggle(self):
+    async def async_toggle(self):
         dps_hvac_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE])
         dps_display_on = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON])
 
         if dps_hvac_mode != HVAC_MODE_TO_DPS_MODE[HVAC_MODE_OFF]:
-            self.turn_on() if not dps_display_on else self.turn_off()
+            await self.async_turn_on() if not dps_display_on else await self.async_turn_off()
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 9 - 6
custom_components/goldair_climate/heater/lock.py

@@ -3,8 +3,8 @@ Platform to control the child lock on Goldair WiFi-connected heaters and panels.
 """
 from homeassistant.components.lock import (STATE_LOCKED, STATE_UNLOCKED, LockDevice)
 from homeassistant.const import STATE_UNAVAILABLE
-from custom_components.goldair_climate import GoldairTuyaDevice
-from custom_components.goldair_climate.heater.climate import (
+from ..device import GoldairTuyaDevice
+from .const import (
     ATTR_CHILD_LOCK, PROPERTY_TO_DPS_ID
 )
 
@@ -41,10 +41,13 @@ class GoldairHeaterChildLock(LockDevice):
         """Return the a boolean representing whether the child lock is on or not."""
         return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK])
 
-    def lock(self, **kwargs):
+    async def async_lock(self, **kwargs):
         """Turn on the child lock."""
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK], True)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK], True)
 
-    def unlock(self, **kwargs):
+    async def async_unlock(self, **kwargs):
         """Turn off the child lock."""
-        self._device.set_property(PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK], False)
+        await self._device.async_set_property(PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK], False)
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 19 - 12
custom_components/goldair_climate/light.py

@@ -1,21 +1,28 @@
 """
 Setup for different kinds of Goldair climate devices
 """
-from homeassistant.const import CONF_HOST
-from custom_components.goldair_climate import (
-    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN
-)
-from custom_components.goldair_climate.heater.light import GoldairHeaterLedDisplayLight
-from custom_components.goldair_climate.dehumidifier.light import GoldairDehumidifierLedDisplayLight
-from custom_components.goldair_climate.fan.light import GoldairFanLedDisplayLight
+from . import DOMAIN
+from .const import (CONF_DEVICE_ID, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN)
+from .heater.light import GoldairHeaterLedDisplayLight
+from .dehumidifier.light import GoldairDehumidifierLedDisplayLight
+from .fan.light import GoldairFanLedDisplayLight
 
 
-def setup_platform(hass, config, add_devices, discovery_info=None):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
     """Set up the Goldair climate device according to its type."""
-    device = hass.data[DOMAIN][discovery_info[CONF_HOST]]
+    device = hass.data[DOMAIN][discovery_info[CONF_DEVICE_ID]]
     if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
-        add_devices([GoldairHeaterLedDisplayLight(device)])
+        async_add_entities([GoldairHeaterLedDisplayLight(device)])
     elif discovery_info[CONF_TYPE] == CONF_TYPE_DEHUMIDIFIER:
-        add_devices([GoldairDehumidifierLedDisplayLight(device)])
+        async_add_entities([GoldairDehumidifierLedDisplayLight(device)])
     elif discovery_info[CONF_TYPE] == CONF_TYPE_FAN:
-        add_devices([GoldairFanLedDisplayLight(device)])
+        async_add_entities([GoldairFanLedDisplayLight(device)])
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    discovery_info = {
+        CONF_DEVICE_ID: config[CONF_DEVICE_ID],
+        CONF_TYPE: config[CONF_TYPE]
+    }
+    await async_setup_platform(hass, {}, async_add_entities, discovery_info)

+ 17 - 10
custom_components/goldair_climate/lock.py

@@ -1,20 +1,27 @@
 """
 Setup for different kinds of Goldair climate devices
 """
-from homeassistant.const import CONF_HOST
-from custom_components.goldair_climate import (
-    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN
-)
-from custom_components.goldair_climate.heater.lock import GoldairHeaterChildLock
-from custom_components.goldair_climate.dehumidifier.lock import GoldairDehumidifierChildLock
+from . import DOMAIN
+from .const import (CONF_DEVICE_ID, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN)
+from .heater.lock import GoldairHeaterChildLock
+from .dehumidifier.lock import GoldairDehumidifierChildLock
 
 
-def setup_platform(hass, config, add_devices, discovery_info=None):
+async def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
     """Set up the Goldair climate device according to its type."""
-    device = hass.data[DOMAIN][discovery_info[CONF_HOST]]
+    device = hass.data[DOMAIN][discovery_info[CONF_DEVICE_ID]]
     if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
-        add_devices([GoldairHeaterChildLock(device)])
+        async_add_devices([GoldairHeaterChildLock(device)])
     if discovery_info[CONF_TYPE] == CONF_TYPE_DEHUMIDIFIER:
-        add_devices([GoldairDehumidifierChildLock(device)])
+        async_add_devices([GoldairDehumidifierChildLock(device)])
     if discovery_info[CONF_TYPE] == CONF_TYPE_FAN:
         raise ValueError('Goldair fains do not support Child Lock.')
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    discovery_info = {
+        CONF_DEVICE_ID: config[CONF_DEVICE_ID],
+        CONF_TYPE: config[CONF_TYPE]
+    }
+    await async_setup_platform(hass, {}, async_add_entities, discovery_info)

+ 2 - 1
custom_components/goldair_climate/manifest.json

@@ -9,5 +9,6 @@
   "requirements": [
     "pytuya>=7.0.5"
   ],
-  "homeassistant": "0.96.0"
+  "homeassistant": "0.96.0",
+  "config_flow": true
 }

+ 26 - 0
custom_components/goldair_climate/strings.json

@@ -0,0 +1,26 @@
+{
+  "title": "Goldair Climate",
+  "config": {
+    "step": {
+      "user": {
+        "title": "Configure a Goldair climate device",
+        "description": "Please enter your device information.",
+        "data": {
+          "name": "Name",
+          "host": "IP address or hostname",
+          "device_id": "Device ID (uuid)",
+          "local_key": "Local key",
+          "type": "Device type",
+          "heater": "Heater",
+          "dehumidifier": "Dehumidifier",
+          "fan": "Fan",
+          "display_light": "Add LED display as light entity",
+          "child_lock": "Add child lock as lock entity"
+        }
+      }
+    },
+    "abort": {
+      "already_configured": "[%key:common.config_flow.abort.already_configured%]"
+    }
+  }
+}