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

Issue #11: add support for two new devices.

- Pur Line Hoti M100 heater
- Garden PAC pool heatpump
Jason Rumney 5 лет назад
Родитель
Сommit
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)
 [![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
 
@@ -70,9 +70,24 @@ 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
 
-**Light** (Goldair devices)
+**Light** (Goldair and Purline devices)
 
 - **LED display** (on/off)
 
@@ -80,6 +95,10 @@ Current temperature is also displayed.
 
 - **Child lock** (on/off)
 
+**Open Window Detector** (Purline devices)
+
+- **Open Window Detect** (on/off)
+
 ### Switch devices
 
 **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 Purline Hoti M100 heaters and Garden PAC pool heatpumps were added based on information from @Xeovar on Issue #11.
+
 ---
 
 ## Installation
@@ -151,7 +172,7 @@ tuya_local:
 
 #### 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_
 
@@ -169,13 +190,13 @@ tuya_local:
 
 #### 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_
 
 #### 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
 
@@ -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.
 - [awaismun](https://github.com/awaismun) for assistance in supporting Andersson 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_GSH_HEATER,
     CONF_TYPE_KOGAN_HEATER,
+    CONF_TYPE_GARDENPAC_HEATPUMP,
+    CONF_TYPE_PURLINE_M100_HEATER,
     CONF_CLIMATE,
 )
 from .dehumidifier.climate import GoldairDehumidifier
@@ -24,6 +26,8 @@ from .eurom_600_heater.climate import EuromMonSoleil600Heater
 from .gpcv_heater.climate import GoldairGPCVHeater
 from .heater.climate import GoldairHeater
 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
 
 
@@ -54,6 +58,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
         data[CONF_CLIMATE] = KoganHeater(device)
     elif discovery_info[CONF_TYPE] == CONF_TYPE_GSH_HEATER:
         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:
         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_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
+    CONF_TYPE_EUROM_600_HEATER,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
-    CONF_TYPE_EUROM_600_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GSH_HEATER,
+    CONF_TYPE_GARDENPAC_HEATPUMP,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_SWITCH,
+    CONF_TYPE_PURLINE_M100_HEATER,
 )
 
 INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
@@ -31,15 +33,17 @@ INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
         "type": vol.In(
             [
                 CONF_TYPE_AUTO,
-                CONF_TYPE_GPPH_HEATER,
                 CONF_TYPE_DEHUMIDIFIER,
+                CONF_TYPE_EUROM_600_HEATER,
                 CONF_TYPE_FAN,
                 CONF_TYPE_GECO_HEATER,
-                CONF_TYPE_EUROM_600_HEATER,
                 CONF_TYPE_GPCV_HEATER,
+                CONF_TYPE_GPPH_HEATER,
                 CONF_TYPE_GSH_HEATER,
+                CONF_TYPE_GARDENPAC_HEATPUMP,
                 CONF_TYPE_KOGAN_HEATER,
                 CONF_TYPE_KOGAN_SWITCH,
+                CONF_TYPE_PURLINE_M100_HEATER,
             ]
         ),
         "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_SWITCH = "kogan_switch"
 CONF_TYPE_GSH_HEATER = "gsh_heater"
+CONF_TYPE_GARDENPAC_HEATPUMP = "gardenpac_heatpump"
+CONF_TYPE_PURLINE_M100_HEATER = "purline_m100_heater"
 CONF_CLIMATE = "climate"
 CONF_DISPLAY_LIGHT = "display_light"
 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 (
     API_PROTOCOL_VERSIONS,
     CONF_TYPE_DEHUMIDIFIER,
+    CONF_TYPE_EUROM_600_HEATER,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
-    CONF_TYPE_EUROM_600_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GSH_HEATER,
+    CONF_TYPE_GARDENPAC_HEATPUMP,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_SWITCH,
+    CONF_TYPE_PURLINE_M100_HEATER,
     DOMAIN,
 )
 
@@ -99,6 +101,9 @@ class TuyaLocalDevice(object):
         if "8" in cached_state:
             _LOGGER.info(f"Detecting {self.name} as Goldair 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:
             _LOGGER.info(f"Detecting {self.name} as Eurom Mon Soleil 600 Heater")
             return CONF_TYPE_EUROM_600_HEATER
@@ -108,6 +113,9 @@ class TuyaLocalDevice(object):
         if "18" in cached_state:
             _LOGGER.info(f"Detecting {self.name} as newer type of 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:
             _LOGGER.info(f"Detecting {self.name} as Goldair 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_FAN,
     CONF_TYPE_GPPH_HEATER,
+    CONF_TYPE_PURLINE_M100_HEATER,
 )
 from .dehumidifier.light import GoldairDehumidifierLedDisplayLight
 from .fan.light import GoldairFanLedDisplayLight
 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):
@@ -33,6 +35,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
         data[CONF_DISPLAY_LIGHT] = GoldairDehumidifierLedDisplayLight(device)
     elif discovery_info[CONF_TYPE] == CONF_TYPE_FAN:
         data[CONF_DISPLAY_LIGHT] = GoldairFanLedDisplayLight(device)
+    elif discovery_info[CONF_TYPE] == CONF_TYPE_PURLINE_M100_HEATER:
+        dataa[CONF_DISPLAY_LIGHT] = PurlineM100HeaterLedDisplayLight(device)
     else:
         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_TYPE,
     CONF_TYPE_KOGAN_SWITCH,
+    CONF_TYPE_PURLINE_M100_HEATER,
     CONF_TYPE_AUTO,
     CONF_SWITCH,
 )
 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):
@@ -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:
         data[CONF_SWITCH] = KoganSocketSwitch(device)
+    elif discovery_info[CONF_TYPE] == CONF_TYPE_PURLINE_M100_HEATER:
+        data[CONF_SWITCH] = PurlineM100OpenWindowDetector(device)
     else:
         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",
     "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])
         for result in results:
             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_KOGAN_HEATER,
     CONF_TYPE_KOGAN_SWITCH,
+    CONF_TYPE_GARDENPAC_HEATPUMP,
+    CONF_TYPE_PURLINE_M100_HEATER,
 )
 from custom_components.tuya_local.device import TuyaLocalDevice
 
@@ -29,6 +31,8 @@ from .const import (
     GSH_HEATER_PAYLOAD,
     KOGAN_HEATER_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.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):
         self.subject._cached_state = {"1": False}
         self.assertEqual(await self.subject.async_inferred_type(), None)