瀏覽代碼

Add generic tests for devices that are still supported by custom classes.

Having these tests available gives an easy way to judge when the generic classes
are ready to take over for these last devices.

For now, tests that fail are skipped.
Jason Rumney 5 年之前
父節點
當前提交
25f1b12b17

+ 212 - 0
tests/devices/test_gardenpac_heatpump.py

@@ -0,0 +1,212 @@
+from unittest import IsolatedAsyncioTestCase, skip
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.climate.const import (
+    HVAC_MODE_HEAT,
+    HVAC_MODE_OFF,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.const import (
+    STATE_UNAVAILABLE,
+    TEMP_CELSIUS,
+    TEMP_FAHRENHEIT,
+)
+
+from custom_components.tuya_local.generic.climate import TuyaLocalClimate
+from custom_components.tuya_local.helpers.device_config import TuyaDeviceConfig
+
+from ..const import GARDENPAC_HEATPUMP_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+HVACMODE_DPS = "1"
+CURRENTTEMP_DPS = "102"
+UNITS_DPS = "103"
+POWERLEVEL_DPS = "104"
+OPMODE_DPS = "105"
+TEMPERATURE_DPS = "106"
+UNKNOWN107_DPS = "107"
+UNKNOWN108_DPS = "108"
+UNKNOWN115_DPS = "115"
+UNKNOWN116_DPS = "116"
+PRESET_DPS = "117"
+
+
+class TestGardenPACPoolHeatpump(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch("custom_components.tuya_local.device.TuyaLocalDevice")
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+        cfg = TuyaDeviceConfig("gardenpac_heatpump.yaml")
+        climate = cfg.primary_entity
+        self.climate_name = climate.name
+
+        self.subject = TuyaLocalClimate(self.mock_device(), climate)
+
+        self.dps = GARDENPAC_HEATPUMP_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)
+
+    @skip("Icon customisation not supported yet")
+    def test_icon(self):
+        self.dps[HVACMODE_DPS] = True
+        self.assertEqual(self.subject.icon, "mdi:hot-tub")
+
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.icon, "mdi:radiator-disabled")
+
+    @skip("Temperature units not supported yet")
+    def test_temperature_unit(self):
+        self.dps[UNITS_DPS] = False
+        self.assertEqual(self.subject.temperature_unit, TEMP_FAHRENHEIT)
+        self.dps[UNITS_DPS] = True
+        self.assertEqual(self.subject.temperature_unit, TEMP_CELSIUS)
+
+    def test_target_temperature(self):
+        self.dps[TEMPERATURE_DPS] = 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, 18)
+
+    def test_maximum_target_temperature(self):
+        self.assertEqual(self.subject.max_temp, 45)
+
+    async def test_legacy_set_temperature_with_temperature(self):
+        async with assert_device_properties_set(
+            self.subject._device, {TEMPERATURE_DPS: 25}
+        ):
+            await self.subject.async_set_temperature(temperature=25)
+
+    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, {TEMPERATURE_DPS: 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,
+            {TEMPERATURE_DPS: 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 18 and 45"
+        ):
+            await self.subject.async_set_target_temperature(14)
+
+        with self.assertRaisesRegex(
+            ValueError, "Target temperature \\(46\\) must be between 18 and 45"
+        ):
+            await self.subject.async_set_target_temperature(46)
+
+    def test_current_temperature(self):
+        self.dps[CURRENTTEMP_DPS] = 25
+        self.assertEqual(self.subject.current_temperature, 25)
+
+    def test_hvac_mode(self):
+        self.dps[HVACMODE_DPS] = True
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_HEAT)
+
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[HVACMODE_DPS] = 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, {HVACMODE_DPS: 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, {HVACMODE_DPS: False}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_preset_mode(self):
+        self.dps[PRESET_DPS] = False
+        self.assertEqual(self.subject.preset_mode, "Silent")
+
+        self.dps[PRESET_DPS] = True
+        self.assertEqual(self.subject.preset_mode, "Smart")
+
+        self.dps[PRESET_DPS] = None
+        self.assertIs(self.subject.preset_mode, None)
+
+    def test_preset_modes(self):
+        self.assertCountEqual(self.subject.preset_modes, ["Silent", "Smart"])
+
+    async def test_set_preset_mode_to_silent(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: False},
+        ):
+            await self.subject.async_set_preset_mode("Silent")
+
+    async def test_set_preset_mode_to_smart(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: True},
+        ):
+            await self.subject.async_set_preset_mode("Smart")
+
+    @skip("Temperature units not supported yet")
+    def test_device_state_attributes(self):
+        self.dps[POWERLEVEL_DPS] = 50
+        self.dps[OPMODE_DPS] = "cool"
+        self.dps[UNKNOWN107_DPS] = 1
+        self.dps[UNKNOWN108_DPS] = 2
+        self.dps[UNKNOWN115_DPS] = 3
+        self.dps[UNKNOWN116_DPS] = 4
+        self.assertCountEqual(
+            self.subject.device_state_attributes,
+            {
+                "power_level": 50,
+                "operating_mode": "cool",
+                "unknown_107": 1,
+                "unknown_108": 2,
+                "unknown_115": 3,
+                "unknown_116": 4,
+            },
+        )
+
+    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()

+ 666 - 0
tests/devices/test_goldair_dehumidifier.py

