Explorar el Código

Add tests for water heater

- Tests for PR #251
- Allow setting temperature in the general case, make it readonly for Hydrotherm device in the config.
- Add error_code as attribute for Hydrotherm device so more detailed error info can be read.
- remove mapping to scale by 1 (not required) and additional "off" values.
Jason Rumney hace 3 años
padre
commit
246fcc15db

+ 1 - 0
ACKNOWLEDGEMENTS.md

@@ -120,3 +120,4 @@ Further device support has been made with the assistance of users.  Please consi
 - [Svellem](https://github.com/Svellem) for assisting with support for T5E-WF thermostats.
 - [Aptul9](https://github.com/Aptul9) for assisting with support for Sendo air conditioners.
 - [dilorenzo1987](https://github.com/dilorenzo1987) for contributing support for Stadler Form Roger purifiers.
+- [fsevilla3](https://github.com/fsevilla3) for contributing support for water_heater entities and Hydrotherm Dynamix/X8 water heaters.

+ 19 - 0
DEVICES.md

@@ -51,7 +51,12 @@
   or if any of the "unknown" values that are returned as attributes can
   be figured out.
 
+### Water heaters
+
+- Hydrotherm Dynamic/X8 heat pump hot water systems
+
 ### Thermostats
+
 - Inkbird ITC306A thermostat smartplug
 - Inkbird ITC308 thermostat smartplug
 - Beca BHP-6000 room heat pump control thermostat
@@ -73,6 +78,7 @@
 - T5E-WF thermostat
 
 ### Fans
+
 - Goldair GCPF315 fan
 - Anko HEGSM40 fan
 - Lexy F501 fan
@@ -84,6 +90,7 @@
 - Ledkia fan and light controller
 
 ### Air Purifiers
+
 - Renpho RP-AP001S air purifier
 - Poiema One air purifier
 - Himox H05 and H06 air purifiers
@@ -93,6 +100,7 @@
 - Stadler Form Roger air purifier
 
 ### Dehumidifiers
+
 - Goldair GPDH420 dehumidifier
 - ElectriQ CD12PW dehumidifier
 - ElectriQ CD12PWv2 dehumidifier
@@ -107,23 +115,28 @@
 - AlecoAir D14 purifying dehumidifier
 
 ### Humidifiers
+
 - Eanons QT-JS2014 purifying humidifier
 - Wetair WAW-H1210LW humidifier
 - Wilfa Haze HU400BC humidifier
 
 ### Kitchen Appliances
+
 - Kogan glass 1.7L smart kettle
 - Inkbird sous vide cooker
 
 ### Smart Meter/Circuit Breaker
+
 - SmartMCB SMT006 energy meter
 - PC321-TY 3 phase power clamp meter
 - Compteur digital electric (single phase)
 
 ### Battery Charger
+
 - Parkside PLGS 2012 A1 smart charger for powertools
 
 ### SmartPlugs/Wall sockets
+
 - Generic smartplug with energy monitoring (older models)
   _confirmed as working with Kogan and Blitzwolf single smartplugs_
 - Generic smartplug with energy monitoring (newer models)
@@ -150,9 +163,11 @@ Other brands may work with the above configurations
 - Simple switch with timer v2 - the above with timer moved from dp 11 to 9, confirmed with a Nexxt 220V smart switch. 
 
 ### Lights
+
 - Generic RGBCW/RGBWW lightbulb (confirmed with Lijun branded bulb, expected to match others also)
 
 ### Covers
+
 - Simple garage door
 - Simple blind controller
 - Dongguan garage door
@@ -163,16 +178,20 @@ Other brands may work with the above configurations
 - Wistar roller blinds controller
 
 ### Vacuum Cleaners
+
 - Lefant M213 vacuum cleaner (also works for Lefant M213S and APOSEN A550)
 - Kyvol E30 vacuum cleaner
 
 ### Locks
+
 - Orion Grid Connect smart lock
 
 ### Sirens
+
 - Orion Grid Connect outdoor siren
 
 ### Miscellaneous
+
 - Qoto 03 smart water valve / sprinkler controller
 - SD123 HPR01 human presence radar
 - Universal remote control (supports sensors only)

+ 4 - 6
custom_components/tuya_local/devices/hydrotherm_dynamic_x8_water_heater.yaml

@@ -15,11 +15,10 @@ primary_entity:
       range:
         min: 60
         max: 70
+      readonly: true
     - id: 3
       type: integer
       name: current_temperature
-      mapping:
-        - scale: 1
     - id: 4
       type: string
       name: operation_mode
@@ -29,7 +28,6 @@ primary_entity:
           conditions:
             - dps_val: false
               value_redirect: power
-              value: "off"
             - dps_val: true
               value: eco
         - dps_val: STANDARD
@@ -45,7 +43,6 @@ primary_entity:
           conditions:
             - dps_val: false
               value_redirect: power
-              value: "off"
             - dps_val: true
               value: high_demand
         - dps_val: HYBRID1
@@ -53,7 +50,6 @@ primary_entity:
           conditions:
             - dps_val: false
               value_redirect: power
-              value: "off"
             - dps_val: true
               value: performance
         - dps_val: ELEMENT
@@ -61,9 +57,11 @@ primary_entity:
           conditions:
             - dps_val: false
               value_redirect: power
-              value: "off"
             - dps_val: true
               value: electric
+    - id: 21
+      type: bitfield
+      name: fault_code
 secondary_entities:
   - entity: binary_sensor
     class: problem

+ 16 - 5
custom_components/tuya_local/generic/water_heater.py

@@ -4,12 +4,11 @@ Platform to control tuya water heater devices.
 import logging
 
 from homeassistant.components.water_heater import (
+    ATTR_CURRENT_TEMPERATURE,
+    ATTR_OPERATION_MODE,
     WaterHeaterEntity,
     WaterHeaterEntityFeature,
 )
-from homeassistant.components.climate.const import (
-    ATTR_CURRENT_TEMPERATURE,
-)
 from homeassistant.const import (
     ATTR_TEMPERATURE,
     TEMP_CELSIUS,
@@ -54,6 +53,8 @@ class TuyaLocalWaterHeater(TuyaLocalEntity, WaterHeaterEntity):
 
         if self._operation_mode_dps:
             self._support_flags |= WaterHeaterEntityFeature.OPERATION_MODE
+        if self._temperature_dps and not self._temperature_dps.readonly:
+            self._support_flags |= WaterHeaterEntityFeature.TARGET_TEMPERATURE
 
     @property
     def supported_features(self):
@@ -113,8 +114,18 @@ class TuyaLocalWaterHeater(TuyaLocalEntity, WaterHeaterEntity):
         return dps.step(self._device)
 
     async def async_set_temperature(self, **kwargs):
-        """Disable changing temperature manually to protect certain devices (e.g., Hydrotherm DYNAMIC/X8)"""
-        raise NotImplementedError()
+        """Set the target temperature of the water heater."""
+        if kwargs.get(ATTR_OPERATION_MODE) is not None:
+            if self._operation_mode_dps is None:
+                raise NotImplementedError()
+            await self.async_set_operation_mode(kwargs.get(ATTR_OPERATION_MODE))
+
+        if kwargs.get(ATTR_TEMPERATURE) is not None:
+            if self._temperature_dps is None:
+                raise NotImplementedError()
+            await self._temperature_dps.async_set_value(
+                self._device, kwargs.get(ATTR_TEMPERATURE)
+            )
 
     async def async_set_operation_mode(self, operation_mode):
         """Set new target operation mode."""

+ 8 - 0
tests/const.py

@@ -1496,3 +1496,11 @@ INKBIRD_SOUSVIDE_PAYLOAD = {
     "109": 0,
     "110": 0,
 }
+
+HYDROTHERM_DYNAMICX8_PAYLOAD = {
+    "1": True,
+    "2": 65,
+    "3": 60,
+    "4": "STANDARD",
+    "21": 0,
+}

+ 2 - 0
tests/devices/base_device_tests.py

@@ -17,6 +17,7 @@ from custom_components.tuya_local.generic.sensor import TuyaLocalSensor
 from custom_components.tuya_local.generic.siren import TuyaLocalSiren
 from custom_components.tuya_local.generic.switch import TuyaLocalSwitch
 from custom_components.tuya_local.generic.vacuum import TuyaLocalVacuum
+from custom_components.tuya_local.generic.water_heater import TuyaLocalWaterHeater
 
 from custom_components.tuya_local.helpers.device_config import (
     TuyaDeviceConfig,
@@ -37,6 +38,7 @@ DEVICE_TYPES = {
     "sensor": TuyaLocalSensor,
     "siren": TuyaLocalSiren,
     "vacuum": TuyaLocalVacuum,
+    "water_heater": TuyaLocalWaterHeater,
 }
 
 

+ 135 - 0
tests/devices/test_hydrotherm_dynamicx8.py

@@ -0,0 +1,135 @@
+from homeassistant.components.binary_sensor import BinarySensorDeviceClass
+from homeassistant.components.water_heater import (
+    STATE_ECO,
+    STATE_ELECTRIC,
+    STATE_HIGH_DEMAND,
+    STATE_HEAT_PUMP,
+    STATE_OFF,
+    STATE_PERFORMANCE,
+    WaterHeaterEntityFeature,
+)
+
+from ..const import HYDROTHERM_DYNAMICX8_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.binary_sensor import BasicBinarySensorTests
+from .base_device_tests import TuyaDeviceTestCase
+
+POWER_DP = "1"
+TEMPERATURE_DP = "2"
+CURRENTTEMP_DP = "3"
+MODE_DP = "4"
+ERROR_DP = "21"
+
+
+class TestHydrothermDynamicX8(
+    BasicBinarySensorTests,
+    TuyaDeviceTestCase,
+):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig(
+            "hydrotherm_dynamic_x8_water_heater.yaml", HYDROTHERM_DYNAMICX8_PAYLOAD
+        )
+        self.subject = self.entities.get("water_heater")
+        self.setUpBasicBinarySensor(
+            ERROR_DP,
+            self.entities.get("binary_sensor_fault"),
+            device_class=BinarySensorDeviceClass.PROBLEM,
+            testdata=(1, 0),
+        )
+        self.mark_secondary(["binary_sensor_fault"])
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            WaterHeaterEntityFeature.OPERATION_MODE,
+        )
+
+    def test_temperature_unit_returns_device_temperature_unit(self):
+        self.assertEqual(
+            self.subject.temperature_unit,
+            self.subject._device.temperature_unit,
+        )
+
+    def test_current_temperature(self):
+        self.dps[CURRENTTEMP_DP] = 55
+        self.assertEqual(self.subject.current_temperature, 55)
+
+    def test_target_temperature(self):
+        self.dps[TEMPERATURE_DP] = 61
+        self.assertEqual(self.subject.target_temperature, 61)
+
+    def test_operation_list(self):
+        self.assertCountEqual(
+            self.subject.operation_list,
+            [
+                STATE_ECO,
+                STATE_ELECTRIC,
+                STATE_HEAT_PUMP,
+                STATE_HIGH_DEMAND,
+                STATE_PERFORMANCE,
+                STATE_OFF,
+            ],
+        )
+
+    def test_current_operation(self):
+        self.dps[POWER_DP] = True
+        self.dps[MODE_DP] = "ECO"
+        self.assertEqual(self.subject.current_operation, STATE_ECO)
+        self.dps[MODE_DP] = "STANDARD"
+        self.assertEqual(self.subject.current_operation, STATE_HEAT_PUMP)
+        self.dps[MODE_DP] = "HYBRID"
+        self.assertEqual(self.subject.current_operation, STATE_HIGH_DEMAND)
+        self.dps[MODE_DP] = "HYBRID1"
+        self.assertEqual(self.subject.current_operation, STATE_PERFORMANCE)
+        self.dps[MODE_DP] = "ELEMENT"
+        self.assertEqual(self.subject.current_operation, STATE_ELECTRIC)
+        self.dps[POWER_DP] = False
+        self.assertEqual(self.subject.current_operation, STATE_OFF)
+
+    async def test_set_temperature_fails(self):
+        with self.assertRaises(TypeError):
+            await self.subject.async_set_temperature(temperature=65)
+
+    async def test_set_operation_mode_to_eco(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWER_DP: True, MODE_DP: "ECO"},
+        ):
+            await self.subject.async_set_operation_mode(STATE_ECO)
+
+    async def test_set_operation_mode_to_heat_pump(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWER_DP: True, MODE_DP: "STANDARD"},
+        ):
+            await self.subject.async_set_operation_mode(STATE_HEAT_PUMP)
+
+    async def test_set_operation_mode_to_electric(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWER_DP: True, MODE_DP: "ELEMENT"},
+        ):
+            await self.subject.async_set_operation_mode(STATE_ELECTRIC)
+
+    async def test_set_operation_mode_to_highdemand(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWER_DP: True, MODE_DP: "HYBRID"},
+        ):
+            await self.subject.async_set_operation_mode(STATE_HIGH_DEMAND)
+
+    async def test_set_operation_mode_to_performance(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWER_DP: True, MODE_DP: "HYBRID1"},
+        ):
+            await self.subject.async_set_operation_mode(STATE_PERFORMANCE)
+
+    async def test_set_operation_mode_to_off(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWER_DP: False},
+        ):
+            await self.subject.async_set_operation_mode(STATE_OFF)

+ 79 - 0
tests/test_water_heater.py

@@ -0,0 +1,79 @@
+"""Tests for the water heater entity."""
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+from unittest.mock import AsyncMock, Mock
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_TYPE,
+    CONF_WATER_HEATER,
+    DOMAIN,
+)
+from custom_components.tuya_local.generic.water_heater import TuyaLocalWaterHeater
+from custom_components.tuya_local.water_heater import async_setup_entry
+
+
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_TYPE: "hydrotherm_dynamic_x8_water_heater", CONF_DEVICE_ID: "dummy"},
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    hass.data[DOMAIN]["dummy"]["device"] = m_device
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert type(hass.data[DOMAIN]["dummy"][CONF_WATER_HEATER]) == TuyaLocalWaterHeater
+    m_add_entities.assert_called_once()
+
+
+async def test_init_entry_fails_if_device_has_no_water_heater(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_TYPE: "kogan_switch", CONF_DEVICE_ID: "dummy"},
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    hass.data[DOMAIN]["dummy"]["device"] = m_device
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()
+
+
+async def test_init_entry_fails_if_config_is_missing(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_TYPE: "non_existing", CONF_DEVICE_ID: "dummy"},
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    hass.data[DOMAIN]["dummy"]["device"] = m_device
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()