Quellcode durchsuchen

Issue #11: add support for two new devices.

- Pur Line Hoti M100 heater
- Garden PAC pool heatpump
Jason Rumney vor 5 Jahren
Ursprung
Commit
ea42e4b0ee

+ 27 - 5
README.md

@@ -6,7 +6,7 @@
 [![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)
 [![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)
 [![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), 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/), Andersson heaters and Eurom [heaters](https://eurom.nl/en/product-category/heating/wifi-heaters/) into Home Assistant, enabling control of setting the following parameters via the UI and the following services:
+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), 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/), Andersson heaters, Eurom [heaters](https://eurom.nl/en/product-category/heating/wifi-heaters/), Purline [heaters](https://www.purline.es/hoti-m100--ean-8436545097380.htm) and Garden PAC pool [heatpumps](https://www.iot-pool.com/en/products/bomba-de-calor-garden-pac-full-inverter) into Home Assistant, enabling control of setting the following parameters via the UI and the following services:
 
 
 ### Climate devices
 ### Climate devices
 
 
@@ -70,9 +70,24 @@ Current temperature is also displayed.
 
 
 Current temperature is also displayed.
 Current temperature is also displayed.
 
 
+**Garden PAC Pool Heatpumps**
+
+- **power** (on/off)
+- **mode** (silent/smart)
+- **target temperature** (`18`-`45` in °C)
+
+Current temperature is also displayed. Power level, operating mode are available as attributes.
+
+**Purline Hoti M100 Heaters**
+
+- **power** (heat/fan-only/off)
+- **mode** (Fan, 1-5, Auto)
+- **target temperature** (`16`-`35` in °C)
+- **swing** (on/off)
+
 ### Additional features
 ### Additional features
 
 
-**Light** (Goldair devices)
+**Light** (Goldair and Purline devices)
 
 
 - **LED display** (on/off)
 - **LED display** (on/off)
 
 
@@ -80,6 +95,10 @@ Current temperature is also displayed.
 
 
 - **Child lock** (on/off)
 - **Child lock** (on/off)
 
 
+**Open Window Detector** (Purline devices)
+
+- **Open Window Detect** (on/off)
+
 ### Switch devices
 ### Switch devices
 
 
 **Kogan Energy monitoring Smart Plug**
 **Kogan Energy monitoring Smart Plug**
@@ -108,6 +127,8 @@ Support for heaters visually matching Andersson GSH 3.2 was added based on infor
 
 
 Support for Eurom Mon Soleil 600 ceiling heaters was added by @FeikoJoosten. It is possible that this support will also work for other models such as Mon Soleil 610 wall panel heaters, and others in the range.
 Support for Eurom Mon Soleil 600 ceiling heaters was added by @FeikoJoosten. It is possible that this support will also work for other models such as Mon Soleil 610 wall panel heaters, and others in the range.
 
 
+Support for Purline Hoti M100 heaters and Garden PAC pool heatpumps were added based on information from @Xeovar on Issue #11.
+
 ---
 ---
 
 
 ## Installation
 ## Installation
@@ -151,7 +172,7 @@ tuya_local:
 
 
 #### type
 #### type
 
 
-    _(string) (Optional)_ The type of Tuya device. `auto` to automatically detect the device type, or if that doesn't work, select from the available options `heater`, `geco_heater` `gpcv_heater`, `dehumidifier`, `fan`, `kogan_heater`, `gsh_heater`, `eurom_heater` or `kogan_switch`.
+    _(string) (Optional)_ The type of Tuya device. `auto` to automatically detect the device type, or if that doesn't work, select from the available options `heater`, `geco_heater` `gpcv_heater`, `dehumidifier`, `fan`, `kogan_heater`, `gsh_heater`, `eurom_heater`, `gardenpac_heatpump`, `purline_m100_heater`  or `kogan_switch`.
 
 
     _Default value: auto_
     _Default value: auto_
 
 
@@ -169,13 +190,13 @@ tuya_local:
 
 
 #### child_lock
 #### child_lock
 
 
-    _(boolean) (Optional)_ Whether to surface this appliances's child lock as a lock device (not supported for fans, switches, or Andersson and Eurom heaters).
+    _(boolean) (Optional)_ Whether to surface this appliances's child lock as a lock device (not supported for fans, switches, or Andersson ,Eurom, Purline heaters or Garden PAC heatpumps).
 
 
     _Default value: false_
     _Default value: false_
 
 
 #### switch
 #### switch
 
 
-    _(boolean) (Optional)_ Whether to surface this device as a switch device (supported only for switches)
+    _(boolean) (Optional)_ Whether to surface this device as a switch device (supported only for switches and Purline heaters for the Open Window Detection)
 
 
 ## Heater gotchas
 ## Heater gotchas
 
 
@@ -226,3 +247,4 @@ None of this would have been possible without some foundational discovery work t
 - [botts7](https://github.com/botts7) for support towards widening Kogan SmartPlug support.
 - [botts7](https://github.com/botts7) for support towards widening Kogan SmartPlug support.
 - [awaismun](https://github.com/awaismun) for assistance in supporting Andersson heaters.
 - [awaismun](https://github.com/awaismun) for assistance in supporting Andersson heaters.
 - [FeikoJoosten](https://github.com/FeikoJoosten) for development of support for Eurom heaters.
 - [FeikoJoosten](https://github.com/FeikoJoosten) for development of support for Eurom heaters.
+- [Xeovar](https://github.com/Xeovar) for assistance in supporting Purline M100 heaters and Garden PAC pool heatpumps.

+ 8 - 0
custom_components/tuya_local/climate.py

@@ -15,6 +15,8 @@ from .const import (
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GSH_HEATER,
     CONF_TYPE_GSH_HEATER,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_HEATER,
+    CONF_TYPE_GARDENPAC_HEATPUMP,
+    CONF_TYPE_PURLINE_M100_HEATER,
     CONF_CLIMATE,
     CONF_CLIMATE,
 )
 )
 from .dehumidifier.climate import GoldairDehumidifier
 from .dehumidifier.climate import GoldairDehumidifier
@@ -24,6 +26,8 @@ from .eurom_600_heater.climate import EuromMonSoleil600Heater
 from .gpcv_heater.climate import GoldairGPCVHeater
 from .gpcv_heater.climate import GoldairGPCVHeater
 from .heater.climate import GoldairHeater
 from .heater.climate import GoldairHeater
 from .kogan_heater.climate import KoganHeater
 from .kogan_heater.climate import KoganHeater
+from .gardenpac_heatpump.climate import GardenPACPoolHeatpump
+from .purline_m100_heater.climate import PurlineM100Heater
 from .gsh_heater.climate import AnderssonGSHHeater
 from .gsh_heater.climate import AnderssonGSHHeater
 
 
 
 
@@ -54,6 +58,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
         data[CONF_CLIMATE] = KoganHeater(device)
         data[CONF_CLIMATE] = KoganHeater(device)
     elif discovery_info[CONF_TYPE] == CONF_TYPE_GSH_HEATER:
     elif discovery_info[CONF_TYPE] == CONF_TYPE_GSH_HEATER:
         data[CONF_CLIMATE] = AnderssonGSHHeater(device)
         data[CONF_CLIMATE] = AnderssonGSHHeater(device)
+    elif discovery_info[CONF_TYPE] == CONF_TYPE_GARDENPAC_HEATPUMP:
+        data[CONF_CLIMATE] = GardenPACPoolHeatpump(device)
+    elif discovery_info[CONF_TYPE] == CONF_TYPE_PURLINE_M100_HEATER:
+        data[CONF_CLIMATE] = PurlineM100Heater(device)
     else:
     else:
         raise ValueError("This device does not support working as a climate device")
         raise ValueError("This device does not support working as a climate device")
 
 

+ 7 - 3
custom_components/tuya_local/configuration.py

@@ -11,14 +11,16 @@ from .const import (
     CONF_TYPE,
     CONF_TYPE,
     CONF_TYPE_AUTO,
     CONF_TYPE_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_DEHUMIDIFIER,
+    CONF_TYPE_EUROM_600_HEATER,
     CONF_TYPE_FAN,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
     CONF_TYPE_GECO_HEATER,
-    CONF_TYPE_EUROM_600_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GSH_HEATER,
     CONF_TYPE_GSH_HEATER,
+    CONF_TYPE_GARDENPAC_HEATPUMP,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_SWITCH,
     CONF_TYPE_KOGAN_SWITCH,
+    CONF_TYPE_PURLINE_M100_HEATER,
 )
 )
 
 
 INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
 INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
@@ -31,15 +33,17 @@ INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
         "type": vol.In(
         "type": vol.In(
             [
             [
                 CONF_TYPE_AUTO,
                 CONF_TYPE_AUTO,
-                CONF_TYPE_GPPH_HEATER,
                 CONF_TYPE_DEHUMIDIFIER,
                 CONF_TYPE_DEHUMIDIFIER,
+                CONF_TYPE_EUROM_600_HEATER,
                 CONF_TYPE_FAN,
                 CONF_TYPE_FAN,
                 CONF_TYPE_GECO_HEATER,
                 CONF_TYPE_GECO_HEATER,
-                CONF_TYPE_EUROM_600_HEATER,
                 CONF_TYPE_GPCV_HEATER,
                 CONF_TYPE_GPCV_HEATER,
+                CONF_TYPE_GPPH_HEATER,
                 CONF_TYPE_GSH_HEATER,
                 CONF_TYPE_GSH_HEATER,
+                CONF_TYPE_GARDENPAC_HEATPUMP,
                 CONF_TYPE_KOGAN_HEATER,
                 CONF_TYPE_KOGAN_HEATER,
                 CONF_TYPE_KOGAN_SWITCH,
                 CONF_TYPE_KOGAN_SWITCH,
+                CONF_TYPE_PURLINE_M100_HEATER,
             ]
             ]
         ),
         ),
         "required": False,
         "required": False,

+ 2 - 0
custom_components/tuya_local/const.py

@@ -15,6 +15,8 @@ CONF_TYPE_GPCV_HEATER = "gpcv_heater"
 CONF_TYPE_KOGAN_HEATER = "kogan_heater"
 CONF_TYPE_KOGAN_HEATER = "kogan_heater"
 CONF_TYPE_KOGAN_SWITCH = "kogan_switch"
 CONF_TYPE_KOGAN_SWITCH = "kogan_switch"
 CONF_TYPE_GSH_HEATER = "gsh_heater"
 CONF_TYPE_GSH_HEATER = "gsh_heater"
+CONF_TYPE_GARDENPAC_HEATPUMP = "gardenpac_heatpump"
+CONF_TYPE_PURLINE_M100_HEATER = "purline_m100_heater"
 CONF_CLIMATE = "climate"
 CONF_CLIMATE = "climate"
 CONF_DISPLAY_LIGHT = "display_light"
 CONF_DISPLAY_LIGHT = "display_light"
 CONF_CHILD_LOCK = "child_lock"
 CONF_CHILD_LOCK = "child_lock"

+ 9 - 1
custom_components/tuya_local/device.py

@@ -13,14 +13,16 @@ from homeassistant.core import HomeAssistant
 from .const import (
 from .const import (
     API_PROTOCOL_VERSIONS,
     API_PROTOCOL_VERSIONS,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_DEHUMIDIFIER,
+    CONF_TYPE_EUROM_600_HEATER,
     CONF_TYPE_FAN,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
     CONF_TYPE_GECO_HEATER,
-    CONF_TYPE_EUROM_600_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GSH_HEATER,
     CONF_TYPE_GSH_HEATER,
+    CONF_TYPE_GARDENPAC_HEATPUMP,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_SWITCH,
     CONF_TYPE_KOGAN_SWITCH,
+    CONF_TYPE_PURLINE_M100_HEATER,
     DOMAIN,
     DOMAIN,
 )
 )
 
 
@@ -99,6 +101,9 @@ class TuyaLocalDevice(object):
         if "8" in cached_state:
         if "8" in cached_state:
             _LOGGER.info(f"Detecting {self.name} as Goldair Fan")
             _LOGGER.info(f"Detecting {self.name} as Goldair Fan")
             return CONF_TYPE_FAN
             return CONF_TYPE_FAN
+        if "10" in cached_state and "101" in cached_state:
+            _LOGGER.info(f"Detecting {self.name} as Pur Line Hoti M100 heater")
+            return CONF_TYPE_PURLINE_M100_HEATER
         if "5" in cached_state and "2" in cached_state and "4" not in cached_state:
         if "5" in cached_state and "2" in cached_state and "4" not in cached_state:
             _LOGGER.info(f"Detecting {self.name} as Eurom Mon Soleil 600 Heater")
             _LOGGER.info(f"Detecting {self.name} as Eurom Mon Soleil 600 Heater")
             return CONF_TYPE_EUROM_600_HEATER
             return CONF_TYPE_EUROM_600_HEATER
@@ -108,6 +113,9 @@ class TuyaLocalDevice(object):
         if "18" in cached_state:
         if "18" in cached_state:
             _LOGGER.info(f"Detecting {self.name} as newer type of Kogan Switch")
             _LOGGER.info(f"Detecting {self.name} as newer type of Kogan Switch")
             return CONF_TYPE_KOGAN_SWITCH
             return CONF_TYPE_KOGAN_SWITCH
+        if "106" in cached_state and "2" not in cached_state:
+            _LOGGER.info(f"Detecting {self.name} as GardenPAC Pool Heatpump")
+            return CONF_TYPE_GARDENPAC_HEATPUMP
         if "106" in cached_state:
         if "106" in cached_state:
             _LOGGER.info(f"Detecting {self.name} as Goldair GPPH Heater")
             _LOGGER.info(f"Detecting {self.name} as Goldair GPPH Heater")
             return CONF_TYPE_GPPH_HEATER
             return CONF_TYPE_GPPH_HEATER

+ 0 - 0
custom_components/tuya_local/gardenpac_heatpump/__init__.py


+ 192 - 0
custom_components/tuya_local/gardenpac_heatpump/climate.py

@@ -0,0 +1,192 @@
+"""
+Garden PAC InverTech Swimming Pool Heatpump device.
+"""
+from homeassistant.components.climate import ClimateEntity
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    HVAC_MODE_HEAT,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.const import (
+    ATTR_TEMPERATURE,
+    STATE_UNAVAILABLE,
+    TEMP_CELSIUS,
+    TEMP_FAHRENHEIT,
+)
+
+from ..device import TuyaLocalDevice
+from .const import (
+    ATTR_OPERATING_MODE,
+    ATTR_POWER_LEVEL,
+    ATTR_TARGET_TEMPERATURE,
+    ATTR_TEMP_UNIT,
+    HVAC_MODE_TO_DPS_MODE,
+    PRESET_MODE_TO_DPS_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+
+SUPPORT_FLAGS = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE
+
+
+class GardenPACPoolHeatpump(ClimateEntity):
+    """Representation of a GardenPAC InverTech Heatpump WiFi pool heater."""
+
+    def __init__(self, device):
+        """Initialize the heater.
+        Args:
+            device (TuyaLocalDevice): The device API instance."""
+        self._device = device
+
+        self._support_flags = SUPPORT_FLAGS
+
+        self._TEMPERATURE_STEP = 1
+        self._TEMPERATURE_LIMITS = {"min": 18, "max": 45}
+
+    @property
+    def supported_features(self):
+        """Return the list of supported features."""
+        return self._support_flags
+
+    @property
+    def should_poll(self):
+        """Return the polling state."""
+        return True
+
+    @property
+    def name(self):
+        """Return the name of the climate device."""
+        return self._device.name
+
+    @property
+    def unique_id(self):
+        """Return the unique id for this heater."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this heater."""
+        return self._device.device_info
+
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend for this device."""
+        hvac_mode = self.hvac_mode
+
+        if hvac_mode == HVAC_MODE_HEAT:
+            return "mdi:hot-tub"
+        else:
+            return "mdi:radiator-disabled"
+
+    @property
+    def temperature_unit(self):
+        """Return the unit of measurement."""
+        dps_unit = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_TEMP_UNIT])
+        if dps_unit:
+            return TEMP_CELSIUS
+        else:
+            return TEMP_FAHRENHEIT
+
+    @property
+    def target_temperature(self):
+        """Return the temperature we try to reach."""
+        return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE])
+
+    @property
+    def target_temperature_step(self):
+        """Return the supported step of target temperature."""
+        return self._TEMPERATURE_STEP
+
+    @property
+    def min_temp(self):
+        """Return the minimum temperature."""
+        return self._TEMPERATURE_LIMITS["min"]
+
+    @property
+    def max_temp(self):
+        """Return the maximum temperature."""
+        return self._TEMPERATURE_LIMITS["max"]
+
+    async def async_set_temperature(self, **kwargs):
+        """Set new target temperatures."""
+        if kwargs.get(ATTR_TEMPERATURE) is not None:
+            await self.async_set_target_temperature(kwargs.get(ATTR_TEMPERATURE))
+
+    async def async_set_target_temperature(self, target_temperature):
+        target_temperature = int(round(target_temperature))
+
+        limits = self._TEMPERATURE_LIMITS
+        if not limits["min"] <= target_temperature <= limits["max"]:
+            raise ValueError(
+                f"Target temperature ({target_temperature}) must be between "
+                f'{limits["min"]} and {limits["max"]}.'
+            )
+
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE], target_temperature
+        )
+
+    @property
+    def current_temperature(self):
+        """Return the current temperature."""
+        return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_TEMPERATURE])
+
+    @property
+    def hvac_mode(self):
+        """Return current HVAC mode, ie Heat or Off."""
+        dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE])
+
+        if dps_mode is not None:
+            return TuyaLocalDevice.get_key_for_value(HVAC_MODE_TO_DPS_MODE, dps_mode)
+        else:
+            return STATE_UNAVAILABLE
+
+    @property
+    def hvac_modes(self):
+        """Return the list of available HVAC modes."""
+        return list(HVAC_MODE_TO_DPS_MODE.keys())
+
+    async def async_set_hvac_mode(self, hvac_mode):
+        """Set new HVAC mode."""
+        dps_mode = HVAC_MODE_TO_DPS_MODE[hvac_mode]
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE], dps_mode
+        )
+
+    @property
+    def preset_mode(self):
+        """Return the current preset mode."""
+        dps_preset = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE])
+        if dps_preset is not None:
+            return TuyaLocalDevice.get_key_for_value(
+                PRESET_MODE_TO_DPS_MODE, dps_preset
+            )
+        else:
+            return None
+
+    @property
+    def preset_modes(self):
+        """Return the list of available preset modes"""
+        return list(PRESET_MODE_TO_DPS_MODE.keys())
+
+    async def async_set_preset_mode(self, preset_mode):
+        """Set new preset mode."""
+        dps_mode = PRESET_MODE_TO_DPS_MODE[preset_mode]
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE], dps_mode
+        )
+
+    @property
+    def device_state_attributes(self):
+        """Get additional attributes that HA doesn't naturally support."""
+        power_level = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL])
+
+        operating_mode = self._device.get_property(
+            PROPERTY_TO_DPS_ID[ATTR_OPERATING_MODE]
+        )
+
+        return {ATTR_POWER_LEVEL: power_level, ATTR_OPERATING_MODE: operating_mode}
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 27 - 0
custom_components/tuya_local/gardenpac_heatpump/const.py

@@ -0,0 +1,27 @@
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    HVAC_MODE_HEAT,
+    HVAC_MODE_OFF,
+)
+from homeassistant.const import ATTR_TEMPERATURE
+
+ATTR_TARGET_TEMPERATURE = "target_temperature"
+ATTR_TEMP_UNIT = "temperature_unit"
+ATTR_POWER_LEVEL = "power_level"
+ATTR_OPERATING_MODE = "operating_mode"
+
+PROPERTY_TO_DPS_ID = {
+    ATTR_HVAC_MODE: "1",
+    ATTR_TEMPERATURE: "102",
+    ATTR_TEMP_UNIT: "103",
+    ATTR_POWER_LEVEL: "104",
+    ATTR_OPERATING_MODE: "105",
+    ATTR_TARGET_TEMPERATURE: "106",
+    ATTR_PRESET_MODE: "117",
+}
+
+HVAC_MODE_TO_DPS_MODE = {HVAC_MODE_OFF: False, HVAC_MODE_HEAT: True}
+PRESET_SILENT = "Silent"
+PRESET_SMART = "Smart"
+PRESET_MODE_TO_DPS_MODE = {PRESET_SILENT: False, PRESET_SMART: True}

+ 4 - 0
custom_components/tuya_local/light.py

@@ -10,10 +10,12 @@ from .const import (
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
     CONF_TYPE_FAN,
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GPPH_HEATER,
+    CONF_TYPE_PURLINE_M100_HEATER,
 )
 )
 from .dehumidifier.light import GoldairDehumidifierLedDisplayLight
 from .dehumidifier.light import GoldairDehumidifierLedDisplayLight
 from .fan.light import GoldairFanLedDisplayLight
 from .fan.light import GoldairFanLedDisplayLight
 from .heater.light import GoldairHeaterLedDisplayLight
 from .heater.light import GoldairHeaterLedDisplayLight
+from .purline_m100_heater.light import PurlineM100HeaterLedDisplayLight
 
 
 
 
 async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
 async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
@@ -33,6 +35,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
         data[CONF_DISPLAY_LIGHT] = GoldairDehumidifierLedDisplayLight(device)
         data[CONF_DISPLAY_LIGHT] = GoldairDehumidifierLedDisplayLight(device)
     elif discovery_info[CONF_TYPE] == CONF_TYPE_FAN:
     elif discovery_info[CONF_TYPE] == CONF_TYPE_FAN:
         data[CONF_DISPLAY_LIGHT] = GoldairFanLedDisplayLight(device)
         data[CONF_DISPLAY_LIGHT] = GoldairFanLedDisplayLight(device)
+    elif discovery_info[CONF_TYPE] == CONF_TYPE_PURLINE_M100_HEATER:
+        dataa[CONF_DISPLAY_LIGHT] = PurlineM100HeaterLedDisplayLight(device)
     else:
     else:
         raise ValueError("This device does not support panel lighting control.")
         raise ValueError("This device does not support panel lighting control.")
 
 

+ 0 - 0
custom_components/tuya_local/purline_m100_heater/__init__.py


+ 222 - 0
custom_components/tuya_local/purline_m100_heater/climate.py

@@ -0,0 +1,222 @@
+"""
+Purline Hoti M100 WiFi Heater device.
+"""
+from homeassistant.components.climate import ClimateEntity
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE,
+    ATTR_SWING_MODE,
+    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.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from ..device import TuyaLocalDevice
+from .const import (
+    ATTR_POWER_LEVEL,
+    ATTR_TARGET_TEMPERATURE,
+    POWER_LEVEL_AUTO,
+    POWER_LEVEL_FANONLY,
+    POWER_LEVEL_TO_DPS_LEVEL,
+    PRESET_AUTO,
+    PRESET_FAN,
+    PROPERTY_TO_DPS_ID,
+)
+
+SUPPORT_FLAGS = SUPPORT_PRESET_MODE | SUPPORT_SWING_MODE | SUPPORT_TARGET_TEMPERATURE
+
+
+class PurlineM100Heater(ClimateEntity):
+    """Representation of a Purline Hoti M100 WiFi heater."""
+
+    def __init__(self, device):
+        """Initialize the heater.
+        Args:
+            device (TuyaLocalDevice): The device API instance."""
+        self._device = device
+
+        self._support_flags = SUPPORT_FLAGS
+
+        self._TEMPERATURE_STEP = 1
+        self._TEMPERATURE_LIMITS = {"min": 15, "max": 35}
+
+    @property
+    def supported_features(self):
+        """Return the list of supported features."""
+        return self._support_flags
+
+    @property
+    def should_poll(self):
+        """Return the polling state."""
+        return True
+
+    @property
+    def name(self):
+        """Return the name of the climate device."""
+        return self._device.name
+
+    @property
+    def unique_id(self):
+        """Return the unique id for this heater."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this heater."""
+        return self._device.device_info
+
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend for this device."""
+        hvac_mode = self.hvac_mode
+
+        if hvac_mode == HVAC_MODE_HEAT:
+            return "mdi:radiator"
+        elif hvac_mode == HVAC_MODE_FAN_ONLY:
+            return "mdi:fan"
+        else:
+            return "mdi:radiator-disabled"
+
+    @property
+    def temperature_unit(self):
+        """Return the unit of measurement."""
+        return self._device.temperature_unit
+
+    @property
+    def target_temperature(self):
+        """Return the temperature we try to reach."""
+        return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE])
+
+    @property
+    def target_temperature_step(self):
+        """Return the supported step of target temperature."""
+        return self._TEMPERATURE_STEP
+
+    @property
+    def min_temp(self):
+        """Return the minimum temperature."""
+        return self._TEMPERATURE_LIMITS["min"]
+
+    @property
+    def max_temp(self):
+        """Return the maximum temperature."""
+        return self._TEMPERATURE_LIMITS["max"]
+
+    async def async_set_temperature(self, **kwargs):
+        """Set new target temperatures."""
+        if kwargs.get(ATTR_TEMPERATURE) is not None:
+            await self.async_set_target_temperature(kwargs.get(ATTR_TEMPERATURE))
+
+    async def async_set_target_temperature(self, target_temperature):
+        target_temperature = int(round(target_temperature))
+
+        limits = self._TEMPERATURE_LIMITS
+        if not limits["min"] <= target_temperature <= limits["max"]:
+            raise ValueError(
+                f"Target temperature ({target_temperature}) must be between "
+                f'{limits["min"]} and {limits["max"]}.'
+            )
+
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE], target_temperature
+        )
+
+    @property
+    def current_temperature(self):
+        """Return the current temperature."""
+        return self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_TEMPERATURE])
+
+    @property
+    def hvac_mode(self):
+        """Return current HVAC mode, ie Heat or Off."""
+        dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE])
+        dps_level = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL])
+        if dps_mode is not None:
+            if dps_mode is False:
+                return HVAC_MODE_OFF
+            elif dps_level == POWER_LEVEL_FANONLY:
+                return HVAC_MODE_FAN_ONLY
+            else:
+                return HVAC_MODE_HEAT
+        else:
+            return STATE_UNAVAILABLE
+
+    @property
+    def hvac_modes(self):
+        """Return the list of available HVAC modes."""
+        return [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT]
+
+    async def async_set_hvac_mode(self, hvac_mode):
+        """Set new HVAC mode."""
+        dps_mode = True
+        if hvac_mode == HVAC_MODE_OFF:
+            dps_mode = False
+
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE], dps_mode
+        )
+        dps_level = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL])
+
+        if hvac_mode == HVAC_MODE_FAN_ONLY and dps_level != POWER_LEVEL_FANONLY:
+            await self.async_set_preset_mode(PRESET_FAN)
+        elif hvac_mode == HVAC_MODE_HEAT and dps_level == POWER_LEVEL_FANONLY:
+            await self.async_set_preset_mode(PRESET_AUTO)
+
+    @property
+    def preset_mode(self):
+        """Return the power level."""
+        dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL])
+        if dps_mode is None:
+            return None
+
+        return TuyaLocalDevice.get_key_for_value(POWER_LEVEL_TO_DPS_LEVEL, dps_mode)
+
+    @property
+    def preset_modes(self):
+        """Retrn the list of available preset modes."""
+        return list(POWER_LEVEL_TO_DPS_LEVEL.keys())
+
+    async def async_set_preset_mode(self, preset_mode):
+        """Set new power level."""
+        dps_mode = POWER_LEVEL_TO_DPS_LEVEL[preset_mode]
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL], dps_mode
+        )
+
+    @property
+    def swing_mode(self):
+        """Return the swing mode."""
+        dps_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_SWING_MODE])
+        if dps_mode is None:
+            return None
+
+        if dps_mode:
+            return SWING_VERTICAL
+        else:
+            return SWING_OFF
+
+    @property
+    def swing_modes(self):
+        """List of swing modes."""
+        return [SWING_OFF, SWING_VERTICAL]
+
+    async def async_set_swing_mode(self, swing_mode):
+        """Set new swing mode."""
+        if swing_mode == SWING_VERTICAL:
+            swing_state = True
+        elif swing_mode == SWING_OFF:
+            swing_state = False
+        else:
+            raise ValueError(f"Invalid swing mode: {swing_mode}")
+
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_SWING_MODE], swing_state
+        )
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 39 - 0
custom_components/tuya_local/purline_m100_heater/const.py

@@ -0,0 +1,39 @@
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE,
+    ATTR_SWING_MODE,
+)
+from homeassistant.const import ATTR_TEMPERATURE
+
+ATTR_TARGET_TEMPERATURE = "target_temperature"
+ATTR_DISPLAY_OFF = "display_off"
+ATTR_POWER_LEVEL = "power_level"
+ATTR_TIMER_HR = "timer_hours"
+ATTR_TIMER_REMAIN = "timer_remain"
+ATTR_OPEN_WINDOW_DETECT = "open_window_detect"
+
+POWER_LEVEL_AUTO = "auto"
+POWER_LEVEL_FANONLY = "off"
+PRESET_FAN = "Fan"
+PRESET_AUTO = "Auto"
+
+POWER_LEVEL_TO_DPS_LEVEL = {
+    PRESET_FAN: POWER_LEVEL_FANONLY,
+    "1": "1",
+    "2": "2",
+    "3": "3",
+    "4": "4",
+    "5": "5",
+    PRESET_AUTO: POWER_LEVEL_AUTO,
+}
+
+PROPERTY_TO_DPS_ID = {
+    ATTR_HVAC_MODE: "1",
+    ATTR_TARGET_TEMPERATURE: "2",
+    ATTR_TEMPERATURE: "3",
+    ATTR_POWER_LEVEL: "5",
+    ATTR_DISPLAY_OFF: "10",
+    ATTR_TIMER_HR: "11",
+    ATTR_TIMER_REMAIN: "12",
+    ATTR_OPEN_WINDOW_DETECT: "101",
+    ATTR_SWING_MODE: "102",
+}

+ 74 - 0
custom_components/tuya_local/purline_m100_heater/light.py

@@ -0,0 +1,74 @@
+"""
+Platform to control the LED display light on Purline WiFi-connected heaters.
+"""
+from homeassistant.components.light import LightEntity
+from homeassistant.components.climate import ATTR_HVAC_MODE, HVAC_MODE_OFF
+from homeassistant.const import STATE_UNAVAILABLE
+
+from ..device import TuyaLocalDevice
+from .const import ATTR_DISPLAY_OFF, PROPERTY_TO_DPS_ID
+
+
+class PurlineM100HeaterLedDisplayLight(LightEntity):
+    """Representation of a Purline M100 WiFi-connected heater LED display."""
+
+    def __init__(self, device):
+        """Initialize the light.
+        Args:
+            device (TuyaLocalDevice): The device API instance."""
+        self._device = device
+
+    @property
+    def should_poll(self):
+        """Return the polling state."""
+        return True
+
+    @property
+    def name(self):
+        """Return the name of the light."""
+        return self._device.name
+
+    @property
+    def unique_id(self):
+        """Return the unique id for this heater LED display."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this heater LED display."""
+        return self._device.device_info
+
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend for this device."""
+        if self.is_on:
+            return "mdi:led-on"
+        else:
+            return "mdi:led-off"
+
+    @property
+    def is_on(self):
+        """Return the current state. Note: Device state is inverted."""
+        return not (self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_OFF]))
+
+    async def async_turn_on(self):
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_DISPLAY_OFF], False
+        )
+
+    async def async_turn_off(self):
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_DISPLAY_OFF], True
+        )
+
+    async def async_toggle(self):
+        dps_hvac_mode = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE])
+        dps_display_off = self._device.get_property(
+            PROPERTY_TO_DPS_ID[ATTR_DISPLAY_OFF]
+        )
+
+        if dps_hvac_mode:
+            await (self.async_turn_on() if dps_display_off else self.async_turn_off())
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 73 - 0
custom_components/tuya_local/purline_m100_heater/switch.py

@@ -0,0 +1,73 @@
+"""
+Platform to control the Open Window Detector on Purline M100 heaters.
+"""
+from homeassistant.components.switch import SwitchEntity
+from homeassistant.components.switch import DEVICE_CLASS_SWITCH
+
+from homeassistant.const import STATE_UNAVAILABLE
+
+from .const import (
+    ATTR_OPEN_WINDOW_DETECT,
+    PROPERTY_TO_DPS_ID,
+)
+
+
+class PurlinM100OpenWindowDetector(SwitchEntity):
+    """Representation of the Open Window Detection of a Purline M100 heater"""
+
+    def __init__(self, device):
+        """Initialize the switch.
+        Args:
+            device (TuyaLocalDevice): The device API instance."""
+        self._device = device
+
+    @property
+    def should_poll(self):
+        """Return the polling state."""
+        return True
+
+    @property
+    def name(self):
+        """Return the name of the switch."""
+        return self._device.name
+
+    @property
+    def unique_id(self):
+        """Return the unique id for this switch."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this switch."""
+        return self._device.device_info
+
+    @property
+    def device_class(self):
+        """Return the class of this device"""
+        return DEVICE_CLASS_SWITCH
+
+    @property
+    def is_on(self):
+        """Return the whether the switch is on."""
+        is_switched_on = self._device.get_property(
+            PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT]
+        )
+        if is_switched_on is None:
+            return STATE_UNAVAILABLE
+        else:
+            return is_switched_on
+
+    async def async_turn_on(self, **kwargs):
+        """Turn the switch on"""
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT], True
+        )
+
+    async def async_turn_off(self, **kwargs):
+        """Turn the switch off"""
+        await self._device.async_set_property(
+            PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT], False
+        )
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 4 - 0
custom_components/tuya_local/switch.py

