diff --git a/.gitignore b/.gitignore index e420fa42..1b042b35 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ venv .session* Pipfile Pipfile.lock +blink.json +blinktest.py diff --git a/CHANGES.rst b/CHANGES.rst index a0325679..b709589e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,28 @@ Changelog A list of changes between each release +0.22.0 (2023-08-16) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Bugfixes** + +-None + +**New Features** + +- Asyncio conversion (`@mkmer #723 `__) + +**Other Changes** + +- Various fixes to codebase to support asyncio +- Upgrade flake8 to 6.1.0 +- Upgrade pylint to 2.17.5 +- Upgrade pytest to 7.4.0 +- Upgrade black to 23.7.0 +- Upgrade pytest-cov to 4.1.0 +- Upgrade pygments to 2.16.1 +- Upgrade coverage to 7.3.0 + 0.21.0 (2023-05-28) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/README.rst b/README.rst index c912198b..347554a4 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,6 @@ blinkpy |Build Status| |Coverage Status| |Docs| |PyPi Version| |Codestyle| ============================================================================================= -A Python library for the Blink Camera system (Python 3.7+) +A Python library for the Blink Camera system (Python 3.8+) Like the library? Consider buying me a cup of coffee! @@ -44,14 +44,14 @@ 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. 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 ``await 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.blinkpy import Blink blink = Blink() - blink.start() + await 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. @@ -69,15 +69,15 @@ In some cases, having an interactive command-line session is not desired. In th # Can set no_prompt when initializing auth handler auth = Auth({"username": , "password": }, no_prompt=True) blink.auth = auth - blink.start() + await 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() + await auth.send_auth_key(blink, ) + await blink.setup_post_verify() Supplying credentials from file @@ -91,9 +91,9 @@ Other use cases may involved loading credentials from a file. This file must be from blinkpy.helpers.util import json_load blink = Blink() - auth = Auth(json_load("")) + auth = Auth(await json_load("")) blink.auth = auth - blink.start() + await blink.start() Saving credentials @@ -102,7 +102,7 @@ This library also allows you to save your credentials to use in future sessions. .. code:: python - blink.save("") + await blink.save("") Getting cameras @@ -123,19 +123,19 @@ The most recent images and videos can be accessed as a bytes-object via internal .. code:: python camera = blink.cameras['SOME CAMERA NAME'] - 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) + await blink.refresh(force=True) # force a cache update USE WITH CAUTION + camera.image_from_cache # bytes-like image object (jpg) + camera.video_from_cache # bytes-like video object (mp4) The ``blinkpy`` api also allows for saving images and videos to a file and snapping a new picture from the camera remotely: .. code:: python camera = blink.cameras['SOME CAMERA NAME'] - camera.snap_picture() # Take a new picture with the camera - 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') + await camera.snap_picture() # Take a new picture with the camera + await blink.refresh() # Get new information from server + await camera.image_to_file('/local/path/for/image.jpg') + await camera.video_to_file('/local/path/for/video.mp4') Arming Blink @@ -145,13 +145,13 @@ Methods exist to arm/disarm the sync module, as well as enable/disable motion de .. code:: python # Arm a sync module - blink.sync["SYNC MODULE NAME"].arm = True + await blink.sync["SYNC MODULE NAME"].async_arm(True) # Disarm a sync module - blink.sync["SYNC MODULE NAME"].arm = False + await blink.sync["SYNC MODULE NAME"].async_arm(False) # Print arm status of a sync module - a system refresh should be performed first - blink.refresh() + await blink.refresh() sync = blink.sync["SYNC MODULE NAME"] print(f"{sync.name} status: {sync.arm}") @@ -162,13 +162,13 @@ Similar methods exist for individual cameras: camera = blink.cameras["SOME CAMERA NAME"] # Enable motion detection on a camera - camera.arm = True + await camera.async_arm(True) # Disable motion detection on a camera - camera.arm = False + await camera.async_arm( False) # Print arm status of a sync module - a system refresh should be performed first - blink.refresh() + await blink.refresh() print(f"{camera.name} status: {camera.arm}") @@ -180,7 +180,7 @@ Example usage, which downloads all videos recorded since July 4th, 2018 at 9:34a .. code:: python - blink.download_videos('/home/blink', since='2018/07/04 09:34', delay=2) + await blink.download_videos('/home/blink', since='2018/07/04 09:34', delay=2) Sync Module Local Storage diff --git a/blinkapp/blinkapp.py b/blinkapp/blinkapp.py index af6c5018..69d300e0 100644 --- a/blinkapp/blinkapp.py +++ b/blinkapp/blinkapp.py @@ -1,11 +1,12 @@ """Script to run blinkpy as an blinkapp.""" from os import environ +import asyncio from datetime import datetime, timedelta +from aiohttp import ClientSession from blinkpy.blinkpy import Blink from blinkpy.auth import Auth from blinkpy.helpers.util import json_load - CREDFILE = environ.get("CREDFILE") TIMEDELTA = timedelta(environ.get("TIMEDELTA", 1)) @@ -15,25 +16,28 @@ def get_date(): return (datetime.now() - TIMEDELTA).isoformat() -def download_videos(blink, save_dir="/media"): +async def download_videos(blink, save_dir="/media"): """Make request to download videos.""" - blink.download_videos(save_dir, since=get_date()) + await blink.download_videos(save_dir, since=get_date()) -def start(): +async def start(session: ClientSession): """Startup blink app.""" - blink = Blink() - blink.auth = Auth(json_load(CREDFILE)) - blink.start() + blink = Blink(session=session) + blink.auth = Auth(await json_load(CREDFILE)) + await blink.start() return blink -def main(): +async def main(): """Run the blink app.""" - blink = start() - download_videos(blink) + session = ClientSession() + blink = await start(session) + await download_videos(blink) blink.save(CREDFILE) + await session.close() if __name__ == "__main__": - main() + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/blinkpy/api.py b/blinkpy/api.py index 3eaa0b61..0fa5bb3f 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -15,7 +15,7 @@ MIN_THROTTLE_TIME = 5 -def request_login( +async def request_login( auth, url, login_data, @@ -45,7 +45,7 @@ def request_login( } ) - return auth.query( + return await auth.query( url=url, headers=headers, data=data, @@ -55,11 +55,11 @@ def request_login( ) -def request_verify(auth, blink, verify_key): +async def request_verify(auth, blink, verify_key): """Send verification key to blink servers.""" 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( + return await auth.query( url=url, headers=auth.header, data=data, @@ -68,19 +68,19 @@ def request_verify(auth, blink, verify_key): ) -def request_logout(blink): +async def request_logout(blink): """Logout of blink servers.""" url = f"{blink.urls.base_url}/api/v4/account/{blink.account_id}/client/{blink.client_id}/logout" - return http_post(blink, url=url) + return await http_post(blink, url=url) -def request_networks(blink): +async def request_networks(blink): """Request all networks information.""" url = f"{blink.urls.base_url}/networks" - return http_get(blink, url) + return await http_get(blink, url) -def request_network_update(blink, network): +async def request_network_update(blink, network): """ Request network update. @@ -88,16 +88,16 @@ def request_network_update(blink, network): :param network: Sync module network id. """ url = f"{blink.urls.base_url}/network/{network}/update" - return http_post(blink, url) + return await http_post(blink, url) -def request_user(blink): +async def request_user(blink): """Get user information from blink servers.""" url = f"{blink.urls.base_url}/user" - return http_get(blink, url) + return await http_get(blink, url) -def request_network_status(blink, network): +async def request_network_status(blink, network): """ Request network information. @@ -105,10 +105,10 @@ def request_network_status(blink, network): :param network: Sync module network id. """ url = f"{blink.urls.base_url}/network/{network}" - return http_get(blink, url) + return await http_get(blink, url) -def request_syncmodule(blink, network): +async def request_syncmodule(blink, network): """ Request sync module info. @@ -116,11 +116,11 @@ def request_syncmodule(blink, network): :param network: Sync module network id. """ url = f"{blink.urls.base_url}/network/{network}/syncmodules" - return http_get(blink, url) + return await http_get(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) -def request_system_arm(blink, network): +async def request_system_arm(blink, network): """ Arm system. @@ -128,11 +128,11 @@ def request_system_arm(blink, network): :param network: Sync module network id. """ url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/networks/{network}/state/arm" - return http_post(blink, url) + return await http_post(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) -def request_system_disarm(blink, network): +async def request_system_disarm(blink, network): """ Disarm system. @@ -140,10 +140,10 @@ def request_system_disarm(blink, network): :param network: Sync module network id. """ url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/networks/{network}/state/disarm" - return http_post(blink, url) + return await http_post(blink, url) -def request_command_status(blink, network, command_id): +async def request_command_status(blink, network, command_id): """ Request command status. @@ -152,18 +152,18 @@ def request_command_status(blink, network, command_id): :param command_id: Command id to check. """ url = f"{blink.urls.base_url}/network/{network}/command/{command_id}" - return http_get(blink, url) + return await http_get(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) -def request_homescreen(blink): +async def request_homescreen(blink): """Request homescreen info.""" url = f"{blink.urls.base_url}/api/v3/accounts/{blink.account_id}/homescreen" - return http_get(blink, url) + return await http_get(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) -def request_sync_events(blink, network): +async def request_sync_events(blink, network): """ Request events from sync module. @@ -171,11 +171,11 @@ def request_sync_events(blink, network): :param network: Sync module network id. """ url = f"{blink.urls.base_url}/events/network/{network}" - return http_get(blink, url) + return await http_get(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) -def request_new_image(blink, network, camera_id): +async def request_new_image(blink, network, camera_id): """ Request to capture new thumbnail for camera. @@ -184,11 +184,11 @@ def request_new_image(blink, network, camera_id): :param camera_id: Camera ID of camera to request new image from. """ url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/thumbnail" - return http_post(blink, url) + return await http_post(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) -def request_new_video(blink, network, camera_id): +async def request_new_video(blink, network, camera_id): """ Request to capture new video clip. @@ -197,17 +197,17 @@ def request_new_video(blink, network, camera_id): :param camera_id: Camera ID of camera to request new video from. """ url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/clip" - return http_post(blink, url) + return await http_post(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) -def request_video_count(blink): +async def request_video_count(blink): """Request total video count.""" url = f"{blink.urls.base_url}/api/v2/videos/count" - return http_get(blink, url) + return await http_get(blink, url) -def request_videos(blink, time=None, page=0): +async def request_videos(blink, time=None, page=0): """ Perform a request for videos. @@ -217,10 +217,10 @@ def request_videos(blink, time=None, page=0): """ timestamp = get_time(time) url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/media/changed?since={timestamp}&page={page}" - return http_get(blink, url) + return await http_get(blink, url) -def request_cameras(blink, network): +async def request_cameras(blink, network): """ Request all camera information. @@ -228,10 +228,10 @@ def request_cameras(blink, network): :param network: Sync module network id. """ url = f"{blink.urls.base_url}/network/{network}/cameras" - return http_get(blink, url) + return await http_get(blink, url) -def request_camera_info(blink, network, camera_id): +async def request_camera_info(blink, network, camera_id): """ Request camera info for one camera. @@ -240,20 +240,20 @@ def request_camera_info(blink, network, camera_id): :param camera_id: Camera ID of camera to request info from. """ url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/config" - return http_get(blink, url) + return await http_get(blink, url) -def request_camera_usage(blink): +async 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) + return await http_get(blink, url) -def request_camera_liveview(blink, network, camera_id): +async def request_camera_liveview(blink, network, camera_id): """ Request camera liveview. @@ -262,10 +262,10 @@ def request_camera_liveview(blink, network, camera_id): :param camera_id: Camera ID of camera to request liveview from. """ url = f"{blink.urls.base_url}/api/v5/accounts/{blink.account_id}/networks/{network}/cameras/{camera_id}/liveview" - return http_post(blink, url) + return await http_post(blink, url) -def request_camera_sensors(blink, network, camera_id): +async def request_camera_sensors(blink, network, camera_id): """ Request camera sensor info for one camera. @@ -274,11 +274,11 @@ def request_camera_sensors(blink, network, camera_id): :param camera_id: Camera ID of camera to request sesnor info from. """ url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/signals" - return http_get(blink, url) + return await http_get(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) -def request_motion_detection_enable(blink, network, camera_id): +async def request_motion_detection_enable(blink, network, camera_id): """ Enable motion detection for a camera. @@ -287,11 +287,11 @@ def request_motion_detection_enable(blink, network, camera_id): :param camera_id: Camera ID of camera to enable. """ url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/enable" - return http_post(blink, url) + return await http_post(blink, url) @Throttle(seconds=MIN_THROTTLE_TIME) -def request_motion_detection_disable(blink, network, camera_id): +async def request_motion_detection_disable(blink, network, camera_id): """Disable motion detection for a camera. :param blink: Blink instance. @@ -299,10 +299,10 @@ def request_motion_detection_disable(blink, network, camera_id): :param camera_id: Camera ID of camera to disable. """ url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/disable" - return http_post(blink, url) + return await http_post(blink, url) -def request_local_storage_manifest(blink, network, sync_id): +async def request_local_storage_manifest(blink, network, sync_id): """Request creation of an updated manifest of video clips stored in sync module local storage. :param blink: Blink instance. @@ -313,10 +313,10 @@ def request_local_storage_manifest(blink, network, sync_id): f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/networks/{network}/sync_modules/{sync_id}" + "/local_storage/manifest/request" ) - return http_post(blink, url) + return await http_post(blink, url) -def get_local_storage_manifest(blink, network, sync_id, manifest_request_id): +async def get_local_storage_manifest(blink, network, sync_id, manifest_request_id): """Request manifest of video clips stored in sync module local storage. :param blink: Blink instance. @@ -328,10 +328,10 @@ def get_local_storage_manifest(blink, network, sync_id, manifest_request_id): f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/networks/{network}/sync_modules/{sync_id}" + f"/local_storage/manifest/request/{manifest_request_id}" ) - return http_get(blink, url) + return await http_get(blink, url) -def request_local_storage_clip(blink, network, sync_id, manifest_id, clip_id): +async def request_local_storage_clip(blink, network, sync_id, manifest_id, clip_id): """Prepare video clip stored in the sync module to be downloaded. :param blink: Blink instance. @@ -349,10 +349,10 @@ def request_local_storage_clip(blink, network, sync_id, manifest_id, clip_id): manifest_id=manifest_id, clip_id=clip_id, ) - return http_post(blink, url) + return await http_post(blink, url) -def request_get_config(blink, network, camera_id, product_type="owl"): +async def request_get_config(blink, network, camera_id, product_type="owl"): """Get camera configuration. :param blink: Blink instance. @@ -371,10 +371,12 @@ def request_get_config(blink, network, camera_id, product_type="owl"): product_type, ) return None - return http_get(blink, url) + return await http_get(blink, url) -def request_update_config(blink, network, camera_id, product_type="owl", data=None): +async def request_update_config( + blink, network, camera_id, product_type="owl", data=None +): """Update camera configuration. :param blink: Blink instance. @@ -394,10 +396,12 @@ def request_update_config(blink, network, camera_id, product_type="owl", data=No product_type, ) return None - return http_post(blink, url, json=False, data=data) + return await http_post(blink, url, json=False, data=data) -def http_get(blink, url, stream=False, json=True, is_retry=False, timeout=TIMEOUT): +async def http_get( + blink, url, stream=False, json=True, is_retry=False, timeout=TIMEOUT +): """Perform an http get request. :param url: URL to perform get request. @@ -406,7 +410,7 @@ def http_get(blink, url, stream=False, json=True, is_retry=False, timeout=TIMEOU :param is_retry: Is this part of a re-auth attempt? """ _LOGGER.debug("Making GET request to %s", url) - return blink.auth.query( + return await blink.auth.query( url=url, headers=blink.auth.header, reqtype="get", @@ -416,7 +420,7 @@ def http_get(blink, url, stream=False, json=True, is_retry=False, timeout=TIMEOU ) -def http_post(blink, url, is_retry=False, data=None, json=True, timeout=TIMEOUT): +async def http_post(blink, url, is_retry=False, data=None, json=True, timeout=TIMEOUT): """Perform an http post request. :param url: URL to perfom post request. @@ -425,7 +429,7 @@ def http_post(blink, url, is_retry=False, data=None, json=True, timeout=TIMEOUT) :param json: Return json response? TRUE/False """ _LOGGER.debug("Making POST request to %s", url) - return blink.auth.query( + return await blink.auth.query( url=url, headers=blink.auth.header, reqtype="post", diff --git a/blinkpy/auth.py b/blinkpy/auth.py index a7e11f85..ca95321f 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -1,9 +1,6 @@ """Login handler for blink.""" import logging -from functools import partial -from requests import Request, Session, exceptions -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry +from aiohttp import ClientSession, ClientConnectionError from blinkpy import api from blinkpy.helpers import util from blinkpy.helpers.constants import ( @@ -19,7 +16,7 @@ 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, session=None): """ Initialize auth handler. @@ -41,7 +38,10 @@ def __init__(self, login_data=None, no_prompt=False): self.login_response = None self.is_errored = False self.no_prompt = no_prompt - self.session = self.create_session() + if session: + self.session = session + else: + self.session = ClientSession() @property def login_attributes(self): @@ -64,54 +64,27 @@ def header(self): "content-type": "application/json", } - 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=retries, backoff_factor=backoff, status_forcelist=retry_list - ) - adapter = HTTPAdapter(max_retries=retry) - sess.mount("https://", adapter) - sess.mount("http://", adapter) - sess.get = partial(sess.get, timeout=TIMEOUT) - 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.""" 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) - self.data = util.validate_login_data(self.data) - def login(self, login_url=LOGIN_ENDPOINT): + async 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( + response = await api.request_login( self, login_url, self.data, is_retry=False, ) try: - if response.status_code == 200: - return response.json() + if response.status == 200: + return await response.json() raise LoginError except AttributeError as error: raise LoginError from error @@ -120,12 +93,12 @@ def logout(self, blink): """Log out.""" return api.request_logout(blink) - def refresh_token(self): + async def refresh_token(self): """Refresh auth token.""" self.is_errored = True try: _LOGGER.info("Token expired, attempting automatic refresh.") - self.login_response = self.login() + self.login_response = await self.login() self.extract_login_info() self.is_errored = False except LoginError as error: @@ -144,33 +117,31 @@ def extract_login_info(self): self.client_id = self.login_response["account"]["client_id"] self.account_id = self.login_response["account"]["account_id"] - def startup(self): + async def startup(self): """Initialize tokens for communication.""" self.validate_login() if None in self.login_attributes.values(): - self.refresh_token() + await self.refresh_token() - def validate_response(self, response, json_resp): + async 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]: + if response.status in [101, 401]: raise UnauthorizedError - if response.status_code == 404: - raise exceptions.ConnectionError - json_data = response.json() - except KeyError: - pass + if response.status == 404: + raise ClientConnectionError + json_data = await response.json() except (AttributeError, ValueError) as error: raise BlinkBadResponse from error self.is_errored = False return json_data - def query( + async def query( self, url=None, data=None, @@ -181,9 +152,8 @@ def query( is_retry=False, timeout=TIMEOUT, ): + """Perform server requests.""" """ - Perform server requests. - :param url: URL to perform request :param data: Data to send :param headers: Headers to send @@ -192,11 +162,17 @@ def query( :param json_resp: Return JSON response? 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, timeout=timeout) - return self.validate_response(response, json_resp) - except (exceptions.ConnectionError, exceptions.Timeout): + if reqtype == "get": + response = await self.session.get( + url=url, data=data, headers=headers, timeout=timeout + ) + else: + response = await self.session.post( + url=url, data=data, headers=headers, timeout=timeout + ) + return await self.validate_response(response, json_resp) + except (ClientConnectionError, TimeoutError): _LOGGER.error( "Connection error. Endpoint %s possibly down or throttled.", url, @@ -205,7 +181,7 @@ def query( code = None reason = None try: - code = response.status_code + code = response.status reason = response.reason except AttributeError: pass @@ -218,8 +194,8 @@ def query( except UnauthorizedError: try: if not is_retry: - self.refresh_token() - return self.query( + await self.refresh_token() + return await self.query( url=url, data=data, headers=self.header, @@ -234,14 +210,14 @@ def query( _LOGGER.error("Unable to refresh token.") return None - def send_auth_key(self, blink, key): + async 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) + response = await api.request_verify(self, blink, key) try: - json_resp = response.json() + json_resp = await response.json() blink.available = json_resp["valid"] - if not json_resp["valid"]: + if not blink.available: _LOGGER.error("%s", json_resp["message"]) return False except (KeyError, TypeError): diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index f447a6cb..8f81037d 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -17,7 +17,8 @@ import time import logging import datetime -from shutil import copyfileobj +import aiofiles +from aiofiles import ospath from requests.structures import CaseInsensitiveDict from dateutil.parser import parse @@ -46,19 +47,20 @@ def __init__( refresh_rate=DEFAULT_REFRESH, motion_interval=DEFAULT_MOTION_INTERVAL, no_owls=False, + session=None, ): """ Initialize Blink system. :param refresh_rate: Refresh rate of blink information. - Defaults to 15 (seconds) + Defaults to 30 (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_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.auth = Auth(session=session) self.account_id = None self.client_id = None self.network_ids = [] @@ -77,22 +79,22 @@ def __init__( self.no_owls = no_owls @util.Throttle(seconds=MIN_THROTTLE_TIME) - def refresh(self, force=False, force_cache=False): + async def refresh(self, force=False, force_cache=False): """ Perform a system refresh. :param force: Used to override throttle, resets refresh :param force_cache: Used to force update without overriding throttle """ - if self.check_if_ok_to_update() or force or force_cache: + if force or force_cache or self.check_if_ok_to_update(): if not self.available: - self.setup_post_verify() + await self.setup_post_verify() - self.get_homescreen() + await self.get_homescreen() for sync_name, sync_module in self.sync.items(): _LOGGER.debug("Attempting refresh of blink.sync['%s']", sync_name) - sync_module.refresh(force_cache=(force or force_cache)) + await sync_module.refresh(force_cache=(force or force_cache)) if not force_cache: # Prevents rapid clearing of motion detect property @@ -103,13 +105,13 @@ def refresh(self, force=False, force_cache=False): return True return False - def start(self): + async def start(self): """Perform full system setup.""" try: - self.auth.startup() + await self.auth.startup() self.setup_login_ids() self.setup_urls() - self.get_homescreen() + await self.get_homescreen() except (LoginError, TokenRefreshFailed, BlinkSetupError): _LOGGER.error("Cannot setup Blink platform.") self.available = False @@ -119,7 +121,7 @@ def start(self): if self.key_required: if self.auth.no_prompt: return True - self.setup_prompt_2fa() + await self.setup_prompt_2fa() if not self.last_refresh: # Initialize last_refresh to be just before the refresh delay period. @@ -129,28 +131,28 @@ def start(self): + f"{datetime.datetime.fromtimestamp(self.last_refresh)}" ) - return self.setup_post_verify() + return await self.setup_post_verify() - def setup_prompt_2fa(self): + async 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) + result = await self.auth.send_auth_key(self, pin) self.key_required = not result - def setup_post_verify(self): + async def setup_post_verify(self): """Initialize blink system after verification.""" try: - self.setup_networks() + await self.setup_networks() networks = self.setup_network_ids() - cameras = self.setup_camera_list() + cameras = await 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) + await self.setup_sync_module(name, network_id, sync_cameras) self.cameras = self.merge_cameras() @@ -158,20 +160,20 @@ def setup_post_verify(self): self.key_required = False return True - def setup_sync_module(self, name, network_id, cameras): + async 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() + await self.sync[name].start() - def get_homescreen(self): + async def get_homescreen(self): """Get homecreen information.""" if self.no_owls: _LOGGER.debug("Skipping owl extraction.") self.homescreen = {} return - self.homescreen = api.request_homescreen(self) + self.homescreen = await api.request_homescreen(self) - def setup_owls(self): + async def setup_owls(self): """Check for mini cameras.""" network_list = [] camera_list = [] @@ -187,7 +189,7 @@ def setup_owls(self): if owl["onboarded"]: network_list.append(str(network_id)) self.sync[name] = BlinkOwl(self, name, network_id, owl) - self.sync[name].start() + await self.sync[name].start() except KeyError: # No sync-less devices found pass @@ -195,7 +197,7 @@ def setup_owls(self): self.network_ids.extend(network_list) return camera_list - def setup_lotus(self): + async def setup_lotus(self): """Check for doorbells cameras.""" network_list = [] camera_list = [] @@ -217,7 +219,7 @@ def setup_lotus(self): if lotus["onboarded"]: network_list.append(str(network_id)) self.sync[name] = BlinkLotus(self, name, network_id, lotus) - self.sync[name].start() + await self.sync[name].start() except KeyError: # No sync-less devices found pass @@ -225,10 +227,10 @@ def setup_lotus(self): self.network_ids.extend(network_list) return camera_list - def setup_camera_list(self): + async def setup_camera_list(self): """Create camera list for onboarded networks.""" all_cameras = {} - response = api.request_camera_usage(self) + response = await api.request_camera_usage(self) try: for network in response["networks"]: camera_network = str(network["network_id"]) @@ -238,8 +240,8 @@ def setup_camera_list(self): all_cameras[camera_network].append( {"name": camera["name"], "id": camera["id"], "type": "default"} ) - mini_cameras = self.setup_owls() - lotus_cameras = self.setup_lotus() + mini_cameras = await self.setup_owls() + lotus_cameras = await self.setup_lotus() for camera in mini_cameras: for network, camera_info in camera.items(): all_cameras[network].append(camera_info) @@ -247,9 +249,9 @@ def setup_camera_list(self): for network, camera_info in camera.items(): all_cameras[network].append(camera_info) return all_cameras - except (KeyError, TypeError): + except (KeyError, TypeError) as ex: _LOGGER.error("Unable to retrieve cameras from response %s", response) - raise BlinkSetupError + raise BlinkSetupError from ex def setup_login_ids(self): """Retrieve login id numbers from login response.""" @@ -260,19 +262,19 @@ def setup_urls(self): """Create urls for api.""" try: self.urls = util.BlinkURLHandler(self.auth.region_id) - except TypeError: + except TypeError as ex: _LOGGER.error( "Unable to extract region is from response %s", self.auth.login_response ) - raise BlinkSetupError + raise BlinkSetupError from ex - def setup_networks(self): + async def setup_networks(self): """Get network information.""" - response = api.request_networks(self) + response = await api.request_networks(self) try: self.networks = response["summary"] - except (KeyError, TypeError): - raise BlinkSetupError + except (KeyError, TypeError) as ex: + raise BlinkSetupError from ex def setup_network_ids(self): """Create the network ids for onboarded networks.""" @@ -283,11 +285,11 @@ def setup_network_ids(self): if status["onboarded"]: all_networks.append(f"{network}") network_dict[status["name"]] = network - except AttributeError: + except AttributeError as ex: _LOGGER.error( "Unable to retrieve network information from %s", self.networks ) - raise BlinkSetupError + raise BlinkSetupError from ex self.network_ids = all_networks return network_dict @@ -309,11 +311,11 @@ def merge_cameras(self): combined = util.merge_dicts(combined, self.sync[sync].cameras) return combined - def save(self, file_name): + async def save(self, file_name): """Save login data to file.""" - util.json_save(self.auth.login_attributes, file_name) + await util.json_save(self.auth.login_attributes, file_name) - def download_videos( + async def download_videos( self, path, since=None, camera="all", stop=10, delay=1, debug=False ): """ @@ -333,10 +335,10 @@ def download_videos( if not isinstance(camera, list): camera = [camera] - results = self.get_videos_metadata(since=since, stop=stop) - self._parse_downloaded_items(results, camera, path, delay, debug) + results = await self.get_videos_metadata(since=since, stop=stop) + await self._parse_downloaded_items(results, camera, path, delay, debug) - def get_videos_metadata(self, since=None, camera="all", stop=10): + async def get_videos_metadata(self, since=None, camera="all", stop=10): """ Fetch and return video metadata. @@ -356,7 +358,7 @@ def get_videos_metadata(self, since=None, camera="all", stop=10): _LOGGER.info("Retrieving videos since %s", formatted_date) for page in range(1, stop): - response = api.request_videos(self, time=since_epochs, page=page) + response = await api.request_videos(self, time=since_epochs, page=page) _LOGGER.debug("Processing page %s", page) try: result = response["media"] @@ -368,13 +370,13 @@ def get_videos_metadata(self, since=None, camera="all", stop=10): break return videos - def do_http_get(self, address): + async def do_http_get(self, address): """ Do an http_get on address. :param address: address to be added to base_url. """ - response = api.http_get( + response = await api.http_get( self, url=f"{self.urls.base_url}{address}", stream=True, @@ -383,7 +385,7 @@ def do_http_get(self, address): ) return response - def _parse_downloaded_items(self, result, camera, path, delay, debug): + async def _parse_downloaded_items(self, result, camera, path, delay, debug): """Parse downloaded videos.""" for item in result: try: @@ -408,13 +410,13 @@ def _parse_downloaded_items(self, result, camera, path, delay, debug): filename = os.path.join(path, filename) if not debug: - if os.path.isfile(filename): + if await ospath.isfile(filename): _LOGGER.info("%s already exists, skipping...", filename) continue - response = self.do_http_get(address) - with open(filename, "wb") as vidfile: - copyfileobj(response.raw, vidfile) + response = await self.do_http_get(address) + async with aiofiles.open(filename, "wb") as vidfile: + await vidfile.write(await response.read()) _LOGGER.info("Downloaded video to %s", filename) else: diff --git a/blinkpy/camera.py b/blinkpy/camera.py index b34b48c2..80d61d38 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -1,11 +1,13 @@ """Defines Blink cameras.""" import copy import string -from shutil import copyfileobj +import os import logging import datetime from json import dumps import traceback +import aiohttp +from aiofiles import open from requests.compat import urljoin from blinkpy import api from blinkpy.helpers.constants import TIMEOUT_MEDIA @@ -98,21 +100,20 @@ def arm(self): """Return arm status of camera.""" return self.motion_enabled - @arm.setter - def arm(self, value): + async def async_arm(self, value): """Set camera arm status.""" if value: - return api.request_motion_detection_enable( + return await api.request_motion_detection_enable( self.sync.blink, self.network_id, self.camera_id ) - return api.request_motion_detection_disable( + return await api.request_motion_detection_disable( self.sync.blink, self.network_id, self.camera_id ) @property - def night_vision(self): + async def night_vision(self): """Return night_vision status.""" - res = api.request_get_config( + res = await api.request_get_config( self.sync.blink, self.network_id, self.camera_id, @@ -133,43 +134,44 @@ def night_vision(self): ] return {key: res.get(key) for key in nv_keys} - @night_vision.setter - def night_vision(self, value): + async def async_set_night_vision(self, value): """Set camera night_vision status.""" if value not in ["on", "off", "auto"]: return None if self.product_type == "catalina": value = {"off": 0, "on": 1, "auto": 2}.get(value, None) data = dumps({"illuminator_enable": value}) - res = api.request_update_config( + res = await api.request_update_config( self.sync.blink, self.network_id, self.camera_id, product_type=self.product_type, data=data, ) - if res.ok: - return res.json() + if res and res.status == 200: + return await res.json() return None - def record(self): + async def record(self): """Initiate clip recording.""" - return api.request_new_video(self.sync.blink, self.network_id, self.camera_id) + return await api.request_new_video( + self.sync.blink, self.network_id, self.camera_id + ) - def get_media(self, media_type="image"): + async def get_media(self, media_type="image") -> aiohttp.ClientRequest: """Download media (image or video).""" if media_type.lower() == "video": - return self.get_video_clip() - return self.get_thumbnail() + return await self.get_video_clip() + return await self.get_thumbnail() - def get_thumbnail(self, url=None): + async def get_thumbnail(self, url=None): """Download thumbnail image.""" if not url: url = self.thumbnail if not url: _LOGGER.warning(f"Thumbnail URL not available: self.thumbnail={url}") return None - return api.http_get( + return await api.http_get( self.sync.blink, url=url, stream=True, @@ -177,44 +179,48 @@ def get_thumbnail(self, url=None): timeout=TIMEOUT_MEDIA, ) - def get_video_clip(self, url=None): + async def get_video_clip(self, url=None): """Download video clip.""" if not url: url = self.clip if not url: _LOGGER.warning(f"Video clip URL not available: self.clip={url}") return None - response = api.http_get( + return await api.http_get( self.sync.blink, url=url, stream=True, json=False, timeout=TIMEOUT_MEDIA, ) - return response - def snap_picture(self): + async 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) + return await api.request_new_image( + self.sync.blink, self.network_id, self.camera_id + ) - def set_motion_detect(self, enable): + async 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( + return await api.request_motion_detection_enable( self.sync.blink, self.network_id, self.camera_id ) - return api.request_motion_detection_disable( + return await api.request_motion_detection_disable( self.sync.blink, self.network_id, self.camera_id ) - def update(self, config, force_cache=False, expire_clips=True, **kwargs): + async def update(self, config, force_cache=False, expire_clips=True, **kwargs): """Update camera info.""" - self.extract_config_info(config) - self.get_sensor_info() - self.update_images(config, force_cache=force_cache, expire_clips=expire_clips) + if config != {}: + self.extract_config_info(config) + await self.get_sensor_info() + await self.update_images( + config, force_cache=force_cache, expire_clips=expire_clips + ) def extract_config_info(self, config): """Extract info from config.""" @@ -231,9 +237,9 @@ def extract_config_info(self, config): self.wifi_strength = config.get("wifi_strength", None) self.product_type = config.get("type", None) - def get_sensor_info(self): + async def get_sensor_info(self): """Retrieve calibrated temperatue from special endpoint.""" - resp = api.request_camera_sensors( + resp = await api.request_camera_sensors( self.sync.blink, self.network_id, self.camera_id ) try: @@ -242,7 +248,7 @@ def get_sensor_info(self): self.temperature_calibrated = self.temperature _LOGGER.warning("Could not retrieve calibrated temperature.") - def update_images(self, config, force_cache=False, expire_clips=True): + async def update_images(self, config, force_cache=False, expire_clips=True): """Update images for camera.""" new_thumbnail = None thumb_addr = None @@ -259,9 +265,6 @@ def update_images(self, config, force_cache=False, expire_clips=True): # Check that new full api url has not been returned: if thumb_addr.endswith("&ext="): thumb_string = thumb_addr - except TypeError: - # Thumb address is None - pass if thumb_string is not None: new_thumbnail = urljoin(self.sync.urls.base_url, thumb_string) @@ -277,17 +280,17 @@ def update_images(self, config, force_cache=False, expire_clips=True): clip_addr = None try: - def ts(record): + def timest(record): rec_time = record["time"] iso_time = datetime.datetime.fromisoformat(rec_time) - s = int(iso_time.timestamp()) - return s + stamp = int(iso_time.timestamp()) + return stamp if ( len(self.sync.last_records) > 0 and len(self.sync.last_records[self.name]) > 0 ): - last_records = sorted(self.sync.last_records[self.name], key=ts) + last_records = sorted(self.sync.last_records[self.name], key=timest) for rec in last_records: clip_addr = rec["clip"] self.clip = f"{self.sync.urls.base_url}{clip_addr}" @@ -305,11 +308,10 @@ def ts(record): f"Most recent clip for {self.name} was created at {self.last_record}: {self.clip}" ) except (KeyError, IndexError): - e = traceback.format_exc() + ex = traceback.format_exc() trace = "".join(traceback.format_stack()) - _LOGGER.error(f"Error getting last records for '{self.name}': {e}") + _LOGGER.error(f"Error getting last records for '{self.name}': {ex}") _LOGGER.debug(f"\n{trace}") - pass # If the thumbnail or clip have changed, update the cache update_cached_image = False @@ -322,22 +324,26 @@ def ts(record): update_cached_video = True if new_thumbnail is not None and (update_cached_image or force_cache): - self._cached_image = self.get_media() + response = await self.get_media() + if response and response.status == 200: + self._cached_image = await response.read() if clip_addr is not None and (update_cached_video or force_cache): - self._cached_video = self.get_media(media_type="video") + response = await self.get_media(media_type="video") + if response and response.status == 200: + self._cached_video = await response.read() # Don't let the recent clips list grow without bound. if expire_clips: - self.expire_recent_clips() + await self.expire_recent_clips() - def expire_recent_clips(self, delta=datetime.timedelta(hours=1)): + async def expire_recent_clips(self, delta=datetime.timedelta(hours=1)): """Remove recent clips from list when they get too old.""" to_keep = [] for clip in self.recent_clips: - t = (datetime.datetime.now() - delta).timestamp() + timedelta = (datetime.datetime.now() - delta).timestamp() clip_time = datetime.datetime.fromisoformat(clip["time"]).timestamp() - if clip_time > t: + if clip_time > timedelta: to_keep.append(clip) num_expired = len(self.recent_clips) - len(to_keep) if num_expired > 0: @@ -350,50 +356,48 @@ def expire_recent_clips(self, delta=datetime.timedelta(hours=1)): for clip in self.recent_clips: url = clip["clip"] if "local_storage" in url: - api.http_post(self.sync.blink, url) + await api.http_post(self.sync.blink, url) - def get_liveview(self): + async def get_liveview(self): """Get livewview rtsps link.""" - response = api.request_camera_liveview( + response = await api.request_camera_liveview( self.sync.blink, self.sync.network_id, self.camera_id ) return response["server"] - def image_to_file(self, path): + async def image_to_file(self, path): """ Write image to file. :param path: Path to write file """ _LOGGER.debug("Writing image from %s to %s", self.name, path) - response = self.get_media() - if response.status_code == 200: - with open(path, "wb") as imgfile: - copyfileobj(response.raw, imgfile) + response = await self.get_media() + if response and response.status == 200: + async with open(path, "wb") as imgfile: + await imgfile.write(await response.read()) else: - _LOGGER.error( - "Cannot write image to file, response %s", response.status_code - ) + _LOGGER.error("Cannot write image to file, response %s", response.status) - def video_to_file(self, path): + async def video_to_file(self, path): """ Write video to file. :param path: Path to write file """ _LOGGER.debug("Writing video from %s to %s", self.name, path) - response = self.get_media(media_type="video") + response = await self.get_media(media_type="video") if response is None: _LOGGER.error("No saved video exists for %s.", self.name) return - with open(path, "wb") as vidfile: - copyfileobj(response.raw, vidfile) + async with open(path, "wb") as vidfile: + await vidfile.write(await response.read()) - def save_recent_clips( + async def save_recent_clips( self, output_dir="/tmp", file_pattern="${created}_${name}.mp4" ): """Save all recent clips using timestamp file name pattern.""" - if not output_dir[-1] == "/": + if output_dir[-1] != "/": output_dir += "/" recent = copy.deepcopy(self.recent_clips) @@ -406,22 +410,24 @@ def save_recent_clips( ).astimezone(tz=None) created_at = clip_time_local.strftime("%Y%m%d_%H%M%S") clip_addr = clip["clip"] - path = output_dir + string.Template(file_pattern).substitute( + + file_name = string.Template(file_pattern).substitute( created=created_at, name=to_alphanumeric(self.name) ) + path = os.path.join(output_dir, file_name) _LOGGER.debug(f"Saving {clip_addr} to {path}") - media = self.get_video_clip(clip_addr) - if media.status_code == 200: - with open(path, "wb") as clip_file: - copyfileobj(media.raw, clip_file) + media = await self.get_video_clip(clip_addr) + if media and media.status == 200: + async with open(path, "wb") as clip_file: + await clip_file.write(await media.read()) num_saved += 1 try: # Remove recent clip from the list once the download has finished. self.recent_clips.remove(clip) _LOGGER.debug(f"Removed {clip} from recent clips") except ValueError: - e = traceback.format_exc() - _LOGGER.error(f"Error removing clip from list: {e}") + ex = traceback.format_exc() + _LOGGER.error(f"Error removing clip from list: {ex}") trace = "".join(traceback.format_stack()) _LOGGER.debug(f"\n{trace}") @@ -446,25 +452,24 @@ def arm(self): """Return camera arm status.""" return self.sync.arm - @arm.setter - def arm(self, value): + async def async_arm(self, value): """Set camera arm status.""" url = f"{self.sync.urls.base_url}/api/v1/accounts/{self.sync.blink.account_id}/networks/{self.network_id}/owls/{self.camera_id}/config" data = dumps({"enabled": value}) - return api.http_post(self.sync.blink, url, json=False, data=data) + return await api.http_post(self.sync.blink, url, json=False, data=data) - def snap_picture(self): + async 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) + return await api.http_post(self.sync.blink, url) - def get_sensor_info(self): + async def get_sensor_info(self): """Get sensor info for blink mini camera.""" - def get_liveview(self): + async 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) + response = await api.http_post(self.sync.blink, url) server = response["server"] server_split = server.split(":") server_split[0] = "rtsps:" @@ -485,28 +490,27 @@ def arm(self): """Return camera arm status.""" return self.motion_enabled - @arm.setter - def arm(self, value): + async def async_arm(self, value): """Set camera arm status.""" url = f"{self.sync.urls.base_url}/api/v1/accounts/{self.sync.blink.account_id}/networks/{self.sync.network_id}/doorbells/{self.camera_id}" if value: url = f"{url}/enable" else: url = f"{url}/disable" - return api.http_post(self.sync.blink, url) + return await api.http_post(self.sync.blink, url) - def snap_picture(self): + async def snap_picture(self): """Snap picture for a blink doorbell camera.""" url = f"{self.sync.urls.base_url}/api/v1/accounts/{self.sync.blink.account_id}/networks/{self.sync.network_id}/doorbells/{self.camera_id}/thumbnail" - return api.http_post(self.sync.blink, url) + return await api.http_post(self.sync.blink, url) - def get_sensor_info(self): + async def get_sensor_info(self): """Get sensor info for blink doorbell camera.""" - def get_liveview(self): + async def get_liveview(self): """Get liveview link.""" url = f"{self.sync.urls.base_url}/api/v1/accounts/{self.sync.blink.account_id}/networks/{self.sync.network_id}/doorbells/{self.camera_id}/liveview" - response = api.http_post(self.sync.blink, url) + response = await api.http_post(self.sync.blink, url) server = response["server"] link = server.replace("immis://", "rtsps://") return link diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 62e0a380..afd65231 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -3,7 +3,7 @@ import os MAJOR_VERSION = 0 -MINOR_VERSION = 21 +MINOR_VERSION = 22 PATCH_VERSION = 0 __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" diff --git a/blinkpy/helpers/util.py b/blinkpy/helpers/util.py index d31ec97b..749a0cb3 100644 --- a/blinkpy/helpers/util.py +++ b/blinkpy/helpers/util.py @@ -6,6 +6,7 @@ import time import secrets import re +import aiofiles from calendar import timegm from functools import wraps from getpass import getpass @@ -16,11 +17,12 @@ _LOGGER = logging.getLogger(__name__) -def json_load(file_name): +async def json_load(file_name): """Load json credentials from file.""" try: - with open(file_name, "r") as json_file: - data = json.load(json_file) + async with aiofiles.open(file_name, "r") as json_file: + test = await json_file.read() + data = json.loads(test) return data except FileNotFoundError: _LOGGER.error("Could not find %s", file_name) @@ -29,10 +31,10 @@ def json_load(file_name): return None -def json_save(data, file_name): +async 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) + async with aiofiles.open(file_name, "w") as json_file: + await json_file.write(json.dumps(data, indent=4)) def gen_uid(size, uid_format=False): @@ -150,7 +152,7 @@ def __init__(self, seconds=10): def __call__(self, method): """Throttle caller method.""" - def throttle_method(): + async def throttle_method(): """Call when method is throttled.""" return None @@ -161,7 +163,7 @@ def wrapper(*args, **kwargs): now = int(time.time()) last_call_delta = now - self.last_call if force or last_call_delta > self.throttle_time: - result = method(*args, *kwargs) + result = method(*args, **kwargs) self.last_call = now return result diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index cba2a1ef..cc801dc3 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -1,13 +1,11 @@ """Defines a sync module for Blink.""" import logging import string - -from sortedcontainers import SortedSet - import datetime import traceback -import time - +import asyncio +import aiofiles +from sortedcontainers import SortedSet from requests.structures import CaseInsensitiveDict from blinkpy import api from blinkpy.camera import BlinkCamera, BlinkCameraMini, BlinkDoorbell @@ -107,17 +105,16 @@ def local_storage_manifest_ready(self): """Indicate if the manifest is up-to-date.""" return not self._local_storage["manifest_stale"] - @arm.setter - def arm(self, value): + async def async_arm(self, value): """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) + return await api.request_system_arm(self.blink, self.network_id) + return await api.request_system_disarm(self.blink, self.network_id) - def start(self): + async def start(self): """Initialize the system.""" _LOGGER.debug("Initializing the sync module") - response = self.sync_initialize() + response = await self.sync_initialize() if not response: return False @@ -128,21 +125,22 @@ def start(self): except KeyError: _LOGGER.error("Could not extract some sync module info: %s", response) - is_ok = self.get_network_info() + is_ok = await self.get_network_info() - if not is_ok or not self.update_cameras(): + if not is_ok or not await self.update_cameras(): + self.available = False return False self.available = True return True - def sync_initialize(self): + async def sync_initialize(self): """Initialize a sync module.""" # Doesn't include local store info for some reason. - response = api.request_syncmodule(self.blink, self.network_id) + response = await api.request_syncmodule(self.blink, self.network_id) try: self.summary = response["syncmodule"] self.network_id = self.summary["network_id"] - self._init_local_storage(self.summary["id"]) + await self._init_local_storage(self.summary["id"]) except (TypeError, KeyError): _LOGGER.error( "Could not retrieve sync module information with response: %s", response @@ -150,7 +148,7 @@ def sync_initialize(self): return False return response - def _init_local_storage(self, sync_id): + async def _init_local_storage(self, sync_id): """Initialize local storage from homescreen dictionary.""" home_screen = self.blink.homescreen sync_module = None @@ -175,7 +173,7 @@ def _init_local_storage(self, sync_id): return False return sync_module - def update_cameras(self, camera_type=BlinkCamera): + async def update_cameras(self, camera_type=BlinkCamera): """Update cameras from server.""" type_map = { "mini": BlinkCameraMini, @@ -191,14 +189,16 @@ def update_cameras(self, camera_type=BlinkCamera): name = camera_config["name"] self.motion[name] = False unique_info = self.get_unique_info(name) - if blink_camera_type in type_map.keys(): + if blink_camera_type in type_map: camera_type = type_map[blink_camera_type] self.cameras[name] = camera_type(self) - camera_info = self.get_camera_info( + camera_info = await self.get_camera_info( camera_config["id"], unique_info=unique_info ) self._names_table[to_alphanumeric(name)] = name - self.cameras[name].update(camera_info, force_cache=True, force=True) + await 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 @@ -216,22 +216,24 @@ def get_unique_info(self, name): pass return None - def get_events(self, **kwargs): + async def get_events(self, **kwargs): """Retrieve events from server.""" force = kwargs.pop("force", False) - response = api.request_sync_events(self.blink, self.network_id, force=force) + response = await api.request_sync_events( + self.blink, self.network_id, force=force + ) try: return response["event"] except (TypeError, KeyError): _LOGGER.error("Could not extract events: %s", response) return False - def get_camera_info(self, camera_id, **kwargs): + async def get_camera_info(self, camera_id, **kwargs): """Retrieve camera information.""" unique = kwargs.get("unique_info", None) if unique is not None: return unique - response = api.request_camera_info(self.blink, self.network_id, camera_id) + response = await api.request_camera_info(self.blink, self.network_id, camera_id) try: return response["camera"][0] except (TypeError, KeyError): @@ -240,9 +242,11 @@ def get_camera_info(self, camera_id, **kwargs): ) return {} - def get_network_info(self): + async def get_network_info(self): """Retrieve network status.""" - self.network_info = api.request_network_update(self.blink, self.network_id) + self.network_info = await api.request_network_update( + self.blink, self.network_id + ) try: if self.network_info["network"]["sync_module_error"]: raise KeyError @@ -251,22 +255,22 @@ def get_network_info(self): return False return True - def refresh(self, force_cache=False): + async def refresh(self, force_cache=False): """Get all blink cameras and pulls their most recent status.""" - if not self.get_network_info(): + if not await self.get_network_info(): return - self.update_local_storage_manifest() - self.check_new_videos() - for camera_name in self.cameras.keys(): + await self.update_local_storage_manifest() + await self.check_new_videos() + for camera_name in self.cameras: camera_id = self.cameras[camera_name].camera_id - camera_info = self.get_camera_info( + camera_info = await self.get_camera_info( camera_id, unique_info=self.get_unique_info(camera_name), ) - self.cameras[camera_name].update(camera_info, force_cache=force_cache) + await self.cameras[camera_name].update(camera_info, force_cache=force_cache) self.available = True - def check_new_videos(self): + async def check_new_videos(self): """Check if new videos since last refresh.""" _LOGGER.debug("Checking for new videos") try: @@ -277,21 +281,21 @@ def check_new_videos(self): except TypeError: # This is the first start, so refresh hasn't happened yet. # No need to check for motion. - e = traceback.format_exc() + ex = traceback.format_exc() _LOGGER.error( - f"Error calculating interval (last_refresh={self.blink.last_refresh}): {e}" + f"Error calculating interval (last_refresh={self.blink.last_refresh}): {ex}" ) trace = "".join(traceback.format_stack()) _LOGGER.debug(f"\n{trace}") _LOGGER.info("No new videos since last refresh.") return False - resp = api.request_videos(self.blink, time=interval, page=1) + resp = await api.request_videos(self.blink, time=interval, page=1) last_record = {} for camera in self.cameras.keys(): # Initialize the list if doesn't exist yet. - if camera not in self.last_records.keys(): + if camera not in self.last_records: self.last_records[camera] = [] # Hang on to the last record if there is one. if len(self.last_records[camera]) > 0: @@ -339,7 +343,7 @@ def check_new_videos(self): ) last_clip_time = None num_new = 0 - for item in manifest.__reversed__(): + for item in reversed(manifest): iso_timestamp = item.created_at.isoformat() _LOGGER.debug( @@ -347,21 +351,14 @@ def check_new_videos(self): ) # Exit the loop once there are no new videos in the list. if not self.check_new_video_time(iso_timestamp, last_manifest_read): - if num_new > 0: - _LOGGER.info( - f"Found {num_new} new items in local storage manifest " - + f"since last manifest read at {last_read_local}." - ) - else: - _LOGGER.info( - f"No new local storage videos since last manifest read at {last_read_local}." - ) + _LOGGER.info( + f"No new local storage videos since last manifest read at {last_read_local}." + ) break - _LOGGER.debug(f"Found new item in local storage manifest: {item}") name = item.name clip_url = item.url(last_manifest_id) - item.prepare_download(self.blink) + await item.prepare_download(self.blink) self.motion[name] = True record = {"clip": clip_url, "time": iso_timestamp} self.last_records[name].append(record) @@ -376,13 +373,12 @@ def check_new_videos(self): self._local_storage["last_manifest_read"] = last_manifest_read _LOGGER.debug(f"Updated last_manifest_read to {last_manifest_read}") _LOGGER.debug(f"Last clip time was {last_clip_time}") - # We want to keep the last record when no new motion was detected. for camera in self.cameras.keys(): # Check if there are no new records, indicating motion. if len(self.last_records[camera]) == 0: # If no new records, check if we had a previous last record. - if camera in last_record.keys(): + if camera in last_record: # Put the last record back into the empty list. self.last_records[camera].append(last_record[camera]) @@ -394,19 +390,18 @@ def check_new_video_time(self, timestamp, reference=None): :param timestamp ISO-formatted timestamp string :param reference ISO-formatted reference timestamp string """ - if not reference: return time_to_seconds(timestamp) > self.blink.last_refresh return time_to_seconds(timestamp) > time_to_seconds(reference) - def update_local_storage_manifest(self): + async def update_local_storage_manifest(self): """Update local storage manifest, which lists all stored clips.""" if not self.local_storage: self._local_storage["manifest_stale"] = True return None _LOGGER.debug("Updating local storage manifest") - response = self.poll_local_storage_manifest() + response = await self.poll_local_storage_manifest() try: manifest_request_id = response["id"] except (TypeError, KeyError): @@ -416,7 +411,7 @@ def update_local_storage_manifest(self): self._local_storage["manifest_stale"] = True return None - response = self.poll_local_storage_manifest(manifest_request_id) + response = await self.poll_local_storage_manifest(manifest_request_id) try: manifest_id = response["manifest_id"] except (TypeError, KeyError): @@ -436,7 +431,7 @@ def update_local_storage_manifest(self): try: for item in response["clips"]: alphanumeric_name = item["camera_name"] - if alphanumeric_name in self._names_table.keys(): + if alphanumeric_name in self._names_table: camera_name = self._names_table[alphanumeric_name] self._local_storage["manifest"].add( LocalStorageMediaItem( @@ -454,8 +449,8 @@ def update_local_storage_manifest(self): f"Found {num_added} new clip(s) in local storage manifest id={manifest_id}" ) except (TypeError, KeyError): - e = traceback.format_exc() - _LOGGER.error(f"Could not extract clips list from response: {e}") + ex = traceback.format_exc() + _LOGGER.error(f"Could not extract clips list from response: {ex}") trace = "".join(traceback.format_stack()) _LOGGER.debug(f"\n{trace}") self._local_storage["manifest_stale"] = True @@ -464,7 +459,9 @@ def update_local_storage_manifest(self): self._local_storage["manifest_stale"] = False return True - def poll_local_storage_manifest(self, manifest_request_id=None, max_retries=4): + async def poll_local_storage_manifest( + self, manifest_request_id=None, max_retries=4 + ): """Poll for local storage manifest.""" # The sync module may be busy processing another request (like saving a new clip). # Poll the endpoint until it is ready, backing off each retry. @@ -472,21 +469,21 @@ def poll_local_storage_manifest(self, manifest_request_id=None, max_retries=4): for retry in range(max_retries): # Request building the manifest. if not manifest_request_id: - response = api.request_local_storage_manifest( + response = await api.request_local_storage_manifest( self.blink, self.network_id, self.sync_id ) if "id" in response: break # Get the manifest. else: - response = api.get_local_storage_manifest( + response = await api.get_local_storage_manifest( self.blink, self.network_id, self.sync_id, manifest_request_id ) if "clips" in response: break seconds = backoff_seconds(retry=retry, default_time=3) _LOGGER.debug("[retry=%d] Retrying in %d seconds", retry + 1, seconds) - time.sleep(seconds) + await asyncio.sleep(seconds) return response @@ -503,7 +500,7 @@ def __init__(self, blink, name, network_id, response): if not self.serial: self.serial = f"{network_id}-{self.sync_id}" - def sync_initialize(self): + async def sync_initialize(self): """Initialize a sync-less module.""" self.summary = { "id": self.sync_id, @@ -516,11 +513,11 @@ def sync_initialize(self): } return self.summary - def update_cameras(self, camera_type=BlinkCameraMini): + async def update_cameras(self, camera_type=BlinkCameraMini): """Update sync-less cameras.""" - return super().update_cameras(camera_type=BlinkCameraMini) + return await super().update_cameras(camera_type=BlinkCameraMini) - def get_camera_info(self, camera_id, **kwargs): + async def get_camera_info(self, camera_id, **kwargs): """Retrieve camera information.""" try: for owl in self.blink.homescreen["owls"]: @@ -531,7 +528,7 @@ def get_camera_info(self, camera_id, **kwargs): pass return None - def get_network_info(self): + async def get_network_info(self): """Get network info for sync-less module.""" return True @@ -566,7 +563,7 @@ def __init__(self, blink, name, network_id, response): if not self.serial: self.serial = f"{network_id}-{self.sync_id}" - def sync_initialize(self): + async def sync_initialize(self): """Initialize a sync-less module.""" self.summary = { "id": self.sync_id, @@ -579,11 +576,11 @@ def sync_initialize(self): } return self.summary - def update_cameras(self, camera_type=BlinkDoorbell): + async def update_cameras(self, camera_type=BlinkDoorbell): """Update sync-less cameras.""" - return super().update_cameras(camera_type=BlinkDoorbell) + return await super().update_cameras(camera_type=BlinkDoorbell) - def get_camera_info(self, camera_id, **kwargs): + async def get_camera_info(self, camera_id, **kwargs): """Retrieve camera information.""" try: for doorbell in self.blink.homescreen["doorbells"]: @@ -594,7 +591,7 @@ def get_camera_info(self, camera_id, **kwargs): pass return None - def get_network_info(self): + async def get_network_info(self): """Get network info for sync-less module.""" return True @@ -671,21 +668,61 @@ def url(self, manifest_id=None): self._manifest_id = manifest_id return self._build_url(self._manifest_id, self._id) - def prepare_download(self, blink, max_retries=4): + async def prepare_download(self, blink, max_retries=4): """Initiate upload of media item from the sync module to Blink cloud servers.""" url = blink.urls.base_url + self.url() response = None for retry in range(max_retries): - response = api.http_post(blink, url) + response = await api.http_post(blink, url) if "id" in response: break seconds = backoff_seconds(retry=retry, default_time=3) _LOGGER.debug( "[retry=%d] Retrying in %d seconds: %s", retry + 1, seconds, url ) - time.sleep(seconds) + await asyncio.sleep(seconds) return response + async def delete_video(self, blink, max_retries=4) -> bool: + """Delete video from sync module.""" + delete_url = blink.urls.base_url + self.url() + delete_url = delete_url.replace("request", "delete") + + for retry in range(max_retries): + delete = await api.http_post( + blink, delete_url, json=False + ) # Delete the video + if delete.status == 200: + return True + seconds = backoff_seconds(retry=retry, default_time=3) + _LOGGER.debug("[retry=%d] Retrying in %d seconds", retry + 1, seconds) + await asyncio.sleep(seconds) + return False + + async def download_video(self, blink, file_name, max_retries=4) -> bool: + """Download a previously prepared video from sync module.""" + for retry in range(max_retries): + url = blink.urls.base_url + self.url() + video = await api.http_get(blink, url, json=False) + if video.status == 200: + async with aiofiles.open(file_name, "wb") as vidfile: + await vidfile.write(await video.read()) # download the video + return True + seconds = backoff_seconds(retry=retry, default_time=3) + _LOGGER.debug( + "[retry=%d] Retrying in %d seconds: %s", retry + 1, seconds, url + ) + await asyncio.sleep(seconds) + return False + + async def download_video_delete(self, blink, file_name, max_retries=4) -> bool: + """Initiate upload of media item from the sync module to Blink cloud servers then download to local filesystem and delete from sync.""" + if await self.prepare_download(blink): + if await self.download_video(blink, file_name): + if await self.delete_video(blink): + return True + return False + def __repr__(self): """Create string representation.""" return ( diff --git a/blinksync/blinksync.py b/blinksync/blinksync.py new file mode 100644 index 00000000..346c649f --- /dev/null +++ b/blinksync/blinksync.py @@ -0,0 +1,109 @@ +import json +import asyncio +import wx +import logging +import aiohttp +import sys +from sortedcontainers import SortedSet +from forms import LoginDialog, VideosForm, DELAY, CLOSE, DELETE, DOWNLOAD, REFRESH +from blinkpy.blinkpy import Blink, BlinkSyncModule +from blinkpy.auth import Auth + + +async def main(): + """Main loop for blink test.""" + session = aiohttp.ClientSession() + blink = Blink(session=session) + app = wx.App() + try: + with wx.DirDialog(None) as dlg: + if dlg.ShowModal() == wx.ID_OK: + path = dlg.GetPath() + else: + sys.exit(0) + + with open(f"{path}/blink.json", "rt", encoding="ascii") as j: + blink.auth = Auth(json.loads(j.read()), session=session) + + except (StopIteration, FileNotFoundError): + with LoginDialog() as userdlg: + userdlg.ShowModal() + userpass = userdlg.getUserPassword() + if userpass is not None: + blink.auth = Auth( + userpass, + session=session, + ) + await blink.save(f"{path}/blink.json") + else: + sys.exit(0) + with wx.BusyInfo("Blink is Working....") as working: + cursor = wx.BusyCursor() + if await blink.start(): + await blink.setup_post_verify() + elif blink.auth.check_key_required(): + print("I failed to authenticate") + + print(f"Sync status: {blink.network_ids}") + print(f"Sync :{blink.networks}") + if len(blink.networks) == 0: + exit() + my_sync: BlinkSyncModule = blink.sync[ + blink.networks[list(blink.networks)[0]]["name"] + ] + cursor = None + working = None + + while True: + with wx.BusyInfo("Blink is Working....") as working: + cursor = wx.BusyCursor() + for name, camera in blink.cameras.items(): + print(name) + print(camera.attributes) + + my_sync._local_storage["manifest"] = SortedSet() + await my_sync.refresh() + if my_sync.local_storage and my_sync.local_storage_manifest_ready: + print("Manifest is ready") + print(f"Manifest {my_sync._local_storage['manifest']}") + else: + print("Manifest not ready") + for name, camera in blink.cameras.items(): + print(f"{camera.name} status: {blink.cameras[name].arm}") + new_vid = await my_sync.check_new_videos() + print(f"New videos?: {new_vid}") + + manifest = my_sync._local_storage["manifest"] + cursor = None + working = None + frame = VideosForm(manifest) + button = frame.ShowModal() + with wx.BusyInfo("Blink is Working....") as working: + cursor = wx.BusyCursor() + if button == CLOSE: + break + if button == REFRESH: + continue + # Download and delete all videos from sync module + for item in reversed(manifest): + if item.id in frame.ItemList: + if button == DOWNLOAD: + await item.prepare_download(blink) + await item.download_video( + blink, + f"{path}/{item.name}_{item.created_at.astimezone().isoformat().replace(':','_')}.mp4", + ) + if button == DELETE: + await item.delete_video(blink) + await asyncio.sleep(DELAY) + cursor = None + working = None + frame = None + await session.close() + + +# Run the program +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/blinksync/forms.py b/blinksync/forms.py new file mode 100644 index 00000000..6e40e8f3 --- /dev/null +++ b/blinksync/forms.py @@ -0,0 +1,122 @@ +import wx + +DELETE = 1 +CLOSE = 2 +DOWNLOAD = 3 +REFRESH = 4 +DELAY = 5 + +class VideosForm(wx.Dialog): + """My delete form.""" + def __init__(self,manifest): + wx.Frame.__init__(self, None, wx.ID_ANY, "Select List to Download and Delete",size = (450,550)) + + # Add a panel so it looks the correct on all platforms + panel = wx.Panel(self, wx.ID_ANY) + #self.Bind(wx.EVT,self._when_closed) + self.index = 0 + self.ItemList = [] + self.list_ctrl = wx.ListCtrl(panel, size=(-1,400), + style=wx.LC_REPORT + |wx.BORDER_SUNKEN + ) + self.list_ctrl.InsertColumn(0, 'Name') + self.list_ctrl.InsertColumn(1, 'Camera') + self.list_ctrl.InsertColumn(2, 'Date', width=225) + self.list_ctrl.Bind(wx.EVT_LIST_ITEM_RIGHT_CLICK,self.download_line) + + btn = wx.Button(panel, label="Download") + btn.Bind(wx.EVT_BUTTON, self.download_line) + + deletebtn = wx.Button(panel, label="Delete") + deletebtn.Bind(wx.EVT_BUTTON, self.delete_line) + + closeBtn = wx.Button(panel, label="Close") + closeBtn.Bind(wx.EVT_BUTTON, self._when_closed) + + refrestBtn = wx.Button(panel, label="Refresh") + refrestBtn.Bind(wx.EVT_BUTTON, self._refresh) + + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(self.list_ctrl, 0, wx.ALL|wx.EXPAND, 20) + sizer_buttons = wx.BoxSizer(wx.HORIZONTAL) + sizer_buttons.Add(btn, 0, wx.ALL|wx.CENTER, 5) + sizer_buttons.Add(deletebtn,0,wx.ALL|wx.CENTER,5) + sizer_buttons.Add(refrestBtn,0,wx.ALL|wx.CENTER,5) + sizer_buttons.Add(closeBtn,0,wx.ALL|wx.CENTER, 5) + sizer.Add(sizer_buttons,0,wx.ALL|wx.CENTER,5) + panel.SetSizer(sizer) + + for item in reversed(manifest): + self.list_ctrl.InsertItem(self.index, str(item.id)) + self.list_ctrl.SetItem(self.index, 1, item.name) + self.list_ctrl.SetItem(self.index, 2, item.created_at.astimezone().isoformat()) + self.index += 1 + #---------------------------------------------------------------------- + def download_line(self, event): + """Add to list and return DOWNLOAD""" + for count in range(self.list_ctrl.ItemCount): + if self.list_ctrl.IsSelected(count): + self.ItemList.append(int(self.list_ctrl.GetItem(count).Text)) + self.EndModal(DOWNLOAD) + + def delete_line(self, event): + """Add to list and return DOWNLOAD""" + for count in range(self.list_ctrl.ItemCount): + if self.list_ctrl.IsSelected(count): + self.ItemList.append(int(self.list_ctrl.GetItem(count).Text)) + self.EndModal(DELETE) + + + def _when_closed(self,event): + self.EndModal(CLOSE) + + def _refresh(self,event): + self.EndModal(REFRESH) + +class LoginDialog(wx.Dialog): + """ + Class to define login dialog + """ + #---------------------------------------------------------------------- + def __init__(self): + """Constructor""" + wx.Dialog.__init__(self, None, title="Login") + + # user info + user_sizer = wx.BoxSizer(wx.HORIZONTAL) + + user_lbl = wx.StaticText(self, label="Username:") + user_sizer.Add(user_lbl, 0, wx.ALL|wx.CENTER, 5) + self.user = wx.TextCtrl(self) + user_sizer.Add(self.user, 0, wx.ALL, 5) + + # pass info + p_sizer = wx.BoxSizer(wx.HORIZONTAL) + + p_lbl = wx.StaticText(self, label="Password:") + p_sizer.Add(p_lbl, 0, wx.ALL|wx.CENTER, 5) + self.password = wx.TextCtrl(self, style=wx.TE_PASSWORD|wx.TE_PROCESS_ENTER) + p_sizer.Add(self.password, 0, wx.ALL, 5) + + main_sizer = wx.BoxSizer(wx.VERTICAL) + main_sizer.Add(user_sizer, 0, wx.ALL, 5) + main_sizer.Add(p_sizer, 0, wx.ALL, 5) + + btn = wx.Button(self, label="Login") + btn.Bind(wx.EVT_BUTTON, self.onLogin) + main_sizer.Add(btn, 0, wx.ALL|wx.CENTER, 5) + + self.SetSizer(main_sizer) + + #---------------------------------------------------------------------- + def onLogin(self, event): + """ + Check credentials and login + """ + self.account = {"username":self.user.Value,"password":self.password.Value} + self.EndModal(wx.ID_OK) + + def getUserPassword(self): + return self.account + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..b0e5a945 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +filterwarnings = + ignore::DeprecationWarning \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 894fd14b..5c98abc7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ python-dateutil>=2.8.1 requests>=2.24.0 python-slugify>=4.0.1 sortedcontainers~=2.4.0 +aiohttp>=3.8.4 +aiofiles>=23.1.0 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index 4d831951..0106e919 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,15 +1,16 @@ -black==23.3.0 -coverage==7.2.5 -flake8==6.0.0 +black==23.7.0 +coverage==7.3.0 +flake8==6.1.0 pre-commit==3.0.4 flake8-docstrings==1.7.0 -pylint==2.17.4 +pylint==2.17.5 pydocstyle==6.3.0 -pytest==7.3.1 -pytest-cov==3.0.0 +pytest==7.4.0 +pytest-cov==4.1.0 pytest-sugar==0.9.7 pytest-timeout==2.1.0 restructuredtext-lint==1.4.0 -pygments==2.15.1 +pygments==2.16.1 testtools>=2.4.0 sortedcontainers~=2.4.0 +pytest-asyncio>=0.21.0 diff --git a/tests/mock_responses.py b/tests/mock_responses.py index a789e803..2cbfe9f6 100644 --- a/tests/mock_responses.py +++ b/tests/mock_responses.py @@ -1,21 +1,19 @@ """Simple mock responses definitions.""" +from unittest import mock class MockResponse: """Class for mock request response.""" - def __init__(self, json_data, status_code, raw_data=None): + def __init__(self, json_data, status_code, headers={}, raw_data=None): """Initialize mock get response.""" self.json_data = json_data - self.status_code = status_code + self.status = status_code self.raw_data = raw_data self.reason = "foobar" + self.headers = headers + self.read = mock.AsyncMock(return_value=self.raw_data) - def json(self): + async def json(self): """Return json data from get_request.""" return self.json_data - - @property - def raw(self): - """Return raw data from get request.""" - return self.raw_data diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..8a37d0dd --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,151 @@ +"""Test api functions.""" + +from unittest import mock +from unittest import IsolatedAsyncioTestCase +from blinkpy import api +from blinkpy.blinkpy import Blink, util +from blinkpy.auth import Auth +import tests.mock_responses as mresp + + +@mock.patch("blinkpy.auth.Auth.query") +class TestAPI(IsolatedAsyncioTestCase): + """Test the API class in blinkpy.""" + + def setUp(self): + """Set up Login Handler.""" + self.blink = Blink(session=mock.AsyncMock()) + self.auth = Auth() + self.blink.available = True + self.blink.urls = util.BlinkURLHandler("region_id") + self.blink.account_id = 1234 + self.blink.client_id = 5678 + + def tearDown(self): + """Clean up after test.""" + self.blink = None + self.auth = None + + async def test_request_verify(self, mock_resp): + """Test api request verify.""" + mock_resp.return_value = mresp.MockResponse({}, 200) + response = await api.request_verify(self.auth, self.blink, "test key") + self.assertEqual(response.status, 200) + + async def test_request_logout(self, mock_resp): + """Test request_logout.""" + mock_resp.return_value = mresp.MockResponse({}, 200) + response = await api.request_logout(self.blink) + self.assertEqual(response.status, 200) + + async def test_request_networks(self, mock_resp): + """Test request networks.""" + mock_resp.return_value = {"networks": "1234"} + self.assertEqual(await api.request_networks(self.blink), {"networks": "1234"}) + + async def test_request_user(self, mock_resp): + """Test request_user.""" + mock_resp.return_value = {"user": "userid"} + self.assertEqual(await api.request_user(self.blink), {"user": "userid"}) + + async def test_request_network_status(self, mock_resp): + """Test request network status.""" + mock_resp.return_value = {"user": "userid"} + self.assertEqual( + await api.request_network_status(self.blink, "network"), {"user": "userid"} + ) + + async def test_request_command_status(self, mock_resp): + """Test command_status.""" + mock_resp.return_value = {"command": "done"} + self.assertEqual( + await api.request_command_status(self.blink, "network", "command"), + {"command": "done"}, + ) + + async def test_request_new_image(self, mock_resp): + """Test api request new image.""" + mock_resp.return_value = mresp.MockResponse({}, 200) + response = await api.request_new_image(self.blink, "network", "camera") + self.assertEqual(response.status, 200) + + async def test_request_new_video(self, mock_resp): + """Test api request new Video.""" + mock_resp.return_value = mresp.MockResponse({}, 200) + response = await api.request_new_video(self.blink, "network", "camera") + self.assertEqual(response.status, 200) + + async def test_request_video_count(self, mock_resp): + """Test api request video count.""" + mock_resp.return_value = {"count": "10"} + self.assertEqual(await api.request_video_count(self.blink), {"count": "10"}) + + async def test_request_cameras(self, mock_resp): + """Test api request cameras.""" + mock_resp.return_value = {"cameras": {"camera_id": 1}} + self.assertEqual( + await api.request_cameras(self.blink, "network"), + {"cameras": {"camera_id": 1}}, + ) + + async def test_request_camera_usage(self, mock_resp): + """Test api request cameras.""" + mock_resp.return_value = {"cameras": "1111"} + self.assertEqual( + await api.request_camera_usage(self.blink), {"cameras": "1111"} + ) + + async def test_request_motion_detection_enable(self, mock_resp): + """Test Motion detect enable.""" + mock_resp.return_value = mresp.MockResponse({}, 200) + response = await api.request_motion_detection_enable( + self.blink, "network", "camera" + ) + self.assertEqual(response.status, 200) + + async def test_request_motion_detection_disable(self, mock_resp): + """Test Motion detect enable.""" + mock_resp.return_value = mresp.MockResponse({}, 200) + response = await api.request_motion_detection_disable( + self.blink, "network", "camera" + ) + self.assertEqual(response.status, 200) + + async def test_request_local_storage_clip(self, mock_resp): + """Test Motion detect enable.""" + mock_resp.return_value = mresp.MockResponse({}, 200) + response = await api.request_local_storage_clip( + self.blink, "network", "sync_id", "manifest_id", "clip_id" + ) + self.assertEqual(response.status, 200) + + async def test_request_get_config(self, mock_resp): + """Test request get config.""" + mock_resp.return_value = {"config": "values"} + self.assertEqual( + await api.request_get_config(self.blink, "network", "camera_id", "owl"), + {"config": "values"}, + ) + self.assertEqual( + await api.request_get_config( + self.blink, "network", "camera_id", "catalina" + ), + {"config": "values"}, + ) + + async def test_request_update_config(self, mock_resp): + """Test Motion detect enable.""" + mock_resp.return_value = mresp.MockResponse({}, 200) + response = await api.request_update_config( + self.blink, "network", "camera_id", "owl" + ) + self.assertEqual(response.status, 200) + response = await api.request_update_config( + self.blink, "network", "camera_id", "catalina" + ) + self.assertEqual(response.status, 200) + self.assertIsNone( + await api.request_update_config( + self.blink, "network", "camera_id", "other_camera" + ) + ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 9c918974..c881fa8e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,11 +1,10 @@ """Test login handler.""" -import unittest from unittest import mock -from requests import exceptions +from unittest import IsolatedAsyncioTestCase +from aiohttp import ClientConnectionError from blinkpy.auth import ( Auth, - LoginError, TokenRefreshFailed, BlinkBadResponse, UnauthorizedError, @@ -17,7 +16,7 @@ PASSWORD = "deadbeef" -class TestAuth(unittest.TestCase): +class TestAuth(IsolatedAsyncioTestCase): """Test the Auth class in blinkpy.""" def setUp(self): @@ -88,39 +87,41 @@ def test_full_init(self): auth.validate_login() self.assertDictEqual(auth.login_attributes, login_data) - def test_bad_response_code(self): + async 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) + with self.assertRaises(ClientConnectionError): + await 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) + await self.auth.validate_response(fake_resp, True) self.assertTrue(self.auth.is_errored) - def test_good_response_code(self): + async 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.assertEqual( + await self.auth.validate_response(fake_resp, True), {"foo": "bar"} + ) self.assertFalse(self.auth.is_errored) - def test_response_not_json(self): + async 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.assertEqual(await self.auth.validate_response(fake_resp, False), "foobar") self.assertFalse(self.auth.is_errored) - def test_response_bad_json(self): + async 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) + await self.auth.validate_response(None, True) self.assertTrue(self.auth.is_errored) def test_header(self): @@ -138,47 +139,46 @@ def test_header_no_token(self): 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 - 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.validate_login") + @mock.patch("blinkpy.auth.Auth.refresh_token") + async def test_auth_startup(self, mock_validate, mock_refresh): + """Test auth startup.""" + await self.auth.startup() - @mock.patch("blinkpy.auth.Auth.login") - def test_refresh_token(self, mock_login): + @mock.patch("blinkpy.auth.Auth.query") + async def test_refresh_token(self, mock_resp): """Test refresh token method.""" - mock_login.return_value = { - "account": {"account_id": 5678, "client_id": 1234, "tier": "test"}, - "auth": {"token": "foobar"}, - } - self.assertTrue(self.auth.refresh_token()) + mock_resp.return_value.json = mock.AsyncMock( + return_value={ + "account": {"account_id": 5678, "client_id": 1234, "tier": "test"}, + "auth": {"token": "foobar"}, + } + ) + mock_resp.return_value.status = 200 + + self.auth.no_prompt = True + self.assertTrue(await 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_resp.return_value.status = 400 + with self.assertRaises(TokenRefreshFailed): + await self.auth.refresh_token() + + mock_resp.return_value.status = 200 + mock_resp.return_value.json = mock.AsyncMock(side_effect=AttributeError) + with self.assertRaises(TokenRefreshFailed): + await self.auth.refresh_token() + @mock.patch("blinkpy.auth.Auth.login") - def test_refresh_token_failed(self, mock_login): + async 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() + await self.auth.refresh_token() self.assertTrue(self.auth.is_errored) def test_check_key_required(self): @@ -193,109 +193,113 @@ def test_check_key_required(self): self.assertTrue(self.auth.check_key_required()) @mock.patch("blinkpy.auth.api.request_logout") - def test_logout(self, mock_req): + async def test_logout(self, mock_req): """Test logout method.""" mock_blink = MockBlink(None) mock_req.return_value = True - self.assertTrue(self.auth.logout(mock_blink)) + self.assertTrue(await self.auth.logout(mock_blink)) @mock.patch("blinkpy.auth.api.request_verify") - def test_send_auth_key(self, mock_req): + async 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(await 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)) + self.assertFalse(await 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.assertFalse(await self.auth.send_auth_key(mock_blink, 1234)) - self.assertTrue(self.auth.send_auth_key(mock_blink, None)) + self.assertTrue(await self.auth.send_auth_key(mock_blink, None)) @mock.patch("blinkpy.auth.api.request_verify") - def test_send_auth_key_fail(self, mock_req): + async 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)) + self.assertFalse(await 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): + self.assertFalse(await self.auth.send_auth_key(mock_blink, 1234)) + mock_req.return_value = mresp.MockResponse( + {"valid": False, "message": "Not good"}, 200 + ) + self.assertFalse(await self.auth.send_auth_key(mock_blink, 1234)) + + @mock.patch( + "blinkpy.auth.Auth.validate_response", + mock.AsyncMock(side_effect=[UnauthorizedError, "foobar"]), + ) + @mock.patch("blinkpy.auth.Auth.refresh_token", mock.AsyncMock(return_value=True)) + @mock.patch("blinkpy.auth.Auth.query", mock.AsyncMock(return_value="foobar")) + async def test_query_retry(self): # , mock_refresh, mock_validate): """Check handling of request retry.""" self.auth.session = MockSession() - mock_validate.side_effect = [UnauthorizedError, "foobar"] - mock_refresh.return_value = True - self.assertEqual(self.auth.query(url="http://example.com"), "foobar") + self.assertEqual(await 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): + async def test_query_retry_failed(self, mock_refresh, mock_validate): """Check handling of failed retry request.""" self.auth.session = MockSession() - mock_validate.side_effect = [UnauthorizedError, BlinkBadResponse] + mock_validate.side_effect = [ + BlinkBadResponse, + UnauthorizedError, + TokenRefreshFailed, + ] mock_refresh.return_value = True - self.assertEqual(self.auth.query(url="http://example.com"), None) + self.assertEqual(await self.auth.query(url="http://example.com"), None) + self.assertEqual(await self.auth.query(url="http://example.com"), None) - mock_validate.side_effect = [UnauthorizedError, TokenRefreshFailed] - self.assertEqual(self.auth.query(url="http://example.com"), None) + @mock.patch("blinkpy.auth.Auth.validate_response") + async def test_query(self, mock_validate): + """Test query functions.""" + self.auth.session = MockSession_with_data() + await self.auth.query("URL", "data", "headers", "get") + await self.auth.query("URL", "data", "headers", "post") - 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] - ) + mock_validate.side_effect = ClientConnectionError + self.assertIsNone(await self.auth.query("URL", "data", "headers", "get")) + + mock_validate.side_effect = BlinkBadResponse + self.assertIsNone(await self.auth.query("URL", "data", "headers", "post")) - 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]) + mock_validate.side_effect = UnauthorizedError + self.auth.refresh_token = mock.AsyncMock() + self.assertIsNone(await self.auth.query("URL", "data", "headers", "post")) class MockSession: """Object to mock a session.""" - def send(self, *args, **kwargs): + async def get(self, *args, **kwargs): """Mock send function.""" return None + async def post(self, *args, **kwargs): + """Mock send function.""" + return None + + +class MockSession_with_data: + """Object to mock a session.""" + + async def get(self, *args, **kwargs): + """Mock send function.""" + response = mock.AsyncMock + response.status = 400 + response.reason = "Some Reason" + return response + + async def post(self, *args, **kwargs): + """Mock send function.""" + response = mock.AsyncMock + response.status = 400 + response.reason = "Some Reason" + return response + class MockBlink: """Object to mock basic blink class.""" diff --git a/tests/test_blink_functions.py b/tests/test_blink_functions.py index 3996e5ae..7b890b84 100644 --- a/tests/test_blink_functions.py +++ b/tests/test_blink_functions.py @@ -1,21 +1,19 @@ """Tests camera and system functions.""" -import unittest -from unittest import mock +from unittest import mock, IsolatedAsyncioTestCase import time import random - +from io import BufferedIOBase +import aiofiles from blinkpy import blinkpy from blinkpy.sync_module import BlinkSyncModule from blinkpy.camera import BlinkCamera from blinkpy.helpers.util import get_time, BlinkURLHandler -from requests import Response - class MockSyncModule(BlinkSyncModule): """Mock blink sync module object.""" - def get_network_info(self): + async def get_network_info(self): """Mock network info method.""" return True @@ -28,16 +26,16 @@ def __init__(self, sync): super().__init__(sync) self.camera_id = random.randint(1, 100000) - def update(self, config, force_cache=False, **kwargs): + async def update(self, config, force_cache=False, **kwargs): """Mock camera update method.""" -class TestBlinkFunctions(unittest.TestCase): +class TestBlinkFunctions(IsolatedAsyncioTestCase): """Test Blink and BlinkCamera functions in blinkpy.""" def setUp(self): """Set up Blink module.""" - self.blink = blinkpy.Blink() + self.blink = blinkpy.Blink(session=mock.AsyncMock()) self.blink.urls = BlinkURLHandler("test") def tearDown(self): @@ -57,9 +55,9 @@ def test_merge_cameras(self): self.assertEqual(expected, result) @mock.patch("blinkpy.blinkpy.api.request_videos") - def test_download_video_exit(self, mock_req): + async def test_download_video_exit(self, mock_req): """Test we exit method when provided bad response.""" - blink = blinkpy.Blink() + blink = blinkpy.Blink(session=mock.AsyncMock()) blink.last_refresh = 0 mock_req.return_value = {} formatted_date = get_time(blink.last_refresh) @@ -69,13 +67,13 @@ def test_download_video_exit(self, mock_req): "INFO:blinkpy.blinkpy:No videos found on page 1. Exiting.", ] with self.assertLogs(level="DEBUG") as dl_log: - blink.download_videos("/tmp") + await blink.download_videos("/tmp") self.assertListEqual(dl_log.output, expected_log) @mock.patch("blinkpy.blinkpy.api.request_videos") - def test_parse_downloaded_items(self, mock_req): + async def test_parse_downloaded_items(self, mock_req): """Test ability to parse downloaded items list.""" - blink = blinkpy.Blink() + blink = blinkpy.Blink(session=mock.AsyncMock()) generic_entry = { "created_at": "1970", "device_name": "foo", @@ -92,11 +90,11 @@ def test_parse_downloaded_items(self, mock_req): "DEBUG:blinkpy.blinkpy:foo: /bar.mp4 is marked as deleted.", ] with self.assertLogs(level="DEBUG") as dl_log: - blink.download_videos("/tmp", stop=2, delay=0) + await blink.download_videos("/tmp", stop=2, delay=0) self.assertListEqual(dl_log.output, expected_log) @mock.patch("blinkpy.blinkpy.api.request_videos") - def test_parse_downloaded_throttle(self, mock_req): + async def test_parse_downloaded_throttle(self, mock_req): """Test ability to parse downloaded items list.""" generic_entry = { "created_at": "1970", @@ -108,21 +106,20 @@ def test_parse_downloaded_throttle(self, mock_req): mock_req.return_value = {"media": result} self.blink.last_refresh = 0 start = time.time() - self.blink.download_videos("/tmp", stop=2, delay=0, debug=True) + await self.blink.download_videos("/tmp", stop=2, delay=0, debug=True) now = time.time() delta = now - start self.assertTrue(delta < 0.1) start = time.time() - self.blink.download_videos("/tmp", stop=2, delay=0.1, debug=True) + await self.blink.download_videos("/tmp", stop=2, delay=0.1, debug=True) now = time.time() delta = now - start self.assertTrue(delta >= 0.1) @mock.patch("blinkpy.blinkpy.api.request_videos") - def test_get_videos_metadata(self, mock_req): + async def test_get_videos_metadata(self, mock_req): """Test ability to fetch videos metadata.""" - blink = blinkpy.Blink() generic_entry = { "created_at": "1970", "device_name": "foo", @@ -131,33 +128,103 @@ def test_get_videos_metadata(self, mock_req): } result = [generic_entry] mock_req.return_value = {"media": result} - blink.last_refresh = 0 + self.blink.last_refresh = 0 - results = blink.get_videos_metadata(stop=2) - expected_results = [ - { - "created_at": "1970", - "device_name": "foo", - "deleted": True, - "media": "/bar.mp4", - } - ] - self.assertListEqual(results, expected_results) + results = await self.blink.get_videos_metadata(stop=2) + self.assertListEqual(results, result) + + results = await self.blink.get_videos_metadata( + since="2018/07/28 12:33:00", stop=2 + ) + self.assertListEqual(results, result) + + mock_req.return_value = {"media": None} + results = await self.blink.get_videos_metadata(stop=2) + self.assertListEqual(results, []) @mock.patch("blinkpy.blinkpy.api.http_get") - def test_do_http_get(self, mock_req): + async def test_do_http_get(self, mock_req): """Test ability to do_http_get.""" - blink = blinkpy.Blink() + blink = blinkpy.Blink(session=mock.AsyncMock()) blink.urls = BlinkURLHandler("test") - - mock_req.return_value = Response() - response = blink.do_http_get("/path/to/request") + response = await blink.do_http_get("/path/to/request") self.assertTrue(response is not None) @mock.patch("blinkpy.blinkpy.api.request_videos") - def test_parse_camera_not_in_list(self, mock_req): + async def test_download_videos_deleted(self, mock_req): + """Test ability to download videos.""" + generic_entry = { + "created_at": "1970", + "device_name": "foo", + "deleted": True, + "media": "/bar.mp4", + } + result = [generic_entry] + mock_req.return_value = {"media": result} + self.blink.last_refresh = 0 + formatted_date = get_time(self.blink.last_refresh) + expected_log = [ + "INFO:blinkpy.blinkpy:Retrieving videos since {}".format(formatted_date), + "DEBUG:blinkpy.blinkpy:Processing page 1", + "DEBUG:blinkpy.blinkpy:foo: /bar.mp4 is marked as deleted.", + ] + with self.assertLogs(level="DEBUG") as dl_log: + await self.blink.download_videos("/tmp", camera="foo", stop=2, delay=0) + self.assertListEqual(dl_log.output, expected_log) + + @mock.patch("blinkpy.blinkpy.api.request_videos") + @mock.patch("aiofiles.ospath.isfile") + async def test_download_videos_file(self, mock_isfile, mock_req): + """Test ability to download videos to a file.""" + generic_entry = { + "created_at": "1970", + "device_name": "foo", + "deleted": False, + "media": "/bar.mp4", + } + result = [generic_entry] + mock_req.return_value = {"media": result} + mock_isfile.return_value = False + self.blink.last_refresh = 0 + + aiofiles.threadpool.wrap.register(mock.MagicMock)( + lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase( + *args, **kwargs + ) + ) + mock_file = mock.MagicMock(spec=BufferedIOBase) + with mock.patch("aiofiles.threadpool.sync_open", return_value=mock_file): + await self.blink.download_videos("/tmp", camera="foo", stop=2, delay=0) + assert mock_file.write.call_count == 1 + + @mock.patch("blinkpy.blinkpy.api.request_videos") + @mock.patch("aiofiles.ospath.isfile") + async def test_download_videos_file_exists(self, mock_isfile, mock_req): + """Test ability to download videos with file exists.""" + generic_entry = { + "created_at": "1970", + "device_name": "foo", + "deleted": False, + "media": "/bar.mp4", + } + result = [generic_entry] + mock_req.return_value = {"media": result} + mock_isfile.return_value = True + + self.blink.last_refresh = 0 + formatted_date = get_time(self.blink.last_refresh) + expected_log = [ + "INFO:blinkpy.blinkpy:Retrieving videos since {}".format(formatted_date), + "DEBUG:blinkpy.blinkpy:Processing page 1", + "INFO:blinkpy.blinkpy:/tmp/foo-1970.mp4 already exists, skipping...", + ] + with self.assertLogs(level="DEBUG") as dl_log: + await self.blink.download_videos("/tmp", camera="foo", stop=2, delay=0) + self.assertListEqual(dl_log.output, expected_log) + + @mock.patch("blinkpy.blinkpy.api.request_videos") + async def test_parse_camera_not_in_list(self, mock_req): """Test ability to parse downloaded items list.""" - blink = blinkpy.Blink() generic_entry = { "created_at": "1970", "device_name": "foo", @@ -166,20 +233,39 @@ def test_parse_camera_not_in_list(self, mock_req): } result = [generic_entry] mock_req.return_value = {"media": result} - blink.last_refresh = 0 - formatted_date = get_time(blink.last_refresh) + self.blink.last_refresh = 0 + formatted_date = get_time(self.blink.last_refresh) expected_log = [ "INFO:blinkpy.blinkpy:Retrieving videos since {}".format(formatted_date), "DEBUG:blinkpy.blinkpy:Processing page 1", "DEBUG:blinkpy.blinkpy:Skipping videos for foo.", ] with self.assertLogs(level="DEBUG") as dl_log: - blink.download_videos("/tmp", camera="bar", stop=2, delay=0) + await self.blink.download_videos("/tmp", camera="bar", stop=2, delay=0) + self.assertListEqual(dl_log.output, expected_log) + + @mock.patch("blinkpy.blinkpy.api.request_videos") + async def test_parse_malformed_entry(self, mock_req): + """Test ability to parse downloaded items in malformed list.""" + self.blink.last_refresh = 0 + formatted_date = get_time(self.blink.last_refresh) + generic_entry = { + "created_at": "1970", + } + result = [generic_entry] + mock_req.return_value = {"media": result} + expected_log = [ + "INFO:blinkpy.blinkpy:Retrieving videos since {}".format(formatted_date), + "DEBUG:blinkpy.blinkpy:Processing page 1", + "INFO:blinkpy.blinkpy:Missing clip information, skipping...", + ] + with self.assertLogs(level="DEBUG") as dl_log: + await self.blink.download_videos("/tmp", camera="bar", stop=2, delay=0) self.assertListEqual(dl_log.output, expected_log) @mock.patch("blinkpy.blinkpy.api.request_network_update") @mock.patch("blinkpy.auth.Auth.query") - def test_refresh(self, mock_req, mock_update): + async def test_refresh(self, mock_req, mock_update): """Test ability to refresh system.""" mock_update.return_value = {"network": {"sync_module_error": False}} mock_req.return_value = None @@ -188,4 +274,4 @@ def test_refresh(self, mock_req, mock_update): self.blink.sync["foo"] = MockSyncModule(self.blink, "foo", 1, []) self.blink.cameras = {"bar": MockCamera(self.blink.sync)} self.blink.sync["foo"].cameras = self.blink.cameras - self.assertTrue(self.blink.refresh()) + self.assertTrue(await self.blink.refresh()) diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index e942222f..6745d6c6 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -6,21 +6,22 @@ any communication related errors at startup. """ -import unittest from unittest import mock -from blinkpy.blinkpy import Blink, BlinkSetupError +from unittest import IsolatedAsyncioTestCase +import time +from blinkpy.blinkpy import Blink, BlinkSetupError, LoginError, TokenRefreshFailed from blinkpy.sync_module import BlinkOwl, BlinkLotus from blinkpy.helpers.constants import __version__ SPECIAL = "!@#$%^&*()_+-=[]{}|/<>?,.'" -class TestBlinkSetup(unittest.TestCase): +class TestBlinkSetup(IsolatedAsyncioTestCase): """Test the Blink class in blinkpy.""" def setUp(self): """Initialize blink test object.""" - self.blink = Blink() + self.blink = Blink(session=mock.AsyncMock()) self.blink.available = True def tearDown(self): @@ -61,7 +62,7 @@ def test_multiple_onboarded_networks(self): self.assertTrue("1234" in self.blink.network_ids) @mock.patch("blinkpy.blinkpy.time.time") - def test_throttle(self, mock_time): + async def test_throttle(self, mock_time): """Check throttling functionality.""" now = self.blink.refresh_rate + 1 mock_time.return_value = now @@ -71,12 +72,26 @@ def test_throttle(self, mock_time): with mock.patch( "blinkpy.sync_module.BlinkSyncModule.refresh", return_value=True ), mock.patch("blinkpy.blinkpy.Blink.get_homescreen", return_value=True): - self.blink.refresh(force=True) + await self.blink.refresh(force=True) self.assertEqual(self.blink.last_refresh, now) self.assertEqual(self.blink.check_if_ok_to_update(), False) self.assertEqual(self.blink.last_refresh, now) + async def test_not_available_refresh(self): + """Check that setup_post_verify executes on refresh when not avialable.""" + self.blink.available = False + with mock.patch( + "blinkpy.sync_module.BlinkSyncModule.refresh", return_value=True + ), mock.patch( + "blinkpy.blinkpy.Blink.get_homescreen", return_value=True + ), mock.patch( + "blinkpy.blinkpy.Blink.setup_post_verify", return_value=True + ): + self.assertTrue(await self.blink.refresh(force=True)) + with mock.patch("time.time", return_value=time.time() + 4): + self.assertFalse(await self.blink.refresh()) + def test_sync_case_insensitive_dict(self): """Check that we can access sync modules ignoring case.""" self.blink.sync["test"] = 1234 @@ -91,7 +106,7 @@ def test_sync_special_chars(self): @mock.patch("blinkpy.api.request_camera_usage") @mock.patch("blinkpy.api.request_homescreen") - def test_setup_cameras(self, mock_home, mock_req): + async def test_setup_cameras(self, mock_home, mock_req): """Check retrieval of camera information.""" mock_home.return_value = {} mock_req.return_value = { @@ -107,7 +122,7 @@ def test_setup_cameras(self, mock_home, mock_req): {"network_id": 4321, "cameras": [{"id": 0000, "name": "test"}]}, ] } - result = self.blink.setup_camera_list() + result = await self.blink.setup_camera_list() self.assertEqual( result, { @@ -121,14 +136,14 @@ def test_setup_cameras(self, mock_home, mock_req): ) @mock.patch("blinkpy.api.request_camera_usage") - def test_setup_cameras_failure(self, mock_home): + async def test_setup_cameras_failure(self, mock_home): """Check that on failure we raise a setup error.""" mock_home.return_value = {} with self.assertRaises(BlinkSetupError): - self.blink.setup_camera_list() + await self.blink.setup_camera_list() mock_home.return_value = None with self.assertRaises(BlinkSetupError): - self.blink.setup_camera_list() + await self.blink.setup_camera_list() def test_setup_urls(self): """Check setup of URLS.""" @@ -143,60 +158,70 @@ def test_setup_urls_failure(self): self.blink.setup_urls() @mock.patch("blinkpy.api.request_networks") - def test_setup_networks(self, mock_networks): + async def test_setup_networks(self, mock_networks): """Check setup of networks.""" mock_networks.return_value = {"summary": "foobar"} - self.blink.setup_networks() + await self.blink.setup_networks() self.assertEqual(self.blink.networks, "foobar") @mock.patch("blinkpy.api.request_networks") - def test_setup_networks_failure(self, mock_networks): + async 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() + await self.blink.setup_networks() mock_networks.return_value = None with self.assertRaises(BlinkSetupError): - self.blink.setup_networks() + await self.blink.setup_networks() @mock.patch("blinkpy.blinkpy.Auth.send_auth_key") - def test_setup_prompt_2fa(self, mock_key): + async 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() + await 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() + await 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") @mock.patch("blinkpy.blinkpy.Blink.setup_owls") @mock.patch("blinkpy.blinkpy.Blink.setup_lotus") - def test_setup_post_verify(self, mock_lotus, mock_owl, mock_networks, mock_camera): + @mock.patch("blinkpy.blinkpy.BlinkSyncModule.start") + async def test_setup_post_verify( + self, mock_sync, mock_lotus, mock_owl, mock_networks, mock_camera + ): """Test setup after verification.""" self.blink.available = False self.blink.key_required = True mock_lotus.return_value = True mock_owl.return_value = True + mock_camera.side_effect = [ + { + "name": "bar", + "id": "1323", + "type": "default", + } + ] mock_networks.return_value = { - "summary": {"foo": {"onboarded": False, "name": "bar"}} + "summary": {"foo": {"onboarded": True, "name": "bar"}} } mock_camera.return_value = [] - self.assertTrue(self.blink.setup_post_verify()) + self.assertTrue(await 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): + async 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(await self.blink.setup_post_verify()) self.assertFalse(self.blink.available) def test_merge_cameras(self): @@ -212,7 +237,7 @@ def test_merge_cameras(self): self.assertEqual(combined["bar"], "foo") @mock.patch("blinkpy.blinkpy.BlinkOwl.start") - def test_initialize_blink_minis(self, mock_start): + async def test_initialize_blink_minis(self, mock_start): """Test blink mini initialization.""" mock_start.return_value = True self.blink.homescreen = { @@ -240,7 +265,7 @@ def test_initialize_blink_minis(self, mock_start): ] } self.blink.sync = {} - self.blink.setup_owls() + await 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) @@ -248,7 +273,7 @@ def test_initialize_blink_minis(self, mock_start): self.assertEqual(self.blink.sync["foo"].name, "foo") self.assertEqual(self.blink.sync["bar"].name, "bar") - def test_blink_mini_cameras_returned(self): + async def test_blink_mini_cameras_returned(self): """Test that blink mini cameras are found if attached to sync module.""" self.blink.network_ids = ["1234"] self.blink.homescreen = { @@ -265,7 +290,7 @@ def test_blink_mini_cameras_returned(self): } ] } - result = self.blink.setup_owls() + result = await self.blink.setup_owls() self.assertEqual(self.blink.network_ids, ["1234"]) self.assertEqual( result, [{"1234": {"name": "foo", "id": "1234", "type": "mini"}}] @@ -273,13 +298,13 @@ def test_blink_mini_cameras_returned(self): self.blink.no_owls = True self.blink.network_ids = [] - self.blink.get_homescreen() - result = self.blink.setup_owls() + await self.blink.get_homescreen() + result = await self.blink.setup_owls() self.assertEqual(self.blink.network_ids, []) self.assertEqual(result, []) @mock.patch("blinkpy.api.request_camera_usage") - def test_blink_mini_attached_to_sync(self, mock_usage): + async 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"] self.blink.homescreen = { @@ -297,13 +322,13 @@ def test_blink_mini_attached_to_sync(self, mock_usage): ] } mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]} - result = self.blink.setup_camera_list() + result = await self.blink.setup_camera_list() self.assertEqual( result, {"1234": [{"name": "foo", "id": "1234", "type": "mini"}]} ) @mock.patch("blinkpy.blinkpy.BlinkLotus.start") - def test_initialize_blink_doorbells(self, mock_start): + async def test_initialize_blink_doorbells(self, mock_start): """Test blink doorbell initialization.""" mock_start.return_value = True self.blink.homescreen = { @@ -331,7 +356,7 @@ def test_initialize_blink_doorbells(self, mock_start): ] } self.blink.sync = {} - self.blink.setup_lotus() + await self.blink.setup_lotus() self.assertEqual(self.blink.sync["foo"].__class__, BlinkLotus) self.assertEqual(self.blink.sync["bar"].__class__, BlinkLotus) self.assertEqual(self.blink.sync["foo"].arm, False) @@ -340,7 +365,7 @@ def test_initialize_blink_doorbells(self, mock_start): self.assertEqual(self.blink.sync["bar"].name, "bar") @mock.patch("blinkpy.api.request_camera_usage") - def test_blink_doorbell_attached_to_sync(self, mock_usage): + async def test_blink_doorbell_attached_to_sync(self, mock_usage): """Test that blink doorbell cameras are properly attached to sync module.""" self.blink.network_ids = ["1234"] self.blink.homescreen = { @@ -358,13 +383,13 @@ def test_blink_doorbell_attached_to_sync(self, mock_usage): ] } mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]} - result = self.blink.setup_camera_list() + result = await self.blink.setup_camera_list() self.assertEqual( result, {"1234": [{"name": "foo", "id": "1234", "type": "doorbell"}]} ) @mock.patch("blinkpy.api.request_camera_usage") - def test_blink_multi_doorbell(self, mock_usage): + async def test_blink_multi_doorbell(self, mock_usage): """Test that multiple doorbells are properly attached to sync module.""" self.blink.network_ids = ["1234"] self.blink.homescreen = { @@ -398,11 +423,11 @@ def test_blink_multi_doorbell(self, mock_usage): ] } mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]} - result = self.blink.setup_camera_list() + result = await self.blink.setup_camera_list() self.assertEqual(result, expected) @mock.patch("blinkpy.api.request_camera_usage") - def test_blink_multi_mini(self, mock_usage): + async def test_blink_multi_mini(self, mock_usage): """Test that multiple minis are properly attached to sync module.""" self.blink.network_ids = ["1234"] self.blink.homescreen = { @@ -436,11 +461,11 @@ def test_blink_multi_mini(self, mock_usage): ] } mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]} - result = self.blink.setup_camera_list() + result = await self.blink.setup_camera_list() self.assertEqual(result, expected) @mock.patch("blinkpy.api.request_camera_usage") - def test_blink_camera_mix(self, mock_usage): + async def test_blink_camera_mix(self, mock_usage): """Test that a mix of cameras are properly attached to sync module.""" self.blink.network_ids = ["1234"] self.blink.homescreen = { @@ -503,15 +528,59 @@ def test_blink_camera_mix(self, mock_usage): {"cameras": [{"name": "normal", "id": "1234"}], "network_id": 1234} ] } - result = self.blink.setup_camera_list() + result = await self.blink.setup_camera_list() self.assertTrue("1234" in result) for element in result["1234"]: self.assertTrue(element in expected["1234"]) + @mock.patch("blinkpy.blinkpy.Blink.get_homescreen") + @mock.patch("blinkpy.blinkpy.Blink.setup_prompt_2fa") + @mock.patch("blinkpy.auth.Auth.startup") + @mock.patch("blinkpy.blinkpy.Blink.setup_login_ids") + @mock.patch("blinkpy.blinkpy.Blink.setup_urls") + @mock.patch("blinkpy.auth.Auth.check_key_required") + @mock.patch("blinkpy.blinkpy.Blink.setup_post_verify") + async def test_blink_start( + self, + mock_verify, + mock_check_key, + mock_urls, + mock_ids, + mock_auth_startup, + mock_2fa, + mock_homescreen, + ): + """Test blink_start funcion.""" + + self.assertTrue(await self.blink.start()) + + self.blink.auth.no_prompt = True + self.assertTrue(await self.blink.start()) + + mock_homescreen.side_effect = [LoginError, TokenRefreshFailed] + self.assertFalse(await self.blink.start()) + self.assertFalse(await self.blink.start()) + + def test_setup_login_ids(self): + """Test setup_login_ids function.""" + + self.blink.auth.client_id = 1 + self.blink.auth.account_id = 2 + self.blink.setup_login_ids() + self.assertEqual(self.blink.client_id, 1) + self.assertEqual(self.blink.account_id, 2) + + @mock.patch("blinkpy.blinkpy.util.json_save") + async def test_save(self, mock_util): + """Test save function.""" + await self.blink.save("blah") + self.assertEqual(mock_util.call_count, 1) + class MockSync: """Mock sync module class.""" def __init__(self, cameras): """Initialize fake class.""" + self.cameras = cameras diff --git a/tests/test_camera_functions.py b/tests/test_camera_functions.py index 23999d42..5231674d 100644 --- a/tests/test_camera_functions.py +++ b/tests/test_camera_functions.py @@ -7,12 +7,13 @@ """ import datetime -import unittest from unittest import mock +from unittest import IsolatedAsyncioTestCase 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, BlinkDoorbell +import tests.mock_responses as mresp CAMERA_CFG = { "camera": [ @@ -27,12 +28,12 @@ @mock.patch("blinkpy.auth.Auth.query") -class TestBlinkCameraSetup(unittest.TestCase): +class TestBlinkCameraSetup(IsolatedAsyncioTestCase): """Test the Blink class in blinkpy.""" def setUp(self): """Set up Blink module.""" - self.blink = Blink() + self.blink = Blink(session=mock.AsyncMock()) self.blink.urls = BlinkURLHandler("test") self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", 1234, []) self.camera = BlinkCamera(self.blink.sync["test"]) @@ -44,7 +45,7 @@ def tearDown(self): self.blink = None self.camera = None - def test_camera_update(self, mock_resp): + async def test_camera_update(self, mock_resp): """Test that we can properly update camera properties.""" config = { "name": "new", @@ -64,10 +65,12 @@ def test_camera_update(self, mock_resp): } mock_resp.side_effect = [ {"temp": 71}, - "test", - "foobar", + mresp.MockResponse({"test": 200}, 200, raw_data="test"), + mresp.MockResponse({"foobar": 200}, 200, raw_data="foobar"), ] - self.camera.update(config, expire_clips=False) + self.assertIsNone(self.camera.image_from_cache) + + await self.camera.update(config, expire_clips=False) self.assertEqual(self.camera.name, "new") self.assertEqual(self.camera.camera_id, "1234") self.assertEqual(self.camera.network_id, "5678") @@ -88,14 +91,18 @@ def test_camera_update(self, mock_resp): self.assertEqual(self.camera.video_from_cache, "foobar") # Check that thumbnail without slash processed properly - mock_resp.side_effect = [None] - self.camera.update_images({"thumbnail": "thumb_no_slash"}, expire_clips=False) + mock_resp.side_effect = [ + mresp.MockResponse({"test": 200}, 200, raw_data="thumb_no_slash") + ] + await self.camera.update_images( + {"thumbnail": "thumb_no_slash"}, expire_clips=False + ) self.assertEqual( self.camera.thumbnail, "https://rest-test.immedia-semi.com/thumb_no_slash.jpg", ) - def test_no_thumbnails(self, mock_resp): + async def test_no_thumbnails(self, mock_resp): """Tests that thumbnail is 'None' if none found.""" mock_resp.return_value = "foobar" self.camera.last_record = ["1"] @@ -114,7 +121,7 @@ def test_no_thumbnails(self, mock_resp): self.camera.sync.homescreen = {"devices": []} self.assertEqual(self.camera.temperature_calibrated, None) with self.assertLogs() as logrecord: - self.camera.update(config, force=True, expire_clips=False) + await self.camera.update(config, force=True, expire_clips=False) self.assertEqual(self.camera.thumbnail, None) self.assertEqual(self.camera.last_record, ["1"]) self.assertEqual(self.camera.temperature_calibrated, 68) @@ -129,7 +136,7 @@ def test_no_thumbnails(self, mock_resp): ], ) - def test_no_video_clips(self, mock_resp): + async def test_no_video_clips(self, mock_resp): """Tests that we still proceed with camera setup with no videos.""" mock_resp.return_value = "foobar" config = { @@ -144,12 +151,13 @@ def test_no_video_clips(self, mock_resp): "wifi_strength": 4, "thumbnail": "/foobar", } + mock_resp.return_value = mresp.MockResponse({"test": 200}, 200, raw_data="") self.camera.sync.homescreen = {"devices": []} - self.camera.update(config, force_cache=True, expire_clips=False) + await self.camera.update(config, force_cache=True, expire_clips=False) self.assertEqual(self.camera.clip, None) self.assertEqual(self.camera.video_from_cache, None) - def test_recent_video_clips(self, mock_resp): + async def test_recent_video_clips(self, mock_resp): """Tests that the last records in the sync module are added to the camera recent clips list.""" config = { "name": "new", @@ -169,13 +177,37 @@ def test_recent_video_clips(self, mock_resp): record1 = {"clip": "/clip1", "time": "2022-12-01 00:00:00+00:00"} self.camera.sync.last_records["foobar"].append(record1) self.camera.sync.motion["foobar"] = True - self.camera.update_images(config, expire_clips=False) + await self.camera.update_images(config, expire_clips=False) record1["clip"] = self.blink.urls.base_url + "/clip1" record2["clip"] = self.blink.urls.base_url + "/clip2" self.assertEqual(self.camera.recent_clips[0], record1) self.assertEqual(self.camera.recent_clips[1], record2) - def test_expire_recent_clips(self, mock_resp): + async def test_recent_video_clips_missing_key(self, mock_resp): + """Tests that the missing key failst.""" + config = { + "name": "new", + "id": 1234, + "network_id": 5678, + "serial": "12345678", + "enabled": False, + "battery_voltage": 90, + "battery_state": "ok", + "temperature": 68, + "wifi_strength": 4, + "thumbnail": "/thumb", + } + self.camera.sync.last_records["foobar"] = [] + record2 = {"clip": "/clip2"} + self.camera.sync.last_records["foobar"].append(record2) + self.camera.sync.motion["foobar"] = True + + with self.assertLogs(level="ERROR") as dl_log: + await self.camera.update_images(config, expire_clips=False) + + self.assertIsNotNone(dl_log.output) + + async def test_expire_recent_clips(self, mock_resp): """Test expiration of recent clips.""" self.camera.recent_clips = [] now = datetime.datetime.now() @@ -188,17 +220,152 @@ def test_expire_recent_clips(self, mock_resp): self.camera.recent_clips.append( { "time": (now - datetime.timedelta(minutes=1)).isoformat(), - "clip": "/clip2", + "clip": "local_storage/clip2", }, ) - self.camera.expire_recent_clips(delta=datetime.timedelta(minutes=5)) + await self.camera.expire_recent_clips(delta=datetime.timedelta(minutes=5)) self.assertEqual(len(self.camera.recent_clips), 1) - @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): + @mock.patch( + "blinkpy.api.request_motion_detection_enable", + mock.AsyncMock(return_value="enable"), + ) + @mock.patch( + "blinkpy.api.request_motion_detection_disable", + mock.AsyncMock(return_value="disable"), + ) + async def test_motion_detection_enable_disable(self, 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") + self.assertEqual(await self.camera.set_motion_detect(True), "enable") + self.assertEqual(await self.camera.set_motion_detect(False), "disable") + + async def test_night_vision(self, mock_resp): + """Test Night Vision Camera functions.""" + # MJK - I don't know what the "real" response is supposed to look like + # Need to confirm and adjust this test to match reality? + mock_resp.return_value = "blah" + self.assertIsNone(await self.camera.night_vision) + + self.camera.product_type = "catalina" + mock_resp.return_value = {"camera": [{"name": "123", "illuminator_enable": 1}]} + self.assertIsNotNone(await self.camera.night_vision) + + self.assertIsNone(await self.camera.async_set_night_vision("0")) + + mock_resp.return_value = mresp.MockResponse({"code": 200}, 200) + self.assertIsNotNone(await self.camera.async_set_night_vision("on")) + + mock_resp.return_value = mresp.MockResponse({"code": 400}, 400) + self.assertIsNone(await self.camera.async_set_night_vision("on")) + + async def test_record(self, mock_resp): + """Test camera record function.""" + with mock.patch( + "blinkpy.api.request_new_video", mock.AsyncMock(return_value=True) + ): + self.assertTrue(await self.camera.record()) + + with mock.patch( + "blinkpy.api.request_new_video", mock.AsyncMock(return_value=False) + ): + self.assertFalse(await self.camera.record()) + + async def test_get_thumbnail(self, mock_resp): + """Test get thumbnail without URL.""" + self.assertIsNone(await self.camera.get_thumbnail()) + + async def test_get_video(self, mock_resp): + """Test get video clip without URL.""" + self.assertIsNone(await self.camera.get_video_clip()) + + @mock.patch( + "blinkpy.api.request_new_image", mock.AsyncMock(return_value={"json": "Data"}) + ) + async def test_snap_picture(self, mock_resp): + """Test camera snap picture function.""" + self.assertIsNotNone(await self.camera.snap_picture()) + + @mock.patch("blinkpy.api.http_post", mock.AsyncMock(return_value={"json": "Data"})) + async def test_snap_picture_blinkmini(self, mock_resp): + """Test camera snap picture function.""" + self.camera = BlinkCameraMini(self.blink.sync["test"]) + self.assertIsNotNone(await self.camera.snap_picture()) + + @mock.patch("blinkpy.api.http_post", mock.AsyncMock(return_value={"json": "Data"})) + async def test_snap_picture_blinkdoorbell(self, mock_resp): + """Test camera snap picture function.""" + self.camera = BlinkDoorbell(self.blink.sync["test"]) + self.assertIsNotNone(await self.camera.snap_picture()) + + @mock.patch("blinkpy.camera.open", create=True) + async def test_image_to_file(self, mock_open, mock_resp): + """Test camera image to file.""" + mock_resp.return_value = mresp.MockResponse({}, 200, raw_data="raw data") + self.camera.thumbnail = "/thumbnail" + await self.camera.image_to_file("my_path") + + @mock.patch("blinkpy.camera.open", create=True) + async def test_image_to_file_error(self, mock_open, mock_resp): + """Test camera image to file with error.""" + mock_resp.return_value = mresp.MockResponse({}, 400, raw_data="raw data") + self.camera.thumbnail = "/thumbnail" + with self.assertLogs(level="DEBUG") as dl_log: + await self.camera.image_to_file("my_path") + self.assertEquals( + dl_log.output[2], + "ERROR:blinkpy.camera:Cannot write image to file, response 400", + ) + + @mock.patch("blinkpy.camera.open", create=True) + async def test_video_to_file_none_response(self, mock_open, mock_resp): + """Test camera video to file.""" + mock_resp.return_value = mresp.MockResponse({}, 200, raw_data="raw data") + with self.assertLogs(level="DEBUG") as dl_log: + await self.camera.video_to_file("my_path") + self.assertEqual( + dl_log.output[2], + f"ERROR:blinkpy.camera:No saved video exists for {self.camera.name}.", + ) + + @mock.patch("blinkpy.camera.open", create=True) + async def test_video_to_file(self, mock_open, mock_resp): + """Test camera vido to file with error.""" + mock_resp.return_value = mresp.MockResponse({}, 400, raw_data="raw data") + self.camera.clip = "my_clip" + await self.camera.video_to_file("my_path") + mock_open.assert_called_once() + + @mock.patch("blinkpy.camera.open", create=True) + @mock.patch("blinkpy.camera.BlinkCamera.get_video_clip") + async def test_save_recent_clips(self, mock_clip, mock_open, mock_resp): + """Test camera save recent clips.""" + with self.assertLogs(level="DEBUG") as dl_log: + await self.camera.save_recent_clips() + self.assertEqual( + dl_log.output[0], + f"INFO:blinkpy.camera:No recent clips to save for '{self.camera.name}'.", + ) + assert mock_open.call_count == 0 + + self.camera.recent_clips = [] + now = datetime.datetime.now() + self.camera.recent_clips.append( + { + "time": (now - datetime.timedelta(minutes=20)).isoformat(), + "clip": "/clip1", + }, + ) + self.camera.recent_clips.append( + { + "time": (now - datetime.timedelta(minutes=1)).isoformat(), + "clip": "local_storage/clip2", + }, + ) + mock_clip.return_value = mresp.MockResponse({}, 200, raw_data="raw data") + with self.assertLogs(level="DEBUG") as dl_log: + await self.camera.save_recent_clips() + self.assertEqual( + dl_log.output[4], + f"INFO:blinkpy.camera:Saved 2 of 2 recent clips from '{self.camera.name}' to directory /tmp/", + ) + assert mock_open.call_count == 2 diff --git a/tests/test_cameras.py b/tests/test_cameras.py index 798e5821..660c9648 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -6,12 +6,13 @@ Blink system is set up. """ -import unittest from unittest import mock +from unittest import IsolatedAsyncioTestCase from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler from blinkpy.sync_module import BlinkSyncModule from blinkpy.camera import BlinkCamera, BlinkCameraMini, BlinkDoorbell +import tests.mock_responses as mresp CAMERA_CFG = { "camera": [ @@ -26,12 +27,12 @@ @mock.patch("blinkpy.auth.Auth.query") -class TestBlinkCameraSetup(unittest.TestCase): +class TestBlinkCameraSetup(IsolatedAsyncioTestCase): """Test the Blink class in blinkpy.""" def setUp(self): """Set up Blink module.""" - self.blink = Blink() + self.blink = Blink(session=mock.AsyncMock()) self.blink.urls = BlinkURLHandler("test") self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", 1234, []) self.camera = BlinkCamera(self.blink.sync["test"]) @@ -43,29 +44,42 @@ def tearDown(self): self.blink = None self.camera = None - def test_camera_arm_status(self, mock_resp): + @mock.patch( + "blinkpy.api.request_motion_detection_enable", + mock.AsyncMock(return_value="enable"), + ) + @mock.patch( + "blinkpy.api.request_motion_detection_disable", + mock.AsyncMock(return_value="disable"), + ) + async def test_camera_arm_status(self, mock_resp): """Test arming and disarming camera.""" self.camera.motion_enabled = None - self.camera.arm = None + await self.camera.async_arm(None) self.assertFalse(self.camera.arm) - self.camera.arm = False + await self.camera.async_arm(False) self.camera.motion_enabled = False self.assertFalse(self.camera.arm) - self.camera.arm = True + await self.camera.async_arm(True) self.camera.motion_enabled = True self.assertTrue(self.camera.arm) - def test_doorbell_camera_arm(self, mock_resp): + self.camera = BlinkCameraMini(self.blink.sync["test"]) + self.camera.motion_enabled = None + await self.camera.async_arm(None) + self.assertFalse(self.camera.arm) + + async def test_doorbell_camera_arm(self, mock_resp): """Test arming and disarming camera.""" self.blink.sync.arm = False doorbell_camera = BlinkDoorbell(self.blink.sync["test"]) doorbell_camera.motion_enabled = None - doorbell_camera.arm = None + await doorbell_camera.async_arm(None) self.assertFalse(doorbell_camera.arm) - doorbell_camera.arm = False + await doorbell_camera.async_arm(False) doorbell_camera.motion_enabled = False self.assertFalse(doorbell_camera.arm) - doorbell_camera.arm = True + await doorbell_camera.async_arm(True) doorbell_camera.motion_enabled = True self.assertTrue(doorbell_camera.arm) @@ -102,16 +116,16 @@ def test_doorbell_missing_attributes(self, mock_resp): continue self.assertEqual(attr[key], None) - def test_camera_stream(self, mock_resp): + async def test_camera_stream(self, mock_resp): """Test that camera stream returns correct url.""" mock_resp.return_value = {"server": "rtsps://foo.bar"} mini_camera = BlinkCameraMini(self.blink.sync["test"]) doorbell_camera = BlinkDoorbell(self.blink.sync["test"]) - self.assertEqual(self.camera.get_liveview(), "rtsps://foo.bar") - self.assertEqual(mini_camera.get_liveview(), "rtsps://foo.bar") - self.assertEqual(doorbell_camera.get_liveview(), "rtsps://foo.bar") + self.assertEqual(await self.camera.get_liveview(), "rtsps://foo.bar") + self.assertEqual(await mini_camera.get_liveview(), "rtsps://foo.bar") + self.assertEqual(await doorbell_camera.get_liveview(), "rtsps://foo.bar") - def test_different_thumb_api(self, mock_resp): + async def test_different_thumb_api(self, mock_resp): """Test that the correct url is created with new api.""" thumb_endpoint = "https://rest-test.immedia-semi.com/api/v3/media/accounts/9999/networks/5678/test/1234/thumbnail/thumbnail.jpg?ts=1357924680&ext=" config = { @@ -129,13 +143,13 @@ def test_different_thumb_api(self, mock_resp): } mock_resp.side_effect = [ {"temp": 71}, - "test", + mresp.MockResponse({"test": 200}, 200, raw_data="test"), ] self.camera.sync.blink.account_id = 9999 - self.camera.update(config, expire_clips=False) + await self.camera.update(config, expire_clips=False) self.assertEqual(self.camera.thumbnail, thumb_endpoint) - def test_thumb_return_none(self, mock_resp): + async def test_thumb_return_none(self, mock_resp): """Test that a 'None" thumbnail is doesn't break system.""" config = { "name": "new", @@ -154,10 +168,10 @@ def test_thumb_return_none(self, mock_resp): {"temp": 71}, "test", ] - self.camera.update(config, expire_clips=False) + await self.camera.update(config, expire_clips=False) self.assertEqual(self.camera.thumbnail, None) - def test_new_thumb_url_returned(self, mock_resp): + async def test_new_thumb_url_returned(self, mock_resp): """Test that thumb handled properly if new url returned.""" thumb_return = "/api/v3/media/accounts/9999/networks/5678/test/1234/thumbnail/thumbnail.jpg?ts=1357924680&ext=" config = { @@ -175,10 +189,10 @@ def test_new_thumb_url_returned(self, mock_resp): } mock_resp.side_effect = [ {"temp": 71}, - "test", + mresp.MockResponse({"test": 200}, 200, raw_data="test"), ] self.camera.sync.blink.account_id = 9999 - self.camera.update(config, expire_clips=False) + await self.camera.update(config, expire_clips=False) self.assertEqual( self.camera.thumbnail, f"https://rest-test.immedia-semi.com{thumb_return}" ) diff --git a/tests/test_doorbell_as_sync.py b/tests/test_doorbell_as_sync.py index 413f4373..154c0727 100644 --- a/tests/test_doorbell_as_sync.py +++ b/tests/test_doorbell_as_sync.py @@ -1,7 +1,7 @@ """Tests camera and system functions.""" -import unittest from unittest import mock - +from unittest import IsolatedAsyncioTestCase +import pytest from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler from blinkpy.sync_module import BlinkLotus @@ -9,12 +9,12 @@ @mock.patch("blinkpy.auth.Auth.query") -class TestBlinkDoorbell(unittest.TestCase): +class TestBlinkDoorbell(IsolatedAsyncioTestCase): """Test BlinkDoorbell functions in blinkpy.""" def setUp(self): """Set up Blink module.""" - self.blink = Blink(motion_interval=0) + self.blink = Blink(motion_interval=0, session=mock.AsyncMock()) self.blink.last_refresh = 0 self.blink.urls = BlinkURLHandler("test") response = { @@ -38,10 +38,11 @@ def test_sync_attributes(self, mock_resp): self.assertEqual(self.blink.sync["test"].attributes["name"], "test") self.assertEqual(self.blink.sync["test"].attributes["network_id"], "1234") - def test_lotus_start(self, mock_resp): + @pytest.mark.asyncio + async def test_lotus_start(self, mock_resp): """Test doorbell instantiation.""" self.blink.last_refresh = None lotus = self.blink.sync["test"] - self.assertTrue(lotus.start()) + self.assertTrue(await lotus.start()) self.assertTrue("test" in lotus.cameras) self.assertEqual(lotus.cameras["test"].__class__, BlinkDoorbell) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..356b0a4e --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,23 @@ +"""Test blink Utils errors.""" +import unittest +from blinkpy.helpers.errors import ( + USERNAME, + PASSWORD, + AUTH_TOKEN, + AUTHENTICATE, + REQUEST, + BLINK_ERRORS, +) + + +class TestBlinkUtilsErrors(unittest.TestCase): + """Test BlinkUtilErros functions in blinkpy.""" + + def test_helpers_errors(self) -> None: + """Test the helper errors.""" + assert USERNAME + assert PASSWORD + assert AUTH_TOKEN + assert AUTHENTICATE + assert REQUEST + assert BLINK_ERRORS diff --git a/tests/test_mini_as_sync.py b/tests/test_mini_as_sync.py index a44c9865..5c6f3e68 100644 --- a/tests/test_mini_as_sync.py +++ b/tests/test_mini_as_sync.py @@ -1,7 +1,7 @@ """Tests camera and system functions.""" -import unittest from unittest import mock - +from unittest import IsolatedAsyncioTestCase +import pytest from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler from blinkpy.sync_module import BlinkOwl @@ -9,12 +9,12 @@ @mock.patch("blinkpy.auth.Auth.query") -class TestBlinkSyncModule(unittest.TestCase): +class TestBlinkSyncModule(IsolatedAsyncioTestCase): """Test BlinkSyncModule functions in blinkpy.""" def setUp(self): """Set up Blink module.""" - self.blink = Blink(motion_interval=0) + self.blink = Blink(motion_interval=0, session=mock.AsyncMock()) self.blink.last_refresh = 0 self.blink.urls = BlinkURLHandler("test") response = { @@ -38,10 +38,11 @@ def test_sync_attributes(self, mock_resp): 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): + @pytest.mark.asyncio + async def test_owl_start(self, mock_resp): """Test owl camera instantiation.""" self.blink.last_refresh = None owl = self.blink.sync["test"] - self.assertTrue(owl.start()) + self.assertTrue(await owl.start()) self.assertTrue("test" in owl.cameras) self.assertEqual(owl.cameras["test"].__class__, BlinkCameraMini) diff --git a/tests/test_sync_functions.py b/tests/test_sync_functions.py index ebf69188..f31928f7 100644 --- a/tests/test_sync_functions.py +++ b/tests/test_sync_functions.py @@ -1,11 +1,10 @@ """Tests camera and system functions.""" import json -import unittest from unittest import mock - +from unittest import IsolatedAsyncioTestCase from random import shuffle - +import pytest from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler from blinkpy.sync_module import BlinkSyncModule @@ -13,12 +12,12 @@ @mock.patch("blinkpy.auth.Auth.query") -class TestBlinkSyncModule(unittest.TestCase): +class TestBlinkSyncModule(IsolatedAsyncioTestCase): """Test BlinkSyncModule functions in blinkpy.""" def setUp(self): """Set up Blink module.""" - self.blink = Blink(motion_interval=0) + self.blink = Blink(motion_interval=0, session=mock.AsyncMock()) self.blink.last_refresh = 0 self.blink.urls = BlinkURLHandler("test") self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", "1234", []) @@ -46,7 +45,8 @@ def tearDown(self): self.camera = None self.mock_start = None - def test_check_new_videos(self, mock_resp): + @pytest.mark.asyncio + async def test_check_new_videos(self, mock_resp): """Test recent video response.""" mock_resp.return_value = { "media": [ @@ -62,21 +62,22 @@ def test_check_new_videos(self, mock_resp): sync_module.cameras = {"foo": None} sync_module.blink.last_refresh = 0 self.assertEqual(sync_module.motion, {}) - self.assertTrue(sync_module.check_new_videos()) + self.assertTrue(await sync_module.check_new_videos()) self.assertEqual( sync_module.last_records["foo"], [{"clip": "/foo/bar.mp4", "time": "1990-01-01T00:00:00+00:00"}], ) self.assertEqual(sync_module.motion, {"foo": True}) mock_resp.return_value = {"media": []} - self.assertTrue(sync_module.check_new_videos()) + self.assertTrue(await sync_module.check_new_videos()) self.assertEqual(sync_module.motion, {"foo": False}) self.assertEqual( sync_module.last_records["foo"], [{"clip": "/foo/bar.mp4", "time": "1990-01-01T00:00:00+00:00"}], ) - def test_check_new_videos_old_date(self, mock_resp): + @pytest.mark.asyncio + async def test_check_new_videos_old_date(self, mock_resp): """Test videos return response with old date.""" mock_resp.return_value = { "media": [ @@ -91,10 +92,11 @@ def test_check_new_videos_old_date(self, mock_resp): sync_module = self.blink.sync["test"] sync_module.cameras = {"foo": None} sync_module.blink.last_refresh = 1000 - self.assertTrue(sync_module.check_new_videos()) + self.assertTrue(await sync_module.check_new_videos()) self.assertEqual(sync_module.motion, {"foo": False}) - def test_check_no_motion_if_not_armed(self, mock_resp): + @pytest.mark.asyncio + async def test_check_no_motion_if_not_armed(self, mock_resp): """Test that motion detection is not set if module unarmed.""" mock_resp.return_value = { "media": [ @@ -108,13 +110,14 @@ def test_check_no_motion_if_not_armed(self, mock_resp): sync_module = self.blink.sync["test"] sync_module.cameras = {"foo": None} sync_module.blink.last_refresh = 1000 - self.assertTrue(sync_module.check_new_videos()) + self.assertTrue(await sync_module.check_new_videos()) self.assertEqual(sync_module.motion, {"foo": True}) sync_module.network_info = {"network": {"armed": False}} - self.assertTrue(sync_module.check_new_videos()) + self.assertTrue(await sync_module.check_new_videos()) self.assertEqual(sync_module.motion, {"foo": False}) - def test_check_multiple_videos(self, mock_resp): + @pytest.mark.asyncio + async def test_check_multiple_videos(self, mock_resp): """Test motion found even with multiple videos.""" mock_resp.return_value = { "media": [ @@ -138,24 +141,26 @@ def test_check_multiple_videos(self, mock_resp): sync_module = self.blink.sync["test"] sync_module.cameras = {"foo": None} sync_module.blink.last_refresh = 1000 - self.assertTrue(sync_module.check_new_videos()) + self.assertTrue(await sync_module.check_new_videos()) self.assertEqual(sync_module.motion, {"foo": True}) expected_result = { "foo": [{"clip": "/bar/foo.mp4", "time": "1990-01-01T00:00:00+00:00"}] } self.assertEqual(sync_module.last_records, expected_result) - def test_sync_start(self, mock_resp): + @pytest.mark.asyncio + async def test_sync_start(self, mock_resp): """Test sync start function.""" mock_resp.side_effect = self.mock_start - self.blink.sync["test"].start() + await self.blink.sync["test"].start() self.assertEqual(self.blink.sync["test"].name, "test") self.assertEqual(self.blink.sync["test"].sync_id, 1234) self.assertEqual(self.blink.sync["test"].network_id, 5678) self.assertEqual(self.blink.sync["test"].serial, "12345678") self.assertEqual(self.blink.sync["test"].status, "foobar") - def test_sync_with_mixed_cameras(self, mock_resp): + @pytest.mark.asyncio + async def test_sync_with_mixed_cameras(self, mock_resp): """Test sync module with mixed cameras attached.""" resp_sync = { "syncmodule": { @@ -196,7 +201,7 @@ def test_sync_with_mixed_cameras(self, mock_resp): test_sync = self.blink.sync["test"] - self.assertTrue(test_sync.start()) + self.assertTrue(await test_sync.start()) self.assertEqual(test_sync.cameras["foo"].__class__, BlinkCamera) self.assertEqual(test_sync.cameras["bar"].__class__, BlinkCameraMini) self.assertEqual(test_sync.cameras["fake"].__class__, BlinkDoorbell) @@ -205,7 +210,7 @@ def test_sync_with_mixed_cameras(self, mock_resp): for i in range(0, 10): shuffle(test_sync.camera_list) mock_resp.side_effect = side_effect - self.assertTrue(test_sync.start()) + self.assertTrue(await test_sync.start()) debug_msg = f"Iteration: {i}, {test_sync.camera_list}" self.assertEqual( test_sync.cameras["foo"].__class__, BlinkCamera, msg=debug_msg @@ -217,7 +222,8 @@ def test_sync_with_mixed_cameras(self, mock_resp): test_sync.cameras["fake"].__class__, BlinkDoorbell, msg=debug_msg ) - def test_init_local_storage(self, mock_resp): + @pytest.mark.asyncio + async def test_init_local_storage(self, mock_resp): """Test initialization of local storage object.""" json_fragment = """{ "sync_modules": [ @@ -231,5 +237,5 @@ def test_init_local_storage(self, mock_resp): ] }""" self.blink.homescreen = json.loads(json_fragment) - self.blink.sync["test"]._init_local_storage(123456) + await self.blink.sync["test"]._init_local_storage(123456) self.assertTrue(self.blink.sync["test"].local_storage) diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py index 5d79fa42..e07b83d2 100644 --- a/tests/test_sync_module.py +++ b/tests/test_sync_module.py @@ -1,26 +1,35 @@ """Tests camera and system functions.""" import datetime -import unittest +from unittest import IsolatedAsyncioTestCase from unittest import mock - +import aiofiles from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler, to_alphanumeric -from blinkpy.sync_module import BlinkSyncModule +from blinkpy.sync_module import ( + BlinkSyncModule, + BlinkOwl, + BlinkLotus, + LocalStorageMediaItem, +) from blinkpy.camera import BlinkCamera from tests.test_blink_functions import MockCamera +import tests.mock_responses as mresp @mock.patch("blinkpy.auth.Auth.query") -class TestBlinkSyncModule(unittest.TestCase): +class TestBlinkSyncModule(IsolatedAsyncioTestCase): """Test BlinkSyncModule functions in blinkpy.""" def setUp(self): """Set up Blink module.""" - self.blink = Blink(motion_interval=0) + self.blink: Blink = Blink(motion_interval=0, session=mock.AsyncMock()) self.blink.last_refresh = 0 self.blink.urls = BlinkURLHandler("test") - self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", "1234", []) - self.camera = BlinkCamera(self.blink.sync) + self.blink.sync["test"]: (BlinkSyncModule) = BlinkSyncModule( + self.blink, "test", "1234", [] + ) + self.blink.sync["test"].network_info = {"network": {"armed": True}} + self.camera: BlinkCamera = BlinkCamera(self.blink.sync) self.mock_start = [ { "syncmodule": { @@ -36,7 +45,6 @@ def setUp(self): None, {"devicestatus": {}}, ] - self.blink.sync["test"].network_info = {"network": {"armed": True}} def tearDown(self): """Clean up after test.""" @@ -44,14 +52,19 @@ def tearDown(self): self.camera = None self.mock_start = None - def test_bad_status(self, mock_resp): + def test_bad_status(self, mock_resp) -> None: """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): + async def test_arm(self, mock_resp) -> None: + """Check that we arm and disarm a module.""" + self.assertTrue(await self.blink.sync["test"].async_arm(True)) + self.assertTrue(await self.blink.sync["test"].async_arm(False)) + + def test_bad_arm(self, mock_resp) -> None: """Check that we mark module unavaiable if bad arm status.""" self.blink.sync["test"].network_info = None self.blink.sync["test"].available = True @@ -62,126 +75,163 @@ def test_bad_arm(self, mock_resp): self.assertEqual(self.blink.sync["test"].arm, None) self.assertFalse(self.blink.sync["test"].available) - def test_get_events(self, mock_resp): + async def test_get_events(self, mock_resp) -> None: """Test get events function.""" mock_resp.return_value = {"event": True} - self.assertEqual(self.blink.sync["test"].get_events(), True) + self.assertEqual(await self.blink.sync["test"].get_events(), True) - def test_get_events_fail(self, mock_resp): + @mock.patch( + "blinkpy.api.request_sync_events", + mock.AsyncMock(return_value={"BAD_event": True}), + ) + async def test_get_events_malformed(self, mock_resp) -> None: + """Test malformed event message.""" + self.assertFalse(await self.blink.sync["test"].get_events()) + + @mock.patch("blinkpy.sync_module.BlinkSyncModule.get_events") + async def test_get_events_fail(self, mock_get, mock_resp) -> None: """Test handling of failed get events function.""" mock_resp.return_value = None - self.assertFalse(self.blink.sync["test"].get_events()) + mock_get.return_value = None + self.assertFalse(await self.blink.sync["test"].get_events()) mock_resp.return_value = {} - self.assertFalse(self.blink.sync["test"].get_events()) + mock_get.return_value = {} + self.assertFalse(await self.blink.sync["test"].get_events()) - def test_get_camera_info(self, mock_resp): + async def test_get_camera_info(self, mock_resp) -> None: """Test get camera info function.""" mock_resp.return_value = {"camera": ["foobar"]} - self.assertEqual(self.blink.sync["test"].get_camera_info("1234"), "foobar") + self.assertEqual( + await self.blink.sync["test"].get_camera_info("1234"), "foobar" + ) - def test_get_camera_info_fail(self, mock_resp): + async def test_get_camera_info_fail(self, mock_resp) -> None: """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(await self.blink.sync["test"].get_camera_info("1"), {}) mock_resp.return_value = {} - self.assertEqual(self.blink.sync["test"].get_camera_info("1"), {}) + self.assertEqual(await 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(await self.blink.sync["test"].get_camera_info("1"), {}) - def test_get_network_info(self, mock_resp): + async def test_get_network_info(self, mock_resp) -> None: """Test network retrieval.""" mock_resp.return_value = {"network": {"sync_module_error": False}} - self.assertTrue(self.blink.sync["test"].get_network_info()) + self.assertTrue(await 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()) + self.assertFalse(await self.blink.sync["test"].get_network_info()) - def test_get_network_info_failure(self, mock_resp): + async def test_get_network_info_failure(self, mock_resp) -> None: """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(await 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(await self.blink.sync["test"].get_network_info()) self.assertFalse(self.blink.sync["test"].available) - def test_check_new_videos_startup(self, mock_resp): + async def test_check_new_videos_startup(self, mock_resp) -> None: """Test that check_new_videos does not block startup.""" sync_module = self.blink.sync["test"] self.blink.last_refresh = None - self.assertFalse(sync_module.check_new_videos()) + self.assertFalse(await sync_module.check_new_videos()) - def test_check_new_videos_failed(self, mock_resp): + async def test_check_new_videos_failed(self, mock_resp) -> None: """Test method when response is unexpected.""" - mock_resp.side_effect = [None, "just a string", {}] + generic_entry = { + "device_name": "foo", + "deleted": True, + "media": "/bar.mp4", + } + result = [generic_entry] + mock_resp.return_value = {"media": result} sync_module = self.blink.sync["test"] + # I think this should be false - should the exception return False? + self.assertTrue(await sync_module.check_new_videos()) + + mock_resp.side_effect = [None, "just a string", {}] sync_module.cameras = {"foo": None} sync_module.motion["foo"] = True - self.assertFalse(sync_module.check_new_videos()) + self.assertFalse(await sync_module.check_new_videos()) self.assertFalse(sync_module.motion["foo"]) sync_module.motion["foo"] = True - self.assertFalse(sync_module.check_new_videos()) + self.assertFalse(await sync_module.check_new_videos()) self.assertFalse(sync_module.motion["foo"]) sync_module.motion["foo"] = True - self.assertFalse(sync_module.check_new_videos()) + self.assertFalse(await sync_module.check_new_videos()) self.assertFalse(sync_module.motion["foo"]) - def test_unexpected_summary(self, mock_resp): + async def test_unexpected_summary(self, mock_resp) -> None: """Test unexpected summary response.""" self.mock_start[0] = None mock_resp.side_effect = self.mock_start - self.assertFalse(self.blink.sync["test"].start()) + self.assertFalse(await self.blink.sync["test"].start()) - def test_summary_with_no_network_id(self, mock_resp): + async def test_summary_with_no_network_id(self, mock_resp) -> None: """Test handling of bad summary.""" self.mock_start[0]["syncmodule"] = None mock_resp.side_effect = self.mock_start - self.assertFalse(self.blink.sync["test"].start()) + self.assertFalse(await self.blink.sync["test"].start()) + + async def test_missing_key_startup(self, mock_resp) -> None: + """Test for missing key at sync module startup.""" + del self.mock_start[0]["syncmodule"]["serial"] + mock_resp.side_effect = self.mock_start + self.assertFalse(await self.blink.sync["test"].start()) - def test_summary_with_only_network_id(self, mock_resp): + async def test_summary_with_only_network_id(self, mock_resp) -> None: """Test handling of sparse summary.""" self.mock_start[0]["syncmodule"] = {"network_id": 8675309} mock_resp.side_effect = self.mock_start - self.blink.sync["test"].start() + await self.blink.sync["test"].start() self.assertEqual(self.blink.sync["test"].network_id, 8675309) - def test_unexpected_camera_info(self, mock_resp): + async def test_unexpected_camera_info(self, mock_resp) -> None: """Test unexpected camera info response.""" self.blink.sync["test"].cameras["foo"] = None self.mock_start[5] = None mock_resp.side_effect = self.mock_start - self.blink.sync["test"].start() + await self.blink.sync["test"].start() self.assertEqual(self.blink.sync["test"].cameras, {"foo": None}) - def test_missing_camera_info(self, mock_resp): + async def test_missing_camera_info(self, mock_resp) -> None: """Test missing key from camera info response.""" self.blink.sync["test"].cameras["foo"] = None self.mock_start[5] = {} - self.blink.sync["test"].start() + await self.blink.sync["test"].start() self.assertEqual(self.blink.sync["test"].cameras, {"foo": None}) - def test_sync_attributes(self, mock_resp): + def test_sync_attributes(self, mock_resp) -> None: """Test sync attributes.""" self.assertEqual(self.blink.sync["test"].attributes["name"], "test") self.assertEqual(self.blink.sync["test"].attributes["network_id"], "1234") - def test_name_not_in_config(self, mock_resp): + async def test_name_not_in_config(self, mock_resp) -> None: """Check that function exits when name not in camera_config.""" test_sync = self.blink.sync["test"] test_sync.camera_list = [{"foo": "bar"}] - self.assertTrue(test_sync.update_cameras()) + self.assertTrue(await test_sync.update_cameras()) - def test_camera_config_key_error(self, mock_resp): + async def test_camera_config_key_error(self, mock_resp) -> None: """Check that update returns False on KeyError.""" test_sync = self.blink.sync["test"] test_sync.camera_list = [{"name": "foobar"}] - self.assertFalse(test_sync.update_cameras()) + self.assertFalse(await test_sync.update_cameras()) - def test_update_local_storage_manifest(self, mock_resp): + @mock.patch( + "blinkpy.sync_module.BlinkSyncModule.get_network_info", + mock.AsyncMock(return_value=False), + ) + async def test_refresh_network_info(self, mock_resp) -> None: + """Test no network info on refresh.""" + self.assertFalse(await self.blink.sync["test"].refresh()) + + async def test_update_local_storage_manifest(self, mock_resp) -> None: """Test getting the manifest from the sync module.""" self.blink.account_id = 10111213 test_sync = self.blink.sync["test"] @@ -229,7 +279,7 @@ def test_update_local_storage_manifest(self, mock_resp): test_sync._names_table[to_alphanumeric("Front Door")] = "Front Door" test_sync._names_table[to_alphanumeric("Back Door")] = "Back Door" test_sync._names_table[to_alphanumeric("Yard")] = "Yard" - test_sync.update_local_storage_manifest() + await test_sync.update_local_storage_manifest() self.assertEqual(len(test_sync._local_storage["manifest"]), 5) self.assertEqual( test_sync._local_storage["manifest"][0].url(), @@ -242,7 +292,7 @@ def test_update_local_storage_manifest(self, mock_resp): + "manifest/4321/clip/request/866333964", ) - def test_check_new_videos_with_local_storage(self, mock_resp): + async def test_check_new_videos_with_local_storage(self, mock_resp) -> None: """Test checking new videos in local storage.""" self.blink.account_id = 10111213 test_sync = self.blink.sync["test"] @@ -252,7 +302,7 @@ def test_check_new_videos_with_local_storage(self, mock_resp): test_sync.cameras["Back Door"] = MockCamera(self.blink.sync) test_sync.cameras["Front_Door"] = MockCamera(self.blink.sync) created_at = ( - datetime.datetime.utcnow() + datetime.timedelta(seconds=30) + datetime.datetime.utcnow() - datetime.timedelta(seconds=60) ).isoformat() mock_resp.side_effect = [ {"id": 387372591, "network_id": 123456}, @@ -277,11 +327,16 @@ def test_check_new_videos_with_local_storage(self, mock_resp): {"media": []}, {"id": 489371591, "network_id": 123456}, {"id": 489371592, "network_id": 123456}, + {"media": []}, + {"id": 489371592, "network_id": 123456}, + {"id": 489371592, "network_id": 123456}, ] + test_sync._names_table[to_alphanumeric("Front_Door")] = "Front_Door" test_sync._names_table[to_alphanumeric("Back Door")] = "Back Door" - test_sync.update_local_storage_manifest() - self.assertTrue(test_sync.check_new_videos()) + await test_sync.update_local_storage_manifest() + self.assertTrue(await test_sync.check_new_videos()) + self.assertTrue(await test_sync.check_new_videos()) self.assertEqual( test_sync.last_records["Back Door"][0]["clip"], "/api/v1/accounts/10111213/networks/1234/sync_modules/1234/local_storage/" @@ -292,3 +347,271 @@ def test_check_new_videos_with_local_storage(self, mock_resp): "/api/v1/accounts/10111213/networks/1234/sync_modules/1234/local_storage/" + "manifest/4321/clip/request/1568781420", ) + + @mock.patch("blinkpy.sync_module.BlinkSyncModule.poll_local_storage_manifest") + # Need to mock out poll_local_storage_manifest due to retries timing out test + async def test_check_no_missing_id_with_update_local_storage_manifest( + self, mock_poll, mock_resp + ) -> None: + """Test checking missing ID in local storage update.""" + self.blink.account_id = 10111213 + test_sync = self.blink.sync["test"] + test_sync._local_storage["status"] = True + test_sync.sync_id = 1234 + + test_sync.cameras["Back Door"] = MockCamera(self.blink.sync) + test_sync.cameras["Front_Door"] = MockCamera(self.blink.sync) + mock_poll.return_value = [ + {"network_id": 123456}, + ] + test_sync._names_table[to_alphanumeric("Front_Door")] = "Front_Door" + test_sync._names_table[to_alphanumeric("Back Door")] = "Back Door" + + self.assertIsNone(await test_sync.update_local_storage_manifest()) + + async def test_check_missing_manifest_id_with_update_local_storage_manifest( + self, mock_resp + ) -> None: + """Test checking missing manifest in update.""" + self.blink.account_id = 10111213 + test_sync = self.blink.sync["test"] + test_sync._local_storage["status"] = True + test_sync.sync_id = 1234 + + test_sync.cameras["Back Door"] = MockCamera(self.blink.sync) + test_sync.cameras["Front_Door"] = MockCamera(self.blink.sync) + created_at = ( + datetime.datetime.utcnow() - datetime.timedelta(seconds=60) + ).isoformat() + + mock_resp.side_effect = [ + {"id": 387372591, "network_id": 123456}, + { + "version": "1.0", + "clips": [ + { + "id": "866333964", + "size": "234", + "camera_name": "BackDoor", + "created_at": f"{created_at}", + }, + { + "id": "1568781420", + "size": "430", + "camera_name": "Front_Door", + "created_at": f"{created_at}", + }, + ], + }, + ] + + test_sync._names_table[to_alphanumeric("Front_Door")] = "Front_Door" + test_sync._names_table[to_alphanumeric("Back Door")] = "Back Door" + + self.assertIsNone(await test_sync.update_local_storage_manifest()) + + async def test_check_malformed_clips_with_update_local_storage_manifest( + self, mock_resp + ) -> None: + """Test checking malformed clips in update.""" + self.blink.account_id = 10111213 + test_sync = self.blink.sync["test"] + test_sync._local_storage["status"] = True + test_sync.sync_id = 1234 + + test_sync.cameras["Back Door"] = MockCamera(self.blink.sync) + test_sync.cameras["Front_Door"] = MockCamera(self.blink.sync) + created_at = ( + datetime.datetime.utcnow() - datetime.timedelta(seconds=60) + ).isoformat() + + mock_resp.side_effect = [ + {"id": 489371591, "network_id": 123456}, + { + "version": "1.0", + "manifest_id": "4321", + "clips": [ + { + "id": "866333964", + "camera_name": "BackDoor", + "created_at": f"{created_at}", + }, + { + "id": "1568781420", + "size": "430", + "camera_name": "Front_Door", + "created_at": f"{created_at}", + }, + ], + }, + ] + test_sync._names_table[to_alphanumeric("Front_Door")] = "Front_Door" + test_sync._names_table[to_alphanumeric("Back Door")] = "Back Door" + + self.assertIsNone(await test_sync.update_local_storage_manifest()) + + async def test_check_poll_local_storage_manifest_retry(self, mock_resp) -> None: + """Test checking poll local storage manifest retry.""" + self.blink.account_id = 10111213 + test_sync = self.blink.sync["test"] + test_sync._local_storage["status"] = True + test_sync.sync_id = 1234 + + test_sync.cameras["Back Door"] = MockCamera(self.blink.sync) + test_sync.cameras["Front_Door"] = MockCamera(self.blink.sync) + + mock_resp.side_effect = [ + {"network_id": 123456}, + ] + test_sync._names_table[to_alphanumeric("Front_Door")] = "Front_Door" + test_sync._names_table[to_alphanumeric("Back Door")] = "Back Door" + + response = await test_sync.poll_local_storage_manifest(max_retries=1) + self.assertEqual( + response, + {"network_id": 123456}, + ) + + async def test_sync_owl_init(self, mock_resp): + """Test sync owl setup with no serial in response.""" + self.blink: Blink = Blink(motion_interval=0, session=mock.AsyncMock()) + self.blink.last_refresh = 0 + self.blink.urls = BlinkURLHandler("test") + response_value = {"id": 489371591, "enabled": 123456, "serial": None} + test = BlinkOwl(self.blink, "test", "1234", response=response_value) + + self.assertIsNotNone(test.serial) + + self.blink.homescreen = {"owls": {"enabled": True}} + self.assertIsNone(await test.get_camera_info("test")) + + async def test_sync_lotus_init(self, mock_resp): + """Test sync lotus setup with no serial in response.""" + self.blink: Blink = Blink(motion_interval=0, session=mock.AsyncMock()) + self.blink.last_refresh = 0 + self.blink.urls = BlinkURLHandler("test") + response_value = {"id": 489371591, "enabled": 123456, "serial": None} + test = BlinkLotus(self.blink, "test", "1234", response=response_value) + + self.assertIsNotNone(test.serial) + + self.blink.homescreen = {"doorbells": {"enabled": True}} + self.assertIsNone(await test.get_camera_info("test")) + + async def test_local_storage_media_item(self, mock_resp): + """Test local storage media properties.""" + blink: Blink = Blink(motion_interval=0, session=mock.AsyncMock()) + blink.last_refresh = 0 + blink.urls = BlinkURLHandler("test") + item = LocalStorageMediaItem( + "1234", + "Backdoor", + datetime.datetime.utcnow().isoformat(), + "432", + " manifest_id", + "url", + ) + item2 = LocalStorageMediaItem( + "1235", + "Backdoor", + datetime.datetime.utcnow().isoformat(), + "432", + " manifest_id", + "url", + ) + self.assertEqual(item.id, 1234) + self.assertEqual(item.size, "432") + self.assertFalse(item == item2) + + mock_resp.side_effect = [ + {"network_id": 123456}, + ] + + self.assertEquals( + await item.prepare_download(blink, max_retries=1), {"network_id": 123456} + ) + + with mock.patch("blinkpy.api.http_post", return_value=""): + self.assertIsNone(await item2.prepare_download(blink, max_retries=0)) + + async def test_poll_local_storage_manifest(self, mock_resp): + """Test incorrect response.""" + with mock.patch("blinkpy.api.request_local_storage_manifest", return_value=""): + self.assertIsNone( + await self.blink.sync["test"].poll_local_storage_manifest(max_retries=0) + ) + + async def test_delete_video(self, mock_resp): + """Test item delete.""" + blink: Blink = Blink(motion_interval=0, session=mock.AsyncMock()) + blink.last_refresh = 0 + blink.urls = BlinkURLHandler("test") + item = LocalStorageMediaItem( + "1234", + "Backdoor", + datetime.datetime.utcnow().isoformat(), + "432", + " manifest_id", + "url", + ) + mock_resp.return_value = mresp.MockResponse({"status": 200}, 200) + self.assertTrue(await item.delete_video(blink)) + + mock_resp.return_value = mresp.MockResponse({"status": 400}, 400) + self.assertFalse(await item.delete_video(blink, 1)) + + async def test_download_video(self, mock_resp): + """Test item download.""" + blink: Blink = Blink(motion_interval=0, session=mock.AsyncMock()) + blink.last_refresh = 0 + blink.urls = BlinkURLHandler("test") + item = LocalStorageMediaItem( + "1234", + "Backdoor", + datetime.datetime.utcnow().isoformat(), + "432", + " manifest_id", + "url", + ) + mock_file = mock.MagicMock() + aiofiles.threadpool.wrap.register(mock.MagicMock)( + lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase( + *args, **kwargs + ) + ) + with mock.patch("aiofiles.threadpool.sync_open", return_value=mock_file): + mock_resp.return_value = mresp.MockResponse({"status": 200}, 200) + self.assertTrue(await item.download_video(blink, "filename.mp4")) + + mock_resp.return_value = mresp.MockResponse({"status": 400}, 400) + self.assertFalse(await item.download_video(blink, "filename.mp4", 1)) + + @mock.patch("blinkpy.sync_module.LocalStorageMediaItem.download_video") + @mock.patch("blinkpy.sync_module.LocalStorageMediaItem.delete_video") + @mock.patch("blinkpy.sync_module.LocalStorageMediaItem.prepare_download") + async def test_download_delete(self, mock_prepdl, mock_del, mock_dl, mock_resp): + """Test download and delete.""" + blink: Blink = Blink(motion_interval=0, session=mock.AsyncMock()) + blink.last_refresh = 0 + blink.urls = BlinkURLHandler("test") + item = LocalStorageMediaItem( + "1234", + "Backdoor", + datetime.datetime.utcnow().isoformat(), + "432", + " manifest_id", + "url", + ) + + self.assertTrue(await item.download_video_delete(self.blink, "filename.mp4")) + + mock_prepdl.return_value = False + self.assertFalse(await item.download_video_delete(self.blink, "filename.mp4")) + + mock_prepdl.return_value = mock.AsyncMock() + mock_del.return_value = False + self.assertFalse(await item.download_video_delete(self.blink, "filename.mp4")) + + mock_del.return_value = mock.AsyncMock() + mock_dl.return_value = False + self.assertFalse(await item.download_video_delete(self.blink, "filename.mp4")) diff --git a/tests/test_util.py b/tests/test_util.py index ed96acb4..06427fcb 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,12 +1,24 @@ """Test various api functions.""" -import unittest -from unittest import mock +from unittest import mock, IsolatedAsyncioTestCase import time -from blinkpy.helpers.util import json_load, Throttle, time_to_seconds, gen_uid - - -class TestUtil(unittest.TestCase): +import aiofiles +from io import BufferedIOBase +from blinkpy.helpers.util import ( + json_load, + json_save, + Throttle, + time_to_seconds, + gen_uid, + get_time, + merge_dicts, + backoff_seconds, + BlinkException, +) +from blinkpy.helpers import constants as const + + +class TestUtil(IsolatedAsyncioTestCase): """Test the helpers/util module.""" def setUp(self): @@ -15,63 +27,63 @@ def setUp(self): def tearDown(self): """Tear down blink module.""" - def test_throttle(self): + async def test_throttle(self): """Test the throttle decorator.""" calls = [] @Throttle(seconds=5) - def test_throttle(): + async def test_throttle(force=False): calls.append(1) now = int(time.time()) now_plus_four = now + 4 now_plus_six = now + 6 - test_throttle() + await test_throttle() self.assertEqual(1, len(calls)) # Call again, still shouldn't fire - test_throttle() + await test_throttle() self.assertEqual(1, len(calls)) # Call with force - test_throttle(force=True) + await test_throttle(force=True) self.assertEqual(2, len(calls)) # Call without throttle, shouldn't fire - test_throttle() + await test_throttle() self.assertEqual(2, len(calls)) # Fake time as 4 seconds from now with mock.patch("time.time", return_value=now_plus_four): - test_throttle() + await test_throttle() self.assertEqual(2, len(calls)) # Fake time as 6 seconds from now with mock.patch("time.time", return_value=now_plus_six): - test_throttle() + await test_throttle() self.assertEqual(3, len(calls)) - def test_throttle_per_instance(self): + async def test_throttle_per_instance(self): """Test that throttle is done once per instance of class.""" class Tester: """A tester class for throttling.""" - def test(self): + async def test(self): """Test the throttle.""" return True tester = Tester() throttled = Throttle(seconds=1)(tester.test) - self.assertEqual(throttled(), True) - self.assertEqual(throttled(), None) + self.assertEqual(await throttled(), True) + self.assertEqual(await throttled(), None) - def test_throttle_multiple_objects(self): + async def test_throttle_multiple_objects(self): """Test that function is throttled even if called by multiple objects.""" @Throttle(seconds=5) - def test_throttle_method(): + async def test_throttle_method(): return True class Tester: @@ -83,22 +95,22 @@ def test(self): tester1 = Tester() tester2 = Tester() - self.assertEqual(tester1.test(), True) - self.assertEqual(tester2.test(), None) + self.assertEqual(await tester1.test(), True) + self.assertEqual(await tester2.test(), None) - def test_throttle_on_two_methods(self): + async def test_throttle_on_two_methods(self): """Test that throttle works for multiple methods.""" class Tester: """A tester class for throttling.""" @Throttle(seconds=3) - def test1(self): + async def test1(self): """Test function for throttle.""" return True @Throttle(seconds=5) - def test2(self): + async def test2(self): """Test function for throttle.""" return True @@ -107,18 +119,18 @@ def test2(self): now_plus_4 = now + 4 now_plus_6 = now + 6 - self.assertEqual(tester.test1(), True) - self.assertEqual(tester.test2(), True) - self.assertEqual(tester.test1(), None) - self.assertEqual(tester.test2(), None) + self.assertEqual(await tester.test1(), True) + self.assertEqual(await tester.test2(), True) + self.assertEqual(await tester.test1(), None) + self.assertEqual(await tester.test2(), None) with mock.patch("time.time", return_value=now_plus_4): - self.assertEqual(tester.test1(), True) - self.assertEqual(tester.test2(), None) + self.assertEqual(await tester.test1(), True) + self.assertEqual(await tester.test2(), None) with mock.patch("time.time", return_value=now_plus_6): - self.assertEqual(tester.test1(), None) - self.assertEqual(tester.test2(), True) + self.assertEqual(await tester.test1(), None) + self.assertEqual(await tester.test2(), True) def test_time_to_seconds(self): """Test time to seconds conversion.""" @@ -127,11 +139,52 @@ def test_time_to_seconds(self): self.assertEqual(time_to_seconds(correct_time), 5) self.assertFalse(time_to_seconds(wrong_time)) - def test_json_load_bad_data(self): + async def test_json_save(self): + """Check that the file is saved.""" + mock_file = mock.MagicMock() + aiofiles.threadpool.wrap.register(mock.MagicMock)( + lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase( + *args, **kwargs + ) + ) + with mock.patch( + "aiofiles.threadpool.sync_open", return_value=mock_file + ) as mock_open: + await json_save('{"test":1,"test2":2}', "face.file") + mock_open.assert_called_once() + + async def test_json_load_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) + filename = "fake.file" + aiofiles.threadpool.wrap.register(mock.MagicMock)( + lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase( + *args, **kwargs + ) + ) + self.assertEqual(await json_load(filename), None) + + mock_file = mock.MagicMock(spec=BufferedIOBase) + mock_file.name = filename + mock_file.read.return_value = '{"some data":"more"}' + with mock.patch("aiofiles.threadpool.sync_open", return_value=mock_file): + self.assertNotEqual(await json_load(filename), None) + + async def test_json_load_bad_data(self): + """Check that bad file is handled.""" + self.assertEqual(await json_load("fake.file"), None) + filename = "fake.file" + aiofiles.threadpool.wrap.register(mock.MagicMock)( + lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase( + *args, **kwargs + ) + ) + self.assertEqual(await json_load(filename), None) + + mock_file = mock.MagicMock(spec=BufferedIOBase) + mock_file.name = filename + mock_file.read.return_value = "" + with mock.patch("aiofiles.threadpool.sync_open", return_value=mock_file): + self.assertEqual(await json_load("fake.file"), None) def test_gen_uid(self): """Test gen_uid formatting.""" @@ -148,3 +201,33 @@ def test_gen_uid(self): self.assertEqual(len(val2_split[2]), 4) self.assertEqual(len(val2_split[3]), 4) self.assertEqual(len(val2_split[4]), 12) + + def test_get_time(self): + """Test the get time util.""" + self.assertEqual( + get_time(), time.strftime(const.TIMESTAMP_FORMAT, time.gmtime(time.time())) + ) + + def test_merge_dicts(self): + """Test for duplicates message in merge dicts.""" + dict_A = {"key1": "value1", "key2": "value2"} + dict_B = {"key1": "value1"} + + expected_log = [ + "WARNING:blinkpy.helpers.util:Duplicates found during merge: ['key1']. " + "Renaming is recommended." + ] + + with self.assertLogs(level="DEBUG") as merge_log: + merge_dicts(dict_A, dict_B) + self.assertListEqual(merge_log.output, expected_log) + + def test_backoff_seconds(self): + """Test the backoff seconds function.""" + self.assertNotEqual(backoff_seconds(), None) + + def test_blink_exception(self): + """Test the Blink Exception class.""" + test_exception = BlinkException([1, "No good"]) + self.assertEqual(test_exception.errid, 1) + self.assertEqual(test_exception.message, "No good")