453 lines
16 KiB
Python
453 lines
16 KiB
Python
"""
|
|
blinkpy is an unofficial api for the Blink security camera system.
|
|
|
|
repo url: https://github.com/fronzbot/blinkpy
|
|
|
|
Original protocol hacking by MattTW :
|
|
https://github.com/MattTW/BlinkMonitorProtocol
|
|
|
|
Published under the MIT license - See LICENSE file for more details.
|
|
"Blink Wire-Free HS Home Monitoring & Alert Systems" is a trademark
|
|
owned by Immedia Inc., see www.blinkforhome.com for more information.
|
|
blinkpy is in no way affiliated with Blink, nor Immedia Inc.
|
|
"""
|
|
|
|
import os.path
|
|
import time
|
|
import logging
|
|
import datetime
|
|
import aiofiles
|
|
import aiofiles.ospath
|
|
from requests.structures import CaseInsensitiveDict
|
|
from dateutil.parser import parse
|
|
from slugify import slugify
|
|
|
|
from blinkpy import api
|
|
from blinkpy.sync_module import BlinkSyncModule, BlinkOwl, BlinkLotus
|
|
from blinkpy.helpers import util
|
|
from blinkpy.helpers.constants import (
|
|
DEFAULT_MOTION_INTERVAL,
|
|
DEFAULT_REFRESH,
|
|
MIN_THROTTLE_TIME,
|
|
TIMEOUT_MEDIA,
|
|
)
|
|
from blinkpy.helpers.constants import __version__
|
|
from blinkpy.auth import Auth, TokenRefreshFailed, LoginError
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class Blink:
|
|
"""Class to initialize communication."""
|
|
|
|
def __init__(
|
|
self,
|
|
refresh_rate=DEFAULT_REFRESH,
|
|
motion_interval=DEFAULT_MOTION_INTERVAL,
|
|
no_owls=False,
|
|
session=None,
|
|
):
|
|
"""
|
|
Initialize Blink system.
|
|
|
|
:param refresh_rate: Refresh rate of blink information.
|
|
Defaults to 30 (seconds)
|
|
:param motion_interval: How far back to register motion in minutes.
|
|
Defaults to last refresh time.
|
|
Useful for preventing motion_detected property
|
|
from de-asserting too quickly.
|
|
:param no_owls: Disable searching for owl entries (blink mini cameras \
|
|
only known entity). Prevents an unnecessary API call \
|
|
if you don't have these in your network.
|
|
"""
|
|
self.auth = Auth(session=session)
|
|
self.account_id = None
|
|
self.client_id = None
|
|
self.network_ids = []
|
|
self.urls = None
|
|
self.sync = CaseInsensitiveDict({})
|
|
self.last_refresh = None
|
|
self.refresh_rate = refresh_rate
|
|
self.networks = []
|
|
self.cameras = CaseInsensitiveDict({})
|
|
self.video_list = CaseInsensitiveDict({})
|
|
self.motion_interval = motion_interval
|
|
self.version = __version__
|
|
self.available = False
|
|
self.key_required = False
|
|
self.homescreen = {}
|
|
self.no_owls = no_owls
|
|
|
|
@util.Throttle(seconds=MIN_THROTTLE_TIME)
|
|
async def refresh(self, force=False, force_cache=False):
|
|
"""
|
|
Perform a system refresh.
|
|
|
|
:param force: Used to override throttle, resets refresh
|
|
:param force_cache: Used to force update without overriding throttle
|
|
"""
|
|
if force or force_cache or self.check_if_ok_to_update():
|
|
if not self.available:
|
|
await self.setup_post_verify()
|
|
|
|
await self.get_homescreen()
|
|
|
|
for sync_name, sync_module in self.sync.items():
|
|
_LOGGER.debug("Attempting refresh of blink.sync['%s']", sync_name)
|
|
await sync_module.refresh(force_cache=(force or force_cache))
|
|
|
|
if not force_cache:
|
|
# Prevents rapid clearing of motion detect property
|
|
self.last_refresh = int(time.time())
|
|
last_refresh = datetime.datetime.fromtimestamp(self.last_refresh)
|
|
_LOGGER.debug("last_refresh = %s", last_refresh)
|
|
|
|
return True
|
|
return False
|
|
|
|
async def start(self):
|
|
"""Perform full system setup."""
|
|
try:
|
|
await self.auth.startup()
|
|
self.setup_login_ids()
|
|
self.setup_urls()
|
|
await self.get_homescreen()
|
|
except (LoginError, TokenRefreshFailed, BlinkSetupError):
|
|
_LOGGER.error("Cannot setup Blink platform.")
|
|
self.available = False
|
|
return False
|
|
|
|
self.key_required = self.auth.check_key_required()
|
|
if self.key_required:
|
|
if self.auth.no_prompt:
|
|
return True
|
|
await self.setup_prompt_2fa()
|
|
|
|
if not self.last_refresh:
|
|
# Initialize last_refresh to be just before the refresh delay period.
|
|
self.last_refresh = int(time.time() - self.refresh_rate * 1.05)
|
|
_LOGGER.debug(
|
|
"Initialized last_refresh to %s == %s",
|
|
self.last_refresh,
|
|
datetime.datetime.fromtimestamp(self.last_refresh),
|
|
)
|
|
|
|
return await self.setup_post_verify()
|
|
|
|
async def setup_prompt_2fa(self):
|
|
"""Prompt for 2FA."""
|
|
email = self.auth.data["username"]
|
|
pin = input(f"Enter code sent to {email}: ")
|
|
result = await self.auth.send_auth_key(self, pin)
|
|
self.key_required = not result
|
|
|
|
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()
|
|
except BlinkSetupError:
|
|
self.available = False
|
|
return False
|
|
|
|
for name, network_id in networks.items():
|
|
sync_cameras = cameras.get(network_id, {})
|
|
await self.setup_sync_module(name, network_id, sync_cameras)
|
|
|
|
self.cameras = self.merge_cameras()
|
|
|
|
self.available = True
|
|
self.key_required = False
|
|
return True
|
|
|
|
async def setup_sync_module(self, name, network_id, cameras):
|
|
"""Initialize a sync module."""
|
|
self.sync[name] = BlinkSyncModule(self, name, network_id, cameras)
|
|
await self.sync[name].start()
|
|
|
|
async def get_homescreen(self):
|
|
"""Get homescreen information."""
|
|
if self.no_owls:
|
|
_LOGGER.debug("Skipping owl extraction.")
|
|
self.homescreen = {}
|
|
return
|
|
self.homescreen = await api.request_homescreen(self)
|
|
_LOGGER.debug("homescreen = %s", util.json_dumps(self.homescreen))
|
|
|
|
async def setup_owls(self):
|
|
"""Check for mini cameras."""
|
|
network_list = []
|
|
camera_list = []
|
|
try:
|
|
for owl in self.homescreen["owls"]:
|
|
name = owl["name"]
|
|
network_id = str(owl["network_id"])
|
|
if network_id in self.network_ids:
|
|
camera_list.append(
|
|
{network_id: {"name": name, "id": network_id, "type": "mini"}}
|
|
)
|
|
continue
|
|
if owl["onboarded"]:
|
|
network_list.append(str(network_id))
|
|
self.sync[name] = BlinkOwl(self, name, network_id, owl)
|
|
await self.sync[name].start()
|
|
except (KeyError, TypeError):
|
|
# No sync-less devices found
|
|
pass
|
|
|
|
self.network_ids.extend(network_list)
|
|
return camera_list
|
|
|
|
async 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)
|
|
await self.sync[name].start()
|
|
except (KeyError, TypeError):
|
|
# No sync-less devices found
|
|
pass
|
|
|
|
self.network_ids.extend(network_list)
|
|
return camera_list
|
|
|
|
async def setup_camera_list(self):
|
|
"""Create camera list for onboarded networks."""
|
|
all_cameras = {}
|
|
response = await api.request_camera_usage(self)
|
|
try:
|
|
for network in response["networks"]:
|
|
_LOGGER.info("network = %s", util.json_dumps(network))
|
|
camera_network = str(network["network_id"])
|
|
if camera_network not in all_cameras:
|
|
all_cameras[camera_network] = []
|
|
for camera in network["cameras"]:
|
|
all_cameras[camera_network].append(
|
|
{"name": camera["name"], "id": camera["id"], "type": "default"}
|
|
)
|
|
mini_cameras = await self.setup_owls()
|
|
lotus_cameras = await 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) as ex:
|
|
_LOGGER.error("Unable to retrieve cameras from response %s", response)
|
|
raise BlinkSetupError from ex
|
|
|
|
def setup_login_ids(self):
|
|
"""Retrieve login id numbers from login response."""
|
|
self.client_id = self.auth.client_id
|
|
self.account_id = self.auth.account_id
|
|
|
|
def setup_urls(self):
|
|
"""Create urls for api."""
|
|
try:
|
|
self.urls = util.BlinkURLHandler(self.auth.region_id)
|
|
except TypeError as ex:
|
|
_LOGGER.error(
|
|
"Unable to extract region is from response %s", self.auth.login_response
|
|
)
|
|
raise BlinkSetupError from ex
|
|
|
|
async def setup_networks(self):
|
|
"""Get network information."""
|
|
response = await api.request_networks(self)
|
|
try:
|
|
self.networks = response["summary"]
|
|
except (KeyError, TypeError) as ex:
|
|
raise BlinkSetupError from ex
|
|
|
|
def setup_network_ids(self):
|
|
"""Create the network ids for onboarded networks."""
|
|
all_networks = []
|
|
network_dict = {}
|
|
try:
|
|
for network, status in self.networks.items():
|
|
if status["onboarded"]:
|
|
all_networks.append(f"{network}")
|
|
network_dict[status["name"]] = network
|
|
except AttributeError as ex:
|
|
_LOGGER.error(
|
|
"Unable to retrieve network information from %s", self.networks
|
|
)
|
|
raise BlinkSetupError from ex
|
|
|
|
self.network_ids = all_networks
|
|
return network_dict
|
|
|
|
def check_if_ok_to_update(self):
|
|
"""Check if it is ok to perform an http request."""
|
|
current_time = int(time.time())
|
|
last_refresh = self.last_refresh
|
|
if last_refresh is None:
|
|
last_refresh = 0
|
|
if current_time >= (last_refresh + self.refresh_rate):
|
|
return True
|
|
return False
|
|
|
|
def merge_cameras(self):
|
|
"""Merge all sync camera dicts into one."""
|
|
combined = CaseInsensitiveDict({})
|
|
for sync in self.sync:
|
|
combined = util.merge_dicts(combined, self.sync[sync].cameras)
|
|
return combined
|
|
|
|
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
|
|
):
|
|
"""
|
|
Download all videos from server since specified time.
|
|
|
|
:param path: Path to write files. /path/<cameraname>_<recorddate>.mp4
|
|
:param since: Date and time to get videos from.
|
|
Ex: "2018/07/28 12:33:00" to retrieve videos since
|
|
July 28th 2018 at 12:33:00
|
|
:param camera: Camera name to retrieve. Defaults to "all".
|
|
Use a list for multiple cameras.
|
|
:param stop: Page to stop on (~25 items per page. Default page 10).
|
|
:param delay: Number of seconds to wait in between subsequent video downloads.
|
|
:param debug: Set to TRUE to prevent downloading of items.
|
|
Instead of downloading, entries will be printed to log.
|
|
"""
|
|
if not isinstance(camera, list):
|
|
camera = [camera]
|
|
|
|
results = await self.get_videos_metadata(since=since, stop=stop)
|
|
await self._parse_downloaded_items(results, camera, path, delay, debug)
|
|
|
|
async def get_videos_metadata(self, since=None, camera="all", stop=10):
|
|
"""
|
|
Fetch and return video metadata.
|
|
|
|
:param since: Date and time to get videos from.
|
|
Ex: "2018/07/28 12:33:00" to retrieve videos since
|
|
July 28th 2018 at 12:33:00
|
|
:param stop: Page to stop on (~25 items per page. Default page 10).
|
|
"""
|
|
videos = []
|
|
if since is None:
|
|
since_epochs = self.last_refresh
|
|
else:
|
|
parsed_datetime = parse(since, fuzzy=True)
|
|
since_epochs = parsed_datetime.timestamp()
|
|
|
|
formatted_date = util.get_time(time_to_convert=since_epochs)
|
|
_LOGGER.info("Retrieving videos since %s", formatted_date)
|
|
|
|
for page in range(1, stop):
|
|
response = await api.request_videos(self, time=since_epochs, page=page)
|
|
_LOGGER.debug("Processing page %s", page)
|
|
try:
|
|
result = response["media"]
|
|
if not result:
|
|
raise KeyError
|
|
videos.extend(result)
|
|
except (KeyError, TypeError):
|
|
_LOGGER.info("No videos found on page %s. Exiting.", page)
|
|
break
|
|
return videos
|
|
|
|
async def do_http_get(self, address):
|
|
"""
|
|
Do an http_get on address.
|
|
|
|
:param address: address to be added to base_url.
|
|
"""
|
|
response = await api.http_get(
|
|
self,
|
|
url=f"{self.urls.base_url}{address}",
|
|
stream=True,
|
|
json=False,
|
|
timeout=TIMEOUT_MEDIA,
|
|
)
|
|
return response
|
|
|
|
async def _parse_downloaded_items(self, result, camera, path, delay, debug):
|
|
"""Parse downloaded videos."""
|
|
for item in result:
|
|
try:
|
|
created_at = item["created_at"]
|
|
camera_name = item["device_name"]
|
|
is_deleted = item["deleted"]
|
|
address = item["media"]
|
|
except KeyError:
|
|
_LOGGER.info("Missing clip information, skipping...")
|
|
continue
|
|
|
|
if camera_name not in camera and "all" not in camera:
|
|
_LOGGER.debug("Skipping videos for %s.", camera_name)
|
|
continue
|
|
|
|
if is_deleted:
|
|
_LOGGER.debug("%s: %s is marked as deleted.", camera_name, address)
|
|
continue
|
|
|
|
filename = f"{camera_name}-{created_at}"
|
|
filename = f"{slugify(filename)}.mp4"
|
|
filename = os.path.join(path, filename)
|
|
|
|
if not debug:
|
|
if await aiofiles.ospath.isfile(filename):
|
|
_LOGGER.info("%s already exists, skipping...", filename)
|
|
continue
|
|
|
|
response = await self.do_http_get(address)
|
|
async with aiofiles.open(filename, "wb") as vidfile:
|
|
await vidfile.write(await response.read())
|
|
|
|
_LOGGER.info("Downloaded video to %s", filename)
|
|
else:
|
|
print(
|
|
f"Camera: {camera_name}, Timestamp: {created_at}, "
|
|
f"Address: {address}, Filename: {filename}"
|
|
)
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
|
|
|
|
class BlinkSetupError(Exception):
|
|
"""Class to handle setup errors."""
|