From 890c11cc7c802955ece970eb632e2c8e8ad12fa1 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Wed, 1 Nov 2017 04:20:56 +0100 Subject: [PATCH] WIP: Timer component (#9889) * Added timer component * Reworked functionality a bit * Fixed requested change * Fixed state updates when finished * Removing expired listeners, added events, changed services * Added finish service * Using timedelta parameters in start-service * Cleanup * Lint * Updating state for remaining time * Removed duration from cancel method * Renamed service to fix disabled lint * Some tests (incomplete) * Relocated service descriptions * Addressed requested changes * Adjusted tests, added methods and events * Added test for finish service, lint * Code cleanp, using string states * tzzz... one char... * Proper usage of restore_state * Some more cleanup --- homeassistant/components/timer.py | 320 +++++++++++++++++++ homeassistant/components/timer/services.yaml | 34 ++ tests/components/test_timer.py | 199 ++++++++++++ 3 files changed, 553 insertions(+) create mode 100644 homeassistant/components/timer.py create mode 100644 homeassistant/components/timer/services.yaml create mode 100644 tests/components/test_timer.py diff --git a/homeassistant/components/timer.py b/homeassistant/components/timer.py new file mode 100644 index 00000000000..4d21cca40bb --- /dev/null +++ b/homeassistant/components/timer.py @@ -0,0 +1,320 @@ +""" +Timer component. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/timer/ +""" +import asyncio +import logging +import os +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.util.dt as dt_util +import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_track_point_in_utc_time + +from homeassistant.loader import bind_hass + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'timer' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +DEFAULT_DURATION = 0 +ATTR_DURATION = 'duration' +CONF_DURATION = 'duration' + +STATUS_IDLE = 'idle' +STATUS_ACTIVE = 'active' +STATUS_PAUSED = 'paused' + +EVENT_TIMER_FINISHED = 'timer.finished' +EVENT_TIMER_CANCELLED = 'timer.cancelled' + +SERVICE_START = 'start' +SERVICE_PAUSE = 'pause' +SERVICE_CANCEL = 'cancel' +SERVICE_FINISH = 'finish' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SERVICE_SCHEMA_DURATION = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_DURATION, + default=timedelta(DEFAULT_DURATION)): cv.time_period, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.Any({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_DURATION, timedelta(DEFAULT_DURATION)): + cv.time_period, + }, None) + }) +}, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def start(hass, entity_id, duration): + """Start a timer.""" + hass.add_job(async_start, hass, entity_id, {ATTR_ENTITY_ID: entity_id, + ATTR_DURATION: duration}) + + +@callback +@bind_hass +def async_start(hass, entity_id, duration): + """Start a timer.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_START, {ATTR_ENTITY_ID: entity_id, + ATTR_DURATION: duration})) + + +@bind_hass +def pause(hass, entity_id): + """Pause a timer.""" + hass.add_job(async_pause, hass, entity_id) + + +@callback +@bind_hass +def async_pause(hass, entity_id): + """Pause a timer.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_PAUSE, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def cancel(hass, entity_id): + """Cancel a timer.""" + hass.add_job(async_cancel, hass, entity_id) + + +@callback +@bind_hass +def async_cancel(hass, entity_id): + """Cancel a timer.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_CANCEL, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def finish(hass, entity_id): + """Finish a timer.""" + hass.add_job(async_cancel, hass, entity_id) + + +@callback +@bind_hass +def async_finish(hass, entity_id): + """Finish a timer.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_FINISH, {ATTR_ENTITY_ID: entity_id})) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up a timer.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + icon = cfg.get(CONF_ICON) + duration = cfg.get(CONF_DURATION) + + entities.append(Timer(hass, object_id, name, icon, duration)) + + if not entities: + return False + + @asyncio.coroutine + def async_handler_service(service): + """Handle a call to the timer services.""" + target_timers = component.async_extract_from_service(service) + + attr = None + if service.service == SERVICE_PAUSE: + attr = 'async_pause' + elif service.service == SERVICE_CANCEL: + attr = 'async_cancel' + elif service.service == SERVICE_FINISH: + attr = 'async_finish' + + tasks = [getattr(timer, attr)() for timer in target_timers if attr] + if service.service == SERVICE_START: + for timer in target_timers: + tasks.append( + timer.async_start(service.data.get(ATTR_DURATION)) + ) + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), os.path.join(DOMAIN, 'services.yaml')) + ) + + hass.services.async_register( + DOMAIN, SERVICE_START, async_handler_service, + descriptions[SERVICE_START], SERVICE_SCHEMA_DURATION) + hass.services.async_register( + DOMAIN, SERVICE_PAUSE, async_handler_service, + descriptions[SERVICE_PAUSE], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_CANCEL, async_handler_service, + descriptions[SERVICE_CANCEL], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_FINISH, async_handler_service, + descriptions[SERVICE_FINISH], SERVICE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class Timer(Entity): + """Representation of a timer.""" + + def __init__(self, hass, object_id, name, icon, duration): + """Initialize a timer.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._state = STATUS_IDLE + self._duration = duration + self._remaining = self._duration + self._icon = icon + self._hass = hass + self._end = None + self._listener = None + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the timer.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the current value of the timer.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_DURATION: str(self._duration), + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity is about to be added to Home Assistant.""" + # If not None, we got an initial value. + if self._state is not None: + return + + restore_state = self._hass.helpers.restore_state + state = yield from restore_state.async_get_last_state(self.entity_id) + self._state = state and state.state == state + + @asyncio.coroutine + def async_start(self, duration): + """Start a timer.""" + if self._listener: + self._listener() + self._listener = None + newduration = None + if duration: + newduration = duration + + self._state = STATUS_ACTIVE + # pylint: disable=redefined-outer-name + start = dt_util.utcnow() + if self._remaining and newduration is None: + self._end = start + self._remaining + else: + if newduration: + self._duration = newduration + self._remaining = newduration + else: + self._remaining = self._duration + self._end = start + self._duration + self._listener = async_track_point_in_utc_time(self._hass, + self.async_finished, + self._end) + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_pause(self): + """Pause a timer.""" + if self._listener is None: + return + + self._listener() + self._listener = None + self._remaining = self._end - dt_util.utcnow() + self._state = STATUS_PAUSED + self._end = None + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_cancel(self): + """Cancel a timer.""" + if self._listener: + self._listener() + self._listener = None + self._state = STATUS_IDLE + self._end = None + self._remaining = timedelta() + self._hass.bus.async_fire(EVENT_TIMER_CANCELLED, + {"entity_id": self.entity_id}) + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_finish(self): + """Reset and updates the states, fire finished event.""" + if self._state != STATUS_ACTIVE: + return + + self._listener = None + self._state = STATUS_IDLE + self._remaining = timedelta() + self._hass.bus.async_fire(EVENT_TIMER_FINISHED, + {"entity_id": self.entity_id}) + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_finished(self, time): + """Reset and updates the states, fire finished event.""" + if self._state != STATUS_ACTIVE: + return + + self._listener = None + self._state = STATUS_IDLE + self._remaining = timedelta() + self._hass.bus.async_fire(EVENT_TIMER_FINISHED, + {"entity_id": self.entity_id}) + yield from self.async_update_ha_state() diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml new file mode 100644 index 00000000000..f7d2c1a77b5 --- /dev/null +++ b/homeassistant/components/timer/services.yaml @@ -0,0 +1,34 @@ +start: + description: Start a timer. + + fields: + entity_id: + description: Entity id of the timer to start. [optional] + example: 'timer.timer0' + duration: + description: Duration the timer requires to finish. [optional] + example: '00:01:00 or 60' + +pause: + description: Pause a timer. + + fields: + entity_id: + description: Entity id of the timer to pause. [optional] + example: 'timer.timer0' + +cancel: + description: Cancel a timer. + + fields: + entity_id: + description: Entity id of the timer to cancel. [optional] + example: 'timer.timer0' + +finish: + description: Finish a timer. + + fields: + entity_id: + description: Entity id of the timer to finish. [optional] + example: 'timer.timer0' \ No newline at end of file diff --git a/tests/components/test_timer.py b/tests/components/test_timer.py new file mode 100644 index 00000000000..5b36273f046 --- /dev/null +++ b/tests/components/test_timer.py @@ -0,0 +1,199 @@ +"""The tests for the timer component.""" +# pylint: disable=protected-access +import asyncio +import unittest +import logging +from datetime import timedelta + +from homeassistant.core import CoreState +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.timer import ( + DOMAIN, CONF_DURATION, CONF_NAME, STATUS_ACTIVE, STATUS_IDLE, + STATUS_PAUSED, CONF_ICON, ATTR_DURATION, EVENT_TIMER_FINISHED, + EVENT_TIMER_CANCELLED, SERVICE_START, SERVICE_PAUSE, SERVICE_CANCEL, + SERVICE_FINISH) +from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME, CONF_ENTITY_ID) +from homeassistant.util.dt import utcnow + +from tests.common import (get_test_home_assistant, async_fire_time_changed) + +_LOGGER = logging.getLogger(__name__) + + +class TestTimer(unittest.TestCase): + """Test the timer component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_config(self): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] + + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + def test_config_options(self): + """Test configuration options.""" + count_start = len(self.hass.states.entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_DURATION: 10, + } + } + } + + assert setup_component(self.hass, 'timer', config) + self.hass.block_till_done() + + self.assertEqual(count_start + 2, len(self.hass.states.entity_ids())) + self.hass.block_till_done() + + state_1 = self.hass.states.get('timer.test_1') + state_2 = self.hass.states.get('timer.test_2') + + self.assertIsNotNone(state_1) + self.assertIsNotNone(state_2) + + self.assertEqual(STATUS_IDLE, state_1.state) + self.assertNotIn(ATTR_ICON, state_1.attributes) + self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes) + + self.assertEqual(STATUS_IDLE, state_2.state) + self.assertEqual('Hello World', + state_2.attributes.get(ATTR_FRIENDLY_NAME)) + self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON)) + self.assertEqual('0:00:10', state_2.attributes.get(ATTR_DURATION)) + + +@asyncio.coroutine +def test_methods_and_events(hass): + """Test methods and events.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': { + CONF_DURATION: 10, + } + }}) + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_IDLE + + results = [] + + def fake_event_listener(event): + """Fake event listener for trigger.""" + results.append(event) + + hass.bus.async_listen(EVENT_TIMER_FINISHED, fake_event_listener) + hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener) + + yield from hass.services.async_call(DOMAIN, + SERVICE_START, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_ACTIVE + + yield from hass.services.async_call(DOMAIN, + SERVICE_PAUSE, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_PAUSED + + yield from hass.services.async_call(DOMAIN, + SERVICE_CANCEL, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_IDLE + + assert len(results) == 1 + assert results[-1].event_type == EVENT_TIMER_CANCELLED + + yield from hass.services.async_call(DOMAIN, + SERVICE_START, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from 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=10)) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_IDLE + + assert len(results) == 2 + assert results[-1].event_type == EVENT_TIMER_FINISHED + + yield from hass.services.async_call(DOMAIN, + SERVICE_START, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_ACTIVE + + yield from hass.services.async_call(DOMAIN, + SERVICE_FINISH, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_IDLE + + assert len(results) == 3 + assert results[-1].event_type == EVENT_TIMER_FINISHED + + +@asyncio.coroutine +def test_no_initial_state_and_no_restore_state(hass): + """Ensure that entity is create without initial and restore feature.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': { + CONF_DURATION: 10, + } + }}) + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_IDLE