Przeglądaj źródła

Merge upstream changes

Jason Rumney 5 lat temu
rodzic
commit
b68113adce
43 zmienionych plików z 2600 dodań i 66 usunięć
  1. 2 1
      .devcontainer/devcontainer.json
  2. 1 0
      .github/workflows/hacs-validate.yml
  3. 1 0
      .github/workflows/hassfest-validate.yml
  4. 1 1
      .github/workflows/linting.yml
  5. 1 1
      .github/workflows/tests.yml
  6. 13 1
      .vscode/tasks.json
  7. 14 7
      README.md
  8. 2 2
      custom_components/tuya_local/__init__.py
  9. 2 1
      custom_components/tuya_local/climate.py
  10. 4 3
      custom_components/tuya_local/config_flow.py
  11. 7 7
      custom_components/tuya_local/configuration.py
  12. 3 1
      custom_components/tuya_local/dehumidifier/light.py
  13. 2 11
      custom_components/tuya_local/device.py
  14. 5 3
      custom_components/tuya_local/fan/climate.py
  15. 1 1
      custom_components/tuya_local/fan/light.py
  16. 1 1
      custom_components/tuya_local/geco_heater/climate.py
  17. 1 1
      custom_components/tuya_local/gpcv_heater/climate.py
  18. 7 6
      custom_components/tuya_local/heater/climate.py
  19. 2 2
      custom_components/tuya_local/light.py
  20. 5 4
      custom_components/tuya_local/lock.py
  21. 2 3
      custom_components/tuya_local/manifest.json
  22. 1 1
      requirements-dev.txt
  23. 1 1
      requirements-first.txt
  24. 1 1
      requirements.txt
  25. 0 0
      tests/dehumidifier/__init__.py
  26. 586 0
      tests/dehumidifier/test_climate.py
  27. 98 0
      tests/dehumidifier/test_light.py
  28. 81 0
      tests/dehumidifier/test_lock.py
  29. 0 0
      tests/fan/__init__.py
  30. 302 0
      tests/fan/test_climate.py
  31. 96 0
      tests/fan/test_light.py
  32. 0 0
      tests/geco_heater/__init__.py
  33. 156 0
      tests/geco_heater/test_climate.py
  34. 81 0
      tests/geco_heater/test_lock.py
  35. 0 0
      tests/gpcv_heater/__init__.py
  36. 217 0
      tests/gpcv_heater/test_climate.py
  37. 81 0
      tests/gpcv_heater/test_lock.py
  38. 0 0
      tests/heater/__init__.py
  39. 399 0
      tests/heater/test_climate.py
  40. 96 0
      tests/heater/test_light.py
  41. 79 0
      tests/heater/test_lock.py
  42. 25 0
      tests/helpers.py
  43. 223 6
      tests/test_device.py

+ 2 - 1
.devcontainer/devcontainer.json

@@ -9,7 +9,8 @@
     "ms-python.python",
     "visualstudioexptteam.vscodeintellicode",
     "redhat.vscode-yaml",
-    "esbenp.prettier-vscode"
+    "esbenp.prettier-vscode",
+    "ryanluker.vscode-coverage-gutters"
   ],
   "settings": {
     "files.exclude": {

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

@@ -2,6 +2,7 @@ name: Validate with HACS
 
 on:
   push:
+  pull_request:
   schedule:
     - cron: '0 0 * * *'
 

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

@@ -2,6 +2,7 @@ name: Validate with hassfest
 
 on:
   push:
+  pull_request:
   schedule:
     - cron: '0 0 * * *'
 

+ 1 - 1
.github/workflows/linting.yml

@@ -1,6 +1,6 @@
 name: Linting
 
-on: push
+on: [push, pull_request]
 
 jobs:
   lint:

+ 1 - 1
.github/workflows/tests.yml

@@ -1,6 +1,6 @@
 name: Python tests
 
-on: push
+on: [push, pull_request]
 
 jobs:
   tests:

+ 13 - 1
.vscode/tasks.json

@@ -4,7 +4,7 @@
     {
       "label": "Install requirements",
       "type": "shell",
-      "command": "pip3 install pycrypto==2.6.1;pip3 install pytuya==7.0.5",
+      "command": "pip3 install -r requirements-first.txt;pip3 install -r requirements-dev.txt",
       "problemMatcher": []
     },
     {
@@ -30,6 +30,18 @@
       "type": "shell",
       "command": "container set-version",
       "problemMatcher": []
+    },
+    {
+      "label": "Unit tests",
+      "type": "shell",
+      "command": "pytest --cov=. --cov-config=.coveragerc --cov-report xml:coverage.xml",
+      "problemMatcher": []
+    },
+    {
+      "label": "Reformat code",
+      "type": "shell",
+      "command": "isort --recursive . &&  black .",
+      "problemMatcher": []
     }
   ]
 }

+ 14 - 7
README.md

@@ -1,5 +1,11 @@
 # Home Assistant Tuya Local component
 
