Przeglądaj źródła

Merge branch 'nikrolls/master'

Update also the Kogan Heater support for async support and autodetection.

# Conflicts:
#	README.md
#	custom_components/goldair_climate/climate.py
#	custom_components/goldair_climate/manifest.json
#	custom_components/tuya_local/.translations
#	custom_components/tuya_local/__init__.py
#	custom_components/tuya_local/binary_sensor.py
#	custom_components/tuya_local/config_flow.py
#	custom_components/tuya_local/configuration.py
#	custom_components/tuya_local/const.py
#	custom_components/tuya_local/dehumidifier/binary_sensor.py
#	custom_components/tuya_local/dehumidifier/climate.py
#	custom_components/tuya_local/dehumidifier/const.py
#	custom_components/tuya_local/dehumidifier/light.py
#	custom_components/tuya_local/dehumidifier/lock.py
#	custom_components/tuya_local/device.py
#	custom_components/tuya_local/fan/climate.py
#	custom_components/tuya_local/fan/const.py
#	custom_components/tuya_local/fan/light.py
#	custom_components/tuya_local/heater/climate.py
#	custom_components/tuya_local/heater/const.py
#	custom_components/tuya_local/heater/light.py
#	custom_components/tuya_local/heater/lock.py
#	custom_components/tuya_local/light.py
#	custom_components/tuya_local/lock.py
#	custom_components/tuya_local/strings.json
#	custom_components/tuya_local/translations/en.json
Jason Rumney 5 lat temu
rodzic
commit
8bb2d9f91c
30 zmienionych plików z 1305 dodań i 703 usunięć
  1. 18 0
      .github/workflows/hacs-validate.yml
  2. 14 0
      .github/workflows/hassfest-validate.yml
  3. 7 10
      README.md
  4. 1 0
      custom_components/tuya_local/.translations
  5. 80 216
      custom_components/tuya_local/__init__.py
  6. 0 20
      custom_components/tuya_local/binary_sensor.py
  7. 35 15
      custom_components/tuya_local/climate.py
  8. 71 0
      custom_components/tuya_local/config_flow.py
  9. 46 0
      custom_components/tuya_local/configuration.py
  10. 18 0
      custom_components/tuya_local/const.py
  11. 0 63
      custom_components/tuya_local/dehumidifier/binary_sensor.py
  12. 122 89
      custom_components/tuya_local/dehumidifier/climate.py
  13. 51 0
      custom_components/tuya_local/dehumidifier/const.py
  14. 34 20
      custom_components/tuya_local/dehumidifier/light.py
  15. 22 9
      custom_components/tuya_local/dehumidifier/lock.py
  16. 240 0
      custom_components/tuya_local/device.py
  17. 61 55
      custom_components/tuya_local/fan/climate.py
  18. 51 0
      custom_components/tuya_local/fan/const.py
  19. 36 20
      custom_components/tuya_local/fan/light.py
  20. 95 89
      custom_components/tuya_local/heater/climate.py
  21. 53 0
      custom_components/tuya_local/heater/const.py
  22. 36 20
      custom_components/tuya_local/heater/light.py
  23. 23 9
      custom_components/tuya_local/heater/lock.py
  24. 55 42
      custom_components/tuya_local/kogan_heater/climate.py
  25. 24 0
      custom_components/tuya_local/kogan_heater/const.py
  26. 35 15
      custom_components/tuya_local/light.py
  27. 29 10
      custom_components/tuya_local/lock.py
  28. 1 1
      custom_components/tuya_local/manifest.json
  29. 46 0
      custom_components/tuya_local/strings.json
  30. 1 0
      custom_components/tuya_local/translations/en.json

+ 18 - 0
.github/workflows/hacs-validate.yml

@@ -0,0 +1,18 @@
+name: Validate with HACS
+
+on:
+  push:
+  pull_request:
+  schedule:
+    - cron: "0 0 * * *"
+
+jobs:
+  validate:
+    runs-on: "ubuntu-latest"
+    steps:
+      - uses: "actions/checkout@v2"
+      - name: HACS validation
+        uses: "hacs/integration/action@master"
+        with:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          CATEGORY: "integration"

+ 14 - 0
.github/workflows/hassfest-validate.yml

@@ -0,0 +1,14 @@
+name: Validate with hassfest
+
+on:
+  push:
+  pull_request:
+  schedule:
+    - cron: "0 0 * * *"
+
+jobs:
+  validate:
+    runs-on: "ubuntu-latest"
+    steps:
+      - uses: "actions/checkout@v2"
+      - uses: home-assistant/actions/hassfest@master

+ 7 - 10
README.md

@@ -18,7 +18,7 @@ Current temperature is also displayed.
 * **mode** (Normal, Low, High, Dry clothes, Air clean)
 * **target humidity** (`30`-`80`%)
 
-Current temperature is displayed, and current humidity is available as a property.
+Current temperature is displayed, and current humidity is available as a property. The "tank full" state is available via the **error** attribute, and if you want to you can easily surface this to a top-level entity using a [template sensor](https://www.home-assistant.io/integrations/template/).
 
 **Goldair Fans**
 * **power** (on/off)
@@ -40,9 +40,6 @@ Current temperature is also displayed.
 **Lock** (Goldair heaters and dehumidifiers)
 * **Child lock** (on/off)
 
-**Binary Sensor** (Goldair dehumidifiers)
-* **Tank full** (on/off)
-
 There was previously a sensor option, however this is easily achieved using a [template sensor](https://www.home-assistant.io/integrations/template/) and therefore is no longer supported.
 
 ---
@@ -68,7 +65,9 @@ Alternatively you can copy the contents of this repository's `custom_components`
 
 Configuration
 -------------
-Add the following lines to your `configuration.yaml` file:
+You can easily configure your devices using the Integrations UI at `Home Assistant > Configuration > Integrations > +`. This is the preferred method as things will be unlikely to break as this integration is upgraded. You will need to provide your device's IP address, device ID and local key; the last two can be found using [the instructions below](#finding-your-device-id-and-local-key).
+
+If you would rather configure using yaml, add the following lines to your `configuration.yaml` file (but bear in mind that if the configuration options change your configuration may break until you update it to match the changes):
 
 ```yaml
 # Example configuration.yaml entry
@@ -77,7 +76,6 @@ tuya_local:
     host: 1.2.3.4
     device_id: <your device id>
     local_key: <your local key>
-    type: heater
 ```
 
 ### Configuration variables
@@ -98,7 +96,9 @@ tuya_local:
                                               [as per the instructions below](#finding-your-device-id-and-local-key).
 
 #### type
-&nbsp;&nbsp;&nbsp;&nbsp;*(string) (Required)* The type of Goldair device: currently `heater`, `dehumidifier`, `fan` or `kogan_heater`.
+&nbsp;&nbsp;&nbsp;&nbsp;*(string) (Optional)* The type of Tuya device. `auto` to automatically detect the device type, or if that doesn't work, select from the available options `heater`, `dehumidifier`, `fan` or `kogan_heater`.
+
+&nbsp;&nbsp;&nbsp;&nbsp;*Default value: auto*
 
 #### climate
 &nbsp;&nbsp;&nbsp;&nbsp;*(boolean) (Optional)* Whether to surface this appliance as a climate device.
@@ -115,9 +115,6 @@ tuya_local:
 
 &nbsp;&nbsp;&nbsp;&nbsp;*Default value: false* 
 
-#### tank_full
-&nbsp;&nbsp;&nbsp;&nbsp;*(boolean) (Optional)* Whether to surface this appliances's tank full sensor as a binary_sensor device (only supported for dehumidifiers).
-
 Heater gotchas
 --------------
 Goldair heaters have individual target temperatures for their Comfort and Eco modes, whereas Home Assistant only supports

+ 1 - 0
custom_components/tuya_local/.translations

@@ -0,0 +1 @@
+translations

+ 80 - 216
custom_components/tuya_local/__init__.py

@@ -6,251 +6,115 @@ 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
+import voluptuous as vol
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.discovery import async_load_platform
 
-VERSION = '0.0.8'
+from .configuration import individual_config_schema
+from .const import (DOMAIN, CONF_CHILD_LOCK, CONF_CLIMATE, CONF_DEVICE_ID,
+                    CONF_DISPLAY_LIGHT, CONF_LOCAL_KEY, CONF_TYPE,
+                    CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN, CONF_TYPE_HEATER, SCAN_INTERVAL, CONF_TYPE_AUTO)
+from .device import TuyaLocalDevice
 
 _LOGGER = logging.getLogger(__name__)
 
-DOMAIN = 'tuya_local'
-DATA_TUYA_LOCAL = 'data_tuya_local'
-
-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_TYPE_KOGAN_HEATER = 'kogan_heater'
-CONF_CLIMATE = 'climate'
-CONF_DISPLAY_LIGHT = 'display_light'
-CONF_CHILD_LOCK = 'child_lock'
-CONF_TANK_FULL = 'tank_full'
+VERSION = "0.0.8"
 
-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, CONF_TYPE_KOGAN_HEATER]),
-    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,
-    vol.Optional(CONF_TANK_FULL, default=False): cv.boolean,
-})
+CONFIG_SCHEMA = vol.Schema(
+    {DOMAIN: vol.All(cv.ensure_list, [vol.Schema(individual_config_schema())])},
+    extra=vol.ALLOW_EXTRA,
+)
 
