From 56367a9a74f699caf01fb58a5286e342dc6a7dfa Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 30 Oct 2019 16:24:52 -0600 Subject: [PATCH] Finish out the first iteration of the library (#7) --- .coveragerc | 11 + .gitignore | 3 + Makefile | 12 +- README.md | 85 +++- eufy_security/__version__.py | 2 + eufy_security/api.py | 28 +- eufy_security/camera.py | 9 +- eufy_security/errors.py | 10 + example.py | 23 +- tests/__init__.py | 1 + tests/const.py | 4 + tests/fixtures/__init__.py | 785 +++++++++++++++++++++++++++++++++++ tests/fixtures/camera.py | 18 + tests/test_api.py | 181 ++++++++ tests/test_camera.py | 127 ++++++ 15 files changed, 1270 insertions(+), 29 deletions(-) create mode 100644 .coveragerc create mode 100644 eufy_security/__version__.py create mode 100644 tests/const.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/camera.py create mode 100644 tests/test_api.py create mode 100644 tests/test_camera.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..73ac515 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,11 @@ +[run] +source = eufy_security + +omit = + eufy_security/__version__.py + +[report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: + diff --git a/.gitignore b/.gitignore index 123ff80..ba22623 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ tags Pipfile.lock +.coverage +.mypy_cache/ +coverage.xml diff --git a/Makefile b/Makefile index e877a52..4dad20a 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,21 @@ clean: pipenv --rm coverage: - pipenv run py.test -s --verbose --cov-report term-missing --cov-report xml --cov=aiowwlln tests + pipenv run py.test -s --verbose --cov-report term-missing --cov-report xml --cov=eufy_security tests init: pip3 install --upgrade pip pipenv pipenv lock pipenv install --three --dev pipenv run pre-commit install lint: - pipenv run flake8 aiowwlln - pipenv run pydocstyle aiowwlln - pipenv run pylint aiowwlln + pipenv run flake8 eufy_security + pipenv run pydocstyle eufy_security + pipenv run pylint eufy_security publish: pipenv run python setup.py sdist bdist_wheel pipenv run twine upload dist/* - rm -rf dist/ build/ .egg aiowwlln.egg-info/ + rm -rf dist/ build/ .egg eufy_security.egg-info/ test: pipenv run py.test typing: - pipenv run mypy --ignore-missing-imports aiowwlln + pipenv run mypy --ignore-missing-imports eufy_security diff --git a/README.md b/README.md index 0e5030a..cbd8e1a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,85 @@ # python-eufy-security -Python library for Eufy Security cameras -Very much a work in progress. Will be used for integration into HomeAssistant. +This is an experimental Python library for Eufy Security devices (cameras, doorbells, +etc.). + +# Python Versions + +The library is currently supported on + +* Python 3.6 +* Python 3.7 + +# Installation + +TBD + +# Usage + +Everything starts with an: +[aiohttp](https://aiohttp.readthedocs.io/en/stable/) `ClientSession`: + +```python +import asyncio + +from aiohttp import ClientSession + + +async def main() -> None: + """Create the aiohttp session and run the example.""" + async with ClientSession() as websession: + # YOUR CODE HERE + + +asyncio.get_event_loop().run_until_complete(main()) +``` + +Login and get to work: + +```python +import asyncio + +from aiohttp import ClientSession + +from eufy_security import async_login + + +async def main() -> None: + """Create the aiohttp session and run the example.""" + async with ClientSession() as websession: + # Create an API client: + api = await async_login(EUFY_EMAIL, EUFY_PASSWORD, websession) + + # Loop through the cameras associated with the account: + for camera in api.cameras.values(): + print("------------------") + print("Camera Name: %s", camera.name) + print("Serial Number: %s", camera.serial) + print("Station Serial Number: %s", camera.station_serial) + print("Last Camera Image URL: %s", camera.last_camera_image_url) + + print("Starting RTSP Stream") + stream_url = await camera.async_start_stream() + print("Stream URL: %s", stream_url) + + print("Stopping RTSP Stream") + stream_url = await camera.async_stop_stream() + + +asyncio.get_event_loop().run_until_complete(main()) +``` + +Check out `example.py`, the tests, and the source files themselves for method +signatures and more examples. + +# Contributing + +1. [Check for open features/bugs](https://github.com/FuzzyMistborn/python-eufy-security/issues) + or [initiate a discussion on one](https://github.com/FuzzyMistborn/python-eufy-security/issues/new). +2. [Fork the repository](https://github.com/FuzzyMistborn/python-eufy-security/fork). +3. Install the dev environment: `make init`. +4. Enter the virtual environment: `pipenv shell` +5. Code your new feature or bug fix. +6. Write a test that covers your new functionality. +7. Run tests and ensure 100% code coverage: `make coverage` +8. Submit a pull request! diff --git a/eufy_security/__version__.py b/eufy_security/__version__.py new file mode 100644 index 0000000..8f0889a --- /dev/null +++ b/eufy_security/__version__.py @@ -0,0 +1,2 @@ +"""Define the package version.""" +VERSION: str = "0.0.1" diff --git a/eufy_security/api.py b/eufy_security/api.py index 75085ce..3e2d50f 100644 --- a/eufy_security/api.py +++ b/eufy_security/api.py @@ -7,7 +7,7 @@ from aiohttp.client_exceptions import ClientError from .camera import Camera -from .errors import ExpiredTokenError, InvalidCredentialsError, RequestError +from .errors import RequestError, raise_error _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -40,13 +40,19 @@ async def async_authenticate(self) -> None: auth_resp["data"]["token_expires_at"] ) + async def async_get_history(self) -> dict: + """Get the camera's history.""" + history_resp = await self.request("post", "event/app/get_all_history_record") + + return history_resp["data"] + async def async_update_device_info(self) -> None: """Get the latest device info.""" devices_resp = await self.request("post", "app/get_devs_list") for device_info in devices_resp["data"]: if device_info["device_sn"] in self.cameras: - camera = self.cameras[device_info["device_info"]] + camera = self.cameras[device_info["device_sn"]] camera.camera_info = device_info continue @@ -78,25 +84,29 @@ async def request( method, url, headers=headers, json=json ) as resp: try: + resp.raise_for_status() data: dict = await resp.json(content_type=None) if not data: raise RequestError(f"No response while requesting {endpoint}") - if data["code"] != 0: - raise RequestError( - f"There was an error while requesting {endpoint}: {data['msg']}" - ) + _raise_on_error(data) return data except ClientError as err: - if "401" in str(err): - raise InvalidCredentialsError("Invalid credentials") raise RequestError( - f"There was an unknown error while requesting {endpoint}" + f"There was an unknown error while requesting {endpoint}: {err}" ) +def _raise_on_error(data: dict) -> None: + """Raise appropriately when a returned data payload contains an error.""" + if data["code"] == 0: + return + + raise_error(data) + + async def async_login(email: str, password: str, websession: ClientSession) -> API: """Return an authenticated API object.""" api: API = API(email, password, websession) diff --git a/eufy_security/camera.py b/eufy_security/camera.py index 4d2abaa..270889c 100644 --- a/eufy_security/camera.py +++ b/eufy_security/camera.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from .api import API + from .api import API # pylint: disable=cyclic-import _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -36,13 +36,6 @@ def station_serial(self) -> str: """Return the camera's station serial number.""" return self.camera_info["station_sn"] - async def async_get_history(self) -> dict: - """Get the camera's history.""" - history_resp = await self._api.request( - "post", "event/app/get_all_history_record" - ) - return history_resp["data"] - async def async_start_stream(self) -> str: """Start the camera stream and return the RTSP URL.""" start_resp = await self._api.request( diff --git a/eufy_security/errors.py b/eufy_security/errors.py index 28d6228..96b25ea 100644 --- a/eufy_security/errors.py +++ b/eufy_security/errors.py @@ -1,4 +1,5 @@ """Define package errors.""" +from typing import Dict, Type class EufySecurityError(Exception): @@ -17,3 +18,12 @@ class RequestError(EufySecurityError): """Define an error related to invalid requests.""" pass + + +ERRORS: Dict[int, Type[EufySecurityError]] = {26006: InvalidCredentialsError} + + +def raise_error(data: dict) -> None: + """Raise the appropriate error based upon a response code.""" + cls = ERRORS.get(data["code"], EufySecurityError) + raise cls(data["msg"]) diff --git a/example.py b/example.py index 803a7c0..56dbd45 100644 --- a/example.py +++ b/example.py @@ -1,17 +1,21 @@ """Run an example script to quickly test.""" import asyncio +import logging from aiohttp import ClientSession from eufy_security import async_login from eufy_security.errors import EufySecurityError -EUFY_EMAIL = "" -EUFY_PASSWORD = "" +_LOGGER: logging.Logger = logging.getLogger() + +EUFY_EMAIL: str = "" +EUFY_PASSWORD: str = "" async def main() -> None: """Create the aiohttp session and run the example.""" + logging.basicConfig(level=logging.INFO) async with ClientSession() as websession: try: # Create an API client: @@ -19,9 +23,20 @@ async def main() -> None: # Loop through the cameras associated with the account: for camera in api.cameras.values(): - print(camera.name) + _LOGGER.info("------------------") + _LOGGER.info("Camera Name: %s", camera.name) + _LOGGER.info("Serial Number: %s", camera.serial) + _LOGGER.info("Station Serial Number: %s", camera.station_serial) + _LOGGER.info("Last Camera Image URL: %s", camera.last_camera_image_url) + + _LOGGER.info("Starting RTSP Stream") + stream_url = await camera.async_start_stream() + _LOGGER.info("Stream URL: %s", stream_url) + + _LOGGER.info("Stopping RTSP Stream") + stream_url = await camera.async_stop_stream() except EufySecurityError as err: - print(f"There was an error: {err}") + print(f"There was a/an {type(err)} error: {err}") asyncio.get_event_loop().run_until_complete(main()) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..752bc6e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Define the test suite.""" diff --git a/tests/const.py b/tests/const.py new file mode 100644 index 0000000..b24cc22 --- /dev/null +++ b/tests/const.py @@ -0,0 +1,4 @@ +"""Define constants to use in tests.""" +TEST_ACCESS_TOKEN = "abcde12345" +TEST_EMAIL = "user@host.com" +TEST_PASSWORD = "password12345" diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..6a87f93 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1,785 @@ +"""Define fixtures for the base API.""" +from datetime import datetime, timedelta + +import pytest + +from ..const import TEST_ACCESS_TOKEN, TEST_EMAIL + + +@pytest.fixture() +def devices_list_json(): + """Define a successful response to POST /api/v1/app/get_devs_list.""" + return { + "code": 0, + "msg": "Succeed.", + "data": [ + { + "device_id": 14907, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "device_name": "Driveway", + "device_model": "T8111", + "time_zone": "", + "device_type": 1, + "device_channel": 0, + "station_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "schedule": "", + "schedulex": "", + "wifi_mac": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "sub1g_mac": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "main_sw_version": "1.9.3", + "main_hw_version": "HAIYI-IMX323", + "sec_sw_version": "2.0.6.3-0627-us", + "sec_hw_version": "P1", + "sector_id": 0, + "event_num": 1, + "wifi_ssid": "", + "ip_addr": "", + "main_sw_time": 1565008299, + "sec_sw_time": 1563582100, + "bind_time": 1546718435, + "cover_path": "https://path/to/image.jpg", + "cover_time": 1572460903, + "local_ip": "", + "create_time": 1539179003, + "update_time": 1572460903, + "status": 1, + "svr_domain": "", + "svr_port": 0, + "station_conn": { + "station_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "station_name": "Home", + "station_model": "T8001", + "main_sw_version": "1.1.1.5", + "main_hw_version": "P1", + "p2p_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "push_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "ndt_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "p2p_conn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "app_conn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "binded": False, + "setup_code": "", + "setup_id": "", + }, + "family_num": 0, + "member": { + "family_id": 18456, + "station_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "admin_user_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "member_user_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "member_type": 2, + "permissions": 0, + "member_nick": "", + "action_user_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "fence_state": 0, + "create_time": 1546717990, + "update_time": 1546717990, + "status": 1, + "email": TEST_EMAIL, + "nick_name": "", + "avatar": "", + "action_user_name": "", + }, + "permission": None, + "params": [ + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1015, + "param_value": "0", + "create_time": 1546718455, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1239, + "param_value": "9", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1243, + "param_value": "2", + "create_time": 1546718455, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1210, + "param_value": "50", + "create_time": 1546718455, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1101, + "param_value": "80", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1246, + "param_value": "0", + "create_time": 1546718455, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1134, + "param_value": "0", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1204, + "param_value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "create_time": 1553318596, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1145, + "param_value": "0", + "create_time": 1546722865, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1229, + "param_value": "100", + "create_time": 1546718455, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615122, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 99901, + "param_value": "0", + "create_time": 1546718451, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1141, + "param_value": "-89", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1013, + "param_value": "1", + "create_time": 1546718455, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1230, + "param_value": "100", + "create_time": 1546718455, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1142, + "param_value": "-64", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1011, + "param_value": "1", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1225, + "param_value": "1", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1252, + "param_value": "0", + "create_time": 1572355417, + "update_time": 1572355417, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1146, + "param_value": "0", + "create_time": 1547260362, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 2111, + "param_value": "2", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1241, + "param_value": "1", + "create_time": 1546718455, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1131, + "param_value": "1", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 99904, + "param_value": "0", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1045, + "param_value": "0", + "create_time": 1546718455, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1240, + "param_value": "1", + "create_time": 1546718455, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1", + "param_type": 1138, + "param_value": "-5", + "create_time": 1546718454, + "update_time": 0, + "status": 1, + }, + ], + "pir_total": 40, + "pir_none": 8, + "week_pir_total": 159, + "week_pir_none": 90, + "month_pir_total": 1067, + "month_pir_none": 831, + }, + { + "device_id": 14920, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "device_name": "Patio", + "device_model": "T8111", + "time_zone": "", + "device_type": 1, + "device_channel": 1, + "station_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "schedule": "", + "schedulex": "", + "wifi_mac": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "sub1g_mac": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "main_sw_version": "1.9.3", + "main_hw_version": "HAIYI-IMX323", + "sec_sw_version": "2.0.6.3-0627-us", + "sec_hw_version": "P1", + "sector_id": 0, + "event_num": 0, + "wifi_ssid": "", + "ip_addr": "", + "main_sw_time": 1565008350, + "sec_sw_time": 1563582129, + "bind_time": 1546718602, + "cover_path": "https://path/to/image.jpg", + "cover_time": 1572455103, + "local_ip": "", + "create_time": 1539179631, + "update_time": 1572460765, + "status": 1, + "svr_domain": "", + "svr_port": 0, + "station_conn": { + "station_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "station_name": "Home", + "station_model": "T8001", + "main_sw_version": "1.1.1.5", + "main_hw_version": "P1", + "p2p_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "push_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "ndt_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "p2p_conn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "app_conn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "binded": False, + "setup_code": "", + "setup_id": "", + }, + "family_num": 0, + "member": { + "family_id": 18456, + "station_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "admin_user_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "member_user_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "member_type": 2, + "permissions": 0, + "member_nick": "", + "action_user_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "fence_state": 0, + "create_time": 1546717990, + "update_time": 1546717990, + "status": 1, + "email": TEST_EMAIL, + "nick_name": "", + "avatar": "", + "action_user_name": "", + }, + "permission": None, + "params": [ + { + "param_id": 615148, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1015, + "param_value": "0", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615155, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1239, + "param_value": "9", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615147, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1243, + "param_value": "2", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615145, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1210, + "param_value": "30", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615158, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1101, + "param_value": "95", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615146, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1246, + "param_value": "0", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1134, + "param_value": "0", + "create_time": 1546718642, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1204, + "param_value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "create_time": 1553318600, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1145, + "param_value": "0", + "create_time": 1546722873, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615151, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1229, + "param_value": "100", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 99901, + "param_value": "0", + "create_time": 1546718629, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615150, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1013, + "param_value": "1", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1141, + "param_value": "-57", + "create_time": 1546718642, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1142, + "param_value": "-45", + "create_time": 1546718642, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615153, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1230, + "param_value": "100", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615144, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1011, + "param_value": "1", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615156, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1225, + "param_value": "1", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1252, + "param_value": "0", + "create_time": 1572357260, + "update_time": 1572357260, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1146, + "param_value": "0", + "create_time": 1547260787, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 2111, + "param_value": "2", + "create_time": 1546718642, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615154, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1241, + "param_value": "1", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615157, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1131, + "param_value": "1", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615159, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 99904, + "param_value": "0", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615149, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1045, + "param_value": "0", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 615152, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1240, + "param_value": "1", + "create_time": 1546718622, + "update_time": 0, + "status": 1, + }, + { + "param_id": 0, + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2", + "param_type": 1138, + "param_value": "-11", + "create_time": 1546718642, + "update_time": 0, + "status": 1, + }, + ], + "pir_total": 37, + "pir_none": 28, + "week_pir_total": 124, + "week_pir_none": 82, + "month_pir_total": 1119, + "month_pir_none": 603, + }, + ], + } + + +@pytest.fixture() +def empty_response(): + """Define a fixture that returns an empty response.""" + return None + + +@pytest.fixture() +def history_json(): + """Define a successful response to POST api/v1/event/app/get_all_history_record.""" + return { + "code": 0, + "msg": "Succeed.", + "data": [ + { + "monitor_id": 128428371, + "station_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "storage_type": 1, + "storage_path": "/media/mmcblk0p1/Camera00/20191028123014.dat", + "hevc_storage_path": "", + "cloud_path": "", + "frame_num": 119, + "thumb_path": "https://path/to/image.jpg", + "thumb_data": "", + "start_time": 1572287416088, + "end_time": 1572287426100, + "cipher_id": 158, + "cipher_user_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "has_human": 0, + "volume": "Anker_HmQx0Cp_g", + "vision": 0, + "device_name": "Driveway", + "device_type": 1, + "video_type": 0, + "extra": "", + "viewed": False, + "create_time": 1572287430, + "update_time": 1572287430, + "status": 1, + "station_name": "", + "p2p_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "push_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "p2p_license": "WPMWQK", + "push_license": "BDYNMB", + "ndt_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "ndt_license": "DKUIYX", + "p2p_conn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "app_conn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "wipn_enc_dec_key": "ZXSecurity17Cam@", + "wipn_ndt_aes128key": "ZXSecurity17Cam@", + "query_server_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "prefix": "", + "ai_faces": None, + "is_favorite": False, + }, + { + "monitor_id": 128340343, + "station_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "device_sn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "storage_type": 1, + "storage_path": "/media/mmcblk0p1/Camera00/20191028100453.dat", + "hevc_storage_path": "", + "cloud_path": "", + "frame_num": 113, + "thumb_path": "https://path/to/image.jpg", + "thumb_data": "", + "start_time": 1572278695752, + "end_time": 1572278702140, + "cipher_id": 158, + "cipher_user_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "has_human": 1, + "volume": "Anker_HmQx0Cp_g", + "vision": 0, + "device_name": "Driveway", + "device_type": 1, + "video_type": 0, + "extra": "", + "viewed": False, + "create_time": 1572278710, + "update_time": 1572278710, + "status": 1, + "station_name": "", + "p2p_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "push_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "p2p_license": "WPMWQK", + "push_license": "BDYNMB", + "ndt_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "ndt_license": "DKUIYX", + "p2p_conn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "app_conn": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "wipn_enc_dec_key": "ZXSecurity17Cam@", + "wipn_ndt_aes128key": "ZXSecurity17Cam@", + "query_server_did": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "prefix": "", + "ai_faces": None, + "is_favorite": False, + }, + ], + } + + +@pytest.fixture() +def login_invalid_email_json(): + """ + Define a "failure" response to POST /api/v1/passport/login. + + This failure is because of an invalid email address. + """ + return {"code": 26006, "msg": "This email address does not exist.", "data": None} + + +@pytest.fixture() +def login_invalid_password_json(): + """ + Define a "failure" response to POST /api/v1/passport/login. + + This failure is because of an invalid email address/password combination. + """ + return {"code": 26006, "msg": "Email address or password incorrect.", "data": None} + + +@pytest.fixture() +def login_success_json(): + """Define a successful response to POST /api/v1/passport/login.""" + return { + "code": 0, + "msg": "Succeed.", + "data": { + "user_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "email": TEST_EMAIL, + "nick_name": "", + "auth_token": TEST_ACCESS_TOKEN, + "token_expires_at": int((datetime.now() + timedelta(days=1)).timestamp()), + "avatar": "", + "invitation_code": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "inviter_code": "", + "mac_addr": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "domain": "security-app.eufylife.com", + "ab_code": "US", + "geo_key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "privilege": 0, + "params": [ + {"param_type": 10000, "param_value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} + ], + }, + } diff --git a/tests/fixtures/camera.py b/tests/fixtures/camera.py new file mode 100644 index 0000000..5059d8c --- /dev/null +++ b/tests/fixtures/camera.py @@ -0,0 +1,18 @@ +"""Define fixtures for cameras.""" +import pytest + + +@pytest.fixture() +def start_stream_json(): + """Define a successful response to POST /api/v1/web/equipment/start_stream.""" + return { + "code": 0, + "msg": "Succeed.", + "data": {"url": "rtmp://p2p-vir-6.eufylife.com/hls/123"}, + } + + +@pytest.fixture() +def stop_stream_json(): + """Define a successful response to POST /api/v1/web/equipment/stop_stream.""" + return {"code": 0, "msg": "Succeed."} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..87657b7 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,181 @@ +"""Define tests for the base API.""" +from datetime import datetime, timedelta +import json + +import aiohttp +import pytest + +from eufy_security import async_login +from eufy_security.errors import InvalidCredentialsError, RequestError + +from .const import TEST_EMAIL, TEST_PASSWORD +from .fixtures import ( + devices_list_json, + empty_response, + history_json, + login_invalid_email_json, + login_invalid_password_json, + login_success_json, +) + + +@pytest.mark.asyncio +async def test_bad_email(aresponses, event_loop, login_invalid_email_json): + """Test authenticating with a bad email.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_invalid_email_json), status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + with pytest.raises(InvalidCredentialsError): + await async_login("bad_email@host.com", TEST_PASSWORD, websession) + + +@pytest.mark.asyncio +async def test_bad_password(aresponses, event_loop, login_invalid_password_json): + """Test authenticating with a bad pasword.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_invalid_password_json), status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + with pytest.raises(InvalidCredentialsError): + await async_login(TEST_EMAIL, "bad_password", websession) + + +@pytest.mark.asyncio +async def test_empty_response( + aresponses, empty_response, event_loop, login_success_json +): + """Test the odd use case that arises when a response is empty.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=None, status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + with pytest.raises(RequestError): + await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + + +@pytest.mark.asyncio +async def test_http_error(aresponses, event_loop, login_success_json): + """Test the Eufy Security web API returning a non-2xx HTTP error code.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=None, status=500), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + with pytest.raises(RequestError): + await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + + +@pytest.mark.asyncio +async def test_get_history( + aresponses, devices_list_json, event_loop, history_json, login_success_json +): + """Test a successful login and API object creation.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/event/app/get_all_history_record", + "post", + aresponses.Response(text=json.dumps(history_json), status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + history = await api.async_get_history() + assert len(history) == 2 + + +@pytest.mark.asyncio +async def test_login_success( + aresponses, devices_list_json, event_loop, login_success_json +): + """Test a successful login and API object creation.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + assert api._email == TEST_EMAIL + assert api._password == TEST_PASSWORD + assert api._token is not None + assert api._token_expiration is not None + assert len(api.cameras) == 2 + + +@pytest.mark.asyncio +async def test_refreshing_access_token( + aresponses, devices_list_json, event_loop, login_success_json +): + """Test that an expired access token refreshes automatically and correctly.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + api._token_expiration = datetime.now() - timedelta(seconds=10) + await api.async_update_device_info() + assert len(api.cameras) == 2 diff --git a/tests/test_camera.py b/tests/test_camera.py new file mode 100644 index 0000000..be2d22c --- /dev/null +++ b/tests/test_camera.py @@ -0,0 +1,127 @@ +"""Define tests for cameras.""" +import json + +import aiohttp +import pytest + +from eufy_security import async_login + +from .const import TEST_EMAIL, TEST_PASSWORD +from .fixtures import devices_list_json, login_success_json +from .fixtures.camera import start_stream_json, stop_stream_json + + +@pytest.mark.asyncio +async def test_properties( + aresponses, devices_list_json, event_loop, login_success_json +): + """Test authenticating with a bad email.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + camera = list(api.cameras.values())[0] + assert camera.last_camera_image_url == "https://path/to/image.jpg" + assert camera.name == "Driveway" + assert camera.serial == "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1" + assert camera.station_serial == "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + + +@pytest.mark.asyncio +async def test_start_stream( + aresponses, devices_list_json, event_loop, login_success_json, start_stream_json +): + """Test starting the RTSP stream.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/web/equipment/start_stream", + "post", + aresponses.Response(text=json.dumps(start_stream_json), status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + camera = list(api.cameras.values())[0] + stream_url = await camera.async_start_stream() + assert stream_url == "rtmp://p2p-vir-6.eufylife.com/hls/123" + + +@pytest.mark.asyncio +async def test_stop_stream( + aresponses, devices_list_json, event_loop, login_success_json, stop_stream_json +): + """Test stopping the RTSP stream.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/web/equipment/stop_stream", + "post", + aresponses.Response(text=json.dumps(stop_stream_json), status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + camera = list(api.cameras.values())[0] + await camera.async_stop_stream() + + +@pytest.mark.asyncio +async def test_update(aresponses, devices_list_json, event_loop, login_success_json): + """Test stopping the RTSP stream.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + camera = list(api.cameras.values())[0] + await camera.async_update()