+[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=make-all_tuya-local&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=make-all_tuya-local)
+[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=make-all_tuya-local&metric=security_rating)](https://sonarcloud.io/dashboard?id=make-all_tuya-local)
+[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=make-all_tuya-local&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=make-all_tuya-local)
+[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=make-all_tuya-local&metric=ncloc)](https://sonarcloud.io/dashboard?id=make-all_tuya-local)
+[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=make-all_tuya-local&metric=coverage)](https://sonarcloud.io/dashboard?id=make-all_tuya-local)
+
 The `tuya_local` component integrates Goldair WiFi-enabled [heaters](http://www.goldair.co.nz/product-catalogue/heating/wifi-heaters), [dehumidifiers](http://www.goldair.co.nz/product-catalogue/heating/dehumidifiers) and [fans](http://www.goldair.co.nz/product-catalogue/cooling/pedestal-fans/40cm-dc-quiet-fan-with-wifi-and-remote-gcpf315) and Kogan WiFi-enabled [heaters](https://www.kogan.com/au/c/smarterhome-range/shop/heating-cooling/) and [plugs](https://www.kogan.com/au/shop/connected-home/smart-plug/) into Home Assistant, enabling control of setting the following parameters via the UI and the following services:
 
 ### Climate devices
@@ -71,15 +77,11 @@ Current temperature is also displayed.
 
 ---
 
-### Warning
-
-Please note, this component has currently only been tested with the Goldair GPPH (inverter), GPDH420 (dehumidifier), and GCPF315 fan, however theoretically it should also work with GECO, GEPH and GPCV heater devices, may work with the GPDH440 dehumidifier and any other Goldair heaters, dehumidifiers or fans based on the Tuya platform.
+### Device support
 
-GPCV support is based on feedback from etamtlosz on Issue #27.
+Please note, this component is actively tested with the Goldair GPPH (inverter), GPDH420 (dehumidifier), Kogan SmarterHome 1500W Smart Panel Heater and Kogan SmarterHome Energy Monitoring SmartPlug. Theoretically it should also work with GECO, GEPH and GPCV heater devices, and GCPF315 fan and may work with the GPDH440 dehumidifier and any other Goldair heaters, dehumidifiers or fans and Kogan heaters and smartplugs based on the Tuya platform.
 
-GECO support is based on work in KiLLeRRaT/homeassistant-goldair-climate and the feature set from the online manual for these heaters. GEPH heaters appear to be the same as the GECO270, so may also work with this setting.  This heater is almost compatible with the GPCV but without the Low/High mode. 
-
-Kogan heater support is tested with the Kogan SmarterHome 1500W Smart Panel Heater.  If you have another type of Kogan SmarterHome heater, it may or may not work with the same configuration.
+GPCV support is based on feedback from etamtlosz on Issue #27. GECO support is based on work in KiLLeRRaT/homeassistant-goldair-climate and the feature set from the online manual for these heaters. GEPH heaters appear to be the same as the GECO270, so may also work with this setting.  This heater is almost compatible with the GPCV but without the Low/High mode. 
 
 ---
 
@@ -182,6 +184,10 @@ You can find these keys the same way as you would for any Tuya local integration
 3. The devices need to be generalized so a new subdirectory with source code is not needed to add a new device.  Instead, device descriptors should be in a yaml file, which is referenced by the config.
 4. Further config flow improvements to filter the available types to possibilities based on the known dps.  When many device configurations are supported, this will be required, as not all devices will be distinguishable automatically.
 5. This component needs specs! Once they're written I'm considering submitting it to the HA team for inclusion in standard installations. Please report any issues and feel free to raise pull requests.
+6. This component is partially unit-tested thanks to the upstream project, but there are a few more to complete. Feel free to use existing specs as inspiration and the Sonar Cloud analysis to see where the gaps are.
+7. Once unit tests are complete, the next task is to complete the Home Assistant quality checklist before considering submission to the HA team for inclusion in standard installations.
+
+Please report any issues and feel free to raise pull requests.
 
 ## Acknowledgements
 
@@ -191,3 +197,4 @@ None of this would have been possible without some foundational discovery work t
 - [TarxBoy](https://github.com/TarxBoy)'s [investigation using codetheweb/tuyapi](https://github.com/codetheweb/tuyapi/issues/31) to figure out the correlation of the cryptic DPS states 
 - [sean6541](https://github.com/sean6541)'s [tuya-homeassistant](https://github.com/sean6541/tuya-homeassistant) library giving an example of integrating Tuya devices with Home Assistant
 - [clach04](https://github.com/clach04)'s [python-tuya](https://github.com/clach04/python-tuya) library
+- [etamtlosz](https://github.com/etamtlosz) and [KiLLeRRaT](https://github.com/KiLLeRRaT) for their support and dev work towards GECO and GPCV heaters

+ 2 - 2
custom_components/tuya_local/__init__.py

@@ -10,20 +10,20 @@ import logging
 
 import homeassistant.helpers.config_validation as cv
 import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
 from homeassistant.const import CONF_HOST, CONF_NAME
 from homeassistant.core import HomeAssistant
 from homeassistant.helpers.discovery import async_load_platform
 
 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_SWITCH,
+    DOMAIN,
 )
 from .device import TuyaLocalDevice
 from .config_flow import ConfigFlowHandler

+ 2 - 1
custom_components/tuya_local/climate.py

@@ -3,8 +3,10 @@ Setup for different kinds of Tuya climate devices
 """
 from . import DOMAIN
 from .const import (
+    CONF_CLIMATE,
     CONF_DEVICE_ID,
     CONF_TYPE,
+    CONF_TYPE_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
@@ -12,7 +14,6 @@ from .const import (
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_KOGAN_HEATER,
     CONF_CLIMATE,
-    CONF_TYPE_AUTO,
 )
 from .dehumidifier.climate import GoldairDehumidifier
 from .fan.climate import GoldairFan

+ 4 - 3
custom_components/tuya_local/config_flow.py

@@ -1,11 +1,12 @@
+import logging
+
 import voluptuous as vol
 from homeassistant import config_entries, data_entry_flow
-from homeassistant.const import CONF_NAME, CONF_HOST
-from homeassistant.core import callback, HomeAssistant
+from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.core import HomeAssistant, callback
 
 from . import DOMAIN, individual_config_schema, TuyaLocalDevice
 from .const import CONF_DEVICE_ID, CONF_LOCAL_KEY
-import logging
 
 _LOGGER = logging.getLogger(__name__)
 

+ 7 - 7
custom_components/tuya_local/configuration.py

@@ -1,22 +1,22 @@
 import voluptuous as vol
-from homeassistant.const import CONF_NAME, CONF_HOST
+from homeassistant.const import CONF_HOST, CONF_NAME
 
 from .const import (
+    CONF_CHILD_LOCK,
+    CONF_CLIMATE,
     CONF_DEVICE_ID,
+    CONF_DISPLAY_LIGHT,
     CONF_LOCAL_KEY,
+    CONF_SWITCH,
     CONF_TYPE,
-    CONF_TYPE_GPPH_HEATER,
+    CONF_TYPE_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
     CONF_TYPE_GPCV_HEATER,
+    CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_SWITCH,
-    CONF_CLIMATE,
-    CONF_DISPLAY_LIGHT,
-    CONF_CHILD_LOCK,
-    CONF_SWITCH,
-    CONF_TYPE_AUTO,
 )
 
 INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [

+ 3 - 1
custom_components/tuya_local/dehumidifier/light.py

@@ -72,7 +72,9 @@ class GoldairDehumidifierLedDisplayLight(LightEntity):
         )
 
         if dps_hvac_mode != HVAC_MODE_TO_DPS_MODE[HVAC_MODE_OFF]:
-            await (self.turn_on() if dps_display_off 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()

+ 2 - 11
custom_components/tuya_local/device.py

@@ -11,7 +11,6 @@ from homeassistant.const import TEMP_CELSIUS
 from homeassistant.core import HomeAssistant
 
 from .const import (
-    DOMAIN,
     API_PROTOCOL_VERSIONS,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
@@ -20,6 +19,7 @@ from .const import (
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_SWITCH,
+    DOMAIN,
 )
 
 _LOGGER = logging.getLogger(__name__)
@@ -43,7 +43,6 @@ class TuyaLocalDevice(object):
         self._refresh_task = None
         self._rotate_api_protocol_version()
 
-        self._fixed_properties = {}
         self._reset_cached_state()
 
         self._TEMPERATURE_UNIT = TEMP_CELSIUS
@@ -112,13 +111,6 @@ class TuyaLocalDevice(object):
         _LOGGER.warning(f"Detection for {self.name} failed")
         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()
-
     async def async_refresh(self):
         last_updated = self._get_cached_state()["updated_at"]
         if self._refresh_task is None or time() - last_updated >= self._CACHE_TIMEOUT:
@@ -178,7 +170,6 @@ class TuyaLocalDevice(object):
 
     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():
@@ -262,4 +253,4 @@ class TuyaLocalDevice(object):
     def get_key_for_value(obj, value, fallback=None):
         keys = list(obj.keys())
         values = list(obj.values())
-        return keys[values.index(value)] or fallback
+        return keys[values.index(value)] if value in values else fallback

+ 5 - 3
custom_components/tuya_local/fan/climate.py

@@ -15,7 +15,7 @@ from homeassistant.components.climate.const import (
     SUPPORT_PRESET_MODE,
     SUPPORT_SWING_MODE,
 )
-from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, TEMP_CELSIUS
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
 
 from ..device import TuyaLocalDevice
 from .const import (
@@ -74,7 +74,7 @@ class GoldairFan(ClimateEntity):
     @property
     def temperature_unit(self):
         """This is not used but required by Home Assistant."""
-        return TEMP_CELSIUS
+        return self._device.temperature_unit
 
     @property
     def hvac_mode(self):
@@ -142,7 +142,7 @@ class GoldairFan(ClimateEntity):
 
     @property
     def fan_mode(self):
-        """Return current fan mode: 1-12"""
+        """Return current fan mode: 1-12 or 1-3 depending on the preset"""
         dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_FAN_MODE])
         if (
             dps_mode is not None
@@ -170,6 +170,8 @@ class GoldairFan(ClimateEntity):
             await self._device.async_set_property(
                 PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], dps_mode
             )
+        else:
+            raise ValueError("Fan mode can only be set when a preset mode is set")
 
     async def async_update(self):
         await self._device.async_refresh()

+ 1 - 1
custom_components/tuya_local/fan/light.py

@@ -1,5 +1,5 @@
 """
-Platform to control the LED display light on Goldair WiFi-connected fans and panels.
+Platform to control the LED display light on Goldair WiFi-connected fans.
 """
 try:
     from homeassistant.components.light import LightEntity

+ 1 - 1
custom_components/tuya_local/geco_heater/climate.py

@@ -110,7 +110,7 @@ class GoldairGECOHeater(ClimateEntity):
         if not limits["min"] <= target_temperature <= limits["max"]:
             raise ValueError(
                 f"Target temperature ({target_temperature}) must be between "
-                f'{limits["min"]} and {limits["max"]}'
+                f'{limits["min"]} and {limits["max"]}.'
             )
 
         await self._device.async_set_property(

+ 1 - 1
custom_components/tuya_local/gpcv_heater/climate.py

@@ -115,7 +115,7 @@ class GoldairGPCVHeater(ClimateEntity):
         if not limits["min"] <= target_temperature <= limits["max"]:
             raise ValueError(
                 f"Target temperature ({target_temperature}) must be between "
-                f'{limits["min"]} and {limits["max"]}'
+                f'{limits["min"]} and {limits["max"]}.'
             )
 
         await self._device.async_set_property(

+ 7 - 6
custom_components/tuya_local/heater/climate.py

@@ -27,6 +27,7 @@ from .const import (
     ATTR_POWER_MODE_USER,
     ATTR_TARGET_TEMPERATURE,
     HVAC_MODE_TO_DPS_MODE,
+    POWER_LEVEL_STOP,
     POWER_LEVEL_TO_DPS_LEVEL,
     PRESET_MODE_TO_DPS_MODE,
     PROPERTY_TO_DPS_ID,
@@ -86,7 +87,7 @@ class GoldairHeater(ClimateEntity):
         """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":
+        if hvac_mode == HVAC_MODE_HEAT and power_level != POWER_LEVEL_STOP:
             return "mdi:radiator"
         else:
             return "mdi:radiator-disabled"
@@ -149,16 +150,16 @@ class GoldairHeater(ClimateEntity):
         if not limits["min"] <= target_temperature <= limits["max"]:
             raise ValueError(
                 f"Target temperature ({target_temperature}) must be between "
-                f'{limits["min"]} and {limits["max"]}'
+                f'{limits["min"]} and {limits["max"]}.'
             )
 
-        if preset_mode == STATE_COMFORT:
+        if preset_mode == STATE_ECO:
             await self._device.async_set_property(
-                PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE], target_temperature
+                PROPERTY_TO_DPS_ID[ATTR_ECO_TARGET_TEMPERATURE], target_temperature
             )
-        elif preset_mode == STATE_ECO:
+        else:
             await self._device.async_set_property(
-                PROPERTY_TO_DPS_ID[ATTR_ECO_TARGET_TEMPERATURE], target_temperature
+                PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE], target_temperature
             )
 
     @property

+ 2 - 2
custom_components/tuya_local/light.py

@@ -4,12 +4,12 @@ Setup for different kinds of Tuya climate devices
 from . import DOMAIN
 from .const import (
     CONF_DEVICE_ID,
+    CONF_DISPLAY_LIGHT,
     CONF_TYPE,
+    CONF_TYPE_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
     CONF_TYPE_GPPH_HEATER,
-    CONF_DISPLAY_LIGHT,
-    CONF_TYPE_AUTO,
 )
 from .dehumidifier.light import GoldairDehumidifierLedDisplayLight
 from .fan.light import GoldairFanLedDisplayLight

+ 5 - 4
custom_components/tuya_local/lock.py

@@ -1,24 +1,25 @@
 """
 Setup for different kinds of Tuya climate devices
 """
+import logging
+
 from . import DOMAIN
 from .const import (
+    CONF_CHILD_LOCK,
     CONF_DEVICE_ID,
     CONF_TYPE,
+    CONF_TYPE_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_GECO_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_KOGAN_HEATER,
-    CONF_CHILD_LOCK,
-    CONF_TYPE_AUTO,
 )
 from .dehumidifier.lock import GoldairDehumidifierChildLock
-from .gpcv_heater.lock import GoldairGPCVHeaterChildLock
 from .geco_heater.lock import GoldairGECOHeaterChildLock
+from .gpcv_heater.lock import GoldairGPCVHeaterChildLock
 from .heater.lock import GoldairHeaterChildLock
 from .kogan_heater.lock import KoganHeaterChildLock
-import logging
 
 _LOGGER = logging.getLogger(__name__)
 

+ 2 - 3
custom_components/tuya_local/manifest.json

@@ -2,9 +2,8 @@
   "domain": "tuya_local",
   "name": "Tuya based devices local control",
   "documentation": "https://github.com/make-all/tuya-local",
-  "issue_tracker": "https://github.com/make-all/tuya-local/issues",
   "dependencies": [],
-  "codeowners": ["@make-all", "@nikrolls"],
-  "requirements": ["pytuya>=7.0.6"],
+  "codeowners": ["@make-all"],
+  "requirements": ["pycryptodome~=3.9","pytuya~=7.0.5"],
   "config_flow": true
 }

+ 1 - 1
requirements-dev.txt

@@ -1,5 +1,5 @@
 homeassistant~=0.110
-pycrypto~=2.6
+pycryptodome~=3.9
 pytuya~=7.0
 pytest~=5.4
 pytest-cov~=2.9

+ 1 - 1
requirements-first.txt

@@ -1 +1 @@
-pycrypto~=2.6
+pycryptodome~=3.9

+ 1 - 1
requirements.txt

@@ -1,2 +1,2 @@
-pycrypto~=2.6
+pycryptodome~=3.9
 pytuya~=7.0

+ 0 - 0
tests/dehumidifier/__init__.py


+ 586 - 0
tests/dehumidifier/test_climate.py

@@ -0,0 +1,586 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+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,
+    SUPPORT_FAN_MODE,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_TARGET_HUMIDITY,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from custom_components.goldair_climate.dehumidifier.climate import GoldairDehumidifier
+from custom_components.goldair_climate.dehumidifier.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 ..const import DEHUMIDIFIER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairDehumidifier(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairDehumidifier(self.mock_device())
+
+        self.dps = DEHUMIDIFIER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_TARGET_HUMIDITY | SUPPORT_PRESET_MODE | SUPPORT_FAN_MODE,
+        )
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_icon_is_always_standard_when_off_without_error(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = None
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = False
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+        self.assertEqual(self.subject.icon, "mdi:air-humidifier")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.assertEqual(self.subject.icon, "mdi:air-humidifier")
+
+    def test_icon_is_purifier_when_air_clean_is_active(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = None
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = True
+
+        self.assertEqual(self.subject.icon, "mdi:air-purifier")
+
+    def test_icon_is_tshirt_when_dry_clothes_is_active(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = None
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+
+        self.assertEqual(self.subject.icon, "mdi:tshirt-crew-outline")
+
+    def test_icon_is_always_melting_snowflake_when_defrosting_and_tank_not_full(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DEFROSTING]] = True
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.icon, "mdi:snowflake-melt")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.icon, "mdi:snowflake-melt")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+        self.assertEqual(self.subject.icon, "mdi:snowflake-melt")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.assertEqual(self.subject.icon, "mdi:snowflake-melt")
+
+    def test_icon_is_always_tank_when_tank_full_error_is_present(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = ERROR_CODE_TO_DPS_CODE[ERROR_TANK]
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.icon, "mdi:cup-water")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.icon, "mdi:cup-water")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+        self.assertEqual(self.subject.icon, "mdi:cup-water")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.assertEqual(self.subject.icon, "mdi:cup-water")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DEFROSTING]] = True
+        self.assertEqual(self.subject.icon, "mdi:cup-water")
+
+    def test_current_humidity(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HUMIDITY]] = 47
+        self.assertEqual(self.subject.current_humidity, 47)
+
+    def test_min_target_humidity(self):
+        self.assertEqual(self.subject.min_humidity, 30)
+
+    def test_max_target_humidity(self):
+        self.assertEqual(self.subject.max_humidity, 80)
+
+    def test_target_humidity_in_normal_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TARGET_HUMIDITY]] = 53
+
+        self.assertEqual(self.subject.target_humidity, 53)
+
+    def test_target_humidity_outside_normal_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TARGET_HUMIDITY]] = 53
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_HIGH
+        ]
+        self.assertIs(self.subject.target_humidity, None)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_LOW
+        ]
+        self.assertIs(self.subject.target_humidity, None)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+        self.assertIs(self.subject.target_humidity, None)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = True
+        self.assertIs(self.subject.target_humidity, None)
+
+    async def test_set_target_humidity_in_normal_preset_rounds_up_to_5_percent(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_HUMIDITY]: 55},
+        ):
+            await self.subject.async_set_humidity(53)
+
+    async def test_set_target_humidity_in_normal_preset_rounds_down_to_5_percent(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_HUMIDITY]: 50},
+        ):
+            await self.subject.async_set_humidity(52)
+
+    async def test_set_target_humidity_raises_error_outside_of_normal_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_LOW
+        ]
+        with self.assertRaisesRegex(
+            ValueError, "Target humidity can only be changed while in Normal mode"
+        ):
+            await self.subject.async_set_humidity(50)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_HIGH
+        ]
+        with self.assertRaisesRegex(
+            ValueError, "Target humidity can only be changed while in Normal mode"
+        ):
+            await self.subject.async_set_humidity(50)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_LOW
+        ]
+        with self.assertRaisesRegex(
+            ValueError, "Target humidity can only be changed while in Normal mode"
+        ):
+            await self.subject.async_set_humidity(50)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+        with self.assertRaisesRegex(
+            ValueError, "Target humidity can only be changed while in Normal mode"
+        ):
+            await self.subject.async_set_humidity(50)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = True
+        with self.assertRaisesRegex(
+            ValueError, "Target humidity can only be changed while in Normal mode"
+        ):
+            await self.subject.async_set_humidity(50)
+
+    def test_temperature_unit_returns_device_temperature_unit(self):
+        self.assertEqual(
+            self.subject.temperature_unit, self.subject._device.temperature_unit
+        )
+
+    def test_minimum_target_temperature(self):
+        self.assertIs(self.subject.min_temp, None)
+
+    def test_maximum_target_temperature(self):
+        self.assertIs(self.subject.max_temp, None)
+
+    def test_current_temperature(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TEMPERATURE]] = 25
+        self.assertEqual(self.subject.current_temperature, 25)
+
+    def test_hvac_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_DRY)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = None
+        self.assertEqual(self.subject.hvac_mode, STATE_UNAVAILABLE)
+
+    def test_hvac_modes(self):
+        self.assertEqual(self.subject.hvac_modes, [HVAC_MODE_OFF, HVAC_MODE_DRY])
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: True}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_DRY)
+
+    async def test_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: False}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_preset_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_NORMAL)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_HIGH
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_HIGH)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_LOW
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_LOW)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_DRY_CLOTHES)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = None
+        self.assertEqual(self.subject.preset_mode, None)
+
+    def test_air_clean_is_surfaced_in_preset_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = True
+
+        self.assertEqual(self.subject.preset_mode, PRESET_AIR_CLEAN)
+
+    def test_preset_modes(self):
+        self.assertEqual(
+            self.subject.preset_modes,
+            [
+                PRESET_NORMAL,
+                PRESET_LOW,
+                PRESET_HIGH,
+                PRESET_DRY_CLOTHES,
+                PRESET_AIR_CLEAN,
+            ],
+        )
+
+    async def test_set_test_preset_mode_to_normal(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    PRESET_NORMAL
+                ],
+                PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]: False,
+            },
+        ):
+            await self.subject.async_set_preset_mode(PRESET_NORMAL)
+            self.subject._device.anticipate_property_value.assert_not_called()
+
+    async def test_set_test_preset_mode_to_low(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    PRESET_LOW
+                ],
+                PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]: False,
+            },
+        ):
+            await self.subject.async_set_preset_mode(PRESET_LOW)
+            self.subject._device.anticipate_property_value.assert_called_once_with(
+                PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], FAN_LOW
+            )
+
+    async def test_set_test_preset_mode_to_high(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    PRESET_HIGH
+                ],
+                PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]: False,
+            },
+        ):
+            await self.subject.async_set_preset_mode(PRESET_HIGH)
+            self.subject._device.anticipate_property_value.assert_called_once_with(
+                PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], FAN_HIGH
+            )
+
+    async def test_set_test_preset_mode_to_dry_clothes(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    PRESET_DRY_CLOTHES
+                ],
+                PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]: False,
+            },
+        ):
+            await self.subject.async_set_preset_mode(PRESET_DRY_CLOTHES)
+            self.subject._device.anticipate_property_value.assert_called_once_with(
+                PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], FAN_HIGH
+            )
+
+    async def test_set_test_preset_mode_to_air_clean(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]: True}
+        ):
+            await self.subject.async_set_preset_mode(PRESET_AIR_CLEAN)
+            self.subject._device.anticipate_property_value.assert_called_once_with(
+                PROPERTY_TO_DPS_ID[ATTR_FAN_MODE], FAN_HIGH
+            )
+
+    def test_fan_mode_is_forced_to_high_in_high_dry_clothes_air_clean_presets(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = FAN_MODE_TO_DPS_MODE[FAN_LOW]
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_HIGH
+        ]
+        self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+        self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = True
+        self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+
+    def test_fan_mode_is_forced_to_low_in_low_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = FAN_MODE_TO_DPS_MODE[FAN_HIGH]
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_LOW
+        ]
+
+        self.assertEqual(self.subject.fan_mode, FAN_LOW)
+
+    def test_fan_mode_reflects_dps_mode_in_normal_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = FAN_MODE_TO_DPS_MODE[FAN_LOW]
+        self.assertEqual(self.subject.fan_mode, FAN_LOW)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = FAN_MODE_TO_DPS_MODE[FAN_HIGH]
+        self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = None
+        self.assertEqual(self.subject.fan_mode, None)
+
+    def test_fan_modes_reflect_preset_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.assertEqual(self.subject.fan_modes, [FAN_LOW, FAN_HIGH])
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_LOW
+        ]
+        self.assertEqual(self.subject.fan_modes, [FAN_LOW])
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_HIGH
+        ]
+        self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+        self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = True
+        self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = None
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = False
+        self.assertEqual(self.subject.fan_modes, [])
+
+    async def test_set_fan_mode_to_low_succeeds_in_normal_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]: FAN_MODE_TO_DPS_MODE[FAN_LOW]},
+        ):
+            await self.subject.async_set_fan_mode(FAN_LOW)
+
+    async def test_set_fan_mode_to_high_succeeds_in_normal_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]: FAN_MODE_TO_DPS_MODE[FAN_HIGH]},
+        ):
+            await self.subject.async_set_fan_mode(FAN_HIGH)
+
+    async def test_set_fan_mode_fails_with_invalid_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        with self.assertRaisesRegex(ValueError, "Invalid fan mode: something"):
+            await self.subject.async_set_fan_mode("something")
+
+    async def test_set_fan_mode_fails_outside_normal_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_LOW
+        ]
+        with self.assertRaisesRegex(
+            ValueError, "Fan mode can only be changed while in Normal preset mode"
+        ):
+            await self.subject.async_set_fan_mode(FAN_HIGH)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_HIGH
+        ]
+        with self.assertRaisesRegex(
+            ValueError, "Fan mode can only be changed while in Normal preset mode"
+        ):
+            await self.subject.async_set_fan_mode(FAN_HIGH)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_DRY_CLOTHES
+        ]
+        with self.assertRaisesRegex(
+            ValueError, "Fan mode can only be changed while in Normal preset mode"
+        ):
+            await self.subject.async_set_fan_mode(FAN_HIGH)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_AIR_CLEAN_ON]] = True
+        with self.assertRaisesRegex(
+            ValueError, "Fan mode can only be changed while in Normal preset mode"
+        ):
+            await self.subject.async_set_fan_mode(FAN_HIGH)
+
+    def test_tank_full_or_missing(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = None
+        self.assertEqual(self.subject.tank_full_or_missing, False)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = ERROR_CODE_TO_DPS_CODE[ERROR_TANK]
+        self.assertEqual(self.subject.tank_full_or_missing, True)
+
+    def test_defrosting(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DEFROSTING]] = False
+        self.assertEqual(self.subject.defrosting, False)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DEFROSTING]] = True
+        self.assertEqual(self.subject.defrosting, True)
+
+    def test_device_state_attributes(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = None
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DEFROSTING]] = False
+        self.assertEqual(
+            self.subject.device_state_attributes,
+            {ATTR_ERROR: None, ATTR_DEFROSTING: False},
+        )
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = ERROR_CODE_TO_DPS_CODE[ERROR_TANK]
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DEFROSTING]] = False
+        self.assertEqual(
+            self.subject.device_state_attributes,
+            {ATTR_ERROR: ERROR_TANK, ATTR_DEFROSTING: False},
+        )
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = None
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DEFROSTING]] = True
+        self.assertEqual(
+            self.subject.device_state_attributes,
+            {ATTR_ERROR: None, ATTR_DEFROSTING: True},
+        )
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = ERROR_CODE_TO_DPS_CODE[ERROR_TANK]
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DEFROSTING]] = True
+        self.assertEqual(
+            self.subject.device_state_attributes,
+            {ATTR_ERROR: ERROR_TANK, ATTR_DEFROSTING: True},
+        )
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 98 - 0
tests/dehumidifier/test_light.py

