Compare commits

..
Author SHA1 Message Date
Josh Hawkins 920e8a1c9f make max target box a function of zoom factor 2026-10-04 07:29:14 -05:00
Josh Hawkins ed8367e51e fix autotracking debug overlay max target box lookup 2026-10-04 07:24:37 -05:00
Josh Hawkins e8a3c870f1 compute max target box from live zoom factor 2026-10-04 07:20:24 -05:00
Josh Hawkins 30012f89fc use camera config for autotracking enabled state
Camera processes read autotracking state from a shared `autotracker_enabled` value that the dispatcher and autotracker had to keep in sync with the config by hand. Camera processes now subscribe to the `autotracking` and `onvif` config updates and read `onvif.autotracking.enabled` directly, so the shared value and the autotracker's mirroring method are gone. `_disable` now publishes its change so the camera process hears about it. Also removes `tracking_active`, which was set and cleared but never read.
2026-10-03 16:11:28 -05:00
Josh Hawkins cc1ff63b53 simplify onvif and autotracking code
Removes about 230 lines from the ONVIF controller and autotracker without changing how PTZ moves are calculated. `OnvifController` no longer keeps its own `camera_configs` copy of the camera config, the cached `GetStatus`, `GetServiceCapabilities`, and `AbsoluteMove` request objects are gone in favor of plain dicts at the call site, and `PtzAutoTrackerThread` is merged into `PtzAutoTracker`. Repeated blocks in the autotracker (waiting for the motor to stop, disabling autotracking on a failed setup step) are now single helpers.
2026-10-03 15:05:56 -05:00
Josh HawkinsandGitHub 9b2839f4fb Fix go2rtc missing from process stats after a restart (#24546)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* re-resolve go2rtc pid on every stats snapshot

* add snapshot test for go2rtc pid after restart
2026-10-02 15:35:04 -06:00
Josh HawkinsandGitHub c06bf97b5e retry and report Frigate+ connection failures at startup (#24545)
A Frigate+ model that wasn't cached yet needed api.frigate.video at startup, and when it couldn't be reached (a network that comes up late, a DNS blip) the requests ConnectionError wasn't a validation error, so Frigate crashed with a traceback before it could start. PlusApi requests now go through a session that retries connection failures for about 30 seconds, and a connection failure that outlasts that is raised as a ValueError so it shows up as a clear config validation error instead.
2026-10-02 15:34:26 -06:00
Josh HawkinsandGitHub f3723698cd Refactor camera status caching (#24544)
* refactor camera status caching

* fix tests
2026-10-02 15:33:58 -06:00
Josh HawkinsandGitHub 38b87feece support env var substitution for notification email (#24517)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
`notifications.email` is now an `EnvString`, so it can come from `secrets.yaml`, a Docker secret, or a container env var. `/api/config` returns the resolved value, so the email is now redacted for non-admin users, including each camera's inherited copy and the profile `base_config` copy.
2026-10-02 06:58:29 -06:00
35 changed files with 992 additions and 1175 deletions
@@ -571,6 +571,8 @@ notifications:
enabled: False
# Optional: Email for push service to reach out to
# NOTE: This is required to use notifications
# NOTE: Email can be specified with an environment variable or docker secrets that must begin with 'FRIGATE_'.
# e.g. email: '{FRIGATE_NOTIFICATION_EMAIL}'
email: "admin@example.com"
# Optional: Cooldown time for notifications in seconds (default: shown below)
cooldown: 0
+14 -3
View File
@@ -311,9 +311,12 @@ def config(request: Request):
mode="json", warnings="none", exclude_none=True
)
# remove environment_vars for non-admin users
if request.headers.get("remote-role") != "admin":
is_admin = request.headers.get("remote-role") == "admin"
# hide environment_vars and the notification email from non-admin users
if not is_admin:
config.pop("environment_vars", None)
redact_credential(config["notifications"], "email")
# redact mqtt credentials
redact_credential(config["mqtt"], "password")
@@ -370,7 +373,15 @@ def config(request: Request):
camera_name
)
if base_sections:
camera_dict["base_config"] = base_sections
# copy so redaction below can't alter the profile manager's cache
camera_dict["base_config"] = copy.deepcopy(base_sections)
# cameras inherit the global notification email
if not is_admin:
redact_credential(camera_dict["notifications"], "email")
redact_credential(
camera_dict.get("base_config", {}).get("notifications", {}), "email"
)
# remove go2rtc stream passwords
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
+3 -7
View File
@@ -77,7 +77,7 @@ from frigate.notices.registry import NoticeRegistry
from frigate.object_detection.base import ObjectDetectProcess
from frigate.object_detection.util import detection_frame_size
from frigate.output.output import OutputProcess
from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.ptz.autotrack import PtzAutoTracker
from frigate.ptz.onvif import OnvifController
from frigate.record.cleanup import RecordingCleanup
from frigate.record.export import migrate_exports
@@ -166,11 +166,7 @@ class FrigateApp:
# create camera_metrics
for camera_name in self.config.cameras.keys():
self.camera_metrics[camera_name] = CameraMetrics(self.metrics_manager)
self.ptz_metrics[camera_name] = PTZMetrics(
autotracker_enabled=self.config.cameras[
camera_name
].onvif.autotracking.enabled
)
self.ptz_metrics[camera_name] = PTZMetrics()
def init_queues(self) -> None:
# Queue for cameras to push tracked objects to
@@ -443,7 +439,7 @@ class FrigateApp:
)
def start_ptz_autotracker(self) -> None:
self.ptz_autotracker_thread = PtzAutoTrackerThread(
self.ptz_autotracker_thread = PtzAutoTracker(
self.config,
self.onvif_controller,
self.ptz_metrics,
+1 -7
View File
@@ -43,8 +43,6 @@ class CameraMetrics:
class PTZMetrics:
autotracker_enabled: Synchronized
start_time: Synchronized
stop_time: Synchronized
frame_time: Synchronized
@@ -52,13 +50,10 @@ class PTZMetrics:
max_zoom: Synchronized
min_zoom: Synchronized
tracking_active: Event
motor_stopped: Event
reset: Event
def __init__(self, *, autotracker_enabled: bool):
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment]
def __init__(self) -> None:
self.start_time = mp.Value("d", 0) # type: ignore[assignment]
self.stop_time = mp.Value("d", 0) # type: ignore[assignment]
self.frame_time = mp.Value("d", 0) # type: ignore[assignment]
@@ -66,7 +61,6 @@ class PTZMetrics:
self.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.min_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.tracking_active = mp.Event()
self.motor_stopped = mp.Event()
self.reset = mp.Event()
+1 -3
View File
@@ -120,9 +120,7 @@ class CameraMaintainer(threading.Thread):
if runtime:
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
self.ptz_metrics[name] = PTZMetrics(
autotracker_enabled=config.onvif.autotracking.enabled
)
self.ptz_metrics[name] = PTZMetrics()
self.region_grids[name] = get_camera_regions_grid(
name,
config.detect,
+9 -13
View File
@@ -16,7 +16,7 @@ from frigate.config import (
ZoomingModeEnum,
)
from frigate.const import CLIPS_DIR, THUMB_DIR
from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
from frigate.track.tracked_object import TrackedObject
from frigate.util.image import (
SharedMemoryFrameManager,
@@ -35,7 +35,7 @@ class CameraState:
name: str,
config: FrigateConfig,
frame_manager: SharedMemoryFrameManager,
ptz_autotracker_thread: PtzAutoTrackerThread,
ptz_autotracker_thread: PtzAutoTracker,
) -> None:
self.name = name
self.config = config
@@ -115,17 +115,13 @@ class CameraState:
# draw thicker box around ptz autotracked object
if (
self.camera_config.onvif.autotracking.enabled
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get(
self.name
)
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
self.name
]
and self.ptz_autotracker_thread.autotracker_init.get(self.name)
and self.ptz_autotracker_thread.tracked_object[self.name]
is not None
and obj["id"]
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
== self.ptz_autotracker_thread.tracked_object[ # type: ignore[union-attr]
self.name
].obj_data["id"] # type: ignore[attr-defined]
].obj_data["id"]
and obj["frame_time"] == frame_time
):
thickness = 5
@@ -138,9 +134,9 @@ class CameraState:
and self.camera_config.detect.width is not None
and self.camera_config.detect.height is not None
):
max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[
self.name
]["max_target_box"] # type: ignore[index]
max_target_box = calculate_max_target_box(
self.camera_config.onvif.autotracking.zoom_factor
)
side_length = max_target_box * (
max(
self.camera_config.detect.width,
+2 -4
View File
@@ -759,15 +759,13 @@ class Dispatcher:
"Autotracking must be enabled in the config to be turned on via MQTT."
)
return
if not self.ptz_metrics[camera_name].autotracker_enabled.value:
if not ptz_autotracker_settings.enabled:
logger.info(f"Turning on ptz autotracker for {camera_name}")
self.ptz_metrics[camera_name].autotracker_enabled.value = True
self.ptz_metrics[camera_name].start_time.value = 0
ptz_autotracker_settings.enabled = True
elif payload == "OFF":
if self.ptz_metrics[camera_name].autotracker_enabled.value:
if ptz_autotracker_settings.enabled:
logger.info(f"Turning off ptz autotracker for {camera_name}")
self.ptz_metrics[camera_name].autotracker_enabled.value = False
self.ptz_metrics[camera_name].start_time.value = 0
ptz_autotracker_settings.enabled = False
+2 -1
View File
@@ -1,6 +1,7 @@
from pydantic import Field
from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["NotificationConfig"]
@@ -11,7 +12,7 @@ class NotificationConfig(FrigateBaseModel):
title="Enable notifications",
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
)
email: str | None = Field(
email: EnvString | None = Field(
default=None,
title="Notification email",
description="Email address used for push notifications or required by certain notification providers.",
+16 -3
View File
@@ -186,15 +186,28 @@ class ModelConfig(BaseModel):
# download the model if it doesn't exist
if not os.path.isfile(self.path):
download_url = plus_api.get_model_download_url(model_id)
r = requests.get(download_url)
try:
download_url = plus_api.get_model_download_url(model_id)
r = requests.get(download_url)
except requests.exceptions.ConnectionError as e:
raise ValueError(
f"Unable to connect to Frigate+ to download model {model_id}"
) from e
with open(self.path, "wb") as f:
f.write(r.content)
# download the model info if it doesn't exist
if not os.path.isfile(model_info_path):
try:
model_info = plus_api.get_model_info(model_id)
except requests.exceptions.ConnectionError as e:
raise ValueError(
f"Unable to connect to Frigate+ to download model info for {model_id}"
) from e
with open(model_info_path, "w") as f:
json.dump(plus_api.get_model_info(model_id), f)
json.dump(model_info, f)
model_info = load_plus_model_info(model_id)
+4 -3
View File
@@ -19,6 +19,7 @@ class ImprovedMotionDetector(MotionDetector):
config: RuntimeMotionConfig,
fps: int,
ptz_metrics: PTZMetrics | None = None,
autotracking_enabled: bool = False,
name: str = "improved",
blur_radius: int = 1,
interpolation: int = cv2.INTER_NEAREST,
@@ -45,6 +46,7 @@ class ImprovedMotionDetector(MotionDetector):
self.contrast_values[:, 1:2] = 255
self.contrast_values_index = 0
self.ptz_metrics = ptz_metrics
self.autotracking_enabled = autotracking_enabled
self.last_stop_time: float | None = None
def is_calibrating(self) -> bool:
@@ -59,8 +61,7 @@ class ImprovedMotionDetector(MotionDetector):
# if ptz motor is moving from autotracking, quickly return
# a single box that is 80% of the frame
if self.ptz_metrics is not None and (
self.ptz_metrics.autotracker_enabled.value
and not self.ptz_metrics.motor_stopped.is_set()
self.autotracking_enabled and not self.ptz_metrics.motor_stopped.is_set()
):
return [
(
@@ -162,7 +163,7 @@ class ImprovedMotionDetector(MotionDetector):
# if so, reassign the average to the current frame so we begin with a new baseline
if self.ptz_metrics is not None and (
# ensure we only do this for cameras with autotracking enabled
self.ptz_metrics.autotracker_enabled.value
self.autotracking_enabled
and self.ptz_metrics.motor_stopped.is_set()
and (
self.last_stop_time is None
+15 -4
View File
@@ -9,7 +9,9 @@ from typing import Any
import cv2
import requests
from numpy import ndarray
from requests.adapters import HTTPAdapter
from requests.models import Response
from urllib3.util.retry import Retry
from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
@@ -101,6 +103,13 @@ class PlusApi:
self._is_active: bool = self.key is not None
self._token_data: dict = {}
# Retry connection failures so a network that comes up late at startup
# doesn't fail the Frigate+ model download
self._session = requests.Session()
self._session.mount(
self.host, HTTPAdapter(max_retries=Retry(connect=5, backoff_factor=1))
)
def _refresh_token_if_needed(self) -> None:
if (
self._token_data.get("expires") is None
@@ -111,7 +120,9 @@ class PlusApi:
"Plus API key not set. See https://docs.frigate.video/integrations/plus#set-your-api-key"
)
parts = self.key.split(":")
r = requests.get(f"{self.host}/v1/auth/token", auth=(parts[0], parts[1]))
r = self._session.get(
f"{self.host}/v1/auth/token", auth=(parts[0], parts[1])
)
if not r.ok:
raise Exception(f"Unable to refresh API token: {r.text}")
self._token_data = r.json()
@@ -121,19 +132,19 @@ class PlusApi:
return {"authorization": f"Bearer {self._token_data.get('accessToken')}"}
def _get(self, path: str) -> Response:
return requests.get(
return self._session.get(
f"{self.host}/v1/{path}", headers=self._get_authorization_header()
)
def _post(self, path: str, data: dict) -> Response:
return requests.post(
return self._session.post(
f"{self.host}/v1/{path}",
headers=self._get_authorization_header(),
json=data,
)
def _put(self, path: str, data: dict) -> Response:
return requests.put(
return self._session.put(
f"{self.host}/v1/{path}",
headers=self._get_authorization_header(),
json=data,
+176 -321
View File
@@ -23,6 +23,7 @@ from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
CameraConfigUpdateTopic,
)
from frigate.const import (
AUTOTRACKING_MAX_AREA_RATIO,
@@ -42,6 +43,11 @@ from frigate.util.image import SharedMemoryFrameManager, intersection_over_union
logger = logging.getLogger(__name__)
def calculate_max_target_box(zoom_factor: float) -> float:
"""Return the largest target box ratio allowed for a zoom factor."""
return AUTOTRACKING_MAX_AREA_RATIO ** (1 / zoom_factor)
def ptz_moving_at_frame_time(frame_time, ptz_start_time, ptz_stop_time):
# Determine if the PTZ was in motion at the set frame time
# for non ptz/autotracking cameras, this will always return False
@@ -180,7 +186,7 @@ class PtzMotionEstimator:
return self.coord_transformations
class PtzAutoTrackerThread(threading.Thread):
class PtzAutoTracker(threading.Thread):
def __init__(
self,
config: FrigateConfig,
@@ -190,56 +196,14 @@ class PtzAutoTrackerThread(threading.Thread):
stop_event: MpEvent,
) -> None:
super().__init__(name="ptz_autotracker")
self.ptz_autotracker = PtzAutoTracker(
config, onvif, ptz_metrics, dispatcher, stop_event
)
self.stop_event = stop_event
self.config = config
def run(self):
while not self.stop_event.wait(1):
self.ptz_autotracker.check_for_updates()
for camera, camera_config in list(self.config.cameras.items()):
if not camera_config.enabled:
continue
if camera_config.onvif.autotracking.enabled:
future = asyncio.run_coroutine_threadsafe(
self.ptz_autotracker.camera_maintenance(camera),
self.ptz_autotracker.onvif.loop,
)
# Wait for the coroutine to complete
future.result()
else:
# disabled dynamically by mqtt
if self.ptz_autotracker.tracked_object.get(camera):
self.ptz_autotracker.tracked_object[camera] = None
self.ptz_autotracker.tracked_object_history[camera].clear()
self.ptz_autotracker.config_subscriber.stop()
logger.info("Exiting autotracker...")
class PtzAutoTracker:
def __init__(
self,
config: FrigateConfig,
onvif: OnvifController,
ptz_metrics: PTZMetrics,
dispatcher: Dispatcher,
stop_event: MpEvent,
) -> None:
self.config = config
self.onvif = onvif
self.ptz_metrics = ptz_metrics
self.dispatcher = dispatcher
self.stop_event = stop_event
self.tracked_object: dict[str, object] = {}
self.tracked_object: dict[str, TrackedObject | None] = {}
self.tracked_object_history: dict[str, object] = {}
self.tracked_object_metrics: dict[str, object] = {}
self.object_types: dict[str, object] = {}
self.required_zones: dict[str, object] = {}
self.tracked_object_metrics: dict[str, dict[str, Any]] = {}
self.move_queues: dict[str, object] = {}
self.move_queue_locks: dict[str, object] = {}
self.move_threads: dict[str, object] = {}
@@ -249,7 +213,6 @@ class PtzAutoTracker:
self.intercept: dict[str, object] = {}
self.move_coefficients: dict[str, object] = {}
self.zoom_time: dict[str, float] = {}
self.zoom_factor: dict[str, object] = {}
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
@@ -277,44 +240,37 @@ class PtzAutoTracker:
# Wait for the coroutine to complete
future.result()
def check_for_updates(self) -> None:
"""Apply camera config updates and mirror autotracking state to ptz metrics.
def run(self) -> None:
while not self.stop_event.wait(1):
self.config_subscriber.check_for_updates()
The camera processes read autotracker_enabled rather than the config, so it
has to follow every path that can change autotracking, not just the mqtt
toggle that writes it directly.
"""
updates = self.config_subscriber.check_for_updates()
for cameras in updates.values():
for camera in cameras:
camera_config = self.config.cameras.get(camera)
metrics = self.ptz_metrics.get(camera)
# a camera added at runtime gets its metrics from the maintainer on
# another thread, which seeds them from this same config value
if camera_config is None or metrics is None:
for camera, camera_config in list(self.config.cameras.items()):
if not camera_config.enabled:
continue
metrics.autotracker_enabled.value = (
camera_config.onvif.autotracking.enabled
)
if camera_config.onvif.autotracking.enabled:
future = asyncio.run_coroutine_threadsafe(
self.camera_maintenance(camera), self.onvif.loop
)
# Wait for the coroutine to complete
future.result()
else:
# disabled dynamically by mqtt
if self.tracked_object.get(camera):
self.tracked_object[camera] = None
self.tracked_object_history[camera].clear()
self.config_subscriber.stop()
logger.info("Exiting autotracker...")
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
logger.debug(f"{camera}: Autotracker init")
self.object_types[camera] = camera_config.onvif.autotracking.track
self.required_zones[camera] = camera_config.onvif.autotracking.required_zones
self.zoom_factor[camera] = camera_config.onvif.autotracking.zoom_factor
self.tracked_object[camera] = None
self.tracked_object_history[camera] = deque(
maxlen=round(camera_config.detect.fps * 1.5)
)
self.tracked_object_metrics[camera] = {
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
** (1 / self.zoom_factor[camera])
}
self._reset_tracked_object_metrics(camera)
self.calibrating[camera] = False
self.move_metrics[camera] = []
@@ -326,43 +282,22 @@ class PtzAutoTracker:
# handle onvif constructor failing due to no connection
if camera not in self.onvif.cams:
logger.warning(
f"Disabling autotracking for {camera}: onvif connection failed"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
self._disable(camera, "onvif connection failed")
return
if not self.onvif.cams[camera]["init"]:
if not await self.onvif._init_onvif(camera):
logger.warning(
f"Disabling autotracking for {camera}: Unable to initialize onvif"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
self._disable(camera, "Unable to initialize onvif")
return
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
logger.warning(
f"Disabling autotracking for {camera}: FOV relative movement not supported"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
self._disable(camera, "FOV relative movement not supported")
return
move_status_supported = await self.onvif.get_service_capabilities(camera)
if not (
isinstance(move_status_supported, bool) and move_status_supported
) and not (
isinstance(move_status_supported, str)
and move_status_supported.lower() == "true"
):
logger.warning(
f"Disabling autotracking for {camera}: ONVIF MoveStatus not supported"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
if str(move_status_supported).lower() != "true":
self._disable(camera, "ONVIF MoveStatus not supported")
return
if self.onvif.cams[camera]["init"]:
@@ -374,59 +309,41 @@ class PtzAutoTracker:
)
if camera_config.onvif.autotracking.movement_weights:
if len(camera_config.onvif.autotracking.movement_weights) == 6:
camera_config.onvif.autotracking.movement_weights = [
float(val)
for val in camera_config.onvif.autotracking.movement_weights
]
self.ptz_metrics[
camera
].min_zoom.value = (
camera_config.onvif.autotracking.movement_weights[0]
)
self.ptz_metrics[
camera
].max_zoom.value = (
camera_config.onvif.autotracking.movement_weights[1]
)
self.intercept[camera] = (
camera_config.onvif.autotracking.movement_weights[2]
)
self.move_coefficients[camera] = (
camera_config.onvif.autotracking.movement_weights[3:5]
)
self.zoom_time[camera] = (
camera_config.onvif.autotracking.movement_weights[5]
)
else:
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
logger.warning(
f"Autotracker recalibration is required for {camera}. Disabling autotracking."
)
(
self.ptz_metrics[camera].min_zoom.value,
self.ptz_metrics[camera].max_zoom.value,
self.intercept[camera],
*self.move_coefficients[camera],
self.zoom_time[camera],
) = map(float, camera_config.onvif.autotracking.movement_weights)
if camera_config.onvif.autotracking.calibrate_on_startup:
await self._calibrate_camera(camera)
self.ptz_metrics[camera].tracking_active.clear()
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
self.autotracker_init[camera] = True
def _write_config(self, camera):
config_file = find_config_file()
def _disable(self, camera: str, reason: str) -> None:
logger.warning(f"Disabling autotracking for {camera}: {reason}")
autotracking_config = self.config.cameras[camera].onvif.autotracking
autotracking_config.enabled = False
logger.debug(
f"{camera}: Writing new config with autotracker motion coefficients: {self.config.cameras[camera].onvif.autotracking.movement_weights}"
# the camera process holds its own copy of the config
self.dispatcher.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
autotracking_config,
)
update_yaml_file_bulk(
config_file,
{
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
camera
].onvif.autotracking.movement_weights
},
)
def _reset_tracked_object_metrics(self, camera: str) -> None:
self.tracked_object_metrics[camera] = {}
async def _wait_until_stopped(
self, camera: str, metrics: PTZMetrics | None = None
) -> None:
metrics = metrics or self.ptz_metrics[camera]
while not metrics.motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
async def _calibrate_camera(self, camera):
# move the camera from the preset in steps and measure the time it takes to move that amount
@@ -459,8 +376,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
@@ -470,8 +386,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value)
@@ -488,8 +403,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
@@ -503,8 +417,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
zoom_stop_time = time.time()
@@ -518,8 +431,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
full_relative_stop_time = time.time()
@@ -531,8 +443,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
self.zoom_time[camera] = (
full_relative_stop_time - full_relative_start_time
@@ -558,9 +469,7 @@ class PtzAutoTracker:
self.ptz_metrics[camera].reset.set()
self.ptz_metrics[camera].motor_stopped.clear()
# Wait until the camera finishes moving
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
for step in range(num_steps):
pan = step_sizes[step]
@@ -569,9 +478,7 @@ class PtzAutoTracker:
start_time = time.time()
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():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
stop_time = time.time()
self.move_metrics[camera].append(
@@ -590,9 +497,7 @@ class PtzAutoTracker:
self.ptz_metrics[camera].reset.set()
self.ptz_metrics[camera].motor_stopped.clear()
# Wait until the camera finishes moving
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
logger.info(
f"Calibration for {camera} in progress: {round((step / num_steps) * 100)}% complete"
@@ -668,7 +573,14 @@ class PtzAutoTracker:
f"{camera}: New regression parameters - intercept: {self.intercept[camera]}, coefficients: {self.move_coefficients[camera]}"
)
self._write_config(camera)
update_yaml_file_bulk(
find_config_file(),
{
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
camera
].onvif.autotracking.movement_weights
},
)
def _predict_movement_time(self, camera, pan, tilt):
combined_movement = abs(pan) + abs(tilt)
@@ -682,6 +594,18 @@ class PtzAutoTracker:
[self.tracked_object_history[camera][-1]["frame_time"] + time],
)
def _predict_target_box(self, camera, predicted_time):
target_box = self.tracked_object_metrics[camera]["target_box"]
if not predicted_time:
return target_box
frame_shape = self.config.cameras[camera].frame_shape
return target_box + self._predict_area_after_time(camera, predicted_time) / (
frame_shape[0] * frame_shape[1]
)
def _calculate_tracked_object_metrics(self, camera, obj):
def remove_outliers(data):
areas = [item["area"] for item in data]
@@ -703,19 +627,20 @@ class PtzAutoTracker:
return filtered_data
camera_config = self.config.cameras[camera]
tom = self.tracked_object_metrics[camera]
zoom_factor = camera_config.onvif.autotracking.zoom_factor
camera_width = camera_config.frame_shape[1]
camera_height = camera_config.frame_shape[0]
# Extract areas and calculate weighted average
# grab the largest dimension of the bounding box and create a square from that
# Filter out the initial frame and use a recent time window
# Use a recent time window
current_time = obj.obj_data["frame_time"]
time_window = 1.5 # seconds
history = [
entry
for entry in self.tracked_object_history[camera]
if not entry.get("is_initial_frame", False)
and current_time - entry["frame_time"] <= time_window
if current_time - entry["frame_time"] <= time_window
]
if not history: # Fallback to latest if no recent entries
history = [self.tracked_object_history[camera][-1]]
@@ -748,33 +673,29 @@ class PtzAutoTracker:
)
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
self.tracked_object_metrics[camera]["area_coefficients"] = np.linalg.lstsq(
X.reshape(-1, 1), y, rcond=None
)[0]
tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
0
]
else:
self.tracked_object_metrics[camera]["area_coefficients"] = np.array([0])
tom["area_coefficients"] = np.array([0])
weights = np.arange(1, len(filtered_areas) + 1)
weighted_area = np.average(
[item["area"] for item in filtered_areas], weights=weights
)
self.tracked_object_metrics[camera]["target_box"] = (
tom["target_box"] = (
weighted_area / (camera_width * camera_height)
) ** self.zoom_factor[camera]
) ** zoom_factor
if "original_target_box" not in self.tracked_object_metrics[camera]:
self.tracked_object_metrics[camera]["original_target_box"] = (
self.tracked_object_metrics[camera]["target_box"]
)
if "original_target_box" not in tom:
tom["original_target_box"] = tom["target_box"]
(
self.tracked_object_metrics[camera]["valid_velocity"],
self.tracked_object_metrics[camera]["velocity"],
tom["valid_velocity"],
tom["velocity"],
) = self._get_valid_velocity(camera, obj)
self.tracked_object_metrics[camera]["distance"] = self._get_distance_threshold(
camera, obj
)
tom["distance"] = self._get_distance_threshold(camera, obj)
centroid_distance = np.linalg.norm(
[
@@ -785,9 +706,7 @@ class PtzAutoTracker:
logger.debug(f"{camera}: Centroid distance: {centroid_distance}")
self.tracked_object_metrics[camera]["below_distance_threshold"] = (
centroid_distance < self.tracked_object_metrics[camera]["distance"]
)
tom["below_distance_threshold"] = centroid_distance < tom["distance"]
async def _process_move_queue(self, camera):
move_queue = self.move_queues[camera]
@@ -833,16 +752,12 @@ class PtzAutoTracker:
if pan != 0 or tilt != 0:
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
# Wait until the camera finishes moving
while not metrics.motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera, metrics)
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 metrics.motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera, metrics)
if camera_config.onvif.autotracking.movement_weights:
logger.debug(
@@ -878,41 +793,20 @@ class PtzAutoTracker:
await move_queue.get()
def _enqueue_move(self, camera, frame_time, pan, tilt, zoom):
def split_value(value, suppress_diff=True):
clipped = np.clip(value, -1, 1)
# don't make small movements
if -0.05 < clipped < 0.05 and suppress_diff:
diff = 0.0
else:
diff = value - clipped
return clipped, diff
pan, tilt, zoom = (np.clip(value, -1, 1) for value in (pan, tilt, zoom))
if (
frame_time > self.ptz_metrics[camera].start_time.value
(pan != 0 or tilt != 0 or zoom != 0)
and frame_time > self.ptz_metrics[camera].start_time.value
and frame_time > self.ptz_metrics[camera].stop_time.value
and not self.move_queue_locks[camera].locked()
):
# we can split up any large moves caused by velocity estimated movements if necessary
# get an excess amount and assign it instead of 0 below
while pan != 0 or tilt != 0 or zoom != 0:
pan, _ = split_value(pan)
tilt, _ = split_value(tilt)
zoom, _ = split_value(zoom, False)
logger.debug(
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}"
)
move_data = (frame_time, pan, tilt, zoom)
self.onvif.loop.call_soon_threadsafe(
self.move_queues[camera].put_nowait, move_data
)
# reset values to not split up large movements
pan = 0
tilt = 0
zoom = 0
logger.debug(
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}"
)
self.onvif.loop.call_soon_threadsafe(
self.move_queues[camera].put_nowait, (frame_time, pan, tilt, zoom)
)
def _touching_frame_edges(self, camera, box):
camera_config = self.config.cameras[camera]
@@ -1046,16 +940,17 @@ class PtzAutoTracker:
return distance_threshold
def _should_zoom_in(
self, camera: str, obj: TrackedObject, box, predicted_time, debug_zooming=False
):
def _should_zoom_in(self, camera: str, box, predicted_time):
# returns True if we should zoom in, False if we should zoom out, None to do nothing
camera_config = self.config.cameras[camera]
tom = self.tracked_object_metrics[camera]
zoom_factor = camera_config.onvif.autotracking.zoom_factor
max_target_box = calculate_max_target_box(zoom_factor)
camera_width = camera_config.frame_shape[1]
camera_height = camera_config.frame_shape[0]
camera_fps = camera_config.detect.fps
average_velocity = self.tracked_object_metrics[camera]["velocity"]
average_velocity = tom["velocity"]
bb_left, bb_top, bb_right, bb_bottom = box
@@ -1073,13 +968,11 @@ class PtzAutoTracker:
touching_frame_edges = self._touching_frame_edges(camera, box)
# make sure object is centered in the frame
below_distance_threshold = self.tracked_object_metrics[camera][
"below_distance_threshold"
]
below_distance_threshold = tom["below_distance_threshold"]
below_dimension_threshold = (bb_right - bb_left) <= camera_width * (
self.zoom_factor[camera] + 0.1
) and (bb_bottom - bb_top) <= camera_height * (self.zoom_factor[camera] + 0.1)
zoom_factor + 0.1
) and (bb_bottom - bb_top) <= camera_height * (zoom_factor + 0.1)
# ensure object is not moving quickly
below_velocity_threshold = np.all(
@@ -1087,30 +980,16 @@ class PtzAutoTracker:
< np.tile([velocity_threshold_x, velocity_threshold_y], 2)
) or np.all(average_velocity == 0)
if not predicted_time:
calculated_target_box = self.tracked_object_metrics[camera]["target_box"]
else:
calculated_target_box = self.tracked_object_metrics[camera][
"target_box"
] + self._predict_area_after_time(camera, predicted_time) / (
camera_width * camera_height
)
calculated_target_box = self._predict_target_box(camera, predicted_time)
below_area_threshold = (
calculated_target_box
< self.tracked_object_metrics[camera]["max_target_box"]
)
below_area_threshold = calculated_target_box < max_target_box
# introduce some hysteresis to prevent a yo-yo zooming effect
zoom_out_hysteresis = (
calculated_target_box
> self.tracked_object_metrics[camera]["max_target_box"]
* AUTOTRACKING_ZOOM_OUT_HYSTERESIS
calculated_target_box > max_target_box * AUTOTRACKING_ZOOM_OUT_HYSTERESIS
)
zoom_in_hysteresis = (
calculated_target_box
< self.tracked_object_metrics[camera]["max_target_box"]
* AUTOTRACKING_ZOOM_IN_HYSTERESIS
calculated_target_box < max_target_box * AUTOTRACKING_ZOOM_IN_HYSTERESIS
)
at_max_zoom = (
@@ -1122,31 +1001,29 @@ class PtzAutoTracker:
== self.ptz_metrics[camera].min_zoom.value
)
# debug zooming
if debug_zooming:
logger.debug(
f"{camera}: Zoom test: touching edges: count: {touching_frame_edges} left: {bb_left < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_width}, right: {bb_right > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_width}, top: {bb_top < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_height}, bottom: {bb_bottom > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_height}"
)
logger.debug(
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
)
logger.debug(
f"{camera}: Zoom test: below area threshold: {(below_area_threshold)} target: {self.tracked_object_metrics[camera]['target_box']}, calculated: {calculated_target_box}, max: {self.tracked_object_metrics[camera]['max_target_box']}"
)
logger.debug(
f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (self.zoom_factor[camera] + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (self.zoom_factor[camera] + 0.1)}"
)
logger.debug(
f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}"
)
logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}")
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")
logger.debug(
f"{camera}: Zoom test: zoom in hysteresis limit: {zoom_in_hysteresis} value: {AUTOTRACKING_ZOOM_IN_HYSTERESIS} original: {self.tracked_object_metrics[camera]['original_target_box']} max: {self.tracked_object_metrics[camera]['max_target_box']} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]['target_box']}"
)
logger.debug(
f"{camera}: Zoom test: zoom out hysteresis limit: {zoom_out_hysteresis} value: {AUTOTRACKING_ZOOM_OUT_HYSTERESIS} original: {self.tracked_object_metrics[camera]['original_target_box']} max: {self.tracked_object_metrics[camera]['max_target_box']} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]['target_box']}"
)
logger.debug(
f"{camera}: Zoom test: touching edges: count: {touching_frame_edges} left: {bb_left < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_width}, right: {bb_right > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_width}, top: {bb_top < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_height}, bottom: {bb_bottom > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_height}"
)
logger.debug(
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
)
logger.debug(
f"{camera}: Zoom test: below area threshold: {(below_area_threshold)} target: {tom['target_box']}, calculated: {calculated_target_box}, max: {max_target_box}"
)
logger.debug(
f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (zoom_factor + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (zoom_factor + 0.1)}"
)
logger.debug(
f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}"
)
logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}")
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")
logger.debug(
f"{camera}: Zoom test: zoom in hysteresis limit: {zoom_in_hysteresis} value: {AUTOTRACKING_ZOOM_IN_HYSTERESIS} original: {tom['original_target_box']} max: {max_target_box} target: {calculated_target_box if calculated_target_box else tom['target_box']}"
)
logger.debug(
f"{camera}: Zoom test: zoom out hysteresis limit: {zoom_out_hysteresis} value: {AUTOTRACKING_ZOOM_OUT_HYSTERESIS} original: {tom['original_target_box']} max: {max_target_box} target: {calculated_target_box if calculated_target_box else tom['target_box']}"
)
# Zoom in conditions (and)
if (
@@ -1237,7 +1114,7 @@ class PtzAutoTracker:
)
zoom = self._get_zoom_amount(
camera, obj, predicted_box, predicted_movement_time, debug_zoom=True
camera, obj, predicted_box, predicted_movement_time
)
if (
@@ -1298,9 +1175,11 @@ class PtzAutoTracker:
obj: TrackedObject,
predicted_box,
predicted_movement_time,
debug_zoom=True,
):
camera_config = self.config.cameras[camera]
tom = self.tracked_object_metrics[camera]
zoom_factor = camera_config.onvif.autotracking.zoom_factor
max_target_box = calculate_max_target_box(zoom_factor)
# frame width and height
camera_width = camera_config.frame_shape[1]
@@ -1317,16 +1196,12 @@ class PtzAutoTracker:
# absolute zooming separately from pan/tilt
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.absolute:
# don't zoom on initial move
if "target_box" not in self.tracked_object_metrics[camera]:
if "target_box" not in tom:
zoom = current_zoom_level
else:
if (
result := self._should_zoom_in(
camera,
obj,
obj.obj_data["box"],
predicted_movement_time,
debug_zoom,
camera, obj.obj_data["box"], predicted_movement_time
)
) is not None:
# divide zoom in 10 increments and always zoom out more than in
@@ -1342,46 +1217,35 @@ class PtzAutoTracker:
# relative zooming concurrently with pan/tilt
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative:
# this is our initial zoom in on a new object
if "target_box" not in self.tracked_object_metrics[camera]:
zoom = target_box ** self.zoom_factor[camera]
if zoom > self.tracked_object_metrics[camera]["max_target_box"]:
if "target_box" not in tom:
zoom = target_box**zoom_factor
if zoom > max_target_box:
zoom = -(1 - zoom)
logger.debug(
f"{camera}: target box: {target_box}, max: {self.tracked_object_metrics[camera]['max_target_box']}, calc zoom: {zoom}"
f"{camera}: target box: {target_box}, max: {max_target_box}, calc zoom: {zoom}"
)
else:
if (
result := self._should_zoom_in(
camera,
obj,
predicted_box
if camera_config.onvif.autotracking.movement_weights
else obj.obj_data["box"],
predicted_movement_time,
debug_zoom,
)
) is not None:
if predicted_movement_time:
calculated_target_box = self.tracked_object_metrics[camera][
"target_box"
] + self._predict_area_after_time(
camera, predicted_movement_time
) / (camera_width * camera_height)
logger.debug(
f"{camera}: Zooming prediction: predicted movement time: {predicted_movement_time}, original box: {self.tracked_object_metrics[camera]['target_box']}, calculated box: {calculated_target_box}"
)
else:
calculated_target_box = self.tracked_object_metrics[camera][
"target_box"
]
# zoom value
ratio = (
self.tracked_object_metrics[camera]["max_target_box"]
/ calculated_target_box
calculated_target_box = self._predict_target_box(
camera, predicted_movement_time
)
if predicted_movement_time:
logger.debug(
f"{camera}: Zooming prediction: predicted movement time: {predicted_movement_time}, original box: {tom['target_box']}, calculated box: {calculated_target_box}"
)
# zoom value
ratio = max_target_box / calculated_target_box
zoom = (ratio - 1) / (ratio + 1)
logger.debug(
f"{camera}: limit: {self.tracked_object_metrics[camera]['max_target_box']}, ratio: {ratio} zoom calculation: {zoom}"
f"{camera}: limit: {max_target_box}, ratio: {ratio} zoom calculation: {zoom}"
)
if not result:
# zoom out with special condition if zooming out because of velocity, edges, etc.
@@ -1394,9 +1258,6 @@ class PtzAutoTracker:
return zoom
def is_autotracking(self, camera: str):
return self.tracked_object[camera] is not None
def autotrack_object(self, camera: str, obj: TrackedObject):
if camera not in self.config.cameras:
return
@@ -1420,8 +1281,9 @@ class PtzAutoTracker:
# new object
self.tracked_object[camera] is None
and obj.camera_config.name == camera
and obj.obj_data["label"] in self.object_types[camera]
and set(obj.entered_zones) & set(self.required_zones[camera])
and obj.obj_data["label"] in camera_config.onvif.autotracking.track
and set(obj.entered_zones)
& set(camera_config.onvif.autotracking.required_zones)
and not obj.previous["false_positive"]
and not obj.false_positive
and not self.tracked_object_history[camera]
@@ -1430,7 +1292,6 @@ class PtzAutoTracker:
logger.debug(
f"{camera}: New object: {obj.obj_data['id']} {obj.obj_data['box']} {obj.obj_data['frame_time']}"
)
self.ptz_metrics[camera].tracking_active.set()
self.dispatcher.publish(
f"{camera}/ptz_autotracker/active", "ON", retain=False
)
@@ -1479,7 +1340,7 @@ class PtzAutoTracker:
# Should we check region (maybe too broad) or expand the previous object's box a bit and check that?
self.tracked_object[camera] is None
and obj.camera_config.name == camera
and obj.obj_data["label"] in self.object_types[camera]
and obj.obj_data["label"] in camera_config.onvif.autotracking.track
and not obj.previous["false_positive"]
and not obj.false_positive
and self.tracked_object_history[camera]
@@ -1515,10 +1376,7 @@ class PtzAutoTracker:
f"{camera}: End object: {obj.obj_data['id']} {obj.obj_data['box']}"
)
self.tracked_object[camera] = None
self.tracked_object_metrics[camera] = {
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
** (1 / self.zoom_factor[camera])
}
self._reset_tracked_object_metrics(camera)
async def camera_maintenance(self, camera):
# bail and don't check anything if we're not set up yet, calibrating, or
@@ -1555,8 +1413,7 @@ class PtzAutoTracker:
self.tracked_object[camera] = None
self.tracked_object_history[camera].clear()
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
logger.debug(
f"{camera}: Time is {self.ptz_metrics[camera].frame_time.value}, returning to preset: {autotracker_config.return_preset}"
)
@@ -1566,10 +1423,8 @@ class PtzAutoTracker:
)
# update stored zoom level from preset
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
self.ptz_metrics[camera].tracking_active.clear()
self.dispatcher.publish(
f"{camera}/ptz_autotracker/active", "OFF", retain=False
)
+205 -304
View File
@@ -10,8 +10,7 @@ from pathlib import Path
from typing import Any
import numpy
from onvif import ONVIFCamera, ONVIFError, ONVIFService
from zeep.exceptions import Fault, TransportError
from onvif import ONVIFCamera, ONVIFService
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig, ZoomingModeEnum
@@ -41,6 +40,14 @@ class OnvifCommandEnum(str, Enum):
focus_out = "focus_out"
PAN_TILT_VELOCITY = {
OnvifCommandEnum.move_left: (-0.5, 0),
OnvifCommandEnum.move_right: (0.5, 0),
OnvifCommandEnum.move_up: (0, 0.5),
OnvifCommandEnum.move_down: (0, -0.5),
}
class OnvifController:
ptz_metrics: dict[str, PTZMetrics]
@@ -61,14 +68,6 @@ class OnvifController:
self.loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
self.loop_thread.start()
self.camera_configs = {}
for cam_name, cam in config.cameras.items():
if not cam.enabled:
continue
if cam.onvif.host:
self.camera_configs[cam_name] = cam
self.status_locks[cam_name] = asyncio.Lock()
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
@@ -92,8 +91,9 @@ class OnvifController:
async def _init_cameras(self) -> None:
"""Initialize all configured cameras."""
for cam_name in self.camera_configs:
await self._init_single_camera(cam_name)
for cam_name, cam in list(self.config.cameras.items()):
if cam.enabled and cam.onvif.host:
await self._init_single_camera(cam_name)
async def _poll_config_updates(self) -> None:
"""Poll for ONVIF config updates and re-initialize cameras as needed."""
@@ -129,13 +129,9 @@ class OnvifController:
async def _remove_camera(self, cam_name: str) -> None:
"""Tear down the ONVIF session for a camera removed at runtime."""
if cam_name not in self.cams and cam_name not in self.camera_configs:
return
logger.debug(f"Tearing down ONVIF for {cam_name} after camera removal")
await self._close_camera(cam_name)
self.cams.pop(cam_name, None)
self.camera_configs.pop(cam_name, None)
self.failed_cams.pop(cam_name, None)
self.status_locks.pop(cam_name, None)
@@ -143,25 +139,14 @@ class OnvifController:
"""Re-initialize a camera after config change."""
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
# close existing session before re-init
# close existing session and reset state before re-init
await self._close_camera(cam_name)
cam = self.config.cameras.get(cam_name)
if not cam or not cam.onvif.host:
# ONVIF removed from config, clean up
self.cams.pop(cam_name, None)
self.camera_configs.pop(cam_name, None)
self.failed_cams.pop(cam_name, None)
return
# update stored config and reset state
self.camera_configs[cam_name] = cam
if cam_name not in self.status_locks:
self.status_locks[cam_name] = asyncio.Lock()
self.cams.pop(cam_name, None)
self.failed_cams.pop(cam_name, None)
await self._init_single_camera(cam_name)
cam = self.config.cameras.get(cam_name)
if cam and cam.onvif.host:
await self._init_single_camera(cam_name)
async def _init_single_camera(self, cam_name: str) -> bool:
"""Initialize a single camera by name.
@@ -172,11 +157,12 @@ class OnvifController:
Returns:
bool: True if initialization succeeded, False otherwise
"""
if cam_name not in self.camera_configs:
cam = self.config.cameras.get(cam_name)
if cam is None:
logger.error(f"No configuration found for camera {cam_name}")
return False
cam = self.camera_configs[cam_name]
self.status_locks.setdefault(cam_name, asyncio.Lock())
try:
self.cams[cam_name] = {
"onvif": ONVIFCamera(
@@ -195,12 +181,11 @@ class OnvifController:
"profiles": [],
}
return True
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
# track initial failures
self.failed_cams[cam_name] = {
"retry_attempts": 0,
"last_error": str(e),
"last_attempt": time.time(),
}
return False
@@ -211,7 +196,8 @@ class OnvifController:
if camera_config is None:
return False
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
cam = self.cams[camera_name]
onvif: ONVIFCamera = cam["onvif"]
try:
await onvif.update_xaddrs()
except Exception as e:
@@ -226,7 +212,7 @@ class OnvifController:
# this will fire an exception if camera is not a ptz
capabilities = onvif.get_definition("ptz")
logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}")
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
)
@@ -235,7 +221,7 @@ class OnvifController:
try:
profiles = await media.GetProfiles()
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
)
@@ -254,7 +240,7 @@ class OnvifController:
]
# store available profiles for API response and log for debugging
self.cams[camera_name]["profiles"] = [
cam["profiles"] = [
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
for p in valid_profiles
]
@@ -267,19 +253,18 @@ class OnvifController:
)
configured_profile = camera_config.onvif.profile
profile = None
if configured_profile is not None:
# match by exact token first, then by name
for p in valid_profiles:
if p.token == configured_profile:
profile = p
break
if profile is None:
for p in valid_profiles:
if getattr(p, "Name", None) == configured_profile:
profile = p
break
profile = next(
(
p
for key in ("token", "Name")
for p in valid_profiles
if getattr(p, key, None) == configured_profile
),
None,
)
if profile is None:
available = [
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
@@ -304,39 +289,30 @@ class OnvifController:
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
# get the PTZ config for the profile
try:
configs = profile.PTZConfiguration
logger.debug(
f"Onvif ptz config for media profile in {camera_name}: {configs}"
)
except Exception as e:
logger.error(
f"Invalid Onvif PTZ configuration for camera: {camera_name}: {e}"
)
return False
configs = profile.PTZConfiguration
logger.debug(f"Onvif ptz config for media profile in {camera_name}: {configs}")
ptz: ONVIFService = await onvif.create_ptz_service()
self.cams[camera_name]["ptz"] = ptz
cam["ptz"] = ptz
try:
imaging: ONVIFService = await onvif.create_imaging_service()
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
imaging = None
self.cams[camera_name]["imaging"] = imaging
cam["imaging"] = imaging
try:
video_sources = await media.GetVideoSources()
if video_sources and len(video_sources) > 0:
self.cams[camera_name]["video_source_token"] = video_sources[0].token
except (Fault, ONVIFError, TransportError, Exception) as e:
cam["video_source_token"] = video_sources[0].token
except Exception as e:
logger.debug(f"Unable to get video sources for {camera_name}: {e}")
self.cams[camera_name]["video_source_token"] = None
cam["video_source_token"] = None
# setup continuous moving request
move_request = ptz.create_type("ContinuousMove")
move_request.ProfileToken = profile.token
self.cams[camera_name]["move_request"] = move_request
cam["move_request"] = move_request
# get PTZ configuration options for feature detection and relative movement
ptz_config = None
@@ -349,7 +325,7 @@ class OnvifController:
logger.debug(
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
)
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.debug(
f"Unable to get PTZ configuration options for {camera_name}: {e}"
)
@@ -375,18 +351,6 @@ class OnvifController:
autotracking_config.enabled_in_config and autotracking_config.enabled
)
# these are local and cost nothing to build, and autotracking can be enabled
# after a camera is initialized, so always create them rather than baking the
# current config value into init state
status_request = ptz.create_type("GetStatus")
status_request.ProfileToken = profile.token
self.cams[camera_name]["status_request"] = status_request
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
self.cams[camera_name]["service_capabilities_request"] = (
service_capabilities_request
)
# setup relative move request when FOV relative movement is supported
if (
fov_space_id is not None
@@ -395,9 +359,7 @@ class OnvifController:
# one-off GetStatus to seed Translation field
status = None
try:
one_off_status_request = ptz.create_type("GetStatus")
one_off_status_request.ProfileToken = profile.token
status = await ptz.GetStatus(one_off_status_request)
status = await ptz.GetStatus({"ProfileToken": profile.token})
logger.debug(f"Onvif status for {camera_name}: {status}")
except Exception as e:
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
@@ -424,7 +386,7 @@ class OnvifController:
# configure zoom on relative move request
if (
autotracking_enabled
and autotracking_config.zooming != ZoomingModeEnum.disabled
and autotracking_config.zooming == ZoomingModeEnum.relative
):
zoom_space_id = next(
(
@@ -463,21 +425,16 @@ class OnvifController:
)
if rel_move_request.Speed is None:
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
rel_move_request.Speed = configs.DefaultPTZSpeed
logger.debug(
f"{camera_name}: Relative move request after setup: {rel_move_request}"
)
self.cams[camera_name]["relative_move_request"] = rel_move_request
# setup absolute move request
abs_move_request = ptz.create_type("AbsoluteMove")
abs_move_request.ProfileToken = profile.token
self.cams[camera_name]["absolute_move_request"] = abs_move_request
cam["relative_move_request"] = rel_move_request
# setup existing presets
try:
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
presets = []
@@ -490,7 +447,7 @@ class OnvifController:
preset_name = preset_name.encode("latin-1").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"]
cam["presets"][preset_name.lower()] = preset["token"]
# get list of supported features
supported_features = []
@@ -504,64 +461,40 @@ class OnvifController:
if configs.DefaultRelativePanTiltTranslationSpace:
supported_features.append("pt-r")
spaces = getattr(ptz_config, "Spaces", None)
if configs.DefaultRelativeZoomTranslationSpace:
supported_features.append("zoom-r")
if ptz_config is not None:
try:
self.cams[camera_name]["relative_zoom_range"] = (
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
)
except Exception as e:
if autotracking_config.zooming == ZoomingModeEnum.relative:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
)
if getattr(spaces, "RelativeZoomTranslationSpace", None):
cam["relative_zoom_range"] = spaces.RelativeZoomTranslationSpace[0]
if configs.DefaultAbsoluteZoomPositionSpace:
supported_features.append("zoom-a")
if ptz_config is not None:
try:
self.cams[camera_name]["absolute_zoom_range"] = (
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
)
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
except Exception as e:
if autotracking_config.zooming != ZoomingModeEnum.disabled:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
)
if getattr(spaces, "AbsoluteZoomPositionSpace", None):
cam["absolute_zoom_range"] = spaces.AbsoluteZoomPositionSpace[0]
# disable autotracking zoom if required ranges are unavailable
if autotracking_config.zooming != ZoomingModeEnum.disabled:
if autotracking_config.zooming == ZoomingModeEnum.relative:
if "relative_zoom_range" not in self.cams[camera_name]:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Relative zoom range unavailable"
)
if autotracking_config.zooming == ZoomingModeEnum.absolute:
if "absolute_zoom_range" not in self.cams[camera_name]:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Absolute zoom range unavailable"
)
if (
self.cams[camera_name]["video_source_token"] is not None
and imaging is not None
# autotracking zoom needs the range for its mode, and get_camera_status
# reads the absolute range in both modes
zooming = autotracking_config.zooming
if zooming != ZoomingModeEnum.disabled and (
"absolute_zoom_range" not in cam or f"{zooming.value}_zoom_range" not in cam
):
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: {zooming.value} zoom range unavailable"
)
if cam["video_source_token"] is not None and imaging is not None:
try:
imaging_capabilities = await imaging.GetImagingSettings(
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
{"VideoSourceToken": cam["video_source_token"]}
)
if (
hasattr(imaging_capabilities, "Focus")
and imaging_capabilities.Focus
):
supported_features.append("focus")
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.debug(f"Focus not supported for {camera_name}: {e}")
# detect FOV relative movement support
@@ -570,17 +503,18 @@ class OnvifController:
and configs.DefaultRelativePanTiltTranslationSpace is not None
):
supported_features.append("pt-r-fov")
self.cams[camera_name]["relative_fov_range"] = (
cam["relative_fov_range"] = (
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
)
self.cams[camera_name]["features"] = supported_features
self.cams[camera_name]["init"] = True
cam["features"] = supported_features
cam["init"] = True
return True
async def _stop(self, camera_name: str) -> None:
move_request = self.cams[camera_name]["move_request"]
await self.cams[camera_name]["ptz"].Stop(
cam = self.cams[camera_name]
move_request = cam["move_request"]
await cam["ptz"].Stop(
{
"ProfileToken": move_request.ProfileToken,
"PanTilt": True,
@@ -588,88 +522,75 @@ class OnvifController:
}
)
if (
"focus" in self.cams[camera_name]["features"]
and self.cams[camera_name]["video_source_token"]
and self.cams[camera_name]["imaging"] is not None
"focus" in cam["features"]
and cam["video_source_token"]
and cam["imaging"] is not None
):
try:
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
stop_request.VideoSourceToken = self.cams[camera_name][
"video_source_token"
]
await self.cams[camera_name]["imaging"].Stop(stop_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
stop_request = cam["imaging"].create_type("Stop")
stop_request.VideoSourceToken = cam["video_source_token"]
await cam["imaging"].Stop(stop_request)
except Exception as e:
logger.warning(f"Failed to stop focus for {camera_name}: {e}")
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
cam = self.cams[camera_name]
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, stopping..."
)
await self._stop(camera_name)
if "pt" not in self.cams[camera_name]["features"]:
if "pt" not in cam["features"]:
logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
return
self.cams[camera_name]["active"] = True
move_request = self.cams[camera_name]["move_request"]
cam["active"] = True
move_request = cam["move_request"]
if command == OnvifCommandEnum.move_left:
move_request.Velocity = {"PanTilt": {"x": -0.5, "y": 0}}
elif command == OnvifCommandEnum.move_right:
move_request.Velocity = {"PanTilt": {"x": 0.5, "y": 0}}
elif command == OnvifCommandEnum.move_up:
move_request.Velocity = {
"PanTilt": {
"x": 0,
"y": 0.5,
}
}
elif command == OnvifCommandEnum.move_down:
move_request.Velocity = {
"PanTilt": {
"x": 0,
"y": -0.5,
}
}
x, y = PAN_TILT_VELOCITY[command]
move_request.Velocity = {"PanTilt": {"x": x, "y": y}}
try:
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
await cam["ptz"].ContinuousMove(move_request)
except Exception as e:
logger.warning(f"Onvif sending move request to {camera_name} failed: {e}")
async def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
if "pt-r-fov" not in self.cams[camera_name]["features"]:
cam = self.cams[camera_name]
if "pt-r-fov" not in cam["features"]:
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
return
metrics = self.ptz_metrics.get(camera_name)
camera_config = self.config.cameras.get(camera_name)
if metrics is None:
if metrics is None or camera_config is None:
return
logger.debug(
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
)
if self.cams[camera_name]["active"]:
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
return
self.cams[camera_name]["active"] = True
cam["active"] = True
# only track start_time for autotracking
if metrics.autotracker_enabled.value:
if camera_config.onvif.autotracking.enabled:
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"]
move_request = cam["relative_move_request"]
# function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera.
# The onvif spec says this can report as +INF and -INF, so this may need to be modified
@@ -677,55 +598,49 @@ class OnvifController:
pan,
[-1, 1],
[
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"],
cam["relative_fov_range"]["XRange"]["Min"],
cam["relative_fov_range"]["XRange"]["Max"],
],
)
tilt = numpy.interp(
tilt,
[-1, 1],
[
self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"],
cam["relative_fov_range"]["YRange"]["Min"],
cam["relative_fov_range"]["YRange"]["Max"],
],
)
move_request.Speed = {
"PanTilt": {
"x": speed,
"y": speed,
},
}
move_speed = {"PanTilt": {"x": speed, "y": speed}}
move_request.Translation.PanTilt.x = pan
move_request.Translation.PanTilt.y = tilt
# include zoom if requested and camera supports relative zoom
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
move_request.Speed = {
"PanTilt": {
"x": speed,
"y": speed,
},
"Zoom": {"x": speed},
}
include_zoom = zoom != 0 and "zoom-r" in cam["features"]
if include_zoom:
move_speed["Zoom"] = {"x": speed}
move_request["Translation"]["Zoom"] = {"x": zoom}
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
move_request.Speed = move_speed
await cam["ptz"].RelativeMove(move_request)
# reset after the move request
move_request.Translation.PanTilt.x = 0
move_request.Translation.PanTilt.y = 0
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
if include_zoom:
del move_request["Translation"]["Zoom"]
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _move_to_preset(self, camera_name: str, preset: str) -> None:
cam = self.cams[camera_name]
preset = preset.lower()
if preset not in self.cams[camera_name]["presets"]:
if preset not in cam["presets"]:
logger.error(f"{preset} is not a valid preset for {camera_name}")
return
@@ -734,44 +649,48 @@ class OnvifController:
if metrics is None:
return
self.cams[camera_name]["active"] = True
cam["active"] = True
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]
move_request = cam["move_request"]
preset_token = cam["presets"][preset]
await self.cams[camera_name]["ptz"].GotoPreset(
await cam["ptz"].GotoPreset(
{
"ProfileToken": move_request.ProfileToken,
"PresetToken": preset_token,
}
)
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
cam = self.cams[camera_name]
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, stopping..."
)
await self._stop(camera_name)
if "zoom" not in self.cams[camera_name]["features"]:
if "zoom" not in cam["features"]:
logger.error(f"{camera_name} does not support ONVIF zooming.")
return
self.cams[camera_name]["active"] = True
move_request = self.cams[camera_name]["move_request"]
cam["active"] = True
move_request = cam["move_request"]
if command == OnvifCommandEnum.zoom_in:
move_request.Velocity = {"Zoom": {"x": 0.5}}
elif command == OnvifCommandEnum.zoom_out:
move_request.Velocity = {"Zoom": {"x": -0.5}}
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
await cam["ptz"].ContinuousMove(move_request)
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
if "zoom-a" not in self.cams[camera_name]["features"]:
cam = self.cams[camera_name]
if "zoom-a" not in cam["features"]:
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
return
@@ -782,56 +701,59 @@ class OnvifController:
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
if self.cams[camera_name]["active"]:
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
return
self.cams[camera_name]["active"] = True
cam["active"] = True
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.
zoom = numpy.interp(
zoom,
[0, 1],
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
cam["absolute_zoom_range"]["XRange"]["Min"],
cam["absolute_zoom_range"]["XRange"]["Max"],
],
)
move_request.Speed = {"Zoom": speed}
move_request.Position = {"Zoom": zoom}
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
await self.cams[camera_name]["ptz"].AbsoluteMove(move_request)
await cam["ptz"].AbsoluteMove(
{
"ProfileToken": cam["move_request"].ProfileToken,
"Position": {"Zoom": zoom},
"Speed": {"Zoom": speed},
}
)
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
cam = self.cams[camera_name]
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
await self._stop(camera_name)
if (
"focus" not in self.cams[camera_name]["features"]
or not self.cams[camera_name]["video_source_token"]
or self.cams[camera_name]["imaging"] is None
"focus" not in cam["features"]
or not cam["video_source_token"]
or cam["imaging"] is None
):
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
return
self.cams[camera_name]["active"] = True
move_request = self.cams[camera_name]["imaging"].create_type("Move")
move_request.VideoSourceToken = self.cams[camera_name]["video_source_token"]
cam["active"] = True
move_request = cam["imaging"].create_type("Move")
move_request.VideoSourceToken = cam["video_source_token"]
move_request.Focus = {
"Continuous": {
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
@@ -839,10 +761,10 @@ class OnvifController:
}
try:
await self.cams[camera_name]["imaging"].Move(move_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
await cam["imaging"].Move(move_request)
except Exception as e:
logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}")
self.cams[camera_name]["active"] = False
cam["active"] = False
async def handle_command_async(
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
@@ -883,7 +805,7 @@ class OnvifController:
await self._focus(camera_name, command)
else:
await self._move(camera_name, command)
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(f"Unable to handle onvif command: {e}")
def handle_command(
@@ -906,6 +828,15 @@ class OnvifController:
f"Error executing command {command} for camera {camera_name}: {e}"
)
def _camera_info(self, camera_name: str) -> dict[str, Any]:
cam = self.cams[camera_name]
return {
"name": camera_name,
"features": cam["features"],
"presets": list(cam["presets"]),
"profiles": cam["profiles"],
}
async def get_camera_info(self, camera_name: str) -> dict[str, Any]:
"""
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
@@ -929,60 +860,21 @@ class OnvifController:
return {}
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
return {
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
"profiles": self.cams[camera_name].get("profiles", []),
}
return self._camera_info(camera_name)
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
success = await self._init_single_camera(camera_name)
if not success:
return {}
# Reset retry count after timeout
attempts = self.failed_cams.get(camera_name, {}).get("retry_attempts", 0)
last_attempt = self.failed_cams.get(camera_name, {}).get("last_attempt", 0)
failed = self.failed_cams.get(camera_name, {})
attempts = failed.get("retry_attempts", 0)
last_attempt = failed.get("last_attempt", 0)
# Reset retry count after timeout
if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
logger.debug(f"Resetting retry count for {camera_name} after timeout")
attempts = 0
self.failed_cams[camera_name]["retry_attempts"] = 0
# Attempt initialization/reconnection
if attempts < self.max_retries:
logger.info(
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
)
try:
if await self._init_onvif(camera_name):
if camera_name in self.failed_cams:
del self.failed_cams[camera_name]
return {
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
}
else:
logger.warning(f"ONVIF initialization failed for {camera_name}")
self.failed_cams[camera_name] = {
"retry_attempts": attempts + 1,
"last_attempt": time.time(),
}
except Exception as e:
logger.error(
f"Error during ONVIF initialization for {camera_name}: {e}"
)
if camera_name not in self.failed_cams:
self.failed_cams[camera_name] = {"retry_attempts": 0}
self.failed_cams[camera_name].update(
{
"retry_attempts": attempts + 1,
"last_error": str(e),
"last_attempt": time.time(),
}
)
if attempts >= self.max_retries:
remaining_time = max(
@@ -991,8 +883,24 @@ class OnvifController:
logger.error(
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
)
return {}
logger.debug(f"Could not initialize ONVIF for {camera_name}")
logger.info(
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
)
try:
if await self._init_onvif(camera_name):
self.failed_cams.pop(camera_name, None)
return self._camera_info(camera_name)
logger.warning(f"ONVIF initialization failed for {camera_name}")
except Exception as e:
logger.error(f"Error during ONVIF initialization for {camera_name}: {e}")
self.failed_cams[camera_name] = {
"retry_attempts": attempts + 1,
"last_attempt": time.time(),
}
return {}
async def get_service_capabilities(self, camera_name: str) -> None:
@@ -1000,16 +908,13 @@ class OnvifController:
logger.error(f"ONVIF is not configured for {camera_name}")
return {}
if not self.cams[camera_name]["init"]:
cam = self.cams[camera_name]
if not cam["init"]:
await self._init_onvif(camera_name)
service_capabilities_request = self.cams[camera_name][
"service_capabilities_request"
]
try:
service_capabilities = await self.cams[camera_name][
"ptz"
].GetServiceCapabilities(service_capabilities_request)
service_capabilities = await cam["ptz"].GetServiceCapabilities()
logger.debug(
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
@@ -1035,13 +940,16 @@ class OnvifController:
if metrics is None or camera_config is None:
return
if not self.cams[camera_name]["init"]:
cam = self.cams[camera_name]
if not cam["init"]:
if not await self._init_onvif(camera_name):
return
status_request = self.cams[camera_name]["status_request"]
try:
status = await self.cams[camera_name]["ptz"].GetStatus(status_request)
status = await cam["ptz"].GetStatus(
{"ProfileToken": cam["move_request"].ProfileToken}
)
except Exception:
pass # We're unsupported, that'll be reported in the next check.
@@ -1072,7 +980,7 @@ class OnvifController:
if pan_tilt_status == "IDLE" and (
zoom_status is None or zoom_status == "IDLE"
):
self.cams[camera_name]["active"] = False
cam["active"] = False
if not metrics.motor_stopped.is_set():
metrics.motor_stopped.set()
@@ -1082,7 +990,7 @@ class OnvifController:
metrics.stop_time.value = metrics.frame_time.value
else:
self.cams[camera_name]["active"] = True
cam["active"] = True
if metrics.motor_stopped.is_set():
metrics.motor_stopped.clear()
@@ -1098,8 +1006,8 @@ class OnvifController:
metrics.zoom_level.value = numpy.interp(
round(status.Position.Zoom.x, 2),
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
cam["absolute_zoom_range"]["XRange"]["Min"],
cam["absolute_zoom_range"]["XRange"]["Max"],
],
[0, 1],
)
@@ -1138,7 +1046,7 @@ class OnvifController:
def close(self) -> None:
"""Gracefully shut down the ONVIF controller."""
if not hasattr(self, "loop") or self.loop.is_closed():
if self.loop.is_closed():
logger.debug("ONVIF controller already closed")
return
@@ -1155,13 +1063,6 @@ class OnvifController:
self.config_subscriber.stop()
def stop_and_cleanup():
try:
self.loop.stop()
except Exception as e:
logger.error(f"Error during loop cleanup: {e}")
# Schedule stop and cleanup in the loop thread
self.loop.call_soon_threadsafe(stop_and_cleanup)
self.loop.call_soon_threadsafe(self.loop.stop)
self.loop_thread.join()
+17 -1
View File
@@ -236,6 +236,15 @@ def skipped_percent(skipped_fps: float, camera_fps: float, enabled: bool) -> flo
return round(skipped_fps / camera_fps * 100, 1)
def get_go2rtc_pid(cpu_usages: dict[str, dict[str, Any]]) -> int | None:
"""Find the pid of the running go2rtc process in the cpu usages."""
for pid, usage in cpu_usages.items():
if usage.get("cmdline", "").split(" ")[0].endswith("/go2rtc"):
return int(pid)
return None
def stats_snapshot(
config: FrigateConfig,
stats_tracking: StatsTrackingTypes,
@@ -356,6 +365,14 @@ def stats_snapshot(
stats["service"]["storage"]["/dev/shm"] = calculate_shm_requirements(config)
cpu_usages = stats.get("cpu_usages", {})
# go2rtc is supervised by s6, so its pid changes when s6 restarts it
go2rtc_pid = get_go2rtc_pid(cpu_usages)
if go2rtc_pid is not None:
stats_tracking["processes"]["go2rtc"] = go2rtc_pid
stats["processes"] = {}
for name, pid in stats_tracking["processes"].items():
stats["processes"][name] = {
@@ -364,7 +381,6 @@ def stats_snapshot(
# Embed cpu/mem stats into detectors, cameras, and processes
# so history consumers don't need the full cpu_usages dict
cpu_usages = stats.get("cpu_usages", {})
for det_stats in stats["detectors"].values():
pid_str = str(det_stats.get("pid", ""))
+25
View File
@@ -4,6 +4,7 @@ from unittest.mock import Mock, patch
import frigate.genai
from frigate.config import GenAIProviderEnum
from frigate.config.env import FRIGATE_ENV_VARS
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment
@@ -111,6 +112,30 @@ class TestHttpApp(BaseTestHttp):
mqtt = response.json()["mqtt"]
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
def test_config_response_hides_notification_email_from_viewers(self):
self.minimal_config["notifications"] = {"email": "{FRIGATE_TEST_EMAIL}"}
with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_TEST_EMAIL": "me@example.com"}):
app = super().create_app()
assert app.frigate_config.notifications.email == "me@example.com"
with AuthTestClient(app) as client:
response = client.get(
"/config",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
assert response.status_code == 200
config = response.json()
assert config["notifications"]["email"] == REDACTED_CREDENTIAL_SENTINEL
assert (
config["cameras"]["front_door"]["notifications"]["email"]
== REDACTED_CREDENTIAL_SENTINEL
)
response = client.get("/config")
assert response.json()["notifications"]["email"] == "me@example.com"
def test_config_response_keeps_plus_model_reference(self):
model_id = "test_plus_reference"
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
+5 -6
View File
@@ -59,7 +59,6 @@ def build_watchdog(
MagicMock(),
)
watchdog.requestor = MagicMock()
return watchdog
@@ -108,8 +107,8 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
def test_status_goes_to_the_matching_role_topic(self):
watchdog = self._build_watchdog()
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0)
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog.record_status[STREAM_TYPE_SUB].send("offline", 100.0)
watchdog.requestor.send_data.assert_any_call(
"front_door/status/record", "online"
@@ -121,9 +120,9 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
def test_status_is_cached_per_stream(self):
watchdog = self._build_watchdog()
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog.record_status[STREAM_TYPE_SUB].send("online", 100.0)
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
assert watchdog.requestor.send_data.call_count == 2
+26
View File
@@ -6,6 +6,7 @@ from copy import deepcopy
from unittest.mock import patch
import numpy as np
import requests
from pydantic import ValidationError
from ruamel.yaml.constructor import DuplicateKeyError
@@ -1595,6 +1596,31 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**config)
assert frigate_config.primary_model.merged_labelmap[0] == "amazon"
@patch(
"frigate.plus.PlusApi.get_model_download_url",
side_effect=requests.exceptions.ConnectionError,
)
def test_plus_unreachable_is_validation_error(self, _):
config = {
"mqtt": {"host": "mqtt"},
"models": [{"path": "plus://unreachable", "devices": ["cpu"]}],
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
}
},
}
with self.assertRaisesRegex(ValidationError, "Unable to connect to Frigate+"):
FrigateConfig(**config)
def test_fails_on_invalid_role(self):
config = {
"mqtt": {"host": "mqtt"},
+70
View File
@@ -0,0 +1,70 @@
"""Tests for resolving the go2rtc pid from cpu usages."""
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
from frigate.stats.util import get_go2rtc_pid, stats_snapshot
class TestGo2rtcPid(unittest.TestCase):
def test_finds_go2rtc_by_binary_path(self):
cpu_usages = {
"frigate.full_system": {"cpu": "1.0", "mem": "2.0"},
"100": {"cmdline": "ffmpeg -i rtsp://127.0.0.1:8554/go2rtc_cam"},
"200": {
"cmdline": "/usr/local/go2rtc/bin/go2rtc -config=/dev/shm/go2rtc.yaml"
},
"300": {"cmdline": "frigate.recording"},
}
self.assertEqual(get_go2rtc_pid(cpu_usages), 200)
def test_finds_custom_go2rtc_binary(self):
self.assertEqual(get_go2rtc_pid({"42": {"cmdline": "/config/go2rtc"}}), 42)
def test_returns_none_when_go2rtc_is_not_running(self):
self.assertIsNone(get_go2rtc_pid({"100": {"cmdline": "ffmpeg -i x"}}))
self.assertIsNone(get_go2rtc_pid({}))
class TestGo2rtcPidInSnapshot(unittest.TestCase):
def snapshot(self, tracking: dict, go2rtc_pid: int) -> dict:
def update_stats(stats: dict) -> None:
stats["cpu_usages"] = {
str(go2rtc_pid): {
"cmdline": "/usr/local/go2rtc/bin/go2rtc -config=x",
"cpu": str(go2rtc_pid / 100),
"mem": str(go2rtc_pid / 10),
}
}
config = SimpleNamespace(
cameras={},
telemetry=SimpleNamespace(stats=SimpleNamespace(network_bandwidth=False)),
)
hardware_stats = Mock()
hardware_stats.update_stats.side_effect = update_stats
with (
patch("frigate.stats.util.get_detector_stats", return_value={}),
patch("frigate.stats.util.embeddings_stats", return_value={}),
patch("frigate.stats.util.calculate_shm_requirements", return_value={}),
):
return stats_snapshot(config, tracking, hardware_stats)
def test_snapshot_follows_go2rtc_restart(self):
tracking = {
"camera_metrics": {},
"detectors": {},
"started": 0,
"latest_frigate_version": "",
"processes": {"go2rtc": 200, "recording": 50},
"storage_maintainer": None,
}
first = self.snapshot(tracking, 200)["processes"]["go2rtc"]
self.assertEqual(first, {"pid": 200, "cpu": "2.0", "mem": "20.0"})
restarted = self.snapshot(tracking, 300)["processes"]["go2rtc"]
self.assertEqual(restarted, {"pid": 300, "cpu": "3.0", "mem": "30.0"})
-1
View File
@@ -29,7 +29,6 @@ class TestImprovedMotionDetector(unittest.TestCase):
class DummyPTZ:
def __init__(self):
self.autotracker_enabled = _Stub(False)
self.motor_stopped = _Stub(False)
self.stop_time = _Stub(0)
+43 -40
View File
@@ -7,9 +7,8 @@ KeyError on the autotracker thread or silently keep the wrong state:
- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
constructed, so runtime-enabled cameras raised KeyError on lookup.
- ptz_metrics autotracker_enabled is what the camera processes read, but nothing
updated it when autotracking was enabled through a config save, so it stayed
False and the tracker never built a motion estimator.
- _disable only changed the main process config, so the camera process kept
running its motion estimator for a camera that could not autotrack.
"""
import unittest
@@ -17,7 +16,8 @@ from unittest.mock import MagicMock
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig
from frigate.ptz.autotrack import PtzAutoTracker
from frigate.config.camera.updater import CameraConfigUpdateEnum
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
CAMERA = "ptz_cam"
@@ -53,8 +53,9 @@ def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker:
onvif over the network. Only the config/metrics state is relevant here."""
tracker = PtzAutoTracker.__new__(PtzAutoTracker)
tracker.config = _config(autotracking_enabled)
tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)}
tracker.ptz_metrics = {CAMERA: PTZMetrics()}
tracker.onvif = MagicMock()
tracker.dispatcher = MagicMock()
tracker.config_subscriber = MagicMock()
tracker.autotracker_init = {}
tracker.calibrating = {}
@@ -83,47 +84,49 @@ class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
tracker.onvif.get_camera_status.assert_not_called()
class TestAutotrackerMetricSync(unittest.TestCase):
def test_metric_follows_config_when_enabled_by_update(self) -> None:
# autotracking enabled via a config save: the metric was seeded False when
# the camera was added and nothing else updates it
class TestAutotrackerEnqueueMove(unittest.TestCase):
def _enqueue(self, pan: float, tilt: float, zoom: float) -> MagicMock:
tracker = _make_tracker()
tracker.move_queues = {CAMERA: MagicMock()}
tracker.move_queue_locks = {CAMERA: MagicMock()}
tracker.move_queue_locks[CAMERA].locked.return_value = False
tracker._enqueue_move(CAMERA, 1000.0, pan, tilt, zoom)
return tracker.onvif.loop.call_soon_threadsafe
def test_move_is_clipped_to_the_onvif_range(self) -> None:
# velocity estimates can push the predicted centroid outside the frame
call_soon = self._enqueue(1.7, -2.5, 0.4)
call_soon.assert_called_once()
self.assertEqual(call_soon.call_args.args[1], (1000.0, 1.0, -1.0, 0.4))
def test_empty_move_is_not_enqueued(self) -> None:
self._enqueue(0, 0, 0).assert_not_called()
class TestAutotrackerDisable(unittest.TestCase):
def test_disable_publishes_to_camera_process(self) -> None:
tracker = _make_tracker(autotracking_enabled=True)
metrics = tracker.ptz_metrics[CAMERA]
self.assertFalse(metrics.autotracker_enabled.value)
tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]}
tracker.check_for_updates()
tracker._disable(CAMERA, "onvif connection failed")
self.assertTrue(metrics.autotracker_enabled.value)
autotracking = tracker.config.cameras[CAMERA].onvif.autotracking
self.assertFalse(autotracking.enabled)
def test_metric_follows_config_when_disabled_by_update(self) -> None:
tracker = _make_tracker(autotracking_enabled=False)
metrics = tracker.ptz_metrics[CAMERA]
metrics.autotracker_enabled.value = True
publish = tracker.dispatcher.config_updater.publish_update
publish.assert_called_once()
topic, payload = publish.call_args.args
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.autotracking)
self.assertEqual(topic.camera, CAMERA)
self.assertIs(payload, autotracking)
tracker.config_subscriber.check_for_updates.return_value = {
"autotracking": [CAMERA]
}
tracker.check_for_updates()
self.assertFalse(metrics.autotracker_enabled.value)
def test_metric_sync_skips_camera_without_metrics(self) -> None:
# `add` reaches the maintainer and the autotracker on separate threads with
# no ordering guarantee, so the metrics may not exist yet
tracker = _make_tracker()
tracker.ptz_metrics = {}
tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]}
tracker.check_for_updates()
def test_metric_sync_skips_unknown_camera(self) -> None:
tracker = _make_tracker()
tracker.config_subscriber.check_for_updates.return_value = {
"add": ["not_in_config"]
}
tracker.check_for_updates()
class TestMaxTargetBox(unittest.TestCase):
def test_follows_zoom_factor(self) -> None:
self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
self.assertAlmostEqual(calculate_max_target_box(0.25), 0.6**4)
if __name__ == "__main__":
+17 -39
View File
@@ -2,14 +2,9 @@
Regression coverage for a camera that is initialized while autotracking is off and
has it enabled later, which is the normal wizard flow: set the camera up first,
configure autotracking afterwards. The autotracking-only request objects used to
be created only when autotracking was enabled at init time, so the camera was left
with init=True but no status_request. get_camera_status skips its re-init branch
when init is True, so it went straight to the missing key and raised KeyError on
the tracking thread.
The request objects are built from the locally parsed WSDL and cost no network, so
they are always created and init=True now implies they exist.
configure autotracking afterwards. get_camera_status skips its re-init branch when
init is True, so everything it reads must exist whether or not autotracking was
enabled at init time.
Also covers the inverse direction: the ptz movement timestamps must not be written
for a camera that has autotracking off, because nothing clears them back out.
@@ -99,7 +94,6 @@ def _make_controller(autotracking_enabled: bool) -> OnvifController:
controller.config = config
controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
controller.failed_cams = {}
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
controller.ptz_metrics = {CAMERA: MagicMock()}
return controller
@@ -110,7 +104,6 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
config = _config(autotracking_enabled)
controller = OnvifController.__new__(OnvifController)
controller.config = config
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
controller.failed_cams = {}
ptz = MagicMock()
@@ -128,45 +121,30 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
},
}
}
controller.ptz_metrics = {
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
}
controller.ptz_metrics = {CAMERA: PTZMetrics()}
return controller
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
async def test_status_request_created_when_autotracking_disabled(self) -> None:
async def test_camera_status_independent_of_autotracking_at_init(self) -> None:
# the wizard flow: onvif configured first, autotracking enabled later
controller = _make_controller(autotracking_enabled=False)
self.assertTrue(await controller._init_onvif(CAMERA))
cam = controller.cams[CAMERA]
self.assertTrue(cam["init"])
self.assertIn("status_request", cam)
self.assertIn("service_capabilities_request", cam)
async def test_status_request_created_when_autotracking_enabled(self) -> None:
controller = _make_controller(autotracking_enabled=True)
self.assertTrue(await controller._init_onvif(CAMERA))
cam = controller.cams[CAMERA]
self.assertIn("status_request", cam)
self.assertIn("service_capabilities_request", cam)
async def test_init_implies_status_request_exists(self) -> None:
# the invariant get_camera_status relies on: it skips re-init when init is
# True and then reads status_request without guarding
for autotracking_enabled in (True, False):
with self.subTest(autotracking_enabled=autotracking_enabled):
controller = _make_controller(autotracking_enabled)
controller.status_locks = {CAMERA: asyncio.Lock()}
await controller._init_onvif(CAMERA)
self.assertTrue(await controller._init_onvif(CAMERA))
cam = controller.cams[CAMERA]
if cam["init"]:
self.assertEqual(cam["status_request"].request_type, "GetStatus")
status = MagicMock()
status.MoveStatus.PanTilt = "IDLE"
status.MoveStatus.Zoom = "IDLE"
ptz = controller.cams[CAMERA]["ptz"]
ptz.GetStatus = AsyncMock(return_value=status)
await controller.get_camera_status(CAMERA)
ptz.GetStatus.assert_awaited_once_with({"ProfileToken": "profile_1"})
self.assertFalse(controller.cams[CAMERA]["active"])
async def test_requests_built_without_contacting_camera(self) -> None:
# create_type is a local WSDL lookup; cameras that do not implement
+4 -4
View File
@@ -223,7 +223,7 @@ class NorfairTracker(ObjectTracker):
),
}
if self.ptz_metrics.autotracker_enabled.value:
if self.camera_config.onvif.autotracking.enabled:
self.ptz_motion_estimator = PtzMotionEstimator(
self.camera_config, self.ptz_metrics
)
@@ -515,7 +515,7 @@ class NorfairTracker(ObjectTracker):
yuv_frame: np.ndarray | None = None
if (
self.ptz_metrics.autotracker_enabled.value
self.camera_config.onvif.autotracking.enabled
or self.detect_config.stationary.classifier
):
yuv_frame = self.frame_manager.get(
@@ -534,7 +534,7 @@ class NorfairTracker(ObjectTracker):
points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]])
embedding = None
if self.ptz_metrics.autotracker_enabled.value:
if self.camera_config.onvif.autotracking.enabled:
embedding = get_histogram(
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
)
@@ -559,7 +559,7 @@ class NorfairTracker(ObjectTracker):
coord_transformations = None
if self.ptz_metrics.autotracker_enabled.value:
if self.camera_config.onvif.autotracking.enabled:
# we must have been enabled by mqtt, so set up the estimator
if not self.ptz_motion_estimator:
self.ptz_motion_estimator = PtzMotionEstimator(
+4 -4
View File
@@ -41,7 +41,7 @@ from frigate.const import (
)
from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.models import Event, ReviewSegment, Timeline
from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.ptz.autotrack import PtzAutoTracker
from frigate.track.tracked_object import TrackedObject
from frigate.util.image import SharedMemoryFrameManager
@@ -60,7 +60,7 @@ class TrackedObjectProcessor(threading.Thread):
config: FrigateConfig,
dispatcher: Dispatcher,
tracked_objects_queue: MpQueue,
ptz_autotracker_thread: PtzAutoTrackerThread,
ptz_autotracker_thread: PtzAutoTracker,
stop_event: MpEvent,
) -> None:
super().__init__(name="detected_frames_processor")
@@ -153,7 +153,7 @@ class TrackedObjectProcessor(threading.Thread):
)
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None:
self.ptz_autotracker_thread.ptz_autotracker.autotrack_object(camera, obj)
self.ptz_autotracker_thread.autotrack_object(camera, obj)
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
# populate has_snapshot
@@ -177,7 +177,7 @@ class TrackedObjectProcessor(threading.Thread):
"type": "end",
}
self.dispatcher.publish("events", json.dumps(message), retain=False)
self.ptz_autotracker_thread.ptz_autotracker.end_object(camera, obj)
self.ptz_autotracker_thread.end_object(camera, obj)
self.event_sender.publish(
(
+10 -2
View File
@@ -94,6 +94,7 @@ class CameraTracker(FrigateProcess):
self.config.detect.fps,
name=self.config.name,
ptz_metrics=self.ptz_metrics,
autotracking_enabled=self.config.onvif.autotracking.enabled,
)
object_detector = RemoteObjectDetector(
self.config.name,
@@ -195,10 +196,12 @@ def process_frames(
None,
{camera_config.name: camera_config},
[
CameraConfigUpdateEnum.autotracking,
CameraConfigUpdateEnum.detect,
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.motion,
CameraConfigUpdateEnum.objects,
CameraConfigUpdateEnum.onvif,
],
)
@@ -235,6 +238,11 @@ def process_frames(
motion_detector.config = camera_config.motion
motion_detector.update_mask()
if "autotracking" in updated_configs or "onvif" in updated_configs:
motion_detector.autotracking_enabled = (
camera_config.onvif.autotracking.enabled
)
if (
not camera_enabled
and prev_enabled != camera_enabled
@@ -349,8 +357,8 @@ def process_frames(
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
# the ptz timestamps are only maintained while autotracking is on, so gate
# on the metric rather than trusting them to be reset otherwise
ptz_moving = ptz_metrics.autotracker_enabled.value and (
# on the config rather than trusting them to be reset otherwise
ptz_moving = camera_config.onvif.autotracking.enabled and (
ptz_moving_at_frame_time(
frame_time,
ptz_metrics.start_time.value,
+48 -43
View File
@@ -6,6 +6,7 @@ import subprocess as sp
import threading
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from multiprocessing import Queue, Value
from multiprocessing.synchronize import Event as MpEvent
@@ -108,6 +109,27 @@ def capture_frames(
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
@dataclass
class RoleStatus:
"""Publishes a role's status when it changes or the resend interval elapses."""
requestor: InterProcessRequestor
topic: str
resend_interval: float
last_status: str | None = None
last_update_time: float = 0.0
def send(self, status: str, now: float) -> None:
"""Publish a changed status or resend it after the configured interval."""
if (
status != self.last_status
or (now - self.last_update_time) >= self.resend_interval
):
self.requestor.send_data(self.topic, status)
self.last_status = status
self.last_update_time = now
class CameraWatchdog(threading.Thread):
def __init__(
self,
@@ -186,33 +208,16 @@ class CameraWatchdog(threading.Thread):
self._stall_active: bool = False
# Status caching to reduce message volume
self._last_detect_status: str | None = None
self._last_record_status: dict[str, str] = {}
self._last_detect_status_update_time: float = 0.0
self._last_record_status_update_time: dict[str, float] = defaultdict(float)
self.detect_status = self._role_status("detect")
self.record_status = {
stream_type: self._role_status(role)
for stream_type, role in STREAM_TYPE_TO_ROLE.items()
}
def _send_detect_status(self, status: str, now: float) -> None:
"""Send detect status only if changed or retry_interval has elapsed."""
if (
status != self._last_detect_status
or (now - self._last_detect_status_update_time) >= self.sleeptime
):
self.requestor.send_data(f"{self.config.name}/status/detect", status)
self._last_detect_status = status
self._last_detect_status_update_time = now
def _send_record_status(self, stream_type: str, status: str, now: float) -> None:
"""Send a record stream's status only if changed or retry_interval has elapsed."""
if (
status != self._last_record_status.get(stream_type)
or (now - self._last_record_status_update_time[stream_type])
>= self.sleeptime
):
self.requestor.send_data(
f"{self.config.name}/status/{STREAM_TYPE_TO_ROLE[stream_type]}", status
)
self._last_record_status[stream_type] = status
self._last_record_status_update_time[stream_type] = now
def _role_status(self, role: str) -> RoleStatus:
return RoleStatus(
self.requestor, f"{self.config.name}/status/{role}", self.sleeptime
)
def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None:
"""Send offline status for each role of a restarted ffmpeg process."""
@@ -222,7 +227,7 @@ class CameraWatchdog(threading.Thread):
stream_type = ROLE_TO_STREAM_TYPE.get(role.value)
if stream_type is not None:
self._send_record_status(stream_type, "offline", now)
self.record_status[stream_type].send("offline", now)
else:
self.requestor.send_data(
f"{self.config.name}/status/{role.value}", "offline"
@@ -388,11 +393,11 @@ class CameraWatchdog(threading.Thread):
# update camera status
now = datetime.now().timestamp()
self._send_detect_status("disabled", now)
self._send_record_status(STREAM_TYPE_MAIN, "disabled", now)
self.detect_status.send("disabled", now)
self.record_status[STREAM_TYPE_MAIN].send("disabled", now)
# cameras without a sub stream never get a record_sub topic
if self.config.record.sub.enabled:
self._send_record_status(STREAM_TYPE_SUB, "disabled", now)
self.record_status[STREAM_TYPE_SUB].send("disabled", now)
self.was_enabled = enabled
continue
@@ -465,7 +470,7 @@ class CameraWatchdog(threading.Thread):
can_restart = time_since_last_restart >= self.sleeptime
if not self.capture_thread.is_alive():
self._send_detect_status("offline", now)
self.detect_status.send("offline", now)
self.camera_fps.value = 0
self.logger.error(
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
@@ -477,7 +482,7 @@ class CameraWatchdog(threading.Thread):
self.fps_overflow_count += 1
if self.fps_overflow_count == 3:
self._send_detect_status("offline", now)
self.detect_status.send("offline", now)
self.fps_overflow_count = 0
self.camera_fps.value = 0
self.logger.info(
@@ -487,7 +492,7 @@ class CameraWatchdog(threading.Thread):
self.reset_capture_thread(drain_output=False)
last_restart_time = now
elif now - self.capture_thread.current_frame.value > 20:
self._send_detect_status("offline", now)
self.detect_status.send("offline", now)
self.camera_fps.value = 0
self.logger.info(
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
@@ -497,7 +502,7 @@ class CameraWatchdog(threading.Thread):
last_restart_time = now
else:
# process is running normally
self._send_detect_status("online", now)
self.detect_status.send("online", now)
self.fps_overflow_count = 0
for p in self.ffmpeg_other_processes:
@@ -540,7 +545,7 @@ class CameraWatchdog(threading.Thread):
elif stale_stream is None:
if poll is None:
for stream_type in recorded_streams:
self._send_record_status(stream_type, "online", now)
self.record_status[stream_type].send("online", now)
p["latest_segment_time"] = max(
self.latest_cache_segment_time[stream_type]
@@ -556,22 +561,22 @@ class CameraWatchdog(threading.Thread):
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
)
if (
self.detect_process_records_sub
and self.config.record.stream_enabled(STREAM_TYPE_SUB)
and self.capture_thread is not None
and self.capture_thread.is_alive()
if self.detect_process_records_sub and self.config.record.stream_enabled(
STREAM_TYPE_SUB
):
now_utc = datetime.now().astimezone(UTC)
stale_reason = self._stream_staleness(STREAM_TYPE_SUB, now_utc)
if stale_reason is None:
self._send_record_status(STREAM_TYPE_SUB, "online", now)
if self.detect_status.last_status == "offline":
# the sub stream is down whenever the detect process is
self.record_status[STREAM_TYPE_SUB].send("offline", now)
elif stale_reason is None:
self.record_status[STREAM_TYPE_SUB].send("online", now)
elif can_restart:
self.logger.error(
f"{stale_reason} for {self.config.name} (sub, shared with detect) in the last {self.record_stale_threshold[STREAM_TYPE_SUB]}s. Restarting ffmpeg..."
)
self._send_record_status(STREAM_TYPE_SUB, "offline", now)
self.record_status[STREAM_TYPE_SUB].send("offline", now)
self.reset_capture_thread()
last_restart_time = now
-11
View File
@@ -1,11 +0,0 @@
import type { FrigateApp } from "../fixtures/frigate-test";
// On mobile the System tabs sit in an OverflowStrip, which keeps an inert copy
// of every tab for measurement and hides the ones that do not fit behind a
// kebab. The selected tab always stays in the strip.
export function systemTab(frigateApp: FrigateApp, name: string) {
return frigateApp.page
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
.first();
}
+2 -3
View File
@@ -7,13 +7,12 @@
*/
import { test, expect } from "../fixtures/frigate-test";
import { systemTab } from "../helpers/system-tabs";
import { viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Auth — admin access @high", () => {
test("admin /system renders general tab", async ({ frigateApp }) => {
await frigateApp.goto("/system");
await expect(systemTab(frigateApp, "general")).toBeVisible({
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 15_000,
});
});
@@ -29,7 +28,7 @@ test.describe("Auth — admin access @high", () => {
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await expect(systemTab(frigateApp, "frigate")).toBeVisible({
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
});
-19
View File
@@ -287,22 +287,3 @@ test.describe("Live mobile layout @critical @mobile", () => {
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});
test.describe("Live camera groups @medium", () => {
test("a group with an invalid icon renders a fallback icon", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
camera_groups: {
outdoor: { cameras: ["front_door"], icon: "generic" },
},
},
});
await frigateApp.goto("/");
const group = frigateApp.page
.locator('[aria-label="Camera Groups"]:not([inert] *)')
.first();
await expect(group.locator("svg")).toBeVisible({ timeout: 10_000 });
});
});
+2 -3
View File
@@ -6,7 +6,6 @@
*/
import { test, expect } from "../fixtures/frigate-test";
import { systemTab } from "../helpers/system-tabs";
import { viewerProfile } from "../fixtures/mock-data/profile";
const NOW = Math.floor(Date.now() / 1000);
@@ -56,7 +55,7 @@ test.describe("System — Health tab @medium", () => {
});
await frigateApp.goto("/system");
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
@@ -802,7 +801,7 @@ test.describe("System — Health notices sources @medium", () => {
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
+71 -66
View File
@@ -6,66 +6,42 @@
* RestartDialog cancel flow.
*/
import { test, expect, FrigateApp } from "../fixtures/frigate-test";
import { test, expect } from "../fixtures/frigate-test";
import {
expectBodyInteractive,
waitForBodyInteractive,
} from "../helpers/overlay-interaction";
import { systemTab } from "../helpers/system-tabs";
async function selectTab(frigateApp: FrigateApp, name: string) {
const kebab = frigateApp.page.getByLabel("Show all tabs");
if (frigateApp.isMobile && (await kebab.isVisible())) {
await kebab.click();
await frigateApp.page
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
.last()
.click();
return;
}
await systemTab(frigateApp, name).click();
}
async function expectTabActive(frigateApp: FrigateApp, name: string) {
await expect(systemTab(frigateApp, name)).toHaveAttribute(
"data-state",
"on",
{
timeout: 5_000,
},
);
}
test.describe("System — tabs @medium", () => {
test("general tab is active by default via #general hash", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
if (!frigateApp.isMobile) {
await expect(systemTab(frigateApp, "storage")).toBeVisible();
await expect(systemTab(frigateApp, "cameras")).toBeVisible();
}
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
});
test("Storage tab activates and deactivates General", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await selectTab(frigateApp, "storage");
await expectTabActive(frigateApp, "storage");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"off",
);
@@ -73,20 +49,24 @@ test.describe("System — tabs @medium", () => {
test("Cameras tab activates", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await selectTab(frigateApp, "cameras");
await expectTabActive(frigateApp, "cameras");
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
});
test("general tab shows version and last-refreshed", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
@@ -107,13 +87,17 @@ test.describe("System — tabs @medium", () => {
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await selectTab(frigateApp, "storage");
await expectTabActive(frigateApp, "storage");
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
// On desktop, tab buttons render text labels so the word "storage"
// always appears in #pageRoot after switching. On mobile, tabs are
// icon-only, so we verify the general-tab content disappears instead
@@ -128,23 +112,25 @@ test.describe("System — tabs @medium", () => {
} else {
// Mobile: tab activation (data-state "on") already asserted above.
// Additionally confirm general tab is no longer the active tab.
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"off",
{ timeout: 5_000 },
);
await expect(
frigateApp.page.getByLabel("Select general"),
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
}
});
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await selectTab(frigateApp, "cameras");
await expectTabActive(frigateApp, "cameras");
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
// Cameras tab lists every camera from config/stats. The default
// mock has front_door, backyard, garage.
for (const cam of ["front_door", "backyard", "garage"]) {
@@ -165,13 +151,17 @@ test.describe("System — tabs @medium", () => {
config: { semantic_search: { enabled: true } },
});
await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await selectTab(frigateApp, "enrichments");
await expectTabActive(frigateApp, "enrichments");
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
await enrichTab.click();
await expect(enrichTab).toHaveAttribute("data-state", "on", {
timeout: 5_000,
});
});
});
@@ -233,27 +223,31 @@ test.describe("System — mobile @medium @mobile", () => {
test("tabs render at mobile viewport", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toBeVisible({
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 15_000,
});
});
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await selectTab(frigateApp, "storage");
await expectTabActive(frigateApp, "storage");
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
});
test("header controls leave the logo uncovered on a narrow phone", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
@@ -261,9 +255,9 @@ test.describe("System — mobile @medium @mobile", () => {
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
const logo = frigateApp.page.locator("svg.fill-current").first();
const kebab = frigateApp.page.getByLabel("Show all tabs");
await expect(kebab).toBeVisible();
const tabs = kebab.locator("..");
const tabs = frigateApp.page
.locator("[data-radix-scroll-area-viewport]")
.filter({ has: frigateApp.page.getByLabel("Select general") });
const refreshed = frigateApp.page.getByText(/Just now|ago/);
const logoBox = await logo.boundingBox();
@@ -273,9 +267,20 @@ test.describe("System — mobile @medium @mobile", () => {
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
// the tabs that do not fit stay reachable through the kebab
await selectTab(frigateApp, "cameras");
await expectTabActive(frigateApp, "cameras");
await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0);
// the clipped tabs stay reachable by scrolling
const overflow = await tabs.evaluate((el) => ({
scroll: el.scrollWidth,
client: el.clientWidth,
}));
expect(overflow.scroll).toBeGreaterThan(overflow.client);
await tabs.evaluate((el) => {
el.scrollLeft = el.scrollWidth;
});
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
});
});
-2
View File
@@ -14,8 +14,6 @@
},
"title": "System",
"metrics": "System metrics",
"showAllTabs": "Show all tabs",
"showLessTabs": "Show less",
"health": {
"title": "Health",
"notices": {
+166 -26
View File
@@ -8,8 +8,18 @@ import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr";
import { MdHome } from "react-icons/md";
import { Button, buttonVariants } from "../ui/button";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import OverflowStrip from "../mobile/OverflowStrip";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import { HiDotsHorizontal } from "react-icons/hi";
import { IoClose } from "react-icons/io5";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { LuPencil, LuPlus } from "react-icons/lu";
import {
@@ -146,7 +156,80 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const [addGroup, setAddGroup] = useState(false);
const groupButtons = () => {
// mobile overflow reveal - the group strip sits left of the logo and is
// clipped (not scrollable) when there are too many groups, so render only
// the buttons that fully fit and surface a kebab next to the last visible
// one that expands a panel revealing all of them
const [expanded, setExpanded] = useState(false);
// null => all buttons fit, render them all with no kebab; a number => only
// that many fit alongside the kebab
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
if (isDesktop) {
return;
}
const wrapper = wrapperRef.current;
const measure = measureRef.current;
if (!wrapper || !measure) {
return;
}
const gap = 8; // gap-2 between buttons in the strip
const wrapperGap = 4; // gap-1 between the strip and the kebab
const compute = () => {
const buttons = Array.from(measure.children) as HTMLElement[];
if (buttons.length === 0) {
return;
}
// the trailing child of the measurement row is a kebab clone
const kebab = buttons[buttons.length - 1];
const groupButtons = buttons.slice(0, -1);
const available = wrapper.clientWidth;
const fullWidth =
groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) +
Math.max(groupButtons.length - 1, 0) * gap;
if (fullWidth <= available) {
setVisibleCount(null);
return;
}
const budget = available - kebab.offsetWidth - wrapperGap;
let used = 0;
let count = 0;
for (const el of groupButtons) {
const next = (count === 0 ? 0 : gap) + el.offsetWidth;
if (used + next <= budget) {
used += next;
count += 1;
} else {
break;
}
}
setVisibleCount(Math.max(count, 1));
};
compute();
const observer = new ResizeObserver(compute);
observer.observe(wrapper);
return () => observer.disconnect();
}, [groups, isAdmin]);
const groupButtons = (afterSelect?: () => void) => {
const buttons = [
<Button
key="default-group"
@@ -162,6 +245,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
if (group) {
setGroup("default", true);
}
afterSelect?.();
}}
>
<MdHome className="size-5" />
@@ -179,16 +263,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
size="sm"
onClick={() => {
setGroup(name, group != "default");
afterSelect?.();
}}
>
<IconRenderer
icon={
isValidIconName(config.icon)
? LuIcons[config.icon]
: LuIcons.LuFolder
}
className="size-5"
/>
{config && config.icon && isValidIconName(config.icon) && (
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
)}
</Button>
)),
];
@@ -202,6 +282,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
size="sm"
onClick={() => {
setAddGroup(true);
afterSelect?.();
}}
>
<LuPencil className="size-5 text-primary-variant" />
@@ -269,14 +350,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
onMouseEnter={() => showTooltip(name)}
onMouseLeave={() => showTooltip(undefined)}
>
<IconRenderer
icon={
isValidIconName(config.icon)
? LuIcons[config.icon]
: LuIcons.LuFolder
}
className="size-4"
/>
{config && config.icon && isValidIconName(config.icon) && (
<IconRenderer
icon={LuIcons[config.icon]}
className="size-4"
/>
)}
</Button>
</TooltipTrigger>
<TooltipPortal>
@@ -311,13 +390,74 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
)}
</div>
) : (
<OverflowStrip
className={className}
items={groupButtons()}
activeIndex={groups.findIndex(([name]) => name == group) + 1}
showAllLabel={t("group.showAll")}
showLessLabel={t("group.showLess")}
/>
<div
ref={wrapperRef}
className={cn("flex min-w-0 items-center gap-1", className)}
>
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
{visibleCount == null
? groupButtons()
: groupButtons().slice(0, visibleCount)}
</div>
{visibleCount != null && (
<Button
variant="ghost"
size="sm"
className="shrink-0 px-2 text-secondary-foreground"
aria-label={t("group.showAll")}
onClick={() => setExpanded(true)}
>
<HiDotsHorizontal className="size-5" />
</Button>
)}
{/* invisible row used only to measure natural button widths so we
can render exactly the buttons that fully fit */}
<div
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
aria-hidden
inert
>
<div ref={measureRef} className="flex w-max items-center gap-2">
{groupButtons()}
<Button variant="ghost" size="sm" className="px-2">
<HiDotsHorizontal className="size-5" />
</Button>
</div>
</div>
{expanded && (
<div
className="fixed inset-0 z-20"
onClick={() => setExpanded(false)}
/>
)}
<AnimatePresence>
{expanded && (
<motion.div
key="group-overlay"
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
initial={{ clipPath: "inset(0 100% 0 0)" }}
animate={{ clipPath: "inset(0 0% 0 0)" }}
exit={{ clipPath: "inset(0 100% 0 0)" }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div className="flex flex-wrap items-center gap-2">
{groupButtons(() => setExpanded(false))}
<Button
variant="ghost"
size="sm"
className="ml-auto shrink-0 px-2 text-secondary-foreground"
aria-label={t("group.showLess")}
onClick={() => setExpanded(false)}
>
<IoClose className="size-5" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</>
);
-167
View File
@@ -1,167 +0,0 @@
import { ReactNode, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { HiDotsHorizontal } from "react-icons/hi";
import { IoClose } from "react-icons/io5";
import { Button } from "../ui/button";
import { cn } from "@/lib/utils";
type OverflowStripProps = {
className?: string;
items: ReactNode[];
activeIndex?: number;
gapClassName?: string;
showAllLabel: string;
showLessLabel: string;
};
// Renders only the items that fully fit and surfaces a kebab next to the last
// visible one. The kebab expands a panel over the nearest positioned ancestor
// that reveals every item.
export default function OverflowStrip({
className,
items,
activeIndex = 0,
gapClassName = "gap-2",
showAllLabel,
showLessLabel,
}: OverflowStripProps) {
const [expanded, setExpanded] = useState(false);
// null => all items fit, render them all with no kebab; a number => only
// that many fit alongside the kebab
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const wrapper = wrapperRef.current;
const measure = measureRef.current;
if (!wrapper || !measure) {
return;
}
const wrapperGap = 4; // gap-1 between the strip and the kebab
const compute = () => {
const children = Array.from(measure.children) as HTMLElement[];
if (children.length === 0) {
return;
}
// the trailing child of the measurement row is a kebab clone
const kebab = children[children.length - 1];
const start = children[0].offsetLeft;
const ends = children
.slice(0, -1)
.map((el) => el.offsetLeft + el.offsetWidth - start);
const available = wrapper.clientWidth;
if (ends[ends.length - 1] <= available) {
setVisibleCount(null);
return;
}
const budget = available - kebab.offsetWidth - wrapperGap;
const count = ends.filter((end) => end <= budget).length;
setVisibleCount(Math.max(count, 1));
};
compute();
const observer = new ResizeObserver(compute);
observer.observe(wrapper);
observer.observe(measure);
return () => observer.disconnect();
}, [items.length, gapClassName]);
// a selected item past the cut takes the last visible slot
const visibleItems =
visibleCount == null
? items
: activeIndex >= visibleCount
? [...items.slice(0, visibleCount - 1), items[activeIndex]]
: items.slice(0, visibleCount);
return (
<div
ref={wrapperRef}
className={cn("flex min-w-0 items-center gap-1", className)}
>
<div
className={cn(
"flex min-w-0 items-center overflow-hidden whitespace-nowrap",
gapClassName,
)}
>
{visibleItems}
</div>
{visibleCount != null && (
<Button
variant="ghost"
size="sm"
className="shrink-0 px-2 text-secondary-foreground"
aria-label={showAllLabel}
onClick={() => setExpanded(true)}
>
<HiDotsHorizontal className="size-5" />
</Button>
)}
{/* invisible row used only to measure natural item widths so we can
render exactly the items that fully fit */}
<div
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
aria-hidden
inert
>
<div
ref={measureRef}
className={cn("flex w-max items-center", gapClassName)}
>
{items}
<Button variant="ghost" size="sm" className="px-2">
<HiDotsHorizontal className="size-5" />
</Button>
</div>
</div>
{expanded && (
<div
className="fixed inset-0 z-20"
onClick={() => setExpanded(false)}
/>
)}
<AnimatePresence>
{expanded && (
<motion.div
key="overflow-overlay"
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
initial={{ clipPath: "inset(0 100% 0 0)" }}
animate={{ clipPath: "inset(0 0% 0 0)" }}
exit={{ clipPath: "inset(0 100% 0 0)" }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{/* a tap on any item bubbles up and collapses the panel */}
<div
className={cn("flex flex-wrap items-center", gapClassName)}
onClick={() => setExpanded(false)}
>
{items}
<Button
variant="ghost"
size="sm"
className="ml-auto shrink-0 px-2 text-secondary-foreground"
aria-label={showLessLabel}
>
<IoClose className="size-5" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+31 -64
View File
@@ -1,10 +1,8 @@
import useSWR from "swr";
import { FrigateStats } from "@/types/stats";
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import TimeAgo from "@/components/dynamic/TimeAgo";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Toggle } from "@/components/ui/toggle";
import OverflowStrip from "@/components/mobile/OverflowStrip";
import { isDesktop, isMobile } from "react-device-detect";
import GeneralMetrics from "@/views/system/GeneralMetrics";
import StorageMetrics from "@/views/system/StorageMetrics";
@@ -38,14 +36,6 @@ const allMetrics = [
] as const;
type SystemMetric = (typeof allMetrics)[number];
const metricIcons: Record<SystemMetric, ReactNode> = {
health: <LuHeartPulse className="size-4" />,
general: <LuActivity className="size-4" />,
enrichments: <LuSearchCode className="size-4" />,
storage: <LuHardDrive className="size-4" />,
cameras: <FaVideo className="size-4" />,
};
function System() {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config", {
@@ -108,66 +98,43 @@ function System() {
{isMobile && (
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
)}
{isMobile ? (
<div className="w-[calc(50%-1rem)]">
<OverflowStrip
items={metrics.map((item) => (
<Toggle
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SystemMetric) => {
if (value) {
setPageToggle(value);
}
}} // don't allow the severity to be unselected
>
{Object.values(metrics).map((item) => (
<ToggleGroupItem
key={item}
className={cn(
"shrink-0 rounded-md px-3 py-4",
pageToggle != item && "*:text-muted-foreground",
)}
size="sm"
pressed={pageToggle == item}
onPressedChange={() => setPageToggle(item)}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
aria-label={t("selectItem", {
ns: "common",
item: t(item + ".title"),
})}
>
{metricIcons[item]}
</Toggle>
))}
activeIndex={metrics.indexOf(pageToggle)}
gapClassName="gap-0.5"
showAllLabel={t("showAllTabs")}
showLessLabel={t("showLessTabs")}
/>
</div>
) : (
<ScrollArea className="whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SystemMetric) => {
if (value) {
setPageToggle(value);
}
}} // don't allow the severity to be unselected
>
{Object.values(metrics).map((item) => (
<ToggleGroupItem
key={item}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
aria-label={t("selectItem", {
ns: "common",
item: t(item + ".title"),
})}
>
{metricIcons[item]}
{item == "health" && <LuHeartPulse className="size-4" />}
{item == "general" && <LuActivity className="size-4" />}
{item == "enrichments" && <LuSearchCode className="size-4" />}
{item == "storage" && <LuHardDrive className="size-4" />}
{item == "cameras" && <FaVideo className="size-4" />}
{isDesktop && (
<div className="smart-capitalize">{t(item + ".title")}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
)}
)}
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
<div className="flex h-full items-center">
{pageToggle == "health" && (
+1 -1
View File
@@ -419,7 +419,7 @@ export default function LiveDashboardView({
{isMobile && (
<div className="relative flex h-11 items-center justify-between">
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
<div className="w-[calc(50%-1rem)]">
<div className="w-[45%]">
<CameraGroupSelector />
</div>
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (