From 883eb5c1336ded556cd1be3aae2ee3321d8d9644 Mon Sep 17 00:00:00 2001 From: Sebastian Goscik Date: Fri, 18 Mar 2022 20:12:44 +0000 Subject: [PATCH 1/3] Added ability to choose which event types to backup --- README.md | 27 +++++++++++--------- unifi_protect_backup/cli.py | 26 ++++++++++++++++++- unifi_protect_backup/unifi_protect_backup.py | 20 ++++++++++++++- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0eec92e..e4b6640 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,17 @@ Usage: unifi-protect-backup [OPTIONS] A Python based tool for backing up Unifi Protect event clips as they occur. Options: + --version Show the version and exit. --address TEXT Address of Unifi Protect instance [required] - --port INTEGER Port of Unifi Protect instance + --port INTEGER Port of Unifi Protect instance [default: + 443] --username TEXT Username to login to Unifi Protect instance [required] --password TEXT Password for Unifi Protect user [required] --verify-ssl / --no-verify-ssl Set if you do not have a valid HTTPS - Certificate for your instance + Certificate for your instance [default: + verify-ssl] --rclone-destination TEXT `rclone` destination path in the format {rclone remote}:{path on remote}. E.g. `gdrive:/backups/unifi_protect` [required] @@ -65,15 +68,14 @@ Options: of `rclone` (https://rclone.org/filtering/#max-age-don- t-transfer-any-file-older-than-this) - --rclone-args TEXT Optional arguments which are directly passed - to `rclone rcat`. These can by used to set - parameters such as the bandwidth limit used - when pushing the files to the rclone - destination, e.g., '--bwlimit=500k'. Please - see the `rclone` documentation for the full - set of arguments it supports - (https://rclone.org/docs/). Please use - responsibly. + [default: 7d] + --rclone-args TEXT Optional extra arguments to pass to `rclone + rcat` directly. Common usage for this would + be to set a bandwidth limit, for example. + --detection-types TEXT A comma separated list of which types of + detections to backup. Valid options are: + `motion`, `person`, `vehicle` [default: + motion,person,vehicle] --ignore-camera TEXT IDs of cameras for which events should not be backed up. Use multiple times to ignore multiple IDs. If being set as an environment @@ -118,6 +120,7 @@ always take priority over environment variables): - `RCLONE_DESTINATION` - `RCLONE_ARGS` - `IGNORE_CAMERAS` +- `DETECTION_TYPES` ## Docker Container You can run this tool as a container if you prefer with the following command. @@ -144,7 +147,7 @@ If you do not already have a `rclone.conf` file you can create one as follows: ``` $ docker run -it --rm -v $PWD:/root/.config/rclone rclone/rclone config ``` -Follow the interactive configuration proceed, this will create a `rclone.conf` +Follow the interactive configuration proceed, this will create a `rclone.conf` file in your current directory. Finally start the container: diff --git a/unifi_protect_backup/cli.py b/unifi_protect_backup/cli.py index b981dc6..8c671a1 100644 --- a/unifi_protect_backup/cli.py +++ b/unifi_protect_backup/cli.py @@ -6,16 +6,31 @@ from unifi_protect_backup import UnifiProtectBackup, __version__ +DETECTION_TYPES = ["motion", "person", "vehicle"] + + +def _parse_detection_types(ctx, param, value): + # split columns by ',' and remove whitespace + types = [t.strip() for t in value.split(',')] + + # validate passed columns + for t in types: + if t not in DETECTION_TYPES: + raise click.BadOptionUsage("detection-types", f"`{t}` is not an available detection type.", ctx) + + return types + @click.command() @click.version_option(__version__) @click.option('--address', required=True, envvar='UFP_ADDRESS', help='Address of Unifi Protect instance') -@click.option('--port', default=443, envvar='UFP_PORT', help='Port of Unifi Protect instance') +@click.option('--port', default=443, envvar='UFP_PORT', show_default=True, help='Port of Unifi Protect instance') @click.option('--username', required=True, envvar='UFP_USERNAME', help='Username to login to Unifi Protect instance') @click.option('--password', required=True, envvar='UFP_PASSWORD', help='Password for Unifi Protect user') @click.option( '--verify-ssl/--no-verify-ssl', default=True, + show_default=True, envvar='UFP_SSL_VERIFY', help="Set if you do not have a valid HTTPS Certificate for your instance", ) @@ -29,6 +44,7 @@ @click.option( '--retention', default='7d', + show_default=True, envvar='RCLONE_RETENTION', help="How long should event clips be backed up for. Format as per the `--max-age` argument of " "`rclone` (https://rclone.org/filtering/#max-age-don-t-transfer-any-file-older-than-this)", @@ -40,6 +56,14 @@ help="Optional extra arguments to pass to `rclone rcat` directly. Common usage for this would " "be to set a bandwidth limit, for example.", ) +@click.option( + '--detection-types', + envvar='DETECTION_TYPES', + default=','.join(DETECTION_TYPES), + show_default=True, + help=f"A comma separated list of which types of detections to backup. Valid options are: {', '.join([f'`{t}`' for t in DETECTION_TYPES])}", + callback=_parse_detection_types, +) @click.option( '--ignore-camera', 'ignore_cameras', diff --git a/unifi_protect_backup/unifi_protect_backup.py b/unifi_protect_backup/unifi_protect_backup.py index 9061a6a..08e3437 100644 --- a/unifi_protect_backup/unifi_protect_backup.py +++ b/unifi_protect_backup/unifi_protect_backup.py @@ -175,6 +175,7 @@ class UnifiProtectBackup: rclone_args (str): Extra args passed directly to `rclone rcat`. ignore_cameras (List[str]): List of camera IDs for which to not backup events verbose (int): How verbose to setup logging, see :func:`setup_logging` for details. + detection_types(List[str]): List of which detection types to backup. _download_queue (asyncio.Queue): Queue of events that need to be backed up _unsub (Callable): Unsubscribe from the websocket callback _has_ffprobe (bool): If ffprobe was found on the host @@ -189,6 +190,7 @@ def __init__( rclone_destination: str, retention: str, rclone_args: str, + detection_types: List[str], ignore_cameras: List[str], verbose: int, port: int = 443, @@ -229,6 +231,7 @@ def __init__( logger.debug(f" {rclone_args=}") logger.debug(f" {ignore_cameras=}") logger.debug(f" {verbose=}") + logger.debug(f" {detection_types=}") self.rclone_destination = rclone_destination self.retention = retention @@ -251,6 +254,7 @@ def __init__( self.ignore_cameras = ignore_cameras self._download_queue: asyncio.Queue = asyncio.Queue() self._unsub: Callable[[], None] + self.detection_types = detection_types self._has_ffprobe = False @@ -422,6 +426,15 @@ def _websocket_callback(self, msg: WSSubscriptionMessage) -> None: return if msg.new_obj.type not in {EventType.MOTION, EventType.SMART_DETECT}: return + if msg.new_obj.type is EventType.MOTION and "motion" not in self.detection_types: + logger.extra_debug(f"Skipping unwanted motion detection event: {msg.new_obj.id}") + return + elif msg.new_obj.type is EventType.SMART_DETECT: + for event_smart_detection_type in msg.new_obj.smart_detect_types: + if event_smart_detection_type not in self.detection_types: + logger.extra_debug(f"Skipping unwanted {event_smart_detection_type} detection event: {msg.new_obj.id}") + return + self._download_queue.put_nowait(msg.new_obj) logger.debug(f"Adding event {msg.new_obj.id} to queue (Current queue={self._download_queue.qsize()})") @@ -445,7 +458,12 @@ async def _backup_events(self) -> None: logger.info(f"Backing up event: {event.id}") logger.debug(f"Remaining Queue: {self._download_queue.qsize()}") logger.debug(f" Camera: {await self._get_camera_name(event.camera_id)}") - logger.debug(f" Type: {event.type}") + if event.type == EventType.MOTION: + logger.debug(f" Type: {event.type}") + elif event.type == EventType.SMART_DETECT: + logger.debug(f" Type: {event.type} ({', '.join(event.smart_detect_types)})") + else: + ValueError(f"Unexpected event type: `{event.type}") logger.debug(f" Start: {event.start.strftime('%Y-%m-%dT%H-%M-%S')} ({event.start.timestamp()})") logger.debug(f" End: {event.end.strftime('%Y-%m-%dT%H-%M-%S')} ({event.end.timestamp()})") duration = (event.end - event.start).total_seconds() From 649041f590321245bcd4515f60276ebd65369dae Mon Sep 17 00:00:00 2001 From: Sebastian Goscik Date: Fri, 18 Mar 2022 20:53:02 +0000 Subject: [PATCH 2/3] Added support for doorbell ring events --- README.md | 4 ++-- unifi_protect_backup/cli.py | 2 +- unifi_protect_backup/unifi_protect_backup.py | 11 +++++------ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e4b6640..ce4c556 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,8 @@ Options: be to set a bandwidth limit, for example. --detection-types TEXT A comma separated list of which types of detections to backup. Valid options are: - `motion`, `person`, `vehicle` [default: - motion,person,vehicle] + `motion`, `person`, `vehicle`, `ring` + [default: motion,person,vehicle,ring] --ignore-camera TEXT IDs of cameras for which events should not be backed up. Use multiple times to ignore multiple IDs. If being set as an environment diff --git a/unifi_protect_backup/cli.py b/unifi_protect_backup/cli.py index 8c671a1..32c9690 100644 --- a/unifi_protect_backup/cli.py +++ b/unifi_protect_backup/cli.py @@ -6,7 +6,7 @@ from unifi_protect_backup import UnifiProtectBackup, __version__ -DETECTION_TYPES = ["motion", "person", "vehicle"] +DETECTION_TYPES = ["motion", "person", "vehicle", "ring"] def _parse_detection_types(ctx, param, value): diff --git a/unifi_protect_backup/unifi_protect_backup.py b/unifi_protect_backup/unifi_protect_backup.py index 08e3437..03182ef 100644 --- a/unifi_protect_backup/unifi_protect_backup.py +++ b/unifi_protect_backup/unifi_protect_backup.py @@ -424,11 +424,12 @@ def _websocket_callback(self, msg: WSSubscriptionMessage) -> None: return if msg.new_obj.end is None: return - if msg.new_obj.type not in {EventType.MOTION, EventType.SMART_DETECT}: - return if msg.new_obj.type is EventType.MOTION and "motion" not in self.detection_types: logger.extra_debug(f"Skipping unwanted motion detection event: {msg.new_obj.id}") return + if msg.new_obj.type is EventType.RING and "ring" not in self.detection_types: + logger.extra_debug(f"Skipping unwanted ring event: {msg.new_obj.id}") + return elif msg.new_obj.type is EventType.SMART_DETECT: for event_smart_detection_type in msg.new_obj.smart_detect_types: if event_smart_detection_type not in self.detection_types: @@ -458,12 +459,10 @@ async def _backup_events(self) -> None: logger.info(f"Backing up event: {event.id}") logger.debug(f"Remaining Queue: {self._download_queue.qsize()}") logger.debug(f" Camera: {await self._get_camera_name(event.camera_id)}") - if event.type == EventType.MOTION: - logger.debug(f" Type: {event.type}") - elif event.type == EventType.SMART_DETECT: + if event.type == EventType.SMART_DETECT: logger.debug(f" Type: {event.type} ({', '.join(event.smart_detect_types)})") else: - ValueError(f"Unexpected event type: `{event.type}") + logger.debug(f" Type: {event.type}") logger.debug(f" Start: {event.start.strftime('%Y-%m-%dT%H-%M-%S')} ({event.start.timestamp()})") logger.debug(f" End: {event.end.strftime('%Y-%m-%dT%H-%M-%S')} ({event.end.timestamp()})") duration = (event.end - event.start).total_seconds() From 1d80a330e5b5abe5aea0ef9eb6ecf170a5ffdccf Mon Sep 17 00:00:00 2001 From: Sebastian Goscik Date: Fri, 18 Mar 2022 21:07:11 +0000 Subject: [PATCH 3/3] Linter fixes --- unifi_protect_backup/cli.py | 3 ++- unifi_protect_backup/unifi_protect_backup.py | 21 ++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/unifi_protect_backup/cli.py b/unifi_protect_backup/cli.py index 32c9690..b9dd77e 100644 --- a/unifi_protect_backup/cli.py +++ b/unifi_protect_backup/cli.py @@ -61,7 +61,8 @@ def _parse_detection_types(ctx, param, value): envvar='DETECTION_TYPES', default=','.join(DETECTION_TYPES), show_default=True, - help=f"A comma separated list of which types of detections to backup. Valid options are: {', '.join([f'`{t}`' for t in DETECTION_TYPES])}", + help="A comma separated list of which types of detections to backup. " + f"Valid options are: {', '.join([f'`{t}`' for t in DETECTION_TYPES])}", callback=_parse_detection_types, ) @click.option( diff --git a/unifi_protect_backup/unifi_protect_backup.py b/unifi_protect_backup/unifi_protect_backup.py index 03182ef..9f89cda 100644 --- a/unifi_protect_backup/unifi_protect_backup.py +++ b/unifi_protect_backup/unifi_protect_backup.py @@ -1,11 +1,11 @@ """Main module.""" import asyncio -from datetime import datetime, timedelta, timezone +import json import logging import pathlib import shutil -import json from asyncio.exceptions import TimeoutError +from datetime import datetime, timedelta, timezone from typing import Callable, List, Optional import aiocron @@ -211,7 +211,8 @@ def __init__( (https://rclone.org/filtering/#max-age-don-t-transfer-any-file-older-than-this) rclone_args (str): A bandwidth limit which is passed to the `--bwlimit` argument of `rclone` (https://rclone.org/docs/#bwlimit-bandwidth-spec) - ignore_cameras (List[str]): List of camera IDs for which to not backup events + detection_types (List[str]): List of which detection types to backup. + ignore_cameras (List[str]): List of camera IDs for which to not backup events. verbose (int): How verbose to setup logging, see :func:`setup_logging` for details. """ setup_logging(verbose) @@ -425,15 +426,17 @@ def _websocket_callback(self, msg: WSSubscriptionMessage) -> None: if msg.new_obj.end is None: return if msg.new_obj.type is EventType.MOTION and "motion" not in self.detection_types: - logger.extra_debug(f"Skipping unwanted motion detection event: {msg.new_obj.id}") + logger.extra_debug(f"Skipping unwanted motion detection event: {msg.new_obj.id}") # type: ignore return if msg.new_obj.type is EventType.RING and "ring" not in self.detection_types: - logger.extra_debug(f"Skipping unwanted ring event: {msg.new_obj.id}") + logger.extra_debug(f"Skipping unwanted ring event: {msg.new_obj.id}") # type: ignore return elif msg.new_obj.type is EventType.SMART_DETECT: for event_smart_detection_type in msg.new_obj.smart_detect_types: if event_smart_detection_type not in self.detection_types: - logger.extra_debug(f"Skipping unwanted {event_smart_detection_type} detection event: {msg.new_obj.id}") + logger.extra_debug( # type: ignore + f"Skipping unwanted {event_smart_detection_type} detection event: {msg.new_obj.id}" + ) return self._download_queue.put_nowait(msg.new_obj) @@ -502,8 +505,10 @@ async def _backup_events(self) -> None: if self._has_ffprobe: try: downloaded_duration = await self._get_video_length(video) - msg = f" Downloaded video length: {downloaded_duration:.3f}s" \ - f"({downloaded_duration - duration:+.3f}s)" + msg = ( + f" Downloaded video length: {downloaded_duration:.3f}s" + f"({downloaded_duration - duration:+.3f}s)" + ) if downloaded_duration < duration: logger.warning(msg) else: