diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py new file mode 100644 index 00000000000..dc0774edef2 --- /dev/null +++ b/homeassistant/components/alert.py @@ -0,0 +1,275 @@ +""" +Support for repeating alerts when conditions are met. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alert/ +""" +import asyncio +from datetime import datetime, timedelta +import logging +import os + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (CONF_ENTITY_ID, STATE_IDLE, CONF_NAME, + CONF_STATE, STATE_ON, STATE_OFF, + SERVICE_TURN_ON, SERVICE_TURN_OFF, + SERVICE_TOGGLE, ATTR_ENTITY_ID) +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers import service, event +from homeassistant.util.async import run_callback_threadsafe +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'alert' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_CAN_ACK = 'can_acknowledge' +CONF_NOTIFIERS = 'notifiers' +CONF_REPEAT = 'repeat' +CONF_SKIP_FIRST = 'skip_first' + +ALERT_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_STATE, default=STATE_ON): cv.string, + vol.Required(CONF_REPEAT): vol.All(cv.ensure_list, [vol.Coerce(float)]), + vol.Required(CONF_CAN_ACK, default=True): cv.boolean, + vol.Required(CONF_SKIP_FIRST, default=False): cv.boolean, + vol.Required(CONF_NOTIFIERS): cv.ensure_list}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: ALERT_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + + +ALERT_SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, +}) + + +def is_on(hass, entity_id): + """Return if the alert is firing and not acknowledged.""" + return hass.states.is_state(entity_id, STATE_ON) + + +def turn_on(hass, entity_id): + """Reset the alert.""" + run_callback_threadsafe(hass.loop, async_turn_on, hass, entity_id) + + +@callback +def async_turn_on(hass, entity_id): + """Async reset the alert.""" + data = {ATTR_ENTITY_ID: entity_id} + hass.async_add_job( + hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)) + + +def turn_off(hass, entity_id): + """Acknowledge alert.""" + run_callback_threadsafe(hass.loop, async_turn_off, hass, entity_id) + + +@callback +def async_turn_off(hass, entity_id): + """Async acknowledge the alert.""" + data = {ATTR_ENTITY_ID: entity_id} + hass.async_add_job( + hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data)) + + +def toggle(hass, entity_id): + """Toggle acknowledgement of alert.""" + run_callback_threadsafe(hass.loop, async_toggle, hass, entity_id) + + +@callback +def async_toggle(hass, entity_id): + """Async toggle acknowledgement of alert.""" + data = {ATTR_ENTITY_ID: entity_id} + hass.async_add_job( + hass.services.async_call(DOMAIN, SERVICE_TOGGLE, data)) + + +@asyncio.coroutine +def async_setup(hass, config): + """Setup alert component.""" + alerts = config.get(DOMAIN) + all_alerts = {} + + @asyncio.coroutine + def async_handle_alert_service(service_call): + """Handle calls to alert services.""" + alert_ids = service.extract_entity_ids(hass, service_call) + + for alert_id in alert_ids: + alert = all_alerts[alert_id] + if service_call.service == SERVICE_TURN_ON: + yield from alert.async_turn_on() + elif service_call.service == SERVICE_TOGGLE: + yield from alert.async_toggle() + else: + yield from alert.async_turn_off() + + # setup alerts + for entity_id, alert in alerts.items(): + entity = Alert(hass, entity_id, + alert[CONF_NAME], alert[CONF_ENTITY_ID], + alert[CONF_STATE], alert[CONF_REPEAT], + alert[CONF_SKIP_FIRST], alert[CONF_NOTIFIERS], + alert[CONF_CAN_ACK]) + all_alerts[entity.entity_id] = entity + + # read descriptions + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml')) + descriptions = descriptions.get(DOMAIN, {}) + + # setup service calls + hass.services.async_register( + DOMAIN, SERVICE_TURN_OFF, async_handle_alert_service, + descriptions.get(SERVICE_TURN_OFF), schema=ALERT_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_TURN_ON, async_handle_alert_service, + descriptions.get(SERVICE_TURN_ON), schema=ALERT_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_TOGGLE, async_handle_alert_service, + descriptions.get(SERVICE_TOGGLE), schema=ALERT_SERVICE_SCHEMA) + + tasks = [alert.async_update_ha_state() for alert in all_alerts.values()] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + return True + + +class Alert(ToggleEntity): + """Representation of an alert.""" + + def __init__(self, hass, entity_id, name, watched_entity_id, state, + repeat, skip_first, notifiers, can_ack): + """Initialize the alert.""" + self.hass = hass + self._name = name + self._alert_state = state + self._skip_first = skip_first + self._notifiers = notifiers + self._can_ack = can_ack + + self._delay = [timedelta(minutes=val) for val in repeat] + self._next_delay = 0 + + self._firing = False + self._ack = False + self._cancel = None + self.entity_id = ENTITY_ID_FORMAT.format(entity_id) + + event.async_track_state_change(hass, watched_entity_id, + self.watched_entity_change) + + @property + def name(self): + """Return the name of the alert.""" + return self._name + + @property + def should_poll(self): + """HASS need not poll these entities.""" + return False + + @property + def state(self): + """Return the alert status.""" + if self._firing: + if self._ack: + return STATE_OFF + return STATE_ON + return STATE_IDLE + + @property + def hidden(self): + """Hide the alert when it is not firing.""" + return not self._can_ack or not self._firing + + @asyncio.coroutine + def watched_entity_change(self, entity, from_state, to_state): + """Determine if the alert should start or stop.""" + _LOGGER.debug('Watched entity (%s) has changed.', entity) + if to_state.state == self._alert_state and not self._firing: + yield from self.begin_alerting() + if to_state.state != self._alert_state and self._firing: + yield from self.end_alerting() + + @asyncio.coroutine + def begin_alerting(self): + """Begin the alert procedures.""" + _LOGGER.debug('Beginning Alert: %s', self._name) + self._ack = False + self._firing = True + self._next_delay = 0 + + if not self._skip_first: + yield from self._notify() + else: + yield from self._schedule_notify() + + self.hass.async_add_job(self.async_update_ha_state) + + @asyncio.coroutine + def end_alerting(self): + """End the alert procedures.""" + _LOGGER.debug('Ending Alert: %s', self._name) + self._cancel() + self._ack = False + self._firing = False + self.hass.async_add_job(self.async_update_ha_state) + + @asyncio.coroutine + def _schedule_notify(self): + """Schedule a notification.""" + delay = self._delay[self._next_delay] + next_msg = datetime.now() + delay + self._cancel = \ + event.async_track_point_in_time(self.hass, self._notify, next_msg) + self._next_delay = min(self._next_delay + 1, len(self._delay) - 1) + + @asyncio.coroutine + def _notify(self, *args): + """Send the alert notification.""" + if not self._firing: + return + + if not self._ack: + _LOGGER.info('Alerting: %s', self._name) + for target in self._notifiers: + yield from self.hass.services.async_call( + 'notify', target, {'message': self._name}) + yield from self._schedule_notify() + + @asyncio.coroutine + def async_turn_on(self): + """Async Unacknowledge alert.""" + _LOGGER.debug('Reset Alert: %s', self._name) + self._ack = False + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_turn_off(self): + """Async Acknowledge alert.""" + _LOGGER.debug('Acknowledged Alert: %s', self._name) + self._ack = True + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_toggle(self): + """Async toggle alert.""" + if self._ack: + return self.async_turn_on() + return self.async_turn_off() diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index c6057dcd2a8..661f8be8dab 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -211,6 +211,31 @@ verisure: description: The serial number of the smartcam you want to capture an image from. example: '2DEU AT5Z' +alert: + turn_off: + description: Silence alert's notifications. + + fields: + entity_id: + description: Name of the alert to silence. + example: 'alert.garage_door_open' + + turn_on: + description: Reset alert's notifications. + + fields: + entity_id: + description: Name of the alert to reset. + example: 'alert.garage_door_open' + + toggle: + description: Toggle alert's notifications. + + fields: + entity_id: + description: Name of the alert to toggle. + example: 'alert.garage_door_open' + hdmi_cec: send_command: description: Sends CEC command into HDMI CEC capable adapter. diff --git a/tests/components/test_alert.py b/tests/components/test_alert.py new file mode 100644 index 00000000000..00e4abec25b --- /dev/null +++ b/tests/components/test_alert.py @@ -0,0 +1,172 @@ +"""The tests for the Alert component.""" +# pylint: disable=protected-access +from copy import deepcopy +import unittest + +from homeassistant.bootstrap import setup_component +from homeassistant.core import callback +import homeassistant.components.alert as alert +import homeassistant.components.notify as notify +from homeassistant.const import (CONF_ENTITY_ID, STATE_IDLE, CONF_NAME, + CONF_STATE, STATE_ON, STATE_OFF) + +from tests.common import get_test_home_assistant + +NAME = "alert_test" +NOTIFIER = 'test' +TEST_CONFIG = \ + {alert.DOMAIN: { + NAME: { + CONF_NAME: NAME, + CONF_ENTITY_ID: "sensor.test", + CONF_STATE: STATE_ON, + alert.CONF_REPEAT: 30, + alert.CONF_SKIP_FIRST: False, + alert.CONF_NOTIFIERS: [NOTIFIER]} + }} +TEST_NOACK = [NAME, NAME, "sensor.test", STATE_ON, + [30], False, NOTIFIER, False] +ENTITY_ID = alert.ENTITY_ID_FORMAT.format(NAME) + + +# pylint: disable=invalid-name +class TestAlert(unittest.TestCase): + """Test the alert module.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_is_on(self): + """Test is_on method.""" + self.hass.states.set(ENTITY_ID, STATE_ON) + self.hass.block_till_done() + self.assertTrue(alert.is_on(self.hass, ENTITY_ID)) + self.hass.states.set(ENTITY_ID, STATE_OFF) + self.hass.block_till_done() + self.assertFalse(alert.is_on(self.hass, ENTITY_ID)) + + def test_setup(self): + """Test setup method.""" + assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG) + self.assertEqual(STATE_IDLE, self.hass.states.get(ENTITY_ID).state) + + def test_fire(self): + """Test the alert firing.""" + assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG) + self.hass.states.set("sensor.test", STATE_ON) + self.hass.block_till_done() + self.assertEqual(STATE_ON, self.hass.states.get(ENTITY_ID).state) + + def test_silence(self): + """Test silencing the alert.""" + assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG) + self.hass.states.set("sensor.test", STATE_ON) + self.hass.block_till_done() + alert.turn_off(self.hass, ENTITY_ID) + self.hass.block_till_done() + self.assertEqual(STATE_OFF, self.hass.states.get(ENTITY_ID).state) + + # alert should not be silenced on next fire + self.hass.states.set("sensor.test", STATE_OFF) + self.hass.block_till_done() + self.assertEqual(STATE_IDLE, self.hass.states.get(ENTITY_ID).state) + self.hass.states.set("sensor.test", STATE_ON) + self.hass.block_till_done() + self.assertEqual(STATE_ON, self.hass.states.get(ENTITY_ID).state) + + def test_reset(self): + """Test resetting the alert.""" + assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG) + self.hass.states.set("sensor.test", STATE_ON) + self.hass.block_till_done() + alert.turn_off(self.hass, ENTITY_ID) + self.hass.block_till_done() + self.assertEqual(STATE_OFF, self.hass.states.get(ENTITY_ID).state) + alert.turn_on(self.hass, ENTITY_ID) + self.hass.block_till_done() + self.assertEqual(STATE_ON, self.hass.states.get(ENTITY_ID).state) + + def test_toggle(self): + """Test toggling alert.""" + assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG) + self.hass.states.set("sensor.test", STATE_ON) + self.hass.block_till_done() + self.assertEqual(STATE_ON, self.hass.states.get(ENTITY_ID).state) + alert.toggle(self.hass, ENTITY_ID) + self.hass.block_till_done() + self.assertEqual(STATE_OFF, self.hass.states.get(ENTITY_ID).state) + alert.toggle(self.hass, ENTITY_ID) + self.hass.block_till_done() + self.assertEqual(STATE_ON, self.hass.states.get(ENTITY_ID).state) + + def test_hidden(self): + """Test entity hidding.""" + assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG) + hidden = self.hass.states.get(ENTITY_ID).attributes.get('hidden') + self.assertTrue(hidden) + + self.hass.states.set("sensor.test", STATE_ON) + self.hass.block_till_done() + hidden = self.hass.states.get(ENTITY_ID).attributes.get('hidden') + self.assertFalse(hidden) + + alert.turn_off(self.hass, ENTITY_ID) + hidden = self.hass.states.get(ENTITY_ID).attributes.get('hidden') + self.assertFalse(hidden) + + def test_notification(self): + """Test notifications.""" + events = [] + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + self.hass.services.register( + notify.DOMAIN, NOTIFIER, record_event) + + assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG) + self.assertEqual(0, len(events)) + + self.hass.states.set("sensor.test", STATE_ON) + self.hass.block_till_done() + self.assertEqual(1, len(events)) + + self.hass.states.set("sensor.test", STATE_OFF) + self.hass.block_till_done() + self.assertEqual(1, len(events)) + + def test_skipfirst(self): + """Test skipping first notification.""" + config = deepcopy(TEST_CONFIG) + config[alert.DOMAIN][NAME][alert.CONF_SKIP_FIRST] = True + events = [] + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + self.hass.services.register( + notify.DOMAIN, NOTIFIER, record_event) + + assert setup_component(self.hass, alert.DOMAIN, config) + self.assertEqual(0, len(events)) + + self.hass.states.set("sensor.test", STATE_ON) + self.hass.block_till_done() + self.assertEqual(0, len(events)) + + def test_noack(self): + """Test no ack feature.""" + entity = alert.Alert(self.hass, *TEST_NOACK) + self.hass.async_add_job(entity.begin_alerting) + self.hass.block_till_done() + + self.assertEqual(True, entity.hidden)