@@ -0,0 +1,666 @@
+from unittest import IsolatedAsyncioTestCase, skip
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.climate.const import (
+    FAN_HIGH,
+    FAN_LOW,
+    HVAC_MODE_DRY,
+    HVAC_MODE_OFF,
+    SUPPORT_FAN_MODE,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_TARGET_HUMIDITY,
+)
+from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED
+from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from custom_components.tuya_local.generic.climate import TuyaLocalClimate
+from custom_components.tuya_local.generic.light import TuyaLocalLight
+from custom_components.tuya_local.generic.lock import TuyaLocalLock
+from custom_components.tuya_local.helpers.device_config import TuyaDeviceConfig
+
+from ..const import DEHUMIDIFIER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+HVACMODE_DPS = "1"
+PRESET_DPS = "2"
+HUMIDITY_DPS = "4"
+AIRCLEAN_DPS = "5"
+FANMODE_DPS = "6"
+LOCK_DPS = "7"
+ERROR_DPS = "11"
+LIGHTOFF_DPS = "102"
+CURRENTTEMP_DPS = "103"
+CURRENTHUMID_DPS = "104"
+DEFROST_DPS = "105"
+
+PRESET_NORMAL = "0"
+PRESET_LOW = "1"
+PRESET_HIGH = "2"
+PRESET_DRY_CLOTHES = "3"
+
+ERROR_TANK = "Tank full or missing"
+
+
+class TestGoldairDehumidifier(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch("custom_components.tuya_local.device.TuyaLocalDevice")
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+        cfg = TuyaDeviceConfig("goldair_dehumidifier.yaml")
+        climate = cfg.primary_entity
+        light = None
+        lock = None
+        for e in cfg.secondary_entities():
+            if e.entity == "light":
+                light = e
+            elif e.entity == "lock":
+                lock = e
+        self.climate_name = climate.name
+        self.light_name = "missing" if light is None else light.name
+        self.lock_name = "missing" if lock is None else lock.name
+
+        self.subject = TuyaLocalClimate(self.mock_device(), climate)
+        self.light = TuyaLocalLight(self.mock_device(), light)
+        self.lock = TuyaLocalLock(self.mock_device(), lock)
+
+        self.dps = DEHUMIDIFIER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    @skip("Humidity and fan not supported yet")
+    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)
+        self.assertTrue(self.light.should_poll)
+        self.assertTrue(self.lock.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+        self.assertEqual(self.light.name, self.subject._device.name)
+        self.assertEqual(self.lock.name, self.subject._device.name)
+
+    def test_friendly_name_returns_config_name(self):
+        self.assertEqual(self.subject.friendly_name, self.climate_name)
+        self.assertEqual(self.light.friendly_name, self.light_name)
+        self.assertEqual(self.lock.friendly_name, self.lock_name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+        self.assertEqual(self.light.unique_id, self.subject._device.unique_id)
+        self.assertEqual(self.lock.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)
+        self.assertEqual(self.light.device_info, self.subject._device.device_info)
+        self.assertEqual(self.lock.device_info, self.subject._device.device_info)
+
+    @skip("Icon customisation not supported yet")
+    def test_icon_is_always_standard_when_off_without_error(self):
+        self.dps[ERROR_DPS] = None
+        self.dps[HVACMODE_DPS] = False
+
+        self.dps[AIRCLEAN_DPS] = False
+        self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
+        self.assertEqual(self.subject.icon, "mdi:air-humidifier")
+
+        self.dps[AIRCLEAN_DPS] = True
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        self.assertEqual(self.subject.icon, "mdi:air-humidifier")
+
+    @skip("Icon customisation not supported yet")
+    def test_icon_is_purifier_when_air_clean_is_active(self):
+        self.dps[ERROR_DPS] = None
+        self.dps[HVACMODE_DPS] = True
+        self.dps[AIRCLEAN_DPS] = True
+        self.assertEqual(self.subject.icon, "mdi:air-purifier")
+
+    @skip("Icon customisation not supported yet")
+    def test_icon_is_tshirt_when_dry_clothes_is_active(self):
+        self.dps[ERROR_DPS] = None
+        self.dps[HVACMODE_DPS] = True
+        self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
+        self.assertEqual(self.subject.icon, "mdi:tshirt-crew-outline")
+
+    @skip("Icon customisation not supported yet")
+    def test_icon_is_always_melting_snowflake_when_defrosting_and_tank_not_full(self):
+        self.dps[DEFROST_DPS] = True
+
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.icon, "mdi:snowflake-melt")
+
+        self.dps[HVACMODE_DPS] = True
+        self.assertEqual(self.subject.icon, "mdi:snowflake-melt")
+
+        self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
+        self.assertEqual(self.subject.icon, "mdi:snowflake-melt")
+
+        self.dps[AIRCLEAN_DPS] = True
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        self.assertEqual(self.subject.icon, "mdi:snowflake-melt")
+
+    @skip("Icon customisation not supported yet")
+    def test_icon_is_always_tank_when_tank_full_error_is_present(self):
+        self.dps[ERROR_DPS] = 8
+
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.icon, "mdi:cup-water")
+
+        self.dps[HVACMODE_DPS] = True
+        self.assertEqual(self.subject.icon, "mdi:cup-water")
+
+        self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
+        self.assertEqual(self.subject.icon, "mdi:cup-water")
+
+        self.dps[AIRCLEAN_DPS] = True
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        self.assertEqual(self.subject.icon, "mdi:cup-water")
+
+        self.dps[DEFROST_DPS] = True
+        self.assertEqual(self.subject.icon, "mdi:cup-water")
+
+    @skip("Humidity not supported yet")
+    def test_current_humidity(self):
+        self.dps[CURRENTHUMID_DPS] = 47
+        self.assertEqual(self.subject.current_humidity, 47)
+
+    @skip("Humidity not supported yet")
+    def test_min_target_humidity(self):
+        self.assertEqual(self.subject.min_humidity, 30)
+
+    @skip("Humidity not supported yet")
+    def test_max_target_humidity(self):
+        self.assertEqual(self.subject.max_humidity, 80)
+
+    @skip("Humidity not supported yet")
+    def test_target_humidity_in_normal_preset(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        self.dps[HUMIDITY_DPS] = 53
+
+        self.assertEqual(self.subject.target_humidity, 53)
+
+    @skip("Humidity not supported yet")
+    def test_target_humidity_outside_normal_preset(self):
+        self.dps[HUMIDITY_DPS] = 53
+
+        self.dps[PRESET_DPS] = PRESET_HIGH
+        self.assertIs(self.subject.target_humidity, None)
+
+        self.dps[PRESET_DPS] = PRESET_LOW
+        self.assertIs(self.subject.target_humidity, None)
+
+        self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
+        self.assertIs(self.subject.target_humidity, None)
+
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        self.dps[AIRCLEAN_DPS] = True
+        self.assertIs(self.subject.target_humidity, None)
+
+    @skip("Humidity not supported yet")
+    async def test_set_target_humidity_in_normal_preset_rounds_up_to_5_percent(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        async with assert_device_properties_set(
+            self.subject._device,
+            {HUMIDITY_DPS: 55},
+        ):
+            await self.subject.async_set_humidity(53)
+
+    @skip("Humidity not supported yet")
+    async def test_set_target_humidity_in_normal_preset_rounds_down_to_5_percent(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+
+        async with assert_device_properties_set(
+            self.subject._device,
+            {HUMIDITY_DPS: 50},
+        ):
+            await self.subject.async_set_humidity(52)
+
+    @skip("Humidity not supported yet")
+    async def test_set_target_humidity_raises_error_outside_of_normal_preset(self):
+        self.dps[PRESET_DPS] = 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[PRESET_DPS] = 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[PRESET_DPS] = 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[PRESET_DPS] = 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[PRESET_DPS] = PRESET_NORMAL
+        self.dps[AIRCLEAN_DPS] = 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[CURRENTTEMP_DPS] = 25
+        self.assertEqual(self.subject.current_temperature, 25)
+
+    def test_hvac_mode(self):
+        self.dps[HVACMODE_DPS] = True
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_DRY)
+
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[HVACMODE_DPS] = 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, {HVACMODE_DPS: 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, {HVACMODE_DPS: False}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_preset_mode(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        self.assertEqual(self.subject.preset_mode, "Normal")
+
+        self.dps[PRESET_DPS] = PRESET_LOW
+        self.assertEqual(self.subject.preset_mode, "Low")
+
+        self.dps[PRESET_DPS] = PRESET_HIGH
+        self.assertEqual(self.subject.preset_mode, "High")
+
+        self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
+        self.assertEqual(self.subject.preset_mode, "Dry clothes")
+
+        self.dps[PRESET_DPS] = None
+        self.assertEqual(self.subject.preset_mode, None)
+
+    @skip("Conditions not supported yet")
+    def test_air_clean_is_surfaced_in_preset_mode(self):
+        self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
+        self.dps[AIRCLEAN_DPS] = True
+
+        self.assertEqual(self.subject.preset_mode, "Air clean")
+
+    @skip("Conditions not supported yet")
+    def test_preset_modes(self):
+        self.assertEqual(
+            self.subject.preset_modes,
+            [
+                "Normal",
+                "Low",
+                "High",
+                "Dry clothes",
+                "Air clean",
+            ],
+        )
+
+    async def test_set_preset_mode_to_normal(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PRESET_DPS: PRESET_NORMAL,
+            },
+        ):
+            await self.subject.async_set_preset_mode("Normal")
+            self.subject._device.anticipate_property_value.assert_not_called()
+
+    @skip("Fan and conditions not supported yet")
+    async def test_set_preset_mode_to_low(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PRESET_DPS: PRESET_LOW,
+            },
+        ):
+            await self.subject.async_set_preset_mode("Low")
+            self.subject._device.anticipate_property_value.assert_called_once_with(
+                FANMODE_DPS, "1"
+            )
+
+    @skip("Fan and conditions not supported yet")
+    async def test_set_preset_mode_to_high(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PRESET_DPS: PRESET_HIGH,
+            },
+        ):
+            await self.subject.async_set_preset_mode("High")
+            self.subject._device.anticipate_property_value.assert_called_once_with(
+                FANMODE_DPS, "3"
+            )
+
+    @skip("Fan and conditions not supported yet")
+    async def test_set_preset_mode_to_dry_clothes(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PRESET_DPS: PRESET_DRY_CLOTHES,
+            },
+        ):
+            await self.subject.async_set_preset_mode("Dry clothes")
+            self.subject._device.anticipate_property_value.assert_called_once_with(
+                FANMODE_DPS, "3"
+            )
+
+    @skip("Conditions not supported yet")
+    async def test_set_preset_mode_to_air_clean(self):
+        async with assert_device_properties_set(
+            self.subject._device, {AIRCLEAN_DPS: True}
+        ):
+            await self.subject.async_set_preset_mode("Air clean")
+            self.subject._device.anticipate_property_value.assert_called_once_with(
+                FANMODE_DPS, "1"
+            )
+
+    @skip("Fan and conditions not supported yet")
+    def test_fan_mode_is_forced_to_high_in_high_dry_clothes_air_clean_presets(self):
+        self.dps[FANMODE_DPS] = "1"
+        self.dps[PRESET_DPS] = PRESET_HIGH
+        self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+
+        self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
+        self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        self.dps[AIRCLEAN_DPS] = True
+        self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+
+    @skip("Fan and conditions not supported yet")
+    def test_fan_mode_is_forced_to_low_in_low_preset(self):
+        self.dps[FANMODE_DPS] = "3"
+        self.dps[PRESET_DPS] = PRESET_LOW
+
+        self.assertEqual(self.subject.fan_mode, FAN_LOW)
+
+    @skip("Fan and conditions not supported yet")
+    def test_fan_mode_reflects_dps_mode_in_normal_preset(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        self.dps[FANMODE_DPS] = "1"
+        self.assertEqual(self.subject.fan_mode, FAN_LOW)
+
+        self.dps[FANMODE_DPS] = "3"
+        self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+
+        self.dps[FANMODE_DPS] = None
+        self.assertEqual(self.subject.fan_mode, None)
+
+    @skip("Fan and conditions not supported yet")
+    def test_fan_modes_reflect_preset_mode(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        self.assertEqual(self.subject.fan_modes, [FAN_LOW, FAN_HIGH])
+
+        self.dps[PRESET_DPS] = PRESET_LOW
+        self.assertEqual(self.subject.fan_modes, [FAN_LOW])
+
+        self.dps[PRESET_DPS] = PRESET_HIGH
+        self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
+
+        self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
+        self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
+
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        self.dps[AIRCLEAN_DPS] = True
+        self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
+
+        self.dps[PRESET_DPS] = None
+        self.dps[AIRCLEAN_DPS] = False
+        self.assertEqual(self.subject.fan_modes, [])
+
+    @skip("Fan not supported yet")
+    async def test_set_fan_mode_to_low_succeeds_in_normal_preset(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        async with assert_device_properties_set(
+            self.subject._device,
+            {FANMODE_DPS: "1"},
+        ):
+            await self.subject.async_set_fan_mode(FAN_LOW)
+
+    @skip("Fan not supported yet")
+    async def test_set_fan_mode_to_high_succeeds_in_normal_preset(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        async with assert_device_properties_set(
+            self.subject._device,
+            {FANMODE_DPS: "3"},
+        ):
+            await self.subject.async_set_fan_mode(FAN_HIGH)
+
+    @skip("Fan not supported yet")
+    async def test_set_fan_mode_fails_with_invalid_mode(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        with self.assertRaisesRegex(ValueError, "Invalid fan mode: something"):
+            await self.subject.async_set_fan_mode("something")
+
+    @skip("Fan and conditions not supported yet")
+    async def test_set_fan_mode_fails_outside_normal_preset(self):
+        self.dps[PRESET_DPS] = 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[PRESET_DPS] = 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[PRESET_DPS] = 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[PRESET_DPS] = PRESET_NORMAL
+        self.dps[AIRCLEAN_DPS] = 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)
+
+    @skip("Redirection supported yet")
+    def test_tank_full_or_missing(self):
+        self.dps[ERROR_DPS] = None
+        self.assertEqual(self.subject.tank_full_or_missing, False)
+
+        self.dps[ERROR_DPS] = 8
+        self.assertEqual(self.subject.tank_full_or_missing, True)
+
+    @skip("Defrosting not supported yet")
+    def test_defrosting(self):
+        self.dps[DEFROST_DPS] = False
+        self.assertEqual(self.subject.defrosting, False)
+
+        self.dps[DEFROST_DPS] = True
+        self.assertEqual(self.subject.defrosting, True)
+
+    @skip("Virtual attributes not supported yet.")
+    def test_device_state_attributes(self):
+        self.dps[ERROR_DPS] = None
+        self.dps[DEFROST_DPS] = False
+        self.assertCountEqual(
+            self.subject.device_state_attributes,
+            {
+                "error_code": None,
+                "error": STATE_UNAVAILABLE,
+                "defrosting": False,
+            },
+        )
+
+        self.dps[ERROR_DPS] = 8
+        self.dps[DEFROST_DPS] = False
+        self.assertCountEqual(
+            self.subject.device_state_attributes,
+            {
+                "error": ERROR_TANK,
+                "error_code": 8,
+                "defrosting": False,
+            },
+        )
+
+        self.dps[ERROR_DPS] = None
+        self.dps[DEFROST_DPS] = True
+        self.assertCountEqual(
+            self.subject.device_state_attributes,
+            {
+                "error_code": None,
+                "error": STATE_UNAVAILABLE,
+                "defrosting": True,
+            },
+        )
+
+        self.dps[ERROR_DPS] = 8
+        self.dps[DEFROST_DPS] = True
+        self.assertCountEqual(
+            self.subject.device_state_attributes,
+            {
+                "error": ERROR_TANK,
+                "error_code": 8,
+                "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()
+
+    def test_lock_was_created(self):
+        self.assertIsInstance(self.lock, TuyaLocalLock)
+
+    def test_lock_is_same_device(self):
+        self.assertEqual(self.lock._device, self.subject._device)
+
+    def test_lock_state(self):
+        self.dps[LOCK_DPS] = True
+        self.assertEqual(self.lock.state, STATE_LOCKED)
+
+        self.dps[LOCK_DPS] = False
+        self.assertEqual(self.lock.state, STATE_UNLOCKED)
+
+        self.dps[LOCK_DPS] = None
+        self.assertEqual(self.lock.state, STATE_UNAVAILABLE)
+
+    def test_lock_is_locked(self):
+        self.dps[LOCK_DPS] = True
+        self.assertTrue(self.lock.is_locked)
+
+        self.dps[LOCK_DPS] = False
+        self.assertFalse(self.lock.is_locked)
+
+        self.dps[LOCK_DPS] = None
+        self.assertFalse(self.lock.is_locked)
+
+    async def async_test_lock_locks(self):
+        async with assert_device_properties_set(self.lock._device, {LOCK_DPS: True}):
+            await self.subject.async_lock()
+
+    async def async_test_lock_unlocks(self):
+        async with assert_device_properties_set(self.lock._device, {LOCK_DPS: False}):
+            await self.subject.async_unlock()
+
+    async def async_test_lock_update(self):
+        result = AsyncMock()
+        self.lock._device.async_refresh.return_value = result()
+
+        await self.lock.async_update()
+
+        self.lock._device.async_refresh.assert_called_once()
+        result.assert_awaited()
+
+    def test_light_was_created(self):
+        self.assertIsInstance(self.light, TuyaLocalLight)
+
+    def test_light_is_same_device(self):
+        self.assertEqual(self.light._device, self.subject._device)
+
+    def test_light_icon(self):
+        self.dps[LIGHTOFF_DPS] = False
+        self.assertEqual(self.light.icon, "mdi:led-on")
+
+        self.dps[LIGHTOFF_DPS] = True
+        self.assertEqual(self.light.icon, "mdi:led-off")
+
+    def test_light_is_on(self):
+        self.dps[LIGHTOFF_DPS] = False
+        self.assertEqual(self.light.is_on, True)
+
+        self.dps[LIGHTOFF_DPS] = True
+        self.assertEqual(self.light.is_on, False)
+
+    def test_light_state_attributes(self):
+        self.assertEqual(self.light.device_state_attributes, {})
+
+    async def test_light_turn_on(self):
+        async with assert_device_properties_set(
+            self.light._device, {LIGHTOFF_DPS: False}
+        ):
+            await self.light.async_turn_on()
+
+    async def test_light_turn_off(self):
+        async with assert_device_properties_set(
+            self.light._device, {LIGHTOFF_DPS: True}
+        ):
+            await self.light.async_turn_off()
+
+    async def test_toggle_turns_the_light_on_when_it_was_off(self):
+        self.dps[LIGHTOFF_DPS] = True
+
+        async with assert_device_properties_set(
+            self.light._device, {LIGHTOFF_DPS: False}
+        ):
+            await self.light.async_toggle()
+
+    async def test_toggle_turns_the_light_off_when_it_was_on(self):
+        self.dps[LIGHTOFF_DPS] = False
+
+        async with assert_device_properties_set(
+            self.light._device, {LIGHTOFF_DPS: True}
+        ):
+            await self.light.async_toggle()
+
+    async def test_light_update(self):
+        result = AsyncMock()
+        self.light._device.async_refresh.return_value = result()
+
+        await self.light.async_update()
+
+        self.light._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 343 - 0
tests/devices/test_goldair_fan.py

@@ -0,0 +1,343 @@
+from unittest import IsolatedAsyncioTestCase, skip
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.climate.const import (
+    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 STATE_UNAVAILABLE
+
+from custom_components.tuya_local.generic.climate import TuyaLocalClimate
+from custom_components.tuya_local.generic.light import TuyaLocalLight
+from custom_components.tuya_local.helpers.device_config import TuyaDeviceConfig
+
+from ..const import FAN_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+HVACMODE_DPS = "1"
+FANMODE_DPS = "2"
+PRESET_DPS = "3"
+SWING_DPS = "8"
+UNKNOWN_DPS = "11"
+LIGHT_DPS = "101"
+
+
+class TestGoldairFan(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch("custom_components.tuya_local.device.TuyaLocalDevice")
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+        cfg = TuyaDeviceConfig("goldair_fan.yaml")
+        climate = cfg.primary_entity
+        light = None
+        for e in cfg.secondary_entities():
+            if e.entity == "light":
+                light = e
+        self.climate_name = climate.name
+        self.light_name = light.name
+
+        self.subject = TuyaLocalClimate(self.mock_device(), climate)
+        self.light = TuyaLocalLight(self.mock_device(), light)
+
+        self.dps = FAN_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    @skip("Fan and swing modes are not supported yet.")
+    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)
+        self.assertTrue(self.light.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+        self.assertEqual(self.light.name, self.subject._device.name)
+
+    def test_friendly_name_returns_config_name(self):
+        self.assertEqual(self.subject.friendly_name, self.climate_name)
+        self.assertEqual(self.light.friendly_name, self.light_name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+        self.assertEqual(self.light.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)
+        self.assertEqual(self.light.device_info, self.subject._device.device_info)
+
+    @skip("Icon customisation not supported yet")
+    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[HVACMODE_DPS] = True
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_FAN_ONLY)
+
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[HVACMODE_DPS] = 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, {HVACMODE_DPS: 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, {HVACMODE_DPS: False}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_preset_mode(self):
+        self.dps[PRESET_DPS] = "normal"
+        self.assertEqual(self.subject.preset_mode, "normal")
+
+        self.dps[PRESET_DPS] = "nature"
+        self.assertEqual(self.subject.preset_mode, PRESET_ECO)
+
+        self.dps[PRESET_DPS] = PRESET_SLEEP
+        self.assertEqual(self.subject.preset_mode, PRESET_SLEEP)
+
+        self.dps[PRESET_DPS] = None
+        self.assertIs(self.subject.preset_mode, None)
+
+    def test_preset_modes(self):
+        self.assertCountEqual(
+            self.subject.preset_modes, ["normal", PRESET_ECO, PRESET_SLEEP]
+        )
+
+    async def test_set_preset_mode_to_normal(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: "normal"},
+        ):
+            await self.subject.async_set_preset_mode("normal")
+
+    async def test_set_preset_mode_to_eco(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: "nature"},
+        ):
+            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,
+            {PRESET_DPS: PRESET_SLEEP},
+        ):
+            await self.subject.async_set_preset_mode(PRESET_SLEEP)
+
+    @skip("Swing mode not supported yet")
+    def test_swing_mode(self):
+        self.dps[SWING_DPS] = False
+        self.assertEqual(self.subject.swing_mode, SWING_OFF)
+
+        self.dps[SWING_DPS] = True
+        self.assertEqual(self.subject.swing_mode, SWING_HORIZONTAL)
+
+        self.dps[SWING_DPS] = None
+        self.assertIs(self.subject.swing_mode, None)
+
+    @skip("Swing mode not supported yet")
+    def test_swing_modes(self):
+        self.assertCountEqual(self.subject.swing_modes, [SWING_OFF, SWING_HORIZONTAL])
+
+    @skip("Swing mode not supported yet")
+    async def test_set_swing_mode_to_off(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {SWING_DPS: False},
+        ):
+            await self.subject.async_set_swing_mode(SWING_OFF)
+
+    @skip("Swing mode not supported yet")
+    async def test_set_swing_mode_to_horizontal(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {SWING_DPS: True},
+        ):
+            await self.subject.async_set_swing_mode(SWING_HORIZONTAL)
+
+    @skip("Fan modes and conditions not supported yet")
+    def test_fan_modes(self):
+        self.dps[PRESET_DPS] = "normal"
+        self.assertCountEqual(self.subject.fan_modes, list(range(1, 13)))
+
+        self.dps[PRESET_DPS] = "nature"
+        self.assertCountEqual(self.subject.fan_modes, [1, 2, 3])
+
+        self.dps[PRESET_DPS] = PRESET_SLEEP
+        self.assertCountEqual(self.subject.fan_modes, [1, 2, 3])
+
+        self.dps[PRESET_DPS] = None
+        self.assertEqual(self.subject.fan_modes, [])
+
+    @skip("Fan modes and conditions not supported yet")
+    def test_fan_mode_for_normal_preset(self):
+        self.dps[PRESET_DPS] = "normal"
+
+        self.dps[FANMODE_DPS] = "1"
+        self.assertEqual(self.subject.fan_mode, 1)
+
+        self.dps[FANMODE_DPS] = "6"
+        self.assertEqual(self.subject.fan_mode, 6)
+
+        self.dps[FANMODE_DPS] = "12"
+        self.assertEqual(self.subject.fan_mode, 12)
+
+        self.dps[FANMODE_DPS] = None
+        self.assertEqual(self.subject.fan_mode, None)
+
+    @skip("Fan modes and conditions not supported yet")
+    async def test_set_fan_mode_for_normal_preset(self):
+        self.dps[PRESET_DPS] = "normal"
+
+        async with assert_device_properties_set(
+            self.subject._device,
+            {FANMODE_DPS: "6"},
+        ):
+            await self.subject.async_set_fan_mode(6)
+
+    @skip("Fan modes and conditions not supported yet")
+    def test_fan_mode_for_eco_preset(self):
+        self.dps[PRESET_DPS] = "nature"
+
+        self.dps[FANMODE_DPS] = "4"
+        self.assertEqual(self.subject.fan_mode, 1)
+
+        self.dps[FANMODE_DPS] = "8"
+        self.assertEqual(self.subject.fan_mode, 2)
+
+        self.dps[FANMODE_DPS] = "12"
+        self.assertEqual(self.subject.fan_mode, 3)
+
+        self.dps[FANMODE_DPS] = None
+        self.assertEqual(self.subject.fan_mode, None)
+
+    @skip("Fan modes and conditions not supported yet")
+    async def test_set_fan_mode_for_eco_preset(self):
+        self.dps[PRESET_DPS] = "nature"
+
+        async with assert_device_properties_set(
+            self.subject._device,
+            {FANMODE_DPS: "4"},
+        ):
+            await self.subject.async_set_fan_mode(1)
+
+    @skip("Fan modes and conditions not supported yet")
+    def test_fan_mode_for_sleep_preset(self):
+        self.dps[PRESET_DPS] = PRESET_SLEEP
+
+        self.dps[FANMODE_DPS] = "4"
+        self.assertEqual(self.subject.fan_mode, 1)
+
+        self.dps[FANMODE_DPS] = "8"
+        self.assertEqual(self.subject.fan_mode, 2)
+
+        self.dps[FANMODE_DPS] = "12"
+        self.assertEqual(self.subject.fan_mode, 3)
+
+        self.dps[FANMODE_DPS] = None
+        self.assertEqual(self.subject.fan_mode, None)
+
+    @skip("Fan modes and conditions not supported yet")
+    async def test_set_fan_mode_for_sleep_preset(self):
+        self.dps[PRESET_DPS] = PRESET_SLEEP
+
+        async with assert_device_properties_set(
+            self.subject._device,
+            {FANMODE_DPS: "8"},
+        ):
+            await self.subject.async_set_fan_mode(2)
+
+    @skip("Fan modes and conditions not supported yet")
+    async def test_set_fan_mode_does_nothing_when_preset_mode_is_not_set(self):
+        self.dps[PRESET_DPS] = 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()
+
+    def test_light_was_created(self):
+        self.assertIsInstance(self.light, TuyaLocalLight)
+
+    def test_light_is_same_device(self):
+        self.assertEqual(self.light._device, self.subject._device)
+
+    def test_light_icon(self):
+        self.dps[LIGHT_DPS] = True
+        self.assertEqual(self.light.icon, "mdi:led-on")
+
+        self.dps[LIGHT_DPS] = False
+        self.assertEqual(self.light.icon, "mdi:led-off")
+
+    def test_light_is_on(self):
+        self.dps[LIGHT_DPS] = True
+        self.assertEqual(self.light.is_on, True)
+
+        self.dps[LIGHT_DPS] = False
+        self.assertEqual(self.light.is_on, False)
+
+    def test_light_state_attributes(self):
+        self.assertEqual(self.light.device_state_attributes, {})
+
+    async def test_light_turn_on(self):
+        async with assert_device_properties_set(self.light._device, {LIGHT_DPS: True}):
+            await self.light.async_turn_on()
+
+    async def test_light_turn_off(self):
+        async with assert_device_properties_set(self.light._device, {LIGHT_DPS: False}):
+            await self.light.async_turn_off()
+
+    async def test_toggle_turns_the_light_on_when_it_was_off(self):
+        self.dps[LIGHT_DPS] = False
+
+        async with assert_device_properties_set(self.light._device, {LIGHT_DPS: True}):
+            await self.light.async_toggle()
+
+    async def test_toggle_turns_the_light_off_when_it_was_on(self):
+        self.dps[LIGHT_DPS] = True
+
+        async with assert_device_properties_set(self.light._device, {LIGHT_DPS: False}):
+            await self.light.async_toggle()
+
+    async def test_light_update(self):
+        result = AsyncMock()
+        self.light._device.async_refresh.return_value = result()
+
+        await self.light.async_update()
+
+        self.light._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 486 - 0
tests/devices/test_goldair_gpph_heater.py

@@ -0,0 +1,486 @@
+from unittest import IsolatedAsyncioTestCase, skip
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.climate.const import (
+    HVAC_MODE_HEAT,
+    HVAC_MODE_OFF,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_SWING_MODE,
+    SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED
+
+from homeassistant.const import STATE_UNAVAILABLE
+
+from custom_components.tuya_local.generic.climate import TuyaLocalClimate
+from custom_components.tuya_local.generic.light import TuyaLocalLight
+from custom_components.tuya_local.generic.lock import TuyaLocalLock
+from custom_components.tuya_local.helpers.device_config import TuyaDeviceConfig
+
+from ..const import GPPH_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+HVACMODE_DPS = "1"
+TEMPERATURE_DPS = "2"
+CURRENTTEMP_DPS = "3"
+PRESET_DPS = "4"
+LOCK_DPS = "6"
+ERROR_DPS = "12"
+POWERLEVEL_DPS = "101"
+TIMER_DPS = "102"
+TIMERACT_DPS = "103"
+LIGHT_DPS = "104"
+SWING_DPS = "105"
+ECOTEMP_DPS = "106"
+
+
+class TestGoldairHeater(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch("custom_components.tuya_local.device.TuyaLocalDevice")
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+        cfg = TuyaDeviceConfig("goldair_gpph_heater.yaml")
+        climate = cfg.primary_entity
+        light = None
+        lock = None
+        for e in cfg.secondary_entities():
+            if e.entity == "light":
+                light = e
+            elif e.entity == "lock":
+                lock = e
+        self.climate_name = climate.name
+        self.light_name = "missing" if light is None else light.name
+        self.lock_name = "missing" if lock is None else lock.name
+
+        self.subject = TuyaLocalClimate(self.mock_device(), climate)
+        self.light = TuyaLocalLight(self.mock_device(), light)
+        self.lock = TuyaLocalLock(self.mock_device(), lock)
+
+        self.dps = GPPH_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    @skip("Swing mode not supported yet")
+    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)
+        self.assertTrue(self.light.should_poll)
+        self.assertTrue(self.lock.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+        self.assertEqual(self.light.name, self.subject._device.name)
+        self.assertEqual(self.lock.name, self.subject._device.name)
+
+    def test_friendly_name_returns_config_name(self):
+        self.assertEqual(self.subject.friendly_name, self.climate_name)
+        self.assertEqual(self.light.friendly_name, self.light_name)
+        self.assertEqual(self.lock.friendly_name, self.lock_name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+        self.assertEqual(self.light.unique_id, self.subject._device.unique_id)
+        self.assertEqual(self.lock.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)
+        self.assertEqual(self.light.device_info, self.subject._device.device_info)
+        self.assertEqual(self.lock.device_info, self.subject._device.device_info)
+
+    @skip("Icon customisation not yet supported")
+    def test_icon(self):
+        self.dps[HVACMODE_DPS] = True
+        self.assertEqual(self.subject.icon, "mdi:radiator")
+
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.icon, "mdi:radiator-disabled")
+
+        self.dps[HVACMODE_DPS] = True
+        self.dps[POWERLEVEL_DPS] = "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[TEMPERATURE_DPS] = 25
+        self.dps[PRESET_DPS] = "C"
+        self.assertEqual(self.subject.target_temperature, 25)
+
+    @skip("Conditional redirection not supported.")
+    def test_target_temperature_in_eco_and_af_modes(self):
+        self.dps[TEMPERATURE_DPS] = 25
+        self.dps[ECOTEMP_DPS] = 15
+
+        self.dps[PRESET_DPS] = "ECO"
+        self.assertEqual(self.subject.target_temperature, 15)
+
+        self.dps[PRESET_DPS] = "AF"
+        self.assertIs(self.subject.target_temperature, None)
+
+    def test_target_temperature_step(self):
+        self.assertEqual(self.subject.target_temperature_step, 1)
+
+    @skip("Conditional ranges not yet implemented")
+    def test_minimum_temperature(self):
+        self.dps[PRESET_DPS] = "C"
+        self.assertEqual(self.subject.min_temp, 5)
+
+        self.dps[PRESET_DPS] = "ECO"
+        self.assertEqual(self.subject.min_temp, 5)
+
+        self.dps[PRESET_DPS] = "AF"
+        self.assertIs(self.subject.min_temp, None)
+
+    @skip("Conditional ranges not yet implemented")
+    def test_maximum_target_temperature(self):
+        self.dps[PRESET_DPS] = "C"
+        self.assertEqual(self.subject.max_temp, 35)
+
+        self.dps[PRESET_DPS] = "ECO"
+        self.assertEqual(self.subject.max_temp, 21)
+
+        self.dps[PRESET_DPS] = "AF"
+        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, {TEMPERATURE_DPS: 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, {PRESET_DPS: "C"}
+        ):
+            await self.subject.async_set_temperature(preset_mode="comfort")
+
+    async def test_legacy_set_temperature_with_both_properties(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                TEMPERATURE_DPS: 25,
+                PRESET_DPS: "C",
+            },
+        ):
+            await self.subject.async_set_temperature(
+                temperature=25, preset_mode="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[PRESET_DPS] = "C"
+
+        async with assert_device_properties_set(
+            self.subject._device, {TEMPERATURE_DPS: 25}
+        ):
+            await self.subject.async_set_target_temperature(25)
+
+    @skip("Redirection not yet supported")
+    async def test_set_target_temperature_in_eco_mode(self):
+        self.dps[PRESET_DPS] = "ECO"
+
+        async with assert_device_properties_set(
+            self.subject._device, {ECOTEMP_DPS: 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,
+            {TEMPERATURE_DPS: 25},
+        ):
+            await self.subject.async_set_target_temperature(24.6)
+
+    @skip("Conditional ranges not supported yet")
+    async def test_set_target_temperature_fails_outside_valid_range_in_comfort(self):
+        self.dps[PRESET_DPS] = "C"
+
+        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)
+
+    @skip("Conditional ranges not supported yet")
+    async def test_set_target_temperature_fails_outside_valid_range_in_eco(self):
+        self.dps[PRESET_DPS] = "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)
+
+    @skip("Conditional ranges not supported yet")
+    async def test_set_target_temperature_fails_in_anti_freeze(self):
+        self.dps[PRESET_DPS] = "AF"
+
+        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[CURRENTTEMP_DPS] = 25
+        self.assertEqual(self.subject.current_temperature, 25)
+
+    def test_hvac_mode(self):
+        self.dps[HVACMODE_DPS] = True
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_HEAT)
+
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[HVACMODE_DPS] = None
+        self.assertEqual(self.subject.hvac_mode, STATE_UNAVAILABLE)
+
+    def test_hvac_modes(self):
+        self.assertCountEqual(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, {HVACMODE_DPS: 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, {HVACMODE_DPS: False}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_preset_mode(self):
+        self.dps[PRESET_DPS] = "C"
+        self.assertEqual(self.subject.preset_mode, "comfort")
+
+        self.dps[PRESET_DPS] = "ECO"
+        self.assertEqual(self.subject.preset_mode, "eco")
+
+        self.dps[PRESET_DPS] = "AF"
+        self.assertEqual(self.subject.preset_mode, "away")
+
+        self.dps[PRESET_DPS] = None
+        self.assertIs(self.subject.preset_mode, None)
+
+    def test_preset_modes(self):
+        self.assertCountEqual(self.subject.preset_modes, ["comfort", "eco", "away"])
+
+    async def test_set_preset_mode_to_comfort(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: "C"},
+        ):
+            await self.subject.async_set_preset_mode("comfort")
+
+    async def test_set_preset_mode_to_eco(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: "ECO"},
+        ):
+            await self.subject.async_set_preset_mode("eco")
+
+    async def test_set_preset_mode_to_anti_freeze(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: "AF"},
+        ):
+            await self.subject.async_set_preset_mode("away")
+
+    @skip("Swing mode and conditional redirection not yet supported")
+    def test_power_level_returns_user_power_level(self):
+        self.dps[SWING_DPS] = "user"
+
+        self.dps[POWERLEVEL_DPS] = "stop"
+        self.assertEqual(self.subject.swing_mode, "Stop")
+
+        self.dps[POWERLEVEL_DPS] = "3"
+        self.assertEqual(self.subject.swing_mode, "3")
+
+        self.dps[POWERLEVEL_DPS] = None
+        self.assertIs(self.subject.swing_mode, None)
+
+    @skip("Swing mode not supported yet")
+    def test_non_user_swing_mode(self):
+        self.dps[SWING_DPS] = "stop"
+        self.assertEqual(self.subject.swing_mode, "Stop")
+
+        self.dps[SWING_DPS] = "auto"
+        self.assertEqual(self.subject.swing_mode, "Auto")
+
+        self.dps[SWING_DPS] = None
+        self.assertIs(self.subject.swing_mode, None)
+
+    @skip("Swing mode and conditional redirection not supported yet")
+    def test_swing_modes(self):
+        self.assertCountEqual(
+            self.subject.swing_modes,
+            ["Stop", "1", "2", "3", "4", "5", "Auto"],
+        )
+
+    @skip("Swing mode and conditional redirection not supported yet")
+    async def test_set_power_level_to_stop(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWERLEVEL_DPS: "stop"},
+        ):
+            await self.subject.async_set_swing_mode("Stop")
+
+    @skip("Swing mode and conditional redirection not supported yet")
+    async def test_set_swing_mode_to_auto(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {SWING_DPS: "auto"},
+        ):
+            await self.subject.async_set_swing_mode("Auto")
+
+    @skip("Swing mode and conditional redirection not supported yet")
+    async def test_set_power_level_to_numeric_value(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWERLEVEL_DPS: "3"},
+        ):
+            await self.subject.async_set_swing_mode("3")
+
+    @skip("Swing mode and conditional redirection not supported yet")
+    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")
+
+    @skip("Hidden dps not supported yet")
+    def test_device_state_attributes(self):
+        self.dps[ERROR_DPS] = "something"
+        self.dps[TIMER_DPS] = 5
+        self.dps[TIMERACT_DPS] = True
+        self.dps[POWERLEVEL_DPS] = 4
+
+        self.assertCountEqual(
+            self.subject.device_state_attributes,
+            {
+                "error": "something",
+                "timer": 5,
+                "timer_mode": True,
+                "power_level": 4,
+            },
+        )
+
+    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()
+
+    def test_lock_was_created(self):
+        self.assertIsInstance(self.lock, TuyaLocalLock)
+
+    def test_lock_is_same_device(self):
+        self.assertEqual(self.lock._device, self.subject._device)
+
+    def test_lock_state(self):
+        self.dps[LOCK_DPS] = True
+        self.assertEqual(self.lock.state, STATE_LOCKED)
+
+        self.dps[LOCK_DPS] = False
+        self.assertEqual(self.lock.state, STATE_UNLOCKED)
+
+        self.dps[LOCK_DPS] = None
+        self.assertEqual(self.lock.state, STATE_UNAVAILABLE)
+
+    def test_lock_is_locked(self):
+        self.dps[LOCK_DPS] = True
+        self.assertTrue(self.lock.is_locked)
+
+        self.dps[LOCK_DPS] = False
+        self.assertFalse(self.lock.is_locked)
+
+        self.dps[LOCK_DPS] = None
+        self.assertFalse(self.lock.is_locked)
+
+    async def async_test_lock_locks(self):
+        async with assert_device_properties_set(self.lock._device, {LOCK_DPS: True}):
+            await self.subject.async_lock()
+
+    async def async_test_lock_unlocks(self):
+        async with assert_device_properties_set(self.lock._device, {LOCK_DPS: False}):
+            await self.subject.async_unlock()
+
+    async def async_test_lock_update(self):
+        result = AsyncMock()
+        self.lock._device.async_refresh.return_value = result()
+
+        await self.lock.async_update()
+
+        self.lock._device.async_refresh.assert_called_once()
+        result.assert_awaited()
+
+    def test_light_was_created(self):
+        self.assertIsInstance(self.light, TuyaLocalLight)
+
+    def test_light_is_same_device(self):
+        self.assertEqual(self.light._device, self.subject._device)
+
+    def test_light_icon(self):
+        self.dps[LIGHT_DPS] = True
+        self.assertEqual(self.light.icon, "mdi:led-on")
+
+        self.dps[LIGHT_DPS] = False
+        self.assertEqual(self.light.icon, "mdi:led-off")
+
+    def test_light_is_on(self):
+        self.dps[LIGHT_DPS] = True
+        self.assertEqual(self.light.is_on, True)
+
+        self.dps[LIGHT_DPS] = False
+        self.assertEqual(self.light.is_on, False)
+
+    def test_light_state_attributes(self):
+        self.assertEqual(self.light.device_state_attributes, {})
+
+    async def test_light_turn_on(self):
+        async with assert_device_properties_set(self.light._device, {LIGHT_DPS: True}):
+            await self.light.async_turn_on()
+
+    async def test_light_turn_off(self):
+        async with assert_device_properties_set(self.light._device, {LIGHT_DPS: False}):
+            await self.light.async_turn_off()
+
+    async def test_toggle_turns_the_light_on_when_it_was_off(self):
+        self.dps[LIGHT_DPS] = False
+
+        async with assert_device_properties_set(self.light._device, {LIGHT_DPS: True}):
+            await self.light.async_toggle()
+
+    async def test_toggle_turns_the_light_off_when_it_was_on(self):
+        self.dps[LIGHT_DPS] = True
+
+        async with assert_device_properties_set(self.light._device, {LIGHT_DPS: False}):
+            await self.light.async_toggle()
+
+    async def test_light_update(self):
+        result = AsyncMock()
+        self.light._device.async_refresh.return_value = result()
+
+        await self.light.async_update()
+
+        self.light._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 2 - 2
tests/devices/test_kogan_switch.py

@@ -97,7 +97,7 @@ class TestKoganSwitch(IsolatedAsyncioTestCase):
         self.dps[VOLTAGE_DPS] = 2350
         self.dps[CURRENT_DPS] = 1234
         self.dps[POWER_DPS] = 5678
-        self.assertEqual(
+        self.assertCountEqual(
             self.subject.device_state_attributes,
             {
                 "timer": 1,
@@ -111,7 +111,7 @@ class TestKoganSwitch(IsolatedAsyncioTestCase):
         self.dps[CURRENT_DPS] = None
         self.dps[VOLTAGE_DPS] = None
         self.dps[POWER_DPS] = None
-        self.assertEqual(
+        self.assertCountEqual(
             self.subject.device_state_attributes,
             {
                 "timer": 0,

+ 2 - 2
tests/devices/test_kogan_switch2.py

@@ -97,7 +97,7 @@ class TestKoganSwitch(IsolatedAsyncioTestCase):
         self.dps[VOLTAGE_DPS] = 2350
         self.dps[CURRENT_DPS] = 1234
         self.dps[POWER_DPS] = 5678
-        self.assertEqual(
+        self.assertCountEqual(
             self.subject.device_state_attributes,
             {
                 "timer": 1,
@@ -111,7 +111,7 @@ class TestKoganSwitch(IsolatedAsyncioTestCase):
         self.dps[CURRENT_DPS] = None
         self.dps[VOLTAGE_DPS] = None
         self.dps[POWER_DPS] = None
-        self.assertEqual(
+        self.assertCountEqual(
             self.subject.device_state_attributes,
             {
                 "timer": 0,

+ 379 - 0
tests/devices/test_purline_m100_heater.py

@@ -0,0 +1,379 @@
+from unittest import IsolatedAsyncioTestCase, skip
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.climate.const import (
+    HVAC_MODE_FAN_ONLY,
+    HVAC_MODE_HEAT,
+    HVAC_MODE_OFF,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_SWING_MODE,
+    SUPPORT_TARGET_TEMPERATURE,
+    SWING_OFF,
+    SWING_VERTICAL,
+)
+from homeassistant.components.switch import DEVICE_CLASS_SWITCH
+from homeassistant.const import STATE_UNAVAILABLE
+
+from custom_components.tuya_local.generic.climate import TuyaLocalClimate
+from custom_components.tuya_local.generic.light import TuyaLocalLight
+from custom_components.tuya_local.generic.switch import TuyaLocalSwitch
+from custom_components.tuya_local.helpers.device_config import TuyaDeviceConfig
+
+from ..const import PURLINE_M100_HEATER_PAYLOAD
+from ..helpers import (
+    assert_device_properties_set,
+    assert_device_properties_set_optional,
+)
+
+HVACMODE_DPS = "1"
+TEMPERATURE_DPS = "2"
+CURRENTTEMP_DPS = "3"
+PRESET_DPS = "5"
+LIGHTOFF_DPS = "10"
+TIMERHR_DPS = "11"
+TIMER_DPS = "12"
+SWITCH_DPS = "101"
+SWING_DPS = "102"
+
+
+class TestPulineM100Heater(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch("custom_components.tuya_local.device.TuyaLocalDevice")
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+        cfg = TuyaDeviceConfig("purline_m100_heater.yaml")
+        climate = cfg.primary_entity
+        light = None
+        switch = None
+        for e in cfg.secondary_entities():
+            if e.entity == "light":
+                light = e
+            elif e.entity == "switch":
+                switch = e
+        self.climate_name = climate.name
+        self.light_name = "missing" if light is None else light.name
+        self.switch_name = "missing" if switch is None else switch.name
+        self.subject = TuyaLocalClimate(self.mock_device(), climate)
+        self.light = TuyaLocalLight(self.mock_device(), light)
+        self.switch = TuyaLocalSwitch(self.mock_device(), switch)
+
+        self.dps = PURLINE_M100_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    @skip("Swing mode not supported yet")
+    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)
+        self.assertTrue(self.light.should_poll)
+        self.assertTrue(self.switch.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+        self.assertEqual(self.light.name, self.subject._device.name)
+        self.assertEqual(self.switch.name, self.subject._device.name)
+
+    def test_friendly_name_returns_config_name(self):
+        self.assertEqual(self.subject.friendly_name, self.climate_name)
+        self.assertEqual(self.light.friendly_name, self.light_name)
+        self.assertEqual(self.switch.friendly_name, self.switch_name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+        self.assertEqual(self.light.unique_id, self.subject._device.unique_id)
+        self.assertEqual(self.switch.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)
+        self.assertEqual(self.light.device_info, self.subject._device.device_info)
+        self.assertEqual(self.switch.device_info, self.subject._device.device_info)
+
+    @skip("Icon customisation not supported yet")
+    def test_icon(self):
+        self.dps[HVACMODE_DPS] = True
+        self.dps[PRESET_DPS] = "auto"
+        self.assertEqual(self.subject.icon, "mdi:radiator")
+
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.icon, "mdi:radiator-disabled")
+
+        self.dps[HVACMODE_DPS] = True
+        self.dps[PRESET_DPS] = "off"
+        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_target_temperature(self):
+        self.dps[TEMPERATURE_DPS] = 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, {TEMPERATURE_DPS: 25}
+        ):
+            await self.subject.async_set_temperature(temperature=25)
+
+    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(self):
+        async with assert_device_properties_set(
+            self.subject._device, {TEMPERATURE_DPS: 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,
+            {TEMPERATURE_DPS: 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 \\(4\\) must be between 15 and 35"
+        ):
+            await self.subject.async_set_target_temperature(4)
+
+        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[CURRENTTEMP_DPS] = 25
+        self.assertEqual(self.subject.current_temperature, 25)
+
+    @skip("Conditions not supported yet")
+    def test_hvac_mode(self):
+        self.dps[HVACMODE_DPS] = True
+        self.dps[PRESET_DPS] = "auto"
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_HEAT)
+
+        self.dps[PRESET_DPS] = "off"
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_FAN_ONLY)
+
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[HVACMODE_DPS] = None
+        self.assertEqual(self.subject.hvac_mode, STATE_UNAVAILABLE)
+
+    @skip("Conditions not supported yet")
+    def test_hvac_modes(self):
+        self.assertCountEqual(
+            self.subject.hvac_modes,
+            [HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_FAN_ONLY],
+        )
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set_optional(
+            self.subject._device,
+            {HVACMODE_DPS: True},
+            {PRESET_DPS: "auto"},
+        ):
+            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,
+            {HVACMODE_DPS: False},
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    @skip("Conditions not supported yet")
+    async def test_turn_on_fan(self):
+        async with assert_device_properties_set_optional(
+            self.subject._device,
+            {HVACMODE_DPS: True},
+            {PRESET_DPS: "off"},
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_FAN_ONLY)
+
+    def test_preset_mode(self):
+        self.dps[PRESET_DPS] = "auto"
+        self.assertEqual(self.subject.preset_mode, "Auto")
+
+        self.dps[PRESET_DPS] = "off"
+        self.assertEqual(self.subject.preset_mode, "Fan")
+
+        self.dps[PRESET_DPS] = "4"
+        self.assertEqual(self.subject.preset_mode, "4")
+
+        self.dps[PRESET_DPS] = None
+        self.assertIs(self.subject.preset_mode, None)
+
+    def test_preset_modes(self):
+        self.assertCountEqual(
+            self.subject.preset_modes,
+            ["Fan", "1", "2", "3", "4", "5", "Auto"],
+        )
+
+    async def test_set_preset_mode_numeric(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: "3"},
+        ):
+            await self.subject.async_set_preset_mode("3")
+
+    def test_swing_mode(self):
+        self.dps[SWING_DPS] = True
+        self.assertEqual(self.subject.swing_mode, SWING_VERTICAL)
+
+        self.dps[SWING_DPS] = False
+        self.assertEqual(self.subject.swing_mode, SWING_OFF)
+
+    def test_swing_modes(self):
+        self.assertCountEqual(
+            self.subject.swing_modes,
+            [SWING_OFF, SWING_VERTICAL],
+        )
+
+    async def test_set_swing_mode_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {SWING_DPS: True}
+        ):
+            await self.subject.async_set_swing_mode(SWING_VERTICAL)
+
+    async def test_set_swing_mode_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {SWING_DPS: False}
+        ):
+            await self.subject.async_set_swing_mode(SWING_OFF)
+
+    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()
+
+    def test_light_was_created(self):
+        self.assertIsInstance(self.light, TuyaLocalLight)
+
+    def test_light_is_same_device(self):
+        self.assertEqual(self.light._device, self.subject._device)
+
+    def test_light_icon(self):
+        self.dps[LIGHTOFF_DPS] = False
+        self.assertEqual(self.light.icon, "mdi:led-on")
+
+        self.dps[LIGHTOFF_DPS] = True
+        self.assertEqual(self.light.icon, "mdi:led-off")
+
+    def test_light_is_on(self):
+        self.dps[LIGHTOFF_DPS] = False
+        self.assertEqual(self.light.is_on, True)
+
+        self.dps[LIGHTOFF_DPS] = True
+        self.assertEqual(self.light.is_on, False)
+
+    def test_light_state_attributes(self):
+        self.assertEqual(self.light.device_state_attributes, {})
+
+    async def test_light_turn_on(self):
+        async with assert_device_properties_set(
+            self.light._device, {LIGHTOFF_DPS: False}
+        ):
+            await self.light.async_turn_on()
+
+    async def test_light_turn_off(self):
+        async with assert_device_properties_set(
+            self.light._device, {LIGHTOFF_DPS: True}
+        ):
+            await self.light.async_turn_off()
+
+    async def test_toggle_turns_the_light_on_when_it_was_off(self):
+        self.dps[LIGHTOFF_DPS] = True
+
+        async with assert_device_properties_set(
+            self.light._device, {LIGHTOFF_DPS: False}
+        ):
+            await self.light.async_toggle()
+
+    async def test_toggle_turns_the_light_off_when_it_was_on(self):
+        self.dps[LIGHTOFF_DPS] = False
+
+        async with assert_device_properties_set(
+            self.light._device, {LIGHTOFF_DPS: True}
+        ):
+            await self.light.async_toggle()
+
+    async def test_light_update(self):
+        result = AsyncMock()
+        self.light._device.async_refresh.return_value = result()
+
+        await self.light.async_update()
+
+        self.light._device.async_refresh.assert_called_once()
+        result.assert_awaited()
+
+    def test_switch_was_created(self):
+        self.assertIsInstance(self.switch, TuyaLocalSwitch)
+
+    def test_switch_is_same_device(self):
+        self.assertEqual(self.switch._device, self.subject._device)
+
+    def test_switch_class_is_outlet(self):
+        self.assertEqual(self.switch.device_class, DEVICE_CLASS_SWITCH)
+
+    def test_switch_is_on(self):
+        self.dps[SWITCH_DPS] = True
+        self.assertTrue(self.switch.is_on)
+
+        self.dps[SWITCH_DPS] = False
+        self.assertFalse(self.switch.is_on)
+
+    def test_switch_is_on_when_unavailable(self):
+        self.dps[SWITCH_DPS] = None
+        self.assertEqual(self.switch.is_on, STATE_UNAVAILABLE)
+
+    async def test_switch_turn_on(self):
+        async with assert_device_properties_set(
+            self.switch._device, {SWITCH_DPS: True}
+        ):
+            await self.switch.async_turn_on()
+
+    async def test_switch_turn_off(self):
+        async with assert_device_properties_set(
+            self.switch._device, {SWITCH_DPS: False}
+        ):
+            await self.switch.async_turn_off()
+
+    async def test_toggle_turns_the_switch_on_when_it_was_off(self):
+        self.dps[SWITCH_DPS] = False
+
+        async with assert_device_properties_set(
+            self.switch._device, {SWITCH_DPS: True}
+        ):
+            await self.switch.async_toggle()
+
+    async def test_toggle_turns_the_switch_off_when_it_was_on(self):
+        self.dps[SWITCH_DPS] = True
+
+        async with assert_device_properties_set(
+            self.switch._device, {SWITCH_DPS: False}
+        ):
+            await self.switch.async_toggle()
+
+    def test_switch_state_attributes_set(self):
+        self.assertEqual(self.switch.device_state_attributes, {})

+ 1 - 99
tests/test_light.py

@@ -1,7 +1,6 @@
 """Tests for the light entity."""
 from pytest_homeassistant_custom_component.common import MockConfigEntry
-from unittest import IsolatedAsyncioTestCase
-from unittest.mock import AsyncMock, Mock, patch
+from unittest.mock import AsyncMock, Mock
 
 from custom_components.tuya_local.const import (
     CONF_DISPLAY_LIGHT,
@@ -12,14 +11,8 @@ from custom_components.tuya_local.const import (
     DOMAIN,
 )
 from custom_components.tuya_local.generic.light import TuyaLocalLight
-from custom_components.tuya_local.helpers.device_config import config_for_legacy_use
 from custom_components.tuya_local.light import async_setup_entry
 
-from .const import GPPH_HEATER_PAYLOAD
-from .helpers import assert_device_properties_set
-
-GPPH_LIGHTSWITCH_DPS = "104"
-
 
 async def test_init_entry(hass):
     """Test the initialisation."""
@@ -41,94 +34,3 @@ async def test_init_entry(hass):
     await async_setup_entry(hass, entry, m_add_entities)
     assert type(hass.data[DOMAIN]["dummy"][CONF_DISPLAY_LIGHT]) == TuyaLocalLight
     m_add_entities.assert_called_once()
-
-
-class TestTuyaLocalLight(IsolatedAsyncioTestCase):
-    def setUp(self):
-        device_patcher = patch("custom_components.tuya_local.device.TuyaLocalDevice")
-        self.addCleanup(device_patcher.stop)
-        self.mock_device = device_patcher.start()
-        gpph_config = config_for_legacy_use(CONF_TYPE_GPPH_HEATER)
-        for light in gpph_config.secondary_entities():
-            if light.entity == "light":
-                break
-        self.subject = TuyaLocalLight(self.mock_device(), light)
-        self.dps = GPPH_HEATER_PAYLOAD.copy()
-        self.light_name = light.name
-        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_friendly_name_returns_config_name(self):
-        self.assertEqual(self.subject.friendly_name, self.light_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[GPPH_LIGHTSWITCH_DPS] = True
-        self.assertEqual(self.subject.icon, "mdi:led-on")
-
-        self.dps[GPPH_LIGHTSWITCH_DPS] = False
-        self.assertEqual(self.subject.icon, "mdi:led-off")
-
-    def test_is_on(self):
-        self.dps[GPPH_LIGHTSWITCH_DPS] = True
-        self.assertEqual(self.subject.is_on, True)
-
-        self.dps[GPPH_LIGHTSWITCH_DPS] = False
-        self.assertEqual(self.subject.is_on, False)
-
-    def test_state_attributes(self):
-        self.assertEqual(self.subject.device_state_attributes, {})
-
-    async def test_turn_on(self):
-        async with assert_device_properties_set(
-            self.subject._device, {GPPH_LIGHTSWITCH_DPS: True}
-        ):
-            await self.subject.async_turn_on()
-
-    async def test_turn_off(self):
-        async with assert_device_properties_set(
-            self.subject._device, {GPPH_LIGHTSWITCH_DPS: 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[GPPH_LIGHTSWITCH_DPS] = False
-
-        async with assert_device_properties_set(
-            self.subject._device, {GPPH_LIGHTSWITCH_DPS: 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[GPPH_LIGHTSWITCH_DPS] = True
-
-        async with assert_device_properties_set(
-            self.subject._device, {GPPH_LIGHTSWITCH_DPS: 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()

+ 1 - 83
tests/test_lock.py

@@ -1,10 +1,6 @@
 """Tests for the lock entity."""
 from pytest_homeassistant_custom_component.common import MockConfigEntry
-from unittest import IsolatedAsyncioTestCase
-from unittest.mock import AsyncMock, Mock, patch
-
-from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED
-from homeassistant.const import STATE_UNAVAILABLE
+from unittest.mock import AsyncMock, Mock
 
 from custom_components.tuya_local.const import (
     CONF_CHILD_LOCK,
@@ -15,15 +11,8 @@ from custom_components.tuya_local.const import (
     DOMAIN,
 )
 from custom_components.tuya_local.generic.lock import TuyaLocalLock
-from custom_components.tuya_local.helpers.device_config import config_for_legacy_use
 from custom_components.tuya_local.lock import async_setup_entry
 
-from .const import GPPH_HEATER_PAYLOAD
-from .helpers import assert_device_properties_set
-
-
-GPPH_LOCK_DPS = "6"
-
 
 async def test_init_entry(hass):
     """Test the initialisation."""
@@ -45,74 +34,3 @@ async def test_init_entry(hass):
     await async_setup_entry(hass, entry, m_add_entities)
     assert type(hass.data[DOMAIN]["dummy"][CONF_CHILD_LOCK]) == TuyaLocalLock
     m_add_entities.assert_called_once()
-
-
-class TestTuyaLocalLock(IsolatedAsyncioTestCase):
-    def setUp(self):
-        device_patcher = patch("custom_components.tuya_local.device.TuyaLocalDevice")
-        self.addCleanup(device_patcher.stop)
-        self.mock_device = device_patcher.start()
-        gpph_config = config_for_legacy_use(CONF_TYPE_GPPH_HEATER)
-        for lock in gpph_config.secondary_entities():
-            if lock.entity == "lock":
-                break
-        self.subject = TuyaLocalLock(self.mock_device(), lock)
-        self.dps = GPPH_HEATER_PAYLOAD.copy()
-        self.lock_name = lock.name
-        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_friendly_name_returns_config_name(self):
-        self.assertEqual(self.subject.friendly_name, self.lock_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[GPPH_LOCK_DPS] = True
-        self.assertEqual(self.subject.state, STATE_LOCKED)
-
-        self.dps[GPPH_LOCK_DPS] = False
-        self.assertEqual(self.subject.state, STATE_UNLOCKED)
-
-        self.dps[GPPH_LOCK_DPS] = None
-        self.assertEqual(self.subject.state, STATE_UNAVAILABLE)
-
-    def test_state_attributes(self):
-        self.assertEqual(self.subject.device_state_attributes, {})
-
-    def test_is_locked(self):
-        self.dps[GPPH_LOCK_DPS] = True
-        self.assertTrue(self.subject.is_locked)
-
-        self.dps[GPPH_LOCK_DPS] = False
-        self.assertFalse(self.subject.is_locked)
-
-    async def test_lock(self):
-        async with assert_device_properties_set(
-            self.subject._device, {GPPH_LOCK_DPS: True}
-        ):
-            await self.subject.async_lock()
-
-    async def test_unlock(self):
-        async with assert_device_properties_set(
-            self.subject._device, {GPPH_LOCK_DPS: 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()