From ddb9a6e33c374cdcec22537bc647854c10d87d8d Mon Sep 17 00:00:00 2001
From: G Johansson <goran.johansson@shiftit.se>
Date: Sun, 21 May 2023 10:11:08 +0200
Subject: [PATCH] Add change service to timer (#84775)

* Add change service

* test subtract

* Test no change if timer not running

* Modify example

* Raise

* Finalize

* test event

* Fix tests

* Fix tracking time
---
 homeassistant/components/timer/__init__.py   |  27 +++
 homeassistant/components/timer/services.yaml |  15 ++
 tests/components/timer/test_init.py          | 197 ++++++++++++++++---
 3 files changed, 217 insertions(+), 22 deletions(-)

diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py
index 7cb2c10425e..90ad5e0491b 100644
--- a/homeassistant/components/timer/__init__.py
+++ b/homeassistant/components/timer/__init__.py
@@ -17,6 +17,7 @@ from homeassistant.const import (
     SERVICE_RELOAD,
 )
 from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.exceptions import HomeAssistantError
 from homeassistant.helpers import collection
 import homeassistant.helpers.config_validation as cv
 from homeassistant.helpers.entity_component import EntityComponent
@@ -50,6 +51,7 @@ STATUS_PAUSED = "paused"
 
 EVENT_TIMER_FINISHED = "timer.finished"
 EVENT_TIMER_CANCELLED = "timer.cancelled"
+EVENT_TIMER_CHANGED = "timer.changed"
 EVENT_TIMER_STARTED = "timer.started"
 EVENT_TIMER_RESTARTED = "timer.restarted"
 EVENT_TIMER_PAUSED = "timer.paused"
@@ -57,6 +59,7 @@ EVENT_TIMER_PAUSED = "timer.paused"
 SERVICE_START = "start"
 SERVICE_PAUSE = "pause"
 SERVICE_CANCEL = "cancel"
+SERVICE_CHANGE = "change"
 SERVICE_FINISH = "finish"
 
 STORAGE_KEY = DOMAIN
@@ -158,6 +161,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
     component.async_register_entity_service(SERVICE_PAUSE, {}, "async_pause")
     component.async_register_entity_service(SERVICE_CANCEL, {}, "async_cancel")
     component.async_register_entity_service(SERVICE_FINISH, {}, "async_finish")
+    component.async_register_entity_service(
+        SERVICE_CHANGE,
+        {vol.Optional(ATTR_DURATION, default=DEFAULT_DURATION): cv.time_period},
+        "async_change",
+    )
 
     return True
 
@@ -321,6 +329,25 @@ class Timer(collection.CollectionEntity, RestoreEntity):
         )
         self.async_write_ha_state()
 
+    @callback
+    def async_change(self, duration: timedelta) -> None:
+        """Change duration of a running timer."""
+        if self._listener is None or self._end is None:
+            raise HomeAssistantError(
+                f"Timer {self.entity_id} is not running, only active timers can be changed"
+            )
+
+        self._listener()
+        self._listener = None
+        self._end += duration
+        self._duration += duration
+        self._remaining = self._end - dt_util.utcnow().replace(microsecond=0)
+        self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id})
+        self._listener = async_track_point_in_utc_time(
+            self.hass, self._async_finished, self._end
+        )
+        self.async_write_ha_state()
+
     @callback
     def async_pause(self):
         """Pause a timer."""
diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml
index e17ea1dd5fb..68caa44a699 100644
--- a/homeassistant/components/timer/services.yaml
+++ b/homeassistant/components/timer/services.yaml
@@ -33,3 +33,18 @@ finish:
   target:
     entity:
       domain: timer