@@ -6,10 +6,12 @@ from .const import (
     CONF_DEVICE_ID,
     CONF_DEVICE_ID,
     CONF_TYPE,
     CONF_TYPE,
     CONF_TYPE_KOGAN_SWITCH,
     CONF_TYPE_KOGAN_SWITCH,
+    CONF_TYPE_PURLINE_M100_HEATER,
     CONF_TYPE_AUTO,
     CONF_TYPE_AUTO,
     CONF_SWITCH,
     CONF_SWITCH,
 )
 )
 from .kogan_socket.switch import KoganSocketSwitch
 from .kogan_socket.switch import KoganSocketSwitch
+from .purline_m100_heater.switch import PurlineM100OpenWindowDetector
 
 
 
 
 async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
 async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
@@ -25,6 +27,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
 
 
     if discovery_info[CONF_TYPE] == CONF_TYPE_KOGAN_SWITCH:
     if discovery_info[CONF_TYPE] == CONF_TYPE_KOGAN_SWITCH:
         data[CONF_SWITCH] = KoganSocketSwitch(device)
         data[CONF_SWITCH] = KoganSocketSwitch(device)
+    elif discovery_info[CONF_TYPE] == CONF_TYPE_PURLINE_M100_HEATER:
+        data[CONF_SWITCH] = PurlineM100OpenWindowDetector(device)
     else:
     else:
         raise ValueError("This device does not support working as a switch")
         raise ValueError("This device does not support working as a switch")
 
 