@@ -0,0 +1,98 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from custom_components.goldair_climate.dehumidifier.const import (
+    ATTR_DISPLAY_ON,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.dehumidifier.light import (
+    GoldairDehumidifierLedDisplayLight,
+)
+
+from ..const import DEHUMIDIFIER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairDehumidifierLedDisplayLight(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairDehumidifierLedDisplayLight(self.mock_device())
+
+        self.dps = DEHUMIDIFIER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_icon(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+        self.assertEqual(self.subject.icon, "mdi:led-on")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+        self.assertEqual(self.subject.icon, "mdi:led-off")
+
+    def test_is_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+        self.assertEqual(self.subject.is_on, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+        self.assertEqual(self.subject.is_on, False)
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_turn_on()
+
+    async def test_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_turn_off()
+
+    async def test_toggle_takes_no_action_when_dehumidifier_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        await self.subject.async_toggle()
+        self.subject._device.async_set_property.assert_not_called
+
+    async def test_toggle_turns_the_light_on_when_it_was_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_toggle_turns_the_light_off_when_it_was_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 81 - 0
tests/dehumidifier/test_lock.py

@@ -0,0 +1,81 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED
+from homeassistant.const import STATE_UNAVAILABLE
+
+from custom_components.goldair_climate.dehumidifier.const import (
+    ATTR_CHILD_LOCK,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.dehumidifier.lock import (
+    GoldairDehumidifierChildLock,
+)
+
+from ..const import DEHUMIDIFIER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairDehumidifierChildLock(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairDehumidifierChildLock(self.mock_device())
+
+        self.dps = DEHUMIDIFIER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_state(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.state, STATE_LOCKED)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.state, STATE_UNLOCKED)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = None
+        self.assertEqual(self.subject.state, STATE_UNAVAILABLE)
+
+    def test_is_locked(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.is_locked, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.is_locked, False)
+
+    async def test_lock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: True}
+        ):
+            await self.subject.async_lock()
+
+    async def test_unlock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: False}
+        ):
+            await self.subject.async_unlock()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 0 - 0
tests/fan/__init__.py


+ 302 - 0
tests/fan/test_climate.py

@@ -0,0 +1,302 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+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,
+    SUPPORT_FAN_MODE,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_SWING_MODE,
+    SWING_HORIZONTAL,
+    SWING_OFF,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from custom_components.goldair_climate.fan.climate import GoldairFan
+from custom_components.goldair_climate.fan.const import (
+    FAN_MODES,
+    HVAC_MODE_TO_DPS_MODE,
+    PRESET_MODE_TO_DPS_MODE,
+    PRESET_NORMAL,
+    PROPERTY_TO_DPS_ID,
+    SWING_MODE_TO_DPS_MODE,
+)
+
+from ..const import FAN_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairFan(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairFan(self.mock_device())
+
+        self.dps = FAN_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_FAN_MODE | SUPPORT_PRESET_MODE | SUPPORT_SWING_MODE,
+        )
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_icon_is_fan(self):
+        self.assertEqual(self.subject.icon, "mdi:fan")
+
+    def test_temperature_unit_returns_device_temperature_unit(self):
+        self.assertEqual(
+            self.subject.temperature_unit, self.subject._device.temperature_unit
+        )
+
+    def test_hvac_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_FAN_ONLY)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = None
+        self.assertEqual(self.subject.hvac_mode, STATE_UNAVAILABLE)
+
+    def test_hvac_modes(self):
+        self.assertEqual(self.subject.hvac_modes, [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY])
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: True}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_FAN_ONLY)
+
+    async def test_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: False}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_preset_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_NORMAL)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_ECO
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_ECO)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_SLEEP
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_SLEEP)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = None
+        self.assertIs(self.subject.preset_mode, None)
+
+    def test_preset_modes(self):
+        self.assertEqual(
+            self.subject.preset_modes, [PRESET_NORMAL, PRESET_ECO, PRESET_SLEEP]
+        )
+
+    async def test_set_preset_mode_to_normal(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    PRESET_NORMAL
+                ]
+            },
+        ):
+            await self.subject.async_set_preset_mode(PRESET_NORMAL)
+
+    async def test_set_preset_mode_to_eco(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[PRESET_ECO]},
+        ):
+            await self.subject.async_set_preset_mode(PRESET_ECO)
+
+    async def test_set_preset_mode_to_sleep(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    PRESET_SLEEP
+                ]
+            },
+        ):
+            await self.subject.async_set_preset_mode(PRESET_SLEEP)
+
+    def test_swing_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_SWING_MODE]] = SWING_MODE_TO_DPS_MODE[
+            SWING_OFF
+        ]
+        self.assertEqual(self.subject.swing_mode, SWING_OFF)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_SWING_MODE]] = SWING_MODE_TO_DPS_MODE[
+            SWING_HORIZONTAL
+        ]
+        self.assertEqual(self.subject.swing_mode, SWING_HORIZONTAL)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_SWING_MODE]] = None
+        self.assertIs(self.subject.swing_mode, None)
+
+    def test_swing_modes(self):
+        self.assertEqual(self.subject.swing_modes, [SWING_OFF, SWING_HORIZONTAL])
+
+    async def test_set_swing_mode_to_off(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_SWING_MODE]: SWING_MODE_TO_DPS_MODE[SWING_OFF]},
+        ):
+            await self.subject.async_set_swing_mode(SWING_OFF)
+
+    async def test_set_swing_mode_to_horizontal(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_SWING_MODE]: SWING_MODE_TO_DPS_MODE[
+                    SWING_HORIZONTAL
+                ]
+            },
+        ):
+            await self.subject.async_set_swing_mode(SWING_HORIZONTAL)
+
+    def test_fan_modes(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+        self.assertEqual(self.subject.fan_modes, list(range(1, 13)))
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_ECO
+        ]
+        self.assertEqual(self.subject.fan_modes, [1, 2, 3])
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_SLEEP
+        ]
+        self.assertEqual(self.subject.fan_modes, [1, 2, 3])
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = None
+        self.assertEqual(self.subject.fan_modes, [])
+
+    def test_fan_mode_for_normal_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = "1"
+        self.assertEqual(self.subject.fan_mode, 1)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = "6"
+        self.assertEqual(self.subject.fan_mode, 6)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = "12"
+        self.assertEqual(self.subject.fan_mode, 12)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = None
+        self.assertEqual(self.subject.fan_mode, None)
+
+    async def test_set_fan_mode_for_normal_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_NORMAL
+        ]
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]: "6"},
+        ):
+            await self.subject.async_set_fan_mode(6)
+
+    def test_fan_mode_for_eco_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_ECO
+        ]
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = "4"
+        self.assertEqual(self.subject.fan_mode, 1)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = "8"
+        self.assertEqual(self.subject.fan_mode, 2)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = "12"
+        self.assertEqual(self.subject.fan_mode, 3)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = None
+        self.assertEqual(self.subject.fan_mode, None)
+
+    async def test_set_fan_mode_for_eco_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_ECO
+        ]
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]: "4"},
+        ):
+            await self.subject.async_set_fan_mode(1)
+
+    def test_fan_mode_for_sleep_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_SLEEP
+        ]
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = "4"
+        self.assertEqual(self.subject.fan_mode, 1)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = "8"
+        self.assertEqual(self.subject.fan_mode, 2)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = "12"
+        self.assertEqual(self.subject.fan_mode, 3)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]] = None
+        self.assertEqual(self.subject.fan_mode, None)
+
+    async def test_set_fan_mode_for_sleep_preset(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_SLEEP
+        ]
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_FAN_MODE]: "8"},
+        ):
+            await self.subject.async_set_fan_mode(2)
+
+    async def test_set_fan_mode_does_nothing_when_preset_mode_is_not_set(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = None
+
+        with self.assertRaises(
+            ValueError, msg="Fan mode can only be set when a preset mode is set"
+        ):
+            await self.subject.async_set_fan_mode(2)
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 96 - 0
tests/fan/test_light.py

@@ -0,0 +1,96 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from custom_components.goldair_climate.fan.const import (
+    ATTR_DISPLAY_ON,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.fan.light import GoldairFanLedDisplayLight
+
+from ..const import FAN_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairFanLedDisplayLight(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairFanLedDisplayLight(self.mock_device())
+
+        self.dps = FAN_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_icon(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+        self.assertEqual(self.subject.icon, "mdi:led-on")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+        self.assertEqual(self.subject.icon, "mdi:led-off")
+
+    def test_is_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+        self.assertEqual(self.subject.is_on, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+        self.assertEqual(self.subject.is_on, False)
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_turn_on()
+
+    async def test_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_turn_off()
+
+    async def test_toggle_takes_no_action_when_fan_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        await self.subject.async_toggle()
+        self.subject._device.async_set_property.assert_not_called
+
+    async def test_toggle_turns_the_light_on_when_it_was_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_toggle_turns_the_light_off_when_it_was_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 0 - 0
tests/geco_heater/__init__.py


+ 156 - 0
tests/geco_heater/test_climate.py

@@ -0,0 +1,156 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    HVAC_MODE_HEAT,
+    HVAC_MODE_OFF,
+    SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from custom_components.goldair_climate.geco_heater.climate import GoldairGECOHeater
+from custom_components.goldair_climate.geco_heater.const import (
+    ATTR_ERROR,
+    ATTR_TARGET_TEMPERATURE,
+    HVAC_MODE_TO_DPS_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+
+from ..const import GECO_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairGECOHeater(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairGECOHeater(self.mock_device())
+
+        self.dps = GECO_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features, SUPPORT_TARGET_TEMPERATURE,
+        )
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_icon(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.icon, "mdi:radiator")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.icon, "mdi:radiator-disabled")
+
+    def test_temperature_unit_returns_device_temperature_unit(self):
+        self.assertEqual(
+            self.subject.temperature_unit, self.subject._device.temperature_unit
+        )
+
+    def test_target_temperature(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]] = 25
+        self.assertEqual(self.subject.target_temperature, 25)
+
+    def test_target_temperature_step(self):
+        self.assertEqual(self.subject.target_temperature_step, 1)
+
+    def test_minimum_target_temperature(self):
+        self.assertEqual(self.subject.min_temp, 15)
+
+    def test_maximum_target_temperature(self):
+        self.assertEqual(self.subject.max_temp, 35)
+
+    async def test_legacy_set_temperature_method(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25}
+        ):
+            await self.subject.async_set_temperature(temperature=25)
+
+    async def test_legacy_set_temperature_does_nothing_without_temperature_value(self):
+        await self.subject.async_set_temperature(something="else")
+        self.subject._device.async_set_property.assert_not_called()
+
+    async def test_set_target_temperature_succeeds_within_valid_range(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25}
+        ):
+            await self.subject.async_set_target_temperature(25)
+
+    async def test_set_target_temperature_rounds_value_to_closest_integer(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25},
+        ):
+            await self.subject.async_set_target_temperature(24.6)
+
+    async def test_set_target_temperature_fails_outside_valid_range(self):
+        with self.assertRaisesRegex(
+            ValueError, "Target temperature \\(14\\) must be between 15 and 35"
+        ):
+            await self.subject.async_set_target_temperature(14)
+
+        with self.assertRaisesRegex(
+            ValueError, "Target temperature \\(36\\) must be between 15 and 35"
+        ):
+            await self.subject.async_set_target_temperature(36)
+
+    def test_current_temperature(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TEMPERATURE]] = 25
+        self.assertEqual(self.subject.current_temperature, 25)
+
+    def test_hvac_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_HEAT)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = None
+        self.assertEqual(self.subject.hvac_mode, STATE_UNAVAILABLE)
+
+    def test_hvac_modes(self):
+        self.assertEqual(self.subject.hvac_modes, [HVAC_MODE_OFF, HVAC_MODE_HEAT])
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: True}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_HEAT)
+
+    async def test_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: False}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_error_state(self):
+        # There are currently no known error states; update this as they're discovered
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = "something"
+        self.assertEqual(
+            self.subject.device_state_attributes, {ATTR_ERROR: "something"}
+        )
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 81 - 0
tests/geco_heater/test_lock.py