+
+change:
+  name: Change
+  description: Change a timer
+  target:
+    entity:
+      domain: timer
+  fields:
+    duration:
+      description: Duration to add or subtract to the running timer
+      default: 0
+      required: true
+      example: "00:01:00, 60 or -60"
+      selector:
+        text:
diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py
index 93b0463e800..a60e42eb768 100644
--- a/tests/components/timer/test_init.py
+++ b/tests/components/timer/test_init.py
@@ -17,11 +17,13 @@ from homeassistant.components.timer import (
     DEFAULT_DURATION,
     DOMAIN,
     EVENT_TIMER_CANCELLED,
+    EVENT_TIMER_CHANGED,
     EVENT_TIMER_FINISHED,
     EVENT_TIMER_PAUSED,
     EVENT_TIMER_RESTARTED,
     EVENT_TIMER_STARTED,
     SERVICE_CANCEL,
+    SERVICE_CHANGE,
     SERVICE_FINISH,
     SERVICE_PAUSE,
     SERVICE_START,
@@ -43,7 +45,7 @@ from homeassistant.const import (
     SERVICE_RELOAD,
 )
 from homeassistant.core import Context, CoreState, HomeAssistant, State
-from homeassistant.exceptions import Unauthorized
+from homeassistant.exceptions import HomeAssistantError, Unauthorized
 from homeassistant.helpers import config_validation as cv, entity_registry as er
 from homeassistant.helpers.restore_state import (
     DATA_RESTORE_STATE_TASK,
@@ -60,7 +62,7 @@ _LOGGER = logging.getLogger(__name__)
 
 
 @pytest.fixture
-def storage_setup(hass, hass_storage):
+def storage_setup(hass: HomeAssistant, hass_storage):
     """Storage setup."""
 
     async def _storage(items=None, config=None):
@@ -168,26 +170,91 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
     hass.bus.async_listen(EVENT_TIMER_PAUSED, fake_event_listener)
     hass.bus.async_listen(EVENT_TIMER_FINISHED, fake_event_listener)
     hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener)
+    hass.bus.async_listen(EVENT_TIMER_CHANGED, fake_event_listener)
 
     steps = [
-        {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED},
-        {"call": SERVICE_PAUSE, "state": STATUS_PAUSED, "event": EVENT_TIMER_PAUSED},
-        {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_RESTARTED},
-        {"call": SERVICE_CANCEL, "state": STATUS_IDLE, "event": EVENT_TIMER_CANCELLED},
-        {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED},
-        {"call": SERVICE_FINISH, "state": STATUS_IDLE, "event": EVENT_TIMER_FINISHED},
-        {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED},
-        {"call": SERVICE_PAUSE, "state": STATUS_PAUSED, "event": EVENT_TIMER_PAUSED},
-        {"call": SERVICE_CANCEL, "state": STATUS_IDLE, "event": EVENT_TIMER_CANCELLED},
-        {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED},
-        {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_RESTARTED},
+        {
+            "call": SERVICE_START,
+            "state": STATUS_ACTIVE,
+            "event": EVENT_TIMER_STARTED,
+            "data": {},
+        },
+        {
+            "call": SERVICE_PAUSE,
+            "state": STATUS_PAUSED,
+            "event": EVENT_TIMER_PAUSED,
+            "data": {},
+        },
+        {
+            "call": SERVICE_START,
+            "state": STATUS_ACTIVE,
+            "event": EVENT_TIMER_RESTARTED,
+            "data": {},
+        },
+        {
+            "call": SERVICE_CANCEL,
+            "state": STATUS_IDLE,
+            "event": EVENT_TIMER_CANCELLED,
+            "data": {},
+        },
+        {
+            "call": SERVICE_START,
+            "state": STATUS_ACTIVE,
+            "event": EVENT_TIMER_STARTED,
+            "data": {},
+        },
+        {
+            "call": SERVICE_FINISH,
+            "state": STATUS_IDLE,
+            "event": EVENT_TIMER_FINISHED,
+            "data": {},
+        },
+        {
+            "call": SERVICE_START,
+            "state": STATUS_ACTIVE,
+            "event": EVENT_TIMER_STARTED,
+            "data": {},
+        },
+        {
+            "call": SERVICE_PAUSE,
+            "state": STATUS_PAUSED,
+            "event": EVENT_TIMER_PAUSED,
+            "data": {},
+        },
+        {
+            "call": SERVICE_CANCEL,
+            "state": STATUS_IDLE,
+            "event": EVENT_TIMER_CANCELLED,
+            "data": {},
+        },
+        {
+            "call": SERVICE_START,
+            "state": STATUS_ACTIVE,
+            "event": EVENT_TIMER_STARTED,
+            "data": {},
+        },
+        {
+            "call": SERVICE_CHANGE,
+            "state": STATUS_ACTIVE,
+            "event": EVENT_TIMER_CHANGED,
+            "data": {CONF_DURATION: 15},
+        },
+        {
+            "call": SERVICE_START,
+            "state": STATUS_ACTIVE,
+            "event": EVENT_TIMER_RESTARTED,
+            "data": {},
+        },
     ]
 
-    expectedEvents = 0
+    expected_events = 0
     for step in steps:
         if step["call"] is not None:
             await hass.services.async_call(
-                DOMAIN, step["call"], {CONF_ENTITY_ID: "timer.test1"}
+                DOMAIN,
+                step["call"],
+                {CONF_ENTITY_ID: "timer.test1", **step["data"]},
+                blocking=True,
             )
             await hass.async_block_till_done()
 
@@ -197,9 +264,9 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
             assert state.state == step["state"]
 
         if step["event"] is not None:
-            expectedEvents += 1
+            expected_events += 1
             assert results[-1].event_type == step["event"]
-            assert len(results) == expectedEvents
+            assert len(results) == expected_events
 
 
 async def test_start_service(hass: HomeAssistant) -> None:
@@ -212,7 +279,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
     assert state.attributes[ATTR_DURATION] == "0:00:10"
 
     await hass.services.async_call(
-        DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}
+        DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
     )
     await hass.async_block_till_done()
     state = hass.states.get("timer.test1")
