Преглед на файлове

feat: add time entity

Adds a time entity for inputting times.
Use this entity for any number entities that appear to be an exact fit
for this - setting alarms, start times, stop times based on time of day.

Issue #3499
Jason Rumney преди 7 месеца
родител
ревизия
5f10974c36

+ 13 - 0
custom_components/tuya_local/devices/README.md

@@ -754,6 +754,19 @@ no information will be available about which specific credential was used to unl
      - if `hidden` is specified as `true`, the mode will be set to `password`, otherwise the mode will be `text`.
      - if the `type` is set to `base64` or `hex`, the `pattern` property of the text entity will be set appropriately. There is currently no way to set an arbitrary pattern.
 
+### `time`
+
+Time is intended to be used for setting wall clock time, daily alarms etc.
+However, it can also be convenient to use it for 24h timers. Since
+there is no way to change the limits in the UI, it is not recommended
+to use it for other length timers.
+
+*At least one of the following dps is required**
+
+- **hour** (optional, integer in range 0-24) - the hours component
+- **minute** (optional, integer in range 0-60 or 0-1440 if the only dp) - the minute component
+- **second** (optional, integer in range 0-60 or 0-84600 if the only dp) - the second component
+
 ### `vacuum`
 - **status** (required, mapping of strings): a dp to report and control the status of the vacuum.
 - **command** (optional, mapping of strings): a dp to control the statuss of the vacuum. If supplied, the status dp is only used to report the state.

+ 12 - 0
custom_components/tuya_local/devices/ble_water_valve.yaml

@@ -102,6 +102,7 @@ entities:
             value: "72h"
   - entity: number
     name: Irrigation time
+    deprecated: time.irrigation_time
     category: config
     class: duration
     translation_key: timer
@@ -117,6 +118,17 @@ entities:
         mapping:
           - scale: 60
             step: 60
+  - entity: time
+    name: Irrigation time
+    category: config
+    dps:
+      - id: 11
+        type: integer
+        name: second
+        optional: true
+        range:
+          min: 0
+          max: 86400
   - entity: switch
     name: Smart weather switch
     icon: "mdi:sun-wireless"

+ 24 - 0
custom_components/tuya_local/devices/diivoo_dwv010.yaml

@@ -100,6 +100,7 @@ entities:
     name: Irrigation time 1
     category: config
     class: duration
+    deprecated: time.irrigation_time_1
     icon: "mdi:timer"
     dps:
       - id: 15
@@ -114,6 +115,7 @@ entities:
     name: Irrigation time 2
     category: config
     class: duration
+    deprecated: time.irrigation_time_1
     icon: "mdi:timer"
     dps:
       - id: 127
@@ -124,6 +126,28 @@ entities:
         range:
           min: 0
           max: 1440
+  - entity: time
+    name: Irrigation time 1
+    category: config
+    dps:
+      - id: 15
+        type: integer
+        name: minute
+        optional: true
+        range:
+          min: 0
+          max: 1440
+  - entity: time
+    name: Irrigation time 2
+    category: config
+    dps:
+      - id: 127
+        type: integer
+        name: minute
+        optional: true
+        range:
+          min: 0
+          max: 1440
   - entity: sensor
     class: duration
     name: Time remaining 1

+ 24 - 0
custom_components/tuya_local/devices/diivoo_wt05.yaml

@@ -81,6 +81,7 @@ entities:
         optional: true
   - entity: number
     name: Irrigation time 1
+    deprecated: time.irrigation_time_1
     category: config
     class: duration
     icon: "mdi:timer"
@@ -95,6 +96,7 @@ entities:
           max: 1440
   - entity: number
     name: Irrigation time 2
+    deprecated: time.irrigation_time_2
     category: config
     class: duration
     icon: "mdi:timer"
@@ -107,6 +109,28 @@ entities:
         range:
           min: 0
           max: 1440
+  - entity: time
+    name: Irrigation time 1
+    category: config
+    dps:
+      - id: 106
+        type: integer
+        name: minute
+        optional: true
+        range:
+          min: 0
+          max: 1440
+  - entity: time
+    name: Irrigation time 2
+    category: config
+    dps:
+      - id: 103
+        type: integer
+        name: minute
+        optional: true
+        range:
+          min: 0
+          max: 1440
   - entity: select
     name: Weather delay 1
     icon: "mdi:weather-cloudy-clock"

+ 22 - 0
custom_components/tuya_local/devices/elspet_cat_litterbox.yaml

@@ -110,6 +110,7 @@ entities:
         name: switch
   - entity: number
     name: Sleep start
+    deprecated: time.sleep_start
     class: duration
     category: config
     icon: "mdi:timer"
@@ -125,6 +126,7 @@ entities:
           - scale: 60
   - entity: number
     name: Sleep end
+    deprecated: time.sleep_end
     class: duration
     category: config
     icon: "mdi:timer"
@@ -138,6 +140,26 @@ entities:
           max: 1440
         mapping:
           - scale: 60
+  - entity: time
+    name: Sleep start
+    category: config
+    dps:
+      - id: 110
+        type: integer
+        name: minute
+        range:
+          min: 0
+          max: 1440
+  - entity: time
+    name: Sleep end
+    category: config
+    dps:
+      - id: 111
+        type: integer
+        name: minute
+        range:
+          min: 0
+          max: 1440
   - entity: binary_sensor
     class: problem
     category: diagnostic

+ 46 - 0
custom_components/tuya_local/devices/mustool_mt15mt29_airbox.yaml

@@ -199,6 +199,7 @@ entities:
     dps:
       - id: 108
         type: integer
+        optional: true
         name: value
         unit: min
         range:
@@ -206,6 +207,7 @@ entities:
           max: 599
   - entity: number
     name: Alarm 1
+    deprecated: time.alarm_1
     icon: "mdi:alarm"
     category: config
     class: duration
@@ -223,6 +225,7 @@ entities:
         name: available
   - entity: number
     name: Alarm 2
+    deprecated: time.alarm_2
     category: config
     icon: "mdi:alarm"
     class: duration
@@ -240,6 +243,7 @@ entities:
         name: available
   - entity: number
     name: Alarm 3
+    deprecated: time.alarm_3
     category: config
     icon: "mdi:alarm"
     class: duration
@@ -255,6 +259,48 @@ entities:
       - id: 118
         type: boolean
         name: available
+  - entity: time
+    name: Alarm 1
+    category: config
+    dps:
+      - id: 109
+        type: integer
+        optional: true
+        name: minute
+        range:
+          min: 0
+          max: 1440
+      - id: 116
+        type: boolean
+        name: available
+  - entity: time
+    name: Alarm 2
+    category: config
+    dps:
+      - id: 110
+        type: integer
+        optional: true
+        name: minute
+        range:
+          min: 0
+          max: 1440
+      - id: 117
+        type: boolean
+        name: available
+  - entity: time
+    name: Alarm 3
+    category: config
+    dps:
+      - id: 111
+        type: integer
+        optional: true
+        name: minute
+        range:
+          min: 0
+          max: 1440
+      - id: 118
+        type: boolean
+        name: available
   - entity: select
     translation_key: temperature_unit
     category: config

+ 13 - 0
custom_components/tuya_local/devices/rohnson_r28858_airfryer.yaml

@@ -115,6 +115,7 @@ entities:
   - entity: number
     name: Cooking time
     class: duration
+    deprecated: time.cooking_time
     translation_key: timer
     dps:
       - id: 1
@@ -127,6 +128,18 @@ entities:
         range:
           min: 0
           max: 1440
+  - entity: time
+    name: Cooking time
+    dps:
+      - id: 1
+        type: boolean
+        name: available
+      - id: 9
+        type: integer
+        name: minute
+        range:
+          min: 0
+          max: 1440
   - entity: sensor
     translation_key: time_remaining
     class: duration

+ 97 - 0
custom_components/tuya_local/time.py

@@ -0,0 +1,97 @@
+"""
+Setup for Tuya time entities
+"""
+
+import logging
+from datetime import time, timedelta, datetime
+
+from homeassistant.components.time import TimeEntity
+from homeassistant.const import UnitOfTime
+
+from .device import TuyaLocalDevice
+from .entity import TuyaLocalEntity, unit_from_ascii
+from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+
+_LOGGER = logging.getLogger(__name__)
+
+MODE_AUTO = "auto"
+
+MIDNIGHT = datetime.combine(datetime.today(), time(0, 0, 0))
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    await async_tuya_setup_platform(
+        hass,
+        async_add_entities,
+        config,
+        "time",
+        TuyaLocalTime,
+    )
+
+
+class TuyaLocalTime(TuyaLocalEntity, TimeEntity):
+    """Representation of a Tuya Time"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the time entity.
+        Args:
+            device (TuyaLocalDevice): the device API instance
+            config (TuyaEntityConfig): the configuration for this entity
+        """
+        super().__init__()
+        dps_map = self._init_begin(device, config)
+        self._hour_dps = dps_map.pop("hour", None)
+        self._minute_dps = dps_map.pop("minute", None)
+        self._second_dps = dps_map.pop("second", None)
+        if (
+            self._hour_dps is None
+            and self._minute_dps is None
+            and self._second_dps is None
+        ):
+            raise AttributeError(
+                f"{config.config_id} is missing an hour, minute or second dp"
+            )
+        self._init_end(dps_map)
+
+    @property
+    def native_value(self):
+        """Return the current value of the time."""
+        hours = minutes = seconds = 0
+        if self._hour_dps:
+            hours = self._hour_dps.get_value(self._device)
+        if self._minute_dps:
+            minutes = self._minute_dps.get_value(self._device)
+        if self._second_dps:
+            seconds = self._second_dps.get_value(self._device)
+        delta = timedelta(hours=hours, minutes=minutes, seconds=seconds)
+        return (MIDNIGHT + delta).time()
+
+    async def async_set_native_value(self, value: time):
+        """Set the number."""
+        settings = {}
+        hours = value.hour
+        minutes = value.minute
+        seconds = value.second
+        if self._hour_dps:
+            settings.update(self._hour_dps.get_values_to_set(self._device, hours))
+        else:
+            minutes = minutes + hours * 60
+
+        if self._minute_dps:
+            settings.update(self._minute_dps.get_values_to_set(self._device, minutes))
+        else:
+            seconds = seconds + minutes * 60
+
+        if self._second_dps:
+            settings.update(self._second_dps.get_values_to_set(self._device, seconds))
+        else:
+            _LOGGER.debug(
+                "%s: Discarding unused precision: %d seconds",
+                self.name,
+                seconds,
+            )
+
+        await self._device.async_set_properties(settings)

+ 31 - 0
tests/const.py

@@ -1670,3 +1670,34 @@ LEDVANCE_PANEL_PAYLOAD = {
     "26": 0,
     "51": "AAcAOQPoA+gCngDI",
 }
+
+MUSTOOL_MT15MT29_AIRBOX_PAYLOAD = {
+    "1": "level_1",
+    "2": 26,
+    "3": 53,
+    "4": 814,
+    "5": 4,
+    "7": 3,
+    "8": 2,
+    "9": 3,
+    "22": 100,
+    "23": True,
+    "28": "middle",
+    "101": 20,
+    "102": 0,
+    "104": 1500,
+    "105": 10,
+    "106": True,
+    "107": 2,
+    "109": 0,
+    "110": 0,
+    "111": 0,
+    "112": "c",
+    "113": 200,
+    "114": 100,
+    "115": 61,
+    "116": True,
+    "117": True,
+    "118": True,
+    # last 3 must be true for the time entities to be enabled
+}

+ 2 - 0
tests/devices/base_device_tests.py

@@ -27,6 +27,7 @@ from custom_components.tuya_local.sensor import TuyaLocalSensor
 from custom_components.tuya_local.siren import TuyaLocalSiren
 from custom_components.tuya_local.switch import TuyaLocalSwitch
 from custom_components.tuya_local.text import TuyaLocalText
+from custom_components.tuya_local.time import TuyaLocalTime
 from custom_components.tuya_local.vacuum import TuyaLocalVacuum
 from custom_components.tuya_local.valve import TuyaLocalValve
 from custom_components.tuya_local.water_heater import TuyaLocalWaterHeater
