Add config entry for SimpliSafe (#17148)

* Initial move into component

* Base functionality in place

* Starting tests in place

* All config entry tests in place

* Made default scan interval more obvious and removed extra logging

* Inherit default scan interval from alarm_control_panel

* Updated coveragerc and CODEOWNERS

* Member-requested changes

* Hound

* Updated requirements

* Updated tests

* Member-requested changes

* Owner-requested changes

* Constant cleanup

* Fixed config flow test

* Owner-requested updates

* Updated requirements

* Using async_will_remove_from_hass

* Corrected scan interval logic

* Fixed tests

* Owner-requested changes

* Additional logging

* Owner-requested changes
This commit is contained in:
Aaron Bach 2018-10-12 11:07:47 -06:00 committed by Paulus Schoutsen
parent 1f863830e1
commit 401e22fc0c
14 changed files with 461 additions and 86 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"
}
}

View file

@ -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

View file

@ -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,
},
)

View file

@ -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}'

View file

@ -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"
}
}
}

View file

@ -146,6 +146,7 @@ FLOWS = [
'mqtt',
'nest',
'openuv',
'simplisafe',
'smhi',
'sonos',
'tradfri',

View file

@ -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

View file

@ -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

View file

@ -97,6 +97,7 @@ TEST_REQUIREMENTS = (
'rflink',
'ring_doorbell',
'rxv',
'simplisafe-python',
'sleepyq',
'smhi-pkg',
'somecomfort',

View file

@ -0,0 +1 @@
"""Define tests for the SimpliSafe component."""

View file

@ -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,
}