diff --git a/.github/stale.yml b/.github/stale.yml index 45dca089..dbe57159 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,5 +1,5 @@ # Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 +daysUntilStale: 30 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 @@ -8,7 +8,8 @@ daysUntilClose: 7 exemptLabels: - Priority - bug - - Help Wanted + - help wanted + - feature request # Set to true to ignore issues in a project (defaults to false) exemptProjects: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 00b8429d..b19ea603 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8] + python-version: [3.9] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bd392ad5..473af5d3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v1 with: - python-version: '3.7' + python-version: '3.8' - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4369ed94..1e64154b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,11 +10,11 @@ jobs: build: runs-on: ${{ matrix.platform }} strategy: - max-parallel: 3 + max-parallel: 4 matrix: platform: - ubuntu-latest - python-version: [3.6, 3.7, 3.8] + python-version: [3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 5eabca97..ebe0e925 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ build/* docs/_build *.log venv +.session* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 1db3211f..00000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,25 +0,0 @@ -image: python - -stages: - - test - -before_script: - - curl -O https://bootstrap.pypa.io/get-pip.py - - python get-pip.py - - pip install tox - -python35: - image: python:3.5 - stage: test - script: tox -e py35 - -python36: - image: python:3.6 - stage: test - script: tox -e py36 - -lint: - image: python:3.6 - stage: test - script: tox -e lint - diff --git a/CHANGES.rst b/CHANGES.rst index d8171ab8..a9924a39 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,29 @@ Changelog A list of changes between each release +0.17.0 (2021-02-15) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Bugfixes:** +- Fix video downloading bug (`#424 `__) +- Fix repeated authorization email bug (`#432 `__ and `#428 `__) + +**New Features:** +- Add logout method (`#429 `__) +- Add camera record method (`#430 `__) + +**Other:** +- Add debug script to main repo to help with general debug +- Upgrade login endpoint from v4 to v5 +- Add python 3.9 support +- Bump coverage to 5.4 +- Bump pytest to 6.2.2 +- Bump pytest-cov to 2.11.1 +- Bump pygments to 2.8.0 +- Bump pre-commit to 2.10.1 +- Bump restructuredtext-lint to 1.3.2 + + 0.16.4 (2020-11-22) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/blinkpy/api.py b/blinkpy/api.py index d72ca0d7..3df449a4 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -23,18 +23,16 @@ def request_login( headers = { "Host": DEFAULT_URL, "Content-Type": "application/json", - "Accept": "/", "user-agent": DEFAULT_USER_AGENT, } data = dumps( { "email": login_data["username"], "password": login_data["password"], - "notification_key": login_data["notification_key"], "unique_id": login_data["uid"], "device_identifier": login_data["device_id"], "client_name": "Computer", - "reauth": "false", + "reauth": True, } ) @@ -57,6 +55,12 @@ def request_verify(auth, blink, verify_key): ) +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) + + def request_networks(blink): """Request all networks information.""" url = f"{blink.urls.base_url}/networks" diff --git a/blinkpy/auth.py b/blinkpy/auth.py index bc613879..3e78c134 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -58,7 +58,11 @@ def header(self): """Return authorization header.""" if self.token is None: return None - return {"TOKEN_AUTH": self.token, "user-agent": DEFAULT_USER_AGENT} + return { + "TOKEN_AUTH": self.token, + "user-agent": DEFAULT_USER_AGENT, + "content-type": "application/json", + } def create_session(self, opts=None): """Create a session for blink communication.""" @@ -104,8 +108,12 @@ def login(self, login_url=LOGIN_ENDPOINT): if response.status_code == 200: return response.json() raise LoginError - except AttributeError: - raise LoginError + except AttributeError as error: + raise LoginError from error + + def logout(self, blink): + """Log out.""" + return api.request_logout(blink) def refresh_token(self): """Refresh auth token.""" @@ -115,21 +123,21 @@ def refresh_token(self): self.login_response = self.login() self.extract_login_info() self.is_errored = False - except LoginError: + except LoginError as error: _LOGGER.error("Login endpoint failed. Try again later.") - raise TokenRefreshFailed - except (TypeError, KeyError): + raise TokenRefreshFailed from error + except (TypeError, KeyError) as error: _LOGGER.error("Malformed login response: %s", self.login_response) - raise TokenRefreshFailed + raise TokenRefreshFailed from error return True def extract_login_info(self): """Extract login info from login response.""" - self.region_id = self.login_response["region"]["tier"] + self.region_id = self.login_response["account"]["tier"] self.host = f"{self.region_id}.{BLINK_URL}" - self.token = self.login_response["authtoken"]["authtoken"] - self.client_id = self.login_response["client"]["id"] - self.account_id = self.login_response["account"]["id"] + self.token = self.login_response["auth"]["token"] + self.client_id = self.login_response["account"]["client_id"] + self.account_id = self.login_response["account"]["account_id"] def startup(self): """Initialize tokens for communication.""" @@ -151,8 +159,8 @@ def validate_response(self, response, json_resp): json_data = response.json() except KeyError: pass - except (AttributeError, ValueError): - raise BlinkBadResponse + except (AttributeError, ValueError) as error: + raise BlinkBadResponse from error self.is_errored = False return json_data @@ -227,6 +235,9 @@ def send_auth_key(self, blink, key): try: json_resp = response.json() blink.available = json_resp["valid"] + if not json_resp["valid"]: + _LOGGER.error("%s", json_resp["message"]) + return False except (KeyError, TypeError): _LOGGER.error("Did not receive valid response from server.") return False @@ -235,7 +246,7 @@ def send_auth_key(self, blink, key): def check_key_required(self): """Check if 2FA key is required.""" try: - if self.login_response["client"]["verification_required"]: + if self.login_response["account"]["client_verification_required"]: return True except (KeyError, TypeError): pass diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 186b8559..6c752962 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -98,6 +98,10 @@ def arm(self, value): self.sync.blink, self.network_id, self.camera_id ) + def record(self): + """Initiate clip recording.""" + return api.request_new_video(self.sync.blink, self.network_id, self.camera_id) + def get_media(self, media_type="image"): """Download media (image or video).""" url = self.thumbnail diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 1862d3dc..164a44af 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -3,8 +3,8 @@ import os MAJOR_VERSION = 0 -MINOR_VERSION = 16 -PATCH_VERSION = 4 +MINOR_VERSION = 17 +PATCH_VERSION = 0 __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" @@ -34,6 +34,7 @@ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Topic :: Home Automation", ] @@ -48,7 +49,7 @@ BLINK_URL = "immedia-semi.com" DEFAULT_URL = f"rest-prod.{BLINK_URL}" BASE_URL = f"https://{DEFAULT_URL}" -LOGIN_ENDPOINT = f"{BASE_URL}/api/v4/account/login" +LOGIN_ENDPOINT = f"{BASE_URL}/api/v5/account/login" """ Dictionaries diff --git a/blinkpy/helpers/util.py b/blinkpy/helpers/util.py index 3bc7380f..81410ad5 100644 --- a/blinkpy/helpers/util.py +++ b/blinkpy/helpers/util.py @@ -33,10 +33,13 @@ def json_save(data, file_name): json.dump(data, json_file, indent=4) -def gen_uid(size): +def gen_uid(size, uid_format=False): """Create a random sring.""" - full_token = secrets.token_hex(size) - return full_token[0:size] + if uid_format: + token = f"BlinkCamera_{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}" + else: + token = secrets.token_hex(size) + return token def time_to_seconds(timestamp): @@ -79,10 +82,7 @@ def prompt_login_data(data): def validate_login_data(data): """Check for missing keys.""" - data["uid"] = data.get("uid", gen_uid(const.SIZE_UID)) - data["notification_key"] = data.get( - "notification_key", gen_uid(const.SIZE_NOTIFICATION_KEY) - ) + data["uid"] = data.get("uid", gen_uid(const.SIZE_UID, uid_format=True)) data["device_id"] = data.get("device_id", const.DEVICE_ID) return data diff --git a/debug_login.py b/debug_login.py new file mode 100644 index 00000000..15d8955d --- /dev/null +++ b/debug_login.py @@ -0,0 +1,77 @@ +"""Login testing script.""" +import sys +import os +from blinkpy.blinkpy import Blink +from blinkpy.auth import Auth +from blinkpy.helpers.util import json_load, json_save + +save_session = False +print("") +print("Blink Login Debug Script ...") +print(" ... Loading previous session information.") +cwd = os.getcwd() +print(f" ... Looking in {cwd}.") +session_path = os.path.join(cwd, ".session_debug") +session = json_load(session_path) + +try: + auth_file = session["file"] +except (TypeError, KeyError): + print(" ... Please input location of auth file") + auth_file = input(" Must contain username and password: ") + save_session = True + +data = json_load(auth_file) +if data is None: + print(f" ... Please fix file contents of {auth_file}.") + print(" ... Exiting.") + sys.exit(1) + +try: + username = data["username"] + password = data["password"] +except KeyError: + print(f" ... File contents of {auth_file} incorrect.") + print(" ... Require username and password at minimum.") + print(" ... Exiting.") + sys.exit(1) + +if save_session: + print(f" ... Saving session file to {session_path}.") + json_save({"file": auth_file}, session_path) + +blink = Blink() +auth = Auth(data) +blink.auth = auth + +print(" ... Starting Blink.") +print("") +blink.start() +print("") +print(" ... Printing login response.") +print("") +print(blink.auth.login_response) +print("") +print(" ... Printing login attributes.") +print("") +print(blink.auth.login_attributes) +print("") +input(" ... Press any key to continue: ") +print(" ... Deactivating auth token.") +blink.auth.token = "foobar" +print(f"\t - blink.auth.token = {blink.auth.token}") + +print(" ... Attempting login.") +print("") +blink.start() +print("") +print(" ... Printing login response.") +print("") +print(blink.auth.login_response) +print("") +print(" ... Printing login attributes.") +print("") +print(blink.auth.login_attributes) +print("") +rint(" ... Done.") +print("") diff --git a/requirements_test.txt b/requirements_test.txt index 2cb1cc7a..bea74d14 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,14 +1,14 @@ black==19.10b0 -coverage==5.3 +coverage==5.4 flake8==3.8.4 flake8-docstrings==1.5.0 -pre-commit==2.7.1 +pre-commit==2.10.1 pylint==2.6.0 pydocstyle==5.1.1 -pytest==6.1.1 -pytest-cov==2.10.1 +pytest==6.2.2 +pytest-cov==2.11.1 pytest-sugar==0.9.4 pytest-timeout==1.4.2 -restructuredtext-lint==1.3.1 -pygments==2.7.1 +restructuredtext-lint==1.3.2 +pygments==2.8.0 testtools>=2.4.0 diff --git a/tests/test_auth.py b/tests/test_auth.py index c5c082b6..9c918974 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -42,7 +42,6 @@ def test_empty_init(self, getpwd, genuid): "username": "foo", "password": "bar", "uid": 1234, - "notification_key": 1234, "device_id": const.DEVICE_ID, } self.assertDictEqual(auth.data, expected_data) @@ -62,7 +61,6 @@ def test_barebones_init(self, getpwd, genuid): "username": "foo", "password": "bar", "uid": 1234, - "notification_key": 1234, "device_id": const.DEVICE_ID, } self.assertDictEqual(auth.data, expected_data) @@ -128,7 +126,11 @@ def test_response_bad_json(self): def test_header(self): """Test header data.""" self.auth.token = "bar" - expected_header = {"TOKEN_AUTH": "bar", "user-agent": const.DEFAULT_USER_AGENT} + expected_header = { + "TOKEN_AUTH": "bar", + "user-agent": const.DEFAULT_USER_AGENT, + "content-type": "application/json", + } self.assertDictEqual(self.auth.header, expected_header) def test_header_no_token(self): @@ -161,10 +163,8 @@ def test_login_bad_response(self, mock_req, mock_validate): def test_refresh_token(self, mock_login): """Test refresh token method.""" mock_login.return_value = { - "region": {"tier": "test"}, - "authtoken": {"authtoken": "foobar"}, - "client": {"id": 1234}, - "account": {"id": 5678}, + "account": {"account_id": 5678, "client_id": 1234, "tier": "test"}, + "auth": {"token": "foobar"}, } self.assertTrue(self.auth.refresh_token()) self.assertEqual(self.auth.region_id, "test") @@ -186,12 +186,19 @@ def test_check_key_required(self): self.auth.login_response = {} self.assertFalse(self.auth.check_key_required()) - self.auth.login_response = {"client": {"verification_required": False}} + self.auth.login_response = {"account": {"client_verification_required": False}} self.assertFalse(self.auth.check_key_required()) - self.auth.login_response = {"client": {"verification_required": True}} + self.auth.login_response = {"account": {"client_verification_required": True}} self.assertTrue(self.auth.check_key_required()) + @mock.patch("blinkpy.auth.api.request_logout") + 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)) + @mock.patch("blinkpy.auth.api.request_verify") def test_send_auth_key(self, mock_req): """Check sending of auth key.""" diff --git a/tests/test_util.py b/tests/test_util.py index ee157cd6..ed96acb4 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -3,7 +3,7 @@ import unittest from unittest import mock import time -from blinkpy.helpers.util import json_load, Throttle, time_to_seconds +from blinkpy.helpers.util import json_load, Throttle, time_to_seconds, gen_uid class TestUtil(unittest.TestCase): @@ -132,3 +132,19 @@ def test_json_load_bad_data(self): self.assertEqual(json_load("fake.file"), None) with mock.patch("builtins.open", mock.mock_open(read_data="")): self.assertEqual(json_load("fake.file"), None) + + def test_gen_uid(self): + """Test gen_uid formatting.""" + val1 = gen_uid(8) + val2 = gen_uid(8, uid_format=True) + + self.assertEqual(len(val1), 16) + + self.assertTrue(val2.startswith("BlinkCamera_")) + val2_cut = val2.split("_") + val2_split = val2_cut[1].split("-") + self.assertEqual(len(val2_split[0]), 8) + self.assertEqual(len(val2_split[1]), 4) + self.assertEqual(len(val2_split[2]), 4) + self.assertEqual(len(val2_split[3]), 4) + self.assertEqual(len(val2_split[4]), 12) diff --git a/tox.ini b/tox.ini index e82717a9..dcc71120 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = build, py36, py37, py38, lint +envlist = build, py36, py37, py38, py39, lint skip_missing_interpreters = True skipsdist = True