mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-31 07:27:57 +00:00
Guard lookups when adding/deleting cameras at runtime (#23994)
* Guard object processor queue handlers against unknown cameras * Skip embeddings post processing for removed cameras * End review segments for removed cameras * Drop queued autotracker moves for removed cameras * Release tracked event thumbnails when skipping a removed camera * Add locked accessors for camera states * Read camera states through the processor accessors * Guard output and recording paths against cameras not yet known * Resolve camera state once in ONVIF, notification, and transcription paths
This commit is contained in:
parent
a83219af56
commit
f0d7c1d7d4
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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."
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
227
frigate/test/test_camera_lifecycle.py
Normal file
227
frigate/test/test_camera_lifecycle.py
Normal file
@ -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)
|
||||
@ -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():
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user