├── .coveragerc ├── .gitignore ├── .pytest_cache └── v │ └── cache │ └── .gitignore ├── .travis.yml ├── CHANGES.rst ├── LICENSE ├── README.rst ├── pylintrc ├── requirements.txt ├── requirements_test.txt ├── setup.py ├── skybell.http ├── skybellpy ├── __init__.py ├── __main__.py ├── device.py ├── exceptions.py ├── helpers │ ├── __init__.py │ ├── constants.py │ └── errors.py └── utils.py ├── tests ├── __init__.py ├── mock │ ├── __init__.py │ ├── device.py │ ├── device_activities.py │ ├── device_avatar.py │ ├── device_info.py │ ├── device_settings.py │ └── login.py ├── test_device.py └── test_skybell.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | skybellpy/__main__.py 4 | skybellpy/helpers/* 5 | tests/* 6 | setup.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | .pytest_cache/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | /.pypirc 92 | 93 | # LiClipse Project 94 | /.project 95 | /.pydevproject 96 | .settings/ 97 | *.pickle 98 | 99 | # pycharm IDE settings 100 | .idea/ 101 | 102 | # vscode 103 | .vscode/ -------------------------------------------------------------------------------- /.pytest_cache/v/cache/.gitignore: -------------------------------------------------------------------------------- 1 | /lastfailed 2 | /nodeids 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | fast_finish: true 3 | include: 4 | - python: "3.5" 5 | env: TOXENV=py35 6 | - python: "3.6" 7 | env: TOXENV=py36 8 | - python: "3.7" 9 | env: TOXENV=py37 10 | 11 | install: pip install -U tox coveralls 12 | language: python 13 | script: tox 14 | after_success: coveralls 15 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ----------- 3 | 4 | A list of changes between each release. 5 | 6 | 0.3.0 (2018-12-14) 7 | ^^^^^^^^^^^^^^^^^^ 8 | - Switch device image back to avatar url (fixed) 9 | - Preserve last activity image functionality via `device.activity_image` 10 | 11 | 0.2.0 (2018-12-07) 12 | ^^^^^^^^^^^^^^^^^^ 13 | - Switch device image from avatar url (broken) to last activity image. 14 | 15 | 0.1.0 (2017-09-24) 16 | ^^^^^^^^^^^^^^^^^^ 17 | - Initial release of skybellpy -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Mister Wil 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | skybell-python |Build Status| |Coverage Status| 2 | ================================================= 3 | A thin Python library for the Skybell HD API. 4 | Only compatible with Python 3+ 5 | 6 | Disclaimer: 7 | ~~~~~~~~~~~~~~~ 8 | Published under the MIT license - See LICENSE file for more details. 9 | 10 | "Skybell" is a trademark owned by SkyBell Technologies, Inc, see www.skybell.com for more information. 11 | I am in no way affiliated with Skybell. 12 | 13 | Thank you Skybell for having a relatively simple API to reverse engineer. Hopefully in the future you'll 14 | open it up for official use. 15 | 16 | API calls faster than 60 seconds is not recommended as it can overwhelm Skybell's servers. 17 | 18 | Please use this module responsibly. 19 | 20 | Installation 21 | ============ 22 | From PyPi: 23 | 24 | pip3 install skybellpy 25 | 26 | Command Line Usage 27 | ================== 28 | Simple command line implementation arguments:: 29 | 30 | $ skybellpy --help 31 | usage: SkybellPy: Command Line Utility [-h] -u USERNAME -p PASSWORD [--mode] 32 | [--devices] [--device device_id] 33 | 34 | optional arguments: 35 | -h, --help show this help message and exit 36 | -u USERNAME, --username USERNAME 37 | Username 38 | -p PASSWORD, --password PASSWORD 39 | Password 40 | --devices Output all devices 41 | --device device_id Output one device for device_id 42 | 43 | You can get all device information:: 44 | 45 | $ skybellpy -u USERNAME -p PASSWORD --devices 46 | 47 | Output here 48 | 49 | Development and Testing 50 | ======================= 51 | 52 | Install the core dependencies:: 53 | 54 | $ sudo apt-get install python3-pip python3-dev python3-venv 55 | 56 | Checkout from github and then create a virtual environment:: 57 | 58 | $ git clone https://github.com/MisterWil/skybellpy.git 59 | $ cd skybellpy 60 | $ python3 -m venv venv 61 | 62 | Activate the virtual environment:: 63 | 64 | $ source venv/bin/activate 65 | 66 | Install requirements:: 67 | 68 | $ pip install -r requirements.txt -r requirements_test.txt 69 | 70 | Install skybellpy locally in "editable mode":: 71 | 72 | $ pip3 install -e . 73 | 74 | Run the run the full test suite with tox before commit:: 75 | 76 | $ tox 77 | 78 | Alternatively you can run just the tests:: 79 | 80 | $ tox -e py35 81 | 82 | Library Usage 83 | ============= 84 | TODO 85 | 86 | Class Descriptions 87 | ================== 88 | TODO 89 | 90 | .. |Build Status| image:: https://travis-ci.org/MisterWil/skybellpy.svg?branch=master 91 | :target: https://travis-ci.org/MisterWil/skybellpy 92 | .. |Coverage Status| image:: https://coveralls.io/repos/github/MisterWil/skybellpy/badge.svg 93 | :target: https://coveralls.io/github/MisterWil/skybellpy 94 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | reports=no 3 | good-names=m 4 | notes=FIXME,XXX 5 | 6 | # Reasons disabled: 7 | # locally-disabled - it spams too much 8 | # duplicate-code - it's annoying 9 | # unused-argument - generic callbacks and setup methods create a lot of warnings 10 | # too-many-* - are not enforced for the sake of readability 11 | # too-few-* - same as too-many-* 12 | 13 | disable= 14 | locally-disabled, 15 | unused-argument, 16 | duplicate-code, 17 | too-many-arguments, 18 | too-many-branches, 19 | too-many-instance-attributes, 20 | too-many-locals, 21 | too-many-public-methods, 22 | too-many-return-statements, 23 | too-many-statements, 24 | too-many-lines, 25 | too-few-public-methods, 26 | 27 | ignored-modules=distutils,distutils.util -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.12.4 2 | colorlog>=3.0.1 3 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | wheel==0.29.0 2 | tox==3.14.0 3 | flake8>=3.6.0 4 | flake8-docstrings==1.1.0 5 | pylint==2.4.4 6 | pydocstyle==2.0.0 7 | pytest>=2.9.2 8 | pytest-cov>=2.3.1 9 | pytest-sugar>=0.8.0 10 | pytest-timeout>=1.0.0 11 | restructuredtext-lint>=1.0.1 12 | pygments>=2.2.0 13 | requests_mock>=1.3.0 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """skybellpy setup script.""" 3 | from setuptools import setup, find_packages 4 | from skybellpy.helpers.constants import (__version__, PROJECT_PACKAGE_NAME, 5 | PROJECT_LICENSE, PROJECT_URL, 6 | PROJECT_EMAIL, PROJECT_DESCRIPTION, 7 | PROJECT_CLASSIFIERS, PROJECT_AUTHOR, 8 | PROJECT_LONG_DESCRIPTION) 9 | 10 | PACKAGES = find_packages(exclude=['tests', 'tests.*']) 11 | 12 | setup( 13 | name=PROJECT_PACKAGE_NAME, 14 | version=__version__, 15 | description=PROJECT_DESCRIPTION, 16 | long_description=PROJECT_LONG_DESCRIPTION, 17 | author=PROJECT_AUTHOR, 18 | author_email=PROJECT_EMAIL, 19 | license=PROJECT_LICENSE, 20 | url=PROJECT_URL, 21 | platforms='any', 22 | py_modules=['skybellpy'], 23 | packages=PACKAGES, 24 | include_package_data=True, 25 | install_requires=[ 26 | 'requests>=2,<3', 27 | 'colorlog>=3.0.1' 28 | ], 29 | test_suite='tests', 30 | entry_points={ 31 | 'console_scripts': [ 32 | 'skybellpy = skybellpy.__main__:main' 33 | ] 34 | }, 35 | classifiers=PROJECT_CLASSIFIERS 36 | ) 37 | -------------------------------------------------------------------------------- /skybell.http: -------------------------------------------------------------------------------- 1 | @app_id = appid 2 | @client_id = clientid 3 | @token = randomtoken 4 | ### 5 | 6 | // Login to the Skybell cloud API. Returns an access token for follow-up requests 7 | 8 | POST https://cloud.myskybell.com/api/v3/login HTTP/1.1 9 | content-type: application/json 10 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 11 | x-skybell-app-id: {{app_id}} 12 | x-skybell-client-id: {{client_id}} 13 | 14 | { 15 | "username": "username", 16 | "password": "password", 17 | "appId": "{{app_id}}", 18 | "token": "{{token}}" 19 | } 20 | 21 | @access_token = Bearer tokenhere 22 | ### 23 | 24 | // Register an app ? 25 | 26 | POST https://cloud.myskybell.com/api/v3/register HTTP/1.1 27 | content-type: application/json 28 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 29 | x-skybell-app-id: {{app_id}} 30 | x-skybell-client-id: {{client_id}} 31 | authorization: {{access_token}} 32 | 33 | { 34 | "appId": "{{app_id}}", 35 | "protocol": "socketio", 36 | "token": "{{token}}" 37 | } 38 | 39 | ### 40 | 41 | // Logout (need to register an appId to logout correctly) 42 | 43 | POST https://cloud.myskybell.com/api/v3/logout 44 | content-type: application/json 45 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 46 | x-skybell-app-id: {{app_id}} 47 | x-skybell-client-id: {{client_id}} 48 | authorization: {{access_token}} 49 | 50 | {"appId": "{{app_id}}"} 51 | 52 | ### 53 | 54 | // Get "me" user information (first name, last name, user ID) 55 | 56 | GET https://cloud.myskybell.com/api/v3/users/me HTTP/1.1 57 | content-type: application/json 58 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 59 | x-skybell-app-id: {{app_id}} 60 | x-skybell-client-id: {{client_id}} 61 | authorization: {{access_token}} 62 | 63 | 64 | ### 65 | @user = userid 66 | 67 | // Get "user" info by user id (does not work) 68 | 69 | GET https://cloud.myskybell.com/api/v3/users/{{user}} HTTP/1.1 70 | content-type: application/json 71 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 72 | x-skybell-app-id: {{app_id}} 73 | x-skybell-client-id: {{client_id}} 74 | authorization: {{access_token}} 75 | 76 | ### 77 | 78 | // Get all device "subscriptions"? 79 | // Returns an array of subscriptions with the ower ID, owner info, device info, and an ID of the device 80 | 81 | GET https://cloud.myskybell.com/api/v3/subscriptions?include=owner HTTP/1.1 82 | content-type: application/json 83 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 84 | x-skybell-app-id: {{app_id}} 85 | x-skybell-client-id: {{client_id}} 86 | authorization: {{access_token}} 87 | 88 | 89 | ### 90 | @subscription_id = subscriptionid 91 | 92 | // Get detailed device info such as wifi info, hardware info, last checkin, etc 93 | 94 | GET https://cloud.myskybell.com/api/v3/subscriptions/{{subscription_id}}/info HTTP/1.1 95 | content-type: application/json 96 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 97 | x-skybell-app-id: {{app_id}} 98 | x-skybell-client-id: {{client_id}} 99 | authorization: {{access_token}} 100 | 101 | ### 102 | 103 | // Get a list of all device settings 104 | 105 | // do_not_disturb = true, false, Indoor Chime On/Off 106 | // chime_level = 0, 1, 2, 3 = Outdoor Chime Off, Low, Medium, High 107 | // motion_policy = disabled, call = Enable/Disabled 108 | // motion_threshold = 32, 50, 100 = Motion Senitivity, High, Medium, Low 109 | // video_profile = 0, 1, 2, 3 = Image Quality, 1080p, 720p (Better), 720p (Good), 480p 110 | 111 | GET https://cloud.myskybell.com/api/v3/subscriptions/{{subscription_id}}/settings HTTP/1.1 112 | content-type: application/json 113 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 114 | x-skybell-app-id: {{app_id}} 115 | x-skybell-client-id: {{client_id}} 116 | authorization: {{access_token}} 117 | 118 | ### 119 | 120 | // Update a setting value 121 | 122 | PATCH https://cloud.myskybell.com/api/v3/subscriptions/{{subscription_id}}/settings HTTP/1.1 123 | content-type: application/json 124 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 125 | x-skybell-app-id: {{app_id}} 126 | x-skybell-client-id: {{client_id}} 127 | authorization: {{access_token}} 128 | 129 | ### 130 | 131 | // Get a list of "app installs" for a specific user. 132 | // I believe this is a list of callback endpoints. 133 | // "apns" = apple push notification service 134 | // "endpointArn" = endpoint amazon resource name 135 | 136 | GET https://cloud.myskybell.com/api/v3/users/{{user}}/app_installs HTTP/1.1 137 | content-type: application/json 138 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 139 | x-skybell-app-id: {{app_id}} 140 | x-skybell-client-id: {{client_id}} 141 | authorization: {{access_token}} 142 | 143 | ### 144 | 145 | @example_app_id = appid 146 | 147 | // Get a list of all settings for callbacks 148 | 149 | GET https://cloud.myskybell.com/api/v3/users/{{user}}/app_installs/{{example_app_id}}/subscriptions/{{subscription_id}}/settings HTTP/1.1 150 | content-type: application/json 151 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 152 | x-skybell-app-id: {{app_id}} 153 | x-skybell-client-id: {{client_id}} 154 | authorization: {{access_token}} 155 | 156 | ### 157 | 158 | // Get a list of device activities 159 | 160 | GET https://cloud.myskybell.com/api/v3/subscriptions/{{subscription_id}}/activities/ HTTP/1.1 161 | content-type: application/json 162 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 163 | x-skybell-app-id: {{app_id}} 164 | x-skybell-client-id: {{client_id}} 165 | authorization: {{access_token}} 166 | 167 | ### 168 | 169 | // Get the device "avatar" aka static image with a last taken date 170 | 171 | GET https://cloud.myskybell.com/api/v3/subscriptions/{{subscription_id}}/avatar HTTP/1.1 172 | content-type: application/json 173 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 174 | x-skybell-app-id: {{app_id}} 175 | x-skybell-client-id: {{client_id}} 176 | authorization: {{access_token}} 177 | 178 | ### 179 | 180 | // Get the avatar image 181 | 182 | GET http://aws.... 183 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 184 | Accept: image/jpeg, image/png, image/gif, image/tiff 185 | 186 | ### 187 | 188 | // Get a list of all devices 189 | 190 | GET https://cloud.myskybell.com/api/v3/devices HTTP/1.1 191 | content-type: application/json 192 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 193 | x-skybell-app-id: {{app_id}} 194 | x-skybell-client-id: {{client_id}} 195 | authorization: {{access_token}} 196 | 197 | ### 198 | 199 | // Get info for a single device 200 | 201 | @device_id = deviceid 202 | 203 | GET https://cloud.myskybell.com/api/v3/devices/{{device_id}} HTTP/1.1 204 | content-type: application/json 205 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 206 | x-skybell-app-id: {{app_id}} 207 | x-skybell-client-id: {{client_id}} 208 | authorization: {{access_token}} 209 | 210 | ### 211 | 212 | // Get avatar url for a single device 213 | 214 | GET https://cloud.myskybell.com/api/v3/devices/{{device_id}}/avatar HTTP/1.1 215 | content-type: application/json 216 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 217 | x-skybell-app-id: {{app_id}} 218 | x-skybell-client-id: {{client_id}} 219 | authorization: {{access_token}} 220 | 221 | ### 222 | 223 | // Get activities for a single device 224 | 225 | GET https://cloud.myskybell.com/api/v3/devices/{{device_id}}/activities HTTP/1.1 226 | content-type: application/json 227 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 228 | x-skybell-app-id: {{app_id}} 229 | x-skybell-client-id: {{client_id}} 230 | authorization: {{access_token}} 231 | 232 | ### 233 | 234 | // Start and stop "on demand video" (POST and DELETE) 235 | 236 | DELETE https://cloud.myskybell.com/api/v3/devices/{{device_id}}/calls HTTP/1.1 237 | content-type: application/json 238 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 239 | x-skybell-app-id: {{app_id}} 240 | x-skybell-client-id: {{client_id}} 241 | authorization: {{access_token}} 242 | 243 | ### 244 | 245 | GET https://cloud.myskybell.com/api/v3/subscriptions/{{subscription_id}}/photo HTTP/1.1 246 | content-type: application/json 247 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 248 | x-skybell-app-id: {{app_id}} 249 | x-skybell-client-id: {{client_id}} 250 | authorization: {{access_token}} 251 | 252 | ### 253 | 254 | GET https://cloud.myskybell.com/api/v3/devices/{{device_id}}/photo HTTP/1.1 255 | content-type: application/json 256 | user-agent: SkyBell/3.4.1 (iPhone9,2; iOS 11.0; loc=en_US; lang=en-US) com.skybell.doorbell/1 257 | x-skybell-app-id: {{app_id}} 258 | x-skybell-client-id: {{client_id}} 259 | authorization: {{access_token}} -------------------------------------------------------------------------------- /skybellpy/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | skybellpy by Wil Schrader - An Abode alarm Python library. 5 | 6 | https://github.com/MisterWil/skybellpy 7 | 8 | Influenced by blinkpy, because I'm a python noob: 9 | https://github.com/fronzbot/blinkpy/ 10 | 11 | Published under the MIT license - See LICENSE file for more details. 12 | 13 | "Skybell" is a trademark owned by SkyBell Technologies, Inc, see 14 | www.skybell.com for more information. I am in no way affiliated with Skybell. 15 | """ 16 | import os.path 17 | import json 18 | import logging 19 | import time 20 | import requests 21 | from requests.exceptions import RequestException 22 | 23 | from skybellpy.device import SkybellDevice 24 | from skybellpy.exceptions import ( 25 | SkybellAuthenticationException, SkybellException) 26 | import skybellpy.helpers.constants as CONST 27 | import skybellpy.helpers.errors as ERROR 28 | import skybellpy.utils as UTILS 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | class Skybell(): 34 | """Main Skybell class.""" 35 | 36 | def __init__(self, username=None, password=None, 37 | auto_login=False, get_devices=False, 38 | cache_path=CONST.CACHE_PATH, disable_cache=False, 39 | agent_identifier=CONST.DEFAULT_AGENT_IDENTIFIER, 40 | login_sleep=True): 41 | """Init Abode object.""" 42 | self._username = username 43 | self._password = password 44 | self._session = None 45 | self._cache_path = cache_path 46 | self._disable_cache = disable_cache 47 | self._devices = None 48 | self._session = requests.session() 49 | self._user_agent = '{} ({})'.format(CONST.USER_AGENT, agent_identifier) 50 | self._login_sleep = login_sleep 51 | 52 | # Create a new cache template 53 | self._cache = { 54 | CONST.APP_ID: UTILS.gen_id(), 55 | CONST.CLIENT_ID: UTILS.gen_id(), 56 | CONST.TOKEN: UTILS.gen_token(), 57 | CONST.ACCESS_TOKEN: None, 58 | CONST.DEVICES: {} 59 | } 60 | 61 | # Load and merge an existing cache 62 | if not disable_cache: 63 | self._load_cache() 64 | 65 | if (self._username is not None and 66 | self._password is not None and 67 | auto_login): 68 | self.login() 69 | 70 | if get_devices: 71 | self.get_devices() 72 | 73 | def login(self, username=None, password=None, sleep=False): 74 | """Execute Skybell login.""" 75 | if username is not None: 76 | self._username = username 77 | if password is not None: 78 | self._password = password 79 | 80 | if self._username is None or not isinstance(self._username, str): 81 | raise SkybellAuthenticationException(ERROR.USERNAME) 82 | 83 | if self._password is None or not isinstance(self._password, str): 84 | raise SkybellAuthenticationException(ERROR.PASSWORD) 85 | 86 | self.update_cache( 87 | { 88 | CONST.ACCESS_TOKEN: None 89 | }) 90 | 91 | self._session = requests.session() 92 | 93 | login_data = { 94 | 'username': self._username, 95 | 'password': self._password, 96 | 'appId': self.cache(CONST.APP_ID), 97 | CONST.TOKEN: self.cache(CONST.TOKEN) 98 | } 99 | 100 | try: 101 | response = self.send_request('post', CONST.LOGIN_URL, 102 | json_data=login_data, retry=False) 103 | except Exception as exc: 104 | raise SkybellAuthenticationException(ERROR.LOGIN_FAILED, exc) 105 | 106 | _LOGGER.debug("Login Response: %s", response.text) 107 | 108 | response_object = json.loads(response.text) 109 | 110 | self.update_cache({ 111 | CONST.ACCESS_TOKEN: response_object[CONST.ACCESS_TOKEN]}) 112 | 113 | if self._login_sleep: 114 | _LOGGER.info("Login successful, waiting 5 seconds...") 115 | time.sleep(5) 116 | else: 117 | _LOGGER.info("Login successful") 118 | 119 | return True 120 | 121 | def logout(self): 122 | """Explicit Skybell logout.""" 123 | if self.cache(CONST.ACCESS_TOKEN): 124 | # No explicit logout call as it doesn't seem to matter 125 | # if a logout happens without registering the app which 126 | # we aren't currently doing. 127 | self._session = requests.session() 128 | self._devices = None 129 | 130 | self.update_cache({CONST.ACCESS_TOKEN: None}) 131 | 132 | return True 133 | 134 | def get_devices(self, refresh=False): 135 | """Get all devices from Abode.""" 136 | if refresh or self._devices is None: 137 | if self._devices is None: 138 | self._devices = {} 139 | 140 | _LOGGER.info("Updating all devices...") 141 | response = self.send_request("get", CONST.DEVICES_URL) 142 | response_object = json.loads(response.text) 143 | 144 | _LOGGER.debug("Get Devices Response: %s", response.text) 145 | 146 | for device_json in response_object: 147 | # Attempt to reuse an existing device 148 | device = self._devices.get(device_json['id']) 149 | 150 | # No existing device, create a new one 151 | if device: 152 | device.update(device_json) 153 | else: 154 | device = SkybellDevice(device_json, self) 155 | self._devices[device.device_id] = device 156 | 157 | return list(self._devices.values()) 158 | 159 | def get_device(self, device_id, refresh=False): 160 | """Get a single device.""" 161 | if self._devices is None: 162 | self.get_devices() 163 | refresh = False 164 | 165 | device = self._devices.get(device_id) 166 | 167 | if device and refresh: 168 | device.refresh() 169 | 170 | return device 171 | 172 | def send_request(self, method, url, headers=None, 173 | json_data=None, retry=True): 174 | """Send requests to Skybell.""" 175 | if not self.cache(CONST.ACCESS_TOKEN) and url != CONST.LOGIN_URL: 176 | self.login() 177 | 178 | if not headers: 179 | headers = {} 180 | 181 | if self.cache(CONST.ACCESS_TOKEN): 182 | headers['Authorization'] = 'Bearer ' + \ 183 | self.cache(CONST.ACCESS_TOKEN) 184 | 185 | headers['user-agent'] = self._user_agent 186 | headers['content-type'] = 'application/json' 187 | headers['accepts'] = '*/*' 188 | headers['x-skybell-app-id'] = self.cache(CONST.APP_ID) 189 | headers['x-skybell-client-id'] = self.cache(CONST.CLIENT_ID) 190 | 191 | _LOGGER.debug("HTTP %s %s Request with headers: %s", 192 | method, url, headers) 193 | 194 | try: 195 | response = getattr(self._session, method)( 196 | url, headers=headers, json=json_data) 197 | _LOGGER.debug("%s %s", response, response.text) 198 | 199 | if response and response.status_code < 400: 200 | return response 201 | except RequestException as exc: 202 | _LOGGER.warning("Skybell request exception: %s", exc) 203 | 204 | if retry: 205 | self.login() 206 | 207 | return self.send_request(method, url, headers, json_data, False) 208 | 209 | raise SkybellException(ERROR.REQUEST, "Retry failed") 210 | 211 | def cache(self, key): 212 | """Get a cached value.""" 213 | return self._cache.get(key) 214 | 215 | def update_cache(self, data): 216 | """Update a cached value.""" 217 | UTILS.update(self._cache, data) 218 | self._save_cache() 219 | 220 | def dev_cache(self, device, key=None): 221 | """Get a cached value for a device.""" 222 | device_cache = self._cache.get(CONST.DEVICES, {}).get(device.device_id) 223 | 224 | if device_cache and key: 225 | return device_cache.get(key) 226 | 227 | return device_cache 228 | 229 | def update_dev_cache(self, device, data): 230 | """Update cached values for a device.""" 231 | self.update_cache( 232 | { 233 | CONST.DEVICES: { 234 | device.device_id: data 235 | } 236 | }) 237 | 238 | def _load_cache(self): 239 | """Load existing cache and merge for updating if required.""" 240 | if not self._disable_cache: 241 | if os.path.exists(self._cache_path): 242 | _LOGGER.debug("Cache found at: %s", self._cache_path) 243 | if os.path.getsize(self._cache_path) > 0: 244 | loaded_cache = UTILS.load_cache(self._cache_path) 245 | UTILS.update(self._cache, loaded_cache) 246 | else: 247 | _LOGGER.debug("Cache file is empty. Removing it.") 248 | os.remove(self._cache_path) 249 | 250 | self._save_cache() 251 | 252 | def _save_cache(self): 253 | """Trigger a cache save.""" 254 | if not self._disable_cache: 255 | UTILS.save_cache(self._cache, self._cache_path) 256 | -------------------------------------------------------------------------------- /skybellpy/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | """ 3 | skybellcl by Wil Schrader - A Skybell Python library command line interface. 4 | 5 | https://github.com/MisterWil/skybellpy 6 | 7 | Published under the MIT license - See LICENSE file for more details. 8 | 9 | "Skybell" is a trademark owned by SkyBell Technologies, Inc, see 10 | www.skybell.com for more information. I am in no way affiliated with Skybell. 11 | """ 12 | import json 13 | import logging 14 | 15 | import argparse 16 | 17 | from colorlog import ColoredFormatter 18 | 19 | import skybellpy 20 | import skybellpy.helpers.constants as CONST 21 | from skybellpy.exceptions import SkybellException 22 | 23 | _LOGGER = logging.getLogger('skybellcl') 24 | 25 | 26 | def setup_logging(log_level=logging.INFO): 27 | """Set up the logging.""" 28 | logging.basicConfig(level=log_level) 29 | fmt = ("%(asctime)s %(levelname)s (%(threadName)s) " 30 | "[%(name)s] %(message)s") 31 | colorfmt = "%(log_color)s{}%(reset)s".format(fmt) 32 | datefmt = '%Y-%m-%d %H:%M:%S' 33 | 34 | # Suppress overly verbose logs from libraries that aren't helpful 35 | logging.getLogger('requests').setLevel(logging.WARNING) 36 | logging.getLogger('urllib3').setLevel(logging.WARNING) 37 | logging.getLogger('aiohttp.access').setLevel(logging.WARNING) 38 | 39 | try: 40 | logging.getLogger().handlers[0].setFormatter(ColoredFormatter( 41 | colorfmt, 42 | datefmt=datefmt, 43 | reset=True, 44 | log_colors={ 45 | 'DEBUG': 'cyan', 46 | 'INFO': 'green', 47 | 'WARNING': 'yellow', 48 | 'ERROR': 'red', 49 | 'CRITICAL': 'red', 50 | } 51 | )) 52 | except ImportError: 53 | pass 54 | 55 | logger = logging.getLogger('') 56 | logger.setLevel(log_level) 57 | 58 | 59 | def get_arguments(): 60 | """Get parsed arguments.""" 61 | parser = argparse.ArgumentParser("SkybellPy: Command Line Utility") 62 | 63 | parser.add_argument( 64 | '-u', '--username', 65 | help='Username', 66 | required=True) 67 | 68 | parser.add_argument( 69 | '-p', '--password', 70 | help='Password', 71 | required=True) 72 | 73 | parser.add_argument( 74 | '--set', 75 | metavar='setting=value', 76 | help='Set setting to a value', 77 | required=False, action='append') 78 | 79 | parser.add_argument( 80 | '--devices', 81 | help='Output all devices', 82 | required=False, default=False, action="store_true") 83 | 84 | parser.add_argument( 85 | '--device', 86 | metavar='device_id', 87 | help='Output one device for device_id', 88 | required=False, action='append') 89 | 90 | parser.add_argument( 91 | '--json', 92 | metavar='device_id', 93 | help='Output the json for device_id', 94 | required=False, action='append') 95 | 96 | parser.add_argument( 97 | '--activity-json', 98 | metavar='device_id', 99 | help='Output the activity activity json for device_id', 100 | required=False, action='append') 101 | 102 | parser.add_argument( 103 | '--avatar-image', 104 | metavar='device_id', 105 | help='Output the avatar image url for device_id', 106 | required=False, action='append') 107 | 108 | parser.add_argument( 109 | '--activity-image', 110 | metavar='device_id', 111 | help='Output the last activity image url for device_id', 112 | required=False, action='append') 113 | 114 | parser.add_argument( 115 | '--capture', 116 | metavar='device_id', 117 | help='NOT IMPLEMENTED: ' 118 | 'Trigger a new image capture for the given device_id', 119 | required=False, action='append') 120 | 121 | parser.add_argument( 122 | '--image', 123 | metavar='device_id=location/image.jpg', 124 | help='NOT IMPLEMENTED: ' 125 | 'Save an image from a camera (if available) to the given path', 126 | required=False, action='append') 127 | 128 | parser.add_argument( 129 | '--debug', 130 | help='Enable debug logging', 131 | required=False, default=False, action="store_true") 132 | 133 | parser.add_argument( 134 | '--quiet', 135 | help='Output only warnings and errors', 136 | required=False, default=False, action="store_true") 137 | 138 | return parser.parse_args() 139 | 140 | 141 | def call(): 142 | """Execute command line helper.""" 143 | args = get_arguments() 144 | 145 | # Set up logging 146 | if args.debug: 147 | log_level = logging.DEBUG 148 | elif args.quiet: 149 | log_level = logging.WARN 150 | else: 151 | log_level = logging.INFO 152 | 153 | setup_logging(log_level) 154 | 155 | skybell = None 156 | 157 | try: 158 | # Create skybellpy instance. 159 | skybell = skybellpy.Skybell(username=args.username, 160 | password=args.password, 161 | get_devices=True, 162 | agent_identifier='skybellcl') 163 | 164 | # # Set setting 165 | # for setting in args.set or []: 166 | # keyval = setting.split("=") 167 | # if skybell.set_setting(keyval[0], keyval[1]): 168 | # _LOGGER.info("Setting %s changed to %s", keyval[0], keyval[1]) 169 | 170 | # Output Json 171 | for device_id in args.json or []: 172 | device = skybell.get_device(device_id) 173 | 174 | if device: 175 | # pylint: disable=protected-access 176 | _LOGGER.info(device_id + " JSON:\n" + 177 | json.dumps(device._device_json, sort_keys=True, 178 | indent=4, separators=(',', ': '))) 179 | else: 180 | _LOGGER.warning("Could not find device with id: %s", device_id) 181 | 182 | # Print 183 | def _device_print(dev, append=''): 184 | _LOGGER.info("%s%s", 185 | dev.desc, append) 186 | 187 | # Print out all devices. 188 | if args.devices: 189 | for device in skybell.get_devices(): 190 | _device_print(device) 191 | 192 | # Print out specific devices by device id. 193 | if args.device: 194 | for device_id in args.device: 195 | device = skybell.get_device(device_id) 196 | 197 | if device: 198 | _device_print(device) 199 | else: 200 | _LOGGER.warning( 201 | "Could not find device with id: %s", device_id) 202 | 203 | # Print out last motion event 204 | if args.activity_json: 205 | for device_id in args.activity_json: 206 | device = skybell.get_device(device_id) 207 | 208 | if device: 209 | _LOGGER.info(device.latest(CONST.EVENT_MOTION)) 210 | else: 211 | _LOGGER.warning( 212 | "Could not find device with id: %s", device_id) 213 | 214 | # Print out avatar image 215 | if args.avatar_image: 216 | for device_id in args.avatar_image: 217 | device = skybell.get_device(device_id) 218 | 219 | if device: 220 | _LOGGER.info(device.image) 221 | else: 222 | _LOGGER.warning( 223 | "Could not find device with id: %s", device_id) 224 | 225 | # Print out last motion event image 226 | if args.activity_image: 227 | for device_id in args.activity_image: 228 | device = skybell.get_device(device_id) 229 | 230 | if device: 231 | _LOGGER.info(device.activity_image) 232 | else: 233 | _LOGGER.warning( 234 | "Could not find device with id: %s", device_id) 235 | 236 | except SkybellException as exc: 237 | _LOGGER.error(exc) 238 | # finally: 239 | # if skybell: 240 | # skybell.logout() 241 | 242 | 243 | def main(): 244 | """Execute from command line.""" 245 | call() 246 | 247 | 248 | if __name__ == '__main__': 249 | main() 250 | -------------------------------------------------------------------------------- /skybellpy/device.py: -------------------------------------------------------------------------------- 1 | """The device class used by SkybellPy.""" 2 | import json 3 | import logging 4 | 5 | from distutils.util import strtobool 6 | 7 | from skybellpy.exceptions import SkybellException 8 | import skybellpy.helpers.constants as CONST 9 | import skybellpy.helpers.errors as ERROR 10 | import skybellpy.utils as UTILS 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class SkybellDevice(): 16 | """Class to represent each Skybell device.""" 17 | 18 | def __init__(self, device_json, skybell): 19 | """Set up Skybell device.""" 20 | self._device_json = device_json 21 | self._device_id = device_json.get(CONST.ID) 22 | self._type = device_json.get(CONST.TYPE) 23 | self._skybell = skybell 24 | 25 | self._avatar_json = self._avatar_request() 26 | self._info_json = self._info_request() 27 | self._settings_json = self._settings_request() 28 | 29 | self._update_activities() 30 | 31 | def refresh(self): 32 | """Refresh the devices json object data.""" 33 | # Update core device data 34 | new_device_json = self._device_request() 35 | _LOGGER.debug("Device Refresh Response: %s", new_device_json) 36 | 37 | # Update avatar url 38 | new_avatar_json = self._avatar_request() 39 | _LOGGER.debug("Avatar Refresh Response: %s", new_avatar_json) 40 | 41 | # Update device detail info 42 | new_info_json = self._info_request() 43 | _LOGGER.debug("Device Info Refresh Response: %s", new_info_json) 44 | 45 | # Update device setting details 46 | new_settings_json = self._settings_request() 47 | _LOGGER.debug("Device Settings Refresh Response: %s", 48 | new_settings_json) 49 | 50 | # Update the stored data 51 | self.update(new_device_json, new_info_json, new_settings_json, 52 | new_avatar_json) 53 | 54 | # Update the activities 55 | self._update_activities() 56 | 57 | def _device_request(self): 58 | url = str.replace(CONST.DEVICE_URL, '$DEVID$', self.device_id) 59 | response = self._skybell.send_request(method="get", url=url) 60 | return json.loads(response.text) 61 | 62 | def _avatar_request(self): 63 | url = str.replace(CONST.DEVICE_AVATAR_URL, '$DEVID$', self.device_id) 64 | response = self._skybell.send_request(method="get", url=url) 65 | return json.loads(response.text) 66 | 67 | def _info_request(self): 68 | url = str.replace(CONST.DEVICE_INFO_URL, '$DEVID$', self.device_id) 69 | response = self._skybell.send_request(method="get", url=url) 70 | return json.loads(response.text) 71 | 72 | def _settings_request(self, method="get", json_data=None): 73 | url = str.replace(CONST.DEVICE_SETTINGS_URL, '$DEVID$', self.device_id) 74 | response = self._skybell.send_request(method=method, 75 | url=url, 76 | json_data=json_data) 77 | return json.loads(response.text) 78 | 79 | def _activities_request(self): 80 | url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 81 | '$DEVID$', self.device_id) 82 | response = self._skybell.send_request(method="get", url=url) 83 | return json.loads(response.text) 84 | 85 | def update(self, device_json=None, info_json=None, settings_json=None, 86 | avatar_json=None): 87 | """Update the internal device json data.""" 88 | if device_json: 89 | UTILS.update(self._device_json, device_json) 90 | 91 | if avatar_json: 92 | UTILS.update(self._avatar_json, avatar_json) 93 | 94 | if info_json: 95 | UTILS.update(self._info_json, info_json) 96 | 97 | if settings_json: 98 | UTILS.update(self._settings_json, settings_json) 99 | 100 | def _update_activities(self): 101 | """Update stored activities and update caches as required.""" 102 | self._activities = self._activities_request() 103 | _LOGGER.debug("Device Activities Response: %s", self._activities) 104 | 105 | if not self._activities: 106 | self._activities = [] 107 | elif not isinstance(self._activities, (list, tuple)): 108 | self._activities = [self._activities] 109 | 110 | self._update_events() 111 | 112 | def _update_events(self): 113 | """Update our cached list of latest activity events.""" 114 | events = self._skybell.dev_cache(self, CONST.EVENT) or {} 115 | 116 | for activity in self._activities: 117 | event = activity.get(CONST.EVENT) 118 | created_at = activity.get(CONST.CREATED_AT) 119 | 120 | old_event = events.get(event) 121 | 122 | if old_event and created_at < old_event.get(CONST.CREATED_AT): 123 | continue 124 | 125 | events[event] = activity 126 | 127 | self._skybell.update_dev_cache( 128 | self, 129 | { 130 | CONST.EVENT: events 131 | }) 132 | 133 | def activities(self, limit=1, event=None): 134 | """Return device activity information.""" 135 | activities = self._activities or [] 136 | 137 | # Filter our activity array if requested 138 | if event: 139 | activities = list( 140 | filter( 141 | lambda activity: 142 | activity[CONST.EVENT] == event, activities)) 143 | 144 | # Return the requested number 145 | return activities[:limit] 146 | 147 | def latest(self, event=None): 148 | """Return the latest event activity.""" 149 | events = self._skybell.dev_cache(self, CONST.EVENT) or {} 150 | _LOGGER.debug(events) 151 | 152 | if event: 153 | return events.get(event) 154 | 155 | latest = None 156 | for _, evt in events.items(): 157 | if not latest or \ 158 | latest.get(CONST.CREATED_AT) < evt.get(CONST.CREATED_AT): 159 | latest = evt 160 | return latest 161 | 162 | def _set_setting(self, settings): 163 | """Validate the settings and then send the PATCH request.""" 164 | for key, value in settings.items(): 165 | _validate_setting(key, value) 166 | 167 | try: 168 | self._settings_request(method="patch", json_data=settings) 169 | 170 | self.update(settings_json=settings) 171 | except SkybellException as exc: 172 | _LOGGER.warning("Exception changing settings: %s", settings) 173 | _LOGGER.warning(exc) 174 | 175 | @property 176 | def name(self): 177 | """Get the name of this device.""" 178 | return self._device_json.get(CONST.NAME) 179 | 180 | @property 181 | def type(self): 182 | """Get the type of this device.""" 183 | return self._type 184 | 185 | @property 186 | def device_id(self): 187 | """Get the device id.""" 188 | return self._device_id 189 | 190 | @property 191 | def status(self): 192 | """Get the generic status of a device (up/down).""" 193 | return self._device_json.get(CONST.STATUS) 194 | 195 | @property 196 | def is_up(self): 197 | """Shortcut to get if the device status is up.""" 198 | return self.status == CONST.STATUS_UP 199 | 200 | @property 201 | def location(self): 202 | """Return lat and lng tuple.""" 203 | location = self._device_json.get(CONST.LOCATION, {}) 204 | 205 | return (location.get(CONST.LOCATION_LAT, 0), 206 | location.get(CONST.LOCATION_LNG, 0)) 207 | 208 | @property 209 | def image(self): 210 | """Get the most recent 'avatar' image.""" 211 | return self._avatar_json.get(CONST.AVATAR_URL) 212 | 213 | @property 214 | def activity_image(self): 215 | """Get the most recent activity image.""" 216 | return self.latest().get(CONST.MEDIA_URL) 217 | 218 | @property 219 | def wifi_status(self): 220 | """Get the wifi status.""" 221 | return self._info_json.get(CONST.STATUS, {}).get(CONST.WIFI_LINK) 222 | 223 | @property 224 | def wifi_ssid(self): 225 | """Get the wifi ssid.""" 226 | return self._info_json.get(CONST.WIFI_SSID) 227 | 228 | @property 229 | def last_check_in(self): 230 | """Get last check in timestamp.""" 231 | return self._info_json.get(CONST.CHECK_IN) 232 | 233 | @property 234 | def do_not_disturb(self): 235 | """Get if do not disturb is enabled.""" 236 | return bool(strtobool(str(self._settings_json.get( 237 | CONST.SETTINGS_DO_NOT_DISTURB)))) 238 | 239 | @do_not_disturb.setter 240 | def do_not_disturb(self, enabled): 241 | """Set do not disturb.""" 242 | self._set_setting( 243 | { 244 | CONST.SETTINGS_DO_NOT_DISTURB: str(enabled).lower() 245 | }) 246 | 247 | @property 248 | def outdoor_chime_level(self): 249 | """Get devices outdoor chime level.""" 250 | return self._settings_json.get(CONST.SETTINGS_OUTDOOR_CHIME) 251 | 252 | @outdoor_chime_level.setter 253 | def outdoor_chime_level(self, level): 254 | """Set outdoor chime level.""" 255 | self._set_setting({CONST.SETTINGS_OUTDOOR_CHIME: level}) 256 | 257 | @property 258 | def outdoor_chime(self): 259 | """Get if the devices outdoor chime is enabled.""" 260 | return self.outdoor_chime_level is not CONST.SETTINGS_OUTDOOR_CHIME_OFF 261 | 262 | @property 263 | def motion_sensor(self): 264 | """Get if the devices motion sensor is enabled.""" 265 | return ( 266 | self._settings_json.get(CONST.SETTINGS_MOTION_POLICY) == 267 | CONST.SETTINGS_MOTION_POLICY_ON) 268 | 269 | @motion_sensor.setter 270 | def motion_sensor(self, enabled): 271 | """Set the motion sensor state.""" 272 | if enabled is True: 273 | value = CONST.SETTINGS_MOTION_POLICY_ON 274 | elif enabled is False: 275 | value = CONST.SETTINGS_MOTION_POLICY_OFF 276 | else: 277 | raise SkybellException(ERROR.INVALID_SETTING_VALUE, 278 | (CONST.SETTINGS_MOTION_POLICY, enabled)) 279 | 280 | self._set_setting({CONST.SETTINGS_MOTION_POLICY: value}) 281 | 282 | @property 283 | def motion_threshold(self): 284 | """Get devices motion threshold.""" 285 | return self._settings_json.get(CONST.SETTINGS_MOTION_THRESHOLD) 286 | 287 | @motion_threshold.setter 288 | def motion_threshold(self, threshold): 289 | """Set motion threshold.""" 290 | self._set_setting({CONST.SETTINGS_MOTION_THRESHOLD: threshold}) 291 | 292 | @property 293 | def video_profile(self): 294 | """Get devices video profile.""" 295 | return self._settings_json.get(CONST.SETTINGS_VIDEO_PROFILE) 296 | 297 | @video_profile.setter 298 | def video_profile(self, profile): 299 | """Set video profile.""" 300 | self._set_setting({CONST.SETTINGS_VIDEO_PROFILE: profile}) 301 | 302 | @property 303 | def led_rgb(self): 304 | """Get devices LED color.""" 305 | return (int(self._settings_json.get(CONST.SETTINGS_LED_R)), 306 | int(self._settings_json.get(CONST.SETTINGS_LED_G)), 307 | int(self._settings_json.get(CONST.SETTINGS_LED_B))) 308 | 309 | @led_rgb.setter 310 | def led_rgb(self, color): 311 | """Set devices LED color.""" 312 | if (not isinstance(color, (list, tuple)) or 313 | not all(isinstance(item, int) for item in color)): 314 | raise SkybellException(ERROR.COLOR_VALUE_NOT_VALID, color) 315 | 316 | self._set_setting( 317 | { 318 | CONST.SETTINGS_LED_R: color[0], 319 | CONST.SETTINGS_LED_G: color[1], 320 | CONST.SETTINGS_LED_B: color[2] 321 | }) 322 | 323 | @property 324 | def led_intensity(self): 325 | """Get devices LED intensity.""" 326 | return int(self._settings_json.get(CONST.SETTINGS_LED_INTENSITY)) 327 | 328 | @led_intensity.setter 329 | def led_intensity(self, intensity): 330 | """Set devices LED intensity.""" 331 | self._set_setting({CONST.SETTINGS_LED_INTENSITY: intensity}) 332 | 333 | @property 334 | def desc(self): 335 | """Get a short description of the device.""" 336 | # Front Door (id: ) - skybell hd - status: up - wifi status: good 337 | return '{0} (id: {1}) - {2} - status: {3} - wifi status: {4}'.format( 338 | self.name, self.device_id, self.type, 339 | self.status, self.wifi_status) 340 | 341 | 342 | def _validate_setting(setting, value): 343 | """Validate the setting and value.""" 344 | if setting not in CONST.ALL_SETTINGS: 345 | raise SkybellException(ERROR.INVALID_SETTING, setting) 346 | 347 | if setting == CONST.SETTINGS_DO_NOT_DISTURB: 348 | if value not in CONST.SETTINGS_DO_NOT_DISTURB_VALUES: 349 | raise SkybellException(ERROR.INVALID_SETTING_VALUE, 350 | (setting, value)) 351 | 352 | if setting == CONST.SETTINGS_OUTDOOR_CHIME: 353 | if value not in CONST.SETTINGS_OUTDOOR_CHIME_VALUES: 354 | raise SkybellException(ERROR.INVALID_SETTING_VALUE, 355 | (setting, value)) 356 | 357 | if setting == CONST.SETTINGS_MOTION_POLICY: 358 | if value not in CONST.SETTINGS_MOTION_POLICY_VALUES: 359 | raise SkybellException(ERROR.INVALID_SETTING_VALUE, 360 | (setting, value)) 361 | 362 | if setting == CONST.SETTINGS_MOTION_THRESHOLD: 363 | if value not in CONST.SETTINGS_MOTION_THRESHOLD_VALUES: 364 | raise SkybellException(ERROR.INVALID_SETTING_VALUE, 365 | (setting, value)) 366 | 367 | if setting == CONST.SETTINGS_VIDEO_PROFILE: 368 | if value not in CONST.SETTINGS_VIDEO_PROFILE_VALUES: 369 | raise SkybellException(ERROR.INVALID_SETTING_VALUE, 370 | (setting, value)) 371 | 372 | if setting in CONST.SETTINGS_LED_COLOR: 373 | if (value < CONST.SETTINGS_LED_VALUES[0] or 374 | value > CONST.SETTINGS_LED_VALUES[1]): 375 | raise SkybellException(ERROR.INVALID_SETTING_VALUE, 376 | (setting, value)) 377 | 378 | if setting == CONST.SETTINGS_LED_INTENSITY: 379 | if not isinstance(value, int): 380 | raise SkybellException(ERROR.COLOR_INTENSITY_NOT_VALID, value) 381 | 382 | if (value < CONST.SETTINGS_LED_INTENSITY_VALUES[0] or 383 | value > CONST.SETTINGS_LED_INTENSITY_VALUES[1]): 384 | raise SkybellException(ERROR.INVALID_SETTING_VALUE, 385 | (setting, value)) 386 | -------------------------------------------------------------------------------- /skybellpy/exceptions.py: -------------------------------------------------------------------------------- 1 | """The exceptions used by SkybellPy.""" 2 | 3 | 4 | class SkybellException(Exception): 5 | """Class to throw general skybell exception.""" 6 | 7 | def __init__(self, error, details=None): 8 | """Initialize SkybellException.""" 9 | # Call the base class constructor with the parameters it needs 10 | super(SkybellException, self).__init__( 11 | '{}: {}'.format(error[1], details)) 12 | 13 | self.errcode = error[0] 14 | self.message = error[1] 15 | self.details = details 16 | 17 | 18 | class SkybellAuthenticationException(SkybellException): 19 | """Class to throw authentication exception.""" 20 | -------------------------------------------------------------------------------- /skybellpy/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for helpers directory.""" 2 | -------------------------------------------------------------------------------- /skybellpy/helpers/constants.py: -------------------------------------------------------------------------------- 1 | """skybellpy constants.""" 2 | import os 3 | 4 | MAJOR_VERSION = 0 5 | MINOR_VERSION = 6 6 | PATCH_VERSION = '3' 7 | 8 | __version__ = '{}.{}.{}'.format(MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION) 9 | 10 | REQUIRED_PYTHON_VER = (3, 5, 0) 11 | 12 | PROJECT_NAME = 'skybellpy' 13 | PROJECT_PACKAGE_NAME = 'skybellpy' 14 | PROJECT_LICENSE = 'MIT' 15 | PROJECT_AUTHOR = 'Wil Schrader' 16 | PROJECT_COPYRIGHT = ' 2017, {}'.format(PROJECT_AUTHOR) 17 | PROJECT_URL = 'https://github.com/MisterWil/skybellpy' 18 | PROJECT_EMAIL = 'wilrader@gmail.com' 19 | PROJECT_DESCRIPTION = ('An Skybell HD Python library ' 20 | 'running on Python 3.') 21 | PROJECT_LONG_DESCRIPTION = ('skybellpy is an open-source ' 22 | 'unofficial API for the Skybell HD ' 23 | 'doorbell with the intention for easy ' 24 | 'integration into various home ' 25 | 'automation platforms.') 26 | if os.path.exists('README.rst'): 27 | PROJECT_LONG_DESCRIPTION = open('README.rst').read() 28 | PROJECT_CLASSIFIERS = [ 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python :: 3.5', 33 | 'Topic :: Home Automation' 34 | ] 35 | 36 | PROJECT_GITHUB_USERNAME = 'MisterWil' 37 | PROJECT_GITHUB_REPOSITORY = 'skybellpy' 38 | 39 | PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME) 40 | 41 | CACHE_PATH = './skybell.pickle' 42 | 43 | USER_AGENT = 'skybellpy/{}.{}.{}'.format(MAJOR_VERSION, 44 | MINOR_VERSION, 45 | PATCH_VERSION) 46 | 47 | DEFAULT_AGENT_IDENTIFIER = 'default' 48 | 49 | # URLS 50 | BASE_URL = 'https://cloud.myskybell.com/api/v3/' 51 | BASE_URL_V4 = 'https://cloud.myskybell.com/api/v4/' 52 | 53 | LOGIN_URL = BASE_URL + 'login/' 54 | LOGOUT_URL = BASE_URL + 'logout/' 55 | 56 | USERS_ME_URL = BASE_URL + 'users/me/' 57 | 58 | DEVICES_URL = BASE_URL + 'devices/' 59 | DEVICE_URL = DEVICES_URL + '$DEVID$/' 60 | DEVICE_ACTIVITIES_URL = DEVICE_URL + 'activities/' 61 | DEVICE_AVATAR_URL = DEVICE_URL + 'avatar/' 62 | DEVICE_INFO_URL = DEVICE_URL + 'info/' 63 | DEVICE_SETTINGS_URL = DEVICE_URL + 'settings/' 64 | 65 | SUBSCRIPTIONS_URL = BASE_URL + 'subscriptions?include=device,owner' 66 | SUBSCRIPTION_URL = BASE_URL + 'subscriptions/$SUBSCRIPTIONID$' 67 | SUBSCRIPTION_INFO_URL = SUBSCRIPTION_URL + '/info/' 68 | SUBSCRIPTION_SETTINGS_URL = SUBSCRIPTION_URL + '/settings/' 69 | 70 | # GENERAL 71 | APP_ID = 'app_id' 72 | CLIENT_ID = 'client_id' 73 | TOKEN = 'token' 74 | ACCESS_TOKEN = 'access_token' 75 | DEVICES = 'devices' 76 | 77 | # DEVICE 78 | NAME = 'name' 79 | ID = 'id' 80 | TYPE = 'type' 81 | STATUS = 'status' 82 | STATUS_UP = 'up' 83 | LOCATION = 'location' 84 | LOCATION_LAT = 'lat' 85 | LOCATION_LNG = 'lng' 86 | AVATAR = 'avatar' 87 | AVATAR_URL = 'url' 88 | MEDIA_URL = 'media' 89 | 90 | # DEVICE INFO 91 | WIFI_LINK = 'wifiLink' 92 | WIFI_SSID = 'essid' 93 | CHECK_IN = 'checkedInAt' 94 | 95 | # DEVICE ACTIVITIES 96 | EVENT = 'event' 97 | EVENT_ON_DEMAND = 'application:on-demand' 98 | EVENT_BUTTON = 'device:sensor:button' 99 | EVENT_MOTION = 'device:sensor:motion' 100 | CREATED_AT = 'createdAt' 101 | 102 | STATE = 'state' 103 | STATE_READY = 'ready' 104 | 105 | VIDEO_STATE = 'videoState' 106 | VIDEO_STATE_READY = 'download:ready' 107 | 108 | # DEVICE SETTINGS 109 | SETTINGS_DO_NOT_DISTURB = 'do_not_disturb' 110 | SETTINGS_OUTDOOR_CHIME = 'chime_level' 111 | SETTINGS_MOTION_POLICY = 'motion_policy' 112 | SETTINGS_MOTION_THRESHOLD = 'motion_threshold' 113 | SETTINGS_VIDEO_PROFILE = 'video_profile' 114 | SETTINGS_LED_R = 'green_r' 115 | SETTINGS_LED_G = 'green_g' 116 | SETTINGS_LED_B = 'green_b' 117 | SETTINGS_LED_COLOR = [SETTINGS_LED_R, SETTINGS_LED_G, SETTINGS_LED_B] 118 | SETTINGS_LED_INTENSITY = 'led_intensity' 119 | 120 | ALL_SETTINGS = [SETTINGS_DO_NOT_DISTURB, SETTINGS_OUTDOOR_CHIME, 121 | SETTINGS_MOTION_POLICY, SETTINGS_MOTION_THRESHOLD, 122 | SETTINGS_VIDEO_PROFILE, SETTINGS_LED_R, 123 | SETTINGS_LED_G, SETTINGS_LED_B, SETTINGS_LED_INTENSITY] 124 | 125 | # SETTINGS Values 126 | SETTINGS_DO_NOT_DISTURB_VALUES = ["true", "false"] 127 | 128 | SETTINGS_OUTDOOR_CHIME_OFF = 0 129 | SETTINGS_OUTDOOR_CHIME_LOW = 1 130 | SETTINGS_OUTDOOR_CHIME_MEDIUM = 2 131 | SETTINGS_OUTDOOR_CHIME_HIGH = 3 132 | SETTINGS_OUTDOOR_CHIME_VALUES = [SETTINGS_OUTDOOR_CHIME_OFF, 133 | SETTINGS_OUTDOOR_CHIME_LOW, 134 | SETTINGS_OUTDOOR_CHIME_MEDIUM, 135 | SETTINGS_OUTDOOR_CHIME_HIGH] 136 | 137 | SETTINGS_MOTION_POLICY_OFF = 'disabled' 138 | SETTINGS_MOTION_POLICY_ON = 'call' 139 | SETTINGS_MOTION_POLICY_VALUES = [SETTINGS_MOTION_POLICY_OFF, 140 | SETTINGS_MOTION_POLICY_ON] 141 | 142 | SETTINGS_MOTION_THRESHOLD_LOW = 100 143 | SETTINGS_MOTION_THRESHOLD_MEDIUM = 50 144 | SETTINGS_MOTION_THRESHOLD_HIGH = 32 145 | SETTINGS_MOTION_THRESHOLD_VALUES = [SETTINGS_MOTION_THRESHOLD_LOW, 146 | SETTINGS_MOTION_THRESHOLD_MEDIUM, 147 | SETTINGS_MOTION_THRESHOLD_HIGH] 148 | 149 | SETTINGS_VIDEO_PROFILE_1080P = 0 150 | SETTINGS_VIDEO_PROFILE_720P_BETTER = 1 151 | SETTINGS_VIDEO_PROFILE_720P_GOOD = 2 152 | SETTINGS_VIDEO_PROFILE_480P = 3 153 | SETTINGS_VIDEO_PROFILE_VALUES = [SETTINGS_VIDEO_PROFILE_1080P, 154 | SETTINGS_VIDEO_PROFILE_720P_BETTER, 155 | SETTINGS_VIDEO_PROFILE_720P_GOOD, 156 | SETTINGS_VIDEO_PROFILE_480P] 157 | 158 | SETTINGS_LED_VALUES = [0, 255] 159 | 160 | SETTINGS_LED_INTENSITY_VALUES = [0, 100] 161 | -------------------------------------------------------------------------------- /skybellpy/helpers/errors.py: -------------------------------------------------------------------------------- 1 | """Errors for SkybellPy.""" 2 | USERNAME = (0, "Username must be a non-empty string") 3 | 4 | PASSWORD = (1, "Password must be a non-empty string") 5 | 6 | LOGIN_FAILED = (2, "Login failed") 7 | 8 | REQUEST = (3, "Request failed") 9 | 10 | INVALID_SETTING = ( 11 | 4, "Setting is not valid") 12 | 13 | INVALID_SETTING_VALUE = ( 14 | 5, "Value for setting is not valid") 15 | 16 | COLOR_VALUE_NOT_VALID = ( 17 | 6, "RGB color value is not a list of three integers between 0 and 255") 18 | 19 | COLOR_INTENSITY_NOT_VALID = ( 20 | 7, "Intensity value is not a valid integer") 21 | -------------------------------------------------------------------------------- /skybellpy/utils.py: -------------------------------------------------------------------------------- 1 | """Skybellpy utility methods.""" 2 | import pickle 3 | import random 4 | import string 5 | import uuid 6 | 7 | 8 | def save_cache(data, filename): 9 | """Save cookies to a file.""" 10 | with open(filename, 'wb') as handle: 11 | pickle.dump(data, handle) 12 | 13 | 14 | def load_cache(filename): 15 | """Load cookies from a file.""" 16 | with open(filename, 'rb') as handle: 17 | return pickle.load(handle) 18 | 19 | 20 | def gen_id(): 21 | """Generate new Skybell IDs.""" 22 | return str(uuid.uuid4()) 23 | 24 | 25 | def gen_token(): 26 | """Generate a new Skybellpy token.""" 27 | return ''.join( 28 | random.choice( 29 | string.ascii_uppercase + string.ascii_lowercase + string.digits) 30 | for _ in range(32)) 31 | 32 | 33 | def update(dct, dct_merge): 34 | """Recursively merge dicts.""" 35 | for key, value in dct_merge.items(): 36 | if key in dct and isinstance(dct[key], dict): 37 | dct[key] = update(dct[key], value) 38 | else: 39 | dct[key] = value 40 | return dct 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for tests directory.""" 2 | -------------------------------------------------------------------------------- /tests/mock/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mock responses that mimic actual data from Skybell servers. 3 | 4 | This file should be updated any time the Skybell server responses 5 | change so we can test that abodepy can still communicate. 6 | """ 7 | 8 | ACCESS_TOKEN = 'magicalauthtokenhere' 9 | USERID = '123456abc' 10 | 11 | UNAUTORIZED = ''' 12 | { 13 | "errors": { 14 | "message": "Invalid Login - SmartAuth" 15 | } 16 | }''' 17 | -------------------------------------------------------------------------------- /tests/mock/device.py: -------------------------------------------------------------------------------- 1 | """Mock Skybell Device Response.""" 2 | 3 | from tests.mock import USERID 4 | 5 | EMPTY_DEVICE_RESPONSE = '[]' 6 | 7 | DEVID = 'devid123abc' 8 | 9 | 10 | def get_response_ok(user_id=USERID, name='Front Door', dev_id=DEVID): 11 | """Return the successful device response json.""" 12 | return ''' 13 | { 14 | "user": "''' + user_id + '''", 15 | "uuid": "devuuid123", 16 | "resourceId": "devresourceid123", 17 | "deviceInviteToken": "devInviteToken123", 18 | "location": { 19 | "lat": "-0.0", 20 | "lng": "0.0" 21 | }, 22 | "name": "''' + name + '''", 23 | "type": "skybell hd", 24 | "status": "up", 25 | "createdAt": "2016-12-03T16:48:13.651Z", 26 | "updatedAt": "2017-09-25T23:32:45.374Z", 27 | "timeZone": { 28 | "dstOffset": 3600, 29 | "rawOffset": 36000, 30 | "status": "OK", 31 | "timeZoneId": "Australia/Sydney", 32 | "timeZoneName": "Australian Eastern Daylight Time" 33 | }, 34 | "avatar": { 35 | "bucket": "v3-production-devices-avatar", 36 | "key": "path/key123.jpg", 37 | "createdAt": "2017-09-25T23:32:45.312Z", 38 | "url": "http://www.google.com/" 39 | }, 40 | "id": "''' + dev_id + '''", 41 | "acl": "owner" 42 | }''' 43 | -------------------------------------------------------------------------------- /tests/mock/device_activities.py: -------------------------------------------------------------------------------- 1 | """Mock Skybell Device Activities Response.""" 2 | 3 | import datetime 4 | 5 | import skybellpy.helpers.constants as CONST 6 | import tests.mock.device as DEVICE 7 | 8 | EMPTY_ACTIVITIES_RESPONSE = '[]' 9 | 10 | 11 | def get_response_ok(dev_id=DEVICE.DEVID, 12 | event=CONST.EVENT_BUTTON, 13 | state=CONST.STATE_READY, 14 | video_state=CONST.VIDEO_STATE_READY, 15 | created_at=datetime.datetime.now()): 16 | """Return the device activity response json.""" 17 | str_created_at = created_at.strftime('%Y-%m-%dT%H:%M:%SZ') 18 | return ''' 19 | { 20 | "_id": "activityId", 21 | "updatedAt": "''' + str_created_at + '''", 22 | "createdAt": "''' + str_created_at + '''", 23 | "device": "''' + dev_id + '''", 24 | "callId": "''' + str_created_at + '''", 25 | "event": "''' + event + '''", 26 | "state": "''' + state + '''", 27 | "ttlStartDate": "''' + str_created_at + '''", 28 | "videoState": "''' + video_state + '''", 29 | "id": "activityId", 30 | "media": "http://www.image.com/image.jpg", 31 | "mediaSmall": "http://www.image.com/image.jpg" 32 | }''' 33 | -------------------------------------------------------------------------------- /tests/mock/device_avatar.py: -------------------------------------------------------------------------------- 1 | """Mock Skybell Device Avatar Response.""" 2 | 3 | 4 | def get_response_ok(data=''): 5 | """Return the successful device info response json.""" 6 | return ''' 7 | { 8 | "createdAt": "2018-12-14T21:20:06.198Z", 9 | "url": 10 | "https://v3-production-devices-avatar.s3-us-west-2.amazonaws.com/''' + \ 11 | data + '''" 12 | }''' 13 | -------------------------------------------------------------------------------- /tests/mock/device_info.py: -------------------------------------------------------------------------------- 1 | """Mock Skybell Device Info Response.""" 2 | 3 | from tests.mock.device import DEVID 4 | 5 | SSID = 'devid123abc' 6 | WIFI_STATUS = 'good' 7 | 8 | 9 | def get_response_ok(dev_id=DEVID, ssid=SSID, wifi_status=WIFI_STATUS): 10 | """Return the successful device info response json.""" 11 | return ''' 12 | { 13 | "wifiNoise": "-79", 14 | "wifiBitrate": "52", 15 | "proxy_port": "5683", 16 | "wifiLinkQuality": "92", 17 | "port": "5683", 18 | "wifiSnr": "29", 19 | "mac": "dd:cc:99:00:77:88", 20 | "serialNo": "333444555666", 21 | "wifiTxPwrEeprom": "16", 22 | "hardwareRevision": "SKYBELL_HD_3_1_1008848-009", 23 | "proxy_address": "127.0.0.1", 24 | "localHostname": "host.internal", 25 | "address": "127.0.0.1", 26 | "firmwareVersion": "1128", 27 | "essid": "''' + ssid + '''", 28 | "timestamp": "63673679013", 29 | "wifiSignalLevel": "-50", 30 | "deviceId": "''' + dev_id + '''", 31 | "checkedInAt": "2017-09-26T21:03:33.000Z", 32 | "status": { 33 | "wifiLink": "''' + wifi_status + '''" 34 | } 35 | }''' 36 | -------------------------------------------------------------------------------- /tests/mock/device_settings.py: -------------------------------------------------------------------------------- 1 | """Mock Skybell Device Info Response.""" 2 | 3 | import skybellpy.helpers.constants as CONST 4 | 5 | PATCH_RESPONSE_OK = '{}' 6 | 7 | PATHCH_RESPONSE_BAD_REQUEST = '' 8 | 9 | 10 | def get_response_ok(do_not_disturb=False, 11 | outdoor_chime=CONST.SETTINGS_OUTDOOR_CHIME_HIGH, 12 | motion_policy=CONST.SETTINGS_MOTION_POLICY_ON, 13 | motion_threshold=CONST.SETTINGS_MOTION_THRESHOLD_HIGH, 14 | video_profile=CONST.SETTINGS_VIDEO_PROFILE_720P_BETTER, 15 | led_rgb=(255, 255, 255), 16 | led_intensity=100): 17 | """Return the successful device info response json.""" 18 | return ''' 19 | { 20 | "ring_tone": 0, 21 | "do_not_ring": false, 22 | "do_not_disturb": ''' + str(do_not_disturb).lower() + ''', 23 | "digital_doorbell": false, 24 | "video_profile": ''' + str(video_profile) + ''', 25 | "mic_volume": 63, 26 | "speaker_volume": 96, 27 | "chime_level": ''' + str(outdoor_chime) + ''', 28 | "motion_threshold": ''' + str(motion_threshold) + ''', 29 | "low_lux_threshold": 50, 30 | "med_lux_threshold": 150, 31 | "high_lux_threshold": 400, 32 | "low_front_led_dac": 220, 33 | "med_front_led_dac": 195, 34 | "high_front_led_dac": 170, 35 | "green_r": ''' + str(led_rgb[0]) + ''', 36 | "green_g": ''' + str(led_rgb[1]) + ''', 37 | "green_b": ''' + str(led_rgb[2]) + ''', 38 | "led_intensity": ''' + str(led_intensity) + ''', 39 | "motion_policy": "''' + motion_policy + '''" 40 | }''' 41 | -------------------------------------------------------------------------------- /tests/mock/login.py: -------------------------------------------------------------------------------- 1 | """Mock Skybell Login Response.""" 2 | 3 | from tests.mock import ACCESS_TOKEN 4 | from tests.mock import USERID 5 | 6 | 7 | def post_response_ok(access_token=ACCESS_TOKEN, user_id=USERID): 8 | """Return the successful login response json.""" 9 | return ''' 10 | { 11 | "firstName": "John", 12 | "lastName": "Doe", 13 | "resourceId": "resourceid123", 14 | "createdAt": "2016-11-26T22:30:45.254Z", 15 | "updatedAt": "2016-11-26T22:30:45.254Z", 16 | "id": "''' + user_id + '''", 17 | "userLinks": [], 18 | "access_token": "''' + access_token + '''" 19 | }''' 20 | -------------------------------------------------------------------------------- /tests/test_device.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test Skybell device functionality. 3 | 4 | Tests the device initialization and attributes of the Skybell device class. 5 | """ 6 | import datetime 7 | import json 8 | import unittest 9 | 10 | from distutils.util import strtobool 11 | 12 | import requests_mock 13 | 14 | import skybellpy 15 | import skybellpy.helpers.constants as CONST 16 | 17 | import tests.mock.login as LOGIN 18 | import tests.mock.device as DEVICE 19 | import tests.mock.device_avatar as DEVICE_AVATAR 20 | import tests.mock.device_info as DEVICE_INFO 21 | import tests.mock.device_settings as DEVICE_SETTINGS 22 | import tests.mock.device_activities as DEVICE_ACTIVITIES 23 | 24 | USERNAME = 'foobar' 25 | PASSWORD = 'deadbeef' 26 | 27 | 28 | class TestSkybell(unittest.TestCase): 29 | """Test the Skybell class in skybellpy.""" 30 | 31 | def setUp(self): 32 | """Set up Skybell module.""" 33 | self.skybell = skybellpy.Skybell(username=USERNAME, 34 | password=PASSWORD, 35 | disable_cache=True, 36 | login_sleep=False) 37 | 38 | def tearDown(self): 39 | """Clean up after test.""" 40 | self.skybell = None 41 | 42 | @requests_mock.mock() 43 | def tests_device_init(self, m): 44 | """Check that the Skybell device init's properly.""" 45 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 46 | 47 | # Set up device 48 | device_text = '[' + DEVICE.get_response_ok() + ']' 49 | device_json = json.loads(device_text) 50 | 51 | avatar_text = DEVICE_AVATAR.get_response_ok() 52 | avatar_json = json.loads(avatar_text) 53 | avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 54 | '$DEVID$', DEVICE.DEVID) 55 | 56 | info_text = DEVICE_INFO.get_response_ok() 57 | info_json = json.loads(info_text) 58 | info_url = str.replace(CONST.DEVICE_INFO_URL, 59 | '$DEVID$', DEVICE.DEVID) 60 | 61 | settings_text = DEVICE_SETTINGS.get_response_ok() 62 | settings_json = json.loads(settings_text) 63 | settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 64 | '$DEVID$', DEVICE.DEVID) 65 | 66 | activities_text = '[' + \ 67 | DEVICE_ACTIVITIES.get_response_ok( 68 | dev_id=DEVICE.DEVID, 69 | event=CONST.EVENT_BUTTON) + ',' + \ 70 | DEVICE_ACTIVITIES.get_response_ok( 71 | dev_id=DEVICE.DEVID, 72 | event=CONST.EVENT_MOTION) + ']' 73 | activities_json = json.loads(activities_text) 74 | activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 75 | '$DEVID$', DEVICE.DEVID) 76 | 77 | m.get(CONST.DEVICES_URL, text=device_text) 78 | m.get(avatar_url, text=avatar_text) 79 | m.get(info_url, text=info_text) 80 | m.get(settings_url, text=settings_text) 81 | m.get(activities_url, text=activities_text) 82 | 83 | # Logout to reset everything 84 | self.skybell.logout() 85 | 86 | # Get our specific device 87 | device = self.skybell.get_device(DEVICE.DEVID) 88 | 89 | # Check device states match 90 | self.assertIsNotNone(device) 91 | # pylint: disable=W0212 92 | 93 | # Test Device Details 94 | self.assertEqual(device.name, device_json[0][CONST.NAME]) 95 | self.assertEqual(device.type, device_json[0][CONST.TYPE]) 96 | self.assertEqual(device.status, device_json[0][CONST.STATUS]) 97 | self.assertEqual(device.device_id, device_json[0][CONST.ID]) 98 | self.assertTrue(device.is_up) 99 | self.assertEqual(device.location[0], 100 | device_json[0][CONST.LOCATION][CONST.LOCATION_LAT]) 101 | self.assertEqual(device.location[1], 102 | device_json[0][CONST.LOCATION][CONST.LOCATION_LNG]) 103 | self.assertEqual(device.image, 104 | avatar_json[CONST.AVATAR_URL]) 105 | self.assertEqual(device.activity_image, 106 | activities_json[0][CONST.MEDIA_URL]) 107 | 108 | # Test Info Details 109 | self.assertEqual(device.wifi_status, 110 | info_json[CONST.STATUS][CONST.WIFI_LINK]) 111 | self.assertEqual(device.wifi_ssid, info_json[CONST.WIFI_SSID]) 112 | self.assertEqual(device.last_check_in, 113 | info_json[CONST.CHECK_IN]) 114 | 115 | # Test Settings Details 116 | self.assertEqual(device.do_not_disturb, 117 | settings_json[CONST.SETTINGS_DO_NOT_DISTURB]) 118 | self.assertEqual(device.outdoor_chime_level, 119 | settings_json[CONST.SETTINGS_OUTDOOR_CHIME]) 120 | self.assertEqual(device.motion_sensor, 121 | (settings_json[CONST.SETTINGS_MOTION_POLICY] == 122 | CONST.SETTINGS_MOTION_POLICY_ON)) 123 | self.assertEqual(device.motion_threshold, 124 | settings_json[CONST.SETTINGS_MOTION_THRESHOLD]) 125 | self.assertEqual(device.video_profile, 126 | settings_json[CONST.SETTINGS_VIDEO_PROFILE]) 127 | self.assertEqual(device.led_rgb, 128 | (settings_json[CONST.SETTINGS_LED_R], 129 | settings_json[CONST.SETTINGS_LED_G], 130 | settings_json[CONST.SETTINGS_LED_B])) 131 | self.assertEqual(device.led_intensity, 132 | settings_json[CONST.SETTINGS_LED_INTENSITY]) 133 | # Test Desc 134 | self.assertIsNotNone(device.desc) 135 | 136 | @requests_mock.mock() 137 | def tests_device_refresh(self, m): 138 | """Check that the Skybell device refreshes data.""" 139 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 140 | 141 | # Set up device 142 | device_name = 'Shut The Back Door' 143 | device_text = '[' + DEVICE.get_response_ok(name=device_name) + ']' 144 | 145 | avatar_text = DEVICE_AVATAR.get_response_ok() 146 | avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 147 | '$DEVID$', DEVICE.DEVID) 148 | 149 | device_ssid = 'Super SSID64' 150 | device_wifi_status = 'good' 151 | info_text = DEVICE_INFO.get_response_ok( 152 | ssid=device_ssid, wifi_status=device_wifi_status) 153 | info_url = str.replace(CONST.DEVICE_INFO_URL, 154 | '$DEVID$', DEVICE.DEVID) 155 | 156 | do_not_disturb = False 157 | outdoor_chime = CONST.SETTINGS_OUTDOOR_CHIME_HIGH 158 | motion_policy = CONST.SETTINGS_MOTION_POLICY_ON 159 | motion_threshold = CONST.SETTINGS_MOTION_THRESHOLD_HIGH 160 | video_profile = CONST.SETTINGS_VIDEO_PROFILE_720P_BETTER 161 | led_rgb = (255, 255, 255) 162 | led_intensity = 100 163 | settings_text = DEVICE_SETTINGS.get_response_ok( 164 | do_not_disturb, 165 | outdoor_chime, 166 | motion_policy, 167 | motion_threshold, 168 | video_profile, 169 | led_rgb, 170 | led_intensity) 171 | settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 172 | '$DEVID$', DEVICE.DEVID) 173 | activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 174 | '$DEVID$', DEVICE.DEVID) 175 | 176 | m.get(CONST.DEVICES_URL, text=device_text) 177 | m.get(avatar_url, text=avatar_text) 178 | m.get(info_url, text=info_text) 179 | m.get(settings_url, text=settings_text) 180 | m.get(activities_url, text=DEVICE_ACTIVITIES.EMPTY_ACTIVITIES_RESPONSE) 181 | 182 | # Logout to reset everything 183 | self.skybell.logout() 184 | 185 | # Get our specific device 186 | device = self.skybell.get_device(DEVICE.DEVID) 187 | 188 | # Check device states match 189 | self.assertIsNotNone(device) 190 | # pylint: disable=W0212 191 | self.assertEqual(device.name, device_name) 192 | self.assertEqual(device.wifi_status, device_wifi_status) 193 | self.assertEqual(device.wifi_ssid, device_ssid) 194 | self.assertEqual(device.do_not_disturb, do_not_disturb) 195 | self.assertEqual(device.outdoor_chime_level, outdoor_chime) 196 | self.assertTrue(device.outdoor_chime) 197 | self.assertEqual(device.motion_sensor, True) 198 | self.assertEqual(device.motion_threshold, motion_threshold) 199 | self.assertEqual(device.video_profile, video_profile) 200 | self.assertEqual(device.led_rgb, led_rgb) 201 | self.assertEqual(device.led_intensity, led_intensity) 202 | 203 | # Change the values 204 | device_name = 'Shut The Front Door' 205 | device_text = DEVICE.get_response_ok(name=device_name) 206 | device_url = str.replace(CONST.DEVICE_URL, '$DEVID$', DEVICE.DEVID) 207 | 208 | avatar_text = DEVICE_AVATAR.get_response_ok() 209 | avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 210 | '$DEVID$', DEVICE.DEVID) 211 | 212 | device_ssid = 'Gamecube' 213 | device_wifi_status = 'bad' 214 | info_text = DEVICE_INFO.get_response_ok( 215 | ssid=device_ssid, wifi_status=device_wifi_status) 216 | 217 | do_not_disturb = True 218 | outdoor_chime = CONST.SETTINGS_OUTDOOR_CHIME_OFF 219 | motion_policy = CONST.SETTINGS_MOTION_POLICY_OFF 220 | motion_threshold = CONST.SETTINGS_MOTION_THRESHOLD_LOW 221 | video_profile = CONST.SETTINGS_VIDEO_PROFILE_480P 222 | led_rgb = (128, 128, 128) 223 | led_intensity = 25 224 | settings_text = DEVICE_SETTINGS.get_response_ok( 225 | do_not_disturb, 226 | outdoor_chime, 227 | motion_policy, 228 | motion_threshold, 229 | video_profile, 230 | led_rgb, 231 | led_intensity) 232 | 233 | m.get(device_url, text=device_text) 234 | m.get(avatar_url, text=avatar_text) 235 | m.get(info_url, text=info_text) 236 | m.get(settings_url, text=settings_text) 237 | 238 | # Refresh the device 239 | device = self.skybell.get_device(DEVICE.DEVID, refresh=True) 240 | 241 | # Check new values 242 | self.assertEqual(device.name, device_name) 243 | self.assertEqual(device.wifi_status, device_wifi_status) 244 | self.assertEqual(device.wifi_ssid, device_ssid) 245 | self.assertEqual(device.name, device_name) 246 | self.assertEqual(device.wifi_status, device_wifi_status) 247 | self.assertEqual(device.wifi_ssid, device_ssid) 248 | self.assertEqual(device.do_not_disturb, do_not_disturb) 249 | self.assertEqual(device.outdoor_chime_level, outdoor_chime) 250 | self.assertFalse(device.outdoor_chime) 251 | self.assertEqual(device.motion_sensor, False) 252 | self.assertEqual(device.motion_threshold, motion_threshold) 253 | self.assertEqual(device.video_profile, video_profile) 254 | self.assertEqual(device.led_rgb, led_rgb) 255 | self.assertEqual(device.led_intensity, led_intensity) 256 | 257 | @requests_mock.mock() 258 | def tests_settings_change(self, m): 259 | """Check that the Skybell device changes data.""" 260 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 261 | 262 | # Set up device 263 | device_text = '[' + DEVICE.get_response_ok() + ']' 264 | 265 | avatar_text = DEVICE_AVATAR.get_response_ok() 266 | avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 267 | '$DEVID$', DEVICE.DEVID) 268 | 269 | info_text = DEVICE_INFO.get_response_ok() 270 | info_url = str.replace(CONST.DEVICE_INFO_URL, '$DEVID$', DEVICE.DEVID) 271 | 272 | settings_text = DEVICE_SETTINGS.get_response_ok() 273 | settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 274 | '$DEVID$', DEVICE.DEVID) 275 | activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 276 | '$DEVID$', DEVICE.DEVID) 277 | 278 | m.get(CONST.DEVICES_URL, text=device_text) 279 | m.get(avatar_url, text=avatar_text) 280 | m.get(info_url, text=info_text) 281 | m.get(settings_url, text=settings_text) 282 | m.get(activities_url, text=DEVICE_ACTIVITIES.EMPTY_ACTIVITIES_RESPONSE) 283 | m.patch(settings_url, text=DEVICE_SETTINGS.PATCH_RESPONSE_OK) 284 | 285 | # Logout to reset everything 286 | self.skybell.logout() 287 | 288 | # Get our specific device 289 | # pylint: disable=W0212 290 | device = self.skybell.get_device(DEVICE.DEVID) 291 | self.assertIsNotNone(device) 292 | 293 | # Change and test new values 294 | for value in CONST.SETTINGS_DO_NOT_DISTURB_VALUES: 295 | device.do_not_disturb = value 296 | self.assertEqual(device.do_not_disturb, strtobool(value)) 297 | 298 | for value in CONST.SETTINGS_OUTDOOR_CHIME_VALUES: 299 | device.outdoor_chime_level = value 300 | self.assertEqual(device.outdoor_chime_level, value) 301 | 302 | for value in [True, False]: 303 | device.motion_sensor = value 304 | self.assertEqual(device.motion_sensor, value) 305 | 306 | for value in CONST.SETTINGS_MOTION_THRESHOLD_VALUES: 307 | device.motion_threshold = value 308 | self.assertEqual(device.motion_threshold, value) 309 | 310 | for value in CONST.SETTINGS_VIDEO_PROFILE_VALUES: 311 | device.video_profile = value 312 | self.assertEqual(device.video_profile, value) 313 | 314 | for value in CONST.SETTINGS_LED_VALUES: 315 | rgb = (value, value, value) 316 | device.led_rgb = rgb 317 | self.assertEqual(device.led_rgb, rgb) 318 | 319 | for value in CONST.SETTINGS_LED_INTENSITY_VALUES: 320 | device.led_intensity = value 321 | self.assertEqual(device.led_intensity, value) 322 | 323 | @requests_mock.mock() 324 | def tests_settings_validation(self, m): 325 | """Check that the Skybell device settings validate.""" 326 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 327 | 328 | # Set up device 329 | device_text = '[' + DEVICE.get_response_ok() + ']' 330 | 331 | avatar_text = DEVICE_AVATAR.get_response_ok() 332 | avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 333 | '$DEVID$', DEVICE.DEVID) 334 | 335 | info_text = DEVICE_INFO.get_response_ok() 336 | info_url = str.replace(CONST.DEVICE_INFO_URL, '$DEVID$', DEVICE.DEVID) 337 | 338 | settings_text = DEVICE_SETTINGS.get_response_ok() 339 | settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 340 | '$DEVID$', DEVICE.DEVID) 341 | activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 342 | '$DEVID$', DEVICE.DEVID) 343 | 344 | m.get(CONST.DEVICES_URL, text=device_text) 345 | m.get(avatar_url, text=avatar_text) 346 | m.get(info_url, text=info_text) 347 | m.get(settings_url, text=settings_text) 348 | m.get(activities_url, text=DEVICE_ACTIVITIES.EMPTY_ACTIVITIES_RESPONSE) 349 | m.patch(settings_url, text=DEVICE_SETTINGS.PATCH_RESPONSE_OK) 350 | 351 | # Logout to reset everything 352 | self.skybell.logout() 353 | 354 | # Get our specific device 355 | device = self.skybell.get_device(DEVICE.DEVID) 356 | self.assertIsNotNone(device) 357 | 358 | # Change and test new values 359 | with self.assertRaises(skybellpy.SkybellException): 360 | device.do_not_disturb = "monkey" 361 | 362 | with self.assertRaises(skybellpy.SkybellException): 363 | device.outdoor_chime_level = "bamboo" 364 | 365 | with self.assertRaises(skybellpy.SkybellException): 366 | device.motion_sensor = "dumbo" 367 | 368 | with self.assertRaises(skybellpy.SkybellException): 369 | device.motion_threshold = "lists" 370 | 371 | with self.assertRaises(skybellpy.SkybellException): 372 | device.video_profile = "alpha" 373 | 374 | with self.assertRaises(skybellpy.SkybellException): 375 | device.led_rgb = "grapes" 376 | 377 | with self.assertRaises(skybellpy.SkybellException): 378 | device.led_rgb = ("oranges", "apples", "peaches") 379 | 380 | with self.assertRaises(skybellpy.SkybellException): 381 | device.led_rgb = (500, -600, 70.1) 382 | 383 | with self.assertRaises(skybellpy.SkybellException): 384 | device.led_rgb = (-1, 266, 11) 385 | 386 | with self.assertRaises(skybellpy.SkybellException): 387 | device.led_intensity = "purple" 388 | 389 | with self.assertRaises(skybellpy.SkybellException): 390 | device.led_intensity = -500 391 | 392 | with self.assertRaises(skybellpy.SkybellException): 393 | device.led_intensity = 70.1 394 | 395 | with self.assertRaises(skybellpy.SkybellException): 396 | # pylint: disable=W0212 397 | device._set_setting({"lol": "kik"}) 398 | 399 | with self.assertRaises(skybellpy.SkybellException): 400 | # pylint: disable=W0212 401 | device._set_setting({CONST.SETTINGS_MOTION_POLICY: "kik"}) 402 | 403 | @requests_mock.mock() 404 | def tests_settings_failed(self, m): 405 | """Check that the Skybell device settings fail without changing.""" 406 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 407 | 408 | # Set up device 409 | device_text = '[' + DEVICE.get_response_ok() + ']' 410 | 411 | avatar_text = DEVICE_AVATAR.get_response_ok() 412 | avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 413 | '$DEVID$', DEVICE.DEVID) 414 | 415 | info_text = DEVICE_INFO.get_response_ok() 416 | info_url = str.replace(CONST.DEVICE_INFO_URL, '$DEVID$', DEVICE.DEVID) 417 | 418 | settings_text = DEVICE_SETTINGS.get_response_ok( 419 | do_not_disturb=True) 420 | settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 421 | '$DEVID$', DEVICE.DEVID) 422 | activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 423 | '$DEVID$', DEVICE.DEVID) 424 | 425 | m.get(CONST.DEVICES_URL, text=device_text) 426 | m.get(avatar_url, text=avatar_text) 427 | m.get(info_url, text=info_text) 428 | m.get(settings_url, text=settings_text) 429 | m.get(activities_url, text=DEVICE_ACTIVITIES.EMPTY_ACTIVITIES_RESPONSE) 430 | m.patch(settings_url, text=DEVICE_SETTINGS.PATHCH_RESPONSE_BAD_REQUEST, 431 | status_code=400) 432 | 433 | # Logout to reset everything 434 | self.skybell.logout() 435 | 436 | # Get our specific device 437 | device = self.skybell.get_device(DEVICE.DEVID) 438 | self.assertIsNotNone(device) 439 | self.assertEqual(device.do_not_disturb, True) 440 | 441 | # Test setting to false then validate still True 442 | device.do_not_disturb = False 443 | self.assertEqual(device.do_not_disturb, True) 444 | 445 | @requests_mock.mock() 446 | def tests_activities(self, m): 447 | """Check that the Skybell device activities work.""" 448 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 449 | 450 | # Set up device 451 | device_text = '[' + DEVICE.get_response_ok() + ']' 452 | 453 | avatar_text = DEVICE_AVATAR.get_response_ok() 454 | avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 455 | '$DEVID$', DEVICE.DEVID) 456 | 457 | info_text = DEVICE_INFO.get_response_ok() 458 | info_url = str.replace(CONST.DEVICE_INFO_URL, '$DEVID$', DEVICE.DEVID) 459 | 460 | settings_text = DEVICE_SETTINGS.get_response_ok() 461 | settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 462 | '$DEVID$', DEVICE.DEVID) 463 | 464 | activities_text = '[' + \ 465 | DEVICE_ACTIVITIES.get_response_ok( 466 | dev_id=DEVICE.DEVID, 467 | event=CONST.EVENT_BUTTON) + ',' + \ 468 | DEVICE_ACTIVITIES.get_response_ok( 469 | dev_id=DEVICE.DEVID, 470 | event=CONST.EVENT_MOTION) + ',' + \ 471 | DEVICE_ACTIVITIES.get_response_ok( 472 | dev_id=DEVICE.DEVID, 473 | event=CONST.EVENT_ON_DEMAND) + ']' 474 | activities_json = json.loads(activities_text) 475 | 476 | activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 477 | '$DEVID$', DEVICE.DEVID) 478 | 479 | m.get(CONST.DEVICES_URL, text=device_text) 480 | m.get(avatar_url, text=avatar_text) 481 | m.get(info_url, text=info_text) 482 | m.get(settings_url, text=settings_text) 483 | m.get(activities_url, text=activities_text) 484 | 485 | # Logout to reset everything 486 | self.skybell.logout() 487 | 488 | # Get our specific device 489 | device = self.skybell.get_device(DEVICE.DEVID) 490 | self.assertIsNotNone(device) 491 | # pylint: disable=W0212 492 | self.assertEqual(device._activities, activities_json) 493 | 494 | # Get all activities from device 495 | activities = device.activities(limit=100) 496 | self.assertIsNotNone(activities) 497 | self.assertEqual(len(activities), 3) 498 | 499 | # Get only button activities 500 | activities = device.activities(event=CONST.EVENT_BUTTON) 501 | self.assertIsNotNone(activities) 502 | self.assertEqual(len(activities), 1) 503 | self.assertEqual(activities[0][CONST.EVENT], CONST.EVENT_BUTTON) 504 | 505 | @requests_mock.mock() 506 | def tests_bad_activities(self, m): 507 | """Check that device activities recovers from bad data.""" 508 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 509 | 510 | # Set up device 511 | device_text = '[' + DEVICE.get_response_ok() + ']' 512 | 513 | avatar_text = DEVICE_AVATAR.get_response_ok() 514 | avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 515 | '$DEVID$', DEVICE.DEVID) 516 | 517 | info_text = DEVICE_INFO.get_response_ok() 518 | info_url = str.replace(CONST.DEVICE_INFO_URL, '$DEVID$', DEVICE.DEVID) 519 | 520 | settings_text = DEVICE_SETTINGS.get_response_ok() 521 | settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 522 | '$DEVID$', DEVICE.DEVID) 523 | 524 | activities_text = DEVICE_ACTIVITIES.get_response_ok( 525 | dev_id=DEVICE.DEVID, 526 | event=CONST.EVENT_BUTTON) 527 | 528 | activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 529 | '$DEVID$', DEVICE.DEVID) 530 | 531 | m.get(CONST.DEVICES_URL, text=device_text) 532 | m.get(avatar_url, text=avatar_text) 533 | m.get(info_url, text=info_text) 534 | m.get(settings_url, text=settings_text) 535 | m.get(activities_url, text=activities_text) 536 | 537 | # Logout to reset everything 538 | self.skybell.logout() 539 | 540 | # Get our specific device 541 | device = self.skybell.get_device(DEVICE.DEVID) 542 | self.assertIsNotNone(device) 543 | 544 | # Get all activities from device 545 | activities = device.activities(limit=100) 546 | self.assertIsNotNone(activities) 547 | self.assertEqual(len(activities), 1) 548 | 549 | # Force our device variable empty 550 | # pylint: disable=W0212 551 | device._activities = None 552 | 553 | # Get all activities from device 554 | activities = device.activities(limit=100) 555 | self.assertIsNotNone(activities) 556 | self.assertEqual(len(activities), 0) 557 | 558 | @requests_mock.mock() 559 | def tests_latest_event(self, m): 560 | """Check that the latest event is always obtained.""" 561 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 562 | 563 | # Set up device 564 | device_text = '[' + DEVICE.get_response_ok() + ']' 565 | 566 | avatar_text = DEVICE_AVATAR.get_response_ok() 567 | avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 568 | '$DEVID$', DEVICE.DEVID) 569 | 570 | info_text = DEVICE_INFO.get_response_ok() 571 | info_url = str.replace(CONST.DEVICE_INFO_URL, '$DEVID$', DEVICE.DEVID) 572 | 573 | settings_text = DEVICE_SETTINGS.get_response_ok() 574 | settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 575 | '$DEVID$', DEVICE.DEVID) 576 | 577 | activity_1 = DEVICE_ACTIVITIES.get_response_ok( 578 | dev_id=DEVICE.DEVID, 579 | event=CONST.EVENT_BUTTON, 580 | state='alpha', 581 | created_at=datetime.datetime(2017, 1, 1, 0, 0, 0)) 582 | 583 | activity_2 = DEVICE_ACTIVITIES.get_response_ok( 584 | dev_id=DEVICE.DEVID, 585 | event=CONST.EVENT_BUTTON, 586 | state='beta', 587 | created_at=datetime.datetime(2017, 1, 1, 0, 0, 1)) 588 | 589 | activities_text = '[' + activity_1 + ',' + activity_2 + ']' 590 | 591 | activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 592 | '$DEVID$', DEVICE.DEVID) 593 | 594 | m.get(CONST.DEVICES_URL, text=device_text) 595 | m.get(avatar_url, text=avatar_text) 596 | m.get(info_url, text=info_text) 597 | m.get(settings_url, text=settings_text) 598 | m.get(activities_url, text=activities_text) 599 | 600 | # Logout to reset everything 601 | self.skybell.logout() 602 | 603 | # Get our specific device 604 | device = self.skybell.get_device(DEVICE.DEVID) 605 | self.assertIsNotNone(device) 606 | 607 | # Get latest button event 608 | event = device.latest(CONST.EVENT_BUTTON) 609 | 610 | # Test 611 | self.assertIsNotNone(event) 612 | self.assertEqual(event.get(CONST.STATE), 'beta') 613 | 614 | @requests_mock.mock() 615 | def tests_newest_event_cached(self, m): 616 | """Check that the a newer cached event is kept over an older event.""" 617 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 618 | 619 | # Set up device 620 | device = DEVICE.get_response_ok() 621 | device_text = '[' + device + ']' 622 | device_url = str.replace(CONST.DEVICE_URL, '$DEVID$', DEVICE.DEVID) 623 | 624 | avatar_text = DEVICE_AVATAR.get_response_ok() 625 | avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 626 | '$DEVID$', DEVICE.DEVID) 627 | 628 | info_text = DEVICE_INFO.get_response_ok() 629 | info_url = str.replace(CONST.DEVICE_INFO_URL, '$DEVID$', DEVICE.DEVID) 630 | 631 | settings_text = DEVICE_SETTINGS.get_response_ok() 632 | settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 633 | '$DEVID$', DEVICE.DEVID) 634 | 635 | activity_1 = DEVICE_ACTIVITIES.get_response_ok( 636 | dev_id=DEVICE.DEVID, 637 | event=CONST.EVENT_BUTTON, 638 | state='alpha', 639 | created_at=datetime.datetime(2017, 1, 1, 0, 0, 0)) 640 | 641 | activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 642 | '$DEVID$', DEVICE.DEVID) 643 | 644 | m.get(CONST.DEVICES_URL, text=device_text) 645 | m.get(avatar_url, text=avatar_text) 646 | m.get(device_url, text=device) 647 | m.get(info_url, text=info_text) 648 | m.get(settings_url, text=settings_text) 649 | m.get(activities_url, text='[' + activity_1 + ']') 650 | 651 | # Logout to reset everything 652 | self.skybell.logout() 653 | 654 | # Get our specific device 655 | device = self.skybell.get_device(DEVICE.DEVID) 656 | self.assertIsNotNone(device) 657 | 658 | # Get latest button event 659 | event = device.latest(CONST.EVENT_BUTTON) 660 | 661 | # Test 662 | self.assertIsNotNone(event) 663 | self.assertEqual(event.get(CONST.STATE), 'alpha') 664 | 665 | activity_2 = DEVICE_ACTIVITIES.get_response_ok( 666 | dev_id=DEVICE.DEVID, 667 | event=CONST.EVENT_BUTTON, 668 | state='beta', 669 | created_at=datetime.datetime(2014, 1, 1, 0, 0, 1)) 670 | 671 | m.get(activities_url, text='[' + activity_2 + ']') 672 | 673 | # Refresh device 674 | device.refresh() 675 | 676 | # Get latest button event 677 | event = device.latest(CONST.EVENT_BUTTON) 678 | 679 | # Test 680 | self.assertIsNotNone(event) 681 | self.assertEqual(event.get(CONST.STATE), 'alpha') 682 | -------------------------------------------------------------------------------- /tests/test_skybell.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test Skybell system functionality. 3 | 4 | Tests the system initialization and attributes of the main Skybell class. 5 | """ 6 | import os 7 | import json 8 | import unittest 9 | 10 | import requests 11 | import requests_mock 12 | 13 | import skybellpy 14 | import skybellpy.helpers.constants as CONST 15 | 16 | import tests.mock as MOCK 17 | import tests.mock.login as LOGIN 18 | import tests.mock.device as DEVICE 19 | import tests.mock.device_avatar as DEVICE_AVATAR 20 | import tests.mock.device_info as DEVICE_INFO 21 | import tests.mock.device_settings as DEVICE_SETTINGS 22 | import tests.mock.device_activities as DEVICE_ACTIVITIES 23 | 24 | USERNAME = 'foobar' 25 | PASSWORD = 'deadbeef' 26 | 27 | 28 | class TestSkybell(unittest.TestCase): 29 | """Test the Skybell class in skybellpy.""" 30 | 31 | def setUp(self): 32 | """Set up Skybell module.""" 33 | self.skybell_no_cred = skybellpy.Skybell(login_sleep=False) 34 | self.skybell = skybellpy.Skybell(username=USERNAME, 35 | password=PASSWORD, 36 | disable_cache=True, 37 | login_sleep=False) 38 | 39 | def tearDown(self): 40 | """Clean up after test.""" 41 | self.skybell = None 42 | self.skybell_no_cred = None 43 | 44 | def tests_initialization(self): 45 | """Verify we can initialize skybell.""" 46 | # pylint: disable=protected-access 47 | self.assertEqual(self.skybell._username, USERNAME) 48 | # pylint: disable=protected-access 49 | self.assertEqual(self.skybell._password, PASSWORD) 50 | 51 | def tests_no_credentials(self): 52 | """Check that we throw an exception when no username/password.""" 53 | with self.assertRaises(skybellpy.SkybellAuthenticationException): 54 | self.skybell_no_cred.login() 55 | 56 | # pylint: disable=protected-access 57 | self.skybell_no_cred._username = USERNAME 58 | with self.assertRaises(skybellpy.SkybellAuthenticationException): 59 | self.skybell_no_cred.login() 60 | 61 | @requests_mock.mock() 62 | def tests_manual_login(self, m): 63 | """Check that we can manually use the login() function.""" 64 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 65 | 66 | self.skybell_no_cred.login(username=USERNAME, password=PASSWORD) 67 | 68 | # pylint: disable=protected-access 69 | self.assertEqual(self.skybell_no_cred._username, USERNAME) 70 | # pylint: disable=protected-access 71 | self.assertEqual(self.skybell_no_cred._password, PASSWORD) 72 | 73 | @requests_mock.mock() 74 | def tests_auto_login(self, m): 75 | """Test that automatic login works.""" 76 | access_token = MOCK.ACCESS_TOKEN 77 | login_json = LOGIN.post_response_ok(access_token) 78 | 79 | m.post(CONST.LOGIN_URL, text=login_json) 80 | 81 | skybell = skybellpy.Skybell(username='fizz', 82 | password='buzz', 83 | auto_login=True, 84 | get_devices=False, 85 | disable_cache=True, 86 | login_sleep=False) 87 | 88 | # pylint: disable=W0212 89 | self.assertEqual(skybell._username, 'fizz') 90 | self.assertEqual(skybell._password, 'buzz') 91 | self.assertEqual(skybell._cache['access_token'], MOCK.ACCESS_TOKEN) 92 | self.assertIsNone(skybell._devices) 93 | 94 | skybell.logout() 95 | 96 | skybell = None 97 | 98 | @requests_mock.mock() 99 | def tests_auto_fetch(self, m): 100 | """Test that automatic device retrieval works.""" 101 | access_token = MOCK.ACCESS_TOKEN 102 | login_json = LOGIN.post_response_ok(access_token) 103 | 104 | m.post(CONST.LOGIN_URL, text=login_json) 105 | m.get(CONST.DEVICES_URL, text=DEVICE.EMPTY_DEVICE_RESPONSE) 106 | 107 | skybell = skybellpy.Skybell(username='fizz', 108 | password='buzz', 109 | get_devices=True, 110 | disable_cache=True, 111 | login_sleep=False) 112 | 113 | # pylint: disable=W0212 114 | self.assertEqual(skybell._username, 'fizz') 115 | self.assertEqual(skybell._password, 'buzz') 116 | self.assertEqual(skybell._cache['access_token'], MOCK.ACCESS_TOKEN) 117 | self.assertEqual(len(skybell._devices), 0) 118 | 119 | skybell.logout() 120 | 121 | skybell = None 122 | 123 | @requests_mock.mock() 124 | def tests_login_failure(self, m): 125 | """Test login failed.""" 126 | m.post(CONST.LOGIN_URL, 127 | text="invalid_client", status_code=400) 128 | 129 | with self.assertRaises(skybellpy.SkybellAuthenticationException): 130 | self.skybell_no_cred.login(username=USERNAME, password=PASSWORD) 131 | 132 | @requests_mock.mock() 133 | def tests_full_setup(self, m): 134 | """Test that Skybell is set up propertly.""" 135 | access_token = MOCK.ACCESS_TOKEN 136 | login_json = LOGIN.post_response_ok(access_token=access_token) 137 | 138 | m.post(CONST.LOGIN_URL, text=login_json) 139 | m.get(CONST.DEVICES_URL, text=DEVICE.EMPTY_DEVICE_RESPONSE) 140 | 141 | self.skybell.get_devices() 142 | 143 | # pylint: disable=protected-access 144 | original_session = self.skybell._session 145 | 146 | # pylint: disable=W0212 147 | self.assertEqual(self.skybell._username, USERNAME) 148 | self.assertEqual(self.skybell._password, PASSWORD) 149 | self.assertEqual(self.skybell._cache['access_token'], 150 | MOCK.ACCESS_TOKEN) 151 | self.assertEqual(len(self.skybell._devices), 0) 152 | self.assertIsNotNone(self.skybell._session) 153 | self.assertEqual(self.skybell._session, original_session) 154 | 155 | self.skybell.logout() 156 | 157 | self.assertIsNone(self.skybell._cache['access_token']) 158 | self.assertIsNone(self.skybell._devices) 159 | self.assertIsNotNone(self.skybell._session) 160 | self.assertNotEqual(self.skybell._session, original_session) 161 | 162 | self.skybell.logout() 163 | 164 | @requests_mock.mock() 165 | def tests_reauthorize(self, m): 166 | """Check that Skybell can reauthorize after token timeout.""" 167 | new_token = "FOOBAR" 168 | m.post(CONST.LOGIN_URL, [ 169 | {'text': LOGIN.post_response_ok( 170 | access_token=new_token), 'status_code': 200} 171 | ]) 172 | 173 | m.get(CONST.DEVICES_URL, [ 174 | {'text': MOCK.UNAUTORIZED, 'status_code': 401}, 175 | {'text': DEVICE.EMPTY_DEVICE_RESPONSE, 'status_code': 200} 176 | ]) 177 | 178 | # Forces a device update 179 | self.skybell.get_devices(refresh=True) 180 | 181 | # pylint: disable=W0212 182 | self.assertEqual(self.skybell._cache['access_token'], new_token) 183 | 184 | self.skybell.logout() 185 | 186 | @requests_mock.mock() 187 | def tests_send_request_exception(self, m): 188 | """Check that send_request recovers from an exception.""" 189 | new_token = "DEADBEEF" 190 | m.post(CONST.LOGIN_URL, [ 191 | {'text': LOGIN.post_response_ok( 192 | access_token=new_token), 'status_code': 200} 193 | ]) 194 | 195 | m.get(CONST.DEVICES_URL, [ 196 | {'exc': requests.exceptions.ConnectTimeout}, 197 | {'text': DEVICE.EMPTY_DEVICE_RESPONSE, 'status_code': 200} 198 | ]) 199 | 200 | # Forces a device update 201 | self.skybell.get_devices(refresh=True) 202 | 203 | # pylint: disable=W0212 204 | self.assertEqual(self.skybell._cache['access_token'], new_token) 205 | 206 | self.skybell.logout() 207 | 208 | @requests_mock.mock() 209 | def tests_continuous_bad_auth(self, m): 210 | """Check that Skybell won't get stuck with repeated failed retries.""" 211 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 212 | m.get(CONST.DEVICES_URL, text=MOCK.UNAUTORIZED, status_code=401) 213 | 214 | with self.assertRaises(skybellpy.SkybellException): 215 | self.skybell.get_devices(refresh=True) 216 | 217 | self.skybell.logout() 218 | 219 | @requests_mock.mock() 220 | def tests_cookies(self, m): 221 | """Check that cookies are saved and loaded successfully.""" 222 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 223 | 224 | # Define test pickle file and cleanup old one if exists 225 | cache_path = "./test_cookies.pickle" 226 | 227 | if os.path.exists(cache_path): 228 | os.remove(cache_path) 229 | 230 | # Assert that no cookies file exists 231 | self.assertFalse(os.path.exists(cache_path)) 232 | 233 | # Cookies are created 234 | skybell = skybellpy.Skybell(username='fizz', 235 | password='buzz', 236 | auto_login=False, 237 | cache_path=cache_path, 238 | login_sleep=False) 239 | 240 | # Test that our cookies are fully realized prior to login 241 | # pylint: disable=W0212 242 | self.assertIsNotNone(skybell._cache['app_id']) 243 | self.assertIsNotNone(skybell._cache['client_id']) 244 | self.assertIsNotNone(skybell._cache['token']) 245 | self.assertIsNone(skybell._cache['access_token']) 246 | 247 | # Login to get the access_token 248 | skybell.login() 249 | 250 | # Test that we now have an access token 251 | self.assertIsNotNone(skybell._cache['access_token']) 252 | 253 | # Test that we now have a cookies file 254 | self.assertTrue(os.path.exists(cache_path)) 255 | 256 | # Copy our current cookies file and data 257 | first_pickle = open(cache_path, 'rb').read() 258 | first_cookies_data = skybell._cache 259 | 260 | # Test that logout clears the auth token 261 | skybell.logout() 262 | 263 | self.assertIsNone(skybell._cache['access_token']) 264 | 265 | # Tests that our pickle file has changed with the cleared token 266 | self.assertNotEqual(first_pickle, open(cache_path, 'rb').read()) 267 | 268 | # New skybell instance reads in old data 269 | skybell = skybellpy.Skybell(username='fizz', 270 | password='buzz', 271 | auto_login=False, 272 | cache_path=cache_path, 273 | login_sleep=False) 274 | 275 | # Test that the cookie data is the same 276 | self.assertEqual(skybell._cache['app_id'], 277 | first_cookies_data['app_id']) 278 | self.assertEqual(skybell._cache['client_id'], 279 | first_cookies_data['client_id']) 280 | self.assertEqual(skybell._cache['token'], 281 | first_cookies_data['token']) 282 | 283 | # Cleanup cookies 284 | os.remove(cache_path) 285 | 286 | @requests_mock.mock() 287 | def test_empty_cookies(self, m): 288 | """Check that empty cookies file is loaded successfully.""" 289 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 290 | 291 | # Test empty cookies file 292 | empty_cache_path = "./test_cookies_empty.pickle" 293 | 294 | # Remove the file if it exists 295 | if os.path.exists(empty_cache_path): 296 | os.remove(empty_cache_path) 297 | 298 | # Create an empty file 299 | with open(empty_cache_path, 'a'): 300 | os.utime(empty_cache_path, None) 301 | 302 | # Assert that empty cookies file exists 303 | self.assertTrue(os.path.exists(empty_cache_path)) 304 | 305 | # Cookies are created 306 | empty_skybell = skybellpy.Skybell(username='fizz', 307 | password='buzz', 308 | auto_login=False, 309 | cache_path=empty_cache_path, 310 | login_sleep=False) 311 | 312 | # Test that our cookies are fully realized prior to login 313 | # pylint: disable=W0212 314 | self.assertIsNotNone(empty_skybell._cache['app_id']) 315 | self.assertIsNotNone(empty_skybell._cache['client_id']) 316 | self.assertIsNotNone(empty_skybell._cache['token']) 317 | self.assertIsNone(empty_skybell._cache['access_token']) 318 | 319 | @requests_mock.mock() 320 | def test_get_device(self, m): 321 | """Check that device retrieval works.""" 322 | dev1_devid = 'dev1' 323 | dev1 = DEVICE.get_response_ok(name='Dev1', dev_id=dev1_devid) 324 | dev1_avatar = DEVICE_AVATAR.get_response_ok('dev1') 325 | dev1_avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 326 | '$DEVID$', dev1_devid) 327 | dev1_info = DEVICE_INFO.get_response_ok(dev_id=dev1_devid) 328 | dev1_info_url = str.replace(CONST.DEVICE_INFO_URL, 329 | '$DEVID$', dev1_devid) 330 | dev1_settings = DEVICE_SETTINGS.get_response_ok() 331 | dev1_settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 332 | '$DEVID$', dev1_devid) 333 | dev1_activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 334 | '$DEVID$', dev1_devid) 335 | 336 | dev2_devid = 'dev2' 337 | dev2 = DEVICE.get_response_ok(name='Dev2', dev_id=dev2_devid) 338 | dev2_avatar = DEVICE_AVATAR.get_response_ok('dev2') 339 | dev2_avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 340 | '$DEVID$', dev2_devid) 341 | dev2_info = DEVICE_INFO.get_response_ok(dev_id=dev1_devid) 342 | dev2_info_url = str.replace(CONST.DEVICE_INFO_URL, 343 | '$DEVID$', dev2_devid) 344 | dev2_settings = DEVICE_SETTINGS.get_response_ok() 345 | dev2_settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 346 | '$DEVID$', dev2_devid) 347 | dev2_activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 348 | '$DEVID$', dev2_devid) 349 | 350 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 351 | m.get(CONST.DEVICES_URL, text='[' + dev1 + ',' + dev2 + ']') 352 | m.get(dev1_avatar_url, text=dev1_avatar) 353 | m.get(dev2_avatar_url, text=dev2_avatar) 354 | m.get(dev1_info_url, text=dev1_info) 355 | m.get(dev2_info_url, text=dev2_info) 356 | m.get(dev1_settings_url, text=dev1_settings) 357 | m.get(dev2_settings_url, text=dev2_settings) 358 | m.get(dev1_activities_url, 359 | text=DEVICE_ACTIVITIES.EMPTY_ACTIVITIES_RESPONSE) 360 | m.get(dev2_activities_url, 361 | text=DEVICE_ACTIVITIES.EMPTY_ACTIVITIES_RESPONSE) 362 | 363 | # Reset 364 | self.skybell.logout() 365 | 366 | # Get and test all devices 367 | # pylint: disable=W0212 368 | dev1_dev = self.skybell.get_device(dev1_devid) 369 | dev2_dev = self.skybell.get_device(dev2_devid) 370 | 371 | self.assertIsNotNone(dev1_dev) 372 | self.assertIsNotNone(dev2_dev) 373 | self.assertEqual(json.loads(dev1), dev1_dev._device_json) 374 | self.assertEqual(json.loads(dev2), dev2_dev._device_json) 375 | self.assertEqual(json.loads(dev1_avatar), dev1_dev._avatar_json) 376 | self.assertEqual(json.loads(dev2_avatar), dev2_dev._avatar_json) 377 | self.assertEqual(json.loads(dev1_info), dev1_dev._info_json) 378 | self.assertEqual(json.loads(dev2_info), dev2_dev._info_json) 379 | self.assertEqual(json.loads(dev1_settings), 380 | dev1_dev._settings_json) 381 | self.assertEqual(json.loads(dev2_settings), 382 | dev2_dev._settings_json) 383 | 384 | @requests_mock.mock() 385 | def test_all_device_refresh(self, m): 386 | """Check that device refresh works and reuses the same objects.""" 387 | dev1_devid = 'dev1' 388 | dev1a = DEVICE.get_response_ok(name='Dev1', dev_id=dev1_devid) 389 | dev1a_avatar = DEVICE_AVATAR.get_response_ok('dev1a') 390 | dev1a_avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 391 | '$DEVID$', dev1_devid) 392 | dev1a_info = DEVICE_INFO.get_response_ok(dev_id=dev1_devid) 393 | dev1a_info_url = str.replace(CONST.DEVICE_INFO_URL, 394 | '$DEVID$', dev1_devid) 395 | dev1a_settings = DEVICE_SETTINGS.get_response_ok() 396 | dev1a_settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 397 | '$DEVID$', dev1_devid) 398 | 399 | dev1a_activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 400 | '$DEVID$', dev1_devid) 401 | 402 | dev2_devid = 'dev2' 403 | dev2a = DEVICE.get_response_ok(name='Dev2', dev_id=dev2_devid) 404 | dev2a_avatar = DEVICE_AVATAR.get_response_ok('dev2a') 405 | dev2a_avatar_url = str.replace(CONST.DEVICE_AVATAR_URL, 406 | '$DEVID$', dev2_devid) 407 | dev2a_info = DEVICE_INFO.get_response_ok(dev_id=dev1_devid) 408 | dev2a_info_url = str.replace(CONST.DEVICE_INFO_URL, 409 | '$DEVID$', dev2_devid) 410 | dev2a_settings = DEVICE_SETTINGS.get_response_ok() 411 | dev2a_settings_url = str.replace(CONST.DEVICE_SETTINGS_URL, 412 | '$DEVID$', dev2_devid) 413 | 414 | dev2a_activities_url = str.replace(CONST.DEVICE_ACTIVITIES_URL, 415 | '$DEVID$', dev2_devid) 416 | 417 | m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) 418 | m.get(CONST.DEVICES_URL, text='[' + dev1a + ',' + dev2a + ']') 419 | m.get(dev1a_avatar_url, text=dev1a_avatar) 420 | m.get(dev2a_avatar_url, text=dev2a_avatar) 421 | m.get(dev1a_info_url, text=dev1a_info) 422 | m.get(dev2a_info_url, text=dev2a_info) 423 | m.get(dev1a_settings_url, text=dev1a_settings) 424 | m.get(dev2a_settings_url, text=dev2a_settings) 425 | m.get(dev1a_activities_url, 426 | text=DEVICE_ACTIVITIES.EMPTY_ACTIVITIES_RESPONSE) 427 | m.get(dev2a_activities_url, 428 | text=DEVICE_ACTIVITIES.EMPTY_ACTIVITIES_RESPONSE) 429 | 430 | # Reset 431 | self.skybell.logout() 432 | 433 | # Get all devices 434 | self.skybell.get_devices() 435 | 436 | # Get and check devices 437 | # pylint: disable=W0212 438 | dev1a_dev = self.skybell.get_device(dev1_devid) 439 | self.assertEqual(json.loads(dev1a)['id'], dev1a_dev.device_id) 440 | 441 | dev2a_dev = self.skybell.get_device(dev2_devid) 442 | self.assertEqual(json.loads(dev2a)['id'], dev2a_dev.device_id) 443 | 444 | # Change device states 445 | dev1b = DEVICE.get_response_ok(name='Dev1_new', dev_id=dev1_devid) 446 | dev2b = DEVICE.get_response_ok(name='Dev2_new', dev_id=dev2_devid) 447 | 448 | m.get(CONST.DEVICES_URL, text='[' + dev1b + ',' + dev2b + ']') 449 | 450 | # Refresh all devices 451 | self.skybell.get_devices(refresh=True) 452 | 453 | # Get and check devices again, ensuring they are the same object 454 | # Future note: "if a is b" tests that the object is the same 455 | # Asserting dev1a_dev is dev1b_dev tests if they are the same object 456 | dev1b_dev = self.skybell.get_device(dev1_devid) 457 | self.assertEqual(json.loads(dev1b)['id'], dev1b_dev.device_id) 458 | self.assertIs(dev1a_dev, dev1b_dev) 459 | 460 | dev2b_dev = self.skybell.get_device(dev2_devid) 461 | self.assertEqual(json.loads(dev2b)['id'], dev2b_dev.device_id) 462 | self.assertIs(dev2a_dev, dev2b_dev) 463 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = build, py35, py36, py37, lint 3 | skip_missing_interpreters = True 4 | skipsdist = True 5 | 6 | [testenv] 7 | setenv = 8 | LANG=en_US.UTF-8 9 | PYTHONPATH = {toxinidir}/skybellpy 10 | commands = 11 | py.test --timeout=30 --cov=skybellpy --cov-report term-missing {posargs} 12 | deps = 13 | -r{toxinidir}/requirements.txt 14 | -r{toxinidir}/requirements_test.txt 15 | 16 | [testenv:lint] 17 | deps = 18 | -r{toxinidir}/requirements.txt 19 | -r{toxinidir}/requirements_test.txt 20 | basepython = python3 21 | ignore_errors = True 22 | commands = 23 | pylint --rcfile={toxinidir}/pylintrc skybellpy tests 24 | flake8 skybellpy tests 25 | pydocstyle skybellpy tests 26 | rst-lint README.rst 27 | rst-lint CHANGES.rst 28 | 29 | [testenv:build] 30 | recreate = True 31 | skip_install = True 32 | whitelist_externals = 33 | /bin/sh 34 | /bin/rm 35 | deps = 36 | -r{toxinidir}/requirements.txt 37 | commands = 38 | /bin/rm -rf build dist 39 | python setup.py sdist bdist_wheel 40 | /bin/sh -c "pip install --upgrade dist/*.whl" 41 | --------------------------------------------------------------------------------