+ 26 - 0
tests/const.py

@@ -78,3 +78,29 @@ GSH_HEATER_PAYLOAD = {
     "4": "low",
     "4": "low",
     "12": 0,
     "12": 0,
 }
 }
+
+GARDENPAC_HEATPUMP_PAYLOAD = {
+    "1": True,
+    "102": 28,
+    "103": True,
+    "104": 100,
+    "105": "warm",
+    "106": 30,
+    "107": 18,
+    "108": 40,
+    "115": 0,
+    "116": 0,
+    "117": True,
+}
+
+PURLINE_M100_HEATER_PAYLOAD = {
+    "1": True,
+    "2": 23,
+    "3": 23,
+    "5": "off",
+    "10": True,
+    "11": 0,
+    "12": 0,
+    "101": False,
+    "102": False,
+}

+ 0 - 0
tests/gardenpac_heatpump/__init__.py


+ 209 - 0
tests/gardenpac_heatpump/test_climate.py

@@ -0,0 +1,209 @@
+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,
+    TEMP_CELSIUS,
+    TEMP_FAHRENHEIT,
+)
+
+from custom_components.tuya_local.gardenpac_heatpump.climate import (
+    GardenPACPoolHeatpump,
+)
+from custom_components.tuya_local.gardenpac_heatpump.const import (
+    ATTR_OPERATING_MODE,
+    ATTR_POWER_LEVEL,
+    ATTR_TARGET_TEMPERATURE,
+    ATTR_TEMP_UNIT,
+    HVAC_MODE_TO_DPS_MODE,
+    PRESET_SILENT,
+    PRESET_SMART,
+    PRESET_MODE_TO_DPS_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+
+from ..const import GARDENPAC_HEATPUMP_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+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()
+
+        self.subject = GardenPACPoolHeatpump(self.mock_device())
+
+        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)
+
+    def test_icon(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.assertEqual(self.subject.icon, "mdi:hot-tub")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        self.assertEqual(self.subject.icon, "mdi:radiator-disabled")
+
+    def test_temperature_unit(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TEMP_UNIT]] = False
+        self.assertEqual(self.subject.temperature_unit, TEMP_FAHRENHEIT)
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_TEMP_UNIT]] = True
+        self.assertEqual(self.subject.temperature_unit, TEMP_CELSIUS)
+
+    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, 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, {PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE]: 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, {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 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[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_SILENT
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_SILENT)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]] = PRESET_MODE_TO_DPS_MODE[
+            PRESET_SMART
+        ]
+        self.assertEqual(self.subject.preset_mode, PRESET_SMART)
+
+        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_SILENT, PRESET_SMART])
+
+    async def test_set_preset_mode_to_silent(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    PRESET_SILENT
+                ]
+            },
+        ):
+            await self.subject.async_set_preset_mode(PRESET_SILENT)
+
+    async def test_set_preset_mode_to_smart(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE]: PRESET_MODE_TO_DPS_MODE[
+                    PRESET_SMART
+                ]
+            },
+        ):
+            await self.subject.async_set_preset_mode(PRESET_SMART)
+
+    def test_device_state_attributes(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = 50
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_OPERATING_MODE]] = "cool"
+        self.assertEqual(
+            self.subject.device_state_attributes,
+            {ATTR_POWER_LEVEL: 50, ATTR_OPERATING_MODE: "cool"},
+        )
+
+    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()

