Repository: fronzbot/blinkpy

Issue:
Support for Mini
**Is your feature request related to a problem? Please describe.**
I tried the Home Assistant integration, but don't get any devices, so I don't think the mini is supported yet.

**Describe the solution you'd like**
Support for the Blink mini.

**Additional context**
https://www.amazon.com/stores/page/C5DECBBE-4F56-4C36-B933-E62144578691?channel=productmini

Discussion:

Patch:
diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py
index 9f98f14..6b904f5 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
+from blinkpy.sync_module import BlinkSyncModule, BlinkOwl
 from blinkpy.helpers import util
 from blinkpy.helpers.constants import (
     DEFAULT_MOTION_INTERVAL,
@@ -33,7 +33,6 @@ from blinkpy.helpers.constants import (
 from blinkpy.helpers.constants import __version__
 from blinkpy.auth import Auth, TokenRefreshFailed, LoginError
 
-
 _LOGGER = logging.getLogger(__name__)
 
 
@@ -68,6 +67,7 @@ class Blink:
         self.version = __version__
         self.available = False
         self.key_required = False
+        self.homescreen = {}
 
     @util.Throttle(seconds=MIN_THROTTLE_TIME)
     def refresh(self, force=False):
@@ -127,7 +127,9 @@ class Blink:
         for name, network_id in networks.items():
             sync_cameras = cameras.get(network_id, {})
             self.setup_sync_module(name, network_id, sync_cameras)
-            self.cameras = self.merge_cameras()
+
+        self.setup_owls()
+        self.cameras = self.merge_cameras()
 
         self.available = True
         self.key_required = False
@@ -138,6 +140,25 @@ class Blink:
         self.sync[name] = BlinkSyncModule(self, name, network_id, cameras)
         self.sync[name].start()
 
+    def setup_owls(self):
+        """Check for mini cameras."""
+        response = api.request_homescreen(self)
+        self.homescreen = response
+        network_list = []
+        try:
+            for owl in response["owls"]:
+                name = owl["name"]
+                network_id = owl["network_id"]
+                if owl["onboarded"]:
+                    network_list.append(str(network_id))
+                    self.sync[name] = BlinkOwl(self, name, network_id, owl)
+                    self.sync[name].start()
+        except KeyError:
+            # No sync-less devices found
+            pass
+
+        self.network_ids.extend(network_list)
+
     def setup_camera_list(self):
         """Create camera list for onboarded networks."""
         all_cameras = {}
diff --git a/blinkpy/camera.py b/blinkpy/camera.py
index 0443dd5..4372e1d 100644
--- a/blinkpy/camera.py
+++ b/blinkpy/camera.py
@@ -112,18 +112,24 @@ class BlinkCamera:
 
     def update(self, config, force_cache=False, **kwargs):
         """Update camera info."""
-        # force = kwargs.pop('force', False)
-        self.name = config["name"]
-        self.camera_id = str(config["id"])
-        self.network_id = str(config["network_id"])
-        self.serial = config["serial"]
-        self.motion_enabled = config["enabled"]
-        self.battery_voltage = config["battery_voltage"]
-        self.battery_state = config["battery_state"]
-        self.temperature = config["temperature"]
-        self.wifi_strength = config["wifi_strength"]
-
-        # Retrieve calibrated temperature from special endpoint
+        self.extract_config_info(config)
+        self.get_sensor_info()
+        self.update_images(config, force_cache=force_cache)
+
+    def extract_config_info(self, config):
+        """Extract info from config."""
+        self.name = config.get("name", "unknown")
+        self.camera_id = str(config.get("id", "unknown"))
+        self.network_id = str(config.get("network_id", "unknown"))
+        self.serial = config.get("serial", None)
+        self.motion_enabled = config.get("enabled", "unknown")
+        self.battery_voltage = config.get("battery_voltage", None)
+        self.battery_state = config.get("battery_state", None)
+        self.temperature = config.get("temperature", None)
+        self.wifi_strength = config.get("wifi_strength", None)
+
+    def get_sensor_info(self):
+        """Retrieve calibrated temperatue from special endpoint."""
         resp = api.request_camera_sensors(
             self.sync.blink, self.network_id, self.camera_id
         )
@@ -133,12 +139,11 @@ class BlinkCamera:
             self.temperature_calibrated = self.temperature
             _LOGGER.warning("Could not retrieve calibrated temperature.")
 
-        # Check if thumbnail exists in config, if not try to
-        # get it from the homescreen info in the sync module
-        # otherwise set it to None and log an error
+    def update_images(self, config, force_cache=False):
+        """Update images for camera."""
         new_thumbnail = None
         thumb_addr = None
-        if config["thumbnail"]:
+        if config.get("thumbnail", False):
             thumb_addr = config["thumbnail"]
         else:
             _LOGGER.warning(
@@ -154,10 +159,12 @@ class BlinkCamera:
             self.motion_detected = False
 
         clip_addr = None
-        if self.name in self.sync.last_record:
+        try:
             clip_addr = self.sync.last_record[self.name]["clip"]
             self.last_record = self.sync.last_record[self.name]["time"]
             self.clip = f"{self.sync.urls.base_url}{clip_addr}"
+        except KeyError:
+            pass
 
         # If the thumbnail or clip have changed, update the cache
         update_cached_image = False
@@ -202,7 +209,8 @@ class BlinkCamera:
             )
 
     def video_to_file(self, path):
-        """Write video to file.
+        """
+        Write video to file.
 
         :param path: Path to write file
         """
@@ -213,3 +221,35 @@ class BlinkCamera:
             return
         with open(path, "wb") as vidfile:
             copyfileobj(response.raw, vidfile)
+
+
+class BlinkCameraMini(BlinkCamera):
+    """Define a class for a Blink Mini camera."""
+
+    @property
+    def arm(self):
+        """Return camera arm status."""
+        return self.sync.arm
+
+    @arm.setter
+    def arm(self, value):
+        """Set camera arm status."""
+        self.sync.arm = value
+
+    def snap_picture(self):
+        """Snap picture for a blink mini camera."""
+        url = f"{self.sync.urls.base_url}/api/v1/accounts/{self.sync.blink.account_id}/networks/{self.network_id}/owls/{self.camera_id}/thumbnail"
+        return api.http_post(self.sync.blink, url)
+
+    def get_sensor_info(self):
+        """Get sensor info for blink mini 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}/owls/{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 f109212..77c1719 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
+from blinkpy.camera import BlinkCamera, BlinkCameraMini
 from blinkpy.helpers.util import time_to_seconds
 from blinkpy.helpers.constants import ONLINE
 
@@ -78,23 +78,12 @@ class BlinkSyncModule:
     @arm.setter
     def arm(self, value):
         """Arm or disarm system."""
-        if value:
-            return api.request_system_arm(self.blink, self.network_id)
-
-        return api.request_system_disarm(self.blink, self.network_id)
+        _LOGGER.warning("Arm/Disarm API for %s not currently implemented.", self.name)
 
     def start(self):
         """Initialize the system."""
-        response = api.request_syncmodule(self.blink, self.network_id)
-        try:
-            self.summary = response["syncmodule"]
-            self.network_id = self.summary["network_id"]
-        except (TypeError, KeyError):
-            _LOGGER.error(
-                ("Could not retrieve sync module information " "with response: %s"),
-                response,
-                exc_info=True,
-            )
+        response = self.sync_initialize()
+        if not response:
             return False
 
         try:
@@ -108,24 +97,39 @@ class BlinkSyncModule:
 
         is_ok = self.get_network_info()
         self.check_new_videos()
+
+        if not is_ok or not self.update_cameras():
+            return False
+        self.available = True
+        return True
+
+    def sync_initialize(self):
+        """Initialize a sync module."""
+        response = api.request_syncmodule(self.blink, self.network_id)
+        try:
+            self.summary = response["syncmodule"]
+            self.network_id = self.summary["network_id"]
+        except (TypeError, KeyError):
+            _LOGGER.error(
+                "Could not retrieve sync module information with response: %s", response
+            )
+            return False
+        return response
+
+    def update_cameras(self, camera_type=BlinkCamera):
+        """Update cameras from server."""
         try:
             for camera_config in self.camera_list:
                 if "name" not in camera_config:
                     break
                 name = camera_config["name"]
-                self.cameras[name] = BlinkCamera(self)
+                self.cameras[name] = camera_type(self)
                 self.motion[name] = False
                 camera_info = self.get_camera_info(camera_config["id"])
                 self.cameras[name].update(camera_info, force_cache=True, force=True)
         except KeyError:
-            _LOGGER.error(
-                "Could not create cameras instances for %s", self.name, exc_info=True
-            )
-            return False
-
-        if not is_ok:
+            _LOGGER.error("Could not create camera instances for %s", self.name)
             return False
-        self.available = True
         return True
 
     def get_events(self, **kwargs):
@@ -205,3 +209,86 @@ class BlinkSyncModule:
     def check_new_video_time(self, timestamp):
         """Check if video has timestamp since last refresh."""
         return time_to_seconds(timestamp) > self.blink.last_refresh
+
+
+class BlinkOwl(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=BlinkCameraMini):
+        """Update sync-less cameras."""
+        return super().update_cameras(camera_type=BlinkCameraMini)
+
+    def get_camera_info(self, camera_id):
+        """Retrieve camera information."""
+        try:
+            for owl in self.blink.homescreen["owls"]:
+                if owl["name"] == self.name:
+                    self.status = owl["enabled"]
+                    return owl
+        except KeyError:
+            pass
+        return None
+
+    def get_network_info(self):
+        """Get network info for sync-less module."""
+        return True
+
+    @property
+    def network_info(self):
+        """Format owl 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."""
+
+    @property
+    def arm(self):
+        """Return arm status."""
+        try:
+            return self.network_info["network"]["armed"]
+        except (KeyError, TypeError):
+            self.available = False
+            return None
+
+    @arm.setter
+    def arm(self, value):
+        """Arm or disarm camera."""
+        if value:
+            return api.request_motion_detection_enable(
+                self.blink, self.network_id, self.sync_id
+            )
+        return api.request_motion_detection_disable(
+            self.blink, self.network_id, self.sync_id
+        )


Test patch:
diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py
index 9f551e9..bd93720 100644
--- a/tests/test_blinkpy.py
+++ b/tests/test_blinkpy.py
@@ -9,6 +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.helpers.constants import __version__
 
 
@@ -160,10 +161,12 @@ class TestBlinkSetup(unittest.TestCase):
 
     @mock.patch("blinkpy.blinkpy.Blink.setup_camera_list")
     @mock.patch("blinkpy.api.request_networks")
-    def test_setup_post_verify(self, mock_networks, mock_camera):
+    @mock.patch("blinkpy.blinkpy.Blink.setup_owls")
+    def test_setup_post_verify(self, mock_owl, mock_networks, mock_camera):
         """Test setup after verification."""
         self.blink.available = False
         self.blink.key_required = True
+        mock_owl.return_value = True
         mock_networks.return_value = {
             "summary": {"foo": {"onboarded": False, "name": "bar"}}
         }
@@ -192,6 +195,44 @@ class TestBlinkSetup(unittest.TestCase):
         self.assertEqual(combined["fizz"], "buzz")
         self.assertEqual(combined["bar"], "foo")
 
+    @mock.patch("blinkpy.api.request_homescreen")
+    @mock.patch("blinkpy.blinkpy.BlinkOwl.start")
+    def test_initialize_blink_minis(self, mock_start, mock_home):
+        """Test blink mini initialization."""
+        mock_start.return_value = True
+        mock_home.return_value = {
+            "owls": [
+                {
+                    "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_owls()
+        self.assertEqual(self.blink.sync["foo"].__class__, BlinkOwl)
+        self.assertEqual(self.blink.sync["bar"].__class__, BlinkOwl)
+        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")
+
 
 class MockSync:
     """Mock sync module class."""
diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py
index baf2bb1..211cdb4 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
-from blinkpy.camera import BlinkCamera
+from blinkpy.sync_module import BlinkSyncModule, BlinkOwl
+from blinkpy.camera import BlinkCamera, BlinkCameraMini
 
 
 @mock.patch("blinkpy.auth.Auth.query")
@@ -275,3 +275,20 @@ class TestBlinkSyncModule(unittest.TestCase):
         """Test sync attributes."""
         self.assertEqual(self.blink.sync["test"].attributes["name"], "test")
         self.assertEqual(self.blink.sync["test"].attributes["network_id"], "1234")
+
+    def test_owl_start(self, mock_resp):
+        """Test owl camera instantiation."""
+        response = {
+            "name": "foo",
+            "id": 2,
+            "serial": "foobar123",
+            "enabled": True,
+            "network_id": 1,
+            "thumbnail": "/foo/bar",
+        }
+        self.blink.last_refresh = None
+        self.blink.homescreen = {"owls": [response]}
+        owl = BlinkOwl(self.blink, "foo", 1234, response)
+        self.assertTrue(owl.start())
+        self.assertTrue("foo" in owl.cameras)
+        self.assertEqual(owl.cameras["foo"].__class__, BlinkCameraMini)