Add support for Finger Print, NFC Card Scan, and Audio Detections

Also refactored code that checks if an event should be backed up into one common shared function.
This commit is contained in:
Sebastian Goscik
2025-07-07 00:55:18 +01:00
parent edf377adc4
commit eaabfbdb4e
8 changed files with 85 additions and 85 deletions

View File

@@ -7,13 +7,15 @@ import click
from aiorun import run # type: ignore from aiorun import run # type: ignore
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from uiprotect.data.types import SmartDetectObjectType from uiprotect.data.types import SmartDetectObjectType, SmartDetectAudioType
from unifi_protect_backup import __version__ from unifi_protect_backup import __version__
from unifi_protect_backup.unifi_protect_backup_core import UnifiProtectBackup from unifi_protect_backup.unifi_protect_backup_core import UnifiProtectBackup
from unifi_protect_backup.utils import human_readable_to_float from unifi_protect_backup.utils import human_readable_to_float
DETECTION_TYPES = ["motion", "ring", "line"] + SmartDetectObjectType.values() DETECTION_TYPES = ["motion", "ring", "line", "fingerprint", "nfc"]
DETECTION_TYPES += [t for t in SmartDetectObjectType.values() if t not in SmartDetectAudioType.values()]
DETECTION_TYPES += [f"{t}" for t in SmartDetectAudioType.values()]
def _parse_detection_types(ctx, param, value): def _parse_detection_types(ctx, param, value):

View File

@@ -114,7 +114,7 @@ class VideoDownloader:
output_queue_max_size = human_readable_size(self.upload_queue.maxsize) output_queue_max_size = human_readable_size(self.upload_queue.maxsize)
self.logger.debug(f"Video Download Buffer: {output_queue_current_size}/{output_queue_max_size}") self.logger.debug(f"Video Download Buffer: {output_queue_current_size}/{output_queue_max_size}")
self.logger.debug(f" Camera: {await get_camera_name(self._protect, event.camera_id)}") self.logger.debug(f" Camera: {await get_camera_name(self._protect, event.camera_id)}")
if event.type == EventType.SMART_DETECT: if event.type in [EventType.SMART_DETECT, EventType.SMART_AUDIO_DETECT]:
self.logger.debug(f" Type: {event.type.value} ({', '.join(event.smart_detect_types)})") self.logger.debug(f" Type: {event.type.value} ({', '.join(event.smart_detect_types)})")
else: else:
self.logger.debug(f" Type: {event.type.value}") self.logger.debug(f" Type: {event.type.value}")

View File

@@ -114,7 +114,7 @@ class VideoDownloaderExperimental:
output_queue_max_size = human_readable_size(self.upload_queue.maxsize) output_queue_max_size = human_readable_size(self.upload_queue.maxsize)
self.logger.debug(f"Video Download Buffer: {output_queue_current_size}/{output_queue_max_size}") self.logger.debug(f"Video Download Buffer: {output_queue_current_size}/{output_queue_max_size}")
self.logger.debug(f" Camera: {await get_camera_name(self._protect, event.camera_id)}") self.logger.debug(f" Camera: {await get_camera_name(self._protect, event.camera_id)}")
if event.type == EventType.SMART_DETECT: if event.type in [EventType.SMART_DETECT, EventType.SMART_AUDIO_DETECT]:
self.logger.debug(f" Type: {event.type.value} ({', '.join(event.smart_detect_types)})") self.logger.debug(f" Type: {event.type.value} ({', '.join(event.smart_detect_types)})")
else: else:
self.logger.debug(f" Type: {event.type.value}") self.logger.debug(f" Type: {event.type.value}")

View File

@@ -3,14 +3,15 @@
import asyncio import asyncio
import logging import logging
from time import sleep from time import sleep
from typing import List from typing import Set
from uiprotect.api import ProtectApiClient from uiprotect.api import ProtectApiClient
from uiprotect.websocket import WebsocketState from uiprotect.websocket import WebsocketState
from uiprotect.data.nvr import Event from uiprotect.data.nvr import Event
from uiprotect.data.types import EventType
from uiprotect.data.websocket import WSAction, WSSubscriptionMessage from uiprotect.data.websocket import WSAction, WSSubscriptionMessage
from unifi_protect_backup.utils import wanted_event_type
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,27 +22,27 @@ class EventListener:
self, self,
event_queue: asyncio.Queue, event_queue: asyncio.Queue,
protect: ProtectApiClient, protect: ProtectApiClient,
detection_types: List[str], detection_types: Set[str],
ignore_cameras: List[str], ignore_cameras: Set[str],
cameras: List[str], cameras: Set[str],
): ):
"""Init. """Init.
Args: Args:
event_queue (asyncio.Queue): Queue to place events to backup on event_queue (asyncio.Queue): Queue to place events to backup on
protect (ProtectApiClient): UniFI Protect API client to use protect (ProtectApiClient): UniFI Protect API client to use
detection_types (List[str]): Desired Event detection types to look for detection_types (Set[str]): Desired Event detection types to look for
ignore_cameras (List[str]): Cameras IDs to ignore events from ignore_cameras (Set[str]): Cameras IDs to ignore events from
cameras (List[str]): Cameras IDs to ONLY include events from cameras (Set[str]): Cameras IDs to ONLY include events from
""" """
self._event_queue: asyncio.Queue = event_queue self._event_queue: asyncio.Queue = event_queue
self._protect: ProtectApiClient = protect self._protect: ProtectApiClient = protect
self._unsub = None self._unsub = None
self._unsub_websocketstate = None self._unsub_websocketstate = None
self.detection_types: List[str] = detection_types self.detection_types: Set[str] = detection_types
self.ignore_cameras: List[str] = ignore_cameras self.ignore_cameras: Set[str] = ignore_cameras
self.cameras: List[str] = cameras self.cameras: Set[str] = cameras
async def start(self): async def start(self):
"""Run main Loop.""" """Run main Loop."""
@@ -63,35 +64,10 @@ class EventListener:
assert isinstance(msg.new_obj, Event) assert isinstance(msg.new_obj, Event)
if msg.action != WSAction.UPDATE: if msg.action != WSAction.UPDATE:
return return
if msg.new_obj.camera_id in self.ignore_cameras:
return
if self.cameras and msg.new_obj.camera_id not in self.cameras:
return
if "end" not in msg.changed_data: if "end" not in msg.changed_data:
return return
if msg.new_obj.type not in [ if not wanted_event_type(msg.new_obj, self.detection_types, self.cameras, self.ignore_cameras):
EventType.MOTION,
EventType.SMART_DETECT,
EventType.RING,
EventType.SMART_DETECT_LINE,
]:
return 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}") # 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}") # type: ignore
return
if msg.new_obj.type is EventType.SMART_DETECT_LINE and "line" not in self.detection_types:
logger.extra_debug(f"Skipping unwanted line 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( # type: ignore
f"Skipping unwanted {event_smart_detection_type} detection event: {msg.new_obj.id}"
)
return
# TODO: Will this even work? I think it will block the async loop # TODO: Will this even work? I think it will block the async loop
while self._event_queue.full(): while self._event_queue.full():

View File