@@ -0,0 +1,81 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED
+from homeassistant.const import STATE_UNAVAILABLE
+
+from custom_components.goldair_climate.geco_heater.const import (
+    ATTR_CHILD_LOCK,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.geco_heater.lock import (
+    GoldairGECOHeaterChildLock,
+)
+
+from ..const import GECO_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairGECOHeaterChildLock(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairGECOHeaterChildLock(self.mock_device())
+
+        self.dps = GECO_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_state(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.state, STATE_LOCKED)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.state, STATE_UNLOCKED)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = None
+        self.assertEqual(self.subject.state, STATE_UNAVAILABLE)
+
+    def test_is_locked(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.is_locked, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.is_locked, False)
+
+    async def test_lock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: True}
+        ):
+            await self.subject.async_lock()
+
+    async def test_unlock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: False}
+        ):
+            await self.subject.async_unlock()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 0 - 0
tests/gpcv_heater/__init__.py


+ 217 - 0
tests/gpcv_heater/test_climate.py

@@ -0,0 +1,217 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    HVAC_MODE_HEAT,
+    HVAC_MODE_OFF,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from custom_components.goldair_climate.gpcv_heater.climate import GoldairGPCVHeater
+from custom_components.goldair_climate.gpcv_heater.const import (
+    ATTR_ERROR,
+    ATTR_TARGET_TEMPERATURE,
+    HVAC_MODE_TO_DPS_MODE,
+    PRESET_HIGH,
+    PRESET_LOW,
+    PRESET_MODE_TO_DPS_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+
+from ..const import GPCV_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairGPCVHeater(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairGPCVHeater(self.mock_device())
+
+        self.dps = GPCV_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE,
+        )
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_icon(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.icon, "mdi:radiator")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.icon, "mdi:radiator-disabled")
+
+    def test_temperature_unit_returns_device_temperature_unit(self):
+        self.assertEqual(
+            self.subject.temperature_unit, self.subject._device.temperature_unit
+        )
+
+    def test_target_temperature(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]] = 25
+        self.assertEqual(self.subject.target_temperature, 25)
+
+    def test_target_temperature_step(self):
+        self.assertEqual(self.subject.target_temperature_step, 1)
+
+    def test_minimum_target_temperature(self):
+        self.assertEqual(self.subject.min_temp, 15)
+
+    def test_maximum_target_temperature(self):
+        self.assertEqual(self.subject.max_temp, 35)
+
+    async def test_legacy_set_temperature_with_temperature(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25}
+        ):
+            await self.subject.async_set_temperature(temperature=25)
+
+    async def test_legacy_set_temperature_with_preset_mode(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[PRESET_LOW]},
+        ):
+            await self.subject.async_set_temperature(preset_mode=PRESET_LOW)
+
+    async def test_legacy_set_temperature_with_both_properties(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25,
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    PRESET_LOW
+                ],
+            },
+        ):
+            await self.subject.async_set_temperature(
+                temperature=25, preset_mode=PRESET_LOW
+            )
+
+    async def test_legacy_set_temperature_with_no_valid_properties(self):
+        await self.subject.async_set_temperature(something="else")
+        self.subject._device.async_set_property.assert_not_called
+
+    async def test_set_target_temperature_succeeds_within_valid_range(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25}
+        ):
+            await self.subject.async_set_target_temperature(25)
+
+    async def test_set_target_temperature_rounds_value_to_closest_integer(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25},
+        ):
+            await self.subject.async_set_target_temperature(24.6)
+
+    async def test_set_target_temperature_fails_outside_valid_range(self):
+        with self.assertRaisesRegex(
+            ValueError, "Target temperature \\(14\\) must be between 15 and 35"
+        ):
+            await self.subject.async_set_target_temperature(14)
+
+        with self.assertRaisesRegex(
+            ValueError, "Target temperature \\(36\\) must be between 15 and 35"
+        ):
+            await self.subject.async_set_target_temperature(36)
+
+    def test_current_temperature(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TEMPERATURE]] = 25
+        self.assertEqual(self.subject.current_temperature, 25)
+
+    def test_hvac_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_HEAT)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = None
+        self.assertEqual(self.subject.hvac_mode, STATE_UNAVAILABLE)
+
+    def test_hvac_modes(self):
+        self.assertEqual(self.subject.hvac_modes, [HVAC_MODE_OFF, HVAC_MODE_HEAT])
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: True}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_HEAT)
+
+    async def test_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: False}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_preset_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_LOW
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_LOW)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_HIGH
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_HIGH)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = None
+        self.assertIs(self.subject.preset_mode, None)
+
+    def test_preset_modes(self):
+        self.assertEqual(self.subject.preset_modes, [PRESET_LOW, PRESET_HIGH])
+
+    async def test_set_preset_mode_to_low(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[PRESET_LOW]},
+        ):
+            await self.subject.async_set_preset_mode(PRESET_LOW)
+
+    async def test_set_preset_mode_to_high(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    PRESET_HIGH
+                ]
+            },
+        ):
+            await self.subject.async_set_preset_mode(PRESET_HIGH)
+
+    def test_error_state(self):
+        # There are currently no known error states; update this as they're discovered
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = "something"
+        self.assertEqual(
+            self.subject.device_state_attributes, {ATTR_ERROR: "something"}
+        )
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 81 - 0
tests/gpcv_heater/test_lock.py