+ 27 - 0
tests/helpers.py

@@ -23,3 +23,30 @@ async def assert_device_properties_set(device: TuyaLocalDevice, properties: dict
             device.async_set_property.assert_any_call(key, properties[key])
             device.async_set_property.assert_any_call(key, properties[key])
         for result in results:
         for result in results:
             result.assert_awaited()
             result.assert_awaited()
+
+
+@asynccontextmanager
+async def assert_device_properties_set_optional(
+    device: TuyaLocalDevice, properties: dict, optional_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())) and (
+            device.async_set_property.call_count
+            <= len(properties.keys()) + len(optional_properties.keys())
+        )
+
+        for key in properties.keys():
+            device.async_set_property.assert_any_call(key, properties[key])
+        for result in results:
+            result.assert_awaited()

+ 0 - 0
tests/purline_m100_heater/__init__.py


+ 231 - 0
tests/purline_m100_heater/test_climate.py

@@ -0,0 +1,231 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.climate.const import (
+    ATTR_HVAC_MODE,
+    ATTR_SWING_MODE,
+    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.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
+
+from custom_components.tuya_local.purline_m100_heater.climate import PurlineM100Heater
+from custom_components.tuya_local.purline_m100_heater.const import (
+    ATTR_POWER_LEVEL,
+    ATTR_TARGET_TEMPERATURE,
+    POWER_LEVEL_AUTO,
+    POWER_LEVEL_FANONLY,
+    POWER_LEVEL_TO_DPS_LEVEL,
+    PRESET_AUTO,
+    PRESET_FAN,
+    PROPERTY_TO_DPS_ID,
+)
+
+from ..const import PURLINE_M100_HEATER_PAYLOAD
+from ..helpers import (
+    assert_device_properties_set,
+    assert_device_properties_set_optional,
+)
+
+
+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()
+
+        self.subject = PurlineM100Heater(self.mock_device())
+
+        self.dps = PURLINE_M100_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.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = POWER_LEVEL_AUTO
+        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_FANONLY
+        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[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_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, {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 \\(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[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.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = POWER_LEVEL_AUTO
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_HEAT)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = POWER_LEVEL_FANONLY
+        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.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,
+            {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: True},
+            {PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]: POWER_LEVEL_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, {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: False},
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    async def test_turn_on_fan(self):
+        async with assert_device_properties_set_optional(
+            self.subject._device,
+            {PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]: True},
+            {PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]: POWER_LEVEL_FANONLY},
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_FAN_ONLY)
+
+    def test_preset_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = POWER_LEVEL_AUTO
+        self.assertEqual(self.subject.preset_mode, PRESET_AUTO)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = POWER_LEVEL_FANONLY
+        self.assertEqual(self.subject.preset_mode, PRESET_FAN)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = POWER_LEVEL_TO_DPS_LEVEL["4"]
+        self.assertEqual(self.subject.preset_mode, "4")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]] = None
+        self.assertIs(self.subject.preset_mode, None)
+
+    def test_preset_modes(self):
+        self.assertCountEqual(
+            self.subject.preset_modes,
+            [PRESET_FAN, "1", "2", "3", "4", "5", PRESET_AUTO],
+        )
+
+    async def test_set_preset_mode_numeric(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_preset_mode("3")
+
+    def test_swing_mode(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_SWING_MODE]] = True
+        self.assertEqual(self.subject.swing_mode, SWING_VERTICAL)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_SWING_MODE]] = 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, {PROPERTY_TO_DPS_ID[ATTR_SWING_MODE]: 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, {PROPERTY_TO_DPS_ID[ATTR_SWING_MODE]: 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()

+ 96 - 0
tests/purline_m100_heater/test_light.py

@@ -0,0 +1,96 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from custom_components.tuya_local.purline_m100_heater.const import (
+    ATTR_DISPLAY_OFF,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.tuya_local.purline_m100_heater.light import (
+    PurlineM100HeaterLedDisplayLight,
+)
+
+from ..const import PURLINE_M100_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestPurlineM100HeaterLedDisplayLight(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch("custom_components.tuya_local.device.TuyaLocalDevice")
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = PurlineM100HeaterLedDisplayLight(self.mock_device())
+
+        self.dps = PURLINE_M100_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_OFF]] = False
+        self.assertEqual(self.subject.icon, "mdi:led-on")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_OFF]] = True
+        self.assertEqual(self.subject.icon, "mdi:led-off")
+
+    def test_is_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_OFF]] = False
+        self.assertEqual(self.subject.is_on, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_OFF]] = True
+        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_OFF]: False}
+        ):
+            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_OFF]: True}
+        ):
+            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_OFF]] = True
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_OFF]: False}
+        ):
+            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_OFF]] = False
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_OFF]: True}
+        ):
+            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()

