From 3f7275a9c7774e794f3b255270aa9f4d4fea1e4e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 7 Jan 2022 19:02:32 +0100 Subject: [PATCH] Give scenes last activated state (#62673) --- homeassistant/components/scene/__init__.py | 37 ++++++++---- .../components/google_assistant/test_trait.py | 2 +- tests/components/homeassistant/test_scene.py | 7 ++- tests/components/hue/test_scene.py | 7 ++- tests/components/mqtt/test_scene.py | 6 +- tests/components/scene/test_init.py | 59 ++++++++++++++++--- tests/components/smartthings/conftest.py | 2 + 7 files changed, 93 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 7dc58d8df6c..774aaad0ee4 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import functools as ft import importlib import logging -from typing import Any +from typing import Any, final import voluptuous as vol @@ -12,14 +12,14 @@ from homeassistant.components.light import ATTR_TRANSITION from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util # mypy: allow-untyped-defs, no-check-untyped-defs DOMAIN = "scene" -STATE = "scening" STATES = "states" @@ -71,7 +71,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_TURN_ON, {ATTR_TRANSITION: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=6553))}, - "async_activate", + "_async_activate", ) return True @@ -89,18 +89,35 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class Scene(Entity): +class Scene(RestoreEntity): """A scene is a group of entities and the states we want them to be.""" - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False + _attr_should_poll = False + __last_activated: str | None = None @property + @final def state(self) -> str | None: """Return the state of the scene.""" - return STATE + if self.__last_activated is None: + return None + return self.__last_activated + + @final + async def _async_activate(self, **kwargs: Any) -> None: + """Activate scene. + + Should not be overridden, handle setting last press timestamp. + """ + self.__last_activated = dt_util.utcnow().isoformat() + self.async_write_ha_state() + await self.async_activate(**kwargs) + + async def async_added_to_hass(self) -> None: + """Call when the button is added to hass.""" + state = await self.async_get_last_state() + if state is not None and state.state is not None: + self.__last_activated = state.state def activate(self, **kwargs: Any) -> None: """Activate scene. Try to get entities into requested state.""" diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 8229d857173..a56a8f967e6 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -800,7 +800,7 @@ async def test_scene_scene(hass): assert helpers.get_google_type(scene.DOMAIN, None) is not None assert trait.SceneTrait.supported(scene.DOMAIN, 0, None, None) - trt = trait.SceneTrait(hass, State("scene.bla", scene.STATE), BASIC_CONFIG) + trt = trait.SceneTrait(hass, State("scene.bla", STATE_UNKNOWN), BASIC_CONFIG) assert trt.sync_attributes() == {} assert trt.query_attributes() == {} assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py index 610bc371b25..d7ab62a6847 100644 --- a/tests/components/homeassistant/test_scene.py +++ b/tests/components/homeassistant/test_scene.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.components.homeassistant import scene as ha_scene from homeassistant.components.homeassistant.scene import EVENT_SCENE_RELOADED +from homeassistant.const import STATE_UNKNOWN from homeassistant.setup import async_setup_component from tests.common import async_capture_events, async_mock_service @@ -119,7 +120,7 @@ async def test_create_service(hass, caplog): assert scene is not None assert scene.domain == "scene" assert scene.name == "hallo" - assert scene.state == "scening" + assert scene.state == STATE_UNKNOWN assert scene.attributes.get("entity_id") == ["light.bed_light"] assert await hass.services.async_call( @@ -137,7 +138,7 @@ async def test_create_service(hass, caplog): assert scene is not None assert scene.domain == "scene" assert scene.name == "hallo" - assert scene.state == "scening" + assert scene.state == STATE_UNKNOWN assert scene.attributes.get("entity_id") == ["light.kitchen_light"] assert await hass.services.async_call( @@ -156,7 +157,7 @@ async def test_create_service(hass, caplog): assert scene is not None assert scene.domain == "scene" assert scene.name == "hallo_2" - assert scene.state == "scening" + assert scene.state == STATE_UNKNOWN assert scene.attributes.get("entity_id") == ["light.kitchen"] diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index 2be4088b105..7f30fd25681 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -1,6 +1,7 @@ """Philips Hue scene platform tests for V2 bridge/api.""" +from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers import entity_registry as er from .conftest import setup_platform @@ -21,7 +22,7 @@ async def test_scene(hass, mock_bridge_v2, v2_resources_test_data): test_entity = hass.states.get("scene.test_zone_dynamic_test_scene") assert test_entity is not None assert test_entity.name == "Test Zone - Dynamic Test Scene" - assert test_entity.state == "scening" + assert test_entity.state == STATE_UNKNOWN assert test_entity.attributes["group_name"] == "Test Zone" assert test_entity.attributes["group_type"] == "zone" assert test_entity.attributes["name"] == "Dynamic Test Scene" @@ -33,7 +34,7 @@ async def test_scene(hass, mock_bridge_v2, v2_resources_test_data): test_entity = hass.states.get("scene.test_room_regular_test_scene") assert test_entity is not None assert test_entity.name == "Test Room - Regular Test Scene" - assert test_entity.state == "scening" + assert test_entity.state == STATE_UNKNOWN assert test_entity.attributes["group_name"] == "Test Room" assert test_entity.attributes["group_type"] == "room" assert test_entity.attributes["name"] == "Regular Test Scene" @@ -142,7 +143,7 @@ async def test_scene_updates(hass, mock_bridge_v2, v2_resources_test_data): # the entity should now be available test_entity = hass.states.get(test_entity_id) assert test_entity is not None - assert test_entity.state == "scening" + assert test_entity.state == STATE_UNKNOWN assert test_entity.name == "Test Room - Mocked Scene" assert test_entity.attributes["brightness"] == 65.0 diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index f7dff2036b6..97f13ba90c0 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components import scene -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -34,7 +34,7 @@ DEFAULT_CONFIG = { async def test_sending_mqtt_commands(hass, mqtt_mock): """Test the sending MQTT commands.""" - fake_state = ha.State("scene.test", scene.STATE) + fake_state = ha.State("scene.test", STATE_UNKNOWN) with patch( "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", @@ -55,7 +55,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock): await hass.async_block_till_done() state = hass.states.get("scene.test") - assert state.state == scene.STATE + assert state.state == STATE_UNKNOWN data = {ATTR_ENTITY_ID: "scene.test"} await hass.services.async_call(scene.DOMAIN, SERVICE_TURN_ON, data, blocking=True) diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py index 4c5b832ac14..41b16261cd1 100644 --- a/tests/components/scene/test_init.py +++ b/tests/components/scene/test_init.py @@ -1,14 +1,22 @@ """The tests for the Scene component.""" import io +from unittest.mock import patch import pytest from homeassistant.components import light, scene -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + ENTITY_MATCH_ALL, + SERVICE_TURN_ON, + STATE_UNKNOWN, +) +from homeassistant.core import State from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.yaml import loader as yaml_loader -from tests.common import async_mock_service +from tests.common import async_mock_service, mock_restore_cache @pytest.fixture(autouse=True) @@ -111,7 +119,14 @@ async def test_activate_scene(hass, entities, enable_custom_integrations): }, ) await hass.async_block_till_done() - await activate(hass, "scene.test") + + assert hass.states.get("scene.test").state == STATE_UNKNOWN + + now = dt_util.utcnow() + with patch("homeassistant.core.dt_util.utcnow", return_value=now): + await activate(hass, "scene.test") + + assert hass.states.get("scene.test").state == now.isoformat() assert light.is_on(hass, light_1.entity_id) assert light.is_on(hass, light_2.entity_id) @@ -121,10 +136,14 @@ async def test_activate_scene(hass, entities, enable_custom_integrations): calls = async_mock_service(hass, "light", "turn_on") - await hass.services.async_call( - scene.DOMAIN, "turn_on", {"transition": 42, "entity_id": "scene.test"} - ) - await hass.async_block_till_done() + now = dt_util.utcnow() + with patch("homeassistant.core.dt_util.utcnow", return_value=now): + await hass.services.async_call( + scene.DOMAIN, "turn_on", {"transition": 42, "entity_id": "scene.test"} + ) + await hass.async_block_till_done() + + assert hass.states.get("scene.test").state == now.isoformat() assert len(calls) == 1 assert calls[0].domain == "light" @@ -132,6 +151,32 @@ async def test_activate_scene(hass, entities, enable_custom_integrations): assert calls[0].data.get("transition") == 42 +async def test_restore_state(hass, entities, enable_custom_integrations): + """Test we restore state integration.""" + mock_restore_cache(hass, (State("scene.test", "2021-01-01T23:59:59+00:00"),)) + + light_1, light_2 = await setup_lights(hass, entities) + + assert await async_setup_component( + hass, + scene.DOMAIN, + { + "scene": [ + { + "name": "test", + "entities": { + light_1.entity_id: "on", + light_2.entity_id: "on", + }, + } + ] + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("scene.test").state == "2021-01-01T23:59:59+00:00" + + async def activate(hass, entity_id=ENTITY_MATCH_ALL): """Activate a scene.""" data = {} diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 2a7b5ed7084..e3e80d80e52 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -289,6 +289,8 @@ def scene_factory_fixture(location): scene = Mock(SceneEntity) scene.scene_id = str(uuid4()) scene.name = name + scene.icon = None + scene.color = None scene.location_id = location.location_id return scene