Repository: fronzbot/blinkpy

Issue:
Blink Video Doorbell Support
**Describe the bug**
Related to https://github.com/home-assistant/core/issues/58662

Blink recently introduced video doorbells.  After installation of hardware and activation on my existing Blink local system, the Video Doorbell does not appear in Home Assistant.  No errors were logged by the home assistant integration.

**To Reproduce**
Steps to reproduce the behavior:

1. Install video doorbell hardware.
2. Follow instruction in Blink Home Monitor to add to existing setup.
3. Once added, reload and re-install Blink Integration in Home Assistant.
4. Other Blink cameras appear normally (including Mini) but Video doorbell is not recognized at all.

**Expected behavior**
Would expect the video doorbell to appear with functionality similar to other Blink cameras.

**Home Assistant version (if applicable):** 2021.10.6

**Log Output/Additional Information**
If using home-assistant, please paste the output of the log showing your error below.  If not, please include any additional useful information.

```
Integration logged no errors.
```

Discussion:

Patch:
diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py
index eda5eb3..c316875 100644
--- a/blinkpy/blinkpy.py
+++ b/blinkpy/blinkpy.py
@@ -23,7 +23,7 @@ from dateutil.parser import parse
 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 @@ class Blink:
         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 @@ class Blink:
                         {"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 6c75296..29ceec8 100644
--- a/blinkpy/camera.py
+++ b/blinkpy/camera.py
@@ -273,3 +273,42 @@ class BlinkCameraMini(BlinkCamera):
         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/sync_module.py b/blinkpy/sync_module.py
index 4e31756..2730de5 100644
--- a/blinkpy/sync_module.py
+++ b/blinkpy/sync_module.py
@@ -4,7 +4,7 @@ import logging
 
 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 @@ class BlinkSyncModule:
                 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 @@ class BlinkSyncModule:
             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 @@ class BlinkSyncModule:
         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 @@ class BlinkSyncModule:
         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 @@ class BlinkOwl(BlinkSyncModule):
     @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."""


Test patch:
diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py
index c632215..e884179 100644
--- a/tests/test_blinkpy.py
+++ b/tests/test_blinkpy.py
@@ -9,7 +9,7 @@ any communication related errors at startup.
 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 @@ class TestBlinkSetup(unittest.TestCase):
     @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 @@ class TestBlinkSetup(unittest.TestCase):
             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 8cf7d76..e482a72 100644
--- a/tests/test_cameras.py
+++ b/tests/test_cameras.py
@@ -11,7 +11,7 @@ from unittest import mock
 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 @@ class TestBlinkCameraSetup(unittest.TestCase):
         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 fc18a51..67d5206 100644
--- a/tests/test_sync_module.py
+++ b/tests/test_sync_module.py
@@ -4,8 +4,8 @@ from unittest import mock
 
 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 @@ class TestBlinkSyncModule(unittest.TestCase):
         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)