-CONFIG_SCHEMA = vol.Schema({
-    DOMAIN: vol.All(cv.ensure_list, [PLATFORM_SCHEMA])
-}, extra=vol.ALLOW_EXTRA)
 
-
-def setup(hass, config):
+async def async_setup(hass: HomeAssistant, config: dict):
     hass.data[DOMAIN] = {}
-    for device_config in config.get(DOMAIN, []):
-        host = device_config.get(CONF_HOST)
-
-        device = TuyaLocalDevice(
-            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)}
-
-        if device_config.get(CONF_CLIMATE) == True:
-            load_platform(hass, 'climate', DOMAIN, discovery_info, config)
-        if device_config.get(CONF_DISPLAY_LIGHT) == True:
-            load_platform(hass, 'light', DOMAIN, discovery_info, config)
-        if device_config.get(CONF_CHILD_LOCK) == True:
-            load_platform(hass, 'lock', DOMAIN, discovery_info, config)
-        if device_config.get(CONF_TANK_FULL) == True:
-            load_platform(hass, 'binary_sensor', DOMAIN, discovery_info, config)
-
-    return True
-
-
-class TuyaLocalDevice(object):
-    def __init__(self, name, dev_id, address, local_key):
-        """
-        Represents a 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 Tuya devices 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
-
-    def set_property(self, dps_id, value):
-        self._set_properties({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
+    for device_config in config.get(DOMAIN, []):
+        setup_device(hass, device_config)
 
-    def _reset_cached_state(self):
-        self._cached_state = {
-            'updated_at': 0
+        discovery_info = {
+            CONF_DEVICE_ID: device_config[CONF_DEVICE_ID],
+            CONF_TYPE: device_config[CONF_TYPE],
         }
-        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
+        if device_config[CONF_CLIMATE] == True:
+            hass.async_create_task(
+                async_load_platform(hass, "climate", DOMAIN, discovery_info, config)
+            )
+        if device_config[CONF_DISPLAY_LIGHT] == True:
+            hass.async_create_task(
+                async_load_platform(hass, "light", DOMAIN, discovery_info, config)
+            )
+        if device_config[CONF_CHILD_LOCK] == True:
+            hass.async_create_task(
+                async_load_platform(hass, "lock", DOMAIN, discovery_info, config)
+            )
 
-        self._add_properties_to_pending_updates(properties)
-        self._debounce_sending_updates()
+    return True
 
-    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
-            }
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+    config = {**entry.data, **entry.options, 'name': entry.title}
+    setup_device(hass, config)
 
-        _LOGGER.debug(f'new pending updates: {json.dumps(self._pending_updates)}')
+    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 _debounce_sending_updates(self):
-        try:
-            self._debounce.cancel()
-        except AttributeError:
-            pass
-        self._debounce = Timer(1, self._send_pending_updates)
-        self._debounce.start()
+    entry.add_update_listener(async_update_entry)
 
-    def _send_pending_updates(self):
-        pending_properties = self._get_pending_properties()
-        payload = self._api.generate_payload('set', pending_properties)
+    return True
 
-        _LOGGER.info(f'sending dps update: {json.dumps(pending_properties)}')
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+    config = entry.data
+    data = hass.data[DOMAIN][config[CONF_DEVICE_ID]]
 
-        self._retry_on_failed_connection(lambda: self._send_payload(payload), 'Failed to update device state.')
+    if CONF_CLIMATE in data:
+        await hass.config_entries.async_forward_entry_unload(entry, "climate")
+    if CONF_DISPLAY_LIGHT in data:
+        await hass.config_entries.async_forward_entry_unload(entry, "light")
+    if CONF_CHILD_LOCK in data:
+        await hass.config_entries.async_forward_entry_unload(entry, "lock")
 
-    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()
+    delete_device(hass, config)
+    del hass.data[DOMAIN][config[CONF_DEVICE_ID]]
 
-    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()
+    return True
 
-    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()}
+async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry):
+    await async_unload_entry(hass, entry)
+    await async_setup_entry(hass, entry)
 
-    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
+def setup_device(hass: HomeAssistant, config: dict):
+    device = TuyaLocalDevice(
+        config[CONF_NAME],
+        config[CONF_DEVICE_ID],
+        config[CONF_HOST],
+        config[CONF_LOCAL_KEY],
+        hass,
+    )
+    hass.data[DOMAIN][config[CONF_DEVICE_ID]] = {
+        'device': device
+    }
 
-        if self._api_protocol_version_index >= len(API_PROTOCOL_VERSIONS):
-            self._api_protocol_version_index = 0
+    return 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: HomeAssistant, config: dict):
+    del hass.data[DOMAIN][config[CONF_DEVICE_ID]]['device']

+ 0 - 20
custom_components/tuya_local/binary_sensor.py

@@ -1,20 +0,0 @@
-"""
-Setup for different kinds of Tuya climate devices
-"""
-from homeassistant.const import CONF_HOST
-from custom_components.tuya_local import (
-    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN, CONF_TYPE_KOGAN_HEATER
-)
-from custom_components.tuya_local.dehumidifier.binary_sensor import GoldairDehumidifierTankFullBinarySensor
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
-    """Set up the Tuya climate device according to its type."""
-    device = hass.data[DOMAIN][discovery_info[CONF_HOST]]
-    if discovery_info[CONF_TYPE] == CONF_TYPE_DEHUMIDIFIER:
-        add_devices([GoldairDehumidifierTankFullBinarySensor(device)])
-    if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
-        raise ValueError('Goldair heaters do not support tank full sensors.')
-    if discovery_info[CONF_TYPE] == CONF_TYPE_FAN:
-        raise ValueError('Goldair fans do not support tank full sensors.')
-    if discovery_info[CONF_TYPE] == CONF_TYPE_KOGAN_HEATER:
-        raise ValueError('Kogan heaters do not support tank full sensors.')

+ 35 - 15
custom_components/tuya_local/climate.py

@@ -1,23 +1,43 @@
 """
 Setup for different kinds of Tuya climate devices
 """
-from homeassistant.const import CONF_HOST
-from custom_components.tuya_local import (
-    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN, CONF_TYPE_KOGAN_HEATER
-)
-from custom_components.tuya_local.heater.climate import GoldairHeater
-from custom_components.tuya_local.dehumidifier.climate import GoldairDehumidifier
-from custom_components.tuya_local.fan.climate import GoldairFan
-from custom_components.tuya_local.kogan_heater.climate import KoganHeater
+from . import DOMAIN
+from .const import (CONF_DEVICE_ID, CONF_TYPE, CONF_TYPE_DEHUMIDIFIER,
+                    CONF_TYPE_FAN, CONF_TYPE_HEATER, CONF_TYPE_KOGAN_HEATER,
+                    CONF_CLIMATE, CONF_TYPE_AUTO)
+from .dehumidifier.climate import GoldairDehumidifier
+from .fan.climate import GoldairFan
+from .heater.climate import GoldairHeater
+from .kogan_heater.climate import KoganHeater
+
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+    """Set up the Goldair climate device according to its type."""
+    data = hass.data[DOMAIN][discovery_info[CONF_DEVICE_ID]]
+    device = data['device']
+
+    if discovery_info[CONF_TYPE] == CONF_TYPE_AUTO:
+        discovery_info[CONF_TYPE] = await device.async_inferred_type()
+
+        if discovery_info[CONF_TYPE] is None:
+            raise ValueError(f"Unable to detect type for device {device.name}")
 
-def setup_platform(hass, config, add_devices, discovery_info=None):
-    """Set up the Tuya climate device according to its type."""
-    device = hass.data[DOMAIN][discovery_info[CONF_HOST]]
     if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
-        add_devices([GoldairHeater(device)])
+        data[CONF_CLIMATE] = GoldairHeater(device)
     elif discovery_info[CONF_TYPE] == CONF_TYPE_DEHUMIDIFIER:
-        add_devices([GoldairDehumidifier(device)])
+        data[CONF_CLIMATE] = GoldairDehumidifier(device)
     elif discovery_info[CONF_TYPE] == CONF_TYPE_FAN:
-        add_devices([GoldairFan(device)])
+        data[CONF_CLIMATE] = GoldairFan(device)
     elif discovery_info[CONF_TYPE] == CONF_TYPE_KOGAN_HEATER:
-        add_devices([KoganHeater(device)])
+        data[CONF_CLIMATE] = KoganHeater(device)
+
+    if CONF_CLIMATE in data:
+        async_add_entities([data[CONF_CLIMATE]])
+
+
+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)

+ 71 - 0
custom_components/tuya_local/config_flow.py

@@ -0,0 +1,71 @@
+import voluptuous as vol
+from homeassistant import config_entries
+from homeassistant.const import CONF_NAME, CONF_HOST
+from homeassistant.core import callback, HomeAssistant
+
+from . import DOMAIN, individual_config_schema, TuyaLocalDevice
+from .const import CONF_DEVICE_ID, CONF_LOCAL_KEY
+
+
+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):
+        errors = {}
+
+        if user_input is not None:
+            await self.async_set_unique_id(user_input[CONF_DEVICE_ID])
+            self._abort_if_unique_id_configured()
+
+            connect_success = await async_test_connection(user_input, self.hass)
+            if connect_success:
+                title = user_input[CONF_NAME]
+                del user_input[CONF_NAME]
+                return self.async_create_entry(title=title, data=user_input)
+            else:
+                errors["base"] = "connection"
+
+        return self.async_show_form(
+            step_id="user", data_schema=vol.Schema(individual_config_schema(user_input or {})), errors=errors
+        )
+
+    @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):
+        return await self.async_step_user(user_input)
+
+    async def async_step_user(self, user_input=None):
+        """Manage the options."""
+        errors = {}
+        config = {**self.config_entry.data, **self.config_entry.options}
+
+        if user_input is not None:
+            config = {**config, **user_input}
+            connect_success = await async_test_connection(config, self.hass)
+            if connect_success:
+                return self.async_create_entry(title="", data=user_input)
+            else:
+                errors["base"] = "connection"
+
+        return self.async_show_form(
+            step_id="user",
+            data_schema=vol.Schema(
+                individual_config_schema(defaults=config, options_only=True)
+            ),
+            errors=errors
+        )
+
+async def async_test_connection(config: dict, hass: HomeAssistant):
+    device = TuyaLocalDevice("Test", config[CONF_DEVICE_ID], config[CONF_HOST], config[CONF_LOCAL_KEY], hass)
+    await device.async_refresh()
+    return device.get_property("1") is not None

+ 46 - 0
custom_components/tuya_local/configuration.py

@@ -0,0 +1,46 @@
+import voluptuous as vol
+from homeassistant.const import CONF_NAME, CONF_HOST
+
+from .const import (CONF_DEVICE_ID, CONF_LOCAL_KEY, CONF_TYPE, CONF_TYPE_HEATER,
+                    CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN, CONF_TYPE_KOGAN_HEATER, CONF_CLIMATE, CONF_DISPLAY_LIGHT, CONF_CHILD_LOCK,
+                    CONF_TYPE_AUTO)
+
+INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
+    {"key": CONF_NAME, "type": str, "required": True, "option": False},
+    {"key": CONF_HOST, "type": str, "required": True, "option": True},
+    {"key": CONF_DEVICE_ID, "type": str, "required": True, "option": False},
+    {"key": CONF_LOCAL_KEY, "type": str, "required": True, "option": True},
+    {
+        "key": CONF_TYPE,
+        "type": vol.In([CONF_TYPE_AUTO, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN, CONF_TYPE_KOGAN_HEATER]),
+        "required": False,
+        "default": CONF_TYPE_AUTO,
+        "option": True,
+    },
+    {"key": CONF_CLIMATE, "type": bool, "required": False, "default": True, "option": True},
+    {"key": CONF_DISPLAY_LIGHT, "type": bool, "required": False, "default": False, "option": True},
+    {"key": CONF_CHILD_LOCK, "type": bool, "required": False, "default": False, "option": True},
+]
+
+
+def individual_config_schema(defaults={}, options_only=False):
+    output = {}
+
+    for prop in INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE:
+        if options_only and not prop.get("option"):
+            continue
+
+        options = {}
+
+        default = defaults.get(prop["key"], prop.get("default"))
+        if default is not None:
+            options["default"] = default
+
+        key = (
+            vol.Required(prop["key"], **options)
+            if prop["required"]
+            else vol.Optional(prop["key"], **options)
+        )
+        output[key] = prop["type"]
+
+    return output

+ 18 - 0
custom_components/tuya_local/const.py

@@ -0,0 +1,18 @@
+from datetime import timedelta
+
+DOMAIN = "goldair_climate"
+
+CONF_DEVICE_ID = "device_id"
+CONF_LOCAL_KEY = "local_key"
+CONF_TYPE = "type"
+CONF_TYPE_AUTO = "auto"
+CONF_TYPE_HEATER = "heater"
+CONF_TYPE_DEHUMIDIFIER = "dehumidifier"
+CONF_TYPE_FAN = "fan"
+CONF_TYPE_KOGAN_HEATER = "kogan_heater"
+CONF_CLIMATE = "climate"
+CONF_DISPLAY_LIGHT = "display_light"
+CONF_CHILD_LOCK = "child_lock"
+
+API_PROTOCOL_VERSIONS = [3.3, 3.1]
+SCAN_INTERVAL = timedelta(seconds=30)

+ 0 - 63
custom_components/tuya_local/dehumidifier/binary_sensor.py

@@ -1,63 +0,0 @@
-"""
-Platform to sense whether the dehumidifier tank is full.
-"""
-
-from homeassistant.components.binary_sensor import (BinarySensorDevice, DEVICE_CLASS_PROBLEM)
-from custom_components.tuya_local import TuyaLocalDevice
-from custom_components.tuya_local.dehumidifier.climate import (
-    ATTR_FAULT, FAULT_CODE_TO_DPS_CODE, PROPERTY_TO_DPS_ID
-)
-
-ATTR_FAULT_CODE = 'fault_code'
-FAULT_TANK = 8
-FAULT_NONE = 0
-
-class GoldairDehumidifierTankFullBinarySensor(BinarySensorDevice):
-    """Representation of a Goldair WiFi-connected dehumidifier Tank sensor."""
-
-    def __init__(self, device):
-        """Initialize the binary sensor.
-        Args:
-            device (TuyaLocalDevice): The device API instance."""
-        self._device = device
-        self._fault = None
-
-    @property
-    def should_poll(self):
-        """Return the polling state"""
-        return True
-
-    @property
-    def name(self):
-        """Return the name of the binary sensor."""
-        return self._device.name
-
-    @property
-    def is_on(self):
-        """Return true if the tank is full."""
-        if (self._fault is None):
-            return None
-        else:
-            return self._fault == FAULT_TANK
-
-    @property
-    def device_class(self):
-        """Return the class of device."""
-        return DEVICE_CLASS_PROBLEM
-
-    @property
-    def device_state_attributes(self):
-        """Return the state attributes"""
-        attrs = {ATTR_FAULT_CODE: self._fault}
-        # attrs.update(super().device_state_attributes)
-        return attrs
-
-    @property
-    def available(self):
-        """Return true if the device is available and value has not expired"""
-        return self._fault is not None
-
-    def update(self):
-        self._device.refresh()
-        self._fault = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_FAULT])
-

+ 122 - 89
custom_components/tuya_local/dehumidifier/climate.py

@@ -1,64 +1,38 @@
 """
 Goldair WiFi Dehumidifier device.
 """
-from homeassistant.const import (
-    ATTR_TEMPERATURE, TEMP_CELSIUS, STATE_UNAVAILABLE
-)
 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_HIGH,
+    FAN_LOW,
+    SUPPORT_FAN_MODE,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_TARGET_HUMIDITY,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from ..device import TuyaLocalDevice
+from .const import (
+    ATTR_AIR_CLEAN_ON,
+    ATTR_DEFROSTING,
+    ATTR_ERROR,
+    ATTR_TARGET_HUMIDITY,
+    ERROR_CODE_TO_DPS_CODE,
+    ERROR_TANK,
+    FAN_MODE_TO_DPS_MODE,
+    HVAC_MODE_TO_DPS_MODE,
+    PRESET_AIR_CLEAN,
+    PRESET_DRY_CLOTHES,
+    PRESET_HIGH,
+    PRESET_LOW,
+    PRESET_MODE_TO_DPS_MODE,
+    PRESET_NORMAL,
+    PROPERTY_TO_DPS_ID,
 )
-from custom_components.tuya_local import TuyaLocalDevice
-
-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
-}
 
 SUPPORT_FLAGS = SUPPORT_TARGET_HUMIDITY | SUPPORT_PRESET_MODE | SUPPORT_FAN_MODE
 
@@ -76,10 +50,7 @@ class GoldairDehumidifier(ClimateDevice):
         self._support_flags = SUPPORT_FLAGS
 
         self._HUMIDITY_STEP = 5
-        self._HUMIDITY_LIMITS = {
-            'min': 30,
-            'max': 80
-        }
+        self._HUMIDITY_LIMITS = {"min": 30, "max": 80}
 
     @property
     def supported_features(self):
@@ -96,6 +67,26 @@ class GoldairDehumidifier(ClimateDevice):
         """Return the name of the climate device."""
         return self._device.name
 
+    @property
+    def unique_id(self):
+        """Return the unique id for this dehumidifier."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this dehumidifier."""
+        return self._device.device_info
+
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend based on the device state."""
+        if self.tank_full_or_missing:
+            return "mdi:cup-water"
+        elif self.defrosting:
+            return "mdi:snowflake-melt"
+        else:
+            return "mdi:air-humidifier"
+
     @property
     def current_humidity(self):
         """Return the current reading of the humidity sensor."""
@@ -104,24 +95,30 @@ class GoldairDehumidifier(ClimateDevice):
     @property
     def min_humidity(self):
         """Return the minimum humidity setting."""
-        return self._HUMIDITY_LIMITS['min']
+        return self._HUMIDITY_LIMITS["min"]
 
     @property
     def max_humidity(self):
         """Return the maximum humidity setting."""
-        return self._HUMIDITY_LIMITS['max']
+        return self._HUMIDITY_LIMITS["max"]
 
     @property
     def target_humidity(self):
         """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)