+ 90 - 0
tests/purline_m100_heater/test_switch.py

@@ -0,0 +1,90 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.switch import DEVICE_CLASS_SWITCH
+from homeassistant.const import STATE_UNAVAILABLE
+
+from custom_components.tuya_local.purline_m100_heater.const import (
+    ATTR_OPEN_WINDOW_DETECT,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.tuya_local.purline_m100_heater.switch import (
+    PurlinM100OpenWindowDetector,
+)
+
+from ..const import PURLINE_M100_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestPulineOpenWindowDetector(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch("custom_components.tuya_local.device.TuyaLocalDevice")
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = PurlinM100OpenWindowDetector(self.mock_device())
+
+        self.dps = PURLINE_M100_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_device_class_is_outlet(self):
+        self.assertEqual(self.subject.device_class, DEVICE_CLASS_SWITCH)
+
+    def test_is_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT]] = True
+        self.assertEqual(self.subject.is_on, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT]] = False
+        self.assertEqual(self.subject.is_on, False)
+
+    def test_is_on_when_unavailable(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT]] = None
+        self.assertEqual(self.subject.is_on, STATE_UNAVAILABLE)
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT]: 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_OPEN_WINDOW_DETECT]: False}
+        ):
+            await self.subject.async_turn_off()
+
+    async def test_toggle_turns_the_switch_on_when_it_was_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT]] = False
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT]: True}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_toggle_turns_the_switch_off_when_it_was_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT]] = True
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_OPEN_WINDOW_DETECT]: 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()

