diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 65a0f4825..ce16042b4 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -340,6 +340,8 @@ objects: review: # Optional: alerts configuration alerts: + # Optional: enables alerts for the camera (default: shown below) + enabled: True # Optional: labels that qualify as an alert (default: shown below) labels: - car @@ -352,6 +354,8 @@ review: - driveway # Optional: detections configuration detections: + # Optional: enables detections for the camera (default: shown below) + enabled: True # Optional: labels that qualify as a detection (default: all labels that are tracked / listened to) labels: - car diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index c344a5aaa..4eaf61919 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -316,6 +316,22 @@ Topic with current state of the PTZ autotracker for a camera. Published values a Topic to determine if PTZ autotracker is actively tracking an object. Published values are `ON` and `OFF`. +### `frigate//review_alerts/set` + +Topic to turn review alerts for a camera on or off. Expected values are `ON` and `OFF`. + +### `frigate//review_alerts/state` + +Topic with current state of review alerts for a camera. Published values are `ON` and `OFF`. + +### `frigate//review_detections/set` + +Topic to turn review detections for a camera on or off. Expected values are `ON` and `OFF`. + +### `frigate//review_detections/state` + +Topic with current state of review detections for a camera. Published values are `ON` and `OFF`. + ### `frigate//birdseye/set` Topic to turn Birdseye for a camera on and off. Expected values are `ON` and `OFF`. Birdseye mode diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index e0c2d96e3..61530d086 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -65,6 +65,8 @@ class Dispatcher: "snapshots": self._on_snapshots_command, "birdseye": self._on_birdseye_command, "birdseye_mode": self._on_birdseye_mode_command, + "review_alerts": self._on_alerts_command, + "review_detections": self._on_detections_command, } self._global_settings_handlers: dict[str, Callable] = { "notifications": self._on_global_notification_command, @@ -178,6 +180,8 @@ class Dispatcher: "autotracking": self.config.cameras[ camera ].onvif.autotracking.enabled, + "alerts": self.config.cameras[camera].review.alerts.enabled, + "detections": self.config.cameras[camera].review.detections.enabled, } self.publish("camera_activity", json.dumps(camera_status)) @@ -565,3 +569,47 @@ class Dispatcher: ), retain=True, ) + + def _on_alerts_command(self, camera_name: str, payload: str) -> None: + """Callback for alerts topic.""" + review_settings = self.config.cameras[camera_name].review + + if payload == "ON": + if not self.config.cameras[camera_name].review.alerts.enabled_in_config: + logger.error( + "Alerts must be enabled in the config to be turned on via MQTT." + ) + return + + if not review_settings.alerts.enabled: + logger.info(f"Turning on alerts for {camera_name}") + review_settings.alerts.enabled = True + elif payload == "OFF": + if review_settings.alerts.enabled: + logger.info(f"Turning off alerts for {camera_name}") + review_settings.alerts.enabled = False + + self.config_updater.publish(f"config/review/{camera_name}", review_settings) + self.publish(f"{camera_name}/review_alerts/state", payload, retain=True) + + def _on_detections_command(self, camera_name: str, payload: str) -> None: + """Callback for detections topic.""" + review_settings = self.config.cameras[camera_name].review + + if payload == "ON": + if not self.config.cameras[camera_name].review.detections.enabled_in_config: + logger.error( + "Detections must be enabled in the config to be turned on via MQTT." + ) + return + + if not review_settings.detections.enabled: + logger.info(f"Turning on detections for {camera_name}") + review_settings.detections.enabled = True + elif payload == "OFF": + if review_settings.detections.enabled: + logger.info(f"Turning off detections for {camera_name}") + review_settings.detections.enabled = False + + self.config_updater.publish(f"config/review/{camera_name}", review_settings) + self.publish(f"{camera_name}/review_detections/state", payload, retain=True) diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 57460b29b..9e11a0af1 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -107,6 +107,16 @@ class MqttClient(Communicator): # type: ignore[misc] ), retain=True, ) + self.publish( + f"{camera_name}/review_alerts/state", + "ON" if camera.review.alerts.enabled_in_config else "OFF", + retain=True, + ) + self.publish( + f"{camera_name}/review_detections/state", + "ON" if camera.review.detections.enabled_in_config else "OFF", + retain=True, + ) if self.config.notifications.enabled_in_config: self.publish( diff --git a/frigate/config/camera/review.py b/frigate/config/camera/review.py index 549c37db4..d8d26edb9 100644 --- a/frigate/config/camera/review.py +++ b/frigate/config/camera/review.py @@ -13,6 +13,8 @@ DEFAULT_ALERT_OBJECTS = ["person", "car"] class AlertsConfig(FrigateBaseModel): """Configure alerts""" + enabled: bool = Field(default=True, title="Enable alerts.") + labels: list[str] = Field( default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for." ) @@ -21,6 +23,10 @@ class AlertsConfig(FrigateBaseModel): title="List of required zones to be entered in order to save the event as an alert.", ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of alerts." + ) + @field_validator("required_zones", mode="before") @classmethod def validate_required_zones(cls, v): @@ -33,6 +39,8 @@ class AlertsConfig(FrigateBaseModel): class DetectionsConfig(FrigateBaseModel): """Configure detections""" + enabled: bool = Field(default=True, title="Enable detections.") + labels: Optional[list[str]] = Field( default=None, title="Labels to create detections for." ) @@ -41,6 +49,10 @@ class DetectionsConfig(FrigateBaseModel): title="List of required zones to be entered in order to save the event as a detection.", ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of detections." + ) + @field_validator("required_zones", mode="before") @classmethod def validate_required_zones(cls, v): diff --git a/frigate/config/config.py b/frigate/config/config.py index aea41a7bc..39ee31411 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -534,6 +534,12 @@ class FrigateConfig(FrigateBaseModel): camera_config.onvif.autotracking.enabled_in_config = ( camera_config.onvif.autotracking.enabled ) + camera_config.review.alerts.enabled_in_config = ( + camera_config.review.alerts.enabled + ) + camera_config.review.detections.enabled_in_config = ( + camera_config.review.detections.enabled + ) # Add default filters object_keys = camera_config.objects.track diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index ebc506c73..d49da5a97 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -187,7 +187,7 @@ class EventProcessor(threading.Thread): ) # keep these from being set back to false because the event - # may have started while recordings and snapshots were enabled + # may have started while recordings/snapshots/alerts/detections were enabled # this would be an issue for long running events if self.events_in_process[event_data["id"]]["has_clip"]: event_data["has_clip"] = True diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index c99479a67..158bc3ac4 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -148,7 +148,8 @@ class ReviewSegmentMaintainer(threading.Thread): # create communication for review segments self.requestor = InterProcessRequestor() - self.config_subscriber = ConfigSubscriber("config/record/") + self.record_config_subscriber = ConfigSubscriber("config/record/") + self.review_config_subscriber = ConfigSubscriber("config/review/") self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all) # manual events @@ -226,6 +227,13 @@ class ReviewSegmentMaintainer(threading.Thread): ) self.active_review_segments[segment.camera] = None + def end_segment(self, camera: str) -> None: + """End the pending segment for a camera.""" + segment = self.active_review_segments.get(camera) + if segment: + prev_data = segment.get_data(False) + self._publish_segment_end(segment, prev_data) + def update_existing_segment( self, segment: PendingReviewSegment, @@ -273,6 +281,7 @@ class ReviewSegmentMaintainer(threading.Thread): & set(camera_config.review.alerts.required_zones) ) ) + and camera_config.review.alerts.enabled ): segment.severity = SeverityEnum.alert should_update = True @@ -369,13 +378,14 @@ class ReviewSegmentMaintainer(threading.Thread): & set(camera_config.review.alerts.required_zones) ) ) + and camera_config.review.alerts.enabled ): severity = SeverityEnum.alert # if object is detection label # and review is not already a detection or alert # and has entered required zones or required zones is not set - # mark this review as alert + # mark this review as detection if ( not severity and ( @@ -390,6 +400,7 @@ class ReviewSegmentMaintainer(threading.Thread): & set(camera_config.review.detections.required_zones) ) ) + and camera_config.review.detections.enabled ): severity = SeverityEnum.detection @@ -430,15 +441,25 @@ class ReviewSegmentMaintainer(threading.Thread): # check if there is an updated config while True: ( - updated_topic, + updated_record_topic, updated_record_config, - ) = self.config_subscriber.check_for_update() + ) = self.record_config_subscriber.check_for_update() - if not updated_topic: + ( + updated_review_topic, + updated_review_config, + ) = self.review_config_subscriber.check_for_update() + + if not updated_record_topic and not updated_review_topic: break - camera_name = updated_topic.rpartition("/")[-1] - self.config.cameras[camera_name].record = updated_record_config + if updated_record_topic: + camera_name = updated_record_topic.rpartition("/")[-1] + self.config.cameras[camera_name].record = updated_record_config + + if updated_review_topic: + camera_name = updated_review_topic.rpartition("/")[-1] + self.config.cameras[camera_name].review = updated_review_config (topic, data) = self.detection_subscriber.check_for_update(timeout=1) @@ -475,12 +496,22 @@ class ReviewSegmentMaintainer(threading.Thread): if not self.config.cameras[camera].record.enabled: if current_segment: - self.update_existing_segment( - current_segment, frame_name, frame_time, [] - ) - + self.end_segment(camera) continue + # Check if the current segment should be processed based on enabled settings + if current_segment: + if ( + current_segment.severity == SeverityEnum.alert + and not self.config.cameras[camera].review.alerts.enabled + ) or ( + current_segment.severity == SeverityEnum.detection + and not self.config.cameras[camera].review.detections.enabled + ): + self.end_segment(camera) + continue + + # If we reach here, the segment can be processed (if it exists) if current_segment is not None: if topic == DetectionTypeEnum.video: self.update_existing_segment( @@ -496,20 +527,24 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment.last_update = frame_time for audio in audio_detections: - if audio in camera_config.review.alerts.labels: + if ( + audio in camera_config.review.alerts.labels + and camera_config.review.alerts.enabled + ): current_segment.audio.add(audio) current_segment.severity = SeverityEnum.alert elif ( camera_config.review.detections.labels is None or audio in camera_config.review.detections.labels - ): + ) and camera_config.review.detections.enabled: current_segment.audio.add(audio) elif topic == DetectionTypeEnum.api: if manual_info["state"] == ManualEventState.complete: current_segment.detections[manual_info["event_id"]] = ( manual_info["label"] ) - current_segment.severity = SeverityEnum.alert + if self.config.cameras[camera].review.alerts.enabled: + current_segment.severity = SeverityEnum.alert current_segment.last_update = manual_info["end_time"] elif manual_info["state"] == ManualEventState.start: self.indefinite_events[camera][manual_info["event_id"]] = ( @@ -518,7 +553,8 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment.detections[manual_info["event_id"]] = ( manual_info["label"] ) - current_segment.severity = SeverityEnum.alert + if self.config.cameras[camera].review.alerts.enabled: + current_segment.severity = SeverityEnum.alert # temporarily make it so this event can not end current_segment.last_update = sys.maxsize @@ -536,12 +572,16 @@ class ReviewSegmentMaintainer(threading.Thread): ) else: if topic == DetectionTypeEnum.video: - self.check_if_new_segment( - camera, - frame_name, - frame_time, - current_tracked_objects, - ) + if ( + self.config.cameras[camera].review.alerts.enabled + or self.config.cameras[camera].review.detections.enabled + ): + self.check_if_new_segment( + camera, + frame_name, + frame_time, + current_tracked_objects, + ) elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0: severity = None @@ -549,13 +589,16 @@ class ReviewSegmentMaintainer(threading.Thread): detections = set() for audio in audio_detections: - if audio in camera_config.review.alerts.labels: + if ( + audio in camera_config.review.alerts.labels + and camera_config.review.alerts.enabled + ): detections.add(audio) severity = SeverityEnum.alert elif ( camera_config.review.detections.labels is None or audio in camera_config.review.detections.labels - ): + ) and camera_config.review.detections.enabled: detections.add(audio) if not severity: @@ -572,28 +615,36 @@ class ReviewSegmentMaintainer(threading.Thread): detections, ) elif topic == DetectionTypeEnum.api: - self.active_review_segments[camera] = PendingReviewSegment( - camera, - frame_time, - SeverityEnum.alert, - {manual_info["event_id"]: manual_info["label"]}, - {}, - [], - set(), - ) - - if manual_info["state"] == ManualEventState.start: - self.indefinite_events[camera][manual_info["event_id"]] = ( - manual_info["label"] + if self.config.cameras[camera].review.alerts.enabled: + self.active_review_segments[camera] = PendingReviewSegment( + camera, + frame_time, + SeverityEnum.alert, + {manual_info["event_id"]: manual_info["label"]}, + {}, + [], + set(), ) - # temporarily make it so this event can not end - self.active_review_segments[camera].last_update = sys.maxsize - elif manual_info["state"] == ManualEventState.complete: - self.active_review_segments[camera].last_update = manual_info[ - "end_time" - ] - self.config_subscriber.stop() + if manual_info["state"] == ManualEventState.start: + self.indefinite_events[camera][manual_info["event_id"]] = ( + manual_info["label"] + ) + # temporarily make it so this event can not end + self.active_review_segments[ + camera + ].last_update = sys.maxsize + elif manual_info["state"] == ManualEventState.complete: + self.active_review_segments[ + camera + ].last_update = manual_info["end_time"] + else: + logger.warning( + f"Manual event API has been called for {camera}, but alerts are disabled. This manual event will not appear as an alert." + ) + + self.record_config_subscriber.stop() + self.review_config_subscriber.stop() self.requestor.stop() self.detection_subscriber.stop() logger.info("Exiting review maintainer...") diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index ea1aeedcb..ac57083df 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -72,18 +72,27 @@ class TrackedObject: def max_severity(self) -> Optional[str]: review_config = self.camera_config.review - if self.obj_data["label"] in review_config.alerts.labels and ( - not review_config.alerts.required_zones - or set(self.entered_zones) & set(review_config.alerts.required_zones) + if ( + self.camera_config.review.alerts.enabled + and self.obj_data["label"] in review_config.alerts.labels + and ( + not review_config.alerts.required_zones + or set(self.entered_zones) & set(review_config.alerts.required_zones) + ) ): return SeverityEnum.alert if ( - not review_config.detections.labels - or self.obj_data["label"] in review_config.detections.labels - ) and ( - not review_config.detections.required_zones - or set(self.entered_zones) & set(review_config.detections.required_zones) + self.camera_config.review.detections.enabled + and ( + not review_config.detections.labels + or self.obj_data["label"] in review_config.detections.labels + ) + and ( + not review_config.detections.required_zones + or set(self.entered_zones) + & set(review_config.detections.required_zones) + ) ): return SeverityEnum.detection diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 3ac7c9fee..a8cedf953 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -61,6 +61,8 @@ function useValue(): useValueReturn { notifications, notifications_suspended, autotracking, + alerts, + detections, } = // @ts-expect-error we know this is correct state["config"]; @@ -76,6 +78,10 @@ function useValue(): useValueReturn { cameraStates[`${name}/ptz_autotracker/state`] = autotracking ? "ON" : "OFF"; + cameraStates[`${name}/review_alerts/state`] = alerts ? "ON" : "OFF"; + cameraStates[`${name}/review_detections/state`] = detections + ? "ON" + : "OFF"; }); setWsState((prevState) => ({ @@ -213,6 +219,31 @@ export function useAutotrackingState(camera: string): { return { payload: payload as ToggleableSetting, send }; } +export function useAlertsState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/review_alerts/state`, `${camera}/review_alerts/set`); + return { payload: payload as ToggleableSetting, send }; +} + +export function useDetectionsState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/review_detections/state`, + `${camera}/review_detections/set`, + ); + return { payload: payload as ToggleableSetting, send }; +} + export function usePtzCommand(camera: string): { payload: string; send: (payload: string, retain?: boolean) => void; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 073308d58..263883976 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -179,6 +179,7 @@ export interface CameraConfig { }; review: { alerts: { + enabled: boolean; required_zones: string[]; labels: string[]; retain: { @@ -187,6 +188,7 @@ export interface CameraConfig { }; }; detections: { + enabled: boolean; required_zones: string[]; labels: string[]; retain: { diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index 30c6229e1..fa9d0ba58 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -27,6 +27,9 @@ import { LuExternalLink } from "react-icons/lu"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { MdCircle } from "react-icons/md"; import { cn } from "@/lib/utils"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { useAlertsState, useDetectionsState } from "@/api/ws"; type CameraSettingsViewProps = { selectedCamera: string; @@ -105,6 +108,11 @@ export default function CameraSettingsView({ const watchedAlertsZones = form.watch("alerts_zones"); const watchedDetectionsZones = form.watch("detections_zones"); + const { payload: alertsState, send: sendAlerts } = + useAlertsState(selectedCamera); + const { payload: detectionsState, send: sendDetections } = + useDetectionsState(selectedCamera); + const handleCheckedChange = useCallback( (isChecked: boolean) => { if (!isChecked) { @@ -244,6 +252,47 @@ export default function CameraSettingsView({ + + Review + + +
+
+ { + sendAlerts(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+
+ { + sendDetections(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+ Enable/disable alerts and detections for this camera. When + disabled, no new review items will be generated. +
+
+
+ + + Review Classification