@@ -3,7 +3,7 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime from datetime import datetime
from typing import AsyncIterator, List from typing import AsyncIterator, List, Set
import aiosqlite import aiosqlite
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -12,6 +12,7 @@ from uiprotect.data.nvr import Event
from uiprotect.data.types import EventType from uiprotect.data.types import EventType
from unifi_protect_backup import VideoDownloader, VideoUploader from unifi_protect_backup import VideoDownloader, VideoUploader
from unifi_protect_backup.utils import EVENT_TYPES_MAP, wanted_event_type
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,9 +28,9 @@ class MissingEventChecker:
downloader: VideoDownloader, downloader: VideoDownloader,
uploaders: List[VideoUploader], uploaders: List[VideoUploader],
retention: relativedelta, retention: relativedelta,
detection_types: List[str], detection_types: Set[str],
ignore_cameras: List[str], ignore_cameras: Set[str],
cameras: List[str], cameras: Set[str],
interval: int = 60 * 5, interval: int = 60 * 5,
) -> None: ) -> None:
"""Init. """Init.
@@ -41,9 +42,9 @@ class MissingEventChecker:
downloader (VideoDownloader): Downloader to check for on-going downloads downloader (VideoDownloader): Downloader to check for on-going downloads
uploaders (List[VideoUploader]): Uploaders to check for on-going uploads uploaders (List[VideoUploader]): Uploaders to check for on-going uploads
retention (relativedelta): Retention period to limit search window retention (relativedelta): Retention period to limit search window
detection_types (List[str]): Detection types wanted to limit search detection_types (Set[str]): Detection types wanted to limit search
ignore_cameras (List[str]): Ignored camera IDs to limit search ignore_cameras (Set[str]): Ignored camera IDs to limit search
cameras (List[str]): Included (ONLY) camera IDs to limit search cameras (Set[str]): Included (ONLY) camera IDs to limit search
interval (int): How frequently, in seconds, to check for missing events, interval (int): How frequently, in seconds, to check for missing events,
""" """
@@ -53,9 +54,9 @@ class MissingEventChecker:
self._downloader: VideoDownloader = downloader self._downloader: VideoDownloader = downloader
self._uploaders: List[VideoUploader] = uploaders self._uploaders: List[VideoUploader] = uploaders
self.retention: relativedelta = retention self.retention: relativedelta = retention
self.detection_types: List[str] = detection_types self.detection_types: Set[str] = detection_types
self.ignore_cameras: List[str] = ignore_cameras self.ignore_cameras: Set[str] = ignore_cameras
self.cameras: List[str] = cameras self.cameras: Set[str] = cameras
self.interval: int = interval self.interval: int = interval
async def _get_missing_events(self) -> AsyncIterator[Event]: async def _get_missing_events(self) -> AsyncIterator[Event]:
@@ -69,12 +70,7 @@ class MissingEventChecker:
events_chunk = await self._protect.get_events( events_chunk = await self._protect.get_events(
start=start_time, start=start_time,
end=end_time, end=end_time,
types=[ types=list(EVENT_TYPES_MAP.keys()),
EventType.MOTION,
EventType.SMART_DETECT,
EventType.RING,
EventType.SMART_DETECT_LINE,
],
limit=chunk_size, limit=chunk_size,
) )
@@ -109,35 +105,23 @@ class MissingEventChecker:
if current_upload is not None: if current_upload is not None:
uploading_event_ids.add(current_upload.id) uploading_event_ids.add(current_upload.id)
missing_event_ids = set(unifi_events.keys()) - (db_event_ids | downloading_event_ids | uploading_event_ids) missing_events = {
event_id: event
for event_id, event in unifi_events.items()
if event_id not in (db_event_ids | downloading_event_ids | uploading_event_ids)
}
# Exclude events of unwanted types # Exclude events of unwanted types
def wanted_event_type(event_id, unifi_events=unifi_events): wanted_events = {
event = unifi_events[event_id] event_id: event
if event.start is None or event.end is None: for event_id, event in missing_events.items()
return False # This event is still on-going if wanted_event_type(event, self.detection_types, self.cameras, self.ignore_cameras)
if event.camera_id in self.ignore_cameras: }
return False
if self.cameras and event.camera_id not in self.cameras:
return False
if event.type is EventType.MOTION and "motion" not in self.detection_types:
return False
if event.type is EventType.RING and "ring" not in self.detection_types:
return False
if event.type is EventType.SMART_DETECT_LINE and "line" not in self.detection_types:
return False
elif event.type is EventType.SMART_DETECT:
for event_smart_detection_type in event.smart_detect_types:
if event_smart_detection_type not in self.detection_types:
return False
return True
wanted_event_ids = set(filter(wanted_event_type, missing_event_ids))
# Yeild events one by one to allow the async loop to start other task while # Yeild events one by one to allow the async loop to start other task while
# waiting on the full list of events # waiting on the full list of events
for id in wanted_event_ids: for event in wanted_events.values():
yield unifi_events[id] yield event
# Last chunk was in-complete, we can stop now # Last chunk was in-complete, we can stop now
if len(events_chunk) < chunk_size: if len(events_chunk) < chunk_size:

View File

@@ -120,6 +120,7 @@ class UnifiProtectBackup:
use_experimental_downloader (bool): Use the new experimental downloader (the same method as used by the use_experimental_downloader (bool): Use the new experimental downloader (the same method as used by the
webUI) webUI)
parallel_uploads (int): Max number of parallel uploads to allow parallel_uploads (int): Max number of parallel uploads to allow
""" """
self.color_logging = color_logging self.color_logging = color_logging
setup_logging(verbose, self.color_logging) setup_logging(verbose, self.color_logging)
@@ -180,11 +181,11 @@ class UnifiProtectBackup:
verify_ssl=self.verify_ssl, verify_ssl=self.verify_ssl,
subscribed_models={ModelType.EVENT}, subscribed_models={ModelType.EVENT},
) )
self.ignore_cameras = ignore_cameras self.ignore_cameras = set(ignore_cameras)
self.cameras = cameras self.cameras = set(cameras)
self._download_queue: asyncio.Queue = asyncio.Queue() self._download_queue: asyncio.Queue = asyncio.Queue()
self._unsub: Callable[[], None] self._unsub: Callable[[], None]
self.detection_types = detection_types self.detection_types = set(detection_types)
self._has_ffprobe = False self._has_ffprobe = False
self._sqlite_path = sqlite_path self._sqlite_path = sqlite_path
self._db = None self._db = None

View File

@@ -45,6 +45,7 @@ class VideoUploader:
file_structure_format (str): format string for how to structure the uploaded files file_structure_format (str): format string for how to structure the uploaded files
db (aiosqlite.Connection): Async SQlite database connection db (aiosqlite.Connection): Async SQlite database connection
color_logging (bool): Whether or not to add color to logging output color_logging (bool): Whether or not to add color to logging output
""" """
self._protect: ProtectApiClient = protect self._protect: ProtectApiClient = protect
self.upload_queue: VideoQueue = upload_queue self.upload_queue: VideoQueue = upload_queue

View File

@@ -4,12 +4,13 @@ import asyncio
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, Set
from apprise import NotifyType from apprise import NotifyType
from async_lru import alru_cache from async_lru import alru_cache
from uiprotect import ProtectApiClient from uiprotect import ProtectApiClient
from uiprotect.data.nvr import Event from uiprotect.data.nvr import Event
from uiprotect.data.types import EventType, SmartDetectObjectType, SmartDetectAudioType
from unifi_protect_backup import notifications from unifi_protect_backup import notifications
@@ -454,3 +455,38 @@ async def wait_until(dt):
"""Sleep until the specified datetime.""" """Sleep until the specified datetime."""
now = datetime.now() now = datetime.now()
await asyncio.sleep((dt - now).total_seconds()) await asyncio.sleep((dt - now).total_seconds())
EVENT_TYPES_MAP = {
EventType.MOTION: {"motion"},
EventType.RING: {"ring"},
EventType.SMART_DETECT_LINE: {"line"},
EventType.FINGERPRINT_IDENTIFIED: {"fingerprint"},
EventType.NFC_CARD_SCANNED: {"nfc"},
EventType.SMART_DETECT: {t for t in SmartDetectObjectType.values() if t not in SmartDetectAudioType.values()},
EventType.SMART_AUDIO_DETECT: {f"{t}" for t in SmartDetectAudioType.values()},
}
def wanted_event_type(event, wanted_detection_types: Set[str], cameras: Set[str], ignore_cameras: Set[str]):
"""Return True if this event is one we want."""
if event.start is None or event.end is None:
return False # This event is still on-going
if event.camera_id in ignore_cameras:
return False
if cameras and event.camera_id not in cameras:
return False
if event.type not in EVENT_TYPES_MAP:
return False
if event.type in [EventType.SMART_DETECT, EventType.SMART_AUDIO_DETECT]:
detection_types = set(event.smart_detect_types)
else:
detection_types = EVENT_TYPES_MAP[event.type]
if not detection_types & wanted_detection_types: # No intersection
return False
return True