Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 41 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,14 @@ import asyncio

from aiohttp import ClientSession

from simplipy import get_systems
from simplipy import API


async def main() -> None:
"""Create the aiohttp session and run."""
async with ClientSession() as websession:
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
systems = await simplisafe.get_systems()
# >>> [<simplipy.system.SystemV2 object at 0x10661e3c8>, ...]


Expand All @@ -97,19 +98,20 @@ these objects, meaning the same properties and methods are available to both.
### Properties and Methods

```python
from simplipy import get_systems
from simplipy import API


async def main() -> None:
"""Create the aiohttp session and run."""
async with ClientSession() as websession:
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
systems = await simplisafe.get_systems()
# >>> [<simplipy.system.SystemV2 object at 0x10661e3c8>]

for system in systems:
# Return a reference to a SimpliSafe™ account object (detailed later):
system.account
# >>> <simplipy.account.SimpliSafe™ object at 0x12aba2321>
# Return a reference to a SimpliSafe™ API object (detailed later):
system.api
# >>> <simplipy.api.API object at 0x12aba2321>

# Return whether the alarm is currently going off:
system.alarm_going_off
Expand Down Expand Up @@ -191,13 +193,14 @@ differences are outlined below.
### Base Properties

```python
from simplipy import get_systems
from simplipy import API


async def main() -> None:
"""Create the aiohttp session and run."""
async with ClientSession() as websession:
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
systems = await simplisafe.get_systems()
for system in systems:
for serial, sensor_attrs in system.sensors.items():
# Return the sensor's name:
Expand Down Expand Up @@ -234,13 +237,14 @@ asyncio.get_event_loop().run_until_complete(main())
### V2 Properties

```python
from simplipy import get_systems
from simplipy import API


async def main() -> None:
"""Create the aiohttp session and run."""
async with ClientSession() as websession:
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
systems = await simplisafe.get_systems()
for system in systems:
for serial, sensor_attrs in system.sensors.items():
# Return the sensor's data as a currently non-understood integer:
Expand All @@ -258,13 +262,14 @@ asyncio.get_event_loop().run_until_complete(main())
### V3 Properties

```python
from simplipy import get_systems
from simplipy import API


async def main() -> None:
"""Create the aiohttp session and run."""
async with ClientSession() as websession:
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
systems = await simplisafe.get_systems()
for system in systems:
for sensor in system.sensors:
# Return whether the sensor is offline:
Expand All @@ -283,37 +288,37 @@ async def main() -> None:
asyncio.get_event_loop().run_until_complete(main())
```

## The `Account` Object
## The `API` Object

Each `System` object has a reference to an `Account` object. This object
contains properties and a method useful for authentication and ongoing
access.
Each `System` object has a reference to an `API` object. This object contains
properties and a method useful for authentication and ongoing access.

**VERY IMPORTANT NOTE:** the `Account` object contains references to
**VERY IMPORTANT NOTE:** the `API` object contains references to
SimpliSafe™ access and refresh tokens. **It is vitally important that you do
not let these tokens leave your control.** If exposed, savvy attackers could
use them to view and alter your system's state. **You have been warned; proper
usage of these properties is solely your responsibility.**

```python
from simplipy import get_systems
from simplipy import API


async def main() -> None:
"""Create the aiohttp session and run."""
async with ClientSession() as websession:
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
systems = await simplisafe.get_systems()
for system in systems:
# Return the current access token:
system.account.access_token
system.api._access_token
# >>> 7s9yasdh9aeu21211add

# Return the current refresh token:
system.account.refresh_token
system.api.refresh_token
# >>> 896sad86gudas87d6asd

# Return the SimpliSafe™ user ID associated with this account:
system.account.user_id
system.api.user_id
# >>> 1234567


Expand All @@ -328,40 +333,34 @@ asyncio.get_event_loop().run_until_complete(main())
errors inherit from
* `simplipy.errors.RequestError`: an error related to HTTP requests that return
something other than a `200` response code
* `simplipy.errors.TokenExpiredError`: an error related to an expired access
token

# Refreshing the Access Token

When `simplipy.get_systems()` is run, everything is set to make repeated
authorized requests against the SimpliSafe™ cloud. At some point, however, the
access token will expire and any future requests will raise
`simplipy.errors.TokenExpiredError`.

When this occurs, a new access token can easily be generated:
It may be desirable to re-authenticate to the SimpliSafe™ API without using
a user's email and password again. In that case, it is recommended that you
save the `refresh_token` property somewhere; when it comes time to
re-authenticate, simply:

```python
await system.account.refresh_access_token()
```
from simplipy import API

This will use the "on-file" refresh token to request a new access token; once
the call is complete, you're good to go.

In some instances, it may be desirable to store the "on-file" refresh token for
later use (for example, if your app/script/etc. stops and needs to restart at
some indeterminate point in the future). In that case, the
`refresh_access_token()` method can take an optional `refresh_token` parameter:
async def main() -> None:
"""Create the aiohttp session and run."""
async with ClientSession() as websession:
simplisafe = API.login_via_token("<REFRESH TOKEN>", websession)
systems = await simplisafe.get_systems()


```python
await system.account.refresh_access_token(refresh_token='abcdefg987665')
asyncio.get_event_loop().run_until_complete(main())
```

Although no official documentation exists, basic testing appears to confirm the
hypothesis that the refresh token is both long-lived and single-use. This means
that theoretically, it should be possible to use it to create an access token
long into the future. If `refresh_access_token()` should throw an error,
however, the system object(s) will need to be recreated via
`simplipy.get_systems`.
`simplipy.API.login_via_credentials`.

