From e032740e908da102ce674ba4cb73ef22583e585d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 2 Mar 2026 08:34:53 +0100 Subject: [PATCH] Add time platform to SmartThings (#164451) --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/icons.json | 8 + .../components/smartthings/strings.json | 8 + homeassistant/components/smartthings/time.py | 102 +++++++++ .../smartthings/snapshots/test_time.ambr | 197 ++++++++++++++++++ tests/components/smartthings/test_time.py | 128 ++++++++++++ 6 files changed, 444 insertions(+) create mode 100644 homeassistant/components/smartthings/time.py create mode 100644 tests/components/smartthings/snapshots/test_time.ambr create mode 100644 tests/components/smartthings/test_time.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index ae177162f268..ea769fed8cae 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -107,6 +107,7 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.TIME, Platform.UPDATE, Platform.VACUUM, Platform.VALVE, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 3ac921a8885d..a4459f7c3094 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -256,6 +256,14 @@ "off": "mdi:tumble-dryer-off" } } + }, + "time": { + "do_not_disturb_end_time": { + "default": "mdi:bell-ring" + }, + "do_not_disturb_start_time": { + "default": "mdi:bell-cancel" + } } } } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fd4a6cbc61f1..1574eca80c3e 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -945,6 +945,14 @@ "name": "Wrinkle prevent" } }, + "time": { + "do_not_disturb_end_time": { + "name": "Do not disturb end time" + }, + "do_not_disturb_start_time": { + "name": "Do not disturb start time" + } + }, "vacuum": { "vacuum": { "state_attributes": { diff --git a/homeassistant/components/smartthings/time.py b/homeassistant/components/smartthings/time.py new file mode 100644 index 000000000000..de4057d4ac1e --- /dev/null +++ b/homeassistant/components/smartthings/time.py @@ -0,0 +1,102 @@ +"""Time platform for SmartThings.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import time + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsTimeEntityDescription(TimeEntityDescription): + """Describe a SmartThings time entity.""" + + attribute: Attribute + + +DND_ENTITIES = [ + SmartThingsTimeEntityDescription( + key=Attribute.START_TIME, + translation_key="do_not_disturb_start_time", + attribute=Attribute.START_TIME, + entity_category=EntityCategory.CONFIG, + ), + SmartThingsTimeEntityDescription( + key=Attribute.END_TIME, + translation_key="do_not_disturb_end_time", + attribute=Attribute.END_TIME, + entity_category=EntityCategory.CONFIG, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add time entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsDnDTime(entry_data.client, device, description) + for device in entry_data.devices.values() + if Capability.CUSTOM_DO_NOT_DISTURB_MODE in device.status.get(MAIN, {}) + for description in DND_ENTITIES + ) + + +class SmartThingsDnDTime(SmartThingsEntity, TimeEntity): + """Define a SmartThings time entity.""" + + entity_description: SmartThingsTimeEntityDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsTimeEntityDescription, + ) -> None: + """Initialize the time entity.""" + super().__init__(client, device, {Capability.CUSTOM_DO_NOT_DISTURB_MODE}) + self.entity_description = entity_description + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_DO_NOT_DISTURB_MODE}_{entity_description.attribute}_{entity_description.attribute}" + + async def async_set_value(self, value: time) -> None: + """Set the time value.""" + payload = { + "mode": self.get_attribute_value( + Capability.CUSTOM_DO_NOT_DISTURB_MODE, Attribute.DO_NOT_DISTURB + ), + "startTime": self.get_attribute_value( + Capability.CUSTOM_DO_NOT_DISTURB_MODE, Attribute.START_TIME + ), + "endTime": self.get_attribute_value( + Capability.CUSTOM_DO_NOT_DISTURB_MODE, Attribute.END_TIME + ), + } + await self.execute_device_command( + Capability.CUSTOM_DO_NOT_DISTURB_MODE, + Command.SET_DO_NOT_DISTURB_MODE, + { + **payload, + self.entity_description.attribute: f"{value.hour:02d}{value.minute:02d}", + }, + ) + + @property + def native_value(self) -> time: + """Return the time value.""" + state = self.get_attribute_value( + Capability.CUSTOM_DO_NOT_DISTURB_MODE, self.entity_description.attribute + ) + return time(int(state[:2]), int(state[3:5])) diff --git a/tests/components/smartthings/snapshots/test_time.ambr b/tests/components/smartthings/snapshots/test_time.ambr new file mode 100644 index 000000000000..4d7dba75901c --- /dev/null +++ b/tests/components/smartthings/snapshots/test_time.ambr @@ -0,0 +1,197 @@ +# serializer version: 1 +# name: test_all_entities[da_ks_hood_01001][time.range_hood_do_not_disturb_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.range_hood_do_not_disturb_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Do not disturb end time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Do not disturb end time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb_end_time', + 'unique_id': 'fa5fca25-fa7a-1807-030a-2f72ee0f7bff_main_custom.doNotDisturbMode_endTime_endTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_hood_01001][time.range_hood_do_not_disturb_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Range hood Do not disturb end time', + }), + 'context': , + 'entity_id': 'time.range_hood_do_not_disturb_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '00:00:00', + }) +# --- +# name: test_all_entities[da_ks_hood_01001][time.range_hood_do_not_disturb_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.range_hood_do_not_disturb_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Do not disturb start time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Do not disturb start time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb_start_time', + 'unique_id': 'fa5fca25-fa7a-1807-030a-2f72ee0f7bff_main_custom.doNotDisturbMode_startTime_startTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_hood_01001][time.range_hood_do_not_disturb_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Range hood Do not disturb start time', + }), + 'context': , + 'entity_id': 'time.range_hood_do_not_disturb_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '00:00:00', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][time.robot_vacuum_do_not_disturb_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.robot_vacuum_do_not_disturb_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Do not disturb end time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Do not disturb end time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb_end_time', + 'unique_id': '01b28624-5907-c8bc-0325-8ad23f03a637_main_custom.doNotDisturbMode_endTime_endTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][time.robot_vacuum_do_not_disturb_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot Vacuum Do not disturb end time', + }), + 'context': , + 'entity_id': 'time.robot_vacuum_do_not_disturb_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '06:00:00', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][time.robot_vacuum_do_not_disturb_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.robot_vacuum_do_not_disturb_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Do not disturb start time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Do not disturb start time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb_start_time', + 'unique_id': '01b28624-5907-c8bc-0325-8ad23f03a637_main_custom.doNotDisturbMode_startTime_startTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][time.robot_vacuum_do_not_disturb_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot Vacuum Do not disturb start time', + }), + 'context': , + 'entity_id': 'time.robot_vacuum_do_not_disturb_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22:00:00', + }) +# --- diff --git a/tests/components/smartthings/test_time.py b/tests/components/smartthings/test_time.py new file mode 100644 index 000000000000..8d69cc7f99fa --- /dev/null +++ b/tests/components/smartthings/test_time.py @@ -0,0 +1,128 @@ +"""Test for the SmartThings time platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smartthings import MAIN +from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.TIME) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("time.robot_vacuum_do_not_disturb_end_time").state == "06:00:00" + ) + + await trigger_update( + hass, + devices, + "01b28624-5907-c8bc-0325-8ad23f03a637", + Capability.CUSTOM_DO_NOT_DISTURB_MODE, + Attribute.END_TIME, + "0800", + ) + + assert ( + hass.states.get("time.robot_vacuum_do_not_disturb_end_time").state == "08:00:00" + ) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_set_value( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting a value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "time.robot_vacuum_do_not_disturb_end_time", + ATTR_TIME: "09:00:00", + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "01b28624-5907-c8bc-0325-8ad23f03a637", + Capability.CUSTOM_DO_NOT_DISTURB_MODE, + Command.SET_DO_NOT_DISTURB_MODE, + MAIN, + argument={ + "mode": "on", + "startTime": "2200", + "endTime": "0900", + }, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_dnd_mode_updates( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting a value.""" + await setup_integration(hass, mock_config_entry) + + await trigger_update( + hass, + devices, + "01b28624-5907-c8bc-0325-8ad23f03a637", + Capability.CUSTOM_DO_NOT_DISTURB_MODE, + Attribute.DO_NOT_DISTURB, + "off", + ) + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "time.robot_vacuum_do_not_disturb_end_time", + ATTR_TIME: "09:00:00", + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "01b28624-5907-c8bc-0325-8ad23f03a637", + Capability.CUSTOM_DO_NOT_DISTURB_MODE, + Command.SET_DO_NOT_DISTURB_MODE, + MAIN, + argument={ + "mode": "off", + "startTime": "2200", + "endTime": "0900", + }, + )