Repository: fronzbot/blinkpy

Issue:
New 2FA email every 24hrs
Been working with RC2, with some adjustments I've been able to stabilize it a bit but I definitely can't run a script more than once every 2-3 minutes as it surely barfs on me within that windows. Does anyone ever hit the blink servers that often and have issues?

Also I notice that it always sends me a new key each morning, I think it's about every 24 hours now. Still makes me think I'm using this wrong (I'm using the cred file mechanism per the other discussions), I generate a cred file initially by forcing it to send me a key then all subsequent requests I reference the cred file specifically before blink.start.

Any ideas how to prevent this or troubleshoot it?

Also any other ideas on how to better be able to run scripts more often than once every few minutes (I do this when opening/closing doors - worst case I suppose I can add a delay in my motion/door events but would rather avoid that).

Thanks!

Discussion:

Patch:
diff --git a/.github/stale.yml b/.github/stale.yml
index 45dca08..dbe5715 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 00b8429..b19ea60 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 bd392ad..473af5d 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/.gitignore b/.gitignore
index 5eabca9..ebe0e92 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 1db3211..0000000
--- 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 d8171ab..a9924a3 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 <https://github.com/fronzbot/blinkpy/pull/424>`__)
+- Fix repeated authorization email bug (`#432 <https://github.com/fronzbot/blinkpy/pull/432>`__ and `#428 <https://github.com/fronzbot/blinkpy/pull/428>`__)
+
+**New Features:**
+- Add logout method (`#429 <https://github.com/fronzbot/blinkpy/pull/429>`__)
+- Add camera record method (`#430 <https://github.com/fronzbot/blinkpy/pull/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 d72ca0d..3df449a 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 bc61387..3e78c13 100644
--- a/blinkpy/auth.py
+++ b/blinkpy/auth.py
@@ -58,7 +58,11 @@ class Auth:
         """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 @@ class Auth:
             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 @@ class Auth:
             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 @@ class Auth:
             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 @@ class Auth:
             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 @@ class Auth:
     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 186b855..6c75296 100644
--- a/blinkpy/camera.py
+++ b/blinkpy/camera.py
@@ -98,6 +98,10 @@ class BlinkCamera:
             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 1862d3d..164a44a 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 @@ PROJECT_CLASSIFIERS = [
     "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 @@ URLS
 BLINK_URL = "immedia-semi.com"
 DEFAULT_URL = f"rest-prod.{BLINK_URL}"
 BASE_URL = f"https://{DEFAULT_URL}"
-LOGIN_ENDPOINT = f"{BASE_URL}/api/v4/account/login"
+LOGIN_ENDPOINT = f"{BASE_URL}/api/v5/account/login"
 
 """
 Dictionaries
diff --git a/blinkpy/helpers/util.py b/blinkpy/helpers/util.py
index 3bc7380..81410ad 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 0000000..15d8955
--- /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/tox.ini b/tox.ini
index e82717a..dcc7112 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
 


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4369ed9..1e64154 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/requirements_test.txt b/requirements_test.txt
index 2cb1cc7..bea74d1 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 c5c082b..9c91897 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -42,7 +42,6 @@ class TestAuth(unittest.TestCase):
             "username": "foo",
             "password": "bar",
             "uid": 1234,
-            "notification_key": 1234,
             "device_id": const.DEVICE_ID,
         }
         self.assertDictEqual(auth.data, expected_data)
@@ -62,7 +61,6 @@ class TestAuth(unittest.TestCase):
             "username": "foo",
             "password": "bar",
             "uid": 1234,
-            "notification_key": 1234,
             "device_id": const.DEVICE_ID,
         }
         self.assertDictEqual(auth.data, expected_data)
@@ -128,7 +126,11 @@ class TestAuth(unittest.TestCase):
     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 @@ class TestAuth(unittest.TestCase):
     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 @@ class TestAuth(unittest.TestCase):
         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 ee157cd..ed96acb 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 @@ class TestUtil(unittest.TestCase):
         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)