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: