diff --git a/frigate/api/chat.py b/frigate/api/chat.py index e8de6b7fd3..ff0a91ebe0 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -597,7 +597,7 @@ async def _execute_get_live_context( try: frame_processor = request.app.detected_frames_processor - camera_state = frame_processor.camera_states.get(camera) + camera_state = frame_processor.get_camera_state(camera) if camera_state is None: return { @@ -661,7 +661,7 @@ async def _get_live_frame_image_url( return None try: frame_processor = request.app.detected_frames_processor - if camera not in frame_processor.camera_states: + if frame_processor.get_camera_state(camera) is None: return None frame = frame_processor.get_current_frame(camera, {}) if frame is None: diff --git a/frigate/api/event.py b/frigate/api/event.py index 1597c18856..c6ea194391 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -1313,7 +1313,7 @@ async def set_sub_label( if request.app.detected_frames_processor: tracked_obj: TrackedObject = None - for state in request.app.detected_frames_processor.camera_states.values(): + for state in request.app.detected_frames_processor.get_camera_states(): tracked_obj = state.tracked_objects.get(event_id) if tracked_obj is not None: @@ -1372,7 +1372,7 @@ async def set_plate( if request.app.detected_frames_processor: tracked_obj: TrackedObject = None - for state in request.app.detected_frames_processor.camera_states.values(): + for state in request.app.detected_frames_processor.get_camera_states(): tracked_obj = state.tracked_objects.get(event_id) if tracked_obj is not None: diff --git a/frigate/api/media.py b/frigate/api/media.py index f4c5eb3fc8..2f4454f2c0 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -820,7 +820,7 @@ async def event_snapshot( # see if the object is currently being tracked try: camera_states: list[CameraState] = ( - request.app.detected_frames_processor.camera_states.values() + request.app.detected_frames_processor.get_camera_states() ) for camera_state in camera_states: if event_id in camera_state.tracked_objects: @@ -898,7 +898,7 @@ async def event_thumbnail( if thumbnail_bytes is None: # see if the object is currently being tracked try: - camera_states = request.app.detected_frames_processor.camera_states.values() + camera_states = request.app.detected_frames_processor.get_camera_states() for camera_state in camera_states: if event_id in camera_state.tracked_objects: tracked_obj = camera_state.tracked_objects.get(event_id) @@ -1127,7 +1127,7 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool = # see if the object is currently being tracked try: camera_states = ( - request.app.detected_frames_processor.camera_states.values() + request.app.detected_frames_processor.get_camera_states() ) for camera_state in camera_states: if event_id in camera_state.tracked_objects: diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index 34a0e57508..a4aab58df8 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -220,7 +220,9 @@ class WebPushClient(Communicator): if topic == "reviews": decoded = json.loads(payload) camera = decoded["before"]["camera"] - if not self.config.cameras[camera].notifications.enabled: + camera_config = self.config.cameras.get(camera) + + if camera_config is None or not camera_config.notifications.enabled: return if self.is_camera_suspended(camera): logger.debug(f"Notifications for {camera} are currently suspended.") @@ -234,13 +236,14 @@ class WebPushClient(Communicator): # ensure notifications are enabled and the specific trigger has # notification action enabled + camera_config = self.config.cameras.get(camera) + if ( - not self.config.cameras[camera].notifications.enabled - or name not in self.config.cameras[camera].semantic_search.triggers + camera_config is None + or not camera_config.notifications.enabled + or name not in camera_config.semantic_search.triggers or "notification" - not in self.config.cameras[camera] - .semantic_search.triggers[name] - .actions + not in camera_config.semantic_search.triggers[name].actions ): return @@ -251,7 +254,9 @@ class WebPushClient(Communicator): elif topic == "camera_monitoring": decoded = json.loads(payload) camera = decoded["camera"] - if not self.config.cameras[camera].notifications.enabled: + camera_config = self.config.cameras.get(camera) + + if camera_config is None or not camera_config.notifications.enabled: return if self.is_camera_suspended(camera): logger.debug(f"Notifications for {camera} are currently suspended.") diff --git a/frigate/data_processing/post/audio_transcription.py b/frigate/data_processing/post/audio_transcription.py index 16895dc873..4e921f6833 100644 --- a/frigate/data_processing/post/audio_transcription.py +++ b/frigate/data_processing/post/audio_transcription.py @@ -83,6 +83,10 @@ class AudioTranscriptionPostProcessor(PostProcessorApi): """ event_id = data["event_id"] camera_name = data["camera"] + camera_config = self.config.cameras.get(camera_name) + + if camera_config is None: + return if data_type == PostProcessDataEnum.recording: start_ts = data["frame_time"] @@ -104,7 +108,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi): try: audio_data = get_audio_from_recording( - self.config.cameras[camera_name].ffmpeg, + camera_config.ffmpeg, camera_name, start_ts, end_ts, diff --git a/frigate/data_processing/post/object_descriptions.py b/frigate/data_processing/post/object_descriptions.py index 9612ce7be9..122f5a9fbe 100644 --- a/frigate/data_processing/post/object_descriptions.py +++ b/frigate/data_processing/post/object_descriptions.py @@ -151,7 +151,12 @@ class ObjectDescriptionProcessor(PostProcessorApi): logger.error(f"Event {event_id} not found for description regeneration") return - camera_config = self.config.cameras[str(event.camera)] + camera_config = self.config.cameras.get(str(event.camera)) + + if camera_config is None: + logger.error("Camera %s no longer exists", event.camera) + return + if not camera_config.objects.genai.enabled and not force: logger.error(f"GenAI not enabled for camera {event.camera}") return diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index 3740ac25f7..b3a390c00e 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -137,7 +137,10 @@ class ReviewDescriptionProcessor(PostProcessorApi): return camera = data["after"]["camera"] - camera_config = self.config.cameras[camera] + camera_config = self.config.cameras.get(camera) + + if camera_config is None: + return if not camera_config.review.genai.enabled: return diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index a91e162e7d..03309a7ad4 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -609,6 +609,18 @@ class EmbeddingMaintainer(threading.Thread): # Embed the thumbnail self._embed_thumbnail(event_id, thumbnail) + # every post processor below reads config.cameras[camera], but + # tracked_events still has to be released or the thumbnails held + # for this event leak, same as the two exits above + if camera not in self.config.cameras: + logger.debug("Skipping post processing for removed camera %s", camera) + + for processor in self.post_processors: + if isinstance(processor, ObjectDescriptionProcessor): + processor.cleanup_event(event_id) + + continue + # call any defined post processors for processor in self.post_processors: if isinstance(processor, LicensePlatePostProcessor): @@ -666,11 +678,18 @@ class EmbeddingMaintainer(threading.Thread): to_remove = [] for id, data in self.detected_license_plates.items(): + camera_config = self.config.cameras.get(data["camera"]) + + if camera_config is None: + # camera was removed, drop the entry rather than expiring it + to_remove.append(id) + continue + last_seen = data.get("last_seen", 0) if not last_seen: continue - if now - last_seen > self.config.cameras[data["camera"]].lpr.expire_time: + if now - last_seen > camera_config.lpr.expire_time: to_remove.append(id) for id in to_remove: self.event_metadata_publisher.publish( diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index a8bdab1d82..72d60f163b 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -747,25 +747,29 @@ class BirdsEyeFrameManager: return False, False force_update = False + camera_state = self.cameras.get(camera) + + if camera_state is None: + return False, False # disabling birdseye is a little tricky if not camera_config.birdseye.enabled or not camera_config.enabled: # if we've rendered a frame (we have a value for last_active_frame) # then we need to set it to zero - if self.cameras[camera]["last_active_frame"] > 0: - self.cameras[camera]["last_active_frame"] = 0 + if camera_state["last_active_frame"] > 0: + camera_state["last_active_frame"] = 0 force_update = True else: return False, False # update the last active frame for the camera - self.cameras[camera]["current_frame"] = frame.copy() - self.cameras[camera]["current_frame_time"] = frame_time + camera_state["current_frame"] = frame.copy() + camera_state["current_frame_time"] = frame_time if self.camera_active( camera_config.birdseye.mode, activity, ): - self.cameras[camera]["last_active_frame"] = frame_time + camera_state["last_active_frame"] = frame_time now = datetime.datetime.now().timestamp() diff --git a/frigate/output/output.py b/frigate/output/output.py index 0793c1cd15..1d8b473713 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -51,8 +51,12 @@ def check_disabled_camera_update( for camera, last_update in write_times.items(): offline_time = now - last_update + camera_config = config.cameras.get(camera) - if config.cameras[camera].enabled: + if camera_config is None: + continue + + if camera_config.enabled: has_enabled_camera = True else: # flag camera as offline when it is disabled @@ -62,8 +66,8 @@ def check_disabled_camera_update( # last camera update was more than 1 second ago # need to send empty data to birdseye because current # frame is now out of date - cam_width = config.cameras[camera].detect.width - cam_height = config.cameras[camera].detect.height + cam_width = camera_config.detect.width + cam_height = camera_config.detect.height if cam_width is None or cam_height is None: raise ValueError(f"Camera {camera} detect dimensions not configured") @@ -309,10 +313,11 @@ class OutputProcess(FrigateProcess): regions, ) = data - frame = frame_manager.get( - frame_name, self.config.cameras[camera].frame_shape_yuv - ) - frame_manager.close(frame_name) + camera_config = self.config.cameras.get(camera) + + if camera_config is not None: + frame_manager.get(frame_name, camera_config.frame_shape_yuv) + frame_manager.close(frame_name) detection_subscriber.stop() diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index e9bbda9889..efe8fd5642 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -799,14 +799,24 @@ class PtzAutoTracker: except TimeoutError: continue + # both are popped when the camera is deleted, so resolve them once + # here and use the locals for the rest of the move; a move already + # in flight then finishes against valid objects + metrics = self.ptz_metrics.get(camera) + camera_config = self.config.cameras.get(camera) + + if metrics is None or camera_config is None: + logger.debug("%s: Dropping queued move, camera was removed", camera) + continue + async with self.move_queue_locks[camera]: frame_time, pan, tilt, zoom = move_data # if we're receiving move requests during a PTZ move, ignore them if ptz_moving_at_frame_time( frame_time, - self.ptz_metrics[camera].start_time.value, - self.ptz_metrics[camera].stop_time.value, + metrics.start_time.value, + metrics.stop_time.value, ): logger.debug( f"{camera}: Move queue: PTZ moving, dequeueing move request - frame time: {frame_time}, final pan: {pan}, final tilt: {tilt}, final zoom: {zoom}" @@ -815,7 +825,7 @@ class PtzAutoTracker: else: if ( - self.config.cameras[camera].onvif.autotracking.zooming + camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative ): await self.onvif._move_relative(camera, pan, tilt, zoom, 1) @@ -824,25 +834,22 @@ class PtzAutoTracker: await self.onvif._move_relative(camera, pan, tilt, 0, 1) # Wait until the camera finishes moving - while not self.ptz_metrics[camera].motor_stopped.is_set(): + while not metrics.motor_stopped.is_set(): await self.onvif.get_camera_status(camera) - if ( - zoom > 0 - and self.ptz_metrics[camera].zoom_level.value != zoom - ): + if zoom > 0 and metrics.zoom_level.value != zoom: await self.onvif._zoom_absolute(camera, zoom, 1) # Wait until the camera finishes moving - while not self.ptz_metrics[camera].motor_stopped.is_set(): + while not metrics.motor_stopped.is_set(): await self.onvif.get_camera_status(camera) - if self.config.cameras[camera].onvif.autotracking.movement_weights: + if camera_config.onvif.autotracking.movement_weights: logger.debug( f"{camera}: Predicted movement time: {self._predict_movement_time(camera, pan, tilt)}" ) logger.debug( - f"{camera}: Actual movement time: {self.ptz_metrics[camera].stop_time.value - self.ptz_metrics[camera].start_time.value}" + f"{camera}: Actual movement time: {metrics.stop_time.value - metrics.start_time.value}" ) # save metrics for better estimate calculations @@ -851,21 +858,15 @@ class PtzAutoTracker: and len(self.move_metrics[camera]) < AUTOTRACKING_MAX_MOVE_METRICS and (pan != 0 or tilt != 0) - and self.config.cameras[ - camera - ].onvif.autotracking.calibrate_on_startup + and camera_config.onvif.autotracking.calibrate_on_startup ): logger.debug(f"{camera}: Adding new values to move metrics") self.move_metrics[camera].append( { "pan": pan, "tilt": tilt, - "start_timestamp": self.ptz_metrics[ - camera - ].start_time.value, - "end_timestamp": self.ptz_metrics[ - camera - ].stop_time.value, + "start_timestamp": metrics.start_time.value, + "end_timestamp": metrics.stop_time.value, } ) diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 6b7e56022d..fe488def72 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -180,6 +180,11 @@ class OnvifController: return False async def _init_onvif(self, camera_name: str) -> bool: + camera_config = self.config.cameras.get(camera_name) + + if camera_config is None: + return False + onvif: ONVIFCamera = self.cams[camera_name]["onvif"] try: await onvif.update_xaddrs() @@ -235,7 +240,7 @@ class OnvifController: p.token, ) - configured_profile = self.config.cameras[camera_name].onvif.profile + configured_profile = camera_config.onvif.profile profile = None if configured_profile is not None: @@ -339,7 +344,7 @@ class OnvifController: except (AttributeError, TypeError): fov_space_id = None - autotracking_config = self.config.cameras[camera_name].onvif.autotracking + autotracking_config = camera_config.onvif.autotracking autotracking_enabled = ( autotracking_config.enabled_in_config and autotracking_config.enabled ) @@ -614,6 +619,11 @@ class OnvifController: logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).") return + metrics = self.ptz_metrics.get(camera_name) + + if metrics is None: + return + logger.debug( f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}" ) @@ -627,15 +637,11 @@ class OnvifController: self.cams[camera_name]["active"] = True # only track start_time for autotracking - if self.ptz_metrics[camera_name].autotracker_enabled.value: - self.ptz_metrics[camera_name].motor_stopped.clear() - logger.debug( - f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}" - ) - self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[ - camera_name - ].frame_time.value - self.ptz_metrics[camera_name].stop_time.value = 0 + if metrics.autotracker_enabled.value: + metrics.motor_stopped.clear() + logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}") + metrics.start_time.value = metrics.frame_time.value + metrics.stop_time.value = 0 move_request = self.cams[camera_name]["relative_move_request"] @@ -697,9 +703,14 @@ class OnvifController: logger.error(f"{preset} is not a valid preset for {camera_name}") return + metrics = self.ptz_metrics.get(camera_name) + + if metrics is None: + return + self.cams[camera_name]["active"] = True - self.ptz_metrics[camera_name].start_time.value = 0 - self.ptz_metrics[camera_name].stop_time.value = 0 + metrics.start_time.value = 0 + metrics.stop_time.value = 0 move_request = self.cams[camera_name]["move_request"] preset_token = self.cams[camera_name]["presets"][preset] @@ -738,6 +749,11 @@ class OnvifController: logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.") return + metrics = self.ptz_metrics.get(camera_name) + + if metrics is None: + return + logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}") if self.cams[camera_name]["active"]: @@ -747,14 +763,10 @@ class OnvifController: return self.cams[camera_name]["active"] = True - self.ptz_metrics[camera_name].motor_stopped.clear() - logger.debug( - f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}" - ) - self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[ - camera_name - ].frame_time.value - self.ptz_metrics[camera_name].stop_time.value = 0 + metrics.motor_stopped.clear() + logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}") + metrics.start_time.value = metrics.frame_time.value + metrics.stop_time.value = 0 move_request = self.cams[camera_name]["absolute_move_request"] # function takes in 0 to 1 for zoom, interpolate to the values of the camera. @@ -875,16 +887,18 @@ class OnvifController: Returns camera details including features and presets if available. """ - if not self.config.cameras[camera_name].enabled: + camera_config = self.config.cameras.get(camera_name) + + if camera_config is None: + return {} + + if not camera_config.enabled: logger.debug( f"Camera {camera_name} disabled, won't try to initialize ONVIF" ) return {} - if camera_name not in self.cams.keys() and ( - camera_name not in self.config.cameras - or not self.config.cameras[camera_name].onvif.host - ): + if camera_name not in self.cams.keys() and (not camera_config.onvif.host): logger.debug(f"ONVIF is not configured for {camera_name}") return {} @@ -985,6 +999,12 @@ class OnvifController: logger.error(f"ONVIF is not configured for {camera_name}") return + metrics = self.ptz_metrics.get(camera_name) + camera_config = self.config.cameras.get(camera_name) + + if metrics is None or camera_config is None: + return + if not self.cams[camera_name]["init"]: if not await self._init_onvif(camera_name): return @@ -1023,36 +1043,29 @@ class OnvifController: zoom_status is None or zoom_status == "IDLE" ): self.cams[camera_name]["active"] = False - if not self.ptz_metrics[camera_name].motor_stopped.is_set(): - self.ptz_metrics[camera_name].motor_stopped.set() + if not metrics.motor_stopped.is_set(): + metrics.motor_stopped.set() logger.debug( - f"{camera_name}: PTZ stop time: {self.ptz_metrics[camera_name].frame_time.value}" + f"{camera_name}: PTZ stop time: {metrics.frame_time.value}" ) - self.ptz_metrics[camera_name].stop_time.value = self.ptz_metrics[ - camera_name - ].frame_time.value + metrics.stop_time.value = metrics.frame_time.value else: self.cams[camera_name]["active"] = True - if self.ptz_metrics[camera_name].motor_stopped.is_set(): - self.ptz_metrics[camera_name].motor_stopped.clear() + if metrics.motor_stopped.is_set(): + metrics.motor_stopped.clear() logger.debug( - f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}" + f"{camera_name}: PTZ start time: {metrics.frame_time.value}" ) - self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[ - camera_name - ].frame_time.value - self.ptz_metrics[camera_name].stop_time.value = 0 + metrics.start_time.value = metrics.frame_time.value + metrics.stop_time.value = 0 - if ( - self.config.cameras[camera_name].onvif.autotracking.zooming - != ZoomingModeEnum.disabled - ): + if camera_config.onvif.autotracking.zooming != ZoomingModeEnum.disabled: # store absolute zoom level as 0 to 1 interpolated from the values of the camera - self.ptz_metrics[camera_name].zoom_level.value = numpy.interp( + metrics.zoom_level.value = numpy.interp( round(status.Position.Zoom.x, 2), [ self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"], @@ -1061,25 +1074,22 @@ class OnvifController: [0, 1], ) logger.debug( - f"{camera_name}: Camera zoom level: {self.ptz_metrics[camera_name].zoom_level.value}" + f"{camera_name}: Camera zoom level: {metrics.zoom_level.value}" ) # some hikvision cams won't update MoveStatus, so warn if it hasn't changed if ( - not self.ptz_metrics[camera_name].motor_stopped.is_set() - and not self.ptz_metrics[camera_name].reset.is_set() - and self.ptz_metrics[camera_name].start_time.value != 0 - and self.ptz_metrics[camera_name].frame_time.value - > (self.ptz_metrics[camera_name].start_time.value + 10) - and self.ptz_metrics[camera_name].stop_time.value == 0 + not metrics.motor_stopped.is_set() + and not metrics.reset.is_set() + and metrics.start_time.value != 0 + and metrics.frame_time.value > (metrics.start_time.value + 10) + and metrics.stop_time.value == 0 ): logger.debug( - f"Start time: {self.ptz_metrics[camera_name].start_time.value}, Stop time: {self.ptz_metrics[camera_name].stop_time.value}, Frame time: {self.ptz_metrics[camera_name].frame_time.value}" + f"Start time: {metrics.start_time.value}, Stop time: {metrics.stop_time.value}, Frame time: {metrics.frame_time.value}" ) # set the stop time so we don't come back into this again and spam the logs - self.ptz_metrics[camera_name].stop_time.value = self.ptz_metrics[ - camera_name - ].frame_time.value + metrics.stop_time.value = metrics.frame_time.value logger.warning( f"Camera {camera_name} is still in ONVIF 'MOVING' status." ) diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index 7f9dbc19da..50a24a85fb 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -745,7 +745,9 @@ class RecordingMaintainer(threading.Thread): regions, ) = data - if self.config.cameras[camera].record.enabled: + camera_config = self.config.cameras.get(camera) + + if camera_config is not None and camera_config.record.enabled: self.object_recordings_info[camera].append( ( frame_time, @@ -762,7 +764,9 @@ class RecordingMaintainer(threading.Thread): audio_detections, ) = data - if self.config.cameras[camera].record.enabled: + camera_config = self.config.cameras.get(camera) + + if camera_config is not None and camera_config.record.enabled: self.audio_recordings_info[camera].append( ( frame_time, diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index ea892a18f9..cce8b440e3 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -418,6 +418,11 @@ class ReviewSegmentMaintainer(threading.Thread): return None + def _handle_camera_removed(self, camera: str) -> None: + """Close out a deleted camera's segment so a reused name cannot inherit it.""" + self.forcibly_end_segment(camera) + self.indefinite_events.pop(camera, None) + def update_existing_segment( self, segment: PendingReviewSegment, @@ -666,6 +671,10 @@ class ReviewSegmentMaintainer(threading.Thread): for camera in updated_topics["enabled"]: self.forcibly_end_segment(camera) + if "remove" in updated_topics: + for camera in updated_topics["remove"]: + self._handle_camera_removed(camera) + result = self.detection_subscriber.check_for_update(timeout=1) if not result: diff --git a/frigate/test/test_camera_lifecycle.py b/frigate/test/test_camera_lifecycle.py new file mode 100644 index 0000000000..d9ce8ae537 --- /dev/null +++ b/frigate/test/test_camera_lifecycle.py @@ -0,0 +1,227 @@ +"""Regression tests for runtime camera add and delete handling.""" + +import asyncio +import threading +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock + +# LicensePlatePostProcessor is imported via the maintainer rather than from +# data_processing.post.license_plate, which circularly imports back through +# frigate.embeddings before that package finishes initializing +from frigate.embeddings.maintainer import ( + EmbeddingMaintainer, + LicensePlatePostProcessor, +) +from frigate.ptz.autotrack import PtzAutoTracker +from frigate.review.maintainer import ReviewSegmentMaintainer +from frigate.track.object_processing import TrackedObjectProcessor + + +def _make_processor() -> TrackedObjectProcessor: + """Build a processor with no cameras, bypassing __init__.""" + processor = TrackedObjectProcessor.__new__(TrackedObjectProcessor) + processor.camera_states = {} + processor.camera_states_lock = threading.Lock() + processor.config = SimpleNamespace(cameras={}) + processor.event_sender = MagicMock() + processor.detection_publisher = MagicMock() + processor.ongoing_manual_events = {} + return processor + + +class TestObjectProcessorUnknownCamera(unittest.TestCase): + def test_save_lpr_snapshot_ignores_unknown_camera(self): + processor = _make_processor() + + # 1x1 png, base64; decoding must not be what fails + payload = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "1234.5-abcdef", + "deleted_cam", + ) + + processor.save_lpr_snapshot(payload) + + processor.event_sender.publish.assert_not_called() + + def test_create_manual_event_ignores_unknown_camera(self): + processor = _make_processor() + + payload = ( + 1234.5, + "deleted_cam", + "person", + "1234.5-abcdef", + True, + 0.9, + None, + None, + "api", + False, + None, + ) + + processor.create_manual_event(payload) + + processor.event_sender.publish.assert_not_called() + self.assertEqual(processor.ongoing_manual_events, {}) + + def test_create_lpr_event_ignores_unknown_camera(self): + processor = _make_processor() + + payload = ( + 1234.5, + "deleted_cam", + "license_plate", + "1234.5-abcdef", + True, + 0.9, + None, + "ABC123", + ) + + processor.create_lpr_event(payload) + + processor.event_sender.publish.assert_not_called() + self.assertEqual(processor.ongoing_manual_events, {}) + + def test_create_manual_event_ignores_camera_added_but_not_yet_drained(self): + """The add window: present in config.cameras, absent from camera_states. + + debug_replay writes the camera into the shared config before publishing + add, so a guard on config.cameras passes here and falls through to + camera_states. This test fails against such a guard. + """ + processor = _make_processor() + processor.config = SimpleNamespace( + cameras={ + "new_cam": SimpleNamespace( + record=SimpleNamespace(event_pre_capture=5, enabled=True) + ) + } + ) + + payload = ( + 1234.5, + "new_cam", + "person", + "1234.5-abcdef", + True, + 0.9, + None, + None, + "api", + False, + None, + ) + + processor.create_manual_event(payload) + + processor.event_sender.publish.assert_not_called() + + +class TestEmbeddingsUnknownCamera(unittest.TestCase): + def _make_maintainer(self) -> EmbeddingMaintainer: + maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer) + maintainer.config = SimpleNamespace(cameras={}) + maintainer.event_end_subscriber = MagicMock() + maintainer.realtime_processors = [MagicMock()] + # spec is required: the dispatch loop is a chain of isinstance checks, + # and a bare MagicMock matches none of them, so the crashing branch + # would never run and the test would pass against unfixed code + maintainer.post_processors = [MagicMock(spec=LicensePlatePostProcessor)] + maintainer.detected_license_plates = {"1234.5-abcdef": {"obj_data": {}}} + maintainer.recordings_available_through = {"deleted_cam": 1234.5} + maintainer.event_metadata_publisher = MagicMock() + return maintainer + + def test_process_finalized_skips_unknown_camera(self): + maintainer = self._make_maintainer() + # updated_db=False bypasses the Event.get branch, which would hit the + # database and mask the KeyError this test is about + maintainer.event_end_subscriber.check_for_update.side_effect = [ + ("1234.5-abcdef", "deleted_cam", False), + None, + ] + + maintainer._process_finalized() + + maintainer.post_processors[0].process_data.assert_not_called() + + def test_process_finalized_still_expires_realtime_state(self): + """The guard must not skip per-event cleanup, only post processing.""" + maintainer = self._make_maintainer() + maintainer.event_end_subscriber.check_for_update.side_effect = [ + ("1234.5-abcdef", "deleted_cam", False), + None, + ] + + maintainer._process_finalized() + + maintainer.realtime_processors[0].expire_object.assert_called_once_with( + "1234.5-abcdef", "deleted_cam" + ) + + def test_expire_dedicated_lpr_drops_entry_for_unknown_camera(self): + maintainer = self._make_maintainer() + maintainer.detected_license_plates = { + "1234.5-abcdef": {"camera": "deleted_cam", "last_seen": 1.0} + } + + maintainer._expire_dedicated_lpr() + + self.assertEqual(maintainer.detected_license_plates, {}) + + +class TestReviewMaintainerRemoval(unittest.TestCase): + def test_camera_removal_ends_segment_and_clears_state(self): + maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer) + maintainer.active_review_segments = {"deleted_cam": MagicMock()} + maintainer.indefinite_events = {"deleted_cam": {"1234.5-abcdef": 1.0}} + maintainer.forcibly_end_segment = MagicMock() + + maintainer._handle_camera_removed("deleted_cam") + + maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam") + self.assertNotIn("deleted_cam", maintainer.indefinite_events) + + +class TestAutotrackerMoveQueue(unittest.TestCase): + def test_move_queue_drops_move_for_removed_camera(self): + tracker = PtzAutoTracker.__new__(PtzAutoTracker) + tracker.stop_event = MagicMock() + # one pass through the loop, then stop + tracker.stop_event.is_set.side_effect = [False, True] + tracker.ptz_metrics = {} + tracker.move_queues = {"deleted_cam": asyncio.Queue()} + tracker.move_queue_locks = {"deleted_cam": asyncio.Lock()} + tracker.onvif = MagicMock() + tracker.config = SimpleNamespace(cameras={}) + tracker.move_queues["deleted_cam"].put_nowait((1234.5, 0.1, 0.1, 0.0)) + + asyncio.run(tracker._process_move_queue("deleted_cam")) + + tracker.onvif._move_relative.assert_not_called() + + +class TestCameraStateAccessors(unittest.TestCase): + def test_get_camera_state_returns_none_for_unknown_camera(self): + processor = _make_processor() + + self.assertIsNone(processor.get_camera_state("deleted_cam")) + + def test_get_camera_states_returns_a_snapshot_not_a_view(self): + """A live values() view raises RuntimeError if the writer pops mid-iteration.""" + processor = _make_processor() + processor.camera_states = {"one": MagicMock(), "two": MagicMock()} + + states = processor.get_camera_states() + processor.camera_states.pop("one") + + self.assertEqual(len(states), 2) + + def test_get_current_frame_time_is_zero_for_unknown_camera(self): + processor = _make_processor() + + self.assertEqual(processor.get_current_frame_time("deleted_cam"), 0.0) diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index 999ec5c04b..df2c0e575e 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -68,6 +68,7 @@ class TrackedObjectProcessor(threading.Thread): self.tracked_objects_queue = tracked_objects_queue self.stop_event: MpEvent = stop_event self.camera_states: dict[str, CameraState] = {} + self.camera_states_lock = threading.Lock() self.frame_manager = SharedMemoryFrameManager() self.last_motion_detected: dict[str, float] = {} self.ptz_autotracker_thread = ptz_autotracker_thread @@ -236,7 +237,9 @@ class TrackedObjectProcessor(threading.Thread): camera_state.on("end", end) camera_state.on("snapshot", snapshot) camera_state.on("camera_activity", camera_activity) - self.camera_states[camera] = camera_state + + with self.camera_states_lock: + self.camera_states[camera] = camera_state def should_save_snapshot(self, camera: str, obj: TrackedObject) -> bool: if obj.false_positive: @@ -324,9 +327,22 @@ class TrackedObjectProcessor(threading.Thread): # reset the last_motion so redundant `off` commands aren't sent self.last_motion_detected[camera] = 0 + def get_camera_state(self, camera: str) -> CameraState | None: + """Returns the state for a camera, or None if it does not exist.""" + with self.camera_states_lock: + return self.camera_states.get(camera) + + def get_camera_states(self) -> list[CameraState]: + """Returns a snapshot of camera states that is safe to iterate.""" + with self.camera_states_lock: + return list(self.camera_states.values()) + def get_best(self, camera: str, label: str) -> dict[str, Any]: - # TODO: need a lock here - camera_state = self.camera_states[camera] + camera_state = self.get_camera_state(camera) + + if camera_state is None: + return {} + if label in camera_state.best_objects: best_obj = camera_state.best_objects[label] @@ -350,17 +366,21 @@ class TrackedObjectProcessor(threading.Thread): (self.config.birdseye.height * 3 // 2, self.config.birdseye.width), ) - if camera not in self.camera_states: + camera_state = self.get_camera_state(camera) + + if camera_state is None: return None - return self.camera_states[camera].get_current_frame(draw_options) + return camera_state.get_current_frame(draw_options) def get_current_frame_time(self, camera: str) -> float: """Returns the latest frame time for a given camera.""" - if camera not in self.camera_states: + camera_state = self.get_camera_state(camera) + + if camera_state is None: return 0.0 - return self.camera_states[camera].current_frame_time + return camera_state.current_frame_time def set_sub_label( self, event_id: str, sub_label: str | None, score: float | None @@ -498,14 +518,18 @@ class TrackedObjectProcessor(threading.Thread): # save the snapshot image (frame, event_id, camera) = payload + camera_state = self.camera_states.get(camera) + + if camera_state is None: + logger.debug("Discarding LPR snapshot for unknown camera %s", camera) + return + img = cv2.imdecode( np.frombuffer(base64.b64decode(frame), dtype=np.uint8), cv2.IMREAD_COLOR, ) - self.camera_states[camera].save_manual_event_image( - img, event_id, "license_plate", {} - ) + camera_state.save_manual_event_image(img, event_id, "license_plate", {}) def create_manual_event(self, payload: tuple) -> None: ( @@ -522,13 +546,17 @@ class TrackedObjectProcessor(threading.Thread): pre_capture, ) = payload + camera_state = self.camera_states.get(camera_name) + + if camera_state is None: + logger.debug("Discarding manual event for unknown camera %s", camera_name) + return + # save the snapshot image - self.camera_states[camera_name].save_manual_event_image( - None, event_id, label, draw - ) + camera_state.save_manual_event_image(None, event_id, label, draw) end_time = frame_time + duration if duration is not None else None start_time = ( - frame_time - self.config.cameras[camera_name].record.event_pre_capture + frame_time - camera_state.camera_config.record.event_pre_capture if pre_capture is None else frame_time - pre_capture ) @@ -548,7 +576,7 @@ class TrackedObjectProcessor(threading.Thread): "camera": camera_name, "start_time": start_time, "end_time": end_time, - "has_clip": self.config.cameras[camera_name].record.enabled + "has_clip": camera_state.camera_config.record.enabled and include_recording, "has_snapshot": True, "snapshot_clean": True, @@ -591,6 +619,12 @@ class TrackedObjectProcessor(threading.Thread): plate, ) = payload + camera_state = self.camera_states.get(camera_name) + + if camera_state is None: + logger.debug("Discarding LPR event for unknown camera %s", camera_name) + return + # send event to event maintainer self.event_sender.publish( ( @@ -605,9 +639,9 @@ class TrackedObjectProcessor(threading.Thread): "score": score, "camera": camera_name, "start_time": frame_time - - self.config.cameras[camera_name].record.event_pre_capture, + - camera_state.camera_config.record.event_pre_capture, "end_time": None, - "has_clip": self.config.cameras[camera_name].record.enabled + "has_clip": camera_state.camera_config.record.enabled and include_recording, "has_snapshot": True, "snapshot_clean": True, @@ -699,7 +733,10 @@ class TrackedObjectProcessor(threading.Thread): continue camera_state.shutdown() - self.camera_states.pop(camera) + + with self.camera_states_lock: + self.camera_states.pop(camera) + self.camera_activity.pop(camera, None) self.last_motion_detected.pop(camera, None) @@ -715,8 +752,6 @@ class TrackedObjectProcessor(threading.Thread): if camera_state is None: continue - camera_state = self.camera_states[camera] - if camera_state.prev_enabled and not current_enabled: logger.debug(f"Not processing objects for disabled camera {camera}") self.force_end_all_events(camera, camera_state) @@ -812,7 +847,11 @@ class TrackedObjectProcessor(threading.Thread): break event_id, camera, _ = update - self.camera_states[camera].finished(event_id) + camera_state = self.camera_states.get(camera) + + # the camera may have been removed while its event was pending + if camera_state is not None: + camera_state.finished(event_id) # shut down camera states for state in self.camera_states.values():