Przeglądaj źródła

Add support for Hysen HY08WE-2 thermometer

Issue #104
Jason Rumney 4 lat temu
rodzic
commit
529734816e

+ 1 - 0
ACKNOWLEDGEMENTS.md

@@ -67,4 +67,5 @@ Further device support has been made with the assistance of users.  Please consi
  - [jdavidr17](https://github.com/jdavidr17) for assistance with discovering timer parameters for switches.
  - [miannelli516](https://github.com/miannelli516) for assistance with TR9B thermostats.
  - [edwinyoo44](https://github.com/edwinyoo44) for contributing support for JJPro JPD01 dehumidifiers.
+ - [mpetcuRO](https://github.com/mpetcuRO) for assistance with Hysen HT08WE-2 thermometers.
  

+ 5 - 3
README.md

@@ -13,9 +13,10 @@ this should not be seen as a security measure, rather it improves
 speed and reliability by using local connections, and may unlock some
 features of your device, or even unlock whole devices, that are not
 supported by the Tuya cloud API.  Currently the focus is mainly on
-climate devices, which are not well supported by other similar
+more complex devices, which are not well supported by other similar
 integrations. Simpler devices like switches and lights can be covered
-by [rospogrigio/localtuya](https://github.com/rospogrigio/localtuya/).
+by [rospogrigio/localtuya](https://github.com/rospogrigio/localtuya/),
+though some switches are now covered by this integration.
 
 ---
 
@@ -79,6 +80,7 @@ the device will not work despite being listed below.
 - Minco MH-1823D thermostat
 - Owon PCT513 thermostat
 - Beok TR9B thermostat (rebadged as Vancoo and perhaps others)
+- Hysen HY08WE-2 thermostat
 
 ### Kettles
 - Kogan Glass 1.7L Smart Kettle (not reliably detected)
@@ -114,7 +116,7 @@ the device will not work despite being listed below.
   confirmed as working with Kogan Single Smartplugs
 - Generic Smartplug with Energy monitoring (newer models)
   confirmed working with Kogan single smartplug with USB and Rillpac smartplugs
-- Generic Smartplg with more advanced energy monitoring
+- Generic Smartplug with more advanced energy monitoring
   confirmed working with CBE smartplugs
 - Mirabella Genio Smart plug with USB
 - Grid Connect double outlet with Energy Monitoring, Master and Individual switches and Child Lock.

+ 240 - 0
custom_components/tuya_local/devices/hysen_hy08we2_thermostat.yaml

@@ -0,0 +1,240 @@
+name: Hysen HY08WE-2 Thermostat
+primary_entity:
+  entity: climate
+  dps:
+    - id: 1
+      type: boolean
+      name: hvac_mode
+      mapping:
+        - dps_val: true
+          value: heat
+        - dps_val: false
+          value: "off"
+    - id: 2
+      type: integer
+      name: temperature
+      range:
+        min: 5
+        max: 1220
+      mapping:
+        - scale: 10
+          step: 5
+    - id: 3
+      type: integer
+      name: current_temperature
+      mapping:
+        - scale: 10
+    - id: 4
+      type: string
+      name: preset_mode
+      mapping:
+        - dps_val: Manual
+          value: Manual
+        - dps_val: Program
+          value: Program
+        - dps_val: TempProg
+          value: Program Override
+        - dps_val: Holiday
+          value: Holiday
+    - id: 12
+      type: integer
+      name: unknown_12
+    - id: 101
+      type: boolean
+      name: unknown_101
+    - id: 102
+      type: boolean
+      name: hvac_action
+      mapping:
+        - dps_val: true
+          value: heating
+        - dps_val: false
+          constraint: hvac_mode
+          conditions:
+            - dps_val: false
+              value: "off"
+            - dps_val: true
+              value: idle
+    - id: 106
+      type: boolean
+      name: unknown_106
+    - id: 107
+      type: boolean
+      name: unknown_107
+    - id: 108
+      type: boolean
+      name: unknown_108
+    - id: 114
+      type: integer
+      name: max_temperature
+    - id: 115
+      type: integer
+      name: min_temperature
+secondary_entities:
+  - entity: lock
+    name: Child Lock
+    category: config
+    dps:
+      - id: 6
+        type: boolean
+        name: lock
+        mapping:
+          - dps_val: True
+            icon: "mdi:hand-back-right-off"
+          - dps_val: False
+            icon: "mdi:hand-back-right"
+  - entity: sensor
+    name: External Temperature
+    class: temperature
+    dps:
+      - id: 103
+        type: integer
+        name: sensor
+  - entity: number
+    name: Holiday Days
+    category: config
+    dps:
+      - id: 104
+        type: integer
+        unit: d
+        name: value
+        range:
+          min: 1
+          max: 30
+  - entity: number
+    name: Holiday Temperature
+    category: config
+    dps:
+      - id: 105
+        type: integer
+        unit: C
+        name: value
+      - id: 114
+        type: integer
+        name: maximum
+      - id: 115
+        type: integer
+        name: minimum
+  - entity: number
+    name: Calibration Offset
+    category: config
+    dps:
+    - id: 109
+      type: integer
+      name: value
+      unit: C
+      range:
+        min: -9
+        max: 9
+  - entity: number
+    name: Calibration Swing Internal
+    category: config
+    dps:
+      - id: 110
+        type: integer
+        name: value
+        unit: C
+        range:
+          min: 5
+          max: 25
+        mapping:
+          - scale: 10
+  - entity: number
+    name: Calibration Swing External
+    category: config
+    dps:
+      - id: 111
+        type: integer
+        name: value
+        unit: C
+        range:
+          min: 1
+          max: 10
+        mapping:
+          - scale: 10
+  - entity: number
+    name: High Temperature Protection
+    category: config
+    dps:
+      - id: 112
+        type: integer
+        name: value
+        unit: C
+        range:
+          min: 35
+          max: 70
+  - entity: number
+    name: Low Temperature Protection
+    category: config
+    dps:
+      - id: 113
+        type: integer
+        name: value
+        unit: C
+        range:
+          min: 1
+          max: 10
+  - entity: number
+    name: High Temperature Limit
+    category: config
+    dps:
+      - id: 114
+        type: integer
+        name: value
+        unit: C
+        range:
+          min: 2
+          max: 70
+  - entity: number
+    name: Low Temperature Limit
+    category: config
+    dps:
+      - id: 115
+        type: integer
+        name: value
+        unit: C
+        range:
+          min: 1
+          max: 10
+  - entity: select
+    name: Temperature Sensor
+    category: config
+    dps:
+      - id: 116
+        type: string
+        name: option
+        mapping:
+          - dps_val: in
+            value: Internal
+          - dps_val: ext
+            value: External
+          - dps_val: all
+            value: Both
+  - entity: select
+    name: Initial State
+    category: config
+    dps:
+      - id: 117
+        type: string
+        name: option
+        mapping:
+          - dps_val: keep
+            value: Previous
+          - dps_val: "off"
+            value: "Off"
+          - dps_val: "on"
+            value: "On"
+  - entity: select
+    name: Schedule
+    category: config
+    dps:
+      - id: 118
+        type: string
+        name: option
+        mapping:
+          - dps_val: 0days
+            value: 7 day
+          - dps_val: 1days
+            value: 6 + 1 day
+          - dps_val: 2days
+            value: 5 + 2 day

+ 6 - 0
custom_components/tuya_local/generic/number.py

@@ -29,15 +29,21 @@ class TuyaLocalNumber(TuyaLocalEntity, NumberEntity):
         if self._value_dps is None:
             raise AttributeError(f"{config.name} is missing a value dps")
         self._unit_dps = dps_map.pop("unit", None)
+        self._min_dps = dps_map.pop("minimum", None)
+        self._max_dps = dps_map.pop("maximum", None)
         self._init_end(dps_map)
 
     @property
     def min_value(self):
+        if self._min_dps is not None:
+            return self._min_dps.get_value(self._device)
         r = self._value_dps.range(self._device)
         return DEFAULT_MIN_VALUE if r is None else r["min"]
 
     @property
     def max_value(self):
+        if self._max_dps is not None:
+            return self._max_dps.get_value(self._device)
         r = self._value_dps.range(self._device)
         return DEFAULT_MAX_VALUE if r is None else r["max"]
 

+ 12 - 0
custom_components/tuya_local/translations/en.json

@@ -54,11 +54,17 @@
 		    "number_calibration_offset_external": "Include calibration offset for external sensor as a number entity",
 		    "number_calibration_offset_internal": "Include calibration offset for internal sensor as a number entity",
 		    "number_calibration_swing": "Include calibration swing as a number entity",
+		    "number_calibration_swing_external": "Include calibration swing for external sensor as a number entity",
+		    "number_calibration_swing_internal": "Include calibration swing for internal sensor as a number entity",
 		    "number_continuous_heat_hours": "Include Continuous Heating Time as a number entity",
 		    "number_fan_runtime": "Include fan run time as a number entity",
 		    "number_high_temperature_limit": "Include High Temperature Limit as a number entity",
 		    "number_low_temperature_limit": "Include Low Temperature Limit as a number entity",
 		    "number_floor_temperature_limit": "Include Floor Temperature Limit as a number entity",
+		    "number_high_temperature_protection": "Include High Temperature Protection as a number entity",
+		    "number_low_temperature_protection": "Include Low Temperature Protection as a number entity",
+		    "number_holiday_days": "Include Holiday Days as a number entity",
+		    "number_holiday_temperature": "Include Holiday Temperature as a number entity",
 		    "number_power_rating": "Include Power Rating as a number entity",
 		    "number_timer": "Include timer as a number entity",
 		    "number_timer_1": "Include timer 1 as a number entity",
@@ -162,11 +168,17 @@
 		    "number_calibration_offset_external": "Include calibration offset for external sensor as a number entity",
 		    "number_calibration_offset_internal": "Include calibration offset for internal sensor as a number entity",
 		    "number_calibration_swing": "Include calibration swing as a number entity",
+		    "number_calibration_swing_external": "Include calibration swing for external sensor as a number entity",
+		    "number_calibration_swing_internal": "Include calibration swing for internal sensor as a number entity",
 		    "number_continuous_heat_hours": "Include Continuous Heating Time as a number entity",
 		    "number_fan_runtime": "Include fan run time as a number entity",
 		    "number_high_temperature_limit": "Include High Temperature Limit as a number entity",
 		    "number_low_temperature_limit": "Include Low Temperature Limit as a number entity",
 		    "number_floor_temperature_limit": "Include Floor Temperature Limit as a number entity",
+		    "number_high_temperature_protection": "Include High Temperature Protection as a number entity",
+		    "number_low_temperature_protection": "Include Low Temperature Protection as a number entity",
+		    "number_holiday_days": "Include Holiday Days as a number entity",
+		    "number_holiday_temperature": "Include Holiday Temperature as a number entity",
 		    "number_power_rating": "Include Power Rating as a number entity",
 		    "number_timer": "Include timer as a number entity",
 		    "number_timer_1": "Include timer 1 as a number entity",

+ 27 - 0
tests/const.py

@@ -788,3 +788,30 @@ OWON_PCT513_THERMOSTAT_PAYLOAD = {
     "123": 25,
     "129": "coolfanon",
 }
+
+HYSEN_HY08WE2_THERMOSTAT_PAYLOAD = {
+    "1": True,
+    "2": 50,
+    "3": 170,
+    "4": "Manual",
+    "6": False,
+    "12": 0,
+    "101": False,
+    "102": False,
+    "103": 170,
+    "104": 4,
+    "105": 15,
+    "106": True,
+    "107": True,
+    "108": True,
+    "109": -10,
+    "110": 10,
+    "111": 2,
+    "112": 35,
+    "113": 5,
+    "114": 30,
+    "115": 5,
+    "116": "all",
+    "117": "keep",
+    "118": "2days",
+}

+ 307 - 0
tests/devices/test_hysen_hy08we2_thermostat.py

@@ -0,0 +1,307 @@
+from homeassistant.components.climate.const import (
+    CURRENT_HVAC_HEAT,
+    CURRENT_HVAC_IDLE,
+    CURRENT_HVAC_OFF,
+    HVAC_MODE_HEAT,
+    HVAC_MODE_OFF,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.const import (
+    DEVICE_CLASS_TEMPERATURE,
+    STATE_UNAVAILABLE,
+    TEMP_CELSIUS,
+    TIME_DAYS,
+)
+
+from ..const import HYSEN_HY08WE2_THERMOSTAT_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.climate import TargetTemperatureTests
+from ..mixins.lock import BasicLockTests
+from ..mixins.number import MultiNumberTests
+from ..mixins.select import MultiSelectTests
+from ..mixins.sensor import BasicSensorTests
+from .base_device_tests import TuyaDeviceTestCase
+
+HVACMODE_DPS = "1"
+TEMPERATURE_DPS = "2"
+CURRENTTEMP_DPS = "3"
+PRESET_DPS = "4"
+LOCK_DPS = "6"
+UNKNOWN12_DPS = "12"
+UNKNOWN101_DPS = "101"
+HVACACTION_DPS = "102"
+EXTTEMP_DPS = "103"
+HOLIDAYS_DPS = "104"
+HOLIDAYTEMP_DPS = "105"
+UNKNOWN106_DPS = "106"
+UNKNOWN107_DPS = "107"
+UNKNOWN108_DPS = "108"
+CALIBOFFSET_DPS = "109"
+CALIBSWINGINT_DPS = "110"
+CALIBSWINGEXT_DPS = "111"
+HIGHTEMP_DPS = "112"
+LOWTEMP_DPS = "113"
+MAXTEMP_DPS = "114"
+MINTEMP_DPS = "115"
+SENSOR_DPS = "116"
+INITIAL_DPS = "117"
+SCHED_DPS = "118"
+
+
+class TestHysenHY08WE2Thermostat(
+    BasicLockTests,
+    MultiNumberTests,
+    MultiSelectTests,
+    BasicSensorTests,
+    TargetTemperatureTests,
+    TuyaDeviceTestCase,
+):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig(
+            "hysen_hy08we2_thermostat.yaml",
+            HYSEN_HY08WE2_THERMOSTAT_PAYLOAD,
+        )
+        self.subject = self.entities.get("climate")
+        self.setUpTargetTemperature(
+            TEMPERATURE_DPS,
+            self.subject,
+            min=5.0,
+            max=30.0,
+            scale=10,
+            step=5,
+        )
+        self.setUpBasicLock(LOCK_DPS, self.entities.get("lock_child_lock"))
+        self.setUpMultiSelect(
+            [
+                {
+                    "dps": SCHED_DPS,
+                    "name": "select_schedule",
+                    "options": {
+                        "2days": "5 + 2 day",
+                        "1days": "6 + 1 day",
+                        "0days": "7 day",
+                    },
+                },
+                {
+                    "dps": INITIAL_DPS,
+                    "name": "select_initial_state",
+                    "options": {
+                        "keep": "Previous",
+                        "on": "On",
+                        "off": "Off",
+                    },
+                },
+                {
+                    "dps": SENSOR_DPS,
+                    "name": "select_temperature_sensor",
+                    "options": {
+                        "in": "Internal",
+                        "ext": "External",
+                        "all": "Both",
+                    },
+                },
+            ],
+        )
+        self.setUpBasicSensor(
+            EXTTEMP_DPS,
+            self.entities.get("sensor_external_temperature"),
+            device_class=DEVICE_CLASS_TEMPERATURE,
+        )
+        self.setUpMultiNumber(
+            [
+                {
+                    "dps": HOLIDAYS_DPS,
+                    "name": "number_holiday_days",
+                    "min": 1,
+                    "max": 30,
+                    "unit": TIME_DAYS,
+                },
+                {
+                    "dps": HOLIDAYTEMP_DPS,
+                    "name": "number_holiday_temperature",
+                    "min": 5,
+                    "max": 30,
+                    "unit": TEMP_CELSIUS,
+                },
+                {
+                    "dps": CALIBOFFSET_DPS,
+                    "name": "number_calibration_offset",
+                    "min": -9,
+                    "max": 9,
+                    "unit": TEMP_CELSIUS,
+                },
+                {
+                    "dps": CALIBSWINGINT_DPS,
+                    "name": "number_calibration_swing_internal",
+                    "min": 0.5,
+                    "max": 2.5,
+                    "scale": 10,
+                    "step": 0.1,
+                    "unit": TEMP_CELSIUS,
+                },
+                {
+                    "dps": CALIBSWINGEXT_DPS,
+                    "name": "number_calibration_swing_external",
+                    "min": 0.1,
+                    "max": 1.0,
+                    "scale": 10,
+                    "step": 0.1,
+                    "unit": TEMP_CELSIUS,
+                },
+                {
+                    "dps": HIGHTEMP_DPS,
+                    "name": "number_high_temperature_protection",
+                    "min": 35,
+                    "max": 70,
+                    "unit": TEMP_CELSIUS,
+                },
+                {
+                    "dps": LOWTEMP_DPS,
+                    "name": "number_low_temperature_protection",
+                    "min": 1,
+                    "max": 10,
+                    "unit": TEMP_CELSIUS,
+                },
+                {
+                    "dps": MINTEMP_DPS,
+                    "name": "number_low_temperature_limit",
+                    "min": 1,
+                    "max": 10,
+                    "unit": TEMP_CELSIUS,
+                },
+                {
+                    "dps": MAXTEMP_DPS,
+                    "name": "number_high_temperature_limit",
+                    "min": 2,
+                    "max": 70,
+                    "unit": TEMP_CELSIUS,
+                },
+            ],
+        )
+        self.mark_secondary(
+            [
+                "lock_child_lock",
+                "number_holiday_days",
+                "number_holiday_temperature",
+                "number_calibration_offset",
+                "number_calibration_swing_internal",
+                "number_calibration_swing_external",
+                "number_high_temperature_protection",
+                "number_low_temperature_protection",
+                "number_low_temperature_limit",
+                "number_high_temperature_limit",
+                "select_temperature_sensor",
+                "select_initial_state",
+                "select_schedule",
+            ],
+        )
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE,
+        )
+
+    def test_current_temperature(self):
+        self.dps[CURRENTTEMP_DPS] = 685
+        self.assertEqual(self.subject.current_temperature, 68.5)
+
+    def test_hvac_mode(self):
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+
+        self.dps[HVACMODE_DPS] = True
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_HEAT)
+
+        self.dps[HVACMODE_DPS] = None
+        self.assertEqual(self.subject.hvac_mode, STATE_UNAVAILABLE)
+
+    def test_hvac_modes(self):
+        self.assertCountEqual(
+            self.subject.hvac_modes,
+            [
+                HVAC_MODE_HEAT,
+                HVAC_MODE_OFF,
+            ],
+        )
+
+    async def test_set_hvac_mode_heat(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {HVACMODE_DPS: True},
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_HEAT)
+
+    async def test_set_hvac_mode_off(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {HVACMODE_DPS: False},
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_hvac_action(self):
+        self.dps[HVACMODE_DPS] = True
+        self.dps[HVACACTION_DPS] = True
+        self.assertEqual(self.subject.hvac_action, CURRENT_HVAC_HEAT)
+        self.dps[HVACACTION_DPS] = False
+        self.assertEqual(self.subject.hvac_action, CURRENT_HVAC_IDLE)
+        self.dps[HVACMODE_DPS] = False
+        self.assertEqual(self.subject.hvac_action, CURRENT_HVAC_OFF)
+
+    def test_preset_modes(self):
+        self.assertCountEqual(
+            self.subject.preset_modes,
+            [ "Manual", "Program", "Program Override", "Holiday"],
+        )
+
+    def test_preset_mode(self):
+        self.dps[PRESET_DPS] = "Manual"
+        self.assertEqual(self.subject.preset_mode, "Manual")
+        self.dps[PRESET_DPS] = "Program"
+        self.assertEqual(self.subject.preset_mode, "Program")
+        self.dps[PRESET_DPS] = "TempProg"
+        self.assertEqual(self.subject.preset_mode, "Program Override")
+        self.dps[PRESET_DPS] = "Holiday"
+        self.assertEqual(self.subject.preset_mode, "Holiday")
+
+    # Override - since min and max are set by attributes, the range
+    # allowed when setting is wider than normal.  The thermostat seems
+    # to be configurable as at least a water heater (to 212F), as tuya
+    # doc says max 1000.0 (after scaling)
+    async def test_set_target_temperature_fails_outside_valid_range(self):
+        with self.assertRaisesRegex(
+            ValueError,
+            f"temperature \\(0\\) must be between 0.5 and 122.0",
+        ):
+            await self.subject.async_set_target_temperature(0)
+        with self.assertRaisesRegex(
+            ValueError,
+            f"temperature \\(122.5\\) must be between 0.5 and 122.0",
+        ):
+            await self.subject.async_set_target_temperature(122.5)
+
+    def test_extra_state_attributes(self):
+        self.dps[UNKNOWN12_DPS] = 12
+        self.dps[UNKNOWN101_DPS] = True
+        self.dps[UNKNOWN106_DPS] = False
+        self.dps[UNKNOWN107_DPS] = True
+        self.dps[UNKNOWN108_DPS] = False
+        self.assertDictEqual(
+            self.subject.extra_state_attributes,
+            {
+                "unknown_12": 12,
+                "unknown_101": True,
+                "unknown_106": False,
+                "unknown_107": True,
+                "unknown_108": False,
+             },
+        )
+
+    def test_icons(self):
+        self.dps[LOCK_DPS] = True
+        self.assertEqual(self.basicLock.icon, "mdi:hand-back-right-off")
+        self.dps[LOCK_DPS] = False
+        self.assertEqual(self.basicLock.icon, "mdi:hand-back-right")