# Contributing

Expand Down
2 changes: 1 addition & 1 deletion simplipy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""Define module-level imports."""
from .account import get_systems # noqa
from .api import API # noqa
104 changes: 58 additions & 46 deletions simplipy/account.py → simplipy/api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Define a SimpliSafe account."""
# pylint: disable=import-error, unused-import
# pylint: disable=import-error,protected-access,unused-import

from datetime import datetime, timedelta
from typing import List, Union # noqa
from typing import List, Type, TypeVar, Union # noqa

from aiohttp import BasicAuth, ClientSession, client_exceptions

from .errors import RequestError, TokenExpiredError
from .errors import RequestError
from .system import System, SystemV2, SystemV3 # noqa

DEFAULT_USER_AGENT = 'SimpliSafe/2105 CFNetwork/902.2 Darwin/17.7.0'
Expand All @@ -17,28 +18,47 @@

SYSTEM_MAP = {2: SystemV2, 3: SystemV3}


# pylint: disable=protected-access
async def get_systems(
email: str, password: str, websession: ClientSession) -> list:
"""Return a list of systems."""
account = SimpliSafe(websession)
await account._login(email, password)
return await account._get_subscriptions()
ApiType = TypeVar('ApiType', bound='API')


class SimpliSafe:
"""Define an "account" client."""
class API:
"""Define an API object to interact with the SimpliSafe cloud."""

def __init__(self, websession: ClientSession) -> None:
"""Initialize."""
self._access_token = None
self._access_token_expire = None # type: Union[None, datetime]
self._actively_refreshing = False
self._email = None # type: Union[None, str]
self._websession = websession
self.access_token = None
self.refresh_token = None
self.user_id = None

@classmethod
async def login_via_credentials(
cls: Type[ApiType], email: str, password: str,
websession: ClientSession) -> ApiType:
"""Create an API object from a email address and password."""
klass = cls(websession)
klass._email = email

await klass._authenticate({
'grant_type': 'password',
'username': email,
'password': password,
})

return klass

@classmethod
async def login_via_token(
cls: Type[ApiType], refresh_token: str,
websession: ClientSession) -> ApiType:
"""Create an API object from a refresh token."""
klass = cls(websession)
await klass._refresh_access_token(refresh_token)
return klass

async def _authenticate(self, payload_data: dict) -> None:
"""Request token data and parse it."""
token_resp = await self.request(
Expand All @@ -47,13 +67,26 @@ async def _authenticate(self, payload_data: dict) -> None:
data=payload_data,
auth=BasicAuth(
login=DEFAULT_AUTH_USERNAME, password='', encoding='latin1'))
self.access_token = token_resp['access_token']
self.refresh_token = token_resp['refresh_token']

auth_check_resp = await self.request('get', 'api/authCheck')
self.user_id = auth_check_resp['userId']
self._access_token = token_resp['access_token']
self._access_token_expire = datetime.now() + timedelta(
seconds=int(token_resp['expires_in']))
self.refresh_token = token_resp['refresh_token']

async def _get_subscriptions(self) -> list:
"""Get subscriptions associated to this account."""
async def _refresh_access_token(self, refresh_token: str) -> None:
"""Regenerate an access token."""
await self._authenticate({
'grant_type': 'refresh_token',
'username': self._email,
'refresh_token': refresh_token,
})

self._actively_refreshing = False

async def get_systems(self) -> list:
"""Get systems associated to this account."""
subscription_resp = await self.get_subscription_data()

systems = [] # type: List[System]
Expand All @@ -66,37 +99,13 @@ async def _get_subscriptions(self) -> list:

return systems

async def _login(self, email: str, password: str) -> None:
"""Login to SimpliSafe."""
self._email = email

await self._authenticate({
'grant_type': 'password',
'username': email,
'password': password,
})

auth_check_resp = await self.request('get', 'api/authCheck')
self.user_id = auth_check_resp['userId']

async def get_subscription_data(self) -> dict:
"""Get the latest location-level data."""
return await self.request(
'get',
'users/{0}/subscriptions'.format(self.user_id),
params={'activeOnly': 'true'})

async def refresh_access_token(self, refresh_token: str = None) -> None:
"""Regenerate an access token using the stored refresh token."""
await self._authenticate({
'grant_type':
'refresh_token',
'username':
self._email,
'refresh_token':
refresh_token if refresh_token else self.refresh_token,
})

async def request(
self,
method: str,
Expand All @@ -109,15 +118,18 @@ async def request(
**kwargs) -> dict:
"""Make a request."""
if (self._access_token_expire
and datetime.now() >= self._access_token_expire):
raise TokenExpiredError('The access token has expired')
and datetime.now() >= self._access_token_expire
and not self._actively_refreshing):
self._actively_refreshing = True
await self._refresh_access_token( # type: ignore
self.refresh_token)

url = '{0}/{1}'.format(URL_BASE, endpoint)

if not headers:
headers = {}
if not kwargs.get('auth') and self.access_token:
headers['Authorization'] = 'Bearer {0}'.format(self.access_token)
if not kwargs.get('auth') and self._access_token:
headers['Authorization'] = 'Bearer {0}'.format(self._access_token)
headers.update({
'Content-Type': 'application/x-www-form-urlencoded',
'Host': URL_HOSTNAME,
Expand Down
6 changes: 0 additions & 6 deletions simplipy/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,3 @@ class RequestError(SimplipyError):
"""Define an error related to invalid requests."""

pass


class TokenExpiredError(SimplipyError):
"""Define an error for expired access tokens."""

pass
Loading