diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py new file mode 100644 index 00000000000..019c9cd8787 --- /dev/null +++ b/homeassistant/components/template/alarm_control_panel.py @@ -0,0 +1,283 @@ +"""Support for Template alarm control panels.""" +import logging + +import voluptuous as vol + +from homeassistant.components.alarm_control_panel import ( + ENTITY_ID_FORMAT, + FORMAT_NUMBER, + PLATFORM_SCHEMA, + AlarmControlPanel, +) +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) +from homeassistant.const import ( + ATTR_CODE, + CONF_NAME, + CONF_VALUE_TEMPLATE, + EVENT_HOMEASSISTANT_START, + MATCH_ALL, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, + STATE_UNAVAILABLE, +) +from homeassistant.core import callback +from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.script import Script + +_LOGGER = logging.getLogger(__name__) +_VALID_STATES = [ + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, + STATE_ALARM_ARMED_NIGHT, + STATE_UNAVAILABLE, +] + +CONF_ARM_AWAY_ACTION = "arm_away" +CONF_ARM_HOME_ACTION = "arm_home" +CONF_ARM_NIGHT_ACTION = "arm_night" +CONF_DISARM_ACTION = "disarm" +CONF_ALARM_CONTROL_PANELS = "panels" +CONF_CODE_ARM_REQUIRED = "code_arm_required" + +ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( + { + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional(CONF_NAME): cv.string, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ALARM_CONTROL_PANELS): cv.schema_with_slug_keys( + ALARM_CONTROL_PANEL_SCHEMA + ), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Template Alarm Control Panels.""" + alarm_control_panels = [] + + for device, device_config in config[CONF_ALARM_CONTROL_PANELS].items(): + name = device_config.get(CONF_NAME, device) + state_template = device_config.get(CONF_VALUE_TEMPLATE) + disarm_action = device_config.get(CONF_DISARM_ACTION) + arm_away_action = device_config.get(CONF_ARM_AWAY_ACTION) + arm_home_action = device_config.get(CONF_ARM_HOME_ACTION) + arm_night_action = device_config.get(CONF_ARM_NIGHT_ACTION) + code_arm_required = device_config[CONF_CODE_ARM_REQUIRED] + + template_entity_ids = set() + + if state_template is not None: + temp_ids = state_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + else: + _LOGGER.warning("No value template - will use optimistic state") + + if not template_entity_ids: + template_entity_ids = MATCH_ALL + + alarm_control_panels.append( + AlarmControlPanelTemplate( + hass, + device, + name, + state_template, + disarm_action, + arm_away_action, + arm_home_action, + arm_night_action, + code_arm_required, + template_entity_ids, + ) + ) + + async_add_entities(alarm_control_panels) + + +class AlarmControlPanelTemplate(AlarmControlPanel): + """Representation of a templated Alarm Control Panel.""" + + def __init__( + self, + hass, + device_id, + name, + state_template, + disarm_action, + arm_away_action, + arm_home_action, + arm_night_action, + code_arm_required, + template_entity_ids, + ): + """Initialize the panel.""" + self.hass = hass + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, device_id, hass=hass + ) + self._name = name + self._template = state_template + self._disarm_script = None + self._code_arm_required = code_arm_required + if disarm_action is not None: + self._disarm_script = Script(hass, disarm_action) + self._arm_away_script = None + if arm_away_action is not None: + self._arm_away_script = Script(hass, arm_away_action) + self._arm_home_script = None + if arm_home_action is not None: + self._arm_home_script = Script(hass, arm_home_action) + self._arm_night_script = None + if arm_night_action is not None: + self._arm_night_script = Script(hass, arm_night_action) + + self._state = None + self._entities = template_entity_ids + + if self._template is not None: + self._template.hass = self.hass + + @property + def name(self): + """Return the display name of this alarm control panel.""" + return self._name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + supported_features = 0 + if self._arm_night_script is not None: + supported_features = supported_features | SUPPORT_ALARM_ARM_NIGHT + + if self._arm_home_script is not None: + supported_features = supported_features | SUPPORT_ALARM_ARM_HOME + + if self._arm_away_script is not None: + supported_features = supported_features | SUPPORT_ALARM_ARM_AWAY + + return supported_features + + @property + def code_format(self): + """Return one or more digits/characters.""" + return FORMAT_NUMBER + + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return self._code_arm_required + + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def template_alarm_state_listener(entity, old_state, new_state): + """Handle target device state changes.""" + self.async_schedule_update_ha_state(True) + + @callback + def template_alarm_control_panel_startup(event): + """Update template on startup.""" + if self._template is not None: + async_track_state_change( + self.hass, self._entities, template_alarm_state_listener + ) + + self.async_schedule_update_ha_state(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_alarm_control_panel_startup + ) + + async def _async_alarm_arm(self, state, script=None, code=None): + """Arm the panel to specified state with supplied script.""" + optimistic_set = False + + if self._template is None: + self._state = state + optimistic_set = True + + if script is not None: + await script.async_run({ATTR_CODE: code}, context=self._context) + else: + _LOGGER.error("No script action defined for %s", state) + + if optimistic_set: + self.async_schedule_update_ha_state() + + async def async_alarm_arm_away(self, code=None): + """Arm the panel to Away.""" + await self._async_alarm_arm( + STATE_ALARM_ARMED_AWAY, script=self._arm_away_script, code=code + ) + + async def async_alarm_arm_home(self, code=None): + """Arm the panel to Home.""" + await self._async_alarm_arm( + STATE_ALARM_ARMED_HOME, script=self._arm_home_script, code=code + ) + + async def async_alarm_arm_night(self, code=None): + """Arm the panel to Night.""" + await self._async_alarm_arm( + STATE_ALARM_ARMED_NIGHT, script=self._arm_night_script, code=code + ) + + async def async_alarm_disarm(self, code=None): + """Disarm the panel.""" + await self._async_alarm_arm( + STATE_ALARM_DISARMED, script=self._disarm_script, code=code + ) + + async def async_update(self): + """Update the state from the template.""" + if self._template is None: + return + + try: + state = self._template.async_render().lower() + except TemplateError as ex: + _LOGGER.error(ex) + self._state = None + + if state in _VALID_STATES: + self._state = state + _LOGGER.debug("Valid state - %s", state) + else: + _LOGGER.error( + "Received invalid alarm panel state: %s. Expected: %s", + state, + ", ".join(_VALID_STATES), + ) + self._state = None diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py new file mode 100644 index 00000000000..36c639bc95b --- /dev/null +++ b/tests/components/template/test_alarm_control_panel.py @@ -0,0 +1,556 @@ +"""The tests for the Template alarm control panel platform.""" +import logging + +from homeassistant import setup +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, +) + +from tests.common import async_mock_service +from tests.components.alarm_control_panel import common + +_LOGGER = logging.getLogger(__name__) + + +async def test_template_state_text(hass): + """Test the state text of a template.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_HOME) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test_template_panel") + assert state.state == STATE_ALARM_ARMED_HOME + + hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_AWAY) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test_template_panel") + assert state.state == STATE_ALARM_ARMED_AWAY + + hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_NIGHT) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test_template_panel") + assert state.state == STATE_ALARM_ARMED_NIGHT + + hass.states.async_set("alarm_control_panel.test", STATE_ALARM_DISARMED) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test_template_panel") + assert state.state == STATE_ALARM_DISARMED + + +async def test_optimistic_states(hass): + """Test the optimistic state.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test_template_panel") + await hass.async_block_till_done() + assert state.state == "unknown" + + await common.async_alarm_arm_away( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + state = hass.states.get("alarm_control_panel.test_template_panel") + await hass.async_block_till_done() + assert state.state == STATE_ALARM_ARMED_AWAY + + await common.async_alarm_arm_home( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + state = hass.states.get("alarm_control_panel.test_template_panel") + await hass.async_block_till_done() + assert state.state == STATE_ALARM_ARMED_HOME + + await common.async_alarm_arm_night( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + state = hass.states.get("alarm_control_panel.test_template_panel") + await hass.async_block_till_done() + assert state.state == STATE_ALARM_ARMED_NIGHT + + await common.async_alarm_disarm( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + state = hass.states.get("alarm_control_panel.test_template_panel") + await hass.async_block_till_done() + assert state.state == STATE_ALARM_DISARMED + + +async def test_no_action_scripts(hass): + """Test no action scripts per state.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + } + }, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_AWAY) + await hass.async_block_till_done() + + await common.async_alarm_arm_away( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + state = hass.states.get("alarm_control_panel.test_template_panel") + await hass.async_block_till_done() + assert state.state == STATE_ALARM_ARMED_AWAY + + await common.async_alarm_arm_home( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + state = hass.states.get("alarm_control_panel.test_template_panel") + await hass.async_block_till_done() + assert state.state == STATE_ALARM_ARMED_AWAY + + await common.async_alarm_arm_night( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + state = hass.states.get("alarm_control_panel.test_template_panel") + await hass.async_block_till_done() + assert state.state == STATE_ALARM_ARMED_AWAY + + await common.async_alarm_disarm( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + state = hass.states.get("alarm_control_panel.test_template_panel") + await hass.async_block_till_done() + assert state.state == STATE_ALARM_ARMED_AWAY + + +async def test_template_syntax_error(hass, caplog): + """Test templating syntax error.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{% if blah %}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + assert ("invalid template") in caplog.text + + +async def test_invalid_name_does_not_create(hass, caplog): + """Test invalid name.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "bad name here": { + "value_template": "{{ disarmed }}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + assert ("invalid slug bad name") in caplog.text + + +async def test_invalid_panel_does_not_create(hass, caplog): + """Test invalid alarm control panel.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "wibble": {"test_panel": "Invalid"}, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + assert ("[wibble] is an invalid option") in caplog.text + + +async def test_no_panels_does_not_create(hass, caplog): + """Test if there are no panels -> no creation.""" + await setup.async_setup_component( + hass, "alarm_control_panel", {"alarm_control_panel": {"platform": "template"}}, + ) + + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + assert ("required key not provided @ data['panels']") in caplog.text + + +async def test_name(hass): + """Test the accessibility of the name attribute.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "name": "Template Alarm Panel", + "value_template": "{{ disarmed }}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test_template_panel") + assert state is not None + + assert state.attributes.get("friendly_name") == "Template Alarm Panel" + + +async def test_arm_home_action(hass): + """Test arm home action.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": {"service": "test.automation"}, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + service_calls = async_mock_service(hass, "test", "automation") + + await common.async_alarm_arm_home( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + + +async def test_arm_away_action(hass): + """Test arm away action.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_away": {"service": "test.automation"}, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + service_calls = async_mock_service(hass, "test", "automation") + + await common.async_alarm_arm_away( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + + +async def test_arm_night_action(hass): + """Test arm night action.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": {"service": "test.automation"}, + "arm_away": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + service_calls = async_mock_service(hass, "test", "automation") + + await common.async_alarm_arm_night( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + + +async def test_disarm_action(hass): + """Test disarm action.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": {"service": "test.automation"}, + "arm_away": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ) + + await hass.async_start() + await hass.async_block_till_done() + + service_calls = async_mock_service(hass, "test", "automation") + + await common.async_alarm_disarm( + hass, entity_id="alarm_control_panel.test_template_panel" + ) + await hass.async_block_till_done() + + assert len(service_calls) == 1