@@ -222,7 +289,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
     assert state.attributes[ATTR_REMAINING] == "0:00:10"
 
     await hass.services.async_call(
-        DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}
+        DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
     )
     await hass.async_block_till_done()
     state = hass.states.get("timer.test1")
@@ -231,8 +298,20 @@ async def test_start_service(hass: HomeAssistant) -> None:
     assert state.attributes[ATTR_DURATION] == "0:00:10"
     assert ATTR_REMAINING not in state.attributes
 
+    with pytest.raises(HomeAssistantError):
+        await hass.services.async_call(
+            DOMAIN,
+            SERVICE_CHANGE,
+            {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 10},
+            blocking=True,
+        )
+        await hass.async_block_till_done()
+
     await hass.services.async_call(
-        DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 15}
+        DOMAIN,
+        SERVICE_START,
+        {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 15},
+        blocking=True,
     )
     await hass.async_block_till_done()
     state = hass.states.get("timer.test1")
@@ -241,6 +320,57 @@ async def test_start_service(hass: HomeAssistant) -> None:
     assert state.attributes[ATTR_DURATION] == "0:00:15"
     assert state.attributes[ATTR_REMAINING] == "0:00:15"
 
+    await hass.services.async_call(
+        DOMAIN,
+        SERVICE_CHANGE,
+        {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 15},
+        blocking=True,
+    )
+    await hass.async_block_till_done()
+    state = hass.states.get("timer.test1")
+    assert state
+    assert state.state == STATUS_ACTIVE
+    assert state.attributes[ATTR_DURATION] == "0:00:30"
+    assert state.attributes[ATTR_REMAINING] == "0:00:30"
+
+    await hass.services.async_call(
+        DOMAIN,
+        SERVICE_CHANGE,
+        {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: -10},
+        blocking=True,
+    )
+    await hass.async_block_till_done()
+    state = hass.states.get("timer.test1")
+    assert state
+    assert state.state == STATUS_ACTIVE
+    assert state.attributes[ATTR_DURATION] == "0:00:20"
+    assert state.attributes[ATTR_REMAINING] == "0:00:20"
+
+    await hass.services.async_call(
+        DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
+    )
+    await hass.async_block_till_done()
+    state = hass.states.get("timer.test1")
+    assert state
+    assert state.state == STATUS_IDLE
+    assert state.attributes[ATTR_DURATION] == "0:00:20"
+    assert ATTR_REMAINING not in state.attributes
+
+    with pytest.raises(HomeAssistantError):
+        await hass.services.async_call(
+            DOMAIN,
+            SERVICE_CHANGE,
+            {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 10},
+            blocking=True,
+        )
+        await hass.async_block_till_done()
+
+    state = hass.states.get("timer.test1")
+    assert state
+    assert state.state == STATUS_IDLE
+    assert state.attributes[ATTR_DURATION] == "0:00:20"
+    assert ATTR_REMAINING not in state.attributes
+
 
 async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
     """Test for a timer to end."""
@@ -262,9 +392,10 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
     hass.bus.async_listen(EVENT_TIMER_PAUSED, fake_event_listener)
     hass.bus.async_listen(EVENT_TIMER_FINISHED, fake_event_listener)
     hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener)
+    hass.bus.async_listen(EVENT_TIMER_CHANGED, fake_event_listener)
 
     await hass.services.async_call(
-        DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}
+        DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
     )
     await hass.async_block_till_done()
 
@@ -275,15 +406,37 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
     assert results[-1].event_type == EVENT_TIMER_STARTED
     assert len(results) == 1
 
+    await hass.services.async_call(
+        DOMAIN,
+        SERVICE_CHANGE,
+        {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 10},
+        blocking=True,
+    )
+    await hass.async_block_till_done()
+
+    state = hass.states.get("timer.test1")
+    assert state
+    assert state.state == STATUS_ACTIVE
+
+    assert results[-1].event_type == EVENT_TIMER_CHANGED
+    assert len(results) == 2
+
     async_fire_time_changed(hass, utcnow() + timedelta(seconds=10))
     await hass.async_block_till_done()
 
+    state = hass.states.get("timer.test1")
+    assert state
+    assert state.state == STATUS_ACTIVE
+
+    async_fire_time_changed(hass, utcnow() + timedelta(seconds=20))
+    await hass.async_block_till_done()
+
     state = hass.states.get("timer.test1")
     assert state
     assert state.state == STATUS_IDLE
 
     assert results[-1].event_type == EVENT_TIMER_FINISHED
-    assert len(results) == 2
+    assert len(results) == 3
 
 
 async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: