diff --git a/.coveragerc b/.coveragerc index 8e9ed417d7b..cf56182fd6d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -290,6 +290,9 @@ omit = homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py + homeassistant/components/simplisafe/__init__.py + homeassistant/components/*/simplisafe.py + homeassistant/components/sisyphus.py homeassistant/components/*/sisyphus.py @@ -401,7 +404,6 @@ omit = homeassistant/components/alarm_control_panel/ifttt.py homeassistant/components/alarm_control_panel/manual_mqtt.py homeassistant/components/alarm_control_panel/nx584.py - homeassistant/components/alarm_control_panel/simplisafe.py homeassistant/components/alarm_control_panel/totalconnect.py homeassistant/components/alarm_control_panel/yale_smart_alarm.py homeassistant/components/apiai.py diff --git a/CODEOWNERS b/CODEOWNERS index ed8d8531a6a..9343407f06f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,7 +49,6 @@ homeassistant/components/hassio/* @home-assistant/hassio # Individual platforms homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell -homeassistant/components/alarm_control_panel/simplisafe.py @bachya homeassistant/components/binary_sensor/hikvision.py @mezz64 homeassistant/components/binary_sensor/threshold.py @fabaff homeassistant/components/camera/yi.py @bachya @@ -192,7 +191,7 @@ homeassistant/components/melissa.py @kennedyshead homeassistant/components/*/melissa.py @kennedyshead homeassistant/components/*/mystrom.py @fabaff -# U +# O homeassistant/components/openuv/* @bachya homeassistant/components/*/openuv.py @bachya @@ -206,6 +205,10 @@ homeassistant/components/*/rainmachine.py @bachya homeassistant/components/*/random.py @fabaff homeassistant/components/*/rfxtrx.py @danielhiversen +# S +homeassistant/components/simplisafe/* @bachya +homeassistant/components/*/simplisafe.py @bachya + # T homeassistant/components/tahoma.py @philklei homeassistant/components/*/tahoma.py @philklei diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py index 34c68f26c2a..cdcdf07c982 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -1,5 +1,5 @@ """ -Interfaces with SimpliSafe alarm control panel. +This platform provides alarm control functionality for SimpliSafe. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.simplisafe/ @@ -7,86 +7,44 @@ https://home-assistant.io/components/alarm_control_panel.simplisafe/ import logging import re -import voluptuous as vol - -from homeassistant.components.alarm_control_panel import ( - PLATFORM_SCHEMA, AlarmControlPanel) +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.simplisafe.const import ( + DATA_CLIENT, DOMAIN, TOPIC_UPDATE) from homeassistant.const import ( - CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) -from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.util.json import load_json, save_json - -REQUIREMENTS = ['simplisafe-python==3.1.2'] + CONF_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) -ATTR_ALARM_ACTIVE = "alarm_active" -ATTR_TEMPERATURE = "temperature" - -DATA_FILE = '.simplisafe' - -DEFAULT_NAME = 'SimpliSafe' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_CODE): cv.string, -}) +ATTR_ALARM_ACTIVE = 'alarm_active' +ATTR_TEMPERATURE = 'temperature' async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Set up the SimpliSafe platform.""" - from simplipy import API - from simplipy.errors import SimplipyError + """Set up a SimpliSafe alarm control panel based on existing config.""" + pass - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - name = config.get(CONF_NAME) - code = config.get(CONF_CODE) - websession = aiohttp_client.async_get_clientsession(hass) - - config_data = await hass.async_add_executor_job( - load_json, hass.config.path(DATA_FILE)) - - try: - if config_data: - try: - simplisafe = await API.login_via_token( - config_data['refresh_token'], websession) - _LOGGER.debug('Logging in with refresh token') - except SimplipyError: - _LOGGER.info('Refresh token expired; attempting credentials') - simplisafe = await API.login_via_credentials( - username, password, websession) - else: - simplisafe = await API.login_via_credentials( - username, password, websession) - _LOGGER.debug('Logging in with credentials') - except SimplipyError as err: - _LOGGER.error("There was an error during setup: %s", err) - return - - config_data = {'refresh_token': simplisafe.refresh_token} - await hass.async_add_executor_job( - save_json, hass.config.path(DATA_FILE), config_data) - - systems = await simplisafe.get_systems() - async_add_entities( - [SimpliSafeAlarm(system, name, code) for system in systems], True) +async def async_setup_entry(hass, entry, async_add_entities): + """Set up a SimpliSafe alarm control panel based on a config entry.""" + systems = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + async_add_entities([ + SimpliSafeAlarm(system, entry.data.get(CONF_CODE)) + for system in systems + ], True) class SimpliSafeAlarm(AlarmControlPanel): """Representation of a SimpliSafe alarm.""" - def __init__(self, system, name, code): + def __init__(self, system, code): """Initialize the SimpliSafe alarm.""" + self._async_unsub_dispatcher_connect = None self._attrs = {} - self._code = str(code) if code else None - self._name = name + self._code = code self._system = system self._state = None @@ -98,9 +56,7 @@ class SimpliSafeAlarm(AlarmControlPanel): @property def name(self): """Return the name of the device.""" - if self._name: - return self._name - return 'Alarm {}'.format(self._system.system_id) + return self._system.address @property def code_format(self): @@ -128,6 +84,21 @@ class SimpliSafeAlarm(AlarmControlPanel): _LOGGER.warning("Wrong code entered for %s", state) return check + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + async def async_alarm_disarm(self, code=None): """Send disarm command.""" if not self._validate_code(code, 'disarming'): @@ -151,22 +122,24 @@ class SimpliSafeAlarm(AlarmControlPanel): async def async_update(self): """Update alarm status.""" - await self._system.update() + from simplipy.system import SystemStates - if self._system.state == self._system.SystemStates.off: - self._state = STATE_ALARM_DISARMED - elif self._system.state in ( - self._system.SystemStates.home, - self._system.SystemStates.home_count): - self._state = STATE_ALARM_ARMED_HOME - elif self._system.state in ( - self._system.SystemStates.away, - self._system.SystemStates.away_count, - self._system.SystemStates.exit_delay): - self._state = STATE_ALARM_ARMED_AWAY - else: - self._state = None + await self._system.update() self._attrs[ATTR_ALARM_ACTIVE] = self._system.alarm_going_off if self._system.temperature: self._attrs[ATTR_TEMPERATURE] = self._system.temperature + + if self._system.state == SystemStates.error: + return + + if self._system.state == SystemStates.off: + self._state = STATE_ALARM_DISARMED + elif self._system.state in (SystemStates.home, + SystemStates.home_count): + self._state = STATE_ALARM_ARMED_HOME + elif self._system.state in (SystemStates.away, SystemStates.away_count, + SystemStates.exit_delay): + self._state = STATE_ALARM_ARMED_AWAY + else: + self._state = None diff --git a/homeassistant/components/simplisafe/.translations/en.json b/homeassistant/components/simplisafe/.translations/en.json new file mode 100644 index 00000000000..b000335af8f --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Account already registered", + "invalid_credentials": "Invalid credentials" + }, + "step": { + "user": { + "data": { + "code": "Code (for Home Assistant)", + "password": "Password", + "username": "Email Address" + }, + "title": "Fill in your information" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py new file mode 100644 index 00000000000..91ffb3dbde4 --- /dev/null +++ b/homeassistant/components/simplisafe/__init__.py @@ -0,0 +1,143 @@ +""" +Support for SimpliSafe alarm systems. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/simplisafe/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_CODE, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME) +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from homeassistant.helpers import config_validation as cv + +from .config_flow import configured_instances +from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_UPDATE + +REQUIREMENTS = ['simplisafe-python==3.1.7'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ACCOUNTS = 'accounts' + +DATA_LISTENER = 'listener' + +ACCOUNT_CONFIG_SCHEMA = vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CODE): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_ACCOUNTS): + vol.All(cv.ensure_list, [ACCOUNT_CONFIG_SCHEMA]), + }), +}, extra=vol.ALLOW_EXTRA) + + +@callback +def _async_save_refresh_token(hass, config_entry, token): + hass.config_entries.async_update_entry( + config_entry, data={ + **config_entry.data, CONF_TOKEN: token + }) + + +async def async_setup(hass, config): + """Set up the SimpliSafe component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + hass.data[DOMAIN][DATA_LISTENER] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + for account in conf[CONF_ACCOUNTS]: + if account[CONF_USERNAME] in configured_instances(hass): + continue + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data={ + CONF_USERNAME: account[CONF_USERNAME], + CONF_PASSWORD: account[CONF_PASSWORD], + CONF_CODE: account.get(CONF_CODE), + CONF_SCAN_INTERVAL: account[CONF_SCAN_INTERVAL], + })) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up SimpliSafe as config entry.""" + from simplipy import API + from simplipy.errors import SimplipyError + + websession = aiohttp_client.async_get_clientsession(hass) + + try: + simplisafe = await API.login_via_token( + config_entry.data[CONF_TOKEN], websession) + except SimplipyError as err: + if 403 in str(err): + _LOGGER.error('Invalid credentials provided') + return False + + _LOGGER.error('Config entry failed: %s', err) + raise ConfigEntryNotReady + + _async_save_refresh_token(hass, config_entry, simplisafe.refresh_token) + + systems = await simplisafe.get_systems() + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = systems + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + config_entry, 'alarm_control_panel')) + + async def refresh(event_time): + """Refresh data from the SimpliSafe account.""" + for system in systems: + _LOGGER.debug('Updating system data: %s', system.system_id) + await system.update() + async_dispatcher_send(hass, TOPIC_UPDATE.format(system.system_id)) + + if system.api.refresh_token_dirty: + _async_save_refresh_token( + hass, config_entry, system.api.refresh_token) + + hass.data[DOMAIN][DATA_LISTENER][ + config_entry.entry_id] = async_track_time_interval( + hass, + refresh, + timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a SimpliSafe config entry.""" + await hass.config_entries.async_forward_entry_unload( + entry, 'alarm_control_panel') + + hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id) + remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(entry.entry_id) + remove_listener() + + return True diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py new file mode 100644 index 00000000000..0a59dcb3e1d --- /dev/null +++ b/homeassistant/components/simplisafe/config_flow.py @@ -0,0 +1,80 @@ +"""Config flow to configure the SimpliSafe component.""" + +from collections import OrderedDict + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.const import ( + CONF_CODE, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME) +from homeassistant.helpers import aiohttp_client + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured SimpliSafe instances.""" + return set( + entry.data[CONF_USERNAME] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class SimpliSafeFlowHandler(config_entries.ConfigFlow): + """Handle a SimpliSafe config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the config flow.""" + self.data_schema = OrderedDict() + self.data_schema[vol.Required(CONF_USERNAME)] = str + self.data_schema[vol.Required(CONF_PASSWORD)] = str + self.data_schema[vol.Optional(CONF_CODE)] = str + + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + from simplipy import API + from simplipy.errors import SimplipyError + + if not user_input: + return await self._show_form() + + if user_input[CONF_USERNAME] in configured_instances(self.hass): + return await self._show_form({CONF_USERNAME: 'identifier_exists'}) + + username = user_input[CONF_USERNAME] + websession = aiohttp_client.async_get_clientsession(self.hass) + + try: + simplisafe = await API.login_via_credentials( + username, user_input[CONF_PASSWORD], websession) + except SimplipyError: + return await self._show_form({'base': 'invalid_credentials'}) + + scan_interval = user_input.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_USERNAME: username, + CONF_TOKEN: simplisafe.refresh_token, + CONF_SCAN_INTERVAL: scan_interval.seconds, + }, + ) diff --git a/homeassistant/components/simplisafe/const.py b/homeassistant/components/simplisafe/const.py new file mode 100644 index 00000000000..437197878e0 --- /dev/null +++ b/homeassistant/components/simplisafe/const.py @@ -0,0 +1,10 @@ +"""Define constants for the SimpliSafe component.""" +from datetime import timedelta + +DOMAIN = 'simplisafe' + +DATA_CLIENT = 'client' + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +TOPIC_UPDATE = 'update_{0}' diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json new file mode 100644 index 00000000000..5df0cf400d4 --- /dev/null +++ b/homeassistant/components/simplisafe/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "SimpliSafe", + "step": { + "user": { + "title": "Fill in your information", + "data": { + "username": "Email Address", + "password": "Password", + "code": "Code (for Home Assistant)" + } + } + }, + "error": { + "identifier_exists": "Account already registered", + "invalid_credentials": "Invalid credentials" + } + } +} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e4719b3ed78..a4f28b63fb1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -146,6 +146,7 @@ FLOWS = [ 'mqtt', 'nest', 'openuv', + 'simplisafe', 'smhi', 'sonos', 'tradfri', diff --git a/requirements_all.txt b/requirements_all.txt index ba7d05fc7e3..0542a0ddf80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1346,8 +1346,8 @@ shodan==1.10.4 # homeassistant.components.notify.simplepush simplepush==1.1.4 -# homeassistant.components.alarm_control_panel.simplisafe -simplisafe-python==3.1.2 +# homeassistant.components.simplisafe +simplisafe-python==3.1.7 # homeassistant.components.sisyphus sisyphus-control==2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db67e27cc61..e449a9ae98a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -214,6 +214,9 @@ ring_doorbell==0.2.1 # homeassistant.components.media_player.yamaha rxv==0.5.1 +# homeassistant.components.simplisafe +simplisafe-python==3.1.7 + # homeassistant.components.sleepiq sleepyq==0.6 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fd161898acc..fd8d673f633 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -97,6 +97,7 @@ TEST_REQUIREMENTS = ( 'rflink', 'ring_doorbell', 'rxv', + 'simplisafe-python', 'sleepyq', 'smhi-pkg', 'somecomfort', diff --git a/tests/components/simplisafe/__init__.py b/tests/components/simplisafe/__init__.py new file mode 100644 index 00000000000..b1cc391eec9 --- /dev/null +++ b/tests/components/simplisafe/__init__.py @@ -0,0 +1 @@ +"""Define tests for the SimpliSafe component.""" diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py new file mode 100644 index 00000000000..63b932ee681 --- /dev/null +++ b/tests/components/simplisafe/test_config_flow.py @@ -0,0 +1,120 @@ +"""Define tests for the SimpliSafe config flow.""" +import json +from datetime import timedelta +from unittest.mock import mock_open, patch, MagicMock, PropertyMock + +from homeassistant import data_entry_flow +from homeassistant.components.simplisafe import DOMAIN, config_flow +from homeassistant.const import ( + CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME) + +from tests.common import MockConfigEntry, mock_coro + + +def mock_api(): + """Mock SimpliSafe API class.""" + api = MagicMock() + type(api).refresh_token = PropertyMock(return_value='12345abc') + return api + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_USERNAME: 'user@email.com', + CONF_PASSWORD: 'password', + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.SimpliSafeFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_USERNAME: 'identifier_exists'} + + +async def test_invalid_credentials(hass): + """Test that invalid credentials throws an error.""" + from simplipy.errors import SimplipyError + conf = { + CONF_USERNAME: 'user@email.com', + CONF_PASSWORD: 'password', + } + + flow = config_flow.SimpliSafeFlowHandler() + flow.hass = hass + + with patch('simplipy.API.login_via_credentials', + return_value=mock_coro(exception=SimplipyError)): + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {'base': 'invalid_credentials'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.SimpliSafeFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import(hass): + """Test that the import step works.""" + conf = { + CONF_USERNAME: 'user@email.com', + CONF_PASSWORD: 'password', + } + + flow = config_flow.SimpliSafeFlowHandler() + flow.hass = hass + + mop = mock_open(read_data=json.dumps({'refresh_token': '12345'})) + + with patch('simplipy.API.login_via_credentials', + return_value=mock_coro(return_value=mock_api())): + with patch('homeassistant.util.json.open', mop, create=True): + with patch('homeassistant.util.json.os.open', return_value=0): + with patch('homeassistant.util.json.os.replace'): + result = await flow.async_step_import(import_config=conf) + + assert result[ + 'type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == 'user@email.com' + assert result['data'] == { + CONF_USERNAME: 'user@email.com', + CONF_TOKEN: '12345abc', + CONF_SCAN_INTERVAL: 30, + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = { + CONF_USERNAME: 'user@email.com', + CONF_PASSWORD: 'password', + CONF_SCAN_INTERVAL: timedelta(seconds=90), + } + + flow = config_flow.SimpliSafeFlowHandler() + flow.hass = hass + + mop = mock_open(read_data=json.dumps({'refresh_token': '12345'})) + + with patch('simplipy.API.login_via_credentials', + return_value=mock_coro(return_value=mock_api())): + with patch('homeassistant.util.json.open', mop, create=True): + with patch('homeassistant.util.json.os.open', return_value=0): + with patch('homeassistant.util.json.os.replace'): + result = await flow.async_step_user(user_input=conf) + + assert result[ + 'type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == 'user@email.com' + assert result['data'] == { + CONF_USERNAME: 'user@email.com', + CONF_TOKEN: '12345abc', + CONF_SCAN_INTERVAL: 90, + }