+            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)
+        )
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_TARGET_HUMIDITY], humidity
+        )
 
     @property
     def temperature_unit(self):
@@ -158,10 +155,12 @@ 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):
@@ -172,7 +171,9 @@ class GoldairDehumidifier(ClimateDevice):
         if air_clean_on:
             return PRESET_AIR_CLEAN
         elif dps_mode is not None:
-            return TuyaLocalDevice.get_key_for_value(PRESET_MODE_TO_DPS_MODE, dps_mode)
+            return TuyaLocalDevice.get_key_for_value(
+                PRESET_MODE_TO_DPS_MODE, dps_mode
+            )
         else:
             return None
 
@@ -181,19 +182,31 @@ 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)
-            self._device.anticipate_property_value(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], FAN_HIGH)
+            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)
+                self._device.anticipate_property_value(
+                    PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], FAN_LOW
+                )
             elif preset_mode in [PRESET_HIGH, PRESET_DRY_CLOTHES]:
-                self._device.anticipate_property_value(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], FAN_HIGH)
+                self._device.anticipate_property_value(
+                    PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], FAN_HIGH
+                )
 
     @property
     def fan_mode(self):
@@ -207,7 +220,9 @@ class GoldairDehumidifier(ClimateDevice):
         else:
             dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE])
             if dps_mode is not None:
-                return TuyaLocalDevice.get_key_for_value(FAN_MODE_TO_DPS_MODE, dps_mode)
+                return TuyaLocalDevice.get_key_for_value(
+                    FAN_MODE_TO_DPS_MODE, dps_mode
+                )
             else:
                 return None
 
@@ -225,25 +240,43 @@ 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.')
+            raise ValueError(
+                "Fan mode can only be changed while in Normal preset mode."
+            )
 
         if fan_mode not in FAN_MODE_TO_DPS_MODE.keys():
-            raise ValueError(f'Invalid fan mode: {fan_mode}')
+            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):
-        """Get the current fault status."""
-        fault = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_FAULT])
-        if fault is None or fault == FAULT_NONE:
-            return None
-        else:
-            return TuyaLocalDevice.get_key_for_value(FAULT_CODE_TO_DPS_CODE, fault)
+    def tank_full_or_missing(self):
+        error = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_ERROR])
+        return (
+            TuyaLocalDevice.get_key_for_value(ERROR_CODE_TO_DPS_CODE, error)
+            == ERROR_TANK
+        )
 
-    def update(self):
-        self._device.refresh()
+    @property
+    def defrosting(self):
+        return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_DEFROSTING])
+
+    @property
+    def device_state_attributes(self):
+        """Get additional attributes that HA doesn't naturally support."""
+        error = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_ERROR])
+        if error:
+            error = TuyaLocalDevice.get_key_for_value(
+                ERROR_CODE_TO_DPS_CODE, error, error
+            )
+
+        return {ATTR_ERROR: error or None, ATTR_DEFROSTING: self.defrosting}
+
+    async def async_update(self):
+        await self._device.async_refresh()

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