@@ -0,0 +1,81 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED
+from homeassistant.const import STATE_UNAVAILABLE
+
+from custom_components.goldair_climate.gpcv_heater.const import (
+    ATTR_CHILD_LOCK,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.gpcv_heater.lock import (
+    GoldairGPCVHeaterChildLock,
+)
+
+from ..const import GPCV_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairGPCVHeaterChildLock(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairGPCVHeaterChildLock(self.mock_device())
+
+        self.dps = GPCV_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_state(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.state, STATE_LOCKED)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.state, STATE_UNLOCKED)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = None
+        self.assertEqual(self.subject.state, STATE_UNAVAILABLE)
+
+    def test_is_locked(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.is_locked, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.is_locked, False)
+
+    async def test_lock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: True}
+        ):
+            await self.subject.async_lock()
+
+    async def test_unlock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: False}
+        ):
+            await self.subject.async_unlock()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 0 - 0
tests/heater/__init__.py


+ 399 - 0
tests/heater/test_climate.py

@@ -0,0 +1,399 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    HVAC_MODE_HEAT,
+    HVAC_MODE_OFF,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_SWING_MODE,
+    SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from custom_components.goldair_climate.heater.climate import GoldairHeater
+from custom_components.goldair_climate.heater.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_AUTO,
+    POWER_LEVEL_STOP,
+    POWER_LEVEL_TO_DPS_LEVEL,
+    PRESET_MODE_TO_DPS_MODE,
+    PROPERTY_TO_DPS_ID,
+    STATE_ANTI_FREEZE,
+    STATE_COMFORT,
+    STATE_ECO,
+)
+
+from ..const import GPPH_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairHeater(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairHeater(self.mock_device())
+
+        self.dps = GPPH_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE | SUPPORT_SWING_MODE,
+        )
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_icon(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.icon, "mdi:radiator")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.icon, "mdi:radiator-disabled")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = POWER_LEVEL_STOP
+        self.assertEqual(self.subject.icon, "mdi:radiator-disabled")
+
+    def test_temperature_unit_returns_device_temperature_unit(self):
+        self.assertEqual(
+            self.subject.temperature_unit, self.subject._device.temperature_unit
+        )
+
+    def test_target_temperature(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]] = 25
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ECO_TARGET_TEMPERATURE]] = 15
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_COMFORT
+        ]
+        self.assertEqual(self.subject.target_temperature, 25)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ECO
+        ]
+        self.assertEqual(self.subject.target_temperature, 15)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ANTI_FREEZE
+        ]
+        self.assertIs(self.subject.target_temperature, None)
+
+    def test_target_temperature_step(self):
+        self.assertEqual(self.subject.target_temperature_step, 1)
+
+    def test_minimum_target_temperature(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_COMFORT
+        ]
+        self.assertEqual(self.subject.min_temp, 5)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ECO
+        ]
+        self.assertEqual(self.subject.min_temp, 5)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ANTI_FREEZE
+        ]
+        self.assertIs(self.subject.min_temp, None)
+
+    def test_maximum_target_temperature(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_COMFORT
+        ]
+        self.assertEqual(self.subject.max_temp, 35)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ECO
+        ]
+        self.assertEqual(self.subject.max_temp, 21)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ANTI_FREEZE
+        ]
+        self.assertIs(self.subject.max_temp, None)
+
+    async def test_legacy_set_temperature_with_temperature(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25}
+        ):
+            await self.subject.async_set_temperature(temperature=25)
+
+    async def test_legacy_set_temperature_with_preset_mode(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    STATE_COMFORT
+                ]
+            },
+        ):
+            await self.subject.async_set_temperature(preset_mode=STATE_COMFORT)
+
+    async def test_legacy_set_temperature_with_both_properties(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25,
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    STATE_COMFORT
+                ],
+            },
+        ):
+            await self.subject.async_set_temperature(
+                temperature=25, preset_mode=STATE_COMFORT
+            )
+
+    async def test_legacy_set_temperature_with_no_valid_properties(self):
+        await self.subject.async_set_temperature(something="else")
+        self.subject._device.async_set_property.assert_not_called
+
+    async def test_set_target_temperature_in_comfort_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_COMFORT
+        ]
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25}
+        ):
+            await self.subject.async_set_target_temperature(25)
+
+    async def test_set_target_temperature_in_eco_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ECO
+        ]
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_ECO_TARGET_TEMPERATURE]: 15}
+        ):
+            await self.subject.async_set_target_temperature(15)
+
+    async def test_set_target_temperature_rounds_value_to_closest_integer(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 25},
+        ):
+            await self.subject.async_set_target_temperature(24.6)
+
+    async def test_set_target_temperature_fails_outside_valid_range_in_comfort(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_COMFORT
+        ]
+
+        with self.assertRaisesRegex(
+            ValueError, "Target temperature \\(4\\) must be between 5 and 35"
+        ):
+            await self.subject.async_set_target_temperature(4)
+
+        with self.assertRaisesRegex(
+            ValueError, "Target temperature \\(36\\) must be between 5 and 35"
+        ):
+            await self.subject.async_set_target_temperature(36)
+
+    async def test_set_target_temperature_fails_outside_valid_range_in_eco(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ECO
+        ]
+
+        with self.assertRaisesRegex(
+            ValueError, "Target temperature \\(4\\) must be between 5 and 21"
+        ):
+            await self.subject.async_set_target_temperature(4)
+
+        with self.assertRaisesRegex(
+            ValueError, "Target temperature \\(22\\) must be between 5 and 21"
+        ):
+            await self.subject.async_set_target_temperature(22)
+
+    async def test_set_target_temperature_fails_in_anti_freeze(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ANTI_FREEZE
+        ]
+
+        with self.assertRaisesRegex(
+            ValueError, "You cannot set the temperature in Anti-freeze mode"
+        ):
+            await self.subject.async_set_target_temperature(25)
+
+    def test_current_temperature(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TEMPERATURE]] = 25
+        self.assertEqual(self.subject.current_temperature, 25)
+
+    def test_hvac_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_HEAT)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = None
+        self.assertEqual(self.subject.hvac_mode, STATE_UNAVAILABLE)
+
+    def test_hvac_modes(self):
+        self.assertEqual(self.subject.hvac_modes, [HVAC_MODE_OFF, HVAC_MODE_HEAT])
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: True}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_HEAT)
+
+    async def test_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: False}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_preset_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_COMFORT
+        ]
+        self.assertEqual(self.subject.preset_mode, STATE_COMFORT)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ECO
+        ]
+        self.assertEqual(self.subject.preset_mode, STATE_ECO)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            STATE_ANTI_FREEZE
+        ]
+        self.assertEqual(self.subject.preset_mode, STATE_ANTI_FREEZE)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = None
+        self.assertIs(self.subject.preset_mode, None)
+
+    def test_preset_modes(self):
+        self.assertEqual(
+            self.subject.preset_modes, [STATE_COMFORT, STATE_ECO, STATE_ANTI_FREEZE]
+        )
+
+    async def test_set_preset_mode_to_comfort(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    STATE_COMFORT
+                ]
+            },
+        ):
+            await self.subject.async_set_preset_mode(STATE_COMFORT)
+
+    async def test_set_preset_mode_to_eco(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[STATE_ECO]},
+        ):
+            await self.subject.async_set_preset_mode(STATE_ECO)
+
+    async def test_set_preset_mode_to_anti_freeze(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    STATE_ANTI_FREEZE
+                ]
+            },
+        ):
+            await self.subject.async_set_preset_mode(STATE_ANTI_FREEZE)
+
+    def test_power_level_returns_user_power_level(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_MODE]] = ATTR_POWER_MODE_USER
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = POWER_LEVEL_TO_DPS_LEVEL[
+            "Stop"
+        ]
+        self.assertEqual(self.subject.swing_mode, "Stop")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = POWER_LEVEL_TO_DPS_LEVEL["3"]
+        self.assertEqual(self.subject.swing_mode, "3")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = POWER_LEVEL_TO_DPS_LEVEL[
+            "Auto"
+        ]
+        self.assertEqual(self.subject.swing_mode, "Auto")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = None
+        self.assertIs(self.subject.swing_mode, None)
+
+    def test_power_level_in_returns_power_mode_when_not_in_user_power_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_MODE]] = POWER_LEVEL_TO_DPS_LEVEL["Stop"]
+        self.assertEqual(self.subject.swing_mode, "Stop")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_MODE]] = POWER_LEVEL_TO_DPS_LEVEL["3"]
+        self.assertEqual(self.subject.swing_mode, "3")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_MODE]] = POWER_LEVEL_TO_DPS_LEVEL["Auto"]
+        self.assertEqual(self.subject.swing_mode, "Auto")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_MODE]] = None
+        self.assertIs(self.subject.swing_mode, None)
+
+    def test_power_levels(self):
+        self.assertEqual(
+            self.subject.swing_modes, ["Stop", "1", "2", "3", "4", "5", "Auto"],
+        )
+
+    async def test_set_power_level_to_stop(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]: POWER_LEVEL_TO_DPS_LEVEL["Stop"]},
+        ):
+            await self.subject.async_set_swing_mode("Stop")
+
+    async def test_set_power_level_to_auto(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]: POWER_LEVEL_TO_DPS_LEVEL["Auto"]},
+        ):
+            await self.subject.async_set_swing_mode("Auto")
+
+    async def test_set_power_level_to_numeric_value(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]: POWER_LEVEL_TO_DPS_LEVEL["3"]},
+        ):
+            await self.subject.async_set_swing_mode("3")
+
+    async def test_set_power_level_to_invalid_value_raises_error(self):
+        with self.assertRaisesRegex(ValueError, "Invalid power level: unknown"):
+            await self.subject.async_set_swing_mode("unknown")
+
+    def test_error_state(self):
+        # There are currently no known error states; update this as they're discovered
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_ERROR]] = "something"
+        self.assertEqual(
+            self.subject.device_state_attributes, {ATTR_ERROR: "something"}
+        )
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 96 - 0
tests/heater/test_light.py

