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

Add fan entity configurations for Goldair Dehumidifier and Eanons humidifier.

Add flag for deprecation and deprecate climate entities for those devices that now support fans.  Add a warning to the log when setting up deprecated entities.
Jason Rumney 4 лет назад
Родитель
Сommit
532fb44d5c

+ 7 - 4
README.md

@@ -80,11 +80,11 @@ You can easily configure your devices using the Integrations UI at `Home Assista
 
 #### 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`, `gardenpac_heatpump`, `purline_m100_heater`  or `kogan_switch`.  Note that the type is likely to change in future to be a configuration file name or product id, as the hardcoded list is a maintenance burden.
+    _(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`, `remora_heatpump`,  or `kogan_switch`.  Note that the type is likely to change in future to be a configuration file name or product id, as the hardcoded list is a maintenance burden.
 
 #### climate
 
-    _(boolean) (Optional)_ Whether to surface this appliance as a climate device. (supported for heaters, fans, heatpumps, dehumidifiers and humidifiers)
+    _(boolean) (Optional)_ Whether to surface this appliance as a climate device. (supported for heaters, heatpumps, deprecated for fans, dehumidifiers and humidifiers which should use the fan and humidifier entities instead)
 
 #### display_light
 
@@ -118,11 +118,14 @@ When setting the target temperature, different heaters have different behaviour,
 
 ## Fan gotchas
 
-In my experience, Goldair fans can be a bit flaky. If they become unresponsive, give them about 60 seconds to wake up again.
+Fans should be configured as `fan` entities, with any auxilary functions such as panel lighting control, child locks or additional switches configured as `light`, `lock` or `switch` entities.  Configuration of Goldair fans as `climate` entities is supported for backward compatibility but is deprecated, and may be removed in future.
+
+Reportedly, Goldair fans can be a bit flaky. If they become unresponsive, give them about 60 seconds to wake up again.
 
 ## Humidifiers and dehumidifiers
 
-Dehumidifiers can be represented either by the humidifier or the climate entity type. There are advantages and disadvantages to both.  Humidifiers can also be represented by the climate entity type, however the on state will show as "Dry", since the climate component does not have a "Humidify" mode.  The climate component has built in support for temperature and humidity sensors, and fan control, while the humidifier component does not.  The default card for a humidifier component will display and allow adjustment of the target humidity, while the climate card expects to work with temperature.
+Humidifiers and Dehumidifiers should be configuured as `humidifier` entities, probably with `fan` entities as well if the fan speed can also be controlled, and any other auxilary features such as panel lighting, child locks or additional switches configured as `light`, `lock` or `switch` entities.  Configration of Goldair Dehumidifiers and Eanons Humidifiers as `climate` entities is also supported for backwards compatibility, but is deprecated and may be removed in future.  In particular, when humidifiers are represented as `climate` entities, the running mode will show as `Dry`, as the climate entity only supports functions commonly found on air conditioners/heatpumps.
+
 
 ## Kogan Switch gotchas
 

+ 2 - 0
custom_components/tuya_local/climate.py

@@ -35,6 +35,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
                 break
         if ecfg.entity != "climate":
             raise ValueError(f"{device.name} does not support use as a climate device.")
+    if ecfg.deprecated:
+        _LOGGER.warning(ecfg.deprecation_message)
 
     legacy_class = ecfg.legacy_class
     # Transition: generic climate entity exists, but is not complete. More

+ 17 - 10
custom_components/tuya_local/devices/eanons_humidifier.yaml

@@ -5,16 +5,6 @@ primary_entity:
   name: Humidifier
   class: humidifier
   dps:
-    - id: 2
-      name: intensity
-      type: string
-      mapping:
-        - dps_val: small
-          value: low
-        - dps_val: middle
-          value: medium
-        - dps_val: large
-          value: high
     - id: 3
       name: timer_hr
       type: string
@@ -52,7 +42,24 @@ primary_entity:
       name: current_humidity
       type: integer
 secondary_entities:
+  - entity: fan
+    name: Intensity
+    dps:
+      - id: 2
+        type: string
+        name: preset_mode
+        mapping:
+          - dps_val: small
+            value: low
+          - dps_val: middle
+            value: medium
+          - dps_val: large
+            value: high
+      - id: 10
+        type: boolean
+        name: switch
   - entity: climate
+    deprecated: humidifier and fan
     dps:
       - id: 2
         name: fan_mode

+ 31 - 9
custom_components/tuya_local/devices/goldair_dehumidifier.yaml

@@ -2,7 +2,6 @@ name: Goldair Dehumidifier
 legacy_type: dehumidifier
 primary_entity:
   entity: humidifier
-  name: Dehumidifier
   class: dehumidifier
   dps:
     - id: 1
@@ -40,14 +39,6 @@ primary_entity:
     - id: 5
       type: boolean
       name: air_clean_on
-    - id: 6
-      type: string
-      mapping:
-        - dps_val: "1"
-          value: "low"
-        - dps_val: "3"
-          value: "high"
-      name: fan_mode
     - id: 11
       type: bitfield
       mapping:
@@ -83,8 +74,39 @@ primary_entity:
           icon_priority: 2
       readonly: true
 secondary_entities:
+  - entity: fan
+    name: Fan
+    dps:
+      - id: 1
+        type: boolean
+        name: switch
+      - id: 2
+        name: dehumidifier_mode
+        type: string
+        hidden: true
+      - id: 6
+        type: string
+        name: preset_mode
+        mapping:
+          - dps_val: "1"
+            value: "low"
+          - dps_val: "3"
+            value: "high"
+        constraint: dehumidifier_mode
+        conditions:
+          - dps_val: "1"
+            value: "low"
+            invalid: true
+          - dps_val: "2"
+            value: "high"
+            invalid: true
+          - dps_val: "3"
+            value: "high"
+            invalid: true
   - entity: climate
     legacy_class: ".dehumidifier.climate.GoldairDehumidifier"
+    name: Dehumidifier as Climate
+    deprecated: humidifier and fan
     dps:
       - id: 1
         type: boolean

+ 3 - 0
custom_components/tuya_local/fan.py

@@ -36,6 +36,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
         if ecfg.entity != "fan":
             raise ValueError(f"{device.name} does not support use as a fan device.")
 
+    if ecfg.deprecated:
+        _LOGGER.warning(ecfg.deprecation_message)
+
     data[CONF_FAN] = TuyaLocalFan(device, ecfg)
 
     async_add_entities([data[CONF_FAN]])

+ 26 - 6
custom_components/tuya_local/helpers/device_config.py

@@ -58,7 +58,7 @@ class TuyaDeviceConfig:
     @property
     def legacy_type(self):
         """Return the legacy conf_type associated with this device."""
-        return self._config.get("legacy_type", None)
+        return self._config.get("legacy_type")
 
     @property
     def primary_entity(self):
@@ -134,16 +134,36 @@ class TuyaEntityConfig:
     @property
     def name(self):
         """The friendly name for this entity."""
-        return self._config.get("name", self._device.name)
+        own_name = self._config.get("name")
+        if own_name is None:
+            return self._device.name
+        else:
+            return self._device.name + " " + own_name
 
     @property
     def legacy_class(self):
         """Return the legacy device corresponding to this config."""
-        name = self._config.get("legacy_class", None)
+        name = self._config.get("legacy_class")
         if name is None:
             return None
         return locate("custom_components.tuya_local" + name)
 
+    @property
+    def deprecated(self):
+        """Return whether this entitiy is deprecated."""
+        return "deprecated" in self._config.keys()
+
+    @property
+    def deprecation_message(self):
+        """Return a deprecation message for this entity"""
+        replacement = self._config.get(
+            "deprecated", "nothing, this warning has been raised in error"
+        )
+        return (
+            f"The use of {self.entity} for {self._device.name} is"
+            f"deprecated and should be replaced by {replacement}"
+        )
+
     @property
     def entity(self):
         """The entity type of this entity."""
@@ -152,7 +172,7 @@ class TuyaEntityConfig:
     @property
     def device_class(self):
         """The device class of this entity."""
-        return self._config.get("class", None)
+        return self._config.get("class")
 
     def dps(self):
         """Iterate through the list of dps for this entity."""
@@ -188,7 +208,7 @@ class TuyaDpsConfig:
             "float": float,
             "bitfield": int,
         }
-        return types.get(t, None)
+        return types.get(t)
 
     @property
     def name(self):
@@ -323,7 +343,7 @@ class TuyaDpsConfig:
             scale = mapping.get("scale", 1)
             if not isinstance(scale, (int, float)):
                 scale = 1
-            step = mapping.get("step", None)
+            step = mapping.get("step")
             if not isinstance(step, (int, float)):
                 step = None
             if "dps_val" in mapping:

+ 2 - 0
custom_components/tuya_local/humidifier.py

@@ -37,6 +37,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
             raise ValueError(
                 f"{device.name} does not support use as a humidifier device."
             )
+    if ecfg.deprecated:
+        _LOGGER.warning(ecfg.deprecation_message)
 
     data[CONF_HUMIDIFIER] = TuyaLocalHumidifier(device, ecfg)
 

+ 2 - 0
custom_components/tuya_local/light.py

@@ -35,6 +35,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
                 break
         if ecfg.entity != "light":
             raise ValueError(f"{device.name} does not support use as a light device.")
+    if ecfg.deprecated:
+        _LOGGER.warning(ecfg.deprecation_message)
 
     data[CONF_DISPLAY_LIGHT] = TuyaLocalLight(device, ecfg)
     async_add_entities([data[CONF_DISPLAY_LIGHT]])

+ 2 - 0
custom_components/tuya_local/lock.py

@@ -36,6 +36,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
                 break
         if ecfg.entity != "lock":
             raise ValueError(f"{device.name} does not support use as a lock device.")
+    if ecfg.deprecated:
+        _LOGGER.warning(ecfg.deprecation_message)
 
     data[CONF_CHILD_LOCK] = TuyaLocalLock(device, ecfg)
     async_add_entities([data[CONF_CHILD_LOCK]])

+ 3 - 0
custom_components/tuya_local/switch.py

@@ -36,6 +36,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
         if ecfg.entity != "switch":
             raise ValueError(f"{device.name} does not support use as a switch device.")
 
+    if ecfg.deprecated:
+        _LOGGER.warning(ecfg.deprecation_message)
+
     data[CONF_SWITCH] = TuyaLocalSwitch(device, ecfg)
     async_add_entities([data[CONF_SWITCH]])
     _LOGGER.debug(f"Adding switch for {discovery_info[CONF_TYPE]}")

+ 0 - 1
tests/devices/test_eanons_humidifier.py

@@ -309,7 +309,6 @@ class TestEanonsHumidifier(IsolatedAsyncioTestCase):
                 "timer_hr": "cancel",
                 "timer_min": 0,
                 "current_humidity": 50,
-                "intensity": FAN_MEDIUM,
             },
         )
 

+ 36 - 4
tests/devices/test_goldair_dehumidifier.py

@@ -15,6 +15,7 @@ from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED
 from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
 
 from custom_components.tuya_local.generic.climate import TuyaLocalClimate
+from custom_components.tuya_local.generic.fan import TuyaLocalFan
 from custom_components.tuya_local.generic.humidifier import TuyaLocalHumidifier
 from custom_components.tuya_local.generic.light import TuyaLocalLight
 from custom_components.tuya_local.generic.lock import TuyaLocalLock
@@ -67,6 +68,7 @@ class TestGoldairDehumidifier(IsolatedAsyncioTestCase):
         self.humidifier_name = (
             "missing" if "humidifier" not in entities else entities["humidifier"].name
         )
+        self.fan_name = "missing" if "fan" not in entities else entities["fan"].name
 
         self.subject = TuyaLocalClimate(self.mock_device(), entities.get("climate"))
         self.light = TuyaLocalLight(self.mock_device(), entities.get("light"))
@@ -74,6 +76,7 @@ class TestGoldairDehumidifier(IsolatedAsyncioTestCase):
         self.humidifier = TuyaLocalHumidifier(
             self.mock_device(), entities.get("humidifier")
         )
+        self.fan = TuyaLocalFan(self.mock_device(), entities.get("fan"))
 
         self.dps = DEHUMIDIFIER_PAYLOAD.copy()
         self.subject._device.get_property.side_effect = lambda id: self.dps[id]
@@ -86,30 +89,35 @@ class TestGoldairDehumidifier(IsolatedAsyncioTestCase):
 
     def test_should_poll(self):
         self.assertTrue(self.subject.should_poll)
+        self.assertTrue(self.fan.should_poll)
         self.assertTrue(self.light.should_poll)
         self.assertTrue(self.lock.should_poll)
         self.assertTrue(self.humidifier.should_poll)
 
     def test_name_returns_device_name(self):
         self.assertEqual(self.subject.name, self.subject._device.name)
+        self.assertEqual(self.fan.name, self.subject._device.name)
         self.assertEqual(self.light.name, self.subject._device.name)
         self.assertEqual(self.lock.name, self.subject._device.name)
         self.assertEqual(self.humidifier.name, self.subject._device.name)
 
     def test_friendly_name_returns_config_name(self):
         self.assertEqual(self.subject.friendly_name, self.climate_name)
+        self.assertEqual(self.fan.friendly_name, self.fan_name)
         self.assertEqual(self.light.friendly_name, self.light_name)
         self.assertEqual(self.lock.friendly_name, self.lock_name)
         self.assertEqual(self.humidifier.friendly_name, self.humidifier_name)
 
     def test_unique_id_returns_device_unique_id(self):
         self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+        self.assertEqual(self.fan.unique_id, self.subject._device.unique_id)
         self.assertEqual(self.light.unique_id, self.subject._device.unique_id)
         self.assertEqual(self.lock.unique_id, self.subject._device.unique_id)
         self.assertEqual(self.humidifier.unique_id, self.subject._device.unique_id)
 
     def test_device_info_returns_device_info_from_device(self):
         self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+        self.assertEqual(self.fan.device_info, self.subject._device.device_info)
         self.assertEqual(self.light.device_info, self.subject._device.device_info)
         self.assertEqual(self.lock.device_info, self.subject._device.device_info)
         self.assertEqual(self.humidifier.device_info, self.subject._device.device_info)
@@ -450,13 +458,16 @@ class TestGoldairDehumidifier(IsolatedAsyncioTestCase):
         self.dps[FANMODE_DPS] = "1"
         self.dps[PRESET_DPS] = PRESET_HIGH
         self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+        self.assertEqual(self.fan.preset_mode, FAN_HIGH)
 
         self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
         self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+        self.assertEqual(self.fan.preset_mode, FAN_HIGH)
 
         self.dps[PRESET_DPS] = PRESET_NORMAL
         self.dps[AIRCLEAN_DPS] = True
         self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+        self.assertEqual(self.subject.preset_mode, FAN_HIGH)
 
     @skip("Conditions not supported yet")
     def test_fan_mode_is_forced_to_low_in_low_preset(self):
@@ -464,39 +475,44 @@ class TestGoldairDehumidifier(IsolatedAsyncioTestCase):
         self.dps[PRESET_DPS] = PRESET_LOW
 
         self.assertEqual(self.subject.fan_mode, FAN_LOW)
+        self.assertEqual(self.fan.preset_mode, FAN_LOW)
 
     def test_fan_mode_reflects_dps_mode_in_normal_preset(self):
         self.dps[PRESET_DPS] = PRESET_NORMAL
         self.dps[FANMODE_DPS] = "1"
         self.assertEqual(self.subject.fan_mode, FAN_LOW)
+        self.assertEqual(self.fan.preset_mode, FAN_LOW)
 
         self.dps[FANMODE_DPS] = "3"
         self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+        self.assertEqual(self.fan.preset_mode, FAN_HIGH)
 
         self.dps[FANMODE_DPS] = None
         self.assertEqual(self.subject.fan_mode, None)
+        self.assertEqual(self.fan.preset_mode, None)
 
     @skip("Conditions not supported yet")
     def test_fan_modes_reflect_preset_mode(self):
         self.dps[PRESET_DPS] = PRESET_NORMAL
         self.assertCountEqual(self.subject.fan_modes, [FAN_LOW, FAN_HIGH])
+        self.assertCountEqual(self.fan.preset_modes, [FAN_LOW, FAN_HIGH])
 
         self.dps[PRESET_DPS] = PRESET_LOW
         self.assertEqual(self.subject.fan_modes, [FAN_LOW])
+        self.assertEqual(self.fan.preset_modes, [FAN_LOW])
 
         self.dps[PRESET_DPS] = PRESET_HIGH
         self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
+        self.assertEqual(self.fan.preset_modes, [FAN_HIGH])
 
         self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
         self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
+        self.assertEqual(self.fan.preset_modes, [FAN_HIGH])
 
         self.dps[PRESET_DPS] = PRESET_NORMAL
         self.dps[AIRCLEAN_DPS] = True
         self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
-
-        self.dps[PRESET_DPS] = None
-        self.dps[AIRCLEAN_DPS] = False
-        self.assertEqual(self.subject.fan_modes, [])
+        self.assertEqual(self.fan.preset_modes, [FAN_HIGH])
 
     async def test_set_fan_mode_to_low_succeeds_in_normal_preset(self):
         self.dps[PRESET_DPS] = PRESET_NORMAL
@@ -514,6 +530,22 @@ class TestGoldairDehumidifier(IsolatedAsyncioTestCase):
         ):
             await self.subject.async_set_fan_mode(FAN_HIGH)
 
+    async def test_set_fan_preset_to_low_succeeds_in_normal_preset(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        async with assert_device_properties_set(
+            self.fan._device,
+            {FANMODE_DPS: "1"},
+        ):
+            await self.fan.async_set_preset_mode(FAN_LOW)
+
+    async def test_set_fan_preset_to_high_succeeds_in_normal_preset(self):
+        self.dps[PRESET_DPS] = PRESET_NORMAL
+        async with assert_device_properties_set(
+            self.fan._device,
+            {FANMODE_DPS: "3"},
+        ):
+            await self.fan.async_set_preset_mode(FAN_HIGH)
+
     @skip("Restriction to listed options not supported yet")
     async def test_set_fan_mode_fails_with_invalid_mode(self):
         self.dps[PRESET_DPS] = PRESET_NORMAL