浏览代码

feat (datetime): support for new entity platform

This is the editable equivalent of a sensor with class: timestamp.

Used by elko_cfmtb_thermostat in previous change

Issue #4239
Jason Rumney 3 周之前
父节点
当前提交
2708227195

+ 171 - 0
custom_components/tuya_local/datetime.py

@@ -0,0 +1,171 @@
+"""
+Setup for Tuya datetime entities
+"""
+
+import logging
+import time
+from datetime import datetime, timedelta, timezone
+
+from homeassistant.components.datetime import DateTimeEntity
+
+from .device import TuyaLocalDevice
+from .entity import TuyaLocalEntity
+from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+
+_LOGGER = logging.getLogger(__name__)
+
+EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
+
+
+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,
+        "datetime",
+        TuyaLocalDateTime,
+    )
+
+
+class TuyaLocalDateTime(TuyaLocalEntity, DateTimeEntity):
+    """Representation of a Tuya DateTime"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the datetime 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._year_dps = dps_map.pop("year", None)
+        self._month_dps = dps_map.pop("month", None)
+        self._day_dps = dps_map.pop("day", None)
+        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._year_dps is None
+            and self._month_dps is None
+            and self._day_dps is None
+            and 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."""
+        year = month = day = hours = minutes = seconds = None
+        tz = timezone.utc
+        if self._year_dps:
+            year = self._year_dps.get_value(self._device)
+            tz = time.now().astimezone().tzinfo
+        if self._month_dps:
+            month = self._month_dps.get_value(self._device)
+        if self._day_dps:
+            day = self._day_dps.get_value(self._device)
+        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)
+        if (
+            year is None
+            and month is None
+            and day is None
+            and hours is None
+            and minutes is None
+            and seconds is None
+        ):
+            return None
+        year = year or 1970
+        month = month or 1
+        day = day or 1
+        hours = hours or 0
+        minutes = minutes or 0
+        seconds = seconds or 0
+        delta = timedelta(
+            years=int(year) - 1970,
+            months=int(month) - 1,
+            days=int(day) - 1,
+            hours=int(hours),
+            minutes=int(minutes),
+            seconds=int(seconds),
+        )
+        return (EPOCH.astimezone(tz) + delta).datetime()
+
+    async def async_set_value(self, value: datetime):
+        """Set the datetime."""
+        settings = {}
+        # Use Local time if split into components
+        if self._year_dps:
+            tz = time.now().astimezone().tzinfo
+            value = value.astimezone(tz)
+        year = value.year
+        month = value.month
+        day = value.day
+        hour = value.hour
+        minute = value.minute
+        second = value.second
+        if self._year_dps:
+            settings.update(
+                self._year_dps.get_values_to_set(self._device, year, settings)
+            )
+            month = month + (year - 1970) * 12
+        if self._month_dps:
+            settings.update(
+                self._month_dps.get_values_to_set(self._device, month, settings)
+            )
+        else:
+            if self._year_dps is None:
+                from_year = 1970
+            else:
+                from_year = value.year
+            day = (
+                day
+                + (
+                    datetime(value.year, value.month, 1) - datetime(from_year, 1, 1)
+                ).days
+                - 1
+            )
+        if self._day_dps:
+            settings.update(
+                self._day_dps.get_values_to_set(self._device, day, settings)
+            )
+        else:
+            hours = hours + day * 24
+        if self._hour_dps:
+            settings.update(
+                self._hour_dps.get_values_to_set(self._device, hours, settings)
+            )
+        else:
+            minutes = minutes + hours * 60
+
+        if self._minute_dps:
+            settings.update(
+                self._minute_dps.get_values_to_set(self._device, minutes, settings)
+            )
+        else:
+            seconds = seconds + minutes * 60
+
+        if self._second_dps:
+            settings.update(
+                self._second_dps.get_values_to_set(self._device, seconds, settings)
+            )
+        else:
+            _LOGGER.debug(
+                "%s: Discarding unused precision: %d seconds",
+                self.name,
+                seconds,
+            )
+
+        await self._device.async_set_properties(settings)

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

