diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..9b45cd3a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: build + +on: + push: + branches: [ master, dev ] + pull_request: + branches: [ master, dev ] + +jobs: + build: + runs-on: ${{ matrix.platform }} + strategy: + matrix: + platform: + - ubuntu-latest + python-version: ['3.11'] + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements_test.txt + pip install tox + - name: Build Wheel + run: | + tox -r -e build diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 9e9a34dd..e42f49f8 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -8,18 +8,15 @@ on: jobs: coverage: - runs-on: ${{ matrix.platform }} + runs-on: ubuntu-latest strategy: - max-parallel: 1 matrix: - platform: - - ubuntu-latest python-version: ['3.11'] - steps: - - uses: actions/checkout@v4 + - name: Check out code from GitHub + uses: actions/checkout@v4.1.6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -28,16 +25,34 @@ jobs: pip install -r requirements.txt pip install -r requirements_test.txt pip install tox - pip install codecov - - name: Test + - name: Run Coverage run: | tox -r -e cov - - name: Codecov - uses: codecov/codecov-action@v4 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Upload coverage + uses: actions/upload-artifact@v4.3.3 with: - flags: unittests - file: ./coverage.xml - name: blinkpy - fail_ci_if_error: true + name: coverage-${{ matrix.python-version }} + path: coverage.xml + overwrite: true + upload-coverage: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.11'] + needs: + - coverage + timeout-minutes: 10 + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.1.6 + - name: Download all coverage artifacts + uses: actions/download-artifact@v4.1.7 + with: + name: coverage-${{ matrix.python-version }} + path: coverage.xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4.4.1 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + name: blinkpy diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index da2d3818..79ca3f8c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,13 +8,13 @@ on: branches: [ master, dev ] pull_request: branches: [ master, dev ] - jobs: lint: runs-on: ubuntu-latest strategy: + max-parallel: 2 matrix: - python-version: [3.11] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 @@ -27,7 +27,12 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements_test.txt - pip install tox - - name: Lint + - name: Ruff + run: | + ruff check blinkpy tests blinkapp + - name: Black + run: | + black --check --color --diff blinkpy tests blinkapp + - name: RST-Lint run: | - tox -r -e lint + rst-lint README.rst CHANGES.rst CONTRIBUTING.rst diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d424c132..fad0f0ce 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,4 @@ -name: build +name: tests on: push: @@ -7,7 +7,7 @@ on: branches: [ master, dev ] jobs: - build: + pytest: runs-on: ${{ matrix.platform }} strategy: max-parallel: 4 @@ -15,9 +15,9 @@ jobs: platform: - ubuntu-latest python-version: ['3.9', '3.10', '3.11', '3.12'] - steps: - - uses: actions/checkout@v3 + - name: Check out code from GitHub + uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -27,7 +27,11 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements_test.txt - pip install tox + pip install . - name: Tests run: | - tox -r + python -m pytest \ + --timeout=30 \ + --durations=10 \ + --cov=blinkpy \ + --cov-report term-missing diff --git a/CHANGES.rst b/CHANGES.rst index 35dc28cc..2acb121b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,12 @@ Changelog A list of changes between each release +0.23.0 (2024-06-19) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +See release notes: (`0.23.0 `__) + + 0.22.7 (2024-04-15) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/blinkpy/api.py b/blinkpy/api.py index 942d29d8..95dc280b 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -62,8 +62,9 @@ async def request_login( 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}" - f"/client/{blink.client_id}/pin/verify" + f"{blink.urls.base_url}/api/v5/accounts/{blink.account_id}" + f"/users/{blink.auth.user_id}" + f"/clients/{blink.client_id}/client_verification/pin/verify" ) data = dumps({"pin": verify_key}) return await auth.query( @@ -165,6 +166,38 @@ async def request_system_disarm(blink, network, **kwargs): return response +async def request_notification_flags(blink, **kwargs): + """ + Get system notification flags. + + :param blink: Blink instance. + """ + url = ( + f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" + "/notifications/configuration" + ) + response = await http_get(blink, url) + await wait_for_command(blink, response) + return response + + +async def request_set_notification_flag(blink, data_dict): + """ + Set a system notification flag. + + :param blink: Blink instance. + :param data_dict: Dictionary of notifications to set. + """ + url = ( + f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" + "/notifications/configuration" + ) + data = dumps({"notifications": data_dict}) + response = await http_post(blink, url, data=data, json=False) + await wait_for_command(blink, response) + return response + + async def request_command_status(blink, network, command_id): """ Request command status. diff --git a/blinkpy/auth.py b/blinkpy/auth.py index 78db9d9a..59967f63 100644 --- a/blinkpy/auth.py +++ b/blinkpy/auth.py @@ -49,6 +49,7 @@ def __init__( self.region_id = login_data.get("region_id", None) self.client_id = login_data.get("client_id", None) self.account_id = login_data.get("account_id", None) + self.user_id = login_data.get("user_id", None) self.login_response = None self.is_errored = False self.no_prompt = no_prompt @@ -64,6 +65,7 @@ def login_attributes(self): self.data["region_id"] = self.region_id self.data["client_id"] = self.client_id self.data["account_id"] = self.account_id + self.data["user_id"] = self.user_id return self.data @property @@ -130,6 +132,7 @@ def extract_login_info(self): 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"] + self.user_id = self.login_response["account"].get("user_id", None) async def startup(self): """Initialize tokens for communication.""" diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index 027e2fff..80ea937b 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -17,6 +17,7 @@ import logging import datetime import aiofiles +import aiofiles.ospath from requests.structures import CaseInsensitiveDict from dateutil.parser import parse from slugify import slugify @@ -143,6 +144,8 @@ async def setup_prompt_2fa(self): async def setup_post_verify(self): """Initialize blink system after verification.""" try: + if not self.homescreen: + await self.get_homescreen() await self.setup_networks() networks = self.setup_network_ids() cameras = await self.setup_camera_list() @@ -191,7 +194,7 @@ async def setup_owls(self): network_list.append(str(network_id)) self.sync[name] = BlinkOwl(self, name, network_id, owl) await self.sync[name].start() - except KeyError: + except (KeyError, TypeError): # No sync-less devices found pass @@ -221,7 +224,7 @@ async def setup_lotus(self): network_list.append(str(network_id)) self.sync[name] = BlinkLotus(self, name, network_id, lotus) await self.sync[name].start() - except KeyError: + except (KeyError, TypeError): # No sync-less devices found pass @@ -317,6 +320,21 @@ async def save(self, file_name): """Save login data to file.""" await util.json_save(self.auth.login_attributes, file_name) + async def get_status(self): + """Get the blink system notification status.""" + response = await api.request_notification_flags(self) + return response.get("notifications", response) + + async def set_status(self, data_dict={}): + """ + Set the blink system notification status. + + :param data_dict: Dictionary of notification keys to modify. + Example: {'low_battery': False, 'motion': False} + """ + response = await api.request_set_notification_flag(self, data_dict) + return response + async def download_videos( self, path, since=None, camera="all", stop=10, delay=1, debug=False ): diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 39a134fb..b3c4a4b7 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -516,6 +516,17 @@ async def async_arm(self, value): await api.wait_for_command(self.sync.blink, response) return response + async def record(self): + """Initiate clip recording for a blink mini camera.""" + url = ( + f"{self.sync.urls.base_url}/api/v1/accounts/" + f"{self.sync.blink.account_id}/networks/" + f"{self.network_id}/owls/{self.camera_id}/clip" + ) + response = await api.http_post(self.sync.blink, url) + await api.wait_for_command(self.sync.blink, response) + return response + async def snap_picture(self): """Snap picture for a blink mini camera.""" url = ( @@ -575,6 +586,18 @@ async def async_arm(self, value): await api.wait_for_command(self.sync.blink, response) return response + async def record(self): + """Initiate clip recording for a blink doorbell camera.""" + url = ( + f"{self.sync.urls.base_url}/api/v1/accounts/" + f"{self.sync.blink.account_id}/networks/" + f"{self.sync.network_id}/doorbells/{self.camera_id}/clip" + ) + + response = await api.http_post(self.sync.blink, url) + await api.wait_for_command(self.sync.blink, response) + return response + async def snap_picture(self): """Snap picture for a blink doorbell camera.""" url = ( diff --git a/pyproject.toml b/pyproject.toml index 48f6f5d3..57633569 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "blinkpy" -version = "0.22.7" +version = "0.23.0" license = {text = "MIT"} description = "A Blink camera Python Library." readme = "README.rst" diff --git a/requirements_test.txt b/requirements_test.txt index 29116a5a..70e43dd5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,13 +1,13 @@ -ruff==0.3.7 -black==24.3.0 +ruff==0.4.9 +black==24.4.2 build==1.2.1 -coverage==7.4.4 -pytest==8.1.1 +coverage==7.5.3 +pytest==8.2.2 pytest-cov==5.0.0 pytest-sugar==1.0.0 pytest-timeout==2.3.1 restructuredtext-lint==1.4.0 -pygments==2.17.2 +pygments==2.18.0 testtools>=2.4.0 sortedcontainers~=2.4.0 pytest-asyncio>=0.21.0 diff --git a/tests/test_api.py b/tests/test_api.py index 2b3a850d..89581dbc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -106,6 +106,23 @@ async def test_request_camera_usage(self, mock_resp): await api.request_camera_usage(self.blink), {"cameras": "1111"} ) + async def test_request_notification_flags(self, mock_resp): + """Test notification flag request.""" + mock_resp.return_value = {"notifications": {"some_key": False}} + self.assertEqual( + await api.request_notification_flags(self.blink), + {"notifications": {"some_key": False}}, + ) + + async def test_request_set_notification_flag(self, mock_resp): + """Test set of notifiaction flags.""" + mock_resp.side_effect = ( + mresp.MockResponse(COMMAND_RESPONSE, 200), + COMMAND_COMPLETE, + ) + response = await api.request_set_notification_flag(self.blink, {}) + self.assertEqual(response.status, 200) + async def test_request_motion_detection_enable(self, mock_resp): """Test Motion detect enable.""" mock_resp.side_effect = ( diff --git a/tests/test_auth.py b/tests/test_auth.py index 0d6168e9..b0f5122f 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -167,6 +167,7 @@ async def test_refresh_token(self, mock_resp): self.assertEqual(self.auth.token, "foobar") self.assertEqual(self.auth.client_id, 1234) self.assertEqual(self.auth.account_id, 5678) + self.assertEqual(self.auth.user_id, None) mock_resp.return_value.status = 400 with self.assertRaises(TokenRefreshFailed): diff --git a/tests/test_blink_functions.py b/tests/test_blink_functions.py index eedebfd3..35477a71 100644 --- a/tests/test_blink_functions.py +++ b/tests/test_blink_functions.py @@ -278,3 +278,21 @@ async def test_refresh(self, mock_req, mock_update): self.blink.cameras = {"bar": MockCamera(self.blink.sync)} self.blink.sync["foo"].cameras = self.blink.cameras self.assertTrue(await self.blink.refresh()) + + @mock.patch("blinkpy.blinkpy.api.request_notification_flags") + async def test_get_status(self, mock_req): + """Test get of notification flags.""" + mock_req.return_value = {"notifications": {"foo": True}} + self.assertDictEqual(await self.blink.get_status(), {"foo": True}) + + @mock.patch("blinkpy.blinkpy.api.request_notification_flags") + async def test_get_status_malformed(self, mock_req): + """Test get of notification flags with malformed response.""" + mock_req.return_value = {"nobueno": {"foo": False}} + self.assertDictEqual(await self.blink.get_status(), {"nobueno": {"foo": False}}) + + @mock.patch("blinkpy.blinkpy.api.request_set_notification_flag") + async def test_set_status(self, mock_req): + """Test set of notification flags.""" + mock_req.return_value = True + self.assertTrue(await self.blink.set_status()) diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index 9c01eb04..4caa81f1 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -192,12 +192,13 @@ async def test_setup_prompt_2fa(self, mock_key): self.assertTrue(self.blink.key_required) @mock.patch("blinkpy.blinkpy.Blink.setup_camera_list") + @mock.patch("blinkpy.api.request_homescreen") @mock.patch("blinkpy.api.request_networks") @mock.patch("blinkpy.blinkpy.Blink.setup_owls") @mock.patch("blinkpy.blinkpy.Blink.setup_lotus") @mock.patch("blinkpy.blinkpy.BlinkSyncModule.start") async def test_setup_post_verify( - self, mock_sync, mock_lotus, mock_owl, mock_networks, mock_camera + self, mock_sync, mock_lotus, mock_owl, mock_networks, mock_home, mock_camera ): """Test setup after verification.""" self.blink.available = False @@ -214,16 +215,19 @@ async def test_setup_post_verify( mock_networks.return_value = { "summary": {"foo": {"onboarded": True, "name": "bar"}} } + mock_home.return_value = {} mock_camera.return_value = [] self.assertTrue(await self.blink.setup_post_verify()) self.assertTrue(self.blink.available) self.assertFalse(self.blink.key_required) + @mock.patch("blinkpy.api.request_homescreen") @mock.patch("blinkpy.api.request_networks") - async def test_setup_post_verify_failure(self, mock_networks): + async def test_setup_post_verify_failure(self, mock_networks, mock_home): """Test failed setup after verification.""" self.blink.available = False mock_networks.return_value = {} + mock_home.return_value = {} self.assertFalse(await self.blink.setup_post_verify()) self.assertFalse(self.blink.available)