+ 16 - 0
tests/test_device.py

@@ -16,6 +16,8 @@ from custom_components.tuya_local.const import (
     CONF_TYPE_GSH_HEATER,
     CONF_TYPE_GSH_HEATER,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_SWITCH,
     CONF_TYPE_KOGAN_SWITCH,
+    CONF_TYPE_GARDENPAC_HEATPUMP,
+    CONF_TYPE_PURLINE_M100_HEATER,
 )
 )
 from custom_components.tuya_local.device import TuyaLocalDevice
 from custom_components.tuya_local.device import TuyaLocalDevice
 
 
@@ -29,6 +31,8 @@ from .const import (
     GSH_HEATER_PAYLOAD,
     GSH_HEATER_PAYLOAD,
     KOGAN_HEATER_PAYLOAD,
     KOGAN_HEATER_PAYLOAD,
     KOGAN_SOCKET_PAYLOAD,
     KOGAN_SOCKET_PAYLOAD,
+    GARDENPAC_HEATPUMP_PAYLOAD,
+    PURLINE_M100_HEATER_PAYLOAD,
 )
 )
 
 
 
 
@@ -132,6 +136,18 @@ class TestDevice(IsolatedAsyncioTestCase):
         self.subject._cached_state = GSH_HEATER_PAYLOAD
         self.subject._cached_state = GSH_HEATER_PAYLOAD
         self.assertEqual(await self.subject.async_inferred_type(), CONF_TYPE_GSH_HEATER)
         self.assertEqual(await self.subject.async_inferred_type(), CONF_TYPE_GSH_HEATER)
 
 
+    async def test_detects_gardenpac_heatpump_payload(self):
+        self.subject._cached_state = GARDENPAC_HEATPUMP_PAYLOAD
+        self.assertEqual(
+            await self.subject.async_inferred_type(), CONF_TYPE_GARDENPAC_HEATPUMP
+        )
+
+    async def test_detects_purline_m100_heater_payload(self):
+        self.subject._cached_state = PURLINE_M100_HEATER_PAYLOAD
+        self.assertEqual(
+            await self.subject.async_inferred_type(), CONF_TYPE_PURLINE_M100_HEATER
+        )
+
     async def test_detection_returns_none_when_device_type_could_not_be_detected(self):
     async def test_detection_returns_none_when_device_type_could_not_be_detected(self):
         self.subject._cached_state = {"1": False}
         self.subject._cached_state = {"1": False}
         self.assertEqual(await self.subject.async_inferred_type(), None)
         self.assertEqual(await self.subject.async_inferred_type(), None)