From f6575ea05f8cff03021f3b34eaed7d37bbf92497 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Fri, 8 May 2020 17:38:10 -0400 Subject: [PATCH 01/87] Dev version bump --- blinkpy/helpers/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index c067f31..2ed447b 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -3,8 +3,8 @@ import os MAJOR_VERSION = 0 -MINOR_VERSION = 15 -PATCH_VERSION = 0 +MINOR_VERSION = 16 +PATCH_VERSION = '0-rc0' __version__ = "{}.{}.{}".format(MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION) From b5c9a51e9ceab0ba9512d3e69d9c025a474d5f4d Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Fri, 8 May 2020 17:40:45 -0400 Subject: [PATCH 02/87] Fix version, fix bad required version...doh --- blinkpy/helpers/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 2ed447b..097cd35 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,11 +4,11 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = '0-rc0' +PATCH_VERSION = "0-rc0" __version__ = "{}.{}.{}".format(MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION) -REQUIRED_PYTHON_VER = (3, 5, 3) +REQUIRED_PYTHON_VER = (3, 6, 0) PROJECT_NAME = "blinkpy" PROJECT_PACKAGE_NAME = "blinkpy" From 30681cd24380cc6c1488f7a1f1a8b5ae74a25182 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 12 May 2020 08:33:08 +0000 Subject: [PATCH 03/87] Bump flake8 from 3.7.9 to 3.8.1 Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.7.9 to 3.8.1. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/compare/3.7.9...3.8.1) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 870520c..fb837be 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ black==19.10b0 coverage==5.1 -flake8==3.7.9 +flake8==3.8.1 flake8-docstrings==1.5.0 pylint==2.5.2 pydocstyle==5.0.2 From 0f9301742afe5da8db0263431d4cc4ebe1ab1678 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sun, 17 May 2020 00:49:13 +0000 Subject: [PATCH 04/87] Add pre-commit hooks --- .pre-commit-config.yaml | 17 +++++++++++++++++ requirements_test.txt | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d06a60d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((blinkpy|tests)/.+)?[^/]+\.py$ + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.1 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.0.2 + files: ^(homeassistant|tests)/.+\.py$ diff --git a/requirements_test.txt b/requirements_test.txt index fb837be..1392b79 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,6 +2,7 @@ black==19.10b0 coverage==5.1 flake8==3.8.1 flake8-docstrings==1.5.0 +pre-commit==2.4.0 pylint==2.5.2 pydocstyle==5.0.2 pytest==5.4.1 @@ -9,4 +10,4 @@ pytest-cov==2.8.1 pytest-sugar==0.9.3 pytest-timeout==1.3.4 restructuredtext-lint==1.3.0 -pygments==2.6.1 \ No newline at end of file +pygments==2.6.1 From 611a167c3085bbb5d4e254ac9d886e521b8944c6 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sun, 17 May 2020 00:54:19 +0000 Subject: [PATCH 05/87] Update dev instructions with venv and pre-commit --- .gitignore | 1 + CONTRIBUTING.md | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index b35c2f0..18cb9de 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dist/* build/* docs/_build *.log +venv diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ac21bd..8c4f50b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,17 +15,26 @@ You can do this right in github: just click the 'fork' button at the top right. $ git remote add upstream https://github.com/fronzbot/blinkpy.git ``` -2. Create a Local Branch +2. Create virtualenv and install dependencies + ```shell + $ python -m venv venv + $ source venv/bin/activate + $ pip install -r requirements.txt + $ pip install -r requirements_test.txt + $ pre-commit install + ``` + +3. Create a Local Branch First, you will want to create a new branch to hold your changes: ``git checkout -b `` -3. Make changes +4. Make changes Now you can make changes to your code. It is worthwhile to test your code as you progress (see the **Testing** section) -4. Commit Your Changes +5. Commit Your Changes To commit changes to your branch, simply add the files you want and the commit them to the branch. After that, you can push to your fork on GitHub: ```shell @@ -34,7 +43,7 @@ You can do this right in github: just click the 'fork' button at the top right. $ git push origin HEAD ``` -5. Submit your pull request on GitHub +6. Submit your pull request on GitHub - On GitHub, navigate to the [blinkpy](https://github.com/fronzbot/blinkpy) repository. - In the "Branch" menu, choose the branch that contains your commits (from your fork). @@ -44,7 +53,7 @@ You can do this right in github: just click the 'fork' button at the top right. - Click Create pull request. - More detailed instructions can be found here: [Creating a Pull Request](https://help.github.com/articles/creating-a-pull-request/) -6. Prior to merge approval +7. Prior to merge approval Finally, the `blinkpy` repository uses continuous integration tools to run tests prior to merging. If there are any problems, you will see a red 'X' next to your pull request. To see what's wrong, you can find your pull request [here](https://travis- ci.org/fronzbot/blinkpy/pull_requests) and click on the failing test to see the logs. Those logs will indicate, as best as they can, what is causing that test to fail. From 6abb14ceebd9f52009ea1b352d102e9c47063236 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sun, 17 May 2020 01:53:01 +0000 Subject: [PATCH 06/87] Bump pytest from 5.4.1 to 5.4.2 Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.4.1 to 5.4.2. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/5.4.1...5.4.2) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 1392b79..b10b340 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ flake8-docstrings==1.5.0 pre-commit==2.4.0 pylint==2.5.2 pydocstyle==5.0.2 -pytest==5.4.1 +pytest==5.4.2 pytest-cov==2.8.1 pytest-sugar==0.9.3 pytest-timeout==1.3.4 From af279b337de330dd5ee988d100049080f2f92b1c Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sun, 24 May 2020 04:09:23 +0000 Subject: [PATCH 07/87] Total refactoring of auth logic - Still need major test updates - Still needs more thorough testing - Want to add an option to override login endpoint --- blinkpy/api.py | 59 ++----- blinkpy/auth.py | 189 ++++++++++++++++++++++ blinkpy/blinkpy.py | 297 +++++++++++++++++------------------ blinkpy/helpers/constants.py | 3 +- blinkpy/helpers/util.py | 142 +++++------------ blinkpy/login_handler.py | 154 ------------------ blinkpy/sync_module.py | 5 +- 7 files changed, 397 insertions(+), 452 deletions(-) create mode 100644 blinkpy/auth.py delete mode 100644 blinkpy/login_handler.py diff --git a/blinkpy/api.py b/blinkpy/api.py index c850a2b..2df9127 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -2,8 +2,7 @@ import logging from json import dumps -import blinkpy.helpers.errors as ERROR -from blinkpy.helpers.util import http_req, get_time, BlinkException, Throttle +from blinkpy.helpers.util import get_time, Throttle from blinkpy.helpers.constants import DEFAULT_URL _LOGGER = logging.getLogger(__name__) @@ -12,45 +11,31 @@ MIN_THROTTLE_TIME = 2 def request_login( - blink, - url, - username, - password, - notification_key, - uid, - is_retry=False, - device_id="Blinkpy", + auth, url, login_data, is_retry=False, ): """ Login request. - :param blink: Blink instance. + :param auth: Auth instance. :param url: Login url. - :param username: Blink username. - :param password: Blink password. - :param notification_key: Randomly genereated key. - :param uid: Randomly generated unique id key. - :param is_retry: Is this part of a re-authorization attempt? - :param device_id: Name of application to send at login. + :login_data: Dictionary containing blink login data. """ headers = {"Host": DEFAULT_URL, "Content-Type": "application/json"} data = dumps( { - "email": username, - "password": password, - "notification_key": notification_key, - "unique_id": uid, + "email": login_data["username"], + "password": login_data["password"], + "notification_key": login_data["notification_key"], + "unique_id": login_data["uid"], "app_version": "6.0.7 (520300) #afb0be72a", + "device_identifier": login_data["device_id"], "client_name": "Computer", "client_type": "android", - "device_identifier": device_id, - "device_name": "Blinkpy", "os_version": "5.1.1", "reauth": "true", } ) - return http_req( - blink, + return auth.query( url=url, headers=headers, data=data, @@ -60,19 +45,14 @@ def request_login( ) -def request_verify(blink, verify_key): +def request_verify(auth, blink, verify_key): """Send verification key to blink servers.""" url = "{}/api/v4/account/{}/client/{}/pin/verify".format( blink.urls.base_url, blink.account_id, blink.client_id ) data = dumps({"pin": verify_key}) - return http_req( - blink, - url=url, - headers=blink.auth_header, - data=data, - json_resp=False, - reqtype="post", + return auth.query( + url=url, headers=auth.header, data=data, json_resp=False, reqtype="post", ) @@ -288,13 +268,10 @@ def http_get(blink, url, stream=False, json=True, is_retry=False): :param json: Return json response? TRUE/False :param is_retry: Is this part of a re-auth attempt? """ - if blink.auth_header is None: - raise BlinkException(ERROR.AUTH_TOKEN) _LOGGER.debug("Making GET request to %s", url) - return http_req( - blink, + return blink.auth.query( url=url, - headers=blink.auth_header, + headers=blink.auth.header, reqtype="get", stream=stream, json_resp=json, @@ -309,9 +286,7 @@ def http_post(blink, url, is_retry=False): :param url: URL to perfom post request. :param is_retry: Is this part of a re-auth attempt? """ - if blink.auth_header is None: - raise BlinkException(ERROR.AUTH_TOKEN) _LOGGER.debug("Making POST request to %s", url) - return http_req( - blink, url=url, headers=blink.auth_header, reqtype="post", is_retry=is_retry + return blink.auth.query( + url=url, headers=blink.auth.header, reqtype="post", is_retry=is_retry ) diff --git a/blinkpy/auth.py b/blinkpy/auth.py new file mode 100644 index 0000000..c6cca12 --- /dev/null +++ b/blinkpy/auth.py @@ -0,0 +1,189 @@ +"""Login handler for blink.""" +import logging +from functools import partial +from requests import Request, Session, exceptions +from blinkpy import api +from blinkpy.helpers import util +from blinkpy.helpers.constants import BLINK_URL, LOGIN_ENDPOINT +from blinkpy.helpers import errors as ERROR + +_LOGGER = logging.getLogger(__name__) + + +class Auth: + """Class to handle login communication.""" + + def __init__(self, login_data=None, no_prompt=False): + """ + Initialize auth handler. + + :param login_data: dictionary for login data + can contain the following: + - username + - password + - device_id + - uid + - notification_key + :param no_prompt: Should any user input prompts + be supressed? True/FALSE + """ + self.data = login_data + self.token = None + self.host = None + self.region_id = None + self.login_response = None + self.no_prompt = no_prompt + self.session = self.create_session() + self.validate_login() + + @property + def login_attributes(self): + """Return a dictionary of login attributes.""" + attributes = { + "token": self.token, + "host": self.host, + "region_id": self.region_id, + } + return util.merge_dicts(attributes, self.data) + + @property + def header(self): + """Return authorization header.""" + if self.token is None: + return None + return {"Host": self.host, "TOKEN_AUTH": self.token} + + def create_session(self): + """Create a session for blink communication.""" + sess = Session() + sess.get = partial(sess.get, timeout=10) + return sess + + def prepare_request(self, url, headers, data, reqtype): + """Prepare a request.""" + req = Request(reqtype.upper(), url, headers=headers, data=data) + return req.prepare() + + def validate_login(self): + """Check login information and prompt if not available.""" + if "username" not in self.data: + self.data["username"] = None + if "password" not in self.data: + self.data["password"] = None + if not self.no_prompt: + self.data = util.prompt_login_data(self.data) + + self.data = util.validate_login_data(self.data) + + def login(self, login_url=LOGIN_ENDPOINT): + """Attempt login to blink servers.""" + _LOGGER.info("Attempting login with %s", login_url) + response = api.request_login(self, login_url, self.data, is_retry=False,) + try: + if response.status_code == 200: + return response.json() + raise LoginError + except AttributeError: + raise LoginError + + def refresh_token(self): + """Refresh auth token.""" + try: + _LOGGER.info("Token expired, attempting automatic refresh.") + self.login_response = self.login() + region_ids = (*self.login_response["region"],) + self.region_id = region_ids[0] + self.host = f"{self.region_id}.{BLINK_URL}" + self.token = self.login_response["authtoken"]["authtoken"] + except KeyError: + _LOGGER.error("Malformed login response: %s", self.login_response) + raise TokenRefreshFailed + return True + + def validate_response(self, response, json_resp): + """Check for valid response.""" + if not json_resp: + return response + + json_data = response.json() + try: + if json_data["code"] in ERROR.BLINK_ERRORS: + raise exceptions.ConnectionError + except KeyError: + pass + + return json_data + + def query( + self, + url=None, + data=None, + headers=None, + reqtype="get", + stream=False, + json_resp=True, + is_retry=False, + ): + """ + Perform server requests. + + :param url: URL to perform request + :param data: Data to send + :param headers: Headers to send + :param reqtype: Can be 'get' or 'post' (default: 'get') + :param stream: Stream response? True/FALSE + :param json_resp: Return JSON response? TRUE/False + :param is_retry: Is this a retry attempt? True/FALSE + """ + + req = self.prepare_request(url, headers, data, reqtype) + try: + response = self.session.send(req, stream=stream) + return self.validate_response(response, json_resp) + + except (exceptions.ConnectionError, exceptions.Timeout, TokenRefreshFailed): + try: + if not is_retry: + self.refresh_token() + return self.query( + url=url, + data=data, + headers=headers, + reqtype=reqtype, + stream=stream, + json_resp=json_resp, + is_retry=True, + ) + except (TokenRefreshFailed, LoginError): + _LOGGER.error("Endpoint %s failed. Unable to refresh login tokens", url) + _LOGGER.error("Endpoint %s failed", url) + return None + + def send_auth_key(self, blink, key): + """Send 2FA key to blink servers.""" + if key is not None: + response = api.request_verify(self, blink, key) + try: + json_resp = response.json() + blink.available = json_resp["valid"] + except (KeyError, TypeError): + _LOGGER.error("Did not receive valid response from server.") + return False + return True + + def check_key_required(self): + """Check if 2FA key is required.""" + try: + if self.login_response["client"]["verification_required"]: + return True + except KeyError: + pass + return False + + +class TokenRefreshFailed(Exception): + """Class to throw failed refresh exception.""" + + +class LoginError(Exception): + """Class to throw failed login exception.""" diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 4809114..9e4f485 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -24,22 +24,15 @@ from slugify import slugify from blinkpy import api from blinkpy.sync_module import BlinkSyncModule -from blinkpy.helpers.util import ( - create_session, - merge_dicts, - get_time, - BlinkURLHandler, - Throttle, -) +from blinkpy.helpers import util from blinkpy.helpers.constants import ( - BLINK_URL, DEFAULT_MOTION_INTERVAL, DEFAULT_REFRESH, + DEVICE_ID, MIN_THROTTLE_TIME, - LOGIN_URLS, ) from blinkpy.helpers.constants import __version__ -from blinkpy.login_handler import LoginHandler +from blinkpy.auth import Auth, TokenRefreshFailed, LoginError _LOGGER = logging.getLogger(__name__) @@ -50,25 +43,22 @@ class Blink: def __init__( self, + data=None, username=None, password=None, - cred_file=None, refresh_rate=DEFAULT_REFRESH, motion_interval=DEFAULT_MOTION_INTERVAL, legacy_subdomain=False, no_prompt=False, - persist_key=None, - device_id="Blinkpy", + device_id=DEVICE_ID, ): """ Initialize Blink system. + :param data: Blink login data. Ignores username, password, + and device_id if supplied. :param username: Blink username (usually email address) :param password: Blink password - :param cred_file: JSON formatted file to store credentials. - If username and password are given, file - is ignored. Otherwise, username and password - are loaded from file. :param refresh_rate: Refresh rate of blink information. Defaults to 15 (seconds) :param motion_interval: How far back to register motion in minutes. @@ -86,166 +76,161 @@ class Blink: fit the implementation (ie. "Home Assistant" in a Home Assistant integration). """ - self.login_handler = LoginHandler( - username=username, - password=password, - cred_file=cred_file, - persist_key=persist_key, - device_id=device_id, - ) - self._token = None - self._auth_header = None - self._host = None + if data is None: + data = {} + data["username"] = username + data["password"] = password + data["device_id"] = device_id + + self.auth = Auth(login_data=data, no_prompt=no_prompt) self.account_id = None self.client_id = None self.network_ids = [] self.urls = None self.sync = CaseInsensitiveDict({}) - self.region = None - self.region_id = None self.last_refresh = None self.refresh_rate = refresh_rate - self.session = create_session() self.networks = [] self.cameras = CaseInsensitiveDict({}) self.video_list = CaseInsensitiveDict({}) - self.login_url = LOGIN_URLS[0] - self.login_urls = [] self.motion_interval = motion_interval self.version = __version__ - self.legacy = legacy_subdomain self.no_prompt = no_prompt self.available = False self.key_required = False - self.login_response = {} - @property - def auth_header(self): - """Return the authentication header.""" - return self._auth_header - - def start(self): - """ - Perform full system setup. - - Method logs in and sets auth token, urls, and ids for future requests. - Essentially this is just a wrapper function for ease of use. - """ - if not self.available: - self.get_auth_token() - - if self.key_required and not self.no_prompt: - email = self.login_handler.data["username"] - key = input("Enter code sent to {}: ".format(email)) - result = self.login_handler.send_auth_key(self, key) - self.key_required = not result - self.setup_post_verify() - elif not self.key_required: - self.setup_post_verify() - - def setup_post_verify(self): - """Initialize blink system after verification.""" - camera_list = self.get_cameras() - networks = self.get_ids() - for network_name, network_id in networks.items(): - if network_id not in camera_list.keys(): - camera_list[network_id] = {} - _LOGGER.warning("No cameras found for %s", network_name) - sync_module = BlinkSyncModule( - self, network_name, network_id, camera_list[network_id] - ) - sync_module.start() - self.sync[network_name] = sync_module - self.cameras = self.merge_cameras() - self.available = self.refresh() - self.key_required = False - - def login(self): - """Perform server login. DEPRECATED.""" - _LOGGER.warning( - "Method is deprecated and will be removed in a future version. Please use the LoginHandler.login() method instead." - ) - return self.login_handler.login(self) - - def get_auth_token(self, is_retry=False): - """Retrieve the authentication token from Blink.""" - self.login_response = self.login_handler.login(self) - if not self.login_response: - self.available = False - return False - self.setup_params(self.login_response) - if self.login_handler.check_key_required(self): - self.key_required = True - return self._auth_header - - def setup_params(self, response): - """Retrieve blink parameters from login response.""" - self.login_url = self.login_handler.login_url - ((self.region_id, self.region),) = response["region"].items() - self._host = "{}.{}".format(self.region_id, BLINK_URL) - self._token = response["authtoken"]["authtoken"] - self._auth_header = {"Host": self._host, "TOKEN_AUTH": self._token} - self.urls = BlinkURLHandler(self.region_id, legacy=self.legacy) - self.networks = self.get_networks() - self.client_id = response["client"]["id"] - self.account_id = response["account"]["id"] - - def get_networks(self): - """Get network information.""" - response = api.request_networks(self) - try: - return response["summary"] - except KeyError: - return None - - def get_ids(self): - """Set the network ID and Account ID.""" - all_networks = [] - network_dict = {} - for network, status in self.networks.items(): - if status["onboarded"]: - all_networks.append("{}".format(network)) - network_dict[status["name"]] = network - - self.network_ids = all_networks - return network_dict - - def get_cameras(self): - """Retrieve a camera list for each onboarded network.""" - response = api.request_homescreen(self) - try: - all_cameras = {} - for camera in response["cameras"]: - camera_network = str(camera["network_id"]) - camera_name = camera["name"] - camera_id = camera["id"] - camera_info = {"name": camera_name, "id": camera_id} - if camera_network not in all_cameras: - all_cameras[camera_network] = [] - - all_cameras[camera_network].append(camera_info) - return all_cameras - except KeyError: - _LOGGER.error("Initialization failue. Could not retrieve cameras.") - return {} - - @Throttle(seconds=MIN_THROTTLE_TIME) - def refresh(self, force_cache=False): + @util.Throttle(seconds=MIN_THROTTLE_TIME) + def refresh(self, force=False): """ Perform a system refresh. - :param force_cache: Force an update of the camera cache + :param force: Force an update of the camera data """ - if self.check_if_ok_to_update() or force_cache: + if self.check_if_ok_to_update() or force: + if not self.available: + self.auth.refresh_token() + self.setup_post_verify() + for sync_name, sync_module in self.sync.items(): _LOGGER.debug("Attempting refresh of sync %s", sync_name) - sync_module.refresh(force_cache=force_cache) - if not force_cache: + sync_module.refresh(force_cache=force) + if not force: # Prevents rapid clearing of motion detect property self.last_refresh = int(time.time()) return True return False + def start(self): + """Perform full system setup.""" + try: + self.auth.refresh_token() + self.setup_login_ids() + self.setup_urls() + except (LoginError, TokenRefreshFailed, BlinkSetupError): + _LOGGER.error("Cannot setup Blink platform.") + self.available = False + return False + + self.key_required = self.auth.check_key_required() + if self.key_required: + if self.no_prompt: + return True + self.setup_prompt_2fa() + return self.setup_post_verify() + + def setup_prompt_2fa(self): + """Prompt for 2FA.""" + email = self.auth.data["username"] + pin = input(f"Enter code sent to {email}: ") + result = self.auth.send_auth_key(self, pin) + self.key_required = not result + + def setup_post_verify(self): + """Initialize blink system after verification.""" + try: + self.setup_networks() + networks = self.setup_network_ids() + cameras = self.setup_camera_list() + except BlinkSetupError: + self.available = False + return False + + for name, network_id in networks.items(): + sync_cameras = cameras.get(network_id, {}) + self.setup_sync_module(name, network_id, sync_cameras) + self.cameras = self.merge_cameras() + + self.available = True + self.key_required = False + return True + + def setup_sync_module(self, name, network_id, cameras): + """Initialize a sync module.""" + self.sync[name] = BlinkSyncModule(self, name, network_id, cameras) + self.sync[name].start() + + def setup_camera_list(self): + """Create camera list for onboarded networks.""" + all_cameras = {} + response = api.request_homescreen(self) + try: + for camera in response["cameras"]: + camera_network = str(camera["network_id"]) + if camera_network not in all_cameras: + all_cameras[camera_network] = [] + all_cameras[camera_network].append( + {"name": camera["name"], "id": camera["id"]} + ) + return all_cameras + except KeyError: + _LOGGER.error("Unable to retrieve cameras from response %s", response) + raise BlinkSetupError + + def setup_login_ids(self): + """Retrieve login id numbers from login response.""" + try: + self.client_id = self.auth.login_response["client"]["id"] + self.account_id = self.auth.login_response["account"]["id"] + except KeyError: + _LOGGER.error("Malformed login response of %s", self.auth.login_response) + raise BlinkSetupError + + def setup_urls(self): + """Create urls for api.""" + try: + self.urls = util.BlinkURLHandler(self.auth.region_id) + except TypeError: + _LOGGER.error( + "Unable to extract region is from response %s", self.auth.login_response + ) + raise BlinkSetupError + + def setup_networks(self): + """Get network information.""" + response = api.request_networks(self) + try: + self.networks = response["summary"] + except KeyError: + raise BlinkSetupError + + def setup_network_ids(self): + """Create the network ids for onboarded networks.""" + all_networks = [] + network_dict = {} + try: + for network, status in self.networks.items(): + if status["onboarded"]: + all_networks.append(f"{network}") + network_dict[status["name"]] = network + except AttributeError: + _LOGGER.error( + "Unable to retrieve network information from %s", self.networks + ) + raise BlinkSetupError + + self.network_ids = all_networks + return network_dict + def check_if_ok_to_update(self): """Check if it is ok to perform an http request.""" current_time = int(time.time()) @@ -260,9 +245,13 @@ class Blink: """Merge all sync camera dicts into one.""" combined = CaseInsensitiveDict({}) for sync in self.sync: - combined = merge_dicts(combined, self.sync[sync].cameras) + combined = util.merge_dicts(combined, self.sync[sync].cameras) return combined + def save(self, file_name): + """Save login data to file.""" + util.json_save(self.auth.login_attributes, file_name) + def download_videos(self, path, since=None, camera="all", stop=10, debug=False): """ Download all videos from server since specified time. @@ -283,7 +272,7 @@ class Blink: parsed_datetime = parse(since, fuzzy=True) since_epochs = parsed_datetime.timestamp() - formatted_date = get_time(time_to_convert=since_epochs) + formatted_date = util.get_time(time_to_convert=since_epochs) _LOGGER.info("Retrieving videos since %s", formatted_date) if not isinstance(camera, list): @@ -343,3 +332,7 @@ class Blink: camera_name, created_at, address, filename ) ) + + +class BlinkSetupError(Exception): + """Class to handle setup errors.""" diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 097cd35..30b40d5 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -48,10 +48,10 @@ URLS BLINK_URL = "immedia-semi.com" DEFAULT_URL = "{}.{}".format("rest-prod", BLINK_URL) BASE_URL = "https://{}".format(DEFAULT_URL) +LOGIN_ENDPOINT = "{}/api/v3/login".format(BASE_URL) LOGIN_URLS = [ "{}/api/v4/login".format(BASE_URL), "{}/api/v3/login".format(BASE_URL), - "{}/api/v2/login".format(BASE_URL), ] """ @@ -62,6 +62,7 @@ ONLINE = {"online": True, "offline": False} """ OTHER """ +DEVICE_ID = "Blinkpy" TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S%z" DEFAULT_MOTION_INTERVAL = 1 DEFAULT_REFRESH = 30 diff --git a/blinkpy/helpers/util.py b/blinkpy/helpers/util.py index 9079e3d..21e00b2 100644 --- a/blinkpy/helpers/util.py +++ b/blinkpy/helpers/util.py @@ -1,19 +1,38 @@ """Useful functions for blinkpy.""" +import json import logging import time import secrets from calendar import timegm -from functools import partial, wraps -from requests import Request, Session, exceptions +from functools import wraps +from getpass import getpass import dateutil.parser -from blinkpy.helpers.constants import BLINK_URL, TIMESTAMP_FORMAT -import blinkpy.helpers.errors as ERROR +from blinkpy.helpers import constants as const _LOGGER = logging.getLogger(__name__) +def json_load(file_name): + """Load json credentials from file.""" + try: + with open(file_name, "r") as json_file: + data = json.load(json_file) + return data + except FileNotFoundError: + _LOGGER.error("Could not find %s", file_name) + except json.decoder.JSONDecodeError: + _LOGGER.error("File %s has improperly formatted json", file_name) + return None + + +def json_save(data, file_name): + """Save data to file location.""" + with open(file_name, "w") as json_file: + json.dump(data, json_file, indent=4) + + def gen_uid(size): """Create a random sring.""" full_token = secrets.token_hex(size) @@ -34,7 +53,7 @@ def get_time(time_to_convert=None): """Create blink-compatible timestamp.""" if time_to_convert is None: time_to_convert = time.time() - return time.strftime(TIMESTAMP_FORMAT, time.gmtime(time_to_convert)) + return time.strftime(const.TIMESTAMP_FORMAT, time.gmtime(time_to_convert)) def merge_dicts(dict_a, dict_b): @@ -48,103 +67,28 @@ def merge_dicts(dict_a, dict_b): return {**dict_a, **dict_b} -def create_session(): - """ - Create a session for blink communication. +def prompt_login_data(data): + """Prompt user for username and password.""" + if data["username"] is None: + data["username"] = input("Username:") + if data["password"] is None: + data["password"] = getpass("Password:") - From @ericfrederich via - https://github.com/kennethreitz/requests/issues/2011 - """ - sess = Session() - sess.get = partial(sess.get, timeout=5) - return sess + return data -def attempt_reauthorization(blink): - """Attempt to refresh auth token and links.""" - _LOGGER.info("Auth token expired, attempting reauthorization.") - headers = blink.get_auth_token(is_retry=True) - return headers +def validate_login_data(data): + """Check for missing keys.""" + valid_keys = { + "uid": gen_uid(const.SIZE_UID), + "notification_key": gen_uid(const.SIZE_NOTIFICATION_KEY), + "device_id": const.DEVICE_ID, + } + for key in valid_keys: + if key not in data: + data[key] = valid_keys[key] - -def http_req( - blink, - url="http://example.com", - data=None, - headers=None, - reqtype="get", - stream=False, - json_resp=True, - is_retry=False, -): - """ - Perform server requests and check if reauthorization neccessary. - - :param blink: Blink instance - :param url: URL to perform request - :param data: Data to send (default: None) - :param headers: Headers to send (default: None) - :param reqtype: Can be 'get' or 'post' (default: 'get') - :param stream: Stream response? True/FALSE - :param json_resp: Return JSON response? TRUE/False - :param is_retry: Is this a retry attempt? True/FALSE - """ - if reqtype == "post": - req = Request("POST", url, headers=headers, data=data) - elif reqtype == "get": - req = Request("GET", url, headers=headers) - else: - _LOGGER.error("Invalid request type: %s", reqtype) - raise BlinkException(ERROR.REQUEST) - - prepped = req.prepare() - - try: - response = blink.session.send(prepped, stream=stream) - if json_resp and "code" in response.json(): - resp_dict = response.json() - code = resp_dict["code"] - message = resp_dict["message"] - if is_retry and code in ERROR.BLINK_ERRORS: - _LOGGER.error("Cannot obtain new token for server auth.") - return None - elif code in ERROR.BLINK_ERRORS: - headers = attempt_reauthorization(blink) - if not headers: - raise exceptions.ConnectionError - return http_req( - blink, - url=url, - data=data, - headers=headers, - reqtype=reqtype, - stream=stream, - json_resp=json_resp, - is_retry=True, - ) - _LOGGER.warning("Response from server: %s - %s", code, message) - - except (exceptions.ConnectionError, exceptions.Timeout): - _LOGGER.info("Cannot connect to server with url %s.", url) - if not is_retry: - headers = attempt_reauthorization(blink) - return http_req( - blink, - url=url, - data=data, - headers=headers, - reqtype=reqtype, - stream=stream, - json_resp=json_resp, - is_retry=True, - ) - _LOGGER.error("Endpoint %s failed. Possible issue with Blink servers.", url) - return None - - if json_resp: - return response.json() - - return response + return data class BlinkException(Exception): @@ -169,7 +113,7 @@ class BlinkURLHandler: self.subdomain = "rest-{}".format(region_id) if legacy: self.subdomain = "rest.{}".format(region_id) - self.base_url = "https://{}.{}".format(self.subdomain, BLINK_URL) + self.base_url = "https://{}.{}".format(self.subdomain, const.BLINK_URL) self.home_url = "{}/homescreen".format(self.base_url) self.event_url = "{}/events/network".format(self.base_url) self.network_url = "{}/network".format(self.base_url) diff --git a/blinkpy/login_handler.py b/blinkpy/login_handler.py deleted file mode 100644 index 2e978f7..0000000 --- a/blinkpy/login_handler.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Login handler for blink.""" -import json -import logging -from os.path import isfile -from getpass import getpass -from blinkpy import api -from blinkpy.helpers import util -from blinkpy.helpers import constants as const - -_LOGGER = logging.getLogger(__name__) - - -class LoginHandler: - """Class to handle login communication.""" - - def __init__( - self, - username=None, - password=None, - cred_file=None, - persist_key=None, - device_id="Blinkpy", - ): - """ - Initialize login handler. - - :param username: Blink username - :param password: Blink password - :param cred_file: JSON formatted credential file. - :param persist_key: File location of persistant key. - :param device_id: Name of application to send at login. - """ - self.login_url = None - self.login_urls = const.LOGIN_URLS - self.cred_file = cred_file - self.persist_key = persist_key - self.device_id = device_id - self.data = { - "username": username, - "password": password, - "uid": None, - "notification_key": None, - } - - self.check_keys() - - def check_keys(self): - """Check if uid exists, if not create.""" - uid = util.gen_uid(const.SIZE_UID) - notification_key = util.gen_uid(const.SIZE_NOTIFICATION_KEY) - data = {"uid": uid, "notification_key": notification_key} - if self.persist_key is None: - return data - if not isfile(self.persist_key): - with open(self.persist_key, "w") as json_file: - json.dump(data, json_file) - else: - with open(self.persist_key, "r") as json_file: - data = json.load(json_file) - return data - - def check_cred_file(self): - """Check if credential file supplied and use if so.""" - if isfile(self.cred_file): - try: - with open(self.cred_file, "r") as json_file: - creds = json.load(json_file) - self.data["username"] = creds["username"] - self.data["password"] = creds["password"] - - except ValueError: - _LOGGER.error( - "Improperly formatted json file %s.", self.cred_file, exc_info=True - ) - return False - - except KeyError: - _LOGGER.error("JSON file information incomplete %s.", exc_info=True) - return False - return True - return False - - def check_login(self): - """Check login information and prompt if not available.""" - if self.data["username"] is None: - self.data["username"] = input("Username:") - if self.data["password"] is None: - self.data["password"] = getpass("Password:") - - if self.data["username"] and self.data["password"]: - return True - return False - - def validate_response(self, url, response): - """Validate response from login endpoint.""" - try: - if response.status_code != 200: - return False - except AttributeError: - _LOGGER.error( - "Response for %s did not return a status code. Deprecated endpoint?", - url, - ) - return False - return True - - def login(self, blink): - """Attempt login to blink servers.""" - if self.cred_file is not None: - self.check_cred_file() - if not self.check_login(): - _LOGGER.error("Cannot login with username %s", self.data["username"]) - return False - - for url in self.login_urls: - _LOGGER.info("Attempting login with %s", url) - response = api.request_login( - blink, - url, - self.data["username"], - self.data["password"], - self.data["notification_key"], - self.data["uid"], - is_retry=False, - device_id=self.device_id, - ) - - if self.validate_response(url, response): - self.login_url = url - return response.json() - - _LOGGER.error("Failed to login to Blink servers. Last response: %s", response) - return False - - def send_auth_key(self, blink, key): - """Send 2FA key to blink servers.""" - if key is not None: - response = api.request_verify(blink, key) - try: - json_resp = response.json() - blink.available = json_resp["valid"] - except (KeyError, TypeError): - _LOGGER.error("Did not receive valid response from server.") - return False - return True - - def check_key_required(self, blink): - """Check if 2FA key is required.""" - try: - if blink.login_response["client"]["verification_required"]: - return True - except KeyError: - pass - return False diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index def9810..061b5ed 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -21,10 +21,8 @@ class BlinkSyncModule: :param blink: Blink class instantiation """ self.blink = blink - self._auth_header = blink.auth_header self.network_id = network_id - self.region = blink.region - self.region_id = blink.region_id + self.region_id = blink.auth.region_id self.name = network_name self.serial = None self.status = None @@ -49,7 +47,6 @@ class BlinkSyncModule: "network_id": self.network_id, "serial": self.serial, "status": self.status, - "region": self.region, "region_id": self.region_id, } return attr From 7fc63e5f23ed3790ac9b16d349d9d86c5cfcbc87 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sun, 24 May 2020 16:16:16 +0000 Subject: [PATCH 08/87] Add cred_file parameter back into Blink class --- blinkpy/auth.py | 1 - blinkpy/blinkpy.py | 22 +++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index c6cca12..e0cac83 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -135,7 +135,6 @@ class Auth: :param json_resp: Return JSON response? TRUE/False :param is_retry: Is this a retry attempt? True/FALSE """ - req = self.prepare_request(url, headers, data, reqtype) try: response = self.session.send(req, stream=stream) diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 9e4f485..e9d3362 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -48,8 +48,8 @@ class Blink: password=None, refresh_rate=DEFAULT_REFRESH, motion_interval=DEFAULT_MOTION_INTERVAL, - legacy_subdomain=False, no_prompt=False, + cred_file=None, device_id=DEVICE_ID, ): """ @@ -65,22 +65,20 @@ class Blink: Defaults to last refresh time. Useful for preventing motion_detected property from de-asserting too quickly. - :param legacy_subdomain: Set to TRUE to use old 'rest.region' - endpoints (only use if you are having - api issues). :param no_prompt: Set to TRUE if using an implementation that needs to suppress command-line output. - :param persist_key: Location of persistant identifier. :param device_id: Identifier for the application. Default is 'Blinkpy'. This is used when logging in and should be changed to fit the implementation (ie. "Home Assistant" in a Home Assistant integration). + :param cred_file: JSON formatted credential file (containing username and password). """ if data is None: data = {} data["username"] = username data["password"] = password data["device_id"] = device_id + data = self.check_cred_file(cred_file, data) self.auth = Auth(login_data=data, no_prompt=no_prompt) self.account_id = None @@ -248,6 +246,20 @@ class Blink: combined = util.merge_dicts(combined, self.sync[sync].cameras) return combined + def check_cred_file(self, cred_file, data): + """Check if cred file is supplied and load data is so.""" + if cred_file: + cred_data = util.json_load(cred_file) + try: + data["username"] = cred_data["username"] + data["password"] = cred_data["password"] + except KeyError: + _LOGGER.error( + "Supplied cred file must contain 'username' and 'password' keys." + ) + + return data + def save(self, file_name): """Save login data to file.""" util.json_save(self.auth.login_attributes, file_name) From f7bc4c7f9307f2341b110897dd236398df4a7f9b Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 25 May 2020 01:51:04 +0000 Subject: [PATCH 09/87] Move all login handling to auth class - Update quick start guide with new flow - If token given at startup don't bother with logging in (token will be auto-refreshed on failed server request anyways) - Update to v4 login by default (2FA endpoint) --- README.rst | 75 ++++++++++++++++++++++++------------ blinkpy/auth.py | 48 +++++++++++++---------- blinkpy/blinkpy.py | 58 +++------------------------- blinkpy/helpers/constants.py | 4 +- blinkpy/helpers/util.py | 13 +++---- 5 files changed, 91 insertions(+), 107 deletions(-) diff --git a/README.rst b/README.rst index f99e40e..7e87da5 100644 --- a/README.rst +++ b/README.rst @@ -45,59 +45,86 @@ This library was built with the intention of allowing easy communication with Bl Quick Start ============= -The simplest way to use this package from a terminal is to call ``Blink.start()`` which will prompt for your Blink username and password and then log you in. Alternatively, you can instantiate the Blink class with a username and password, and call ``Blink.start()`` to login and setup without prompt, as shown below. In addition, http requests are throttled internally via use of the ``Blink.refresh_rate`` variable, which can be set at initialization and defaults to 30 seconds. +The simplest way to use this package from a terminal is to call ``Blink.start()`` which will prompt for your Blink username and password and then log you in. In addition, http requests are throttled internally via use of the ``Blink.refresh_rate`` variable, which can be set at initialization and defaults to 30 seconds. .. code:: python - from blinkpy import blinkpy - blink = blinkpy.Blink(username='YOUR USER NAME', password='YOUR PASSWORD', refresh_rate=30) + from blinkpy.blinkpy import Blink + + blink = Blink() blink.start() -At startup, you may be prompted for a verification key. Just enter this in the command-line prompt. If you just receive a verification email asking to validate access for your device, enter nothing at this prompt. To avoid any command-line interaction, call the ``Blink`` class with the ``no_prompt=True`` flag. Instead, once you receive the verification email, call the following functions: + +This flow will prompt you for your username and password. Once entered, if you likely will need to send a 2FA key to the blink servers (this pin is sent to your email address). When you receive this pin, enter at the prompt and the Blink library will proceed with setup. + +Starting blink without a prompt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +In some cases, having an interactive command-line session is not desired. In this case, you will need to set the ``Blink.auth.no_prompt`` value to ``True``. In addition, since you will not be prompted with a username and password, you must supply the login data to the blink authentication handler. This is best done by instantiating your own auth handler with a dictionary containing at least your username and password. .. code:: python - blink.login_handler.send_auth_key(blink, VERIFICATION_KEY) + from blinkpy.blinkpy import Blink + from blinkpy.auth import Auth + + blink = Blink() + # Can set no_prompt when initializing auth handler + auth = Auth({"username": , "password": }, no_prompt=True) + blink.auth = auth + blink.start() + + +Since you will not be prompted for any 2FA pin, you must call the ``blink.auth.send_auth_key`` function. There are two required parameters: the ``blink`` object as well as the ``key`` you received from Blink for 2FA: + +.. code:: python + + auth.send_auth_key(blink, ) blink.setup_post_verify() -In addition, you can also save your credentials in a json file and initialize Blink with the credential file as follows: + +Supplying credentials from file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Other use cases may involved loading credentials from a file. This file must be ``json`` formatted and contain a minimum of ``username`` and ``password``. A built in function in the ``blinkpy.helpers.util`` module can aid in loading this file. Note, if ``no_prompt`` is desired, a similar flow can be followed as above. .. code:: python - from blinkpy import blinkpy - blink = blinkpy.Blink(cred_file="path/to/credentials.json") + from blinkpy.blinkpy import Blink + from blinkpy.auth import Auth + from blinkpy.helpers.util import json_load + + blink = Blink() + auth = Auth(json_load("")) + blink.auth = auth blink.start() -The credential file must be json formatted with a ``username`` and ``password`` key like follows: -.. code:: json +Saving credentials +~~~~~~~~~~~~~~~~~~~ +This library also allows you to save your credentials to use in future sessions. Saved information includes authentication tokens as well as unique ids which should allow for a more streamlined experience and limits the frequency of login requests. This data can be saved as follows (it can then be loaded by following the instructions above for supplying credentials from a file): - { - "username": "YOUR USER NAME", - "password": "YOUR PASSWORD" - } +.. code:: python + blink.save("") + + +Getting cameras +~~~~~~~~~~~~~~~~ Cameras are instantiated as individual ``BlinkCamera`` classes within a ``BlinkSyncModule`` instance. All of your sync modules are stored within the ``Blink.sync`` dictionary and can be accessed using the name of the sync module as the key (this is the name of your sync module in the Blink App). The below code will display cameras and their available attributes: .. code:: python - from blinkpy import blinkpy - - blink = blinkpy.Blink(username='YOUR USER NAME', password='YOUR PASSWORD') - blink.start() - for name, camera in blink.cameras.items(): print(name) # Name of the camera print(camera.attributes) # Print available attributes of camera -The most recent images and videos can be accessed as a bytes-object via internal variables. These can be updated with calls to ``Blink.refresh()`` but will only make a request if motion has been detected or other changes have been found. This can be overridden with the ``force_cache`` flag, but this should be used for debugging only since it overrides the internal request throttling. + +The most recent images and videos can be accessed as a bytes-object via internal variables. These can be updated with calls to ``Blink.refresh()`` but will only make a request if motion has been detected or other changes have been found. This can be overridden with the ``force`` flag, but this should be used for debugging only since it overrides the internal request throttling. .. code:: python camera = blink.cameras['SOME CAMERA NAME'] - blink.refresh(force_cache=True) # force a cache update USE WITH CAUTION + blink.refresh(force=True) # force a cache update USE WITH CAUTION camera.image_from_cache.raw # bytes-like image object (jpg) camera.video_from_cache.raw # bytes-like video object (mp4) @@ -110,15 +137,15 @@ The ``blinkpy`` api also allows for saving images and videos to a file and snapp blink.refresh() # Get new information from server camera.image_to_file('/local/path/for/image.jpg') camera.video_to_file('/local/path/for/video.mp4') - + +Download videos +~~~~~~~~~~~~~~~~ You can also use this library to download all videos from the server. In order to do this, you must specify a ``path``. You may also specifiy a how far back in time to go to retrieve videos via the ``since=`` variable (a simple string such as ``"2017/09/21"`` is sufficient), as well as how many pages to traverse via the ``page=`` variable. Note that by default, the library will search the first ten pages which is sufficient in most use cases. Additionally, you can specidy one or more cameras via the ``camera=`` property. This can be a single string indicating the name of the camera, or a list of camera names. By default, it is set to the string ``'all'`` to grab videos from all cameras. Example usage, which downloads all videos recorded since July 4th, 2018 at 9:34am to the ``/home/blink`` directory: .. code:: python - blink = blinkpy.Blink(username="YOUR USER NAME", password="YOUR PASSWORD") - blink.start() blink.download_videos('/home/blink', since='2018/07/04 09:34') diff --git a/blinkpy/auth.py b/blinkpy/auth.py index e0cac83..778daf3 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -18,33 +18,33 @@ class Auth: Initialize auth handler. :param login_data: dictionary for login data - can contain the following: + must contain the following: - username - password - - device_id - - uid - - notification_key :param no_prompt: Should any user input prompts be supressed? True/FALSE """ + if login_data is None: + login_data = {} self.data = login_data - self.token = None - self.host = None - self.region_id = None + self.token = login_data.get("token", None) + self.host = login_data.get("host", None) + self.region_id = login_data.get("region_id", None) + self.client_id = login_data.get("client_id", None) + self.account_id = login_data.get("account_id", None) self.login_response = None self.no_prompt = no_prompt self.session = self.create_session() - self.validate_login() @property def login_attributes(self): """Return a dictionary of login attributes.""" - attributes = { - "token": self.token, - "host": self.host, - "region_id": self.region_id, - } - return util.merge_dicts(attributes, self.data) + self.data["token"] = self.token + self.data["host"] = self.host + self.data["region_id"] = self.region_id + self.data["client_id"] = self.client_id + self.data["account_id"] = self.account_id + return self.data @property def header(self): @@ -66,10 +66,8 @@ class Auth: def validate_login(self): """Check login information and prompt if not available.""" - if "username" not in self.data: - self.data["username"] = None - if "password" not in self.data: - self.data["password"] = None + self.data["username"] = self.data.get("username", None) + self.data["password"] = self.data.get("password", None) if not self.no_prompt: self.data = util.prompt_login_data(self.data) @@ -77,6 +75,7 @@ class Auth: def login(self, login_url=LOGIN_ENDPOINT): """Attempt login to blink servers.""" + self.validate_login() _LOGGER.info("Attempting login with %s", login_url) response = api.request_login(self, login_url, self.data, is_retry=False,) try: @@ -91,15 +90,22 @@ class Auth: try: _LOGGER.info("Token expired, attempting automatic refresh.") self.login_response = self.login() - region_ids = (*self.login_response["region"],) - self.region_id = region_ids[0] + self.region_id = self.login_response["region"]["tier"] self.host = f"{self.region_id}.{BLINK_URL}" self.token = self.login_response["authtoken"]["authtoken"] + self.client_id = self.login_response["client"]["id"] + self.account_id = self.login_response["account"]["id"] except KeyError: _LOGGER.error("Malformed login response: %s", self.login_response) raise TokenRefreshFailed return True + def startup(self): + """Initialize tokens for communication.""" + self.validate_login() + if None in self.login_attributes.values(): + self.refresh_token() + def validate_response(self, response, json_resp): """Check for valid response.""" if not json_resp: @@ -175,7 +181,7 @@ class Auth: try: if self.login_response["client"]["verification_required"]: return True - except KeyError: + except (KeyError, TypeError): pass return False diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index e9d3362..ae4fc73 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -28,7 +28,6 @@ from blinkpy.helpers import util from blinkpy.helpers.constants import ( DEFAULT_MOTION_INTERVAL, DEFAULT_REFRESH, - DEVICE_ID, MIN_THROTTLE_TIME, ) from blinkpy.helpers.constants import __version__ @@ -42,45 +41,19 @@ class Blink: """Class to initialize communication.""" def __init__( - self, - data=None, - username=None, - password=None, - refresh_rate=DEFAULT_REFRESH, - motion_interval=DEFAULT_MOTION_INTERVAL, - no_prompt=False, - cred_file=None, - device_id=DEVICE_ID, + self, refresh_rate=DEFAULT_REFRESH, motion_interval=DEFAULT_MOTION_INTERVAL, ): """ Initialize Blink system. - :param data: Blink login data. Ignores username, password, - and device_id if supplied. - :param username: Blink username (usually email address) - :param password: Blink password :param refresh_rate: Refresh rate of blink information. Defaults to 15 (seconds) :param motion_interval: How far back to register motion in minutes. Defaults to last refresh time. Useful for preventing motion_detected property from de-asserting too quickly. - :param no_prompt: Set to TRUE if using an implementation that needs to - suppress command-line output. - :param device_id: Identifier for the application. Default is 'Blinkpy'. - This is used when logging in and should be changed to - fit the implementation (ie. "Home Assistant" in a - Home Assistant integration). - :param cred_file: JSON formatted credential file (containing username and password). """ - if data is None: - data = {} - data["username"] = username - data["password"] = password - data["device_id"] = device_id - data = self.check_cred_file(cred_file, data) - - self.auth = Auth(login_data=data, no_prompt=no_prompt) + self.auth = Auth() self.account_id = None self.client_id = None self.network_ids = [] @@ -93,7 +66,6 @@ class Blink: self.video_list = CaseInsensitiveDict({}) self.motion_interval = motion_interval self.version = __version__ - self.no_prompt = no_prompt self.available = False self.key_required = False @@ -121,7 +93,7 @@ class Blink: def start(self): """Perform full system setup.""" try: - self.auth.refresh_token() + self.auth.startup() self.setup_login_ids() self.setup_urls() except (LoginError, TokenRefreshFailed, BlinkSetupError): @@ -131,7 +103,7 @@ class Blink: self.key_required = self.auth.check_key_required() if self.key_required: - if self.no_prompt: + if self.auth.no_prompt: return True self.setup_prompt_2fa() return self.setup_post_verify() @@ -186,12 +158,8 @@ class Blink: def setup_login_ids(self): """Retrieve login id numbers from login response.""" - try: - self.client_id = self.auth.login_response["client"]["id"] - self.account_id = self.auth.login_response["account"]["id"] - except KeyError: - _LOGGER.error("Malformed login response of %s", self.auth.login_response) - raise BlinkSetupError + self.client_id = self.auth.client_id + self.account_id = self.auth.account_id def setup_urls(self): """Create urls for api.""" @@ -246,20 +214,6 @@ class Blink: combined = util.merge_dicts(combined, self.sync[sync].cameras) return combined - def check_cred_file(self, cred_file, data): - """Check if cred file is supplied and load data is so.""" - if cred_file: - cred_data = util.json_load(cred_file) - try: - data["username"] = cred_data["username"] - data["password"] = cred_data["password"] - except KeyError: - _LOGGER.error( - "Supplied cred file must contain 'username' and 'password' keys." - ) - - return data - def save(self, file_name): """Save login data to file.""" util.json_save(self.auth.login_attributes, file_name) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 30b40d5..e42c1e6 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -48,9 +48,9 @@ URLS BLINK_URL = "immedia-semi.com" DEFAULT_URL = "{}.{}".format("rest-prod", BLINK_URL) BASE_URL = "https://{}".format(DEFAULT_URL) -LOGIN_ENDPOINT = "{}/api/v3/login".format(BASE_URL) +LOGIN_ENDPOINT = "{}/api/v4/account/login".format(BASE_URL) LOGIN_URLS = [ - "{}/api/v4/login".format(BASE_URL), + "{}/api/v4/account/login".format(BASE_URL), "{}/api/v3/login".format(BASE_URL), ] diff --git a/blinkpy/helpers/util.py b/blinkpy/helpers/util.py index 21e00b2..9afd83a 100644 --- a/blinkpy/helpers/util.py +++ b/blinkpy/helpers/util.py @@ -79,14 +79,11 @@ def prompt_login_data(data): def validate_login_data(data): """Check for missing keys.""" - valid_keys = { - "uid": gen_uid(const.SIZE_UID), - "notification_key": gen_uid(const.SIZE_NOTIFICATION_KEY), - "device_id": const.DEVICE_ID, - } - for key in valid_keys: - if key not in data: - data[key] = valid_keys[key] + data["uid"] = data.get("uid", gen_uid(const.SIZE_UID)) + data["notification_key"] = data.get( + "notification_key", gen_uid(const.SIZE_NOTIFICATION_KEY) + ) + data["device_id"] = data.get("device_id", const.DEVICE_ID) return data From 9cb55dff660a3f9e913bf4a6a10974af6a71ff58 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 25 May 2020 04:36:22 +0000 Subject: [PATCH 10/87] Updated tests --- .gitignore | 1 + blinkpy/camera.py | 2 +- blinkpy/helpers/util.py | 20 ++-- blinkpy/sync_module.py | 11 +- tests/test_auth.py | 214 ++++++++++++++++++++++++++++++++++ tests/test_blink_functions.py | 41 ++----- tests/test_blinkpy.py | 143 +++++++++-------------- tests/test_cameras.py | 33 ++---- tests/test_login_handler.py | 135 --------------------- tests/test_sync_module.py | 34 ++++-- tests/test_util.py | 9 +- 11 files changed, 331 insertions(+), 312 deletions(-) create mode 100644 tests/test_auth.py delete mode 100644 tests/test_login_handler.py diff --git a/.gitignore b/.gitignore index 18cb9de..5eabca9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .tox/* __pycache__/* htmlcov/* +.coverage .coverage.* coverage.xml *.pyc diff --git a/blinkpy/camera.py b/blinkpy/camera.py index a2f05be..de97c5a 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -110,7 +110,7 @@ class BlinkCamera: ) try: self.temperature_calibrated = resp["temp"] - except KeyError: + except (TypeError, KeyError): self.temperature_calibrated = self.temperature _LOGGER.warning("Could not retrieve calibrated temperature.") diff --git a/blinkpy/helpers/util.py b/blinkpy/helpers/util.py index 9afd83a..3bc7380 100644 --- a/blinkpy/helpers/util.py +++ b/blinkpy/helpers/util.py @@ -105,17 +105,17 @@ class BlinkAuthenticationException(BlinkException): class BlinkURLHandler: """Class that handles Blink URLS.""" - def __init__(self, region_id, legacy=False): + def __init__(self, region_id): """Initialize the urls.""" - self.subdomain = "rest-{}".format(region_id) - if legacy: - self.subdomain = "rest.{}".format(region_id) - self.base_url = "https://{}.{}".format(self.subdomain, const.BLINK_URL) - self.home_url = "{}/homescreen".format(self.base_url) - self.event_url = "{}/events/network".format(self.base_url) - self.network_url = "{}/network".format(self.base_url) - self.networks_url = "{}/networks".format(self.base_url) - self.video_url = "{}/api/v2/videos".format(self.base_url) + if region_id is None: + raise TypeError + self.subdomain = f"rest-{region_id}" + self.base_url = f"https://{self.subdomain}.{const.BLINK_URL}" + self.home_url = f"{self.base_url}/homescreen" + self.event_url = f"{self.base_url}/events/network" + self.network_url = f"{self.base_url}/network" + self.networks_url = f"{self.base_url}/networks" + self.video_url = f"{self.base_url}/api/v2/videos" _LOGGER.debug("Setting base url to %s.", self.base_url) diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 061b5ed..f1931f1 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -25,7 +25,7 @@ class BlinkSyncModule: self.region_id = blink.auth.region_id self.name = network_name self.serial = None - self.status = None + self.status = "offline" self.sync_id = None self.host = None self.summary = None @@ -59,7 +59,12 @@ class BlinkSyncModule: @property def online(self): """Return boolean system online status.""" - return ONLINE[self.status] + try: + return ONLINE[self.status] + except KeyError: + _LOGGER.error("Unknown sync module status %s", self.status) + self.available = False + return False @property def arm(self): @@ -67,6 +72,7 @@ class BlinkSyncModule: try: return self.network_info["network"]["armed"] except (KeyError, TypeError): + self.available = False return None @arm.setter @@ -159,6 +165,7 @@ class BlinkSyncModule: camera_id = self.cameras[camera_name].camera_id camera_info = self.get_camera_info(camera_id) self.cameras[camera_name].update(camera_info, force_cache=force_cache) + self.available = True def check_new_videos(self): """Check if new videos since last refresh.""" diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..88f9d7d --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,214 @@ +"""Test login handler.""" + +import unittest +from unittest import mock +from requests import exceptions +from blinkpy.auth import Auth, LoginError, TokenRefreshFailed +import blinkpy.helpers.constants as const +import tests.mock_responses as mresp + +USERNAME = "foobar" +PASSWORD = "deadbeef" + + +class TestAuth(unittest.TestCase): + """Test the Auth class in blinkpy.""" + + def setUp(self): + """Set up Login Handler.""" + self.auth = Auth() + + def tearDown(self): + """Clean up after test.""" + self.auth = None + + @mock.patch("blinkpy.helpers.util.gen_uid") + @mock.patch("blinkpy.auth.util.getpass") + def test_empty_init(self, getpwd, genuid): + """Test initialization with no params.""" + auth = Auth() + self.assertDictEqual(auth.data, {}) + getpwd.return_value = "bar" + genuid.return_value = 1234 + with mock.patch("builtins.input", return_value="foo"): + auth.validate_login() + expected_data = { + "username": "foo", + "password": "bar", + "uid": 1234, + "notification_key": 1234, + "device_id": const.DEVICE_ID, + } + self.assertDictEqual(auth.data, expected_data) + + @mock.patch("blinkpy.helpers.util.gen_uid") + @mock.patch("blinkpy.auth.util.getpass") + def test_barebones_init(self, getpwd, genuid): + """Test basebones initialization.""" + login_data = {"username": "foo", "password": "bar"} + auth = Auth(login_data) + self.assertDictEqual(auth.data, login_data) + getpwd.return_value = "bar" + genuid.return_value = 1234 + with mock.patch("builtins.input", return_value="foo"): + auth.validate_login() + expected_data = { + "username": "foo", + "password": "bar", + "uid": 1234, + "notification_key": 1234, + "device_id": const.DEVICE_ID, + } + self.assertDictEqual(auth.data, expected_data) + + def test_full_init(self): + """Test full initialization.""" + login_data = { + "username": "foo", + "password": "bar", + "token": "token", + "host": "host", + "region_id": "region_id", + "client_id": "client_id", + "account_id": "account_id", + "uid": 1234, + "notification_key": 4321, + "device_id": "device_id", + } + auth = Auth(login_data) + self.assertEqual(auth.token, "token") + self.assertEqual(auth.host, "host") + self.assertEqual(auth.region_id, "region_id") + self.assertEqual(auth.client_id, "client_id") + self.assertEqual(auth.account_id, "account_id") + auth.validate_login() + self.assertDictEqual(auth.login_attributes, login_data) + + def test_bad_response_code(self): + """Check bad response code from server.""" + fake_resp = mresp.MockResponse({"code": 404}, 404) + with self.assertRaises(exceptions.ConnectionError): + self.auth.validate_response(fake_resp, True) + + def test_good_response_code(self): + """Check good response code from server.""" + fake_resp = mresp.MockResponse({"foo": "bar"}, 200) + self.assertEqual(self.auth.validate_response(fake_resp, True), {"foo": "bar"}) + + def test_response_not_json(self): + """Check response when not json.""" + fake_resp = "foobar" + self.assertEqual(self.auth.validate_response(fake_resp, False), "foobar") + + def test_header(self): + """Test header data.""" + self.auth.token = "bar" + self.auth.host = "foo" + expected_header = {"Host": "foo", "TOKEN_AUTH": "bar"} + self.assertDictEqual(self.auth.header, expected_header) + + def test_header_no_token(self): + """Test header without token.""" + self.auth.token = None + self.assertEqual(self.auth.header, None) + + @mock.patch("blinkpy.auth.Auth.validate_login", return_value=None) + @mock.patch("blinkpy.auth.api.request_login") + def test_login(self, mock_req, mock_validate): + """Test login handling.""" + fake_resp = mresp.MockResponse({"foo": "bar"}, 200) + mock_req.return_value = fake_resp + self.assertEqual(self.auth.login(), {"foo": "bar"}) + + @mock.patch("blinkpy.auth.Auth.validate_login", return_value=None) + @mock.patch("blinkpy.auth.api.request_login") + def test_login_bad_response(self, mock_req, mock_validate): + """Test login handling when bad response.""" + fake_resp = mresp.MockResponse({"foo": "bar"}, 404) + mock_req.return_value = fake_resp + with self.assertRaises(LoginError): + self.auth.login() + + @mock.patch("blinkpy.auth.Auth.login") + def test_refresh_token(self, mock_login): + """Test refresh token method.""" + mock_login.return_value = { + "region": {"tier": "test"}, + "authtoken": {"authtoken": "foobar"}, + "client": {"id": 1234}, + "account": {"id": 5678}, + } + self.assertTrue(self.auth.refresh_token()) + self.assertEqual(self.auth.region_id, "test") + self.assertEqual(self.auth.token, "foobar") + self.assertEqual(self.auth.client_id, 1234) + self.assertEqual(self.auth.account_id, 5678) + + @mock.patch("blinkpy.auth.Auth.login") + def test_refresh_token_failed(self, mock_login): + """Test refresh token failed.""" + mock_login.return_value = {} + with self.assertRaises(TokenRefreshFailed): + self.auth.refresh_token() + + def test_check_key_required(self): + """Check key required method.""" + self.auth.login_response = {} + self.assertFalse(self.auth.check_key_required()) + + self.auth.login_response = {"client": {"verification_required": False}} + self.assertFalse(self.auth.check_key_required()) + + self.auth.login_response = {"client": {"verification_required": True}} + self.assertTrue(self.auth.check_key_required()) + + @mock.patch("blinkpy.auth.api.request_verify") + def test_send_auth_key(self, mock_req): + """Check sending of auth key.""" + mock_blink = MockBlink(None) + mock_req.return_value = mresp.MockResponse({"valid": True}, 200) + self.assertTrue(self.auth.send_auth_key(mock_blink, 1234)) + self.assertTrue(mock_blink.available) + + mock_req.return_value = mresp.MockResponse(None, 200) + self.assertFalse(self.auth.send_auth_key(mock_blink, 1234)) + + mock_req.return_value = mresp.MockResponse({}, 200) + self.assertFalse(self.auth.send_auth_key(mock_blink, 1234)) + + self.assertTrue(self.auth.send_auth_key(mock_blink, None)) + + @mock.patch("blinkpy.auth.api.request_verify") + def test_send_auth_key_fail(self, mock_req): + """Check handling of auth key failure.""" + mock_blink = MockBlink(None) + mock_req.return_value = mresp.MockResponse(None, 200) + self.assertFalse(self.auth.send_auth_key(mock_blink, 1234)) + mock_req.return_value = mresp.MockResponse({}, 200) + self.assertFalse(self.auth.send_auth_key(mock_blink, 1234)) + + @mock.patch("blinkpy.auth.Auth.validate_response") + @mock.patch("blinkpy.auth.Auth.refresh_token") + def test_query_retry(self, mock_refresh, mock_validate): + """Check handling of request retry.""" + self.auth.session = MockSession() + mock_validate.side_effect = [TokenRefreshFailed, "foobar"] + mock_refresh.return_value = True + self.assertEqual(self.auth.query(url="http://example.com"), "foobar") + + +class MockSession: + """Object to mock a session.""" + + def send(self, *args, **kwargs): + """Mock send function.""" + return None + + +class MockBlink: + """Object to mock basic blink class.""" + + def __init__(self, login_response): + """Initialize mock blink class.""" + self.available = False + self.login_response = login_response diff --git a/tests/test_blink_functions.py b/tests/test_blink_functions.py index cd1c056..89a45dc 100644 --- a/tests/test_blink_functions.py +++ b/tests/test_blink_functions.py @@ -5,11 +5,7 @@ import logging from blinkpy import blinkpy from blinkpy.sync_module import BlinkSyncModule -from blinkpy.helpers.util import create_session, get_time -import tests.mock_responses as mresp - -USERNAME = "foobar" -PASSWORD = "deadbeef" +from blinkpy.helpers.util import get_time, BlinkURLHandler class MockSyncModule(BlinkSyncModule): @@ -34,42 +30,19 @@ class MockSyncModule(BlinkSyncModule): return self.return_value -@mock.patch("blinkpy.helpers.util.Session.send", side_effect=mresp.mocked_session_send) class TestBlinkFunctions(unittest.TestCase): """Test Blink and BlinkCamera functions in blinkpy.""" def setUp(self): """Set up Blink module.""" - self.blink = blinkpy.Blink(username=USERNAME, password=PASSWORD) - # pylint: disable=protected-access - self.blink._auth_header = {"Host": "test.url.tld", "TOKEN_AUTH": "foobar123"} - self.blink.urls = blinkpy.BlinkURLHandler("test") - self.blink.session = create_session() + self.blink = blinkpy.Blink() + self.blink.urls = BlinkURLHandler("test") def tearDown(self): """Clean up after test.""" self.blink = None - @mock.patch("blinkpy.login_handler.api.request_login") - def test_backup_url(self, req, mock_sess): - """Test backup login method.""" - json_resp = { - "authtoken": {"authtoken": "foobar123"}, - "networks": {"1234": {"name": "foobar", "onboarded": True}}, - } - bad_req = mresp.MockResponse({}, 404) - new_req = mresp.MockResponse(json_resp, 200) - req.side_effect = [bad_req, bad_req, new_req] - self.blink.login_handler.login_urls = ["test1", "test2", "test3"] - self.blink.login_handler.login(self.blink) - self.assertEqual(self.blink.login_handler.login_url, "test3") - - req.side_effect = [bad_req, new_req, bad_req] - self.blink.login_handler.login_urls = ["test1", "test2", "test3"] - self.blink.login_handler.login(self.blink) - self.assertEqual(self.blink.login_handler.login_url, "test2") - - def test_merge_cameras(self, mock_sess): + def test_merge_cameras(self): """Test merge camera functionality.""" first_dict = {"foo": "bar", "test": 123} next_dict = {"foobar": 456, "bar": "foo"} @@ -82,7 +55,7 @@ class TestBlinkFunctions(unittest.TestCase): self.assertEqual(expected, result) @mock.patch("blinkpy.blinkpy.api.request_videos") - def test_download_video_exit(self, mock_req, mock_sess): + def test_download_video_exit(self, mock_req): """Test we exit method when provided bad response.""" blink = blinkpy.Blink() # pylint: disable=protected-access @@ -100,7 +73,7 @@ class TestBlinkFunctions(unittest.TestCase): self.assertEqual(dl_log.output, expected_log) @mock.patch("blinkpy.blinkpy.api.request_videos") - def test_parse_downloaded_items(self, mock_req, mock_sess): + def test_parse_downloaded_items(self, mock_req): """Test ability to parse downloaded items list.""" blink = blinkpy.Blink() # pylint: disable=protected-access @@ -125,7 +98,7 @@ class TestBlinkFunctions(unittest.TestCase): self.assertEqual(dl_log.output, expected_log) @mock.patch("blinkpy.blinkpy.api.request_videos") - def test_parse_camera_not_in_list(self, mock_req, mock_sess): + def test_parse_camera_not_in_list(self, mock_req): """Test ability to parse downloaded items list.""" blink = blinkpy.Blink() # pylint: disable=protected-access diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index cfda5e7..5d1c142 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -8,96 +8,57 @@ any communication related errors at startup. import unittest from unittest import mock -from blinkpy import api -from blinkpy.blinkpy import Blink -from blinkpy.sync_module import BlinkSyncModule -from blinkpy.login_handler import LoginHandler -from blinkpy.helpers.util import ( - http_req, - create_session, - BlinkException, - BlinkURLHandler, -) +from blinkpy.blinkpy import Blink, BlinkSetupError from blinkpy.helpers.constants import __version__ -import tests.mock_responses as mresp - -USERNAME = "foobar" -PASSWORD = "deadbeef" -@mock.patch("blinkpy.helpers.util.Session.send", side_effect=mresp.mocked_session_send) class TestBlinkSetup(unittest.TestCase): """Test the Blink class in blinkpy.""" def setUp(self): - """Set up Blink module.""" - self.blink = Blink(username=USERNAME, password=PASSWORD) - self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", "1234", []) - self.blink.urls = BlinkURLHandler("test") - self.blink.session = create_session() + """Initialize blink test object.""" + self.blink = Blink() + self.blink.available = True def tearDown(self): - """Clean up after test.""" + """Cleanup blink test object.""" self.blink = None - def test_initialization(self, mock_sess): + def test_initialization(self): """Verify we can initialize blink.""" - self.assertEqual(self.blink.version, __version__) - self.assertEqual(self.blink.login_handler.data["username"], USERNAME) - self.assertEqual(self.blink.login_handler.data["password"], PASSWORD) + blink = Blink() + self.assertEqual(blink.version, __version__) - def test_bad_request(self, mock_sess): - """Check that we raise an Exception with a bad request.""" - self.blink.session = create_session() - explog = "WARNING:blinkpy.helpers.util:" "Response from server: 200 - foo" - with self.assertRaises(BlinkException): - http_req(self.blink, reqtype="bad") + def test_network_id_failure(self): + """Check that with bad network data a setup error is raised.""" + self.blink.networks = None + with self.assertRaises(BlinkSetupError): + self.blink.setup_network_ids() - with self.assertLogs() as logrecord: - http_req(self.blink, reqtype="post", is_retry=True) - self.assertEqual(logrecord.output, [explog]) - - def test_authentication(self, mock_sess): - """Check that we can authenticate Blink up properly.""" - authtoken = self.blink.get_auth_token()["TOKEN_AUTH"] - expected = mresp.LOGIN_RESPONSE["authtoken"]["authtoken"] - self.assertEqual(authtoken, expected) - - def test_reauthorization_attempt(self, mock_sess): - """Check that we can reauthorize after first unsuccessful attempt.""" - original_header = self.blink.get_auth_token() - # pylint: disable=protected-access - bad_header = {"Host": self.blink._host, "TOKEN_AUTH": "BADTOKEN"} - # pylint: disable=protected-access - self.blink._auth_header = bad_header - self.assertEqual(self.blink.auth_header, bad_header) - api.request_homescreen(self.blink) - self.assertEqual(self.blink.auth_header, original_header) - - def test_multiple_networks(self, mock_sess): + def test_multiple_networks(self): """Check that we handle multiple networks appropriately.""" self.blink.networks = { "0000": {"onboarded": False, "name": "foo"}, "5678": {"onboarded": True, "name": "bar"}, "1234": {"onboarded": False, "name": "test"}, } - self.blink.get_ids() + self.blink.setup_network_ids() self.assertTrue("5678" in self.blink.network_ids) - def test_multiple_onboarded_networks(self, mock_sess): + def test_multiple_onboarded_networks(self): """Check that we handle multiple networks appropriately.""" self.blink.networks = { "0000": {"onboarded": False, "name": "foo"}, "5678": {"onboarded": True, "name": "bar"}, "1234": {"onboarded": True, "name": "test"}, } - self.blink.get_ids() + self.blink.setup_network_ids() self.assertTrue("0000" not in self.blink.network_ids) self.assertTrue("5678" in self.blink.network_ids) self.assertTrue("1234" in self.blink.network_ids) @mock.patch("blinkpy.blinkpy.time.time") - def test_throttle(self, mock_time, mock_sess): + def test_throttle(self, mock_time): """Check throttling functionality.""" now = self.blink.refresh_rate + 1 mock_time.return_value = now @@ -113,19 +74,15 @@ class TestBlinkSetup(unittest.TestCase): self.assertEqual(self.blink.check_if_ok_to_update(), False) self.assertEqual(self.blink.last_refresh, now) - def test_sync_case_insensitive_dict(self, mock_sess): + def test_sync_case_insensitive_dict(self): """Check that we can access sync modules ignoring case.""" - self.assertEqual(self.blink.sync["test"].name, "test") - self.assertEqual(self.blink.sync["TEST"].name, "test") - - @mock.patch("blinkpy.api.request_login") - def test_unexpected_login(self, mock_login, mock_sess): - """Check that we appropriately handle unexpected login info.""" - mock_login.return_value = None - self.assertFalse(self.blink.get_auth_token()) + self.blink.sync["test"] = 1234 + self.assertEqual(self.blink.sync["test"], 1234) + self.assertEqual(self.blink.sync["TEST"], 1234) + self.assertEqual(self.blink.sync["tEsT"], 1234) @mock.patch("blinkpy.api.request_homescreen") - def test_get_cameras(self, mock_home, mock_sess): + def test_setup_cameras(self, mock_home): """Check retrieval of camera information.""" mock_home.return_value = { "cameras": [ @@ -134,7 +91,7 @@ class TestBlinkSetup(unittest.TestCase): {"name": "test", "network_id": 4321, "id": 0000}, ] } - result = self.blink.get_cameras() + result = self.blink.setup_camera_list() self.assertEqual( result, { @@ -144,28 +101,34 @@ class TestBlinkSetup(unittest.TestCase): ) @mock.patch("blinkpy.api.request_homescreen") - def test_get_cameras_failure(self, mock_home, mock_sess): - """Check that on failure we initialize empty info and move on.""" + def test_setup_cameras_failure(self, mock_home): + """Check that on failure we raise a setup error.""" mock_home.return_value = {} - result = self.blink.get_cameras() - self.assertEqual(result, {}) + with self.assertRaises(BlinkSetupError): + self.blink.setup_camera_list() - @mock.patch.object(LoginHandler, "send_auth_key") - @mock.patch.object(Blink, "setup_post_verify") - def test_startup_prompt(self, mock_send_key, mock_verify, mock_sess): - """Test startup logic with command-line prompt.""" - mock_send_key.return_value = True - mock_verify.return_value = True - self.blink.no_prompt = False - self.blink.key_required = True - self.blink.available = True - with mock.patch("builtins.input", return_value="1234"): - self.blink.start() - self.assertFalse(self.blink.key_required) + def test_setup_urls(self): + """Check setup of URLS.""" + self.blink.auth.region_id = "test" + self.blink.setup_urls() + self.assertEqual(self.blink.urls.subdomain, "rest-test") - def test_startup_no_prompt(self, mock_sess): - """Test startup with no_prompt flag set.""" - self.blink.key_required = True - self.blink.no_prompt = True - self.blink.start() - self.assertTrue(self.blink.key_required) + def test_setup_urls_failure(self): + """Check that on failure we raise a setup error.""" + self.blink.auth.region_id = None + with self.assertRaises(BlinkSetupError): + self.blink.setup_urls() + + @mock.patch("blinkpy.api.request_networks") + def test_setup_networks(self, mock_networks): + """Check setup of networks.""" + mock_networks.return_value = {"summary": "foobar"} + self.blink.setup_networks() + self.assertEqual(self.blink.networks, "foobar") + + @mock.patch("blinkpy.api.request_networks") + def test_setup_networks_failure(self, mock_networks): + """Check that on failure we raise a setup error.""" + mock_networks.return_value = {} + with self.assertRaises(BlinkSetupError): + self.blink.setup_networks() diff --git a/tests/test_cameras.py b/tests/test_cameras.py index ca2fbbf..f3d4121 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -8,14 +8,11 @@ Blink system is set up. import unittest from unittest import mock -from blinkpy import blinkpy -from blinkpy.helpers.util import create_session, BlinkURLHandler +from blinkpy.blinkpy import Blink +from blinkpy.helpers.util import BlinkURLHandler from blinkpy.sync_module import BlinkSyncModule from blinkpy.camera import BlinkCamera -import tests.mock_responses as mresp -USERNAME = "foobar" -PASSWORD = "deadbeef" CAMERA_CFG = { "camera": [ @@ -29,20 +26,13 @@ CAMERA_CFG = { } -@mock.patch("blinkpy.helpers.util.Session.send", side_effect=mresp.mocked_session_send) +@mock.patch("blinkpy.auth.Auth.query") class TestBlinkCameraSetup(unittest.TestCase): """Test the Blink class in blinkpy.""" def setUp(self): """Set up Blink module.""" - self.blink = blinkpy.Blink(username=USERNAME, password=PASSWORD) - header = { - "Host": "abc.zxc", - "TOKEN_AUTH": mresp.LOGIN_RESPONSE["authtoken"]["authtoken"], - } - # pylint: disable=protected-access - self.blink._auth_header = header - self.blink.session = create_session() + self.blink = Blink() self.blink.urls = BlinkURLHandler("test") self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", 1234, []) self.camera = BlinkCamera(self.blink.sync["test"]) @@ -52,8 +42,9 @@ class TestBlinkCameraSetup(unittest.TestCase): def tearDown(self): """Clean up after test.""" self.blink = None + self.camera = None - def test_camera_update(self, mock_sess): + def test_camera_update(self, mock_resp): """Test that we can properly update camera properties.""" config = { "name": "new", @@ -71,8 +62,8 @@ class TestBlinkCameraSetup(unittest.TestCase): self.camera.sync.last_record = { "new": {"clip": "/test.mp4", "time": "1970-01-01T00:00:00"} } - mock_sess.side_effect = [ - mresp.MockResponse({"temp": 71}, 200), + mock_resp.side_effect = [ + {"temp": 71}, "test", "foobar", ] @@ -96,9 +87,9 @@ class TestBlinkCameraSetup(unittest.TestCase): self.assertEqual(self.camera.image_from_cache, "test") self.assertEqual(self.camera.video_from_cache, "foobar") - def test_no_thumbnails(self, mock_sess): + def test_no_thumbnails(self, mock_resp): """Tests that thumbnail is 'None' if none found.""" - mock_sess.return_value = "foobar" + mock_resp.return_value = "foobar" self.camera.last_record = ["1"] config = { "name": "new", @@ -133,9 +124,9 @@ class TestBlinkCameraSetup(unittest.TestCase): ], ) - def test_no_video_clips(self, mock_sess): + def test_no_video_clips(self, mock_resp): """Tests that we still proceed with camera setup with no videos.""" - mock_sess.return_value = "foobar" + mock_resp.return_value = "foobar" config = { "name": "new", "id": 1234, diff --git a/tests/test_login_handler.py b/tests/test_login_handler.py deleted file mode 100644 index 11bfc16..0000000 --- a/tests/test_login_handler.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Test login handler.""" - -import unittest -from unittest import mock -from blinkpy.login_handler import LoginHandler -import tests.mock_responses as mresp - -USERNAME = "foobar" -PASSWORD = "deadbeef" - - -@mock.patch("blinkpy.helpers.util.Session.send", side_effect=mresp.mocked_session_send) -class TestLoginHandler(unittest.TestCase): - """Test the LoginHandler class in blinkpy.""" - - def setUp(self): - """Set up Login Handler.""" - self.login_handler = LoginHandler() - - def tearDown(self): - """Clean up after test.""" - self.login_handler = None - - @mock.patch("blinkpy.login_handler.getpass") - def test_manual_login(self, getpwd, mock_sess): - """Check that we can manually use the login() function.""" - getpwd.return_value = PASSWORD - with mock.patch("builtins.input", return_value=USERNAME): - self.assertTrue(self.login_handler.check_login()) - self.assertEqual(self.login_handler.data["username"], USERNAME) - self.assertEqual(self.login_handler.data["password"], PASSWORD) - - def test_no_cred_file(self, mock_sess): - """Check that we return false when cred file doesn't exist.""" - self.login_handler.cred_file = "/tmp/fake.file" - self.assertFalse(self.login_handler.check_cred_file()) - - @mock.patch("blinkpy.login_handler.isfile") - def test_exit_on_missing_json(self, mockisfile, mock_sess): - """Test that we fail on missing json data.""" - self.login_handler.cred_file = "/tmp/fake.file" - mockisfile.return_value = True - with mock.patch("builtins.open", mock.mock_open(read_data="{}")): - self.assertFalse(self.login_handler.check_cred_file()) - - @mock.patch("blinkpy.login_handler.json.load") - @mock.patch("blinkpy.login_handler.isfile") - def test_cred_file(self, mockisfile, mockjson, mock_sess): - """Test that loading credential file works.""" - self.login_handler.cred_file = "/tmp/fake.file" - mockjson.return_value = {"username": "foo", "password": "bar"} - mockisfile.return_value = True - with mock.patch("builtins.open", mock.mock_open(read_data="")): - self.assertTrue(self.login_handler.check_cred_file()) - self.assertEqual(self.login_handler.data["username"], "foo") - self.assertEqual(self.login_handler.data["password"], "bar") - - def test_bad_response(self, mock_sess): - """Check bad response from server.""" - self.assertFalse(self.login_handler.validate_response(None, None)) - - def test_bad_response_code(self, mock_sess): - """Check bad response code from server.""" - fake_resp = mresp.MockResponse(None, 404) - self.assertFalse(self.login_handler.validate_response(None, fake_resp)) - - def test_good_response_code(self, mock_sess): - """Check good response code from server.""" - fake_resp = mresp.MockResponse(None, 200) - self.assertTrue(self.login_handler.validate_response(None, fake_resp)) - - @mock.patch("blinkpy.login_handler.util.gen_uid") - def test_check_keys_no_persist(self, mock_uid, mock_sess): - """Check key generation.""" - uid_value = "abc123" - mock_uid.return_value = "abc123" - self.login_handler.persist_key = None - data = self.login_handler.check_keys() - self.assertEqual(data["uid"], uid_value) - self.assertEqual(data["notification_key"], uid_value) - - @mock.patch("blinkpy.login_handler.util.gen_uid") - @mock.patch("blinkpy.login_handler.json.load") - @mock.patch("blinkpy.login_handler.isfile") - def test_check_keys_persist(self, mockisfile, mockjson, mock_uid, mock_sess): - """Check key load from file.""" - uid_value = "abc123" - mock_file = {"uid": "321cba", "notification_key": "foobar123"} - mock_uid.return_value = uid_value - mockjson.return_value = mock_file - mockisfile.return_value = True - self.login_handler.persist_key = True - data = self.login_handler.check_keys() - self.assertEqual(mock_file["uid"], data["uid"]) - self.assertEqual(mock_file["notification_key"], data["notification_key"]) - - def test_check_key_required(self, mock_sess): - """Check key required method.""" - response_true = {"client": {"verification_required": True}} - response_false = {"client": {"verification_required": False}} - response_nokey = {} - - mock_blink = MockBlink(response_nokey) - self.assertFalse(self.login_handler.check_key_required(mock_blink)) - - mock_blink = MockBlink(response_false) - self.assertFalse(self.login_handler.check_key_required(mock_blink)) - - mock_blink = MockBlink(response_true) - self.assertTrue(self.login_handler.check_key_required(mock_blink)) - - @mock.patch("blinkpy.login_handler.api.request_verify") - def test_send_auth_key(self, mock_req, mock_sess): - """Check sending of auth key.""" - mock_blink = MockBlink(None) - mock_req.return_value = mresp.MockResponse({"valid": True}, 200) - self.assertTrue(self.login_handler.send_auth_key(mock_blink, 1234)) - self.assertTrue(mock_blink.available) - - mock_req.return_value = mresp.MockResponse(None, 200) - self.assertFalse(self.login_handler.send_auth_key(mock_blink, 1234)) - - mock_req.return_value = mresp.MockResponse({}, 200) - self.assertFalse(self.login_handler.send_auth_key(mock_blink, 1234)) - - self.assertTrue(self.login_handler.send_auth_key(mock_blink, None)) - - -class MockBlink: - """Object to mock basic blink class.""" - - def __init__(self, login_response): - """Initialize mock blink class.""" - self.available = False - self.login_response = login_response diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py index 5a8912f..78f3f0f 100644 --- a/tests/test_sync_module.py +++ b/tests/test_sync_module.py @@ -2,27 +2,21 @@ import unittest from unittest import mock -from blinkpy import blinkpy +from blinkpy.blinkpy import Blink +from blinkpy.helpers.util import BlinkURLHandler from blinkpy.sync_module import BlinkSyncModule from blinkpy.camera import BlinkCamera -USERNAME = "foobar" -PASSWORD = "deadbeef" - -@mock.patch("blinkpy.api.http_req") +@mock.patch("blinkpy.auth.Auth.query") class TestBlinkSyncModule(unittest.TestCase): """Test BlinkSyncModule functions in blinkpy.""" def setUp(self): """Set up Blink module.""" - self.blink = blinkpy.Blink( - username=USERNAME, password=PASSWORD, motion_interval=0 - ) - # pylint: disable=protected-access - self.blink._auth_header = {"Host": "test.url.tld", "TOKEN_AUTH": "foobar123"} + self.blink = Blink(motion_interval=0) self.blink.last_refresh = 0 - self.blink.urls = blinkpy.BlinkURLHandler("test") + self.blink.urls = BlinkURLHandler("test") self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", "1234", []) self.camera = BlinkCamera(self.blink.sync) self.mock_start = [ @@ -48,6 +42,24 @@ class TestBlinkSyncModule(unittest.TestCase): self.camera = None self.mock_start = None + def test_bad_status(self, mock_resp): + """Check that we mark module unavaiable on bad status.""" + self.blink.sync["test"].status = None + self.blink.sync["test"].available = True + self.assertFalse(self.blink.sync["test"].online) + self.assertFalse(self.blink.sync["test"].available) + + def test_bad_arm(self, mock_resp): + """Check that we mark module unavaiable if bad arm status.""" + self.blink.sync["test"].network_info = None + self.blink.sync["test"].available = True + self.assertEqual(self.blink.sync["test"].arm, None) + self.assertFalse(self.blink.sync["test"].available) + self.blink.sync["test"].network_info = {} + self.blink.sync["test"].available = True + self.assertEqual(self.blink.sync["test"].arm, None) + self.assertFalse(self.blink.sync["test"].available) + def test_get_events(self, mock_resp): """Test get events function.""" mock_resp.return_value = {"event": True} diff --git a/tests/test_util.py b/tests/test_util.py index f048c35..d515376 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -3,7 +3,7 @@ import unittest from unittest import mock import time -from blinkpy.helpers.util import Throttle, BlinkURLHandler, time_to_seconds +from blinkpy.helpers.util import Throttle, time_to_seconds class TestUtil(unittest.TestCase): @@ -101,13 +101,6 @@ class TestUtil(unittest.TestCase): self.assertEqual(tester.test1(), None) self.assertEqual(tester.test2(), True) - def test_legacy_subdomains(self): - """Test that subdomain can be set to legacy mode.""" - urls = BlinkURLHandler("test") - self.assertEqual(urls.subdomain, "rest-test") - urls = BlinkURLHandler("test", legacy=True) - self.assertEqual(urls.subdomain, "rest.test") - def test_time_to_seconds(self): """Test time to seconds conversion.""" correct_time = "1970-01-01T00:00:05+00:00" From 61e18ee0a8e4c6342cd46bfff5bedd77fc7ea470 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 25 May 2020 04:39:27 +0000 Subject: [PATCH 11/87] Simplify mock_responses file --- tests/mock_responses.py | 49 ----------------------------------------- 1 file changed, 49 deletions(-) diff --git a/tests/mock_responses.py b/tests/mock_responses.py index ea83776..3981926 100644 --- a/tests/mock_responses.py +++ b/tests/mock_responses.py @@ -1,16 +1,5 @@ """Simple mock responses definitions.""" -from blinkpy.helpers.util import BlinkURLHandler -import blinkpy.helpers.constants as const - -LOGIN_RESPONSE = { - "region": {"mock": "Test"}, - "networks": {"1234": {"name": "test", "onboarded": True}}, - "authtoken": {"authtoken": "foobar123", "message": "auth"}, - "client": {"id": "5678"}, - "account": {"id": "1337"}, -} - class MockResponse: """Class for mock request response.""" @@ -29,41 +18,3 @@ class MockResponse: def raw(self): """Return raw data from get request.""" return self.raw_data - - -def mocked_session_send(*args, **kwargs): - """Mock session.""" - prepped = args[0] - url = prepped.url - header = prepped.headers - method = prepped.method - if method == "GET": - expected_token = LOGIN_RESPONSE["authtoken"]["authtoken"] - if header["TOKEN_AUTH"] != expected_token: - response = {"message": "Not Authorized", "code": 400} - status = 400 - elif url == "use_bad_response": - response = {"foo": "bar"} - status = 200 - elif url == "reauth": - response = {"message": "REAUTH", "code": 777} - status = 777 - else: - response = {"test": "foo"} - status = 200 - elif method == "POST": - if url in const.LOGIN_URLS: - response = LOGIN_RESPONSE - status = 200 - elif url == "http://wrong.url/" or url is None: - response = {"message": "Error", "code": 404} - status = 404 - else: - response = {"message": "foo", "code": 200} - status = 200 - - return MockResponse(response, status) - - -class MockURLHandler(BlinkURLHandler): - """Mocks URL Handler in blinkpy module.""" From f0445683caa787774cc9a9ec91ae0e33f38cd732 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 25 May 2020 08:36:41 +0000 Subject: [PATCH 12/87] Bump pytest-cov from 2.8.1 to 2.9.0 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.8.1 to 2.9.0. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.8.1...v2.9.0) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b10b340..e0a696c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,7 +6,7 @@ pre-commit==2.4.0 pylint==2.5.2 pydocstyle==5.0.2 pytest==5.4.2 -pytest-cov==2.8.1 +pytest-cov==2.9.0 pytest-sugar==0.9.3 pytest-timeout==1.3.4 restructuredtext-lint==1.3.0 From ca60d99e905cbf9028aa511c81cd9e85e4d2d0d7 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 25 May 2020 08:36:59 +0000 Subject: [PATCH 13/87] Bump flake8 from 3.8.1 to 3.8.2 Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.8.1 to 3.8.2. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/compare/3.8.1...3.8.2) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b10b340..6c752c4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ black==19.10b0 coverage==5.1 -flake8==3.8.1 +flake8==3.8.2 flake8-docstrings==1.5.0 pre-commit==2.4.0 pylint==2.5.2 From 8fe67ea5744c5aa3968512a1afdddae7390cd469 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 25 May 2020 13:00:26 +0000 Subject: [PATCH 14/87] More test coverage --- tests/test_blinkpy.py | 56 +++++++++++++++++++++++++++++++++++++++ tests/test_cameras.py | 9 +++++++ tests/test_sync_module.py | 21 +++++++++++++++ tests/test_util.py | 8 +++++- 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index 5d1c142..2d980ff 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -132,3 +132,59 @@ class TestBlinkSetup(unittest.TestCase): mock_networks.return_value = {} with self.assertRaises(BlinkSetupError): self.blink.setup_networks() + + @mock.patch("blinkpy.blinkpy.Auth.send_auth_key") + def test_setup_prompt_2fa(self, mock_key): + """Test setup with 2fa prompt.""" + self.blink.auth.data["username"] = "foobar" + self.blink.key_required = True + mock_key.return_value = True + with mock.patch("builtins.input", return_value="foo"): + self.blink.setup_prompt_2fa() + self.assertFalse(self.blink.key_required) + mock_key.return_value = False + with mock.patch("builtins.input", return_value="foo"): + self.blink.setup_prompt_2fa() + self.assertTrue(self.blink.key_required) + + @mock.patch("blinkpy.blinkpy.Blink.setup_camera_list") + @mock.patch("blinkpy.api.request_networks") + def test_setup_post_verify(self, mock_networks, mock_camera): + """Test setup after verification.""" + self.blink.available = False + self.blink.key_required = True + mock_networks.return_value = { + "summary": {"foo": {"onboarded": False, "name": "bar"}} + } + mock_camera.return_value = [] + self.assertTrue(self.blink.setup_post_verify()) + self.assertTrue(self.blink.available) + self.assertFalse(self.blink.key_required) + + @mock.patch("blinkpy.api.request_networks") + def test_setup_post_verify_failure(self, mock_networks): + """Test failed setup after verification.""" + self.blink.available = False + mock_networks.return_value = {} + self.assertFalse(self.blink.setup_post_verify()) + self.assertFalse(self.blink.available) + + def test_merge_cameras(self): + """Test merging of cameras.""" + self.blink.sync = { + "foo": MockSync({"test": 123, "foo": "bar"}), + "bar": MockSync({"fizz": "buzz", "bar": "foo"}), + } + combined = self.blink.merge_cameras() + self.assertEqual(combined["test"], 123) + self.assertEqual(combined["foo"], "bar") + self.assertEqual(combined["fizz"], "buzz") + self.assertEqual(combined["bar"], "foo") + + +class MockSync: + """Mock sync module class.""" + + def __init__(self, cameras): + """Initialize fake class.""" + self.cameras = cameras diff --git a/tests/test_cameras.py b/tests/test_cameras.py index f3d4121..01064bf 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -143,3 +143,12 @@ class TestBlinkCameraSetup(unittest.TestCase): self.camera.update(config, force_cache=True) self.assertEqual(self.camera.clip, None) self.assertEqual(self.camera.video_from_cache, None) + + @mock.patch("blinkpy.camera.api.request_motion_detection_enable") + @mock.patch("blinkpy.camera.api.request_motion_detection_disable") + def test_motion_detection_enable_disable(self, mock_dis, mock_en, mock_rep): + """Test setting motion detection enable properly.""" + mock_dis.return_value = "disable" + mock_en.return_value = "enable" + self.assertEqual(self.camera.set_motion_detect(True), "enable") + self.assertEqual(self.camera.set_motion_detect(False), "disable") diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py index 78f3f0f..6dbc04e 100644 --- a/tests/test_sync_module.py +++ b/tests/test_sync_module.py @@ -65,11 +65,27 @@ class TestBlinkSyncModule(unittest.TestCase): mock_resp.return_value = {"event": True} self.assertEqual(self.blink.sync["test"].get_events(), True) + def test_get_events_fail(self, mock_resp): + """Test handling of failed get events function.""" + mock_resp.return_value = None + self.assertFalse(self.blink.sync["test"].get_events()) + mock_resp.return_value = {} + self.assertFalse(self.blink.sync["test"].get_events()) + def test_get_camera_info(self, mock_resp): """Test get camera info function.""" mock_resp.return_value = {"camera": ["foobar"]} self.assertEqual(self.blink.sync["test"].get_camera_info("1234"), "foobar") + def test_get_camera_info_fail(self, mock_resp): + """Test hadnling of failed get camera info function.""" + mock_resp.return_value = None + self.assertEqual(self.blink.sync["test"].get_camera_info("1"), []) + mock_resp.return_value = {} + self.assertEqual(self.blink.sync["test"].get_camera_info("1"), []) + mock_resp.return_value = {"camera": None} + self.assertEqual(self.blink.sync["test"].get_camera_info("1"), []) + def test_check_new_videos_startup(self, mock_resp): """Test that check_new_videos does not block startup.""" sync_module = self.blink.sync["test"] @@ -236,3 +252,8 @@ class TestBlinkSyncModule(unittest.TestCase): self.mock_start[5] = {} self.blink.sync["test"].start() self.assertEqual(self.blink.sync["test"].cameras, {"foo": None}) + + def test_sync_attributes(self, mock_resp): + """Test sync attributes.""" + self.assertEqual(self.blink.sync["test"].attributes["name"], "test") + self.assertEqual(self.blink.sync["test"].attributes["network_id"], "1234") diff --git a/tests/test_util.py b/tests/test_util.py index d515376..e26c112 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -3,7 +3,7 @@ import unittest from unittest import mock import time -from blinkpy.helpers.util import Throttle, time_to_seconds +from blinkpy.helpers.util import json_load, Throttle, time_to_seconds class TestUtil(unittest.TestCase): @@ -107,3 +107,9 @@ class TestUtil(unittest.TestCase): wrong_time = "1/1/1970 00:00:03" self.assertEqual(time_to_seconds(correct_time), 5) self.assertFalse(time_to_seconds(wrong_time)) + + def test_json_load_bad_data(self): + """Check that bad file is handled.""" + self.assertEqual(json_load("fake.file"), None) + with mock.patch("builtins.open", mock.mock_open(read_data="")): + self.assertEqual(json_load("fake.file"), None) From 359d6930b4e78e15c471ff55b4c9911cf765e4e3 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 25 May 2020 13:40:48 +0000 Subject: [PATCH 15/87] Improved availablility handling for sync module --- blinkpy/sync_module.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index f1931f1..4d5fe9b 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -106,7 +106,7 @@ class BlinkSyncModule: "Could not extract some sync module info: %s", response, exc_info=True ) - self.get_network_info() + is_ok = self.get_network_info() self.check_new_videos() try: for camera_config in self.camera_list: @@ -123,6 +123,8 @@ class BlinkSyncModule: ) return False + if not is_ok: + return False self.available = True return True @@ -156,10 +158,13 @@ class BlinkSyncModule: if is_errored: self.available = False + return False + return True def refresh(self, force_cache=False): """Get all blink cameras and pulls their most recent status.""" - self.get_network_info() + if not self.get_network_info(): + return self.check_new_videos() for camera_name in self.cameras.keys(): camera_id = self.cameras[camera_name].camera_id From 2fc3e735c5f253872973567f210828db2cafccb7 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 25 May 2020 13:55:42 +0000 Subject: [PATCH 16/87] Dev version bump --- blinkpy/helpers/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index e42c1e6..e7b623d 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc0" +PATCH_VERSION = "0-rc1" __version__ = "{}.{}.{}".format(MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION) From d8c80c24fc29f92875c195acd57c64de322b5afc Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 26 May 2020 01:47:05 +0000 Subject: [PATCH 17/87] Update string formatting --- blinkpy/api.py | 52 +++++++++++++----------------------- blinkpy/blinkpy.py | 11 ++++---- blinkpy/camera.py | 4 +-- blinkpy/helpers/constants.py | 16 +++++------ 4 files changed, 32 insertions(+), 51 deletions(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index 2df9127..faefa48 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -47,9 +47,7 @@ def request_login( def request_verify(auth, blink, verify_key): """Send verification key to blink servers.""" - url = "{}/api/v4/account/{}/client/{}/pin/verify".format( - blink.urls.base_url, blink.account_id, blink.client_id - ) + url = f"{blink.urls.base_url}/api/v4/account/{blink.account_id}/client/{blink.client_id}/pin/verify" data = dumps({"pin": verify_key}) return auth.query( url=url, headers=auth.header, data=data, json_resp=False, reqtype="post", @@ -58,7 +56,7 @@ def request_verify(auth, blink, verify_key): def request_networks(blink): """Request all networks information.""" - url = "{}/networks".format(blink.urls.base_url) + url = f"{blink.urls.base_url}/networks" return http_get(blink, url) @@ -69,7 +67,7 @@ def request_network_status(blink, network): :param blink: Blink instance. :param network: Sync module network id. """ - url = "{}/network/{}".format(blink.urls.base_url, network) + url = f"{blink.urls.base_url}/network/{network}" return http_get(blink, url) @@ -80,7 +78,7 @@ def request_syncmodule(blink, network): :param blink: Blink instance. :param network: Sync module network id. """ - url = "{}/network/{}/syncmodules".format(blink.urls.base_url, network) + url = f"{blink.urls.base_url}/network/{network}/syncmodules" return http_get(blink, url) @@ -92,7 +90,7 @@ def request_system_arm(blink, network): :param blink: Blink instance. :param network: Sync module network id. """ - url = "{}/network/{}/arm".format(blink.urls.base_url, network) + url = f"{blink.urls.base_url}/network/{network}/arm" return http_post(blink, url) @@ -104,7 +102,7 @@ def request_system_disarm(blink, network): :param blink: Blink instance. :param network: Sync module network id. """ - url = "{}/network/{}/disarm".format(blink.urls.base_url, network) + url = f"{blink.urls.base_url}/network/{network}/disarm" return http_post(blink, url) @@ -116,16 +114,14 @@ def request_command_status(blink, network, command_id): :param network: Sync module network id. :param command_id: Command id to check. """ - url = "{}/network/{}/command/{}".format(blink.urls.base_url, network, command_id) + url = f"{blink.urls.base_url}/network/{network}/command/{command_id}" return http_get(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) def request_homescreen(blink): """Request homescreen info.""" - url = "{}/api/v3/accounts/{}/homescreen".format( - blink.urls.base_url, blink.account_id - ) + url = f"{blink.urls.base_url}/api/v3/accounts/{blink.account_id}/homescreen" return http_get(blink, url) @@ -137,7 +133,7 @@ def request_sync_events(blink, network): :param blink: Blink instance. :param network: Sync module network id. """ - url = "{}/events/network/{}".format(blink.urls.base_url, network) + url = f"{blink.urls.base_url}/events/network/{network}" return http_get(blink, url) @@ -150,9 +146,7 @@ def request_new_image(blink, network, camera_id): :param network: Sync module network id. :param camera_id: Camera ID of camera to request new image from. """ - url = "{}/network/{}/camera/{}/thumbnail".format( - blink.urls.base_url, network, camera_id - ) + url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/thumbnail" return http_post(blink, url) @@ -165,14 +159,14 @@ def request_new_video(blink, network, camera_id): :param network: Sync module network id. :param camera_id: Camera ID of camera to request new video from. """ - url = "{}/network/{}/camera/{}/clip".format(blink.urls.base_url, network, camera_id) + url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/clip" return http_post(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) def request_video_count(blink): """Request total video count.""" - url = "{}/api/v2/videos/count".format(blink.urls.base_url) + url = f"{blink.urls.base_url}/api/v2/videos/count" return http_get(blink, url) @@ -185,9 +179,7 @@ def request_videos(blink, time=None, page=0): :param page: Page number to get videos from. """ timestamp = get_time(time) - url = "{}/api/v1/accounts/{}/media/changed?since={}&page={}".format( - blink.urls.base_url, blink.account_id, timestamp, page - ) + url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/media/changed?since={timestamp}&page={page}" return http_get(blink, url) @@ -198,7 +190,7 @@ def request_cameras(blink, network): :param Blink: Blink instance. :param network: Sync module network id. """ - url = "{}/network/{}/cameras".format(blink.urls.base_url, network) + url = f"{blink.urls.base_url}/network/{network}/cameras" return http_get(blink, url) @@ -210,9 +202,7 @@ def request_camera_info(blink, network, camera_id): :param network: Sync module network id. :param camera_id: Camera ID of camera to request info from. """ - url = "{}/network/{}/camera/{}/config".format( - blink.urls.base_url, network, camera_id - ) + url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/config" return http_get(blink, url) @@ -224,9 +214,7 @@ def request_camera_sensors(blink, network, camera_id): :param network: Sync module network id. :param camera_id: Camera ID of camera to request sesnor info from. """ - url = "{}/network/{}/camera/{}/signals".format( - blink.urls.base_url, network, camera_id - ) + url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/signals" return http_get(blink, url) @@ -239,9 +227,7 @@ def request_motion_detection_enable(blink, network, camera_id): :param network: Sync module network id. :param camera_id: Camera ID of camera to enable. """ - url = "{}/network/{}/camera/{}/enable".format( - blink.urls.base_url, network, camera_id - ) + url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/enable" return http_post(blink, url) @@ -253,9 +239,7 @@ def request_motion_detection_disable(blink, network, camera_id): :param network: Sync module network id. :param camera_id: Camera ID of camera to disable. """ - url = "{}/network/{}/camera/{}/disable".format( - blink.urls.base_url, network, camera_id - ) + url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/disable" return http_post(blink, url) diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index ae4fc73..1d60d1e 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -277,9 +277,9 @@ class Blink: _LOGGER.debug("%s: %s is marked as deleted.", camera_name, address) continue - clip_address = "{}{}".format(self.urls.base_url, address) - filename = "{}-{}".format(camera_name, created_at) - filename = "{}.mp4".format(slugify(filename)) + clip_address = f"{self.urls.base_url}{address}" + filename = f"{camera_name}-{created_at}" + filename = f"{slugify(filename)}.mp4" filename = os.path.join(path, filename) if not debug: @@ -294,8 +294,9 @@ class Blink: _LOGGER.info("Downloaded video to %s", filename) else: print( - ("Camera: {}, Timestamp: {}, " "Address: {}, Filename: {}").format( - camera_name, created_at, address, filename + ( + f"Camera: {camera_name}, Timestamp: {created_at}, " + "Address: {address}, Filename: {filename}" ) ) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index de97c5a..8823d7e 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -127,7 +127,7 @@ class BlinkCamera: ) if thumb_addr is not None: - new_thumbnail = "{}{}.jpg".format(self.sync.urls.base_url, thumb_addr) + new_thumbnail = f"{self.sync.urls.base_url}{thumb_addr}.jpg" try: self.motion_detected = self.sync.motion[self.name] @@ -138,7 +138,7 @@ class BlinkCamera: if self.name in self.sync.last_record: clip_addr = self.sync.last_record[self.name]["clip"] self.last_record = self.sync.last_record[self.name]["time"] - self.clip = "{}{}".format(self.sync.urls.base_url, clip_addr) + self.clip = f"{self.sync.urls.base_url}{clip_addr}" # If the thumbnail or clip have changed, update the cache update_cached_image = False diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index e7b623d..e270c92 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -6,7 +6,7 @@ MAJOR_VERSION = 0 MINOR_VERSION = 16 PATCH_VERSION = "0-rc1" -__version__ = "{}.{}.{}".format(MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION) +__version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 6, 0) @@ -14,7 +14,7 @@ PROJECT_NAME = "blinkpy" PROJECT_PACKAGE_NAME = "blinkpy" PROJECT_LICENSE = "MIT" PROJECT_AUTHOR = "Kevin Fronczak" -PROJECT_COPYRIGHT = " 2017, {}".format(PROJECT_AUTHOR) +PROJECT_COPYRIGHT = f" 2017, {PROJECT_AUTHOR}" PROJECT_URL = "https://github.com/fronzbot/blinkpy" PROJECT_EMAIL = "kfronczak@gmail.com" PROJECT_DESCRIPTION = "A Blink camera Python library " "running on Python 3." @@ -40,19 +40,15 @@ PROJECT_CLASSIFIERS = [ PROJECT_GITHUB_USERNAME = "fronzbot" PROJECT_GITHUB_REPOSITORY = "blinkpy" -PYPI_URL = "https://pypi.python.org/pypi/{}".format(PROJECT_PACKAGE_NAME) +PYPI_URL = f"https://pypi.python.org/pypi/{PROJECT_PACKAGE_NAME}" """ URLS """ BLINK_URL = "immedia-semi.com" -DEFAULT_URL = "{}.{}".format("rest-prod", BLINK_URL) -BASE_URL = "https://{}".format(DEFAULT_URL) -LOGIN_ENDPOINT = "{}/api/v4/account/login".format(BASE_URL) -LOGIN_URLS = [ - "{}/api/v4/account/login".format(BASE_URL), - "{}/api/v3/login".format(BASE_URL), -] +DEFAULT_URL = f"rest-prod.{BLINK_URL}" +BASE_URL = f"https://{DEFAULT_URL}" +LOGIN_ENDPOINT = f"{BASE_URL}/api/v4/account/login" """ Dictionaries From 93949646882760515366d31e13c58d53e5cff049 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 26 May 2020 01:54:04 +0000 Subject: [PATCH 18/87] Update codecov.yml --- codecov.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/codecov.yml b/codecov.yml index abe7a21..26e78d1 100644 --- a/codecov.yml +++ b/codecov.yml @@ -4,10 +4,8 @@ coverage: status: project: default: - threshold: 2% + target: 80 + threshold: 0.02 comment: true require_ci_to_pass: yes -range: 65..90 -round: down -precision: 1 From 672fca6dbfff9de0a43af67e0bb53619e2971bae Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 26 May 2020 20:44:24 +0000 Subject: [PATCH 19/87] Handle json decode error --- blinkpy/auth.py | 10 +++++++++- tests/test_auth.py | 7 ++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 778daf3..2f07048 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -111,12 +111,14 @@ class Auth: if not json_resp: return response - json_data = response.json() try: + json_data = response.json() if json_data["code"] in ERROR.BLINK_ERRORS: raise exceptions.ConnectionError except KeyError: pass + except (AttributeError, ValueError): + raise BlinkBadResponse return json_data @@ -161,6 +163,8 @@ class Auth: ) except (TokenRefreshFailed, LoginError): _LOGGER.error("Endpoint %s failed. Unable to refresh login tokens", url) + except BlinkBadResponse: + _LOGGER.error("Expected json response, but received: %s", response) _LOGGER.error("Endpoint %s failed", url) return None @@ -192,3 +196,7 @@ class TokenRefreshFailed(Exception): class LoginError(Exception): """Class to throw failed login exception.""" + + +class BlinkBadResponse(Exception): + """Class to throw bad json response exception.""" diff --git a/tests/test_auth.py b/tests/test_auth.py index 88f9d7d..8086267 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -3,7 +3,7 @@ import unittest from unittest import mock from requests import exceptions -from blinkpy.auth import Auth, LoginError, TokenRefreshFailed +from blinkpy.auth import Auth, LoginError, TokenRefreshFailed, BlinkBadResponse import blinkpy.helpers.constants as const import tests.mock_responses as mresp @@ -100,6 +100,11 @@ class TestAuth(unittest.TestCase): fake_resp = "foobar" self.assertEqual(self.auth.validate_response(fake_resp, False), "foobar") + def test_response_bad_json(self): + """Check response when not json but expecting json.""" + with self.assertRaises(BlinkBadResponse): + self.auth.validate_response(None, True) + def test_header(self): """Test header data.""" self.auth.token = "bar" From 1e00f657a706bbc8f56ea0df1b36b1479dc04c6f Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 26 May 2020 21:00:15 +0000 Subject: [PATCH 20/87] Increase timeout from 2s to 5s --- blinkpy/api.py | 2 +- tests/test_util.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index faefa48..92b0eab 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -7,7 +7,7 @@ from blinkpy.helpers.constants import DEFAULT_URL _LOGGER = logging.getLogger(__name__) -MIN_THROTTLE_TIME = 2 +MIN_THROTTLE_TIME = 5 def request_login( diff --git a/tests/test_util.py b/tests/test_util.py index e26c112..ee157cd 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -67,6 +67,25 @@ class TestUtil(unittest.TestCase): self.assertEqual(throttled(), True) self.assertEqual(throttled(), None) + def test_throttle_multiple_objects(self): + """Test that function is throttled even if called by multiple objects.""" + + @Throttle(seconds=5) + def test_throttle_method(): + return True + + class Tester: + """A tester class for throttling.""" + + def test(self): + """Test function for throttle.""" + return test_throttle_method() + + tester1 = Tester() + tester2 = Tester() + self.assertEqual(tester1.test(), True) + self.assertEqual(tester2.test(), None) + def test_throttle_on_two_methods(self): """Test that throttle works for multiple methods.""" From 4775bda51c1aea26b780baff6be75c3f5cc18401 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 27 May 2020 21:27:08 +0000 Subject: [PATCH 21/87] Catch all None responses from http requests --- blinkpy/blinkpy.py | 8 ++++---- blinkpy/sync_module.py | 9 +++------ tests/test_blinkpy.py | 6 ++++++ tests/test_sync_module.py | 20 +++++++++++++++++++- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 1d60d1e..671b380 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -152,7 +152,7 @@ class Blink: {"name": camera["name"], "id": camera["id"]} ) return all_cameras - except KeyError: + except (KeyError, TypeError): _LOGGER.error("Unable to retrieve cameras from response %s", response) raise BlinkSetupError @@ -176,7 +176,7 @@ class Blink: response = api.request_networks(self) try: self.networks = response["summary"] - except KeyError: + except (KeyError, TypeError): raise BlinkSetupError def setup_network_ids(self): @@ -250,8 +250,8 @@ class Blink: try: result = response["media"] if not result: - raise IndexError - except (KeyError, IndexError): + raise KeyError + except (KeyError, TypeError): _LOGGER.info("No videos found on page %s. Exiting.", page) break diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 4d5fe9b..1b8d7cb 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -149,14 +149,11 @@ class BlinkSyncModule: def get_network_info(self): """Retrieve network status.""" - is_errored = False self.network_info = api.request_network_status(self.blink, self.network_id) try: - is_errored = self.network_info["network"]["sync_module_error"] - except KeyError: - is_errored = True - - if is_errored: + if self.network_info["network"]["sync_module_error"]: + raise KeyError + except (TypeError, KeyError): self.available = False return False return True diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index 2d980ff..cd7279f 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -106,6 +106,9 @@ class TestBlinkSetup(unittest.TestCase): mock_home.return_value = {} with self.assertRaises(BlinkSetupError): self.blink.setup_camera_list() + mock_home.return_value = None + with self.assertRaises(BlinkSetupError): + self.blink.setup_camera_list() def test_setup_urls(self): """Check setup of URLS.""" @@ -132,6 +135,9 @@ class TestBlinkSetup(unittest.TestCase): mock_networks.return_value = {} with self.assertRaises(BlinkSetupError): self.blink.setup_networks() + mock_networks.return_value = None + with self.assertRaises(BlinkSetupError): + self.blink.setup_networks() @mock.patch("blinkpy.blinkpy.Auth.send_auth_key") def test_setup_prompt_2fa(self, mock_key): diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py index 6dbc04e..baf2bb1 100644 --- a/tests/test_sync_module.py +++ b/tests/test_sync_module.py @@ -78,7 +78,7 @@ class TestBlinkSyncModule(unittest.TestCase): self.assertEqual(self.blink.sync["test"].get_camera_info("1234"), "foobar") def test_get_camera_info_fail(self, mock_resp): - """Test hadnling of failed get camera info function.""" + """Test handling of failed get camera info function.""" mock_resp.return_value = None self.assertEqual(self.blink.sync["test"].get_camera_info("1"), []) mock_resp.return_value = {} @@ -86,6 +86,24 @@ class TestBlinkSyncModule(unittest.TestCase): mock_resp.return_value = {"camera": None} self.assertEqual(self.blink.sync["test"].get_camera_info("1"), []) + def test_get_network_info(self, mock_resp): + """Test network retrieval.""" + mock_resp.return_value = {"network": {"sync_module_error": False}} + self.assertTrue(self.blink.sync["test"].get_network_info()) + mock_resp.return_value = {"network": {"sync_module_error": True}} + self.assertFalse(self.blink.sync["test"].get_network_info()) + + def test_get_network_info_failure(self, mock_resp): + """Test failed network retrieval.""" + mock_resp.return_value = {} + self.blink.sync["test"].available = True + self.assertFalse(self.blink.sync["test"].get_network_info()) + self.assertFalse(self.blink.sync["test"].available) + self.blink.sync["test"].available = True + mock_resp.return_value = None + self.assertFalse(self.blink.sync["test"].get_network_info()) + self.assertFalse(self.blink.sync["test"].available) + def test_check_new_videos_startup(self, mock_resp): """Test that check_new_videos does not block startup.""" sync_module = self.blink.sync["test"] From 00c2c2cc298e56a30c749b16e868db8649ecf155 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 27 May 2020 21:44:07 +0000 Subject: [PATCH 22/87] Add arm property to camera, deprecate motion enable method --- blinkpy/camera.py | 19 +++++++++++++++++++ tests/test_cameras.py | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 8823d7e..269d14c 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -77,12 +77,31 @@ class BlinkCamera: return self._cached_video return None + @property + def arm(self): + """Return arm status of camera.""" + return self.motion_enabled + + @arm.setter + def arm(self, value): + """Set camera arm status.""" + if value: + return api.request_motion_detection_enable( + self.sync.blink, self.network_id, self.camera_id + ) + return api.request_motion_detection_disable( + self.sync.blink, self.network_id, self.camera_id + ) + def snap_picture(self): """Take a picture with camera to create a new thumbnail.""" return api.request_new_image(self.sync.blink, self.network_id, self.camera_id) def set_motion_detect(self, enable): """Set motion detection.""" + _LOGGER.warning( + "Method is deprecated as of v0.16.0 and will be removed in a future version. Please use the BlinkCamera.arm property instead." + ) if enable: return api.request_motion_detection_enable( self.sync.blink, self.network_id, self.camera_id diff --git a/tests/test_cameras.py b/tests/test_cameras.py index 01064bf..33738f3 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -144,6 +144,15 @@ class TestBlinkCameraSetup(unittest.TestCase): self.assertEqual(self.camera.clip, None) self.assertEqual(self.camera.video_from_cache, None) + def test_camera_arm_status(self, mock_resp): + """Test arming and disarming camera.""" + self.camera.motion_enabled = None + self.assertFalse(self.camera.arm) + self.camera.motion_enabled = False + self.assertFalse(self.camera.arm) + self.camera.motion_enabled = True + self.assertTrue(self.camera.arm) + @mock.patch("blinkpy.camera.api.request_motion_detection_enable") @mock.patch("blinkpy.camera.api.request_motion_detection_disable") def test_motion_detection_enable_disable(self, mock_dis, mock_en, mock_rep): From 9e124ce5cc2cd069fa036806c863eea36a4edfe4 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 27 May 2020 23:05:51 +0000 Subject: [PATCH 23/87] Only refresh token on unauthorized response from request --- blinkpy/auth.py | 46 +++++++++++++++++++++++++++------------ blinkpy/blinkpy.py | 1 - blinkpy/helpers/errors.py | 2 +- tests/mock_responses.py | 1 + tests/test_auth.py | 28 ++++++++++++++++++++++-- 5 files changed, 60 insertions(+), 18 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 2f07048..b379406 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -5,7 +5,6 @@ from requests import Request, Session, exceptions from blinkpy import api from blinkpy.helpers import util from blinkpy.helpers.constants import BLINK_URL, LOGIN_ENDPOINT -from blinkpy.helpers import errors as ERROR _LOGGER = logging.getLogger(__name__) @@ -95,7 +94,10 @@ class Auth: self.token = self.login_response["authtoken"]["authtoken"] self.client_id = self.login_response["client"]["id"] self.account_id = self.login_response["account"]["id"] - except KeyError: + except LoginError: + _LOGGER.error("Login endpoint failed. Try again later.") + raise TokenRefreshFailed + except (TypeError, KeyError): _LOGGER.error("Malformed login response: %s", self.login_response) raise TokenRefreshFailed return True @@ -110,11 +112,12 @@ class Auth: """Check for valid response.""" if not json_resp: return response - try: - json_data = response.json() - if json_data["code"] in ERROR.BLINK_ERRORS: + if response.status_code in [101, 401]: + raise UnauthorizedError + if response.status_code == 404: raise exceptions.ConnectionError + json_data = response.json() except KeyError: pass except (AttributeError, ValueError): @@ -141,31 +144,42 @@ class Auth: :param reqtype: Can be 'get' or 'post' (default: 'get') :param stream: Stream response? True/FALSE :param json_resp: Return JSON response? TRUE/False - :param is_retry: Is this a retry attempt? True/FALSE + :param is_retry: Is this part of a re-auth attempt? True/FALSE """ req = self.prepare_request(url, headers, data, reqtype) try: response = self.session.send(req, stream=stream) return self.validate_response(response, json_resp) - - except (exceptions.ConnectionError, exceptions.Timeout, TokenRefreshFailed): + except (exceptions.ConnectionError, exceptions.Timeout): + _LOGGER.error( + "Connection error. Endpoint %s possibly down or throttled. %s: %s", + url, + response.status_code, + response.reason, + ) + except BlinkBadResponse: + _LOGGER.error( + "Expected json response from %s, but received: %s: %s", + url, + response.status_code, + response.reason, + ) + except UnauthorizedError: try: if not is_retry: self.refresh_token() return self.query( url=url, data=data, - headers=headers, + headers=self.header, reqtype=reqtype, stream=stream, json_resp=json_resp, is_retry=True, ) - except (TokenRefreshFailed, LoginError): - _LOGGER.error("Endpoint %s failed. Unable to refresh login tokens", url) - except BlinkBadResponse: - _LOGGER.error("Expected json response, but received: %s", response) - _LOGGER.error("Endpoint %s failed", url) + _LOGGER.error("Unable to access %s after token refresh.", url) + except TokenRefreshFailed: + _LOGGER.error("Unable to refresh token.") return None def send_auth_key(self, blink, key): @@ -200,3 +214,7 @@ class LoginError(Exception): class BlinkBadResponse(Exception): """Class to throw bad json response exception.""" + + +class UnauthorizedError(Exception): + """Class to throw an unauthorized access error.""" diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 671b380..1d6d0f5 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -78,7 +78,6 @@ class Blink: """ if self.check_if_ok_to_update() or force: if not self.available: - self.auth.refresh_token() self.setup_post_verify() for sync_name, sync_module in self.sync.items(): diff --git a/blinkpy/helpers/errors.py b/blinkpy/helpers/errors.py index 3f39086..e701f2c 100644 --- a/blinkpy/helpers/errors.py +++ b/blinkpy/helpers/errors.py @@ -12,4 +12,4 @@ AUTH_TOKEN = ( ) REQUEST = (4, "Cannot perform request (get/post type incorrect)") -BLINK_ERRORS = [101, 400, 404] +BLINK_ERRORS = [400, 404] diff --git a/tests/mock_responses.py b/tests/mock_responses.py index 3981926..a789e80 100644 --- a/tests/mock_responses.py +++ b/tests/mock_responses.py @@ -9,6 +9,7 @@ class MockResponse: self.json_data = json_data self.status_code = status_code self.raw_data = raw_data + self.reason = "foobar" def json(self): """Return json data from get_request.""" diff --git a/tests/test_auth.py b/tests/test_auth.py index 8086267..8ee18ce 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -3,7 +3,13 @@ import unittest from unittest import mock from requests import exceptions -from blinkpy.auth import Auth, LoginError, TokenRefreshFailed, BlinkBadResponse +from blinkpy.auth import ( + Auth, + LoginError, + TokenRefreshFailed, + BlinkBadResponse, + UnauthorizedError, +) import blinkpy.helpers.constants as const import tests.mock_responses as mresp @@ -90,6 +96,10 @@ class TestAuth(unittest.TestCase): with self.assertRaises(exceptions.ConnectionError): self.auth.validate_response(fake_resp, True) + fake_resp = mresp.MockResponse({"code": 101}, 401) + with self.assertRaises(UnauthorizedError): + self.auth.validate_response(fake_resp, True) + def test_good_response_code(self): """Check good response code from server.""" fake_resp = mresp.MockResponse({"foo": "bar"}, 200) @@ -133,6 +143,8 @@ class TestAuth(unittest.TestCase): mock_req.return_value = fake_resp with self.assertRaises(LoginError): self.auth.login() + with self.assertRaises(TokenRefreshFailed): + self.auth.refresh_token() @mock.patch("blinkpy.auth.Auth.login") def test_refresh_token(self, mock_login): @@ -197,10 +209,22 @@ class TestAuth(unittest.TestCase): def test_query_retry(self, mock_refresh, mock_validate): """Check handling of request retry.""" self.auth.session = MockSession() - mock_validate.side_effect = [TokenRefreshFailed, "foobar"] + mock_validate.side_effect = [UnauthorizedError, "foobar"] mock_refresh.return_value = True self.assertEqual(self.auth.query(url="http://example.com"), "foobar") + @mock.patch("blinkpy.auth.Auth.validate_response") + @mock.patch("blinkpy.auth.Auth.refresh_token") + def test_query_retry_failed(self, mock_refresh, mock_validate): + """Check handling of failed retry request.""" + self.auth.seession = MockSession() + mock_validate.side_effect = [UnauthorizedError, BlinkBadResponse] + mock_refresh.return_value = True + self.assertEqual(self.auth.query(url="http://example.com"), None) + + mock_validate.side_effect = [UnauthorizedError, TokenRefreshFailed] + self.assertEqual(self.auth.query(url="http://example.com"), None) + class MockSession: """Object to mock a session.""" From d5ec39113e60ff325b09710df9d9741ce689172d Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 27 May 2020 23:18:06 +0000 Subject: [PATCH 24/87] Add is_errored property to Auth class --- blinkpy/auth.py | 6 ++++++ tests/test_auth.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index b379406..2cf0010 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -32,6 +32,7 @@ class Auth: self.client_id = login_data.get("client_id", None) self.account_id = login_data.get("account_id", None) self.login_response = None + self.is_errored = False self.no_prompt = no_prompt self.session = self.create_session() @@ -86,6 +87,7 @@ class Auth: def refresh_token(self): """Refresh auth token.""" + self.is_errored = True try: _LOGGER.info("Token expired, attempting automatic refresh.") self.login_response = self.login() @@ -94,6 +96,7 @@ class Auth: self.token = self.login_response["authtoken"]["authtoken"] self.client_id = self.login_response["client"]["id"] self.account_id = self.login_response["account"]["id"] + self.is_errored = False except LoginError: _LOGGER.error("Login endpoint failed. Try again later.") raise TokenRefreshFailed @@ -111,7 +114,9 @@ class Auth: def validate_response(self, response, json_resp): """Check for valid response.""" if not json_resp: + self.is_errored = False return response + self.is_errored = True try: if response.status_code in [101, 401]: raise UnauthorizedError @@ -123,6 +128,7 @@ class Auth: except (AttributeError, ValueError): raise BlinkBadResponse + self.is_errored = False return json_data def query( diff --git a/tests/test_auth.py b/tests/test_auth.py index 8ee18ce..e1fae96 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -92,28 +92,38 @@ class TestAuth(unittest.TestCase): def test_bad_response_code(self): """Check bad response code from server.""" + self.auth.is_errored = False fake_resp = mresp.MockResponse({"code": 404}, 404) with self.assertRaises(exceptions.ConnectionError): self.auth.validate_response(fake_resp, True) + self.assertTrue(self.auth.is_errored) + self.auth.is_errored = False fake_resp = mresp.MockResponse({"code": 101}, 401) with self.assertRaises(UnauthorizedError): self.auth.validate_response(fake_resp, True) + self.assertTrue(self.auth.is_errored) def test_good_response_code(self): """Check good response code from server.""" fake_resp = mresp.MockResponse({"foo": "bar"}, 200) + self.auth.is_errored = True self.assertEqual(self.auth.validate_response(fake_resp, True), {"foo": "bar"}) + self.assertFalse(self.auth.is_errored) def test_response_not_json(self): """Check response when not json.""" fake_resp = "foobar" + self.auth.is_errored = True self.assertEqual(self.auth.validate_response(fake_resp, False), "foobar") + self.assertFalse(self.auth.is_errored) def test_response_bad_json(self): """Check response when not json but expecting json.""" + self.auth.is_errored = False with self.assertRaises(BlinkBadResponse): self.auth.validate_response(None, True) + self.assertTrue(self.auth.is_errored) def test_header(self): """Test header data.""" @@ -141,10 +151,12 @@ class TestAuth(unittest.TestCase): """Test login handling when bad response.""" fake_resp = mresp.MockResponse({"foo": "bar"}, 404) mock_req.return_value = fake_resp + self.auth.is_errored = False with self.assertRaises(LoginError): self.auth.login() with self.assertRaises(TokenRefreshFailed): self.auth.refresh_token() + self.assertTrue(self.auth.is_errored) @mock.patch("blinkpy.auth.Auth.login") def test_refresh_token(self, mock_login): @@ -165,8 +177,10 @@ class TestAuth(unittest.TestCase): def test_refresh_token_failed(self, mock_login): """Test refresh token failed.""" mock_login.return_value = {} + self.auth.is_errored = False with self.assertRaises(TokenRefreshFailed): self.auth.refresh_token() + self.assertTrue(self.auth.is_errored) def test_check_key_required(self): """Check key required method.""" From e53439141addce5d5c9b91cd25e89e931d793d4d Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 27 May 2020 23:23:50 +0000 Subject: [PATCH 25/87] Bump dev version --- blinkpy/helpers/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index e270c92..958b141 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc1" +PATCH_VERSION = "0-rc2" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" From 470ab1781327882454efcecb0d34d403795360c3 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 27 May 2020 23:33:02 +0000 Subject: [PATCH 26/87] Dev bump to 0.16.0-rc3 --- blinkpy/helpers/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 958b141..28a437d 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc2" +PATCH_VERSION = "0-rc3" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" From 2abf195391a0f5300dec924fe2efe9e1933e9ed3 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Thu, 28 May 2020 18:33:36 +0000 Subject: [PATCH 27/87] Documentation overhaul --- CHANGES.rst | 69 +++++++++++++------------ CONTRIBUTING.md | 98 ----------------------------------- CONTRIBUTING.rst | 109 +++++++++++++++++++++++++++++++++++++++ README.rst | 58 ++++++++++++++++----- blinkpy/blinkpy.py | 2 +- docs/CHANGES.rst | 1 + docs/CONTRIBUTING.rst | 1 + docs/advanced.rst | 81 +++++++++++++++++++++++++++++ docs/api/blinkpy.rst | 17 ------ docs/api/implemented.rst | 6 --- docs/index.rst | 8 ++- docs/modules/blinkpy.rst | 41 +++++++++++++++ 12 files changed, 323 insertions(+), 168 deletions(-) delete mode 100644 CONTRIBUTING.md create mode 100644 CONTRIBUTING.rst create mode 120000 docs/CHANGES.rst create mode 120000 docs/CONTRIBUTING.rst create mode 100644 docs/advanced.rst delete mode 100644 docs/api/blinkpy.rst delete mode 100644 docs/api/implemented.rst create mode 100644 docs/modules/blinkpy.rst diff --git a/CHANGES.rst b/CHANGES.rst index a31a03c..66c6c37 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,15 @@ +.. toctree:: + :titlesonly: + :maxdepth: 1 + +========= Changelog ------------ +========= A list of changes between each release 0.15.0 (2020-05-08) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Breaking Changes:** - Removed support for Python 3.5 (3.6 is now the minimum supported version) @@ -29,7 +34,7 @@ This can be used by instantiating the Blink class with the ``device_id`` paramet - Fix typos (`#244 `__) 0.14.3 (2020-04-22) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add time check on recorded videos before determining motion - Fix motion detection variable suck to ``True`` - Add ability to load credentials from a json file @@ -37,18 +42,18 @@ This can be used by instantiating the Blink class with the ``device_id`` paramet - Log response message from server if not attempting a re-authorization 0.14.2 (2019-10-12) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Update dependencies - Dockerize `(@3ch01c __)` 0.14.1 (2019-06-20) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix timeout problems blocking blinkpy startup - Updated login urls using ``rest-region`` subdomain - Removed deprecated thumbanil recovery from homescreen 0.14.0 (2019-05-23) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Breaking Changes:** - ``BlinkCamera.battery`` no longer reports a percentage, instead it returns a string representing the state of the battery. @@ -70,11 +75,11 @@ This can be used by instantiating the Blink class with the ``device_id`` paramet 0.13.1 (2019-03-01) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Remove throttle decorator from network status request 0.13.0 (2019-03-01) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Breaking change:** Wifi status reported in dBm again, instead of bars (which is great). Also, the old ``get_camera_info`` method has changed and requires a ``camera_id`` parameter. @@ -99,11 +104,11 @@ Wifi status reported in dBm again, instead of bars (which is great). Also, the 0.12.1 (2019-01-31) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Remove logging improvements since they were incompatible with home-assistant logging 0.12.0 (2019-01-31) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix video api endpoint, re-enables motion detection - Add improved logging capability - Add download video method @@ -111,22 +116,22 @@ Wifi status reported in dBm again, instead of bars (which is great). Also, the 0.11.2 (2019-01-23) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Hotfix to prevent platform from stalling due to API change - Motion detection and video recovery broken until new API endpoint discovered 0.11.1 (2019-01-02) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fixed incorrect backup login url - Added calibrated temperature property for cameras 0.11.0 (2018-11-23) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Added support for multiple sync modules 0.10.3 (2018-11-18) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Use networks endpoint rather than homecreen to retrieve arm/disarm status (`@md-reddevil `__) - Fix incorrect command status endpoint (`@md-reddevil `__) - Add extra debug logging @@ -134,18 +139,18 @@ Wifi status reported in dBm again, instead of bars (which is great). Also, the 0.10.2 (2018-10-30) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Set minimum required version of the requests library to 2.20.0 due to vulnerability in earlier releases. - When multiple networks detected, changed log level to ``warning`` from ``error`` 0.10.1 (2018-10-18) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix re-authorization bug (fixes `#101 `__) - Log an error if saving video that doesn't exist 0.10.0 (2018-10-16) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Moved all API calls to own module for easier maintainability - Added network ids to sync module and cameras to allow for multi-network use - Removed dependency on video existance prior to camera setup (fixes `#93 `__) @@ -157,7 +162,7 @@ Wifi status reported in dBm again, instead of bars (which is great). Also, the - Check if retrieved clip is "None" prior to storing in cache 0.9.0 (2018-09-27) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Complete code refactoring to enable future multi-sync module support - Add image and video caching to the cameras - Add internal throttling of system refresh @@ -168,7 +173,7 @@ Wifi status reported in dBm again, instead of bars (which is great). Also, the 0.8.1 (2018-09-24) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Update requirements_test.txt - Update linter versions - Fix pylint warnings @@ -184,18 +189,18 @@ Wifi status reported in dBm again, instead of bars (which is great). Also, the - Reset the value every system refresh 0.8.0 (2018-05-21) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Added support for battery voltage level (fixes `#64 `__) - Added motion detection per camera - Added fully accessible camera configuration dict - Added celcius property to camera (fixes `#60 `__) 0.7.1 (2018-05-09) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fixed pip 10 import issue during setup (`@fronzbot `__) 0.7.0 (2018-02-08) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fixed style errors for bumped pydocstring and pylint versions - Changed Blink.cameras dictionary to be case-insensitive (fixes `#35 `__) - Changed api endpoint for video extraction (fixes `#35 `__ and `#41 `__) @@ -209,7 +214,7 @@ Wifi status reported in dBm again, instead of bars (which is great). Also, the - Added ``attributes`` dictionary to camera object 0.6.0 (2017-05-12) -^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~ - Removed redundent properties that only called hidden variables - Revised request wrapper function to be more intelligent - Added tests to ensure exceptions are caught and handled (100% coverage!) @@ -217,44 +222,44 @@ Wifi status reported in dBm again, instead of bars (which is great). Also, the - Added battery level string to reduce confusion with the way Blink reports battery level as integer from 0 to 3 0.5.2 (2017-03-12) -^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~ - Fixed packaging mishap, same as 0.5.0 otherwise 0.5.0 (2017-03-12) -^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~ - Fixed region handling problem - Added rest.piri subdomain as a backup if region can't be found - Improved the file writing function - Large test coverage increase 0.4.4 (2017-03-06) -^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~ - Fixed bug where region id was not being set in the header 0.4.3 (2017-03-05) -^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~ - Changed to bdist_wheel release 0.4.2 (2017-01-28) -^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~ - Fixed inability to retrieve motion data due to Key Error 0.4.1 (2017-01-27) -^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~ - Fixed refresh bug (0.3.1 did not actually fix the problem) - Image refresh routine added (per camera) - Dictionary of thumbnails per camera added - Improved test coverage 0.3.1 (2017-01-25) -^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~ - Fixed refresh bug (Key Error) 0.3.0 (2017-01-25) -^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~ - Added device id to camera lookup table - Added image to file method 0.2.0 (2017-01-21) -^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~ - Initial release of blinkpy diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 8c4f50b..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,98 +0,0 @@ -# Contributing to blinkpy - -Everyone is welcome to contribute to blinkpy! The process to get started is described below. - -## Fork the Repository - -You can do this right in github: just click the 'fork' button at the top right. - -## Start Developing - -1. Setup Local Repository - ```shell - $ git clone https://github.com//blinkpy.git - $ cd blinkpy - $ git remote add upstream https://github.com/fronzbot/blinkpy.git - ``` - -2. Create virtualenv and install dependencies - ```shell - $ python -m venv venv - $ source venv/bin/activate - $ pip install -r requirements.txt - $ pip install -r requirements_test.txt - $ pre-commit install - ``` - -3. Create a Local Branch - - First, you will want to create a new branch to hold your changes: - ``git checkout -b `` - - -4. Make changes - - Now you can make changes to your code. It is worthwhile to test your code as you progress (see the **Testing** section) - -5. Commit Your Changes - - To commit changes to your branch, simply add the files you want and the commit them to the branch. After that, you can push to your fork on GitHub: - ```shell - $ git add . - $ git commit -m "Put your commit text here. Please be concise, but descriptive." - $ git push origin HEAD - ``` - -6. Submit your pull request on GitHub - - - On GitHub, navigate to the [blinkpy](https://github.com/fronzbot/blinkpy) repository. - - In the "Branch" menu, choose the branch that contains your commits (from your fork). - - To the right of the Branch menu, click New pull request. - - The base branch dropdown menu should read `dev`. Use the compare branch drop-down menu to choose the branch you made your changes in. - - Type a title and complete the provided description for your pull request. - - Click Create pull request. - - More detailed instructions can be found here: [Creating a Pull Request](https://help.github.com/articles/creating-a-pull-request/) - -7. Prior to merge approval - - Finally, the `blinkpy` repository uses continuous integration tools to run tests prior to merging. If there are any problems, you will see a red 'X' next to your pull request. To see what's wrong, you can find your pull request [here](https://travis- ci.org/fronzbot/blinkpy/pull_requests) and click on the failing test to see the logs. Those logs will indicate, as best as they can, what is causing that test to fail. - - -## Testing - -It is important to test the code to make sure your changes don't break anything major and that they pass PEP8 style conventions. -First, you need to locally install ``tox`` - -```shell -$ pip3 install tox -``` - -You can then run all of the tests with the following command: - -```shell -$ tox -``` - -### Tips - -If you only want to see if you can pass the local tests, you can run `tox -e py35` (or whatever python version you have installed. Only `py35`, `py36`, and `py37` will be accepted). If you just want to check for style violations, you can run `tox -e lint`. Regardless, when you submit a pull request, your code MUST pass both the unit tests, and the linters. - -If you need to change anything in `requirements.txt` for any reason, you'll want to regenerate the virtual envrionments used by `tox` by running with the `-r` flag: `tox -r` - -If you want to run a single test (perhaps you only changed a small thing in one file) you can run `tox -e py35 -- tests/.py -x`. This will run the test `.py` and stop testing upon the first failure, making it easier to figure out why a particular test might be failing. The test structure mimics the library structure, so if you changed something in `sync_module.py`, the associated test file would be in `test_sync_module.py` (ie. the filename is prepended with `test_`. - -# Catching Up With Reality - -If your code is taking a while to develop, you may be behind the ``dev`` branch, in which case you need to catch up before creating your pull-request. To do this you can run ``git rebase`` as follows (running this on your local branch): - -```shell -$ git fetch upstream dev -$ git rebase upstream/dev -``` - -If rebase detects conflicts, repeat the following process until all changes have been resolved: - -1. ``git status`` shows you the filw with a conflict. You will need to edit that file and resolve the lines between ``<<<< | >>>>`. -2. Add the modified file: ``git add `` or ``git add .``. -3. Continue rebase: ``git rebase --continue``. -4. Repeat until all conflicts resolved. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..668c7c2 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,109 @@ +======================== +Contributing to blinkpy +======================== + +Everyone is welcome to contribute to blinkpy! The process to get started is described below. + + +Fork the Repository +------------------- + +You can do this right in github: just click the 'fork' button at the top right. + +Start Developing +----------------- + +1. Setup Local Repository + .. code:: bash + + $ git clone https://github.com//blinkpy.git + $ cd blinkpy + $ git remote add upstream https://github.com/fronzbot/blinkpy.git + +2. Create virtualenv and install dependencies + + .. code:: bash + + $ python -m venv venv + $ source venv/bin/activate + $ pip install -r requirements.txt + $ pip install -r requirements_test.txt + $ pre-commit install + +3. Create a Local Branch + + First, you will want to create a new branch to hold your changes: + ``git checkout -b `` + + +4. Make changes + + Now you can make changes to your code. It is worthwhile to test your code as you progress (see the **Testing** section) + +5. Commit Your Changes + + To commit changes to your branch, simply add the files you want and the commit them to the branch. After that, you can push to your fork on GitHub: + + .. code:: bash + + $ git add . + $ git commit + $ git push origin HEAD + +6. Submit your pull request on GitHub + + - On GitHub, navigate to the `blinkpy `__ repository. + - In the "Branch" menu, choose the branch that contains your commits (from your fork). + - To the right of the Branch menu, click New pull request. + - The base branch dropdown menu should read ``dev``. Use the compare branch drop-down menu to choose the branch you made your changes in. + - Type a title and complete the provided description for your pull request. + - Click Create pull request. + - More detailed instructions can be found here: `Creating a Pull Request` `__ + +7. Prior to merge approval + + Finally, the ``blinkpy`` repository uses continuous integration tools to run tests prior to merging. If there are any problems, you will see a red 'X' next to your pull request. + + +Testing +------- + +It is important to test the code to make sure your changes don't break anything major and that they pass PEP8 style conventions. +First, you need to locally install ``tox`` + +.. code:: bash + + $ pip install tox + + +You can then run all of the tests with the following command: + +.. code:: bash + + $ tox + +**Tips** + +If you only want to see if you can pass the local tests, you can run ``tox -e py37`` (or whatever python version you have installed. Only ``py36``, ``py37``, and ``py38`` will be accepted). If you just want to check for style violations, you can run ``tox -e lint``. Regardless, when you submit a pull request, your code MUST pass both the unit tests, and the linters. + +If you need to change anything in ``requirements.txt`` for any reason, you'll want to regenerate the virtual envrionments used by ``tox`` by running with the ``-r`` flag: ``tox -r`` + +If you want to run a single test (perhaps you only changed a small thing in one file) you can run ``tox -e py37 -- tests/.py -x``. This will run the test ``.py`` and stop testing upon the first failure, making it easier to figure out why a particular test might be failing. The test structure mimics the library structure, so if you changed something in ``sync_module.py``, the associated test file would be in ``test_sync_module.py`` (ie. the filename is prepended with ``test_``. + + +Catching Up With Reality +------------------------- + +If your code is taking a while to develop, you may be behind the ``dev`` branch, in which case you need to catch up before creating your pull-request. To do this you can run ``git rebase`` as follows (running this on your local branch): + +.. code:: bash + + $ git fetch upstream dev + $ git rebase upstream/dev + +If rebase detects conflicts, repeat the following process until all changes have been resolved: + +1. ``git status`` shows you the filw with a conflict. You will need to edit that file and resolve the lines between ``<<<< | >>>>``. +2. Add the modified file: ``git add `` or ``git add .``. +3. Continue rebase: ``git rebase --continue``. +4. Repeat until all conflicts resolved. diff --git a/README.rst b/README.rst index 7e87da5..51bf293 100644 --- a/README.rst +++ b/README.rst @@ -6,8 +6,7 @@ Like the library? Consider buying me a cup of coffee! |Donate| -Disclaimer: -~~~~~~~~~~~~~~~ +**Disclaimer:** Published under the MIT license - See LICENSE file for more details. "Blink Wire-Free HS Home Monitoring & Alert Systems" is a trademark owned by Immedia Inc., see www.blinkforhome.com for more information. @@ -18,11 +17,11 @@ Original protocol hacking by MattTW : https://github.com/MattTW/BlinkMonitorProt API calls faster than 60 seconds is not recommended as it can overwhelm Blink's servers. Please use this module responsibly. Installation -================ -``pip3 install blinkpy`` +------------- +``pip install blinkpy`` Installing Development Version -================================== +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To install the current development version, perform the following steps. Note that the following will create a blinkpy directory in your home area: .. code:: bash @@ -35,12 +34,12 @@ To install the current development version, perform the following steps. Note t $ pip3 install --upgrade dist/*.whl -If you'd like to contribute to this library, please read the `contributing instructions `__. +If you'd like to contribute to this library, please read the `contributing instructions `__. For more information on how to use this library, please `read the docs `__. Purpose -=========== +------- This library was built with the intention of allowing easy communication with Blink camera systems, specifically to support the `Blink component `__ in `homeassistant `__. Quick Start @@ -58,7 +57,7 @@ The simplest way to use this package from a terminal is to call ``Blink.start()` This flow will prompt you for your username and password. Once entered, if you likely will need to send a 2FA key to the blink servers (this pin is sent to your email address). When you receive this pin, enter at the prompt and the Blink library will proceed with setup. Starting blink without a prompt -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +------------------------------- In some cases, having an interactive command-line session is not desired. In this case, you will need to set the ``Blink.auth.no_prompt`` value to ``True``. In addition, since you will not be prompted with a username and password, you must supply the login data to the blink authentication handler. This is best done by instantiating your own auth handler with a dictionary containing at least your username and password. .. code:: python @@ -82,7 +81,7 @@ Since you will not be prompted for any 2FA pin, you must call the ``blink.auth.s Supplying credentials from file -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +-------------------------------- Other use cases may involved loading credentials from a file. This file must be ``json`` formatted and contain a minimum of ``username`` and ``password``. A built in function in the ``blinkpy.helpers.util`` module can aid in loading this file. Note, if ``no_prompt`` is desired, a similar flow can be followed as above. .. code:: python @@ -98,7 +97,7 @@ Other use cases may involved loading credentials from a file. This file must be Saving credentials -~~~~~~~~~~~~~~~~~~~ +------------------- This library also allows you to save your credentials to use in future sessions. Saved information includes authentication tokens as well as unique ids which should allow for a more streamlined experience and limits the frequency of login requests. This data can be saved as follows (it can then be loaded by following the instructions above for supplying credentials from a file): .. code:: python @@ -107,7 +106,7 @@ This library also allows you to save your credentials to use in future sessions. Getting cameras -~~~~~~~~~~~~~~~~ +---------------- Cameras are instantiated as individual ``BlinkCamera`` classes within a ``BlinkSyncModule`` instance. All of your sync modules are stored within the ``Blink.sync`` dictionary and can be accessed using the name of the sync module as the key (this is the name of your sync module in the Blink App). The below code will display cameras and their available attributes: @@ -138,8 +137,43 @@ The ``blinkpy`` api also allows for saving images and videos to a file and snapp camera.image_to_file('/local/path/for/image.jpg') camera.video_to_file('/local/path/for/video.mp4') + +Arming Blink +------------- +Methods exist to arm/disarm the sync module, as well as enable/disable motion detection for individual cameras. This is done as follows: + +.. code:: python + + # Arm a sync module + blink.sync["SYNC MODULE NAME"].arm = True + + # Disarm a sync module + blink.sync["SYNC MODULE NAME"].disarm = False + + # Print arm status of a sync module - a system refresh should be performed first + blink.refresh() + sync = blink.sync["SYNC MODULE NAME"] + print(f"{sync.name} status: {sync.arm}") + +Similar methods exist for individual cameras: + +.. code:: python + + camera = blink.cameras["SOME CAMERA NAME"] + + # Enable motion detection on a camera + camera.arm = True + + # Disable motion detection on a camera + camera.arm = False + + # Print arm status of a sync module - a system refresh should be performed first + blink.refresh() + print(f"{camera.name} status: {camera.arm}") + + Download videos -~~~~~~~~~~~~~~~~ +---------------- You can also use this library to download all videos from the server. In order to do this, you must specify a ``path``. You may also specifiy a how far back in time to go to retrieve videos via the ``since=`` variable (a simple string such as ``"2017/09/21"`` is sufficient), as well as how many pages to traverse via the ``page=`` variable. Note that by default, the library will search the first ten pages which is sufficient in most use cases. Additionally, you can specidy one or more cameras via the ``camera=`` property. This can be a single string indicating the name of the camera, or a list of camera names. By default, it is set to the string ``'all'`` to grab videos from all cameras. Example usage, which downloads all videos recorded since July 4th, 2018 at 9:34am to the ``/home/blink`` directory: diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 1d6d0f5..2a68c1c 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -224,7 +224,7 @@ class Blink: :param path: Path to write files. /path/_.mp4 :param since: Date and time to get videos from. Ex: "2018/07/28 12:33:00" to retrieve videos since - July 28th 2018 at 12:33:00 + July 28th 2018 at 12:33:00 :param camera: Camera name to retrieve. Defaults to "all". Use a list for multiple cameras. :param stop: Page to stop on (~25 items per page. Default page 10). diff --git a/docs/CHANGES.rst b/docs/CHANGES.rst new file mode 120000 index 0000000..9d60ba9 --- /dev/null +++ b/docs/CHANGES.rst @@ -0,0 +1 @@ +../CHANGES.rst \ No newline at end of file diff --git a/docs/CONTRIBUTING.rst b/docs/CONTRIBUTING.rst new file mode 120000 index 0000000..798f2aa --- /dev/null +++ b/docs/CONTRIBUTING.rst @@ -0,0 +1 @@ +../CONTRIBUTING.rst \ No newline at end of file diff --git a/docs/advanced.rst b/docs/advanced.rst new file mode 100644 index 0000000..24fd431 --- /dev/null +++ b/docs/advanced.rst @@ -0,0 +1,81 @@ +======================= +Advanced Library Usage +======================= + +Usage of this library was designed with the `Home Assistant `__ project in mind. With that said, this library is flexible to be used in other scripts where advanced usage not covered in the Quick Start guide may be required. This usage guide will attempt to cover as many use cases as possible. + +Throttling +-------------- +In general, attempting too many requests to the Blink servers will result in your account being throttled. Where possible, adding a delay between calls is ideal. For use cases where this is not an acceptable solution, the ``blinkpy.helpers.util`` module contains a ``Throttle`` class that can be used as a decorator for calls. There are many examples of usage within the ``blinkpy.api`` module. A simple example of usage is covered below, where the decorated method is prevented from executing again until 10s has passed. Note that if the method call is throttled by the decorator, the method will return `None`. + +.. code:: python + + from blinkpy.helpers.util import Throttle + + @Throttle(seconds=10) + def my_method(*args): + """Some method to be throttled.""" + return True + +Custom Sessions +----------------- +By default, the ``blink.auth.Auth`` class creates its own websession via its ``create_session`` method. This is done when the class is initialized and is accessible via the ``Auth.session`` property. To override with a custom websession, the following code can accomplish that: + +.. code:: python + + from blinkpy.blinkpy import Blink + from blinkpy.auth import Auth + + blink = Blink() + blink.auth = Auth() + blink.auth.session = YourCustomSession + +Custom HTTP requests +--------------------- +In addition to custom sessions, custom blink server requests can be performed. This give you the ability to bypass the built-in ``Auth.query`` method. It also allows flexibility by giving you the option to pass your own url, rather than be limited to what is currently implemented in the ``blinkpy.api`` module. + +**Send custom url** +This prepares a standard "GET" request. + +.. code:: python + + from blinkpy.blinkpy import Blink + from blinkpy.auth import Auth + + blink = Blink() + blink.auth = Auth() + url = some_api_endpoint_string + request = blink.auth.prepare_request(url, blink.auth.header, None, "get") + response = blink.auth.session.send(request) + +**Overload query method** +Another option is to create your own ``Auth`` class with a custom ``query`` method to avoid the built-in response checking. This allows you to use the built in ``blinkpy.api`` endpoints, but also gives you flexibility to send your own urls. + +.. code:: python + + from blinkpy.blinkpy import Blink + from blinkpy.auth import Auth + from blinkpy import api + + class CustomAuth(Auth): + def query( + self, + url=None, + data=None, + headers=self.header, + reqtype="get", + stream=False, + json_resp=True, + **kwargs + ): + req = self.prepare_request(url, headers, data, reqtype) + return self.session.send(req, stream=stream) + + blink = blink.Blink() + blink.auth = CustomAuth() + + # Send custom GET query + response = blink.auth.query(url=some_custom_url) + + # Call built-in networks api endpoint + response = api.request_networks(blink) diff --git a/docs/api/blinkpy.rst b/docs/api/blinkpy.rst deleted file mode 100644 index edd9efa..0000000 --- a/docs/api/blinkpy.rst +++ /dev/null @@ -1,17 +0,0 @@ -.. _core_module: - -Blinkpy ----------------------- - -.. automodule:: blinkpy.blinkpy - :members: - -.. automodule:: blinkpy.sync_module - :members: - -.. automodule:: blinkpy.camera - :members: - -.. automodule:: blinkpy.helpers.util - :members: - diff --git a/docs/api/implemented.rst b/docs/api/implemented.rst deleted file mode 100644 index d0ed9b1..0000000 --- a/docs/api/implemented.rst +++ /dev/null @@ -1,6 +0,0 @@ -API Reference ----------------------- - -.. automodule:: blinkpy.api - :members: - diff --git a/docs/index.rst b/docs/index.rst index d31aabe..5095161 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,12 +7,16 @@ Welcome to blinkpy's documentation! =================================== .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Contents: :glob: README - api/* + advanced + CONTRIBUTING + modules/* + CHANGES + Indices and tables ================== diff --git a/docs/modules/blinkpy.rst b/docs/modules/blinkpy.rst new file mode 100644 index 0000000..1dc8d18 --- /dev/null +++ b/docs/modules/blinkpy.rst @@ -0,0 +1,41 @@ +.. _core_module: + +=========================== +Blinkpy Library Reference +=========================== + +blinkpy.py +----------- +.. automodule:: blinkpy.blinkpy + :members: + +auth.py +-------- + +.. automodule:: blinkpy.auth + :members: + +sync_module.py +---------------- + +.. automodule:: blinkpy.sync_module + :members: + +camera.py +----------- + +.. automodule:: blinkpy.camera + :members: + +api.py +--------- + +.. automodule:: blinkpy.api + :members: + +helpers/util.py +---------------- + +.. automodule:: blinkpy.helpers.util + :members: + From cec801c39c4f9f685599b795b7c4ec2d6a7049fa Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Thu, 28 May 2020 18:57:12 +0000 Subject: [PATCH 28/87] Add CONTRIBUTING.rst to lint check --- CHANGES.rst | 4 ---- tox.ini | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 66c6c37..e1759c9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,3 @@ -.. toctree:: - :titlesonly: - :maxdepth: 1 - ========= Changelog ========= diff --git a/tox.ini b/tox.ini index c9b8f42..e82717a 100644 --- a/tox.ini +++ b/tox.ini @@ -41,8 +41,7 @@ commands = flake8 blinkpy tests app pydocstyle blinkpy tests app black --check --diff blinkpy tests app - rst-lint README.rst - rst-lint CHANGES.rst + rst-lint README.rst CHANGES.rst CONTRIBUTING.rst [testenv:build] recreate = True From 8bc4544eeb80aa86af90b145d6e0fbe43e9f8cc1 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Thu, 28 May 2020 18:59:37 +0000 Subject: [PATCH 29/87] Add rst linter to pre-commit and fix flake8 --- .pre-commit-config.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d06a60d..5f3a324 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,10 +8,15 @@ repos: - --quiet files: ^((blinkpy|tests)/.+)?[^/]+\.py$ - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.1 + rev: 3.8.2 hooks: - id: flake8 additional_dependencies: - flake8-docstrings==1.5.0 - pydocstyle==5.0.2 - files: ^(homeassistant|tests)/.+\.py$ + files: ^(blinkpy|tests)/.+\.py$ + - repo: https://github.com/Lucas-C/pre-commit-hooks-markup + rev: v1.0.0 + hooks: + - id: rst-linter + files: /.+\.rst$ From 27cddaac702062e389c5a8df90716f3350177213 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sat, 30 May 2020 16:37:36 +0000 Subject: [PATCH 30/87] Add new endpoint to get user info --- blinkpy/api.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/blinkpy/api.py b/blinkpy/api.py index 92b0eab..8a530c1 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -60,6 +60,12 @@ def request_networks(blink): return http_get(blink, url) +def request_user(blink): + """Get user information from blink servers.""" + url = f"{blink.urls.base_url}/user" + return http_get(blink, url) + + def request_network_status(blink, network): """ Request network information. From 1d9efabfd6c5816a0bffa59abc558ca144125486 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2020 08:21:45 +0000 Subject: [PATCH 31/87] Bump restructuredtext-lint from 1.3.0 to 1.3.1 Bumps [restructuredtext-lint](https://github.com/twolfson/restructuredtext-lint) from 1.3.0 to 1.3.1. - [Release notes](https://github.com/twolfson/restructuredtext-lint/releases) - [Changelog](https://github.com/twolfson/restructuredtext-lint/blob/master/CHANGELOG.rst) - [Commits](https://github.com/twolfson/restructuredtext-lint/compare/1.3.0...1.3.1) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index e4d5b81..3fb136d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,5 +9,5 @@ pytest==5.4.2 pytest-cov==2.9.0 pytest-sugar==0.9.3 pytest-timeout==1.3.4 -restructuredtext-lint==1.3.0 +restructuredtext-lint==1.3.1 pygments==2.6.1 From 020b6d9855e6cc767c49dbc0225b134fa9c2d929 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2020 08:22:21 +0000 Subject: [PATCH 32/87] Bump pytest from 5.4.2 to 5.4.3 Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.4.2 to 5.4.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/5.4.2...5.4.3) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index e4d5b81..7c4028c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ flake8-docstrings==1.5.0 pre-commit==2.4.0 pylint==2.5.2 pydocstyle==5.0.2 -pytest==5.4.2 +pytest==5.4.3 pytest-cov==2.9.0 pytest-sugar==0.9.3 pytest-timeout==1.3.4 From 4d66d8e0c473975d4944f26b38d564a6a1dcdfce Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Fri, 5 Jun 2020 21:54:47 +0000 Subject: [PATCH 33/87] Add more api endpoints --- blinkpy/api.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/blinkpy/api.py b/blinkpy/api.py index 8a530c1..2510158 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -60,6 +60,17 @@ def request_networks(blink): return http_get(blink, url) +def request_network_update(blink, network): + """ + Request network update. + + :param blink: Blink instance. + :param network: Sync module network id. + """ + url = f"{blink.urls.base_url}/network/{network}/update" + return http_post(blink, url) + + def request_user(blink): """Get user information from blink servers.""" url = f"{blink.urls.base_url}/user" @@ -212,6 +223,30 @@ def request_camera_info(blink, network, camera_id): return http_get(blink, url) +def request_camera_usage(blink): + """ + Request camera status. + + :param blink: Blink instance. + """ + url = f"{blink.urls.base_url}/api/v1/camera/usage" + return http_get(blink, url) + + +def request_camera_liveview(blink, network, camera_id): + """ + Request camera liveview. + + :param blink: Blink instance. + :param network: Sync module network id. + :param camera_id: Camera ID of camera to request liveview from. + """ + url = ( + f"{blink.urls.base_url}/api/v3/networks/{network}/cameras/{camera_id}/liveview" + ) + return http_post(blink, url) + + def request_camera_sensors(blink, network, camera_id): """ Request camera sensor info for one camera. From 04552105983d6850fdaa1388af5558047739e282 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Fri, 5 Jun 2020 21:56:11 +0000 Subject: [PATCH 34/87] Changed paypal donate to buy me a coffee link --- README.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 51bf293..25e8221 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ A Python library for the Blink Camera system (Python 3.6+) Like the library? Consider buying me a cup of coffee! -|Donate| +`Buy me a Coffee! `__ **Disclaimer:** Published under the MIT license - See LICENSE file for more details. @@ -191,7 +191,5 @@ Example usage, which downloads all videos recorded since July 4th, 2018 at 9:34a :target: https://pypi.python.org/pypi/blinkpy .. |Docs| image:: https://readthedocs.org/projects/blinkpy/badge/?version=latest :target: http://blinkpy.readthedocs.io/en/latest/?badge=latest -.. |Donate| image:: https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif - :target: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=UR6Z2B8GXYUCC .. |Codestyle| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black From 338a10c8a200a1463f49da814849a56a0ca34ff0 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2020 08:23:41 +0000 Subject: [PATCH 35/87] Bump pylint from 2.5.2 to 2.5.3 Bumps [pylint](https://github.com/PyCQA/pylint) from 2.5.2 to 2.5.3. - [Release notes](https://github.com/PyCQA/pylint/releases) - [Changelog](https://github.com/PyCQA/pylint/blob/master/ChangeLog) - [Commits](https://github.com/PyCQA/pylint/compare/pylint-2.5.2...pylint-2.5.3) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 3fb136d..4d721b0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ coverage==5.1 flake8==3.8.2 flake8-docstrings==1.5.0 pre-commit==2.4.0 -pylint==2.5.2 +pylint==2.5.3 pydocstyle==5.0.2 pytest==5.4.2 pytest-cov==2.9.0 From 816f09b002902433a49480be5064a02cbc5d3a38 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 8 Jun 2020 16:08:19 +0000 Subject: [PATCH 36/87] Update camera retrieval method (no more homescreen use) --- blinkpy/blinkpy.py | 13 +++++++------ tests/test_blinkpy.py | 21 +++++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 2a68c1c..9f98f14 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -141,15 +141,16 @@ class Blink: def setup_camera_list(self): """Create camera list for onboarded networks.""" all_cameras = {} - response = api.request_homescreen(self) + response = api.request_camera_usage(self) try: - for camera in response["cameras"]: - camera_network = str(camera["network_id"]) + for network in response["networks"]: + camera_network = str(network["network_id"]) if camera_network not in all_cameras: all_cameras[camera_network] = [] - all_cameras[camera_network].append( - {"name": camera["name"], "id": camera["id"]} - ) + for camera in network["cameras"]: + all_cameras[camera_network].append( + {"name": camera["name"], "id": camera["id"]} + ) return all_cameras except (KeyError, TypeError): _LOGGER.error("Unable to retrieve cameras from response %s", response) diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index cd7279f..9f551e9 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -81,14 +81,19 @@ class TestBlinkSetup(unittest.TestCase): self.assertEqual(self.blink.sync["TEST"], 1234) self.assertEqual(self.blink.sync["tEsT"], 1234) - @mock.patch("blinkpy.api.request_homescreen") - def test_setup_cameras(self, mock_home): + @mock.patch("blinkpy.api.request_camera_usage") + def test_setup_cameras(self, mock_req): """Check retrieval of camera information.""" - mock_home.return_value = { - "cameras": [ - {"name": "foo", "network_id": 1234, "id": 5678}, - {"name": "bar", "network_id": 1234, "id": 5679}, - {"name": "test", "network_id": 4321, "id": 0000}, + mock_req.return_value = { + "networks": [ + { + "network_id": 1234, + "cameras": [ + {"id": 5678, "name": "foo"}, + {"id": 5679, "name": "bar"}, + ], + }, + {"network_id": 4321, "cameras": [{"id": 0000, "name": "test"}]}, ] } result = self.blink.setup_camera_list() @@ -100,7 +105,7 @@ class TestBlinkSetup(unittest.TestCase): }, ) - @mock.patch("blinkpy.api.request_homescreen") + @mock.patch("blinkpy.api.request_camera_usage") def test_setup_cameras_failure(self, mock_home): """Check that on failure we raise a setup error.""" mock_home.return_value = {} From df88be56e92cb6d98eaabba3ed7306174c21171f Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 8 Jun 2020 16:20:13 +0000 Subject: [PATCH 37/87] Change sync get network info method --- blinkpy/sync_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 1b8d7cb..f109212 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -149,7 +149,7 @@ class BlinkSyncModule: def get_network_info(self): """Retrieve network status.""" - self.network_info = api.request_network_status(self.blink, self.network_id) + self.network_info = api.request_network_update(self.blink, self.network_id) try: if self.network_info["network"]["sync_module_error"]: raise KeyError From fd2e4ed9a42f4ca408ce2e82f8687e4884f97ed7 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 8 Jun 2020 17:05:54 +0000 Subject: [PATCH 38/87] Add get_liveview command to camera module --- blinkpy/camera.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 269d14c..0443dd5 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -178,6 +178,13 @@ class BlinkCamera: self.sync.blink, url=self.clip, stream=True, json=False ) + def get_liveview(self): + """Get livewview rtsps link.""" + response = api.request_camera_liveview( + self.sync.blink, self.sync.network_id, self.camera_id + ) + return response["server"] + def image_to_file(self, path): """ Write image to file. From 2788ab58eac89bb24d8cf1a560bde43c746cc8fc Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 9 Jun 2020 04:03:31 +0000 Subject: [PATCH 39/87] First cut at blink mini support --- blinkpy/blinkpy.py | 19 +++++- blinkpy/camera.py | 26 +++++++-- blinkpy/sync_module.py | 127 +++++++++++++++++++++++++++++++++++------ tests/test_blinkpy.py | 33 +++++++++++ 4 files changed, 181 insertions(+), 24 deletions(-) diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 9f98f14..0aeaee2 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -23,7 +23,7 @@ from dateutil.parser import parse from slugify import slugify from blinkpy import api -from blinkpy.sync_module import BlinkSyncModule +from blinkpy.sync_module import BlinkSyncModule, BlinkOwl from blinkpy.helpers import util from blinkpy.helpers.constants import ( DEFAULT_MOTION_INTERVAL, @@ -68,6 +68,7 @@ class Blink: self.version = __version__ self.available = False self.key_required = False + self.homescreen = {} @util.Throttle(seconds=MIN_THROTTLE_TIME) def refresh(self, force=False): @@ -129,6 +130,8 @@ class Blink: self.setup_sync_module(name, network_id, sync_cameras) self.cameras = self.merge_cameras() + self.setup_owls() + self.available = True self.key_required = False return True @@ -138,6 +141,20 @@ class Blink: self.sync[name] = BlinkSyncModule(self, name, network_id, cameras) self.sync[name].start() + def setup_owls(self): + """Check for mini cameras.""" + response = api.request_homescreen(self) + self.homescreen = response + try: + for owl in response["owls"]: + name = owl["name"] + network_id = owl["network_id"] + if owl["onboarded"]: + self.sync[name] = BlinkOwl(self, name, network_id, owl) + except KeyError: + # No sync-less devices found + pass + def setup_camera_list(self): """Create camera list for onboarded networks.""" all_cameras = {} diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 0443dd5..f999396 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -133,9 +133,10 @@ class BlinkCamera: self.temperature_calibrated = self.temperature _LOGGER.warning("Could not retrieve calibrated temperature.") - # Check if thumbnail exists in config, if not try to - # get it from the homescreen info in the sync module - # otherwise set it to None and log an error + self.update_images(config, force_cache=force_cache) + + def update_images(self, config, force_cache=False): + """Update images for camera.""" new_thumbnail = None thumb_addr = None if config["thumbnail"]: @@ -202,7 +203,8 @@ class BlinkCamera: ) def video_to_file(self, path): - """Write video to file. + """ + Write video to file. :param path: Path to write file """ @@ -213,3 +215,19 @@ class BlinkCamera: return with open(path, "wb") as vidfile: copyfileobj(response.raw, vidfile) + + +class BlinkCameraMini(BlinkCamera): + """Define a class for a Blink Mini camera.""" + + def update(self, config, force_cache=False, **kwargs): + """Update a blink mini camera.""" + self.name = config["name"] + self.camera_id = str(config["id"]) + self.network_id = str(config["network_id"]) + self.serial = config["serial"] + if not self.serial: + self.serial = f"{self.network_id}-{self.camera_id}" + self.motion_enabled = config["enabled"] + + self.update_images(config, force_cache=force_cache) diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index f109212..6f9d6b4 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -4,7 +4,7 @@ import logging from requests.structures import CaseInsensitiveDict from blinkpy import api -from blinkpy.camera import BlinkCamera +from blinkpy.camera import BlinkCamera, BlinkCameraMini from blinkpy.helpers.util import time_to_seconds from blinkpy.helpers.constants import ONLINE @@ -85,16 +85,8 @@ class BlinkSyncModule: def start(self): """Initialize the system.""" - response = api.request_syncmodule(self.blink, self.network_id) - try: - self.summary = response["syncmodule"] - self.network_id = self.summary["network_id"] - except (TypeError, KeyError): - _LOGGER.error( - ("Could not retrieve sync module information " "with response: %s"), - response, - exc_info=True, - ) + response = self.sync_initialize() + if not response: return False try: @@ -108,24 +100,39 @@ class BlinkSyncModule: is_ok = self.get_network_info() self.check_new_videos() + + if not is_ok or not self.update_cameras(): + return False + self.available = True + return True + + def sync_initialize(self): + """Initialize a sync module.""" + response = api.request_syncmodule(self.blink, self.network_id) + try: + self.summary = response["syncmodule"] + self.network_id = self.summary["network_id"] + except (TypeError, KeyError): + _LOGGER.error( + "Could not retrieve sync module information with response: %s", response + ) + return False + return response + + def update_cameras(self, camera_type=BlinkCamera): + """Update cameras from server.""" try: for camera_config in self.camera_list: if "name" not in camera_config: break name = camera_config["name"] - self.cameras[name] = BlinkCamera(self) + self.cameras[name] = camera_type(self) self.motion[name] = False camera_info = self.get_camera_info(camera_config["id"]) self.cameras[name].update(camera_info, force_cache=True, force=True) except KeyError: - _LOGGER.error( - "Could not create cameras instances for %s", self.name, exc_info=True - ) + _LOGGER.error("Could not create camera instances for %s", self.name) return False - - if not is_ok: - return False - self.available = True return True def get_events(self, **kwargs): @@ -205,3 +212,85 @@ class BlinkSyncModule: def check_new_video_time(self, timestamp): """Check if video has timestamp since last refresh.""" return time_to_seconds(timestamp) > self.blink.last_refresh + + +class BlinkOwl(BlinkSyncModule): + """Representation of a sync-less device.""" + + def __init__(self, blink, name, network_id, response): + """Initialize a sync-less object.""" + self._response = response + cameras = [{network_id: {"name": name, "id": response["id"]}}] + super().__init__(blink, name, network_id, cameras) + self.sync_id = response["id"] + self.serial = response["serial"] + if not self.serial: + self.serial = f"{network_id}-{self.sync_id}" + + def sync_initialize(self): + """Initialize a sync-less module.""" + self.summary = { + "id": self.sync_id, + "name": self.name, + "serial": self.serial, + "status": self.status, + "onboarded": True, + "account_id": self.blink.account_id, + "network_id": self.network_id, + } + return self.summary + + def update_cameras(self, camera_type=BlinkCameraMini): + """Update sync-less cameras.""" + super().update_cameras(camera_type=camera_type) + + def get_camera_info(self, camera_id): + """Retrieve camera information.""" + try: + for owl in self.blink.homescreen["owls"]: + if owl["name"] == self.name: + return owl + except KeyError: + pass + return None + + def get_network_info(self): + """Get network info for sync-less module.""" + return True + + @property + def network_info(self): + """Format owl response to resemble sync module.""" + return { + "network": { + "id": self.network_id, + "name": self.name, + "armed": self._response["enabled"], + "sync_module_error": False, + "account_id": self.blink.account_id, + } + } + + @network_info.setter + def network_info(self, value): + """Set network_info property.""" + + @property + def arm(self): + """Return arm status.""" + try: + return self.network_info["network"]["armed"] + except (KeyError, TypeError): + self.available = False + return None + + @arm.setter + def arm(self, value): + """Arm or disarm camera.""" + if value: + return api.request_motion_detection_enable( + self.blink, self.network_id, self.sync_id + ) + return api.request_motion_detection_disable( + self.blink, self.network_id, self.sync_id + ) diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index 9f551e9..847d470 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -9,6 +9,7 @@ any communication related errors at startup. import unittest from unittest import mock from blinkpy.blinkpy import Blink, BlinkSetupError +from blinkpy.sync_module import BlinkOwl from blinkpy.helpers.constants import __version__ @@ -192,6 +193,38 @@ class TestBlinkSetup(unittest.TestCase): self.assertEqual(combined["fizz"], "buzz") self.assertEqual(combined["bar"], "foo") + @mock.patch("blinkpy.api.request_homescreen") + def test_initialize_blink_minis(self, mock_home): + """Test blink mini initialization.""" + mock_home.return_value = { + "owls": [ + { + "enabled": False, + "id": 1, + "name": "foo", + "network_id": 2, + "onboarded": True, + "status": "online", + "thumbnail": "/foo/bar", + "serial": "", + }, + { + "enabled": True, + "id": 3, + "name": "bar", + "network_id": 4, + "onboarded": True, + "status": "online", + "thumbnail": "/foo/bar", + "serial": "", + }, + ] + } + self.blink.sync = {} + self.blink.setup_owls() + self.assertEqual(self.blink.sync["foo"].__class__, BlinkOwl) + self.assertEqual(self.blink.sync["bar"].__class__, BlinkOwl) + class MockSync: """Mock sync module class.""" From be1a5bf92fa2e073f19dc3f77fc325d8259ec94c Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 9 Jun 2020 04:10:47 +0000 Subject: [PATCH 40/87] Update liveview for mini --- blinkpy/camera.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index f999396..ea6f425 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -231,3 +231,13 @@ class BlinkCameraMini(BlinkCamera): self.motion_enabled = config["enabled"] self.update_images(config, force_cache=force_cache) + + def get_liveview(self): + """Get liveview link.""" + url = f"{self.sync.urls.base_url}/api/v1/accounts/{self.sync.blink.account_id}/networks/{self.network_id}/owls/{self.camera_id}/liveview" + response = api.http_post(self.sync.blink, url) + server = response["server"] + server_split = server.split(":") + server_split[0] = "rtsps" + link = "".join(server_split) + return link From 6479423c6c8cc42427b6ac8e5600e8b76765771f Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 9 Jun 2020 04:33:11 +0000 Subject: [PATCH 41/87] Fix test --- tests/test_blinkpy.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index 847d470..c726655 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -161,10 +161,12 @@ class TestBlinkSetup(unittest.TestCase): @mock.patch("blinkpy.blinkpy.Blink.setup_camera_list") @mock.patch("blinkpy.api.request_networks") - def test_setup_post_verify(self, mock_networks, mock_camera): + @mock.patch("blinkpy.blinkpy.Blink.setup_owls") + def test_setup_post_verify(self, mock_owl, mock_networks, mock_camera): """Test setup after verification.""" self.blink.available = False self.blink.key_required = True + mock_owl.return_value = True mock_networks.return_value = { "summary": {"foo": {"onboarded": False, "name": "bar"}} } From 8fcd579a69d34f224fb525b74cf56fc0e8dd68ce Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2020 08:12:37 +0000 Subject: [PATCH 42/87] Bump flake8 from 3.8.2 to 3.8.3 Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.8.2 to 3.8.3. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/compare/3.8.2...3.8.3) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9eea056..f66e92e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ black==19.10b0 coverage==5.1 -flake8==3.8.2 +flake8==3.8.3 flake8-docstrings==1.5.0 pre-commit==2.4.0 pylint==2.5.3 From eb8f0f643702bc09e5aab74d233c342a46549a09 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2020 08:12:59 +0000 Subject: [PATCH 43/87] Bump pre-commit from 2.4.0 to 2.5.0 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.4.0 to 2.5.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.4.0...v2.5.0) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9eea056..78b58db 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ black==19.10b0 coverage==5.1 flake8==3.8.2 flake8-docstrings==1.5.0 -pre-commit==2.4.0 +pre-commit==2.5.0 pylint==2.5.3 pydocstyle==5.0.2 pytest==5.4.3 From 7dd32a0dadec6658e4ace6875578f673ec5ece0c Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 9 Jun 2020 13:30:29 +0000 Subject: [PATCH 44/87] Make sure cameras are properly merged, initilize minis properly --- blinkpy/blinkpy.py | 8 ++++++-- blinkpy/camera.py | 12 +++++------- blinkpy/sync_module.py | 7 ++++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 0aeaee2..6b904f5 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -33,7 +33,6 @@ from blinkpy.helpers.constants import ( from blinkpy.helpers.constants import __version__ from blinkpy.auth import Auth, TokenRefreshFailed, LoginError - _LOGGER = logging.getLogger(__name__) @@ -128,9 +127,9 @@ class Blink: for name, network_id in networks.items(): sync_cameras = cameras.get(network_id, {}) self.setup_sync_module(name, network_id, sync_cameras) - self.cameras = self.merge_cameras() self.setup_owls() + self.cameras = self.merge_cameras() self.available = True self.key_required = False @@ -145,16 +144,21 @@ class Blink: """Check for mini cameras.""" response = api.request_homescreen(self) self.homescreen = response + network_list = [] try: for owl in response["owls"]: name = owl["name"] network_id = owl["network_id"] if owl["onboarded"]: + network_list.append(str(network_id)) self.sync[name] = BlinkOwl(self, name, network_id, owl) + self.sync[name].start() except KeyError: # No sync-less devices found pass + self.network_ids.extend(network_list) + def setup_camera_list(self): """Create camera list for onboarded networks.""" all_cameras = {} diff --git a/blinkpy/camera.py b/blinkpy/camera.py index ea6f425..627abe7 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -222,13 +222,11 @@ class BlinkCameraMini(BlinkCamera): def update(self, config, force_cache=False, **kwargs): """Update a blink mini camera.""" - self.name = config["name"] - self.camera_id = str(config["id"]) - self.network_id = str(config["network_id"]) - self.serial = config["serial"] - if not self.serial: - self.serial = f"{self.network_id}-{self.camera_id}" - self.motion_enabled = config["enabled"] + self.name = config.get("name", "unknown") + self.camera_id = str(config.get("id", "")) + self.network_id = str(config.get("network_id", "")) + self.serial = config.get("serial", f"{self.network_id}-{self.camera_id}") + self.motion_enabled = config.get("enabled", False) self.update_images(config, force_cache=force_cache) diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 6f9d6b4..b33748c 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -219,11 +219,11 @@ class BlinkOwl(BlinkSyncModule): def __init__(self, blink, name, network_id, response): """Initialize a sync-less object.""" - self._response = response cameras = [{network_id: {"name": name, "id": response["id"]}}] super().__init__(blink, name, network_id, cameras) self.sync_id = response["id"] self.serial = response["serial"] + self.status = response["enabled"] if not self.serial: self.serial = f"{network_id}-{self.sync_id}" @@ -242,13 +242,14 @@ class BlinkOwl(BlinkSyncModule): def update_cameras(self, camera_type=BlinkCameraMini): """Update sync-less cameras.""" - super().update_cameras(camera_type=camera_type) + super().update_cameras(camera_type=BlinkCameraMini) def get_camera_info(self, camera_id): """Retrieve camera information.""" try: for owl in self.blink.homescreen["owls"]: if owl["name"] == self.name: + self.status = owl["enabled"] return owl except KeyError: pass @@ -265,7 +266,7 @@ class BlinkOwl(BlinkSyncModule): "network": { "id": self.network_id, "name": self.name, - "armed": self._response["enabled"], + "armed": self.status, "sync_module_error": False, "account_id": self.blink.account_id, } From 4f00aac7a2b0b1662bd1fa74b1fc2c6d96df72e6 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 9 Jun 2020 15:25:57 +0000 Subject: [PATCH 45/87] Fixed missing camera init from owl, cleaned up camera a bit --- blinkpy/camera.py | 47 +++++++++++++++++++-------------------- blinkpy/sync_module.py | 4 ++-- tests/test_blinkpy.py | 12 +++++++--- tests/test_sync_module.py | 21 +++++++++++++++-- 4 files changed, 53 insertions(+), 31 deletions(-) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 627abe7..4f75c11 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -112,18 +112,24 @@ class BlinkCamera: def update(self, config, force_cache=False, **kwargs): """Update camera info.""" - # force = kwargs.pop('force', False) - self.name = config["name"] - self.camera_id = str(config["id"]) - self.network_id = str(config["network_id"]) - self.serial = config["serial"] - self.motion_enabled = config["enabled"] - self.battery_voltage = config["battery_voltage"] - self.battery_state = config["battery_state"] - self.temperature = config["temperature"] - self.wifi_strength = config["wifi_strength"] + self.extract_config_info(config) + self.get_sensor_info() + self.update_images(config, force_cache=force_cache) - # Retrieve calibrated temperature from special endpoint + def extract_config_info(self, config): + """Extract info from config.""" + self.name = config.get("name", "unknown") + self.camera_id = str(config.get("id", "unknown")) + self.network_id = str(config.get("network_id", "unknown")) + self.serial = config.get("serial", None) + self.motion_enabled = config.get("enabled", "unknown") + self.battery_voltage = config.get("battery_voltage", None) + self.battery_state = config.get("battery_state", None) + self.temperature = config.get("temperature", None) + self.wifi_strength = config.get("wifi_strength", None) + + def get_sensor_info(self): + """Retrieve calibrated temperatue from special endpoint.""" resp = api.request_camera_sensors( self.sync.blink, self.network_id, self.camera_id ) @@ -133,13 +139,11 @@ class BlinkCamera: self.temperature_calibrated = self.temperature _LOGGER.warning("Could not retrieve calibrated temperature.") - self.update_images(config, force_cache=force_cache) - def update_images(self, config, force_cache=False): """Update images for camera.""" new_thumbnail = None thumb_addr = None - if config["thumbnail"]: + if config.get("thumbnail", False): thumb_addr = config["thumbnail"] else: _LOGGER.warning( @@ -155,10 +159,12 @@ class BlinkCamera: self.motion_detected = False clip_addr = None - if self.name in self.sync.last_record: + try: clip_addr = self.sync.last_record[self.name]["clip"] self.last_record = self.sync.last_record[self.name]["time"] self.clip = f"{self.sync.urls.base_url}{clip_addr}" + except KeyError: + pass # If the thumbnail or clip have changed, update the cache update_cached_image = False @@ -220,15 +226,8 @@ class BlinkCamera: class BlinkCameraMini(BlinkCamera): """Define a class for a Blink Mini camera.""" - def update(self, config, force_cache=False, **kwargs): - """Update a blink mini camera.""" - self.name = config.get("name", "unknown") - self.camera_id = str(config.get("id", "")) - self.network_id = str(config.get("network_id", "")) - self.serial = config.get("serial", f"{self.network_id}-{self.camera_id}") - self.motion_enabled = config.get("enabled", False) - - self.update_images(config, force_cache=force_cache) + def get_sensor_info(self): + """Get sensor info for blink mini camera.""" def get_liveview(self): """Get liveview link.""" diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index b33748c..d2a0906 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -219,7 +219,7 @@ class BlinkOwl(BlinkSyncModule): def __init__(self, blink, name, network_id, response): """Initialize a sync-less object.""" - cameras = [{network_id: {"name": name, "id": response["id"]}}] + cameras = [{"name": name, "id": response["id"]}] super().__init__(blink, name, network_id, cameras) self.sync_id = response["id"] self.serial = response["serial"] @@ -242,7 +242,7 @@ class BlinkOwl(BlinkSyncModule): def update_cameras(self, camera_type=BlinkCameraMini): """Update sync-less cameras.""" - super().update_cameras(camera_type=BlinkCameraMini) + return super().update_cameras(camera_type=BlinkCameraMini) def get_camera_info(self, camera_id): """Retrieve camera information.""" diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index c726655..bd93720 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -196,8 +196,10 @@ class TestBlinkSetup(unittest.TestCase): self.assertEqual(combined["bar"], "foo") @mock.patch("blinkpy.api.request_homescreen") - def test_initialize_blink_minis(self, mock_home): + @mock.patch("blinkpy.blinkpy.BlinkOwl.start") + def test_initialize_blink_minis(self, mock_start, mock_home): """Test blink mini initialization.""" + mock_start.return_value = True mock_home.return_value = { "owls": [ { @@ -208,7 +210,7 @@ class TestBlinkSetup(unittest.TestCase): "onboarded": True, "status": "online", "thumbnail": "/foo/bar", - "serial": "", + "serial": "1234", }, { "enabled": True, @@ -218,7 +220,7 @@ class TestBlinkSetup(unittest.TestCase): "onboarded": True, "status": "online", "thumbnail": "/foo/bar", - "serial": "", + "serial": "abcd", }, ] } @@ -226,6 +228,10 @@ class TestBlinkSetup(unittest.TestCase): self.blink.setup_owls() self.assertEqual(self.blink.sync["foo"].__class__, BlinkOwl) self.assertEqual(self.blink.sync["bar"].__class__, BlinkOwl) + self.assertEqual(self.blink.sync["foo"].arm, False) + self.assertEqual(self.blink.sync["bar"].arm, True) + self.assertEqual(self.blink.sync["foo"].name, "foo") + self.assertEqual(self.blink.sync["bar"].name, "bar") class MockSync: diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py index baf2bb1..211cdb4 100644 --- a/tests/test_sync_module.py +++ b/tests/test_sync_module.py @@ -4,8 +4,8 @@ from unittest import mock from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler -from blinkpy.sync_module import BlinkSyncModule -from blinkpy.camera import BlinkCamera +from blinkpy.sync_module import BlinkSyncModule, BlinkOwl +from blinkpy.camera import BlinkCamera, BlinkCameraMini @mock.patch("blinkpy.auth.Auth.query") @@ -275,3 +275,20 @@ class TestBlinkSyncModule(unittest.TestCase): """Test sync attributes.""" self.assertEqual(self.blink.sync["test"].attributes["name"], "test") self.assertEqual(self.blink.sync["test"].attributes["network_id"], "1234") + + def test_owl_start(self, mock_resp): + """Test owl camera instantiation.""" + response = { + "name": "foo", + "id": 2, + "serial": "foobar123", + "enabled": True, + "network_id": 1, + "thumbnail": "/foo/bar", + } + self.blink.last_refresh = None + self.blink.homescreen = {"owls": [response]} + owl = BlinkOwl(self.blink, "foo", 1234, response) + self.assertTrue(owl.start()) + self.assertTrue("foo" in owl.cameras) + self.assertEqual(owl.cameras["foo"].__class__, BlinkCameraMini) From b8da6e219753ad8c82fab8477b6f42f5eff0ac2f Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 9 Jun 2020 16:53:33 +0000 Subject: [PATCH 46/87] Add unsupported notifications, updated Mini thumbnail endpoint --- blinkpy/camera.py | 15 +++++++++++++++ blinkpy/sync_module.py | 5 +---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 4f75c11..4372e1d 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -226,6 +226,21 @@ class BlinkCamera: class BlinkCameraMini(BlinkCamera): """Define a class for a Blink Mini camera.""" + @property + def arm(self): + """Return camera arm status.""" + return self.sync.arm + + @arm.setter + def arm(self, value): + """Set camera arm status.""" + self.sync.arm = value + + def snap_picture(self): + """Snap picture for a blink mini camera.""" + url = f"{self.sync.urls.base_url}/api/v1/accounts/{self.sync.blink.account_id}/networks/{self.network_id}/owls/{self.camera_id}/thumbnail" + return api.http_post(self.sync.blink, url) + def get_sensor_info(self): """Get sensor info for blink mini camera.""" diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index d2a0906..77c1719 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -78,10 +78,7 @@ class BlinkSyncModule: @arm.setter def arm(self, value): """Arm or disarm system.""" - if value: - return api.request_system_arm(self.blink, self.network_id) - - return api.request_system_disarm(self.blink, self.network_id) + _LOGGER.warning("Arm/Disarm API for %s not currently implemented.", self.name) def start(self): """Initialize the system.""" From 0758fa66d3b2e69ed93e4007ec4d1e98fcd03b93 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 9 Jun 2020 17:50:31 +0000 Subject: [PATCH 47/87] Dev version bump --- blinkpy/helpers/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 28a437d..eac975f 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc3" +PATCH_VERSION = "0-rc4" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" From db072b9dea3e298e6c23f6252126c20dbb8002e1 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2020 08:27:36 +0000 Subject: [PATCH 48/87] Bump pre-commit from 2.5.0 to 2.5.1 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.5.0 to 2.5.1. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.5.0...v2.5.1) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index e1cd333..72fe2d3 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ black==19.10b0 coverage==5.1 flake8==3.8.3 flake8-docstrings==1.5.0 -pre-commit==2.5.0 +pre-commit==2.5.1 pylint==2.5.3 pydocstyle==5.0.2 pytest==5.4.3 From b73d122b15c48df6507f7b9b9b15dfbac7bfdf31 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 10 Jun 2020 14:06:42 +0000 Subject: [PATCH 49/87] Fixed missing sync module arm method --- blinkpy/sync_module.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 77c1719..7d943e7 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -77,8 +77,10 @@ class BlinkSyncModule: @arm.setter def arm(self, value): - """Arm or disarm system.""" - _LOGGER.warning("Arm/Disarm API for %s not currently implemented.", self.name) + """Arm or disarm camera.""" + if value: + return api.request_system_arm(self.blink, self.network_id) + return api.request_system_disarm(self.blink, self.network_id) def start(self): """Initialize the system.""" @@ -285,10 +287,4 @@ class BlinkOwl(BlinkSyncModule): @arm.setter def arm(self, value): """Arm or disarm camera.""" - if value: - return api.request_motion_detection_enable( - self.blink, self.network_id, self.sync_id - ) - return api.request_motion_detection_disable( - self.blink, self.network_id, self.sync_id - ) + _LOGGER.warning("Arm/Disarm API for %s not currently implemented.", self.name) From eb7d7d960141c155025191d39fab43ef021fcd02 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 10 Jun 2020 14:13:42 +0000 Subject: [PATCH 50/87] Add blink mini arm/disarm support - Updates endpoint for arm/disarm so it's consistent across device types --- blinkpy/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index 2510158..20d1350 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -107,7 +107,7 @@ def request_system_arm(blink, network): :param blink: Blink instance. :param network: Sync module network id. """ - url = f"{blink.urls.base_url}/network/{network}/arm" + url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/networks/{network}/state/arm" return http_post(blink, url) @@ -119,7 +119,7 @@ def request_system_disarm(blink, network): :param blink: Blink instance. :param network: Sync module network id. """ - url = f"{blink.urls.base_url}/network/{network}/disarm" + url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/networks/{network}/state/disarm" return http_post(blink, url) From e51740ca896b59bc130ac75a3f171789bcbf73bf Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 10 Jun 2020 14:16:16 +0000 Subject: [PATCH 51/87] Removed blink mini arm overload --- blinkpy/sync_module.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 7d943e7..0693f87 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -274,17 +274,3 @@ class BlinkOwl(BlinkSyncModule): @network_info.setter def network_info(self, value): """Set network_info property.""" - - @property - def arm(self): - """Return arm status.""" - try: - return self.network_info["network"]["armed"] - except (KeyError, TypeError): - self.available = False - return None - - @arm.setter - def arm(self, value): - """Arm or disarm camera.""" - _LOGGER.warning("Arm/Disarm API for %s not currently implemented.", self.name) From 08632ec9ceab3c2c7ababbcf5589d510dce9af33 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 10 Jun 2020 14:58:28 +0000 Subject: [PATCH 52/87] Add type check on temperature conversion --- blinkpy/camera.py | 5 ++++- tests/test_cameras.py | 20 +++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 4372e1d..b065037 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -61,7 +61,10 @@ class BlinkCamera: @property def temperature_c(self): """Return temperature in celcius.""" - return round((self.temperature - 32) / 9.0 * 5.0, 1) + try: + return round((self.temperature - 32) / 9.0 * 5.0, 1) + except TypeError: + return None @property def image_from_cache(self): diff --git a/tests/test_cameras.py b/tests/test_cameras.py index 33738f3..94c9dc2 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -11,7 +11,7 @@ from unittest import mock from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler from blinkpy.sync_module import BlinkSyncModule -from blinkpy.camera import BlinkCamera +from blinkpy.camera import BlinkCamera, BlinkCameraMini CAMERA_CFG = { @@ -161,3 +161,21 @@ class TestBlinkCameraSetup(unittest.TestCase): mock_en.return_value = "enable" self.assertEqual(self.camera.set_motion_detect(True), "enable") self.assertEqual(self.camera.set_motion_detect(False), "disable") + + def test_missing_attributes(self, mock_resp): + """Test that attributes return None if missing.""" + self.camera.temperature = None + self.camera.serial = None + attr = self.camera.attributes + self.assertEqual(attr["serial"], None) + self.assertEqual(attr["temperature"], None) + self.assertEqual(attr["temperature_c"], None) + + def test_mini_missing_attributes(self, mock_resp): + """Test that attributes return None if missing.""" + camera = BlinkCameraMini(self.blink.sync) + self.blink.sync.network_id = None + self.blink.sync.name = None + attr = camera.attributes + for key in attr: + self.assertEqual(attr[key], None) From 5d86e0ac9a8f53bb3c0f79647d0b3830f63aa54c Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 10 Jun 2020 15:10:17 +0000 Subject: [PATCH 53/87] Dev version bump --- blinkpy/helpers/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index eac975f..b75883c 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc4" +PATCH_VERSION = "0-rc5" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" From eeaa9cdf6f550a9e7dc203d15d1a481ef421cca7 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Thu, 11 Jun 2020 23:05:37 +0000 Subject: [PATCH 54/87] Prevent sync module creation for minis already attached to one --- blinkpy/blinkpy.py | 14 +++++++++-- blinkpy/camera.py | 10 +++++++- blinkpy/sync_module.py | 28 ++++++++++++++++++---- tests/test_blinkpy.py | 53 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 97 insertions(+), 8 deletions(-) diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 6b904f5..b3b0524 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -128,7 +128,6 @@ class Blink: sync_cameras = cameras.get(network_id, {}) self.setup_sync_module(name, network_id, sync_cameras) - self.setup_owls() self.cameras = self.merge_cameras() self.available = True @@ -145,10 +144,16 @@ class Blink: response = api.request_homescreen(self) self.homescreen = response network_list = [] + camera_list = [] try: for owl in response["owls"]: name = owl["name"] - network_id = owl["network_id"] + network_id = str(owl["network_id"]) + if network_id in self.network_ids: + camera_list.append( + {network_id: {"name": name, "id": network_id, "type": "mini"}} + ) + continue if owl["onboarded"]: network_list.append(str(network_id)) self.sync[name] = BlinkOwl(self, name, network_id, owl) @@ -158,6 +163,7 @@ class Blink: pass self.network_ids.extend(network_list) + return camera_list def setup_camera_list(self): """Create camera list for onboarded networks.""" @@ -172,6 +178,10 @@ class Blink: all_cameras[camera_network].append( {"name": camera["name"], "id": camera["id"]} ) + mini_cameras = self.setup_owls() + for camera in mini_cameras: + for network, camera_info in camera.items(): + all_cameras[network].append(camera_info) return all_cameras except (KeyError, TypeError): _LOGGER.error("Unable to retrieve cameras from response %s", response) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index b065037..dc5ea20 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -29,6 +29,7 @@ class BlinkCamera: self.last_record = None self._cached_image = None self._cached_video = None + self.camera_type = "" @property def attributes(self): @@ -229,6 +230,11 @@ class BlinkCamera: class BlinkCameraMini(BlinkCamera): """Define a class for a Blink Mini camera.""" + def __init__(self, sync): + """Initialize a Blink Mini cameras.""" + super().__init__(sync) + self.camera_type = "mini" + @property def arm(self): """Return camera arm status.""" @@ -237,7 +243,9 @@ class BlinkCameraMini(BlinkCamera): @arm.setter def arm(self, value): """Set camera arm status.""" - self.sync.arm = value + _LOGGER.warning( + "Individual camera motion detection enable/disable for Blink Mini cameras is unsupported at this time." + ) def snap_picture(self): """Snap picture for a blink mini camera.""" diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 0693f87..07a7c14 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -124,16 +124,33 @@ class BlinkSyncModule: for camera_config in self.camera_list: if "name" not in camera_config: break + blink_camera_type = camera_config.get("type", "") name = camera_config["name"] - self.cameras[name] = camera_type(self) self.motion[name] = False - camera_info = self.get_camera_info(camera_config["id"]) + owl_info = self.get_owl_info(name) + if blink_camera_type == "mini": + camera_type = BlinkCameraMini + self.cameras[name] = camera_type(self) + camera_info = self.get_camera_info( + camera_config["id"], owl_info=owl_info + ) self.cameras[name].update(camera_info, force_cache=True, force=True) + except KeyError: _LOGGER.error("Could not create camera instances for %s", self.name) return False return True + def get_owl_info(self, name): + """Extract owl information.""" + try: + for owl in self.blink.homescreen["owls"]: + if owl["name"] == name: + return owl + except KeyError: + pass + return None + def get_events(self, **kwargs): """Retrieve events from server.""" force = kwargs.pop("force", False) @@ -144,8 +161,11 @@ class BlinkSyncModule: _LOGGER.error("Could not extract events: %s", response, exc_info=True) return False - def get_camera_info(self, camera_id): + def get_camera_info(self, camera_id, **kwargs): """Retrieve camera information.""" + owl = kwargs.get("owl_info", None) + if owl is not None: + return owl response = api.request_camera_info(self.blink, self.network_id, camera_id) try: return response["camera"][0] @@ -243,7 +263,7 @@ class BlinkOwl(BlinkSyncModule): """Update sync-less cameras.""" return super().update_cameras(camera_type=BlinkCameraMini) - def get_camera_info(self, camera_id): + def get_camera_info(self, camera_id, **kwargs): """Retrieve camera information.""" try: for owl in self.blink.homescreen["owls"]: diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index bd93720..a7e4ed3 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -83,8 +83,10 @@ class TestBlinkSetup(unittest.TestCase): self.assertEqual(self.blink.sync["tEsT"], 1234) @mock.patch("blinkpy.api.request_camera_usage") - def test_setup_cameras(self, mock_req): + @mock.patch("blinkpy.api.request_homescreen") + def test_setup_cameras(self, mock_home, mock_req): """Check retrieval of camera information.""" + mock_home.return_value = {} mock_req.return_value = { "networks": [ { @@ -233,6 +235,55 @@ class TestBlinkSetup(unittest.TestCase): self.assertEqual(self.blink.sync["foo"].name, "foo") self.assertEqual(self.blink.sync["bar"].name, "bar") + @mock.patch("blinkpy.api.request_homescreen") + def test_blink_mini_cameras_returned(self, mock_home): + """Test that blink mini cameras are found if attached to sync module.""" + self.blink.network_ids = ["1234"] + mock_home.return_value = { + "owls": [ + { + "id": 1, + "name": "foo", + "network_id": 1234, + "onboarded": True, + "enabled": True, + "status": "online", + "thumbnail": "/foo/bar", + "serial": "abc123", + } + ] + } + result = self.blink.setup_owls() + self.assertEqual(self.blink.network_ids, ["1234"]) + self.assertEqual( + result, [{"1234": {"name": "foo", "id": "1234", "type": "mini"}}] + ) + + @mock.patch("blinkpy.api.request_homescreen") + @mock.patch("blinkpy.api.request_camera_usage") + def test_blink_mini_attached_to_sync(self, mock_usage, mock_home): + """Test that blink mini cameras are properly attached to sync module.""" + self.blink.network_ids = ["1234"] + mock_home.return_value = { + "owls": [ + { + "id": 1, + "name": "foo", + "network_id": 1234, + "onboarded": True, + "enabled": True, + "status": "online", + "thumbnail": "/foo/bar", + "serial": "abc123", + } + ] + } + mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]} + result = self.blink.setup_camera_list() + self.assertEqual( + result, {"1234": [{"name": "foo", "id": "1234", "type": "mini"}]} + ) + class MockSync: """Mock sync module class.""" From 695dc963405be179c40f6447707f391157e1a1c0 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Thu, 11 Jun 2020 22:54:46 -0400 Subject: [PATCH 55/87] Update codecov.yml --- codecov.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/codecov.yml b/codecov.yml index 26e78d1..42f2739 100644 --- a/codecov.yml +++ b/codecov.yml @@ -4,8 +4,12 @@ coverage: status: project: default: - target: 80 - threshold: 0.02 + target: 80% + threshold: 2% + patch: + default: + target: 60% + threshold: 5% comment: true require_ci_to_pass: yes From d9e3761c7b10854d3ea9157e4f3e25f201881522 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sun, 14 Jun 2020 02:03:22 +0000 Subject: [PATCH 56/87] Add option to skip homescreen check --- blinkpy/blinkpy.py | 10 +++++++++- tests/test_blinkpy.py | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index b3b0524..2897e54 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -40,7 +40,10 @@ class Blink: """Class to initialize communication.""" def __init__( - self, refresh_rate=DEFAULT_REFRESH, motion_interval=DEFAULT_MOTION_INTERVAL, + self, + refresh_rate=DEFAULT_REFRESH, + motion_interval=DEFAULT_MOTION_INTERVAL, + no_owls=False, ): """ Initialize Blink system. @@ -51,6 +54,7 @@ class Blink: Defaults to last refresh time. Useful for preventing motion_detected property from de-asserting too quickly. + :param no_owls: Disable searching for owl entries (blink mini cameras only known entity). Prevents an uneccessary API call if you don't have these in your network. """ self.auth = Auth() self.account_id = None @@ -68,6 +72,7 @@ class Blink: self.available = False self.key_required = False self.homescreen = {} + self.no_owls = no_owls @util.Throttle(seconds=MIN_THROTTLE_TIME) def refresh(self, force=False): @@ -141,6 +146,9 @@ class Blink: def setup_owls(self): """Check for mini cameras.""" + if self.no_owls: + _LOGGER.debug("Skipping owl extraction.") + return [] response = api.request_homescreen(self) self.homescreen = response network_list = [] diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index a7e4ed3..98a2015 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -259,6 +259,12 @@ class TestBlinkSetup(unittest.TestCase): result, [{"1234": {"name": "foo", "id": "1234", "type": "mini"}}] ) + self.blink.no_owls = True + self.blink.network_ids = [] + result = self.blink.setup_owls() + self.assertEqual(self.blink.network_ids, []) + self.assertEqual(result, []) + @mock.patch("blinkpy.api.request_homescreen") @mock.patch("blinkpy.api.request_camera_usage") def test_blink_mini_attached_to_sync(self, mock_usage, mock_home): From d416e429253554029d5dd5c5fb645e4926f9b245 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sun, 14 Jun 2020 02:12:43 +0000 Subject: [PATCH 57/87] Dev version bump --- blinkpy/helpers/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index b75883c..0c556d2 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc5" +PATCH_VERSION = "0-rc6" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" From 3314ff85edb608e5517c13d7adb9acaa3007191c Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sun, 14 Jun 2020 02:36:57 +0000 Subject: [PATCH 58/87] Updated docke app to be compatible with 0.16.0 changes --- app/app.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/app/app.py b/app/app.py index 11517a1..081bcdf 100644 --- a/app/app.py +++ b/app/app.py @@ -1,11 +1,12 @@ """Script to run blinkpy as an app.""" from os import environ from datetime import datetime, timedelta -from blinkpy import blinkpy +from blinkpy.blinkpy import Blink +from blinkpy.auth import Auth +from blinkpy.helpers.util import json_load -USERNAME = environ.get("USERNAME") -PASSWORD = environ.get("PASSWORD") +CREDFILE = environ.get("CREDFILE") TIMEDELTA = timedelta(environ.get("TIMEDELTA", 1)) @@ -21,11 +22,18 @@ def download_videos(blink, save_dir="/media"): def start(): """Startup blink app.""" - blink = blinkpy.Blink(username=USERNAME, password=PASSWORD) + blink = Blink() + blink.auth = Auth(json_load(CREDFILE)) blink.start() return blink +def main(): + """Run the app.""" + blink = start() + download_videos(blink) + blink.save(CREDFILE) + + if __name__ == "__main__": - BLINK = start() - download_videos(BLINK) + main() From 3805a41d9f313a6c151c146b9722aecc102c0aab Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sun, 14 Jun 2020 13:43:58 +0000 Subject: [PATCH 59/87] Removed 'reauth' key from login payload --- blinkpy/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index 20d1350..00474a2 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -32,7 +32,7 @@ def request_login( "client_name": "Computer", "client_type": "android", "os_version": "5.1.1", - "reauth": "true", + "reauth": "false", } ) return auth.query( From 3cf2b5e093e06674b0d9552309d0e615e90e9bf8 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sun, 14 Jun 2020 10:49:17 -0400 Subject: [PATCH 60/87] dev bump --- blinkpy/helpers/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 0c556d2..a5c1ec0 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc6" +PATCH_VERSION = "0-rc7" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" From 2abfc3e843c81cfb5359b01c39019ed8398aa98f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2020 08:34:05 +0000 Subject: [PATCH 61/87] Bump pytest-timeout from 1.3.4 to 1.4.0 Bumps [pytest-timeout](https://github.com/pytest-dev/pytest-timeout) from 1.3.4 to 1.4.0. - [Release notes](https://github.com/pytest-dev/pytest-timeout/releases) - [Commits](https://github.com/pytest-dev/pytest-timeout/compare/1.3.4...1.4.0) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 72fe2d3..1caa6d0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,6 +8,6 @@ pydocstyle==5.0.2 pytest==5.4.3 pytest-cov==2.9.0 pytest-sugar==0.9.3 -pytest-timeout==1.3.4 +pytest-timeout==1.4.0 restructuredtext-lint==1.3.1 pygments==2.6.1 From 01679004158a7048b79c582856d8ad2dca5546ec Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2020 14:34:40 +0000 Subject: [PATCH 62/87] Bump pytest-cov from 2.9.0 to 2.10.0 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.9.0 to 2.10.0. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.9.0...v2.10.0) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 1caa6d0..ccdbd22 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,7 +6,7 @@ pre-commit==2.5.1 pylint==2.5.3 pydocstyle==5.0.2 pytest==5.4.3 -pytest-cov==2.9.0 +pytest-cov==2.10.0 pytest-sugar==0.9.3 pytest-timeout==1.4.0 restructuredtext-lint==1.3.1 From 815dfd9a8ec3775bb0234a0dc8c043f23f804ab6 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2020 07:58:09 +0000 Subject: [PATCH 63/87] Bump pytest-timeout from 1.4.0 to 1.4.1 Bumps [pytest-timeout](https://github.com/pytest-dev/pytest-timeout) from 1.4.0 to 1.4.1. - [Release notes](https://github.com/pytest-dev/pytest-timeout/releases) - [Commits](https://github.com/pytest-dev/pytest-timeout/compare/1.4.0...1.4.1) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index ccdbd22..65f7ef0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,6 +8,6 @@ pydocstyle==5.0.2 pytest==5.4.3 pytest-cov==2.10.0 pytest-sugar==0.9.3 -pytest-timeout==1.4.0 +pytest-timeout==1.4.1 restructuredtext-lint==1.3.1 pygments==2.6.1 From dff074645021620a116898d23c35bad1b1fdfdd0 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 16 Jun 2020 17:43:22 +0000 Subject: [PATCH 64/87] Broke out homescreen call to prevent back-to-back api calls --- blinkpy/blinkpy.py | 16 ++++++++++------ tests/test_blinkpy.py | 16 +++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 2897e54..420e0f0 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -100,6 +100,7 @@ class Blink: self.auth.startup() self.setup_login_ids() self.setup_urls() + self.get_homescreen() except (LoginError, TokenRefreshFailed, BlinkSetupError): _LOGGER.error("Cannot setup Blink platform.") self.available = False @@ -144,17 +145,20 @@ class Blink: self.sync[name] = BlinkSyncModule(self, name, network_id, cameras) self.sync[name].start() - def setup_owls(self): - """Check for mini cameras.""" + def get_homescreen(self): + """Get homecreen information.""" if self.no_owls: _LOGGER.debug("Skipping owl extraction.") - return [] - response = api.request_homescreen(self) - self.homescreen = response + self.homescreen = {} + return + self.homescreen = api.request_homescreen(self) + + def setup_owls(self): + """Check for mini cameras.""" network_list = [] camera_list = [] try: - for owl in response["owls"]: + for owl in self.homescreen["owls"]: name = owl["name"] network_id = str(owl["network_id"]) if network_id in self.network_ids: diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index 98a2015..821edf0 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -197,12 +197,11 @@ class TestBlinkSetup(unittest.TestCase): self.assertEqual(combined["fizz"], "buzz") self.assertEqual(combined["bar"], "foo") - @mock.patch("blinkpy.api.request_homescreen") @mock.patch("blinkpy.blinkpy.BlinkOwl.start") - def test_initialize_blink_minis(self, mock_start, mock_home): + def test_initialize_blink_minis(self, mock_start): """Test blink mini initialization.""" mock_start.return_value = True - mock_home.return_value = { + self.blink.homescreen = { "owls": [ { "enabled": False, @@ -235,11 +234,10 @@ class TestBlinkSetup(unittest.TestCase): self.assertEqual(self.blink.sync["foo"].name, "foo") self.assertEqual(self.blink.sync["bar"].name, "bar") - @mock.patch("blinkpy.api.request_homescreen") - def test_blink_mini_cameras_returned(self, mock_home): + def test_blink_mini_cameras_returned(self): """Test that blink mini cameras are found if attached to sync module.""" self.blink.network_ids = ["1234"] - mock_home.return_value = { + self.blink.homescreen = { "owls": [ { "id": 1, @@ -261,16 +259,16 @@ class TestBlinkSetup(unittest.TestCase): self.blink.no_owls = True self.blink.network_ids = [] + self.blink.get_homescreen() result = self.blink.setup_owls() self.assertEqual(self.blink.network_ids, []) self.assertEqual(result, []) - @mock.patch("blinkpy.api.request_homescreen") @mock.patch("blinkpy.api.request_camera_usage") - def test_blink_mini_attached_to_sync(self, mock_usage, mock_home): + def test_blink_mini_attached_to_sync(self, mock_usage): """Test that blink mini cameras are properly attached to sync module.""" self.blink.network_ids = ["1234"] - mock_home.return_value = { + self.blink.homescreen = { "owls": [ { "id": 1, From 22e4b7dabe06ca28e46bef0e84feaab242bcf641 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 16 Jun 2020 18:42:59 +0000 Subject: [PATCH 65/87] Fix error with updating owl information during refresh --- blinkpy/helpers/constants.py | 2 +- blinkpy/sync_module.py | 6 ++++-- tests/test_sync_module.py | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index a5c1ec0..2a557db 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc7" +PATCH_VERSION = "0-rc8" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 07a7c14..b1dad59 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -171,7 +171,7 @@ class BlinkSyncModule: return response["camera"][0] except (TypeError, KeyError): _LOGGER.error("Could not extract camera info: %s", response, exc_info=True) - return [] + return {} def get_network_info(self): """Retrieve network status.""" @@ -191,7 +191,9 @@ class BlinkSyncModule: self.check_new_videos() for camera_name in self.cameras.keys(): camera_id = self.cameras[camera_name].camera_id - camera_info = self.get_camera_info(camera_id) + camera_info = self.get_camera_info( + camera_id, owl_info=self.get_owl_info(camera_name) + ) self.cameras[camera_name].update(camera_info, force_cache=force_cache) self.available = True diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py index 211cdb4..fc18a51 100644 --- a/tests/test_sync_module.py +++ b/tests/test_sync_module.py @@ -80,11 +80,11 @@ class TestBlinkSyncModule(unittest.TestCase): def test_get_camera_info_fail(self, mock_resp): """Test handling of failed get camera info function.""" mock_resp.return_value = None - self.assertEqual(self.blink.sync["test"].get_camera_info("1"), []) + self.assertEqual(self.blink.sync["test"].get_camera_info("1"), {}) mock_resp.return_value = {} - self.assertEqual(self.blink.sync["test"].get_camera_info("1"), []) + self.assertEqual(self.blink.sync["test"].get_camera_info("1"), {}) mock_resp.return_value = {"camera": None} - self.assertEqual(self.blink.sync["test"].get_camera_info("1"), []) + self.assertEqual(self.blink.sync["test"].get_camera_info("1"), {}) def test_get_network_info(self, mock_resp): """Test network retrieval.""" From 9ca73934ce06bbfa41a26970c8f01671208c3f13 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2020 08:04:10 +0000 Subject: [PATCH 66/87] Update requests requirement from ~=2.23.0 to ~=2.24.0 Updates the requirements on [requests](https://github.com/psf/requests) to permit the latest version. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/master/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.23.0...v2.24.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 476bd19..0e3988b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ python-dateutil~=2.8.1 -requests~=2.23.0 +requests~=2.24.0 python-slugify~=4.0.0 testtools==2.4.0 From bded3eb156e167e3e21560162d9ed7a913003025 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 29 Jun 2020 15:46:43 +0000 Subject: [PATCH 67/87] Add option to use v3 login endpoint --- blinkpy/auth.py | 10 ++++++---- blinkpy/helpers/constants.py | 7 +++++-- tests/test_auth.py | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 2cf0010..9717930 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -12,7 +12,7 @@ _LOGGER = logging.getLogger(__name__) class Auth: """Class to handle login communication.""" - def __init__(self, login_data=None, no_prompt=False): + def __init__(self, login_data=None, no_prompt=False, login_method="v4"): """ Initialize auth handler. @@ -22,6 +22,7 @@ class Auth: - password :param no_prompt: Should any user input prompts be supressed? True/FALSE + :param login_method: Choose the login endpoint to use. Default: v4. v3 uses email verification rather than a 2FA code. """ if login_data is None: login_data = {} @@ -31,6 +32,7 @@ class Auth: self.region_id = login_data.get("region_id", None) self.client_id = login_data.get("client_id", None) self.account_id = login_data.get("account_id", None) + self.login_url = LOGIN_ENDPOINT[login_method] self.login_response = None self.is_errored = False self.no_prompt = no_prompt @@ -73,11 +75,11 @@ class Auth: self.data = util.validate_login_data(self.data) - def login(self, login_url=LOGIN_ENDPOINT): + def login(self): """Attempt login to blink servers.""" self.validate_login() - _LOGGER.info("Attempting login with %s", login_url) - response = api.request_login(self, login_url, self.data, is_retry=False,) + _LOGGER.info("Attempting login with %s", self.login_url) + response = api.request_login(self, self.login_url, self.data, is_retry=False,) try: if response.status_code == 200: return response.json() diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 2a557db..34da5f1 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc8" +PATCH_VERSION = "0-rc9" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" @@ -48,7 +48,10 @@ URLS BLINK_URL = "immedia-semi.com" DEFAULT_URL = f"rest-prod.{BLINK_URL}" BASE_URL = f"https://{DEFAULT_URL}" -LOGIN_ENDPOINT = f"{BASE_URL}/api/v4/account/login" +LOGIN_ENDPOINT = { + "v4": f"{BASE_URL}/api/v4/account/login", + "v3": f"{BASE_URL}/api/v3/login", +} """ Dictionaries diff --git a/tests/test_auth.py b/tests/test_auth.py index e1fae96..17a3edb 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -144,6 +144,21 @@ class TestAuth(unittest.TestCase): fake_resp = mresp.MockResponse({"foo": "bar"}, 200) mock_req.return_value = fake_resp self.assertEqual(self.auth.login(), {"foo": "bar"}) + mock_req.assert_called_with( + self.auth, const.LOGIN_ENDPOINT["v4"], {}, is_retry=False + ) + + @mock.patch("blinkpy.auth.Auth.validate_login", return_value=None) + @mock.patch("blinkpy.auth.api.request_login") + def test_login_v3(self, mock_req, mock_validate): + """Test login handling.""" + auth_v3 = Auth(login_method="v3") + fake_resp = mresp.MockResponse({"foo": "bar"}, 200) + mock_req.return_value = fake_resp + self.assertEqual(auth_v3.login(), {"foo": "bar"}) + mock_req.assert_called_with( + auth_v3, const.LOGIN_ENDPOINT["v3"], {}, is_retry=False + ) @mock.patch("blinkpy.auth.Auth.validate_login", return_value=None) @mock.patch("blinkpy.auth.api.request_login") From 7b150eb1afaa31a541f2f8c6bd5fdc2284272165 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 29 Jun 2020 17:01:46 +0000 Subject: [PATCH 68/87] Changed the way the new login method switch is handled --- blinkpy/auth.py | 9 ++++++++- blinkpy/helpers/constants.py | 2 +- tests/test_auth.py | 9 +++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 9717930..03a6495 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -32,7 +32,7 @@ class Auth: self.region_id = login_data.get("region_id", None) self.client_id = login_data.get("client_id", None) self.account_id = login_data.get("account_id", None) - self.login_url = LOGIN_ENDPOINT[login_method] + self.login_method = login_method self.login_response = None self.is_errored = False self.no_prompt = no_prompt @@ -55,6 +55,13 @@ class Auth: return None return {"Host": self.host, "TOKEN_AUTH": self.token} + @property + def login_url(self): + """Return login url.""" + if self.login_method not in LOGIN_ENDPOINT: + return LOGIN_ENDPOINT["v4"] + return LOGIN_ENDPOINT[self.login_method] + def create_session(self): """Create a session for blink communication.""" sess = Session() diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 34da5f1..14c3f4f 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc9" +PATCH_VERSION = "0-rc10" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" diff --git a/tests/test_auth.py b/tests/test_auth.py index 17a3edb..3fde2f1 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -254,6 +254,15 @@ class TestAuth(unittest.TestCase): mock_validate.side_effect = [UnauthorizedError, TokenRefreshFailed] self.assertEqual(self.auth.query(url="http://example.com"), None) + def test_login_methods(self): + """Test correct login url returned.""" + auth = Auth() + self.assertEqual(auth.login_url, const.LOGIN_ENDPOINT["v4"]) + auth.login_method = "v3" + self.assertEqual(auth.login_url, const.LOGIN_ENDPOINT["v3"]) + auth.login_method = "foobar" + self.assertEqual(auth.login_url, const.LOGIN_ENDPOINT["v4"]) + class MockSession: """Object to mock a session.""" From 40354e87a0b3e8fa0d0c5aa1a3d0f3614576b585 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 29 Jun 2020 17:23:15 +0000 Subject: [PATCH 69/87] Rolled back to rc8, removed v3 auth functionality (poorly thought out) --- blinkpy/auth.py | 17 ++++------------- blinkpy/helpers/constants.py | 7 ++----- requirements.txt | 2 +- tests/test_auth.py | 24 ------------------------ 4 files changed, 7 insertions(+), 43 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 03a6495..2cf0010 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -12,7 +12,7 @@ _LOGGER = logging.getLogger(__name__) class Auth: """Class to handle login communication.""" - def __init__(self, login_data=None, no_prompt=False, login_method="v4"): + def __init__(self, login_data=None, no_prompt=False): """ Initialize auth handler. @@ -22,7 +22,6 @@ class Auth: - password :param no_prompt: Should any user input prompts be supressed? True/FALSE - :param login_method: Choose the login endpoint to use. Default: v4. v3 uses email verification rather than a 2FA code. """ if login_data is None: login_data = {} @@ -32,7 +31,6 @@ class Auth: self.region_id = login_data.get("region_id", None) self.client_id = login_data.get("client_id", None) self.account_id = login_data.get("account_id", None) - self.login_method = login_method self.login_response = None self.is_errored = False self.no_prompt = no_prompt @@ -55,13 +53,6 @@ class Auth: return None return {"Host": self.host, "TOKEN_AUTH": self.token} - @property - def login_url(self): - """Return login url.""" - if self.login_method not in LOGIN_ENDPOINT: - return LOGIN_ENDPOINT["v4"] - return LOGIN_ENDPOINT[self.login_method] - def create_session(self): """Create a session for blink communication.""" sess = Session() @@ -82,11 +73,11 @@ class Auth: self.data = util.validate_login_data(self.data) - def login(self): + def login(self, login_url=LOGIN_ENDPOINT): """Attempt login to blink servers.""" self.validate_login() - _LOGGER.info("Attempting login with %s", self.login_url) - response = api.request_login(self, self.login_url, self.data, is_retry=False,) + _LOGGER.info("Attempting login with %s", login_url) + response = api.request_login(self, login_url, self.data, is_retry=False,) try: if response.status_code == 200: return response.json() diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 14c3f4f..fec44dc 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc10" +PATCH_VERSION = "0-rc11" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" @@ -48,10 +48,7 @@ URLS BLINK_URL = "immedia-semi.com" DEFAULT_URL = f"rest-prod.{BLINK_URL}" BASE_URL = f"https://{DEFAULT_URL}" -LOGIN_ENDPOINT = { - "v4": f"{BASE_URL}/api/v4/account/login", - "v3": f"{BASE_URL}/api/v3/login", -} +LOGIN_ENDPOINT = f"{BASE_URL}/api/v4/account/login" """ Dictionaries diff --git a/requirements.txt b/requirements.txt index 0e3988b..476bd19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ python-dateutil~=2.8.1 -requests~=2.24.0 +requests~=2.23.0 python-slugify~=4.0.0 testtools==2.4.0 diff --git a/tests/test_auth.py b/tests/test_auth.py index 3fde2f1..e1fae96 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -144,21 +144,6 @@ class TestAuth(unittest.TestCase): fake_resp = mresp.MockResponse({"foo": "bar"}, 200) mock_req.return_value = fake_resp self.assertEqual(self.auth.login(), {"foo": "bar"}) - mock_req.assert_called_with( - self.auth, const.LOGIN_ENDPOINT["v4"], {}, is_retry=False - ) - - @mock.patch("blinkpy.auth.Auth.validate_login", return_value=None) - @mock.patch("blinkpy.auth.api.request_login") - def test_login_v3(self, mock_req, mock_validate): - """Test login handling.""" - auth_v3 = Auth(login_method="v3") - fake_resp = mresp.MockResponse({"foo": "bar"}, 200) - mock_req.return_value = fake_resp - self.assertEqual(auth_v3.login(), {"foo": "bar"}) - mock_req.assert_called_with( - auth_v3, const.LOGIN_ENDPOINT["v3"], {}, is_retry=False - ) @mock.patch("blinkpy.auth.Auth.validate_login", return_value=None) @mock.patch("blinkpy.auth.api.request_login") @@ -254,15 +239,6 @@ class TestAuth(unittest.TestCase): mock_validate.side_effect = [UnauthorizedError, TokenRefreshFailed] self.assertEqual(self.auth.query(url="http://example.com"), None) - def test_login_methods(self): - """Test correct login url returned.""" - auth = Auth() - self.assertEqual(auth.login_url, const.LOGIN_ENDPOINT["v4"]) - auth.login_method = "v3" - self.assertEqual(auth.login_url, const.LOGIN_ENDPOINT["v3"]) - auth.login_method = "foobar" - self.assertEqual(auth.login_url, const.LOGIN_ENDPOINT["v4"]) - class MockSession: """Object to mock a session.""" From 41aa1839b7e234c3309e65a837892d4041ce9a97 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2020 07:53:51 +0000 Subject: [PATCH 70/87] Update python-slugify requirement from ~=4.0.0 to ~=4.0.1 Updates the requirements on [python-slugify](https://github.com/un33k/python-slugify) to permit the latest version. - [Release notes](https://github.com/un33k/python-slugify/releases) - [Changelog](https://github.com/un33k/python-slugify/blob/master/CHANGELOG.md) - [Commits](https://github.com/un33k/python-slugify/compare/4.0.0...4.0.1) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0e3988b..9e0e020 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ python-dateutil~=2.8.1 requests~=2.24.0 -python-slugify~=4.0.0 +python-slugify~=4.0.1 testtools==2.4.0 From 24ca9d961741ff9a91b5584a08e71a7abce66bde Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 30 Jun 2020 16:40:19 +0000 Subject: [PATCH 71/87] Fixed request version, improved setup.py requirement import --- requirements.txt | 2 +- setup.py | 29 ++++++++++++++++------------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/requirements.txt b/requirements.txt index 476bd19..0e3988b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ python-dateutil~=2.8.1 -requests~=2.23.0 +requests~=2.24.0 python-slugify~=4.0.0 testtools==2.4.0 diff --git a/setup.py b/setup.py index 2d19da0..fc0b76f 100644 --- a/setup.py +++ b/setup.py @@ -3,23 +3,26 @@ from os.path import abspath, dirname from setuptools import setup, find_packages from blinkpy.helpers.constants import ( - __version__, PROJECT_PACKAGE_NAME, PROJECT_LICENSE, PROJECT_URL, - PROJECT_EMAIL, PROJECT_DESCRIPTION, PROJECT_CLASSIFIERS, PROJECT_AUTHOR, + __version__, + PROJECT_PACKAGE_NAME, + PROJECT_LICENSE, + PROJECT_URL, + PROJECT_EMAIL, + PROJECT_DESCRIPTION, + PROJECT_CLASSIFIERS, + PROJECT_AUTHOR, ) PROJECT_VERSION = __version__ THIS_DIR = abspath(dirname(__file__)) -REQUIRES = [ - "python-dateutil~=2.8.1", - "requests~=2.23.0", - "python-slugify~=4.0.0", -] +with open(f"{THIS_DIR}/requirements.txt") as req_file: + REQUIRES = [line.rstrip() for line in req_file] -PACKAGES = find_packages(exclude=['tests*', 'docs']) +PACKAGES = find_packages(exclude=["tests*", "docs"]) -with open('{}/README.rst'.format(THIS_DIR), encoding='utf-8') as readme_file: +with open("{}/README.rst".format(THIS_DIR), encoding="utf-8") as readme_file: LONG_DESCRIPTION = readme_file.read() setup( @@ -31,11 +34,11 @@ setup( author_email=PROJECT_EMAIL, license=PROJECT_LICENSE, url=PROJECT_URL, - platforms='any', - py_modules=['blinkpy'], + platforms="any", + py_modules=["blinkpy"], packages=PACKAGES, include_package_data=True, install_requires=REQUIRES, - test_suite='tests', - classifiers=PROJECT_CLASSIFIERS + test_suite="tests", + classifiers=PROJECT_CLASSIFIERS, ) From c4608f4258673c83806e30d06ac820af4f12b4b7 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2020 08:15:35 +0000 Subject: [PATCH 72/87] Bump pre-commit from 2.5.1 to 2.6.0 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.5.1 to 2.6.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.5.1...v2.6.0) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 65f7ef0..0e28478 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ black==19.10b0 coverage==5.1 flake8==3.8.3 flake8-docstrings==1.5.0 -pre-commit==2.5.1 +pre-commit==2.6.0 pylint==2.5.3 pydocstyle==5.0.2 pytest==5.4.3 From 220638e525b639b488a47722904f99f8bbb8960c Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Thu, 2 Jul 2020 14:50:38 +0000 Subject: [PATCH 73/87] Add different timeout for video and image retrieval --- blinkpy/api.py | 6 +++--- blinkpy/auth.py | 8 +++++--- blinkpy/blinkpy.py | 9 ++++++++- blinkpy/camera.py | 13 +++++++++++-- blinkpy/helpers/constants.py | 2 ++ 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index 00474a2..93d3ab3 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -3,7 +3,7 @@ import logging from json import dumps from blinkpy.helpers.util import get_time, Throttle -from blinkpy.helpers.constants import DEFAULT_URL +from blinkpy.helpers.constants import DEFAULT_URL, TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -284,7 +284,7 @@ def request_motion_detection_disable(blink, network, camera_id): return http_post(blink, url) -def http_get(blink, url, stream=False, json=True, is_retry=False): +def http_get(blink, url, stream=False, json=True, is_retry=False, timeout=TIMEOUT): """ Perform an http get request. @@ -304,7 +304,7 @@ def http_get(blink, url, stream=False, json=True, is_retry=False): ) -def http_post(blink, url, is_retry=False): +def http_post(blink, url, is_retry=False, timeout=TIMEOUT): """ Perform an http post request. diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 2cf0010..1520065 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -4,7 +4,7 @@ from functools import partial from requests import Request, Session, exceptions from blinkpy import api from blinkpy.helpers import util -from blinkpy.helpers.constants import BLINK_URL, LOGIN_ENDPOINT +from blinkpy.helpers.constants import BLINK_URL, LOGIN_ENDPOINT, TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -56,7 +56,7 @@ class Auth: def create_session(self): """Create a session for blink communication.""" sess = Session() - sess.get = partial(sess.get, timeout=10) + sess.get = partial(sess.get, timeout=TIMEOUT) return sess def prepare_request(self, url, headers, data, reqtype): @@ -140,6 +140,7 @@ class Auth: stream=False, json_resp=True, is_retry=False, + timeout=TIMEOUT, ): """ Perform server requests. @@ -154,7 +155,7 @@ class Auth: """ req = self.prepare_request(url, headers, data, reqtype) try: - response = self.session.send(req, stream=stream) + response = self.session.send(req, stream=stream, timeout=timeout) return self.validate_response(response, json_resp) except (exceptions.ConnectionError, exceptions.Timeout): _LOGGER.error( @@ -182,6 +183,7 @@ class Auth: stream=stream, json_resp=json_resp, is_retry=True, + timeout=timeout, ) _LOGGER.error("Unable to access %s after token refresh.", url) except TokenRefreshFailed: diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 420e0f0..913aa40 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -29,6 +29,7 @@ from blinkpy.helpers.constants import ( DEFAULT_MOTION_INTERVAL, DEFAULT_REFRESH, MIN_THROTTLE_TIME, + TIMEOUT_MEDIA, ) from blinkpy.helpers.constants import __version__ from blinkpy.auth import Auth, TokenRefreshFailed, LoginError @@ -330,7 +331,13 @@ class Blink: _LOGGER.info("%s already exists, skipping...", filename) continue - response = api.http_get(self, url=clip_address, stream=True, json=False) + response = api.http_get( + self, + url=clip_address, + stream=True, + json=False, + timeout=TIMEOUT_MEDIA, + ) with open(filename, "wb") as vidfile: copyfileobj(response.raw, vidfile) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index dc5ea20..54efe86 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -3,6 +3,7 @@ from shutil import copyfileobj import logging from blinkpy import api +from blinkpy.helpers.constants import TIMEOUT_MEDIA _LOGGER = logging.getLogger(__name__) @@ -182,11 +183,19 @@ class BlinkCamera: if new_thumbnail is not None and (update_cached_image or force_cache): self._cached_image = api.http_get( - self.sync.blink, url=self.thumbnail, stream=True, json=False + self.sync.blink, + url=self.thumbnail, + stream=True, + json=False, + timeout=TIMEOUT_MEDIA, ) if clip_addr is not None and (update_cached_video or force_cache): self._cached_video = api.http_get( - self.sync.blink, url=self.clip, stream=True, json=False + self.sync.blink, + url=self.clip, + stream=True, + json=False, + timeout=TIMEOUT_MEDIA, ) def get_liveview(self): diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index fec44dc..a933607 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -65,3 +65,5 @@ DEFAULT_REFRESH = 30 MIN_THROTTLE_TIME = 2 SIZE_NOTIFICATION_KEY = 152 SIZE_UID = 16 +TIMEOUT = 10 +TIMEOUT_MEDIA = 90 From a030f46b4d8dfc062c0519781b8e65a4cfbfdf6f Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Thu, 2 Jul 2020 18:15:29 +0000 Subject: [PATCH 74/87] Modify session to use HTTPAdapter and handle retries --- blinkpy/auth.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 1520065..e5be63a 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -2,6 +2,8 @@ import logging from functools import partial from requests import Request, Session, exceptions +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry from blinkpy import api from blinkpy.helpers import util from blinkpy.helpers.constants import BLINK_URL, LOGIN_ENDPOINT, TIMEOUT @@ -56,6 +58,16 @@ class Auth: def create_session(self): """Create a session for blink communication.""" sess = Session() + assert_status_hook = [ + lambda response, *args, **kwargs: response.raise_for_status() + ] + sess.hooks["response"] = assert_status_hook + retry = Retry( + total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504] + ) + adapter = HTTPAdapter(max_retries=retry) + sess.mount("https://", adapter) + sess.mount("http://", adapter) sess.get = partial(sess.get, timeout=TIMEOUT) return sess From 2d2e56959560bfa9b1047e9054911b4f43858604 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2020 08:15:43 +0000 Subject: [PATCH 75/87] Bump coverage from 5.1 to 5.2 Bumps [coverage](https://github.com/nedbat/coveragepy) from 5.1 to 5.2. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/coverage-5.1...coverage-5.2) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 0e28478..d1191a1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,5 @@ black==19.10b0 -coverage==5.1 +coverage==5.2 flake8==3.8.3 flake8-docstrings==1.5.0 pre-commit==2.6.0 From cdbd1a4c276a211fd3b57ccfa43eb8588dd574ae Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2020 08:16:07 +0000 Subject: [PATCH 76/87] Bump pytest-sugar from 0.9.3 to 0.9.4 Bumps [pytest-sugar](https://github.com/Teemu/pytest-sugar) from 0.9.3 to 0.9.4. - [Release notes](https://github.com/Teemu/pytest-sugar/releases) - [Changelog](https://github.com/Teemu/pytest-sugar/blob/master/CHANGES.rst) - [Commits](https://github.com/Teemu/pytest-sugar/commits) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 0e28478..9fa7f9c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ pylint==2.5.3 pydocstyle==5.0.2 pytest==5.4.3 pytest-cov==2.10.0 -pytest-sugar==0.9.3 +pytest-sugar==0.9.4 pytest-timeout==1.4.1 restructuredtext-lint==1.3.1 pygments==2.6.1 From d3761b23ec0a3c57441d3897662b1ae4cde010b0 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 8 Jul 2020 17:57:56 +0000 Subject: [PATCH 77/87] Remove reference to response on exception since it may be unbound --- blinkpy/auth.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index e5be63a..7d72645 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -171,10 +171,7 @@ class Auth: return self.validate_response(response, json_resp) except (exceptions.ConnectionError, exceptions.Timeout): _LOGGER.error( - "Connection error. Endpoint %s possibly down or throttled. %s: %s", - url, - response.status_code, - response.reason, + "Connection error. Endpoint %s possibly down or throttled.", url, ) except BlinkBadResponse: _LOGGER.error( From 45cb4028b8187b8d602cf28e4b217ec7622ed9d7 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sat, 11 Jul 2020 14:39:45 +0000 Subject: [PATCH 78/87] Remove host from auth header --- blinkpy/auth.py | 2 +- blinkpy/helpers/constants.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 7d72645..34c85bb 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -53,7 +53,7 @@ class Auth: """Return authorization header.""" if self.token is None: return None - return {"Host": self.host, "TOKEN_AUTH": self.token} + return {"TOKEN_AUTH": self.token} def create_session(self): """Create a session for blink communication.""" diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index a933607..775aa15 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc11" +PATCH_VERSION = "0-rc12" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" From 322aae37dc4726b33a48bc5bf37f643460b095c8 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sat, 11 Jul 2020 14:41:44 +0000 Subject: [PATCH 79/87] fix test (remove host) --- tests/test_auth.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index e1fae96..dc1b35c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -128,8 +128,7 @@ class TestAuth(unittest.TestCase): def test_header(self): """Test header data.""" self.auth.token = "bar" - self.auth.host = "foo" - expected_header = {"Host": "foo", "TOKEN_AUTH": "bar"} + expected_header = {"TOKEN_AUTH": "bar"} self.assertDictEqual(self.auth.header, expected_header) def test_header_no_token(self): From 6e360b2bb166e70b0ceec44d6468b8da0e283658 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2020 08:06:30 +0000 Subject: [PATCH 80/87] Bump pytest-timeout from 1.4.1 to 1.4.2 Bumps [pytest-timeout](https://github.com/pytest-dev/pytest-timeout) from 1.4.1 to 1.4.2. - [Release notes](https://github.com/pytest-dev/pytest-timeout/releases) - [Commits](https://github.com/pytest-dev/pytest-timeout/compare/1.4.1...1.4.2) Signed-off-by: dependabot-preview[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b7e6911..378ac72 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,6 +8,6 @@ pydocstyle==5.0.2 pytest==5.4.3 pytest-cov==2.10.0 pytest-sugar==0.9.4 -pytest-timeout==1.4.1 +pytest-timeout==1.4.2 restructuredtext-lint==1.3.1 pygments==2.6.1 From 82d5a7ea51e338365348cadea149d76fe692fdeb Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 20 Jul 2020 00:34:06 +0000 Subject: [PATCH 81/87] Dev version bump --- blinkpy/helpers/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 775aa15..d997523 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -4,7 +4,7 @@ import os MAJOR_VERSION = 0 MINOR_VERSION = 16 -PATCH_VERSION = "0-rc12" +PATCH_VERSION = "0-rc13" __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" From 854ffbf26a71f8a46ce52ce1111d70d6f8e74d55 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 20 Jul 2020 00:37:30 +0000 Subject: [PATCH 82/87] Removed all exc_info parameters during logging --- blinkpy/camera.py | 4 +--- blinkpy/sync_module.py | 8 +++----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 54efe86..1715fee 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -151,9 +151,7 @@ class BlinkCamera: if config.get("thumbnail", False): thumb_addr = config["thumbnail"] else: - _LOGGER.warning( - "Could not find thumbnail for camera %s", self.name, exc_info=True - ) + _LOGGER.warning("Could not find thumbnail for camera %s", self.name) if thumb_addr is not None: new_thumbnail = f"{self.sync.urls.base_url}{thumb_addr}.jpg" diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index b1dad59..ad780a1 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -93,9 +93,7 @@ class BlinkSyncModule: self.serial = self.summary["serial"] self.status = self.summary["status"] except KeyError: - _LOGGER.error( - "Could not extract some sync module info: %s", response, exc_info=True - ) + _LOGGER.error("Could not extract some sync module info: %s", response) is_ok = self.get_network_info() self.check_new_videos() @@ -158,7 +156,7 @@ class BlinkSyncModule: try: return response["event"] except (TypeError, KeyError): - _LOGGER.error("Could not extract events: %s", response, exc_info=True) + _LOGGER.error("Could not extract events: %s", response) return False def get_camera_info(self, camera_id, **kwargs): @@ -170,7 +168,7 @@ class BlinkSyncModule: try: return response["camera"][0] except (TypeError, KeyError): - _LOGGER.error("Could not extract camera info: %s", response, exc_info=True) + _LOGGER.error("Could not extract camera info: %s", response) return {} def get_network_info(self): From e8589bfa05c4dff741e95e92c5d52ce22e067ba2 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 20 Jul 2020 00:48:40 +0000 Subject: [PATCH 83/87] Fix test --- tests/test_cameras.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_cameras.py b/tests/test_cameras.py index 94c9dc2..25bdaf0 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -117,10 +117,7 @@ class TestBlinkCameraSetup(unittest.TestCase): "WARNING:blinkpy.camera:Could not retrieve calibrated " "temperature." ), - ( - "WARNING:blinkpy.camera:Could not find thumbnail for camera new" - "\nNoneType: None" - ), + ("WARNING:blinkpy.camera:Could not find thumbnail for camera new"), ], ) From 48028914de9a08cc6a07b8c893c145aa713c013a Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 20 Jul 2020 01:18:41 +0000 Subject: [PATCH 84/87] Fix broken test and missing try/except --- blinkpy/auth.py | 11 +++++++++-- tests/test_auth.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 34c85bb..1e20fd0 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -174,11 +174,18 @@ class Auth: "Connection error. Endpoint %s possibly down or throttled.", url, ) except BlinkBadResponse: + code = None + reason = None + try: + code = response.status_code + reason = response.reason + except AttributeError: + pass _LOGGER.error( "Expected json response from %s, but received: %s: %s", url, - response.status_code, - response.reason, + code, + reason, ) except UnauthorizedError: try: diff --git a/tests/test_auth.py b/tests/test_auth.py index dc1b35c..a755300 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -230,7 +230,7 @@ class TestAuth(unittest.TestCase): @mock.patch("blinkpy.auth.Auth.refresh_token") def test_query_retry_failed(self, mock_refresh, mock_validate): """Check handling of failed retry request.""" - self.auth.seession = MockSession() + self.auth.session = MockSession() mock_validate.side_effect = [UnauthorizedError, BlinkBadResponse] mock_refresh.return_value = True self.assertEqual(self.auth.query(url="http://example.com"), None) From a9d3d5568b76c9ba8bef2abcf433c1078708c419 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 20 Jul 2020 01:19:40 +0000 Subject: [PATCH 85/87] Adding retry options to auth class --- blinkpy/auth.py | 17 +++++++++++++---- tests/test_auth.py | 10 ++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 1e20fd0..5cbf0f9 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) class Auth: """Class to handle login communication.""" - def __init__(self, login_data=None, no_prompt=False): + def __init__(self, login_data=None, no_prompt=False, retry_opts=None): """ Initialize auth handler. @@ -24,6 +24,10 @@ class Auth: - password :param no_prompt: Should any user input prompts be supressed? True/FALSE + :param retry_opts: Dictionary containing retry options: + - backoff: backoff factor for http request (backoff*(2^(total_retries)-1) + - retries: total retries to attempt + - retry_list: list of status codes to force retry """ if login_data is None: login_data = {} @@ -36,7 +40,7 @@ class Auth: self.login_response = None self.is_errored = False self.no_prompt = no_prompt - self.session = self.create_session() + self.session = self.create_session(opts=retry_opts) @property def login_attributes(self): @@ -55,15 +59,20 @@ class Auth: return None return {"TOKEN_AUTH": self.token} - def create_session(self): + def create_session(self, opts=None): """Create a session for blink communication.""" + if opts is None: + opts = {} + backoff = opts.get("backoff", 1) + retries = opts.get("retries", 3) + retry_list = opts.get("retry_list", [429, 500, 502, 503, 504]) sess = Session() assert_status_hook = [ lambda response, *args, **kwargs: response.raise_for_status() ] sess.hooks["response"] = assert_status_hook retry = Retry( - total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504] + total=retries, backoff_factor=backoff, status_forcelist=retry_list ) adapter = HTTPAdapter(max_retries=retry) sess.mount("https://", adapter) diff --git a/tests/test_auth.py b/tests/test_auth.py index a755300..0d053a8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -238,6 +238,16 @@ class TestAuth(unittest.TestCase): mock_validate.side_effect = [UnauthorizedError, TokenRefreshFailed] self.assertEqual(self.auth.query(url="http://example.com"), None) + def test_default_session(self): + """Test default session creation.""" + sess = self.auth.create_session() + adapter = sess.adapters["https://"] + self.assertEqual(adapter.max_retries.total, 3) + self.assertEqual(adapter.max_retries.backoff_factor, 1) + self.assertEqual( + adapter.max_retries.status_forcelist, [429, 500, 502, 503, 504] + ) + class MockSession: """Object to mock a session.""" From 5fc315201a4d353791d1fdbeb1eb31552b3aff0d Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 20 Jul 2020 01:33:11 +0000 Subject: [PATCH 86/87] Add tests for retry options --- tests/test_auth.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_auth.py b/tests/test_auth.py index 0d053a8..1c85a6d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -248,6 +248,39 @@ class TestAuth(unittest.TestCase): adapter.max_retries.status_forcelist, [429, 500, 502, 503, 504] ) + def test_custom_session_full(self): + """Test full custom session creation.""" + opts = {"backoff": 2, "retries": 10, "retry_list": [404]} + sess = self.auth.create_session(opts=opts) + adapter = sess.adapters["https://"] + self.assertEqual(adapter.max_retries.total, 10) + self.assertEqual(adapter.max_retries.backoff_factor, 2) + self.assertEqual(adapter.max_retries.status_forcelist, [404]) + + def test_custom_session_partial(self): + """Test partial custom session creation.""" + opts1 = {"backoff": 2} + opts2 = {"retries": 5} + opts3 = {"retry_list": [101, 202]} + sess1 = self.auth.create_session(opts=opts1) + sess2 = self.auth.create_session(opts=opts2) + sess3 = self.auth.create_session(opts=opts3) + adapt1 = sess1.adapters["https://"] + adapt2 = sess2.adapters["https://"] + adapt3 = sess3.adapters["https://"] + + self.assertEqual(adapt1.max_retries.total, 3) + self.assertEqual(adapt1.max_retries.backoff_factor, 2) + self.assertEqual(adapt1.max_retries.status_forcelist, [429, 500, 502, 503, 504]) + + self.assertEqual(adapt2.max_retries.total, 5) + self.assertEqual(adapt2.max_retries.backoff_factor, 1) + self.assertEqual(adapt2.max_retries.status_forcelist, [429, 500, 502, 503, 504]) + + self.assertEqual(adapt3.max_retries.total, 3) + self.assertEqual(adapt3.max_retries.backoff_factor, 1) + self.assertEqual(adapt3.max_retries.status_forcelist, [101, 202]) + class MockSession: """Object to mock a session.""" From fe8eac3dc6fcff2cb28116c6cd9d3a9cb327a56b Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 20 Jul 2020 01:42:07 +0000 Subject: [PATCH 87/87] Update documentation --- blinkpy/auth.py | 8 ++------ docs/advanced.rst | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 5cbf0f9..59950da 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) class Auth: """Class to handle login communication.""" - def __init__(self, login_data=None, no_prompt=False, retry_opts=None): + def __init__(self, login_data=None, no_prompt=False): """ Initialize auth handler. @@ -24,10 +24,6 @@ class Auth: - password :param no_prompt: Should any user input prompts be supressed? True/FALSE - :param retry_opts: Dictionary containing retry options: - - backoff: backoff factor for http request (backoff*(2^(total_retries)-1) - - retries: total retries to attempt - - retry_list: list of status codes to force retry """ if login_data is None: login_data = {} @@ -40,7 +36,7 @@ class Auth: self.login_response = None self.is_errored = False self.no_prompt = no_prompt - self.session = self.create_session(opts=retry_opts) + self.session = self.create_session() @property def login_attributes(self): diff --git a/docs/advanced.rst b/docs/advanced.rst index 24fd431..95bf603 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -30,6 +30,29 @@ By default, the ``blink.auth.Auth`` class creates its own websession via its ``c blink.auth = Auth() blink.auth.session = YourCustomSession + +Custom Retry Logic +-------------------- +The built-in auth session via the ``create_session`` method allows for customizable retry intervals and conditions. These parameters are: + +- retries +- backoff +- retry_list + +``retries`` is the total number of retry attempts that each http request can do before timing out. ``backoff`` is a parameter that allows for non-linear retry times such that the time between retries is backoff*(2^(retries) - 1). ``retry_list`` is simply a list of status codes to force a retry. By default ``retries=3``, ``backoff=1``, and ``retry_list=[429, 500, 502, 503, 504]``. To override them, you need to add you overrides to a dictionary and use that to create a new session with the ``opts`` variable in the ``create_session`` method. The following example can serve as a guide where only the number of retries and backoff factor are overridden: + +.. code:: python + + from blinkpy.blinkpy import Blink + from blinkpy.auth import Auth + + blink = Blink() + blink.auth = Auth() + + opts = {"retries": 10, "backoff": 2} + blink.auth.session = blink.auth.create_session(opts=opts) + + Custom HTTP requests --------------------- In addition to custom sessions, custom blink server requests can be performed. This give you the ability to bypass the built-in ``Auth.query`` method. It also allows flexibility by giving you the option to pass your own url, rather than be limited to what is currently implemented in the ``blinkpy.api`` module.