@@ -0,0 +1,51 @@
+from homeassistant.components.climate.const import (
+    ATTR_FAN_MODE,
+    ATTR_HUMIDITY,
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    FAN_HIGH,
+    FAN_LOW,
+    HVAC_MODE_DRY,
+    HVAC_MODE_OFF,
+)
+from homeassistant.const import ATTR_TEMPERATURE
+
+ATTR_TARGET_HUMIDITY = "target_humidity"
+ATTR_AIR_CLEAN_ON = "air_clean_on"
+ATTR_CHILD_LOCK = "child_lock"
+ATTR_ERROR = "error"
+ATTR_DISPLAY_ON = "display_on"
+ATTR_DEFROSTING = "defrosting"
+
+PRESET_NORMAL = "Normal"
+PRESET_LOW = "Low"
+PRESET_HIGH = "High"
+PRESET_DRY_CLOTHES = "Dry clothes"
+PRESET_AIR_CLEAN = "Air clean"
+
+ERROR_NONE = "No error"
+ERROR_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_ERROR: "11",
+    ATTR_DISPLAY_ON: "102",
+    ATTR_TEMPERATURE: "103",
+    ATTR_HUMIDITY: "104",
+    ATTR_DEFROSTING: "105",
+}
+
+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"}
+ERROR_CODE_TO_DPS_CODE = {ERROR_NONE: 0, ERROR_TANK: 8}

+ 34 - 20
custom_components/tuya_local/dehumidifier/light.py

@@ -1,15 +1,12 @@
 """
 Platform to control the LED display light on Goldair WiFi-connected dehumidifiers.
 """
+from homeassistant.components.climate import ATTR_HVAC_MODE, HVAC_MODE_OFF
 from homeassistant.components.light import Light
 from homeassistant.const import STATE_UNAVAILABLE
-from custom_components.tuya_local import TuyaLocalDevice
-from custom_components.tuya_local.dehumidifier.climate import (
-    ATTR_DISPLAY_ON, PROPERTY_TO_DPS_ID, HVAC_MODE_TO_DPS_MODE
-)
-from homeassistant.components.climate import (
-    ATTR_HVAC_MODE, HVAC_MODE_OFF
-)
+
+from ..device import TuyaLocalDevice
+from .const import ATTR_DISPLAY_ON, HVAC_MODE_TO_DPS_MODE, PROPERTY_TO_DPS_ID
 
 
 class GoldairDehumidifierLedDisplayLight(Light):
@@ -32,25 +29,42 @@ class GoldairDehumidifierLedDisplayLight(Light):
         return self._device.name
 
     @property
-    def is_on(self):
-        """Return the current state."""
-        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])
+    def unique_id(self):
+        """Return the unique id for this dehumidifier LED display."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this dehumidifier LED display."""
+        return self._device.device_info
 
-        if dps_hvac_mode is None or dps_hvac_mode == HVAC_MODE_TO_DPS_MODE[HVAC_MODE_OFF]:
-            return STATE_UNAVAILABLE
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend for this device."""
+        if self.is_on:
+            return "mdi:led-on"
         else:
-            return dps_display_on
+            return "mdi:led-off"
+
+    @property
+    def is_on(self):
+        """Return the current state."""
+        return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_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 self.turn_off())
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 22 - 9
custom_components/tuya_local/dehumidifier/lock.py

@@ -1,13 +1,11 @@
 """
 Platform to control the child lock on Goldair WiFi-connected dehumidifiers.
 """
-from homeassistant.components.lock import (STATE_LOCKED, STATE_UNLOCKED, LockDevice)
+from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockDevice
 from homeassistant.const import STATE_UNAVAILABLE
-from custom_components.tuya_local import TuyaLocalDevice
-from custom_components.tuya_local.dehumidifier.climate import (
-    ATTR_CHILD_LOCK, PROPERTY_TO_DPS_ID
-)
 
+from ..device import TuyaLocalDevice
+from .const import ATTR_CHILD_LOCK, PROPERTY_TO_DPS_ID
 
 class GoldairDehumidifierChildLock(LockDevice):
     """Representation of a Goldair WiFi-connected dehumidifier child lock."""
@@ -28,6 +26,16 @@ class GoldairDehumidifierChildLock(LockDevice):
         """Return the name of the lock."""
         return self._device.name
 
+    @property
+    def unique_id(self):
+        """Return the unique id for this dehumidifier child lock."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this dehumidifier child lock."""
+        return self._device.device_info
+
     @property
     def state(self):
         """Return the current state."""
@@ -41,10 +49,15 @@ 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()

+ 240 - 0
custom_components/tuya_local/device.py

@@ -0,0 +1,240 @@
+"""
+API for Tuya Local devices.
+"""
+
+import json
+import logging
+from threading import Lock, Timer
+from time import time
+
+from homeassistant.const import TEMP_CELSIUS
+
+from .const import DOMAIN, API_PROTOCOL_VERSIONS, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN, CONF_TYPE_HEATER, CONF_TYPE_KOGAN_HEATER
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TuyaLocalDevice(object):
+    def __init__(self, name, dev_id, address, local_key, hass):
+        """
+        Represents a 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 Tuya devices 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 unique_id(self):
+        """Return the unique id for this device (the dev_id)."""
+        return self._api.id
+
+    @property
+    def device_info(self):
+        """Return the device information for this device."""
+        if (self.type
+        return {
+            "identifiers": {(DOMAIN, self.unique_id)},
+            "name": self.name,
+            "manufacturer": "Tuya"
+        }
+
+    @property
+    def temperature_unit(self):
+        return self._TEMPERATURE_UNIT
+
+    async def async_inferred_type(self):
+        cached_state = self._get_cached_state()
+
+        if not "1" in cached_state:
+            if "3" in cached_state:
+                return CONF_TYPE_KOGAN_HEATER
+
+            await self.async_refresh()
+            return await self.async_inferred_type()
+
+        _LOGGER.debug(f"Inferring device type from cached state: {cached_state}")
+        if "5" in cached_state:
+            return CONF_TYPE_DEHUMIDIFIER
+        if "8" in cached_state:
+            return CONF_TYPE_FAN
+        if "106" in cached_state:
+            return CONF_TYPE_HEATER
+
+        return None
+
+    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, fallback=None):
+        keys = list(obj.keys())
+        values = list(obj.values())
+        return keys[values.index(value)] or fallback

+ 61 - 55
custom_components/tuya_local/fan/climate.py

@@ -1,51 +1,26 @@
 """
 Goldair WiFi Fan device.
 """
-from homeassistant.const import (
-    ATTR_TEMPERATURE, TEMP_CELSIUS, STATE_UNAVAILABLE
-)
 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_FAN_MODE,
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    ATTR_SWING_MODE,
+    SUPPORT_FAN_MODE,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_SWING_MODE,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, TEMP_CELSIUS
+
+from ..device import TuyaLocalDevice
+from .const import (
+    FAN_MODES,
+    HVAC_MODE_TO_DPS_MODE,
+    PRESET_MODE_TO_DPS_MODE,
+    PROPERTY_TO_DPS_ID,
+    SWING_MODE_TO_DPS_MODE,
 )
-from custom_components.tuya_local import TuyaLocalDevice
-
-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
 
@@ -77,6 +52,21 @@ class GoldairFan(ClimateDevice):
         """Return the name of the climate device."""
         return self._device.name
 
+    @property
+    def unique_id(self):
+        """Return the unique id for this fan."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this fan."""
+        return self._device.device_info
+
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend for this device."""
+        return "mdi:fan"
+
     @property
     def temperature_unit(self):
         """This is not used but required by Home Assistant."""
@@ -97,17 +87,21 @@ 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):
         """Return current preset mode, ie Comfort, Eco, Anti-freeze."""
         dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE])
         if dps_mode is not None:
-            return TuyaLocalDevice.get_key_for_value(PRESET_MODE_TO_DPS_MODE, dps_mode)
+            return TuyaLocalDevice.get_key_for_value(
+                PRESET_MODE_TO_DPS_MODE, dps_mode
+            )
         else:
             return None
 
@@ -116,10 +110,12 @@ 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,17 +131,25 @@ 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:
-            return TuyaLocalDevice.get_key_for_value(FAN_MODES[self.preset_mode], dps_mode)
+        if (
+            dps_mode is not None
+            and self.preset_mode is not None
+            and dps_mode in FAN_MODES[self.preset_mode].values()
+        ):
+            return TuyaLocalDevice.get_key_for_value(
+                FAN_MODES[self.preset_mode], dps_mode
+            )
         else:
             return None
 
@@ -157,11 +161,13 @@ 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()

+ 51 - 0
custom_components/tuya_local/fan/const.py

@@ -0,0 +1,51 @@
+from homeassistant.components.climate.const import (
+    ATTR_FAN_MODE,
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    ATTR_SWING_MODE,
+    HVAC_MODE_FAN_ONLY,
+    HVAC_MODE_OFF,
+    PRESET_ECO,
+    PRESET_SLEEP,
+    SWING_HORIZONTAL,
+    SWING_OFF,
+)
+
+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"},
+}

+ 36 - 20
custom_components/tuya_local/fan/light.py

@@ -1,15 +1,12 @@
 """
 Platform to control the LED display light on Goldair WiFi-connected fans and panels.
 """
+from homeassistant.components.climate import ATTR_HVAC_MODE, HVAC_MODE_OFF
 from homeassistant.components.light import Light
 from homeassistant.const import STATE_UNAVAILABLE
-from custom_components.tuya_local import TuyaLocalDevice
-from custom_components.tuya_local.fan.climate import (
-    ATTR_DISPLAY_ON, PROPERTY_TO_DPS_ID, HVAC_MODE_TO_DPS_MODE
-)
-from homeassistant.components.climate import (
-    ATTR_HVAC_MODE, HVAC_MODE_OFF
-)
+
+from ..device import TuyaLocalDevice
+from .const import ATTR_DISPLAY_ON, HVAC_MODE_TO_DPS_MODE, PROPERTY_TO_DPS_ID
 
 
 class GoldairFanLedDisplayLight(Light):
@@ -32,25 +29,44 @@ class GoldairFanLedDisplayLight(Light):
         return self._device.name
 
     @property
-    def is_on(self):
-        """Return the current state."""
-        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])
+    def unique_id(self):
+        """Return the unique id for this fan LED display."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this LED display."""
+        return self._device.device_info
 