@@ -0,0 +1,96 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from custom_components.goldair_climate.heater.const import (
+    ATTR_DISPLAY_ON,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.heater.light import GoldairHeaterLedDisplayLight
+
+from ..const import GPPH_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairHeaterLedDisplayLight(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairHeaterLedDisplayLight(self.mock_device())
+
+        self.dps = GPPH_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_icon(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+        self.assertEqual(self.subject.icon, "mdi:led-on")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+        self.assertEqual(self.subject.icon, "mdi:led-off")
+
+    def test_is_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+        self.assertEqual(self.subject.is_on, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+        self.assertEqual(self.subject.is_on, False)
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_turn_on()
+
+    async def test_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_turn_off()
+
+    async def test_toggle_takes_no_action_when_heater_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        await self.subject.async_toggle()
+        self.subject._device.async_set_property.assert_not_called
+
+    async def test_toggle_turns_the_light_on_when_it_was_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_toggle_turns_the_light_off_when_it_was_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 79 - 0
tests/heater/test_lock.py

@@ -0,0 +1,79 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED
+from homeassistant.const import STATE_UNAVAILABLE
+
+from custom_components.goldair_climate.heater.const import (
+    ATTR_CHILD_LOCK,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.heater.lock import GoldairHeaterChildLock
+
+from ..const import GPPH_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestGoldairHeaterChildLock(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.device.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairHeaterChildLock(self.mock_device())
+
+        self.dps = GPPH_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_state(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.state, STATE_LOCKED)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.state, STATE_UNLOCKED)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = None
+        self.assertEqual(self.subject.state, STATE_UNAVAILABLE)
+
+    def test_is_locked(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.is_locked, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.is_locked, False)
+
+    async def test_lock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: True}
+        ):
+            await self.subject.async_lock()
+
+    async def test_unlock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: False}
+        ):
+            await self.subject.async_unlock()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 25 - 0
tests/helpers.py

@@ -0,0 +1,25 @@
+from contextlib import asynccontextmanager
+from unittest.mock import AsyncMock
+
+from custom_components.goldair_climate.device import GoldairTuyaDevice
+
+
+@asynccontextmanager
+async def assert_device_properties_set(device: GoldairTuyaDevice, properties: dict):
+    results = []
+
+    def generate_result(*args):
+        result = AsyncMock()
+        results.append(result)
+        return result()
+
+    device.async_set_property.side_effect = generate_result
+
+    try:
+        yield
+    finally:
+        assert device.async_set_property.call_count == len(properties.keys())
+        for key in properties.keys():
+            device.async_set_property.assert_any_call(key, properties[key])
+        for result in results:
+            result.assert_awaited()

+ 223 - 6
tests/test_device.py

@@ -1,5 +1,10 @@
+import threading
+from datetime import datetime, timedelta
+from time import sleep, time
 from unittest import IsolatedAsyncioTestCase
-from unittest.mock import patch
+from unittest.mock import AsyncMock, call, patch
+
+from homeassistant.const import TEMP_CELSIUS
 
 from custom_components.tuya_local.const import (
     CONF_TYPE_DEHUMIDIFIER,
@@ -21,11 +26,16 @@ from .const import (
 
 class TestDevice(IsolatedAsyncioTestCase):
     def setUp(self):
-        patcher = patch("pytuya.Device")
-        self.addCleanup(patcher.stop)
-        self.mock_api = patcher.start()
+        device_patcher = patch("pytuya.Device")
+        self.addCleanup(device_patcher.stop)
+        self.mock_api = device_patcher.start()
+
+        hass_patcher = patch("homeassistant.core.HomeAssistant")
+        self.addCleanup(hass_patcher.stop)
+        self.hass = hass_patcher.start()
+
         self.subject = TuyaLocalDevice(
-            "Some name", "some_dev_id", "some.ip.address", "some_local_key", None
+            "Some name", "some_dev_id", "some.ip.address", "some_local_key", self.hass()
         )
 
     def test_configures_pytuya_correctly(self):
@@ -36,7 +46,7 @@ class TestDevice(IsolatedAsyncioTestCase):
 
     def test_name(self):
         """Returns the name given at instantiation."""
-        self.assertEqual("Some name", self.subject.name)
+        self.assertEqual(self.subject.name, "Some name")
 
     def test_unique_id(self):
         """Returns the unique ID presented by the API class."""
@@ -53,6 +63,17 @@ class TestDevice(IsolatedAsyncioTestCase):
             },
         )
 
+    def test_temperature_unit(self):
+        self.assertEqual(self.subject.temperature_unit, TEMP_CELSIUS)
+
+    async def test_refreshes_state_if_no_cached_state_exists(self):
+        self.subject._cached_state = {}
+        self.subject.async_refresh = AsyncMock()
+
+        await self.subject.async_inferred_type()
+
+        self.subject.async_refresh.assert_awaited()
+
     async def test_detects_geco_heater_payload(self):
         self.subject._cached_state = GECO_HEATER_PAYLOAD
         self.assertEqual(
@@ -80,3 +101,199 @@ class TestDevice(IsolatedAsyncioTestCase):
     async def test_detects_fan_payload(self):
         self.subject._cached_state = FAN_PAYLOAD
         self.assertEqual(await self.subject.async_inferred_type(), CONF_TYPE_FAN)
+
+    async def test_detection_returns_none_when_device_type_could_not_be_detected(self):
+        self.subject._cached_state = {"1": False}
+        self.assertEqual(await self.subject.async_inferred_type(), None)
+
+    async def test_does_not_refresh_more_often_than_cache_timeout(self):
+        refresh_task = AsyncMock()
+        self.subject._cached_state = {"updated_at": time() - 19}
+        self.subject._refresh_task = awaitable = refresh_task()
+
+        await self.subject.async_refresh()
+
+        refresh_task.assert_awaited()
+        self.assertIs(self.subject._refresh_task, awaitable)
+
+    async def test_refreshes_when_there_is_no_pending_reset(self):
+        async_job = AsyncMock()
+        self.subject._cached_state = {"updated_at": time() - 19}
+        self.subject._hass.async_add_executor_job.return_value = awaitable = async_job()
+
+        await self.subject.async_refresh()
+
+        self.subject._hass.async_add_executor_job.assert_called_once_with(
+            self.subject.refresh
+        )
+        self.assertIs(self.subject._refresh_task, awaitable)
+        async_job.assert_awaited()
+
+    async def test_refreshes_when_there_is_expired_pending_reset(self):
+        async_job = AsyncMock()
+        self.subject._cached_state = {"updated_at": time() - 20}
+        self.subject._hass.async_add_executor_job.return_value = awaitable = async_job()
+        self.subject._refresh_task = {}
+
+        await self.subject.async_refresh()
+
+        self.subject._hass.async_add_executor_job.assert_called_once_with(
+            self.subject.refresh
+        )
+        self.assertIs(self.subject._refresh_task, awaitable)
+        async_job.assert_awaited()
+
+    def test_refresh_reloads_status_from_device(self):
+        self.subject._api.status.return_value = {"dps": {"1": False}}
+        self.subject._cached_state = {"1": True}
+
+        self.subject.refresh()
+
+        self.subject._api.status.assert_called_once()
+        self.assertEqual(self.subject._cached_state["1"], False)
+        self.assertTrue(
+            time() - 1 <= self.subject._cached_state["updated_at"] <= time()
+        )
+
+    def test_refresh_retries_up_to_four_times(self):
+        self.subject._api.status.side_effect = [
+            Exception("Error"),
+            Exception("Error"),
+            Exception("Error"),
+            {"dps": {"1": False}},
+        ]
+
+        self.subject.refresh()
+
+        self.assertEqual(self.subject._api.status.call_count, 4)
+        self.assertEqual(self.subject._cached_state["1"], False)
+
+    def test_refresh_clears_cached_state_and_pending_updates_after_failing_four_times(
+        self,
+    ):
+        self.subject._cached_state = {"1": True}
+        self.subject._pending_updates = {"1": False}
+        self.subject._api.status.side_effect = [
+            Exception("Error"),
+            Exception("Error"),
+            Exception("Error"),
+            Exception("Error"),
+        ]
+
+        self.subject.refresh()
+
+        self.assertEqual(self.subject._api.status.call_count, 4)
+        self.assertEqual(self.subject._cached_state, {"updated_at": 0})
+        self.assertEqual(self.subject._pending_updates, {})
+
+    def test_api_protocol_version_is_rotated_with_each_failure(self):
+        self.subject._api.set_version.assert_called_once_with(3.3)
+        self.subject._api.set_version.reset_mock()
+
+        self.subject._api.status.side_effect = [
+            Exception("Error"),
+            Exception("Error"),
+            Exception("Error"),
+            Exception("Error"),
+        ]
+        self.subject.refresh()
+
+        self.subject._api.set_version.assert_has_calls(
+            [call(3.1), call(3.3), call(3.1)]
+        )
+
+    def test_reset_cached_state_clears_cached_state_and_pending_updates(self):
+        self.subject._cached_state = {"1": True, "updated_at": time()}
+        self.subject._pending_updates = {"1": False}
+
+        self.subject._reset_cached_state()
+
+        self.assertEqual(self.subject._cached_state, {"updated_at": 0})
+        self.assertEqual(self.subject._pending_updates, {})
+
+    def test_get_property_returns_value_from_cached_state(self):
+        self.subject._cached_state = {"1": True}
+        self.assertEqual(self.subject.get_property("1"), True)
+
+    def test_get_property_returns_pending_update_value(self):
+        self.subject._pending_updates = {
+            "1": {"value": False, "updated_at": time() - 9}
+        }
+        self.assertEqual(self.subject.get_property("1"), False)
+
+    def test_pending_update_value_overrides_cached_value(self):
+        self.subject._cached_state = {"1": True}
+        self.subject._pending_updates = {
+            "1": {"value": False, "updated_at": time() - 9}
+        }
+
+        self.assertEqual(self.subject.get_property("1"), False)
+
+    def test_expired_pending_update_value_does_not_override_cached_value(self):
+        self.subject._cached_state = {"1": True}
+        self.subject._pending_updates = {
+            "1": {"value": False, "updated_at": time() - 10}
+        }
+
+        self.assertEqual(self.subject.get_property("1"), True)
+
+    def test_get_property_returns_none_when_value_does_not_exist(self):
+        self.subject._cached_state = {"1": True}
+        self.assertIs(self.subject.get_property("2"), None)
+
+    async def test_async_set_property_schedules_job(self):
+        async_job = AsyncMock()
+        self.subject._hass.async_add_executor_job.return_value = awaitable = async_job()
+
+        await self.subject.async_set_property("1", False)
+
+        self.subject._hass.async_add_executor_job.assert_called_once_with(
+            self.subject.set_property, "1", False
+        )
+        async_job.assert_awaited()
+
+    def test_set_property_immediately_stores_new_value_to_pending_updates(self):
+        self.subject.set_property("1", False)
+        self.subject._cached_state = {"1": True}
+        self.assertEqual(self.subject.get_property("1"), False)
+
+    def test_debounces_multiple_set_calls_into_one_api_call(self):
+        with patch("custom_components.goldair_climate.device.Timer") as mock:
+            self.subject.set_property("1", True)
+            mock.assert_called_once_with(1, self.subject._send_pending_updates)
+
+            debounce = self.subject._debounce
+            mock.reset_mock()
+
+            self.subject.set_property("2", False)
+            debounce.cancel.assert_called_once()
+            mock.assert_called_once_with(1, self.subject._send_pending_updates)
+
+            self.subject._api.generate_payload.return_value = "payload"
+            self.subject._send_pending_updates()
+            self.subject._api.generate_payload.assert_called_once_with(
+                "set", {"1": True, "2": False}
+            )
+            self.subject._api._send_receive.assert_called_once_with("payload")
+
+    def test_set_properties_takes_no_action_when_no_properties_are_provided(self):
+        with patch("custom_components.goldair_climate.device.Timer") as mock:
+            self.subject._set_properties({})
+            mock.assert_not_called()
+
+    def test_anticipate_property_value_updates_cached_state(self):
+        self.subject._cached_state = {"1": True}
+        self.subject.anticipate_property_value("1", False)
+        self.assertEqual(self.subject._cached_state["1"], False)
+
+    def test_get_key_for_value_returns_key_from_object_matching_value(self):
+        obj = {"key1": "value1", "key2": "value2"}
+
+        self.assertEqual(GoldairTuyaDevice.get_key_for_value(obj, "value1"), "key1")
+        self.assertEqual(GoldairTuyaDevice.get_key_for_value(obj, "value2"), "key2")
+
+    def test_get_key_for_value_returns_fallback_when_value_not_found(self):
+        obj = {"key1": "value1", "key2": "value2"}
+        self.assertEqual(
+            GoldairTuyaDevice.get_key_for_value(obj, "value3", fallback="fb"), "fb"
+        )