mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-01 04:16:50 +03:00
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
* check for a valid frame before using its shape With the camera offline, no preview frame, and `camera-error.jpg` missing, `latest_frame` read `frame.shape` before its `frame is None` check, so it raised `AttributeError` and answered 500 with a traceback instead of the intended "Unable to get valid frame". The check now runs first. * fix the has_clip self-heal for events with no recordings `vod_event` looked for a `(body, 404)` tuple, but `vod_ts` returns a `JSONResponse`, so the check never matched and an old event whose recordings are gone kept offering a clip that can't play. It now checks the response status code. * return 403 for a snapshot or thumbnail on another camera The broad `except Exception` handlers in `event_snapshot` and `event_thumbnail` caught the `HTTPException` from `require_camera_access`, so a restricted user asking for another camera's snapshot got a 404 instead of a 403, and for an object still being tracked the snapshot was rendered before the check ran. Both endpoints now look up the event and check access in their own block, the way the other endpoints do, so a denial propagates. * find DST transitions to the second `get_dst_transitions` probed the offset once every 24 hours from the start time and reported a change at the first probe after it, up to a day late, so events, review items and recordings near a transition were grouped into days with the old offset. A transition after the last daily probe wasn't found at all. The end of the range is probed too now, and a probe that sees the offset change bisects the interval to the second of the transition. * don't run page shortcuts for keys a dialog already handled Radix dismisses a dialog on Escape from a capture-phase keydown listener and calls `preventDefault()` without stopping propagation, so `useKeyboardListener` still ran the page's Escape shortcut: cancelling the delete dialog in the face library or a classification model also cleared the whole selection. Keys another shortcut hook handled still get through, since their listener order changes with every render. * fix train image filtering for a class with a dash The backend writes a class with a `-` as `_` in train file names, since it splits those names on `-`, while a dataset folder keeps the dash. Filtering the Train grid by `half-open` compared it with `half_open` and hid every attempt. Both sides are normalized the same way now. * don't edit a chat message while a reply streams The edit button stayed active while a reply streamed. `submitConversation` returns early while loading, but the message bubble still closed its editor, so the edit was silently lost. The edit button is hidden while a reply streams, and an editor that's already open keeps its draft with send disabled until the reply ends. * fix restart failing under non-root restart_frigate() called psutil.Process(1).terminate() to signal s6-svscan, but s6-svscan runs as root while frigate runs as uid 1000, so the call raised AccessDenied. That exception escaped every caller: the UI restart button dropped its websocket client, MQTT restart and Save & Restart just logged and did nothing, and the watchdog crashed its own monitoring thread on a dead detector. This catches AccessDenied and falls through to the existing SIGINT branch, which exits the process for s6 to restart it. * show runtime overrides in the settings form The settings form read a camera section's saved config value, but its dependent warnings (audio transcription requiring audio detection, snapshots requiring detect, etc.) read the live config instead. A runtime toggle from the live view, MQTT, or an active profile can turn a section off without touching yaml, and that override persists across restarts, so the Enable switch showed on while the warning said the feature wasn't enabled. This adds an "Overridden (Live)" badge to any field whose live value differs from what's saved, and swaps the affected warnings to runtime-specific wording when a runtime override is the actual cause instead of the config. * fix mobile overflowing icons in system due to new health pane * fix genai settings keeping a stale model and dropping roles after save Switching a GenAI entry's provider left the previous provider's model selected, so saving wrote a model the new provider doesn't serve. llama.cpp can't find that model in `/v1/models`, so the backend reported every capability as false for the entry, and once the save refetched `genai/models` the roles widget stripped `transcribe` from the form on its own. The section showed unsaved changes right after saving, and saving again would have dropped the role. Switching provider now clears the model, and the roles widget only strips a role for a model or provider picked in the form, since the entry-level capability flags only describe the saved model. A selected role stays visible when the provider can't confirm it, so it can still be switched off. The llama.cpp model list also no longer repeats a model whose alias matches its id, which is what `--alias` produces. * close onvif sessions on shutdown `OnvifController.close()` only stopped its event loop, so the aiohttp sessions each `ONVIFCamera` holds and the `_poll_config_updates` task were left to be garbage collected during interpreter shutdown, when their warnings can no longer be logged. Every restart ended with a run of `Unclosed client session` and `Task was destroyed but it is pending!` logging errors, which only became visible once restart started exiting the process itself under non-root. `close()` now closes each camera's client and cancels the tasks on the loop before stopping it. * fixes * fixes
276 lines
11 KiB
Python
276 lines
11 KiB
Python
"""Tests for ONVIF state that must not depend on the autotracking config.
|
|
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
|
|
import asyncio
|
|
import threading
|
|
import unittest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from frigate.camera import PTZMetrics
|
|
from frigate.config import FrigateConfig
|
|
from frigate.ptz.autotrack import ptz_moving_at_frame_time
|
|
from frigate.ptz.onvif import OnvifController
|
|
|
|
CAMERA = "ptz_cam"
|
|
|
|
|
|
def _config(autotracking_enabled: bool) -> FrigateConfig:
|
|
return FrigateConfig(
|
|
**{
|
|
"mqtt": {"enabled": False},
|
|
"cameras": {
|
|
CAMERA: {
|
|
"ffmpeg": {
|
|
"inputs": [
|
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
|
]
|
|
},
|
|
"detect": {"width": 1920, "height": 1080},
|
|
"zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
|
|
"onvif": {
|
|
"host": "10.0.0.1",
|
|
"autotracking": {
|
|
"enabled": autotracking_enabled,
|
|
"required_zones": ["zone"],
|
|
},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def _make_profile() -> MagicMock:
|
|
profile = MagicMock()
|
|
profile.token = "profile_1"
|
|
profile.Name = "MainStream"
|
|
profile.VideoEncoderConfiguration = MagicMock()
|
|
ptz_config = MagicMock()
|
|
ptz_config.token = "ptz_config_1"
|
|
ptz_config.DefaultContinuousPanTiltVelocitySpace = "space"
|
|
ptz_config.DefaultContinuousZoomVelocitySpace = "space"
|
|
profile.PTZConfiguration = ptz_config
|
|
return profile
|
|
|
|
|
|
def _make_onvif_camera() -> MagicMock:
|
|
"""A camera that supports PTZ but nothing optional, so init takes the simplest
|
|
path through the feature detection below."""
|
|
onvif = MagicMock()
|
|
onvif.update_xaddrs = AsyncMock()
|
|
|
|
video_source = MagicMock()
|
|
video_source.token = "video_source_1"
|
|
|
|
media = MagicMock()
|
|
media.GetProfiles = AsyncMock(return_value=[_make_profile()])
|
|
media.GetVideoSources = AsyncMock(return_value=[video_source])
|
|
onvif.create_media_service = AsyncMock(return_value=media)
|
|
onvif.get_definition = MagicMock(return_value={"ptz": "definition"})
|
|
|
|
ptz = MagicMock()
|
|
# create_type is a local WSDL lookup, so tag the result to assert on it later
|
|
ptz.create_type = MagicMock(side_effect=lambda name: MagicMock(request_type=name))
|
|
ptz.GetConfigurationOptions = AsyncMock(side_effect=Exception("not supported"))
|
|
onvif.create_ptz_service = AsyncMock(return_value=ptz)
|
|
onvif.create_imaging_service = AsyncMock(side_effect=Exception("not supported"))
|
|
return onvif
|
|
|
|
|
|
def _make_controller(autotracking_enabled: bool) -> OnvifController:
|
|
"""Build a controller without invoking __init__, which would start an event loop
|
|
thread and reach out to the camera."""
|
|
config = _config(autotracking_enabled)
|
|
controller = OnvifController.__new__(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
|
|
|
|
|
|
def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
|
|
"""Build an already initialized controller for a camera that supports relative
|
|
FOV movement, with real metrics so the timestamp writes can be asserted on."""
|
|
config = _config(autotracking_enabled)
|
|
controller = OnvifController.__new__(OnvifController)
|
|
controller.config = config
|
|
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
|
|
controller.failed_cams = {}
|
|
|
|
ptz = MagicMock()
|
|
ptz.RelativeMove = AsyncMock()
|
|
controller.cams = {
|
|
CAMERA: {
|
|
"init": True,
|
|
"active": False,
|
|
"ptz": ptz,
|
|
"features": ["pt", "pt-r-fov"],
|
|
"relative_move_request": MagicMock(),
|
|
"relative_fov_range": {
|
|
"XRange": {"Min": -1.0, "Max": 1.0},
|
|
"YRange": {"Min": -1.0, "Max": 1.0},
|
|
},
|
|
}
|
|
}
|
|
controller.ptz_metrics = {
|
|
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
|
|
}
|
|
return controller
|
|
|
|
|
|
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_status_request_created_when_autotracking_disabled(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)
|
|
|
|
await controller._init_onvif(CAMERA)
|
|
|
|
cam = controller.cams[CAMERA]
|
|
if cam["init"]:
|
|
self.assertEqual(cam["status_request"].request_type, "GetStatus")
|
|
|
|
async def test_requests_built_without_contacting_camera(self) -> None:
|
|
# create_type is a local WSDL lookup; cameras that do not implement
|
|
# GetServiceCapabilities must not be asked about it during init
|
|
controller = _make_controller(autotracking_enabled=False)
|
|
|
|
await controller._init_onvif(CAMERA)
|
|
|
|
ptz = controller.cams[CAMERA]["ptz"]
|
|
ptz.GetServiceCapabilities.assert_not_called()
|
|
ptz.GetStatus.assert_not_called()
|
|
|
|
|
|
class TestManualRelativeMoveMetrics(unittest.IsolatedAsyncioTestCase):
|
|
"""A manual move from the UI (click to move, drag to zoom) sends move_relative
|
|
for any camera that advertises pt-r-fov, autotracking or not."""
|
|
|
|
async def test_metrics_untouched_when_autotracking_disabled(self) -> None:
|
|
# only camera_maintenance polls get_camera_status, and only for autotracking
|
|
# cameras, so a manual move that starts the clock here is never stopped
|
|
controller = _make_move_controller(autotracking_enabled=False)
|
|
metrics = controller.ptz_metrics[CAMERA]
|
|
metrics.frame_time.value = 1000.0
|
|
|
|
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
|
|
|
controller.cams[CAMERA]["ptz"].RelativeMove.assert_awaited_once()
|
|
self.assertEqual(metrics.start_time.value, 0)
|
|
self.assertEqual(metrics.stop_time.value, 0)
|
|
self.assertTrue(metrics.motor_stopped.is_set())
|
|
|
|
async def test_detection_regions_not_suppressed_after_manual_move(self) -> None:
|
|
# the symptom of the bug: object detection stops entirely because motion
|
|
# boxes are never promoted to detection regions again
|
|
controller = _make_move_controller(autotracking_enabled=False)
|
|
metrics = controller.ptz_metrics[CAMERA]
|
|
metrics.frame_time.value = 1000.0
|
|
|
|
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
|
|
|
for later_frame_time in (1001.0, 1060.0, 4600.0):
|
|
with self.subTest(frame_time=later_frame_time):
|
|
self.assertFalse(
|
|
ptz_moving_at_frame_time(
|
|
later_frame_time,
|
|
metrics.start_time.value,
|
|
metrics.stop_time.value,
|
|
)
|
|
)
|
|
|
|
async def test_metrics_written_when_autotracking_enabled(self) -> None:
|
|
# get_camera_status resets stop_time once the camera reports IDLE, so the
|
|
# autotracking path keeps its motion estimation timestamps
|
|
controller = _make_move_controller(autotracking_enabled=True)
|
|
metrics = controller.ptz_metrics[CAMERA]
|
|
metrics.frame_time.value = 1000.0
|
|
|
|
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
|
|
|
self.assertEqual(metrics.start_time.value, 1000.0)
|
|
self.assertEqual(metrics.stop_time.value, 0)
|
|
self.assertFalse(metrics.motor_stopped.is_set())
|
|
self.assertTrue(
|
|
ptz_moving_at_frame_time(
|
|
1001.0, metrics.start_time.value, metrics.stop_time.value
|
|
)
|
|
)
|
|
|
|
|
|
class TestOnvifClose(unittest.TestCase):
|
|
"""close() must release everything on the loop, since whatever it leaves is
|
|
garbage collected during interpreter shutdown, where the resulting warnings
|
|
fail to log and fill the shutdown output with logging errors."""
|
|
|
|
def setUp(self) -> None:
|
|
self.controller = _make_controller(autotracking_enabled=False)
|
|
self.onvif = self.controller.cams[CAMERA]["onvif"]
|
|
self.onvif.close = AsyncMock()
|
|
self.controller.config_subscriber = MagicMock()
|
|
self.controller.loop = asyncio.new_event_loop()
|
|
self.controller.loop_thread = threading.Thread(
|
|
target=self.controller._run_event_loop, daemon=True
|
|
)
|
|
self.controller.loop_thread.start()
|
|
self.addCleanup(self.controller.loop.close)
|
|
|
|
def test_close_closes_camera_sessions(self) -> None:
|
|
self.controller.close()
|
|
|
|
self.onvif.close.assert_awaited_once()
|
|
|
|
def test_close_cancels_tasks_left_on_the_loop(self) -> None:
|
|
async def forever() -> None:
|
|
while True:
|
|
await asyncio.sleep(1)
|
|
|
|
poll = asyncio.run_coroutine_threadsafe(forever(), self.controller.loop)
|
|
|
|
self.controller.close()
|
|
|
|
self.assertTrue(poll.cancelled())
|
|
self.assertFalse(self.controller.loop_thread.is_alive())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|