-        if dps_hvac_mode is None or dps_hvac_mode == HVAC_MODE_TO_DPS_MODE[HVAC_MODE_OFF]:
-            return STATE_UNAVAILABLE
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend for this device."""
+        if self.is_on:
+            return "mdi:led-on"
         else:
-            return dps_display_on
+            return "mdi:led-off"
+
+    @property
+    def is_on(self):
+        """Return the current state."""
+        return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_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 self.async_turn_off()
+            )
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 95 - 89
custom_components/tuya_local/heater/climate.py

@@ -1,62 +1,34 @@
 """
 Goldair WiFi Heater device.
 """
-from homeassistant.const import (
-    ATTR_TEMPERATURE, TEMP_CELSIUS, STATE_UNAVAILABLE
-)
 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
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    HVAC_MODE_HEAT,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_SWING_MODE,
+    SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from ..device import TuyaLocalDevice
+from .const import (
+    ATTR_ECO_TARGET_TEMPERATURE,
+    ATTR_ERROR,
+    ATTR_POWER_LEVEL,
+    ATTR_POWER_MODE,
+    ATTR_POWER_MODE_AUTO,
+    ATTR_POWER_MODE_USER,
+    ATTR_TARGET_TEMPERATURE,
+    HVAC_MODE_TO_DPS_MODE,
+    POWER_LEVEL_TO_DPS_LEVEL,
+    PRESET_MODE_TO_DPS_MODE,
+    PROPERTY_TO_DPS_ID,
+    STATE_ANTI_FREEZE,
+    STATE_COMFORT,
+    STATE_ECO,
 )
-from custom_components.tuya_local import TuyaLocalDevice
-
-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'
-}
 
 SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE | SUPPORT_SWING_MODE
 
@@ -75,14 +47,8 @@ class GoldairHeater(ClimateDevice):
 
         self._TEMPERATURE_STEP = 1
         self._TEMPERATURE_LIMITS = {
-            STATE_COMFORT: {
-                'min': 5,
-                'max': 35
-            },
-            STATE_ECO: {
-                'min': 5,
-                'max': 21
-            }
+            STATE_COMFORT: {"min": 5, "max": 35},
+            STATE_ECO: {"min": 5, "max": 21},
         }
 
     @property
@@ -100,6 +66,26 @@ class GoldairHeater(ClimateDevice):
         """Return the name of the climate device."""
         return self._device.name
 
+    @property
+    def unique_id(self):
+        """Return the unique id for this heater."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this heater."""
+        return self._device.device_info
+
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend for this device."""
+        hvac_mode = self.hvac_mode
+        power_level = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL])
+        if hvac_mode == HVAC_MODE_HEAT and power_level != "stop":
+            return "mdi:radiator"
+        else:
+            return "mdi:radiator-disabled"
+
     @property
     def temperature_unit(self):
         """Return the unit of measurement."""
@@ -109,9 +95,13 @@ class GoldairHeater(ClimateDevice):
     def target_temperature(self):
         """Return the temperature we try to reach."""
         if self.preset_mode == STATE_COMFORT:
-            return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE])
+            return self._device.get_property(
+                PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]
+            )
         elif self.preset_mode == STATE_ECO:
-            return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_ECO_TARGET_TEMPERATURE])
+            return self._device.get_property(
+                PROPERTY_TO_DPS_ID[ATTR_ECO_TARGET_TEMPERATURE]
+            )
         else:
             return None
 
@@ -124,7 +114,7 @@ class GoldairHeater(ClimateDevice):
     def min_temp(self):
         """Return the minimum temperature."""
         if self.preset_mode and self.preset_mode != STATE_ANTI_FREEZE:
-            return self._TEMPERATURE_LIMITS[self.preset_mode]['min']
+            return self._TEMPERATURE_LIMITS[self.preset_mode]["min"]
         else:
             return None
 
@@ -132,35 +122,39 @@ class GoldairHeater(ClimateDevice):
     def max_temp(self):
         """Return the maximum temperature."""
         if self.preset_mode and self.preset_mode != STATE_ANTI_FREEZE:
-            return self._TEMPERATURE_LIMITS[self.preset_mode]['max']
+            return self._TEMPERATURE_LIMITS[self.preset_mode]["max"]
         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
 
         if preset_mode == STATE_ANTI_FREEZE:
-            raise ValueError('You cannot set the temperature in Anti-freeze mode.')
+            raise ValueError("You cannot set the temperature in Anti-freeze mode.")
 
         limits = self._TEMPERATURE_LIMITS[preset_mode]
-        if not limits['min'] <= target_temperature <= limits['max']:
+        if not limits["min"] <= target_temperature <= limits["max"]:
             raise ValueError(
-                f'Target temperature ({target_temperature}) must be between '
+                f"Target temperature ({target_temperature}) must be between "
                 f'{limits["min"]} and {limits["max"]}'
             )
 
         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,17 +176,21 @@ 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):
         """Return current preset mode, ie Comfort, Eco, Anti-freeze."""
         dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE])
         if dps_mode is not None:
-            return TuyaLocalDevice.get_key_for_value(PRESET_MODE_TO_DPS_MODE, dps_mode)
+            return TuyaLocalDevice.get_key_for_value(
+                PRESET_MODE_TO_DPS_MODE, dps_mode
+            )
         else:
             return None
 
@@ -201,34 +199,42 @@ 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):
         """Return the power level."""
         dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_POWER_MODE])
         if dps_mode == ATTR_POWER_MODE_USER:
-            return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL])
-        elif dps_mode == ATTR_POWER_MODE_AUTO:
-            return TuyaLocalDevice.get_key_for_value(POWER_LEVEL_TO_DPS_LEVEL, dps_mode)
-        else:
-            return None
+            dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL])
+        return TuyaLocalDevice.get_key_for_value(POWER_LEVEL_TO_DPS_LEVEL, dps_mode)
 
     @property
     def swing_modes(self):
         """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}')