@@ -51,6 +52,7 @@ DEVICE_TYPES = {
     "sensor": TuyaLocalSensor,
     "siren": TuyaLocalSiren,
     "text": TuyaLocalText,
+    "time": TuyaLocalTime,
     "vacuum": TuyaLocalVacuum,
     "valve": TuyaLocalValve,
     "water_heater": TuyaLocalWaterHeater,

+ 1 - 0
tests/devices/test_ble_water_valve.py

@@ -36,6 +36,7 @@ class TestBLEValve(TuyaDeviceTestCase):
                 "sensor_last_use_time",
                 "select_weather_delay",
                 "number_irrigation_time",
+                "time_irrigation_time",
                 "switch_smart_weather_switch",
             ]
         )

+ 57 - 0
tests/devices/test_mustool_mt15mt29_airbox.py

@@ -0,0 +1,57 @@
+"""Tests for Mustool MT15/MT29 Airbox, mainly for time entity."""
+
+from homeassistant.const import UnitOfTime
+
+from ..const import MUSTOOL_MT15MT29_AIRBOX_PAYLOAD
+from ..mixins.time import MultiTimeTests
+from .base_device_tests import TuyaDeviceTestCase
+
+
+class TestMustoolMT15MT29Airbox(MultiTimeTests, TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig(
+            "mustool_mt15mt29_airbox.yaml", MUSTOOL_MT15MT29_AIRBOX_PAYLOAD
+        )
+        self.setUpMultiTime(
+            [
+                {
+                    "minute": "109",
+                    "name": "time_alarm_1",
+                    "testdata": {"minute": 600, "time": "10:00:00"},
+                },
+                {
+                    "minute": "110",
+                    "name": "time_alarm_2",
+                },
+                {
+                    "minute": "111",
+                    "name": "time_alarm_3",
+                },
+            ]
+        )
+        self.mark_secondary(
+            [
+                "sensor_battery",
+                "binary_sensor_plug",
+                "select_alarm_volume",
+                "light_backlight",
+                "number_co2_alarm_threshold",
+                "number_sleep_timer",
+                "number_timer",
+                "number_alarm_1",
+                "number_alarm_2",
+                "number_alarm_3",
+                "time_alarm_1",
+                "time_alarm_2",
+                "time_alarm_3",
+                "select_temperature_unit",
+                "number_co_alarm_threshold",
+                "number_pm2_5_alarm_threshold",
+                "number_formaldehyde_alarm_threshold",
+                "switch_alarm_1",
+                "switch_alarm_2",
+                "switch_alarm_3",
+            ]
+        )

+ 131 - 0
tests/mixins/time.py

@@ -0,0 +1,131 @@
+# Mixins for testing time entities
+from datetime import time
+
+from ..helpers import assert_device_properties_set
+
+
+class BasicTimeTests:
+    def setUpBasicTime(
+        self,
+        subject,
+        hour_dp=None,
+        minute_dp=None,
+        second_dp=None,
+        testdata=None,
+    ):
+        self.basicTime = subject
+        self.basicTimeHourDp = hour_dp
+        self.basicTimeMinDp = minute_dp
+        self.basicTimeSecDp = second_dp
+        self.basicTimeTestData = testdata
+
+    def test_time_value(self):
+        if self.basicTimeTestData:
+            hour = self.basicTimeTestData["hour"]
+            minute = self.basicTimeTestData["minute"]
+            second = self.basicTimeTestData["second"]
+            expected = self.basicTimeTestData["time"]
+        else:
+            expected = "00:00:00"
+            hour = minute = second = 0
+
+        if self.basicTimeHourDp:
+            self.dps[self.basicTimeHourDp] = hour
+        if self.basicTimeMinDp:
+            self.dps[self.basicTimeMinDp] = minute
+        if self.basicTimeSecDp:
+            self.dps[self.basicTimeSecDp] = second
+        self.assertEqual(self.basicTime.native_value.isoformat("seconds"), expected)
+
+    async def test_time_set_value(self):
+        if self.basicTimeTestData:
+            hour = self.basicTimeTestData["hour"]
+            minute = self.basicTimeTestData["minute"]
+            second = self.basicTimeTestData["second"]
+            val = self.basicTimeTestData["time"]
+        else:
+            val = "00:00:00"
+            hour = minute = second = 0
+
+        expected = {}
+        if self.basicTimeHourDp:
+            expected[self.basicTimeHourDp] = hour
+        if self.basicTimeMinDp:
+            expected[self.basicTimeMinDp] = minute
+        if self.basicTimeSecDp:
+            expected[self.basicTimeSecDp] = second
+
+        async with assert_device_properties_set(
+            self.basicTime._device,
+            expected,
+            f"{self.basicTime.name} failed to set correct value",
+        ):
+            await self.basicTime.async_set_native_value(time.fromisoformat(val))
+
+
+class MultiTimeTests:
+    def setUpMultiTime(self, times):
+        self.multiTime = {}
+        self.multiTimeHourDp = {}
+        self.multiTimeMinDp = {}
+        self.multiTimeSecDp = {}
+        self.multiTimeTestData = {}
+
+        for t in times:
+            name = t.get("name")
+            subject = self.entities.get(name)
+            if subject is None:
+                raise AttributeError(f"No time for {name} found.")
+            self.multiTime[name] = subject
+            self.multiTimeHourDp[name] = t.get("hour")
+            self.multiTimeMinDp[name] = t.get("minute")
+            self.multiTimeSecDp[name] = t.get("second")
+            self.multiTimeTestData[name] = t.get("testdata", None)
+
+    def test_multi_time_value(self):
+        for key, subject in self.multiTime.items():
+            if self.multiTimeTestData[key]:
+                hour = self.multiTimeTestData[key].get("hour", 0)
+                minute = self.multiTimeTestData[key].get("minute", 0)
+                second = self.multiTimeTestData[key].get("second", 0)
+                expected = self.multiTimeTestData[key].get("time")
+            else:
+                expected = "00:00:00"
+                hour = minute = second = 0
+
+            if self.multiTimeHourDp[key]:
+                self.dps[self.multiTimeHourDp[key]] = hour
+            if self.multiTimeMinDp[key]:
+                self.dps[self.multiTimeMinDp[key]] = minute
+            if self.multiTimeSecDp[key]:
+                self.dps[self.multiTimeSecDp[key]] = second
+            self.assertEqual(
+                subject.native_value.isoformat("seconds"),
+                expected,
+                f"{key} value mismatch",
+            )
+
+    async def test_multi_time_set_value(self):
+        for key, subject in self.multiTime.items():
+            if self.multiTimeTestData[key]:
+                hour = self.multiTimeTestData[key].get("hour", 0)
+                minute = self.multiTimeTestData[key].get("minute", 0)
+                second = self.multiTimeTestData[key].get("second", 0)
+                val = self.multiTimeTestData[key].get("time")
+            else:
+                val = "00:00:00"
+                hour = minute = second = 0
+
+            expected = {}
+            if self.multiTimeHourDp[key]:
+                expected[self.multiTimeHourDp[key]] = hour
+            if self.multiTimeMinDp[key]:
+                expected[self.multiTimeMinDp[key]] = minute
+            if self.multiTimeSecDp[key]:
+                expected[self.multiTimeSecDp[key]] = second
+            async with assert_device_properties_set(
+                subject._device,
+                expected,
+                f"{key} failed to set correct value",
+            ):
+                await subject.async_set_native_value(time.fromisoformat(val))

+ 2 - 0
tests/test_device_config.py

@@ -145,6 +145,7 @@ ENTITY_SCHEMA = vol.Schema(
                 "siren",
                 "switch",
                 "text",
+                "time",
                 "vacuum",
                 "valve",
                 "water_heater",
@@ -263,6 +264,7 @@ KNOWN_DPS = {
     },
     "switch": {"required": ["switch"], "optional": ["current_power_w"]},
     "text": {"required": ["value"], "optional": []},
+    "time": {"required": [{"or": ["hour", "minute", "second"]}], "optional": []},
     "vacuum": {
         "required": ["status"],
         "optional": [

+ 87 - 0
tests/test_time.py

@@ -0,0 +1,87 @@
+"""Tests for the time entity."""
+
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_PROTOCOL_VERSION,
+    CONF_TYPE,
+    DOMAIN,
+)
+from custom_components.tuya_local.time import TuyaLocalTime, async_setup_entry
+
+
+@pytest.mark.asyncio
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "mustool_mt15mt29_airbox",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {
+        "dummy": {"device": m_device},
+    }
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert type(hass.data[DOMAIN]["dummy"]["time_alarm_1"]) is TuyaLocalTime
+    m_add_entities.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_device_has_no_time(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "simple_switch",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    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()
+
+
+@pytest.mark.asyncio
+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",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    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()