diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..aff9cf9c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + reviewers: + - fronzbot diff --git a/CHANGES.rst b/CHANGES.rst index 37756920..3678aa38 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,29 @@ Changelog A list of changes between each release +0.18.0 (2021-12-11) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Bugfixes:** + +- None + +**New Features:** + +- Support for Blink Doorbell (`@magicalyak `__) + +**Other:** + +- Bump pytest-cov to 3.0.0 +- Bump pre-commit to 2.15.0 +- Bump pytest to 6.2.5 +- Bump pylint to 2.10.2 +- Bump pygments to 2.10.0 +- Bump flake8-docstrings to 1.6.0 +- Bump pydocstyle to 6.0.0 +- Bump coverage to 5.5 + + 0.17.1 (2021-02-18) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -15,14 +38,17 @@ A list of changes between each release ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **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 diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py index eda5eb3d..c3168751 100644 --- a/blinkpy/blinkpy.py +++ b/blinkpy/blinkpy.py @@ -23,7 +23,7 @@ from slugify import slugify from blinkpy import api -from blinkpy.sync_module import BlinkSyncModule, BlinkOwl +from blinkpy.sync_module import BlinkSyncModule, BlinkOwl, BlinkLotus from blinkpy.helpers import util from blinkpy.helpers.constants import ( DEFAULT_MOTION_INTERVAL, @@ -180,6 +180,36 @@ def setup_owls(self): self.network_ids.extend(network_list) return camera_list + def setup_lotus(self): + """Check for doorbells cameras.""" + network_list = [] + camera_list = [] + try: + for lotus in self.homescreen["doorbells"]: + name = lotus["name"] + network_id = str(lotus["network_id"]) + if network_id in self.network_ids: + camera_list.append( + { + network_id: { + "name": name, + "id": network_id, + "type": "doorbell", + } + } + ) + continue + if lotus["onboarded"]: + network_list.append(str(network_id)) + self.sync[name] = BlinkLotus(self, name, network_id, lotus) + self.sync[name].start() + except KeyError: + # No sync-less devices found + pass + + self.network_ids.extend(network_list) + return camera_list + def setup_camera_list(self): """Create camera list for onboarded networks.""" all_cameras = {} @@ -194,9 +224,13 @@ def setup_camera_list(self): {"name": camera["name"], "id": camera["id"]} ) mini_cameras = self.setup_owls() + lotus_cameras = self.setup_lotus() for camera in mini_cameras: for network, camera_info in camera.items(): all_cameras[network].append(camera_info) + for camera in lotus_cameras: + for network, camera_info in camera.items(): + all_cameras[network].append(camera_info) return all_cameras except (KeyError, TypeError): _LOGGER.error("Unable to retrieve cameras from response %s", response) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 6c752962..29ceec8e 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -273,3 +273,42 @@ def get_liveview(self): server_split[0] = "rtsps:" link = "".join(server_split) return link + + +class BlinkDoorbell(BlinkCamera): + """Define a class for a Blink Doorbell camera.""" + + def __init__(self, sync): + """Initialize a Blink Doorbell.""" + super().__init__(sync) + self.camera_type = "doorbell" + + @property + def arm(self): + """Return camera arm status.""" + return self.sync.arm + + @arm.setter + def arm(self, value): + """Set camera arm status.""" + _LOGGER.warning( + "Individual camera motion detection enable/disable for Blink Doorbell is unsupported at this time." + ) + + 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.network_id}/lotus/{self.camera_id}/thumbnail" + return api.http_post(self.sync.blink, url) + + def get_sensor_info(self): + """Get sensor info for blink doorbell camera.""" + + 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}/lotus/{self.camera_id}/liveview" + response = api.http_post(self.sync.blink, url) + server = response["server"] + server_split = server.split(":") + server_split[0] = "rtsps:" + link = "".join(server_split) + return link diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py index 167aec6e..7bb48508 100644 --- a/blinkpy/helpers/constants.py +++ b/blinkpy/helpers/constants.py @@ -3,8 +3,8 @@ import os MAJOR_VERSION = 0 -MINOR_VERSION = 17 -PATCH_VERSION = 1 +MINOR_VERSION = 18 +PATCH_VERSION = 0 __version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 4e317569..2730de5a 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -4,7 +4,7 @@ from requests.structures import CaseInsensitiveDict from blinkpy import api -from blinkpy.camera import BlinkCamera, BlinkCameraMini +from blinkpy.camera import BlinkCamera, BlinkCameraMini, BlinkDoorbell from blinkpy.helpers.util import time_to_seconds from blinkpy.helpers.constants import ONLINE @@ -126,11 +126,14 @@ def update_cameras(self, camera_type=BlinkCamera): name = camera_config["name"] self.motion[name] = False owl_info = self.get_owl_info(name) + lotus_info = self.get_lotus_info(name) if blink_camera_type == "mini": camera_type = BlinkCameraMini + if blink_camera_type == "lotus": + camera_type = BlinkDoorbell self.cameras[name] = camera_type(self) camera_info = self.get_camera_info( - camera_config["id"], owl_info=owl_info + camera_config["id"], owl_info=owl_info, lotus_info=lotus_info ) self.cameras[name].update(camera_info, force_cache=True, force=True) @@ -149,6 +152,16 @@ def get_owl_info(self, name): pass return None + def get_lotus_info(self, name): + """Extract lotus information.""" + try: + for doorbell in self.blink.homescreen["doorbells"]: + if doorbell["name"] == name: + return doorbell + except (TypeError, KeyError): + pass + return None + def get_events(self, **kwargs): """Retrieve events from server.""" force = kwargs.pop("force", False) @@ -164,6 +177,9 @@ def get_camera_info(self, camera_id, **kwargs): owl = kwargs.get("owl_info", None) if owl is not None: return owl + lotus = kwargs.get("lotus_info", None) + if lotus is not None: + return lotus response = api.request_camera_info(self.blink, self.network_id, camera_id) try: return response["camera"][0] @@ -190,7 +206,9 @@ def refresh(self, force_cache=False): for camera_name in self.cameras.keys(): camera_id = self.cameras[camera_name].camera_id camera_info = self.get_camera_info( - camera_id, owl_info=self.get_owl_info(camera_name) + camera_id, + owl_info=self.get_owl_info(camera_name), + lotus_info=self.get_lotus_info(camera_name), ) self.cameras[camera_name].update(camera_info, force_cache=force_cache) self.available = True @@ -294,3 +312,66 @@ def network_info(self): @network_info.setter def network_info(self, value): """Set network_info property.""" + + +class BlinkLotus(BlinkSyncModule): + """Representation of a sync-less device.""" + + def __init__(self, blink, name, network_id, response): + """Initialize a sync-less object.""" + cameras = [{"name": name, "id": response["id"]}] + super().__init__(blink, name, network_id, cameras) + self.sync_id = response["id"] + self.serial = response["serial"] + self.status = response["enabled"] + if not self.serial: + self.serial = f"{network_id}-{self.sync_id}" + + def sync_initialize(self): + """Initialize a sync-less module.""" + self.summary = { + "id": self.sync_id, + "name": self.name, + "serial": self.serial, + "status": self.status, + "onboarded": True, + "account_id": self.blink.account_id, + "network_id": self.network_id, + } + return self.summary + + def update_cameras(self, camera_type=BlinkDoorbell): + """Update sync-less cameras.""" + return super().update_cameras(camera_type=BlinkDoorbell) + + def get_camera_info(self, camera_id, **kwargs): + """Retrieve camera information.""" + try: + for doorbell in self.blink.homescreen["doorbells"]: + if doorbell["name"] == self.name: + self.status = doorbell["enabled"] + return doorbell + except (TypeError, KeyError): + pass + return None + + def get_network_info(self): + """Get network info for sync-less module.""" + return True + + @property + def network_info(self): + """Format lotus response to resemble sync module.""" + return { + "network": { + "id": self.network_id, + "name": self.name, + "armed": self.status, + "sync_module_error": False, + "account_id": self.blink.account_id, + } + } + + @network_info.setter + def network_info(self, value): + """Set network_info property.""" diff --git a/requirements_test.txt b/requirements_test.txt index 922e2753..c10bab75 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,14 +1,14 @@ black==19.10b0 -coverage==5.4 -flake8==3.8.4 -flake8-docstrings==1.5.0 -pre-commit==2.10.1 -pylint==2.6.2 -pydocstyle==5.1.1 -pytest==6.2.2 -pytest-cov==2.11.1 +coverage==5.5 +flake8==3.9.1 +flake8-docstrings==1.6.0 +pre-commit==2.15.0 +pylint==2.10.2 +pydocstyle==6.0.0 +pytest==6.2.5 +pytest-cov==3.0.0 pytest-sugar==0.9.4 pytest-timeout==1.4.2 restructuredtext-lint==1.3.2 -pygments==2.8.0 +pygments==2.10.0 testtools>=2.4.0 diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py index c632215d..e8841798 100644 --- a/tests/test_blinkpy.py +++ b/tests/test_blinkpy.py @@ -9,7 +9,7 @@ import unittest from unittest import mock from blinkpy.blinkpy import Blink, BlinkSetupError -from blinkpy.sync_module import BlinkOwl +from blinkpy.sync_module import BlinkOwl, BlinkLotus from blinkpy.helpers.constants import __version__ @@ -164,10 +164,12 @@ def test_setup_prompt_2fa(self, mock_key): @mock.patch("blinkpy.blinkpy.Blink.setup_camera_list") @mock.patch("blinkpy.api.request_networks") @mock.patch("blinkpy.blinkpy.Blink.setup_owls") - def test_setup_post_verify(self, mock_owl, mock_networks, mock_camera): + @mock.patch("blinkpy.blinkpy.Blink.setup_lotus") + def test_setup_post_verify(self, 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_networks.return_value = { "summary": {"foo": {"onboarded": False, "name": "bar"}} @@ -288,6 +290,96 @@ def test_blink_mini_attached_to_sync(self, mock_usage): result, {"1234": [{"name": "foo", "id": "1234", "type": "mini"}]} ) + @mock.patch("blinkpy.blinkpy.BlinkLotus.start") + def test_initialize_blink_doorbells(self, mock_start): + """Test blink doorbell initialization.""" + mock_start.return_value = True + self.blink.homescreen = { + "doorbells": [ + { + "enabled": False, + "id": 1, + "name": "foo", + "network_id": 2, + "onboarded": True, + "status": "online", + "thumbnail": "/foo/bar", + "serial": "1234", + }, + { + "enabled": True, + "id": 3, + "name": "bar", + "network_id": 4, + "onboarded": True, + "status": "online", + "thumbnail": "/foo/bar", + "serial": "abcd", + }, + ] + } + self.blink.sync = {} + 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) + self.assertEqual(self.blink.sync["bar"].arm, True) + self.assertEqual(self.blink.sync["foo"].name, "foo") + self.assertEqual(self.blink.sync["bar"].name, "bar") + + # def test_blink_doorbell_cameras_returned(self): + # """Test that blink doorbell cameras are found if attached to sync module.""" + # self.blink.network_ids = ["1234"] + # self.blink.homescreen = { + # "doorbells": [ + # { + # "id": 1, + # "name": "foo", + # "network_id": 1234, + # "onboarded": True, + # "enabled": True, + # "status": "online", + # "thumbnail": "/foo/bar", + # "serial": "abc123", + # } + # ] + # } + # result = self.blink.setup_lotus() + # self.assertEqual(self.blink.network_ids, ["1234"]) + # self.assertEqual( + # result, [{"1234": {"name": "foo", "id": "1234", "type": "doorbell"}}] + # ) + + # self.blink.network_ids = [] + # self.blink.get_homescreen() + # result = self.blink.setup_lotus() + # self.assertEqual(self.blink.network_ids, []) + # self.assertEqual(result, []) + + @mock.patch("blinkpy.api.request_camera_usage") + 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 = { + "doorbells": [ + { + "id": 1, + "name": "foo", + "network_id": 1234, + "onboarded": True, + "enabled": True, + "status": "online", + "thumbnail": "/foo/bar", + "serial": "abc123", + } + ] + } + mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]} + result = self.blink.setup_camera_list() + self.assertEqual( + result, {"1234": [{"name": "foo", "id": "1234", "type": "doorbell"}]} + ) + class MockSync: """Mock sync module class.""" diff --git a/tests/test_cameras.py b/tests/test_cameras.py index 8cf7d76f..e482a72a 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -11,7 +11,7 @@ from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler from blinkpy.sync_module import BlinkSyncModule -from blinkpy.camera import BlinkCamera, BlinkCameraMini +from blinkpy.camera import BlinkCamera, BlinkCameraMini, BlinkDoorbell CAMERA_CFG = { @@ -177,9 +177,20 @@ def test_mini_missing_attributes(self, mock_resp): for key in attr: self.assertEqual(attr[key], None) + def test_doorbell_missing_attributes(self, mock_resp): + """Test that attributes return None if missing.""" + camera = BlinkDoorbell(self.blink.sync) + self.blink.sync.network_id = None + self.blink.sync.name = None + attr = camera.attributes + for key in attr: + self.assertEqual(attr[key], None) + 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") diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py index fc18a51e..67d52061 100644 --- a/tests/test_sync_module.py +++ b/tests/test_sync_module.py @@ -4,8 +4,8 @@ from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler -from blinkpy.sync_module import BlinkSyncModule, BlinkOwl -from blinkpy.camera import BlinkCamera, BlinkCameraMini +from blinkpy.sync_module import BlinkSyncModule, BlinkOwl, BlinkLotus +from blinkpy.camera import BlinkCamera, BlinkCameraMini, BlinkDoorbell @mock.patch("blinkpy.auth.Auth.query") @@ -292,3 +292,20 @@ def test_owl_start(self, mock_resp): self.assertTrue(owl.start()) self.assertTrue("foo" in owl.cameras) self.assertEqual(owl.cameras["foo"].__class__, BlinkCameraMini) + + def test_lotus_start(self, mock_resp): + """Test doorbell instantiation.""" + response = { + "name": "doo", + "id": 3, + "serial": "doobar123", + "enabled": True, + "network_id": 1, + "thumbnail": "/foo/bar", + } + self.blink.last_refresh = None + self.blink.homescreen = {"doorbells": [response]} + lotus = BlinkLotus(self.blink, "doo", 1234, response) + self.assertTrue(lotus.start()) + self.assertTrue("doo" in lotus.cameras) + self.assertEqual(lotus.cameras["doo"].__class__, BlinkDoorbell)