+            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
+        )
+
+    @property
+    def device_state_attributes(self):
+        """Get additional attributes that HA doesn't naturally support."""
+        error = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_ERROR])
+
+        return {ATTR_ERROR: error or None}
 
-    def update(self):
-        self._device.refresh()
+    async def async_update(self):
+        await self._device.async_refresh()

+ 53 - 0
custom_components/tuya_local/heater/const.py

@@ -0,0 +1,53 @@
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    HVAC_MODE_HEAT,
+    HVAC_MODE_OFF,
+)
+from homeassistant.const import ATTR_TEMPERATURE
+
+ATTR_TARGET_TEMPERATURE = "target_temperature"
+ATTR_CHILD_LOCK = "child_lock"
+ATTR_ERROR = "error"
+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_ERROR: "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_STOP = "stop"
+POWER_LEVEL_AUTO = "auto"
+POWER_LEVEL_TO_DPS_LEVEL = {
+    "Stop": POWER_LEVEL_STOP,
+    "1": "1",
+    "2": "2",
+    "3": "3",
+    "4": "4",
+    "5": "5",
+    "Auto": POWER_LEVEL_AUTO,
+}

+ 36 - 20
custom_components/tuya_local/heater/light.py

@@ -1,15 +1,12 @@
 """
 Platform to control the LED display light on Goldair WiFi-connected heaters and panels.
 """
+from homeassistant.components.climate import ATTR_HVAC_MODE, HVAC_MODE_OFF
 from homeassistant.components.light import Light
 from homeassistant.const import STATE_UNAVAILABLE
-from custom_components.tuya_local import TuyaLocalDevice
-from custom_components.tuya_local.heater.climate import (
-    ATTR_DISPLAY_ON, PROPERTY_TO_DPS_ID, HVAC_MODE_TO_DPS_MODE
-)
-from homeassistant.components.climate import (
-    ATTR_HVAC_MODE, HVAC_MODE_OFF
-)
+
+from ..device import TuyaLocalDevice
+from .const import ATTR_DISPLAY_ON, HVAC_MODE_TO_DPS_MODE, PROPERTY_TO_DPS_ID
 
 
 class GoldairHeaterLedDisplayLight(Light):
@@ -32,25 +29,44 @@ class GoldairHeaterLedDisplayLight(Light):
         return self._device.name
 
     @property
-    def is_on(self):
-        """Return the current state."""
-        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])
+    def unique_id(self):
+        """Return the unique id for this heater LED display."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this heater LED display."""
+        return self._device.device_info
 
-        if dps_hvac_mode is None or dps_hvac_mode == HVAC_MODE_TO_DPS_MODE[HVAC_MODE_OFF]:
-            return STATE_UNAVAILABLE
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend for this device."""
+        if self.is_on:
+            return "mdi:led-on"
         else:
-            return dps_display_on
+            return "mdi:led-off"
+
+    @property
+    def is_on(self):
+        """Return the current state."""
+        return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_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 self.async_turn_off()
+            )
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 23 - 9
custom_components/tuya_local/heater/lock.py

@@ -1,12 +1,11 @@
 """
 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.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockDevice
 from homeassistant.const import STATE_UNAVAILABLE
-from custom_components.tuya_local import TuyaLocalDevice
-from custom_components.tuya_local.heater.climate import (
-    ATTR_CHILD_LOCK, PROPERTY_TO_DPS_ID
-)
+
+from ..device import TuyaLocalDevice
+from .const import ATTR_CHILD_LOCK, PROPERTY_TO_DPS_ID
 
 
 class GoldairHeaterChildLock(LockDevice):
@@ -28,6 +27,16 @@ class GoldairHeaterChildLock(LockDevice):
         """Return the name of the lock."""
         return self._device.name
 
+    @property
+    def unique_id(self):
+        """Return the unique id for this heater child lock."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this heater child lock."""
+        return self._device.device_info
+
     @property
     def state(self):
         """Return the current state."""
@@ -41,10 +50,15 @@ 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()

+ 55 - 42
custom_components/tuya_local/kogan_heater/climate.py