@@ -655,6 +655,16 @@ Either **position**, **action** or **open** should be specified otherwise the co
 - **open** (optional, boolean): a dp that reports if the cover is open. Only used if **position** is not available.
 - **tilt_position** (optional, number): a dp to control the tilt opening of the cover (an example is venetian blinds that tilt as well as go up and down). The range will be auto-converted to the 0-100 expected by HA.
 
+### `datetime`
+*At least one of the following dps is required**
+
+- **year** (optional, integer in range 1970-) - the year component
+- **month** (optional, integer in range 1-12) - the month component
+- **day** (optional, integer in range 1-31) - the day component
+- **hour** (optional, integer in range 0-24 or more if this is the only dp) - the hour component
+- **minute** (optional, integer in range 0-60 or more if the only dp) - the minute component
+- **second** (optional, integer in range 0-60 or more if the only dp) - the second component. If this is the only component, this is equivalent to `unixtime`.
+
 ### `fan`
 - **switch** (optional, boolean): a dp to control the power state of the fan
 - **preset_mode** (optional, mapping of strings): a dp to control different modes of the fan.

+ 1 - 1
custom_components/tuya_local/devices/device_config_schema.json

@@ -49,7 +49,7 @@
                 "properties": {
                     "entity": {
                         "type": "string",
-                        "enum": ["alarm_control_panel", "binary_sensor", "button", "camera", "climate", "cover", "event", "fan", "humidifier", "lawn_mower", "light", "lock", "number", "remote", "select", "sensor", "siren", "switch", "text", "time", "vacuum", "valve", "water_heater"],
+                        "enum": ["alarm_control_panel", "binary_sensor", "button", "camera", "climate", "cover", "datetime", "event", "fan", "humidifier", "lawn_mower", "light", "lock", "number", "remote", "select", "sensor", "siren", "switch", "text", "time", "vacuum", "valve", "water_heater"],
                         "description": "The type of entity (e.g., light, switch, sensor)."
                     },
                     "name": {

+ 2 - 0
tests/devices/base_device_tests.py

@@ -10,6 +10,7 @@ from custom_components.tuya_local.button import TuyaLocalButton
 from custom_components.tuya_local.camera import TuyaLocalCamera
 from custom_components.tuya_local.climate import TuyaLocalClimate
 from custom_components.tuya_local.cover import TuyaLocalCover
+from custom_components.tuya_local.datetime import TuyaLocalDateTime
 from custom_components.tuya_local.event import TuyaLocalEvent
 from custom_components.tuya_local.fan import TuyaLocalFan
 from custom_components.tuya_local.helpers.device_config import (
@@ -39,6 +40,7 @@ DEVICE_TYPES = {
     "camera": TuyaLocalCamera,
     "climate": TuyaLocalClimate,
     "cover": TuyaLocalCover,
+    "datetime": TuyaLocalDateTime,
     "event": TuyaLocalEvent,
     "fan": TuyaLocalFan,
     "humidifier": TuyaLocalHumidifier,

+ 89 - 0
tests/test_datetime.py

@@ -0,0 +1,89 @@
+"""Tests for the datetime 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.datetime import TuyaLocalDateTime, async_setup_entry
+
+
+@pytest.mark.asyncio
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "elko_cfmtb_thermostat",
+            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"]["datetime_override_end"]) is TuyaLocalDateTime
+    )
+    m_add_entities.assert_called()
+
+
+@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()

+ 5 - 0
tests/test_device_config.py

@@ -132,6 +132,7 @@ ENTITY_SCHEMA = vol.Schema(
                 "camera",
                 "climate",
                 "cover",
+                "datetime",
                 "event",
                 "fan",
                 "humidifier",
@@ -215,6 +216,10 @@ KNOWN_DPS = {
             "reversed",
         ],
     },
+    "datetime": {
+        "required": [{"or": ["year", "month", "day", "hour", "minute", "second"]}],
+        "optional": [],
+    },
     "event": {"required": ["event"], "optional": []},
     "fan": {
         "required": [{"or": ["preset_mode", "speed"]}],