@@ -10,39 +10,25 @@ dps:
   8 = timer (integer) [not supported - use HA based timers]
 """
 
-from homeassistant.const import (
-    ATTR_TEMPERATURE, TEMP_CELSIUS, STATE_UNAVAILABLE
-)
 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
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    HVAC_MODE_HEAT,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from ..device import TuyaLocalDevice
+from .const import (
+    ATTR_TARGET_TEMPERATURE,
+    HVAC_MODE_TO_DPS_MODE,
+    PRESET_HIGH,
+    PRESET_LOW,
+    PRESET_MODE_TO_DPS_MODE,
+    PROPERTY_TO_DPS_ID,
 )
-from custom_components.tuya_local import TuyaLocalDevice
-
-ATTR_TARGET_TEMPERATURE = 'target_temperature'
-
-PRESET_LOW = 'LOW'
-PRESET_HIGH = 'HIGH'
-
-PROPERTY_TO_DPS_ID = {
-    ATTR_HVAC_MODE: '7',
-    ATTR_TARGET_TEMPERATURE: '2',
-    ATTR_TEMPERATURE: '3',
-    ATTR_PRESET_MODE: '4',
-}
-
-HVAC_MODE_TO_DPS_MODE = {
-    HVAC_MODE_OFF: False,
-    HVAC_MODE_HEAT: True
-}
-
-PRESET_MODE_TO_DPS_MODE = {
-    PRESET_LOW: 'Low',
-    PRESET_HIGH: 'High'
-}
-
 SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
 
 
@@ -79,6 +65,25 @@ class KoganHeater(ClimateDevice):
         """Return the name of the climate device."""
         return self._device.name
 
+    @property
+    def unique_id(self):
+        """Return the unique id for this heater."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this heater."""
+        return self._device.device_info
+
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend for this device."""
+        hvac_mode = self.hvac_mode
+        if hvac_mode == HVAC_MODE_HEAT:
+            return "mdi:radiator"
+        else:
+            return "mdi:radiator-disabled"
+
     @property
     def temperature_unit(self):
         """Return the unit of measurement."""
@@ -104,14 +109,14 @@ class KoganHeater(ClimateDevice):
         """Return the maximum temperature."""
         return self._TEMPERATURE_LIMITS['max']
 
-    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))
 
         limits = self._TEMPERATURE_LIMITS
@@ -121,7 +126,9 @@ class KoganHeater(ClimateDevice):
                 f'{limits["min"]} and {limits["max"]}'
             )
 
-        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
+        )
 
     @property
     def current_temperature(self):
@@ -143,17 +150,21 @@ class KoganHeater(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):
         """Return current preset mode, ie Low or High."""
         dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE])
         if dps_mode is not None:
-            return TuyaLocalDevice.get_key_for_value(PRESET_MODE_TO_DPS_MODE, dps_mode)
+            return TuyaLocalDevice.get_key_for_value(
+                PRESET_MODE_TO_DPS_MODE, dps_mode
+            )
         else:
             return None
 
@@ -162,10 +173,12 @@ class KoganHeater(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
+        )
 
-    def update(self):
-        self._device.refresh()
+    async def async_update(self):
+        await self._device.async_refresh()

+ 24 - 0
custom_components/tuya_local/kogan_heater/const.py

@@ -0,0 +1,24 @@
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    HVAC_MODE_HEAT,
+    HVAC_MODE_OFF,
+)
+from homeassistant.const import ATTR_TEMPERATURE
+
+ATTR_TARGET_TEMPERATURE = "target_temperature"
+PRESET_LOW = 'LOW'
+PRESET_HIGH = 'HIGH'
+
+PROPERTY_TO_DPS_ID = {
+    ATTR_HVAC_MODE: "7",
+    ATTR_TARGET_TEMPERATURE: "2",
+    ATTR_TEMPERATURE: "3",
+    ATTR_PRESET_MODE: "4",
+}
+
+HVAC_MODE_TO_DPS_MODE = {HVAC_MODE_OFF: False, HVAC_MODE_HEAT: True}
+PRESET_MODE_TO_DPS_MODE = {
+    PRESET_LOW: "Low",
+    PRESET_HIGH: "High",
+}

+ 35 - 15
custom_components/tuya_local/light.py

@@ -1,23 +1,43 @@
 """
 Setup for different kinds of Tuya climate devices
 """
-from homeassistant.const import CONF_HOST
-from custom_components.tuya_local import (
-    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN, CONF_TYPE_KOGAN_HEATER
-)
-from custom_components.tuya_local.heater.light import GoldairHeaterLedDisplayLight
-from custom_components.tuya_local.dehumidifier.light import GoldairDehumidifierLedDisplayLight
-from custom_components.tuya_local.fan.light import GoldairFanLedDisplayLight
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
-    """Set up the Tuya climate device according to its type."""
-    device = hass.data[DOMAIN][discovery_info[CONF_HOST]]
+from . import DOMAIN
+from .const import (CONF_DEVICE_ID, CONF_TYPE, CONF_TYPE_DEHUMIDIFIER,
+                    CONF_TYPE_FAN, CONF_TYPE_HEATER, CONF_TYPE_KOGAN_HEATER,
+                    CONF_DISPLAY_LIGHT, CONF_TYPE_AUTO)
+from .dehumidifier.light import GoldairDehumidifierLedDisplayLight
+from .fan.light import GoldairFanLedDisplayLight
+from .heater.light import GoldairHeaterLedDisplayLight
+
+
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+    """Set up the Goldair climate device according to its type."""
+    data = hass.data[DOMAIN][discovery_info[CONF_DEVICE_ID]]
+    device = data['device']
+
+    if discovery_info[CONF_TYPE] == CONF_TYPE_AUTO:
+        discovery_info[CONF_TYPE] = await device.async_inferred_type()
+
+        if discovery_info[CONF_TYPE] is None:
+            raise ValueError(f"Unable to detect type for device {device.name}")
+
     if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
-        add_devices([GoldairHeaterLedDisplayLight(device)])
+        data[CONF_DISPLAY_LIGHT] = GoldairHeaterLedDisplayLight(device)
     elif discovery_info[CONF_TYPE] == CONF_TYPE_DEHUMIDIFIER:
-        add_devices([GoldairDehumidifierLedDisplayLight(device)])
+        data[CONF_DISPLAY_LIGHT] = GoldairDehumidifierLedDisplayLight(device)
     elif discovery_info[CONF_TYPE] == CONF_TYPE_FAN:
-        add_devices([GoldairFanLedDisplayLight(device)])
+        data[CONF_DISPLAY_LIGHT] = GoldairFanLedDisplayLight(device)
     elif discovery_info[CONF_TYPE] == CONF_TYPE_KOGAN_HEATER:
         raise ValueError('Kogan heaters do not support panel lighting control')
+
+    if CONF_DISPLAY_LIGHT in data:
+        async_add_entities([data[CONF_DISPLAY_LIGHT]])
+
+
+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)

+ 29 - 10
custom_components/tuya_local/lock.py

@@ -1,22 +1,41 @@
 """
 Setup for different kinds of Tuya climate devices
 """
-from homeassistant.const import CONF_HOST
-from custom_components.tuya_local import (
-    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER, CONF_TYPE_DEHUMIDIFIER, CONF_TYPE_FAN, CONF_TYPE_KOGAN_HEATER
-)
-from custom_components.tuya_local.heater.lock import GoldairHeaterChildLock
-from custom_components.tuya_local.dehumidifier.lock import GoldairDehumidifierChildLock
+from . import DOMAIN
+from .const import (CONF_DEVICE_ID, CONF_TYPE, CONF_TYPE_DEHUMIDIFIER,
+                    CONF_TYPE_FAN, CONF_TYPE_HEATER, CONF_TYPE_KOGAN_HEATER, CONF_CHILD_LOCK, CONF_TYPE_AUTO)
+from .dehumidifier.lock import GoldairDehumidifierChildLock
+from .heater.lock import GoldairHeaterChildLock
 
 
-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]]
+    data = hass.data[DOMAIN][discovery_info[CONF_DEVICE_ID]]
+    device = data['device']
+
+    if discovery_info[CONF_TYPE] == CONF_TYPE_AUTO:
+        discovery_info[CONF_TYPE] = await device.async_inferred_type()
+
+        if discovery_info[CONF_TYPE] is None:
+            raise ValueError(f"Unable to detect type for device {device.name}")
+
     if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
-        add_devices([GoldairHeaterChildLock(device)])
+        data[CONF_CHILD_LOCK] = GoldairHeaterChildLock(device)
     if discovery_info[CONF_TYPE] == CONF_TYPE_DEHUMIDIFIER:
-        add_devices([GoldairDehumidifierChildLock(device)])
+        data[CONF_CHILD_LOCK] = GoldairDehumidifierChildLock(device)
     if discovery_info[CONF_TYPE] == CONF_TYPE_FAN:
         raise ValueError('Goldair fans do not support Child Lock.')
     if discovery_info[CONF_TYPE] == CONF_TYPE_KOGAN_HEATER:
         raise ValueError('Kogan Heaters do not support Child Lock.')
+
+    if CONF_CHILD_LOCK in data:
+        async_add_entities([data[CONF_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)

+ 1 - 1
custom_components/tuya_local/manifest.json

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

+ 46 - 0
custom_components/tuya_local/strings.json

@@ -0,0 +1,46 @@
+{
+  "title": "Tuya Local",
+  "config": {
+    "step": {
+      "user": {
+        "title": "Configure your Tuya Local device",
+        "description": "[Follow these instructions to find your device id and local key.](https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md)",
+        "data": {
+          "name": "Name",
+          "host": "IP address or hostname",
+          "device_id": "Device ID (uuid)",
+          "local_key": "Local key",
+          "type": "Device type",
+          "climate": "Include a climate entity",
+          "display_light": "Include LED display as a light entity",
+          "child_lock": "Include child lock as a lock entity (unsupported on fans)"
+        }
+      }
+    },
+    "abort": {
+      "already_configured": "A device with that ID has already been added."
+    },
+    "error": {
+      "connection": "Unable to connect to your device with those details. It could be an intermittent issue, or they may be incorrect."
+    }
+  },
+  "options": {
+    "step": {
+      "user": {
+        "title": "Configure your Tuya Local device",
+        "description": "[Follow these instructions to find your local key.](https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md)",
+        "data": {
+          "host": "IP address or hostname",
+          "local_key": "Local key",
+          "type": "Device type",
+          "climate": "Include device as a climate entity",
+          "display_light": "Include LED display as light entity",
+          "child_lock": "Include child lock as lock entity (unsupported on fans)"
+        }
+      }
+    },
+    "error": {
+      "connection": "Unable to connect to your device with those details. It could be an intermittent issue, or they may be incorrect."
+    }
+  }
+}

+ 1 - 0
custom_components/tuya_local/translations/en.json

@@ -0,0 +1 @@
+../strings.json