Compare commits

...
92 Commits
Author SHA1 Message Date
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f084e6acc7 Bump brace-expansion from 1.1.18 to 1.1.21 in /docs (#24596)
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.18 to 1.1.21.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.18...v1.1.21)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:49:16 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a3ea529eb5 Bump source-map-js from 1.2.1 to 1.2.2 in /docs (#24595)
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:48:52 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c53adf1f72 Bump compression from 1.8.1 to 1.8.2 in /docs (#24585)
Bumps [compression](https://github.com/expressjs/compression) from 1.8.1 to 1.8.2.
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

---
updated-dependencies:
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:45:14 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
528967a783 Bump source-map-js from 1.2.1 to 1.2.2 in /web (#24583)
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:44:55 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
02dd4df3d9 Bump shell-quote from 1.10.0 to 1.12.0 in /docs (#24582)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.10.0 to 1.12.0.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.10.0...v1.12.0)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.12.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:44:34 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fb67d735b6 Bump proxy-addr from 2.0.7 to 2.0.8 in /docs (#24584)
Bumps [proxy-addr](https://github.com/jshttp/proxy-addr) from 2.0.7 to 2.0.8.
- [Release notes](https://github.com/jshttp/proxy-addr/releases)
- [Changelog](https://github.com/jshttp/proxy-addr/blob/master/HISTORY.md)
- [Commits](https://github.com/jshttp/proxy-addr/compare/v2.0.7...v2.0.8)

---
updated-dependencies:
- dependency-name: proxy-addr
  dependency-version: 2.0.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:44:13 -05:00
lightsandGitHub 25f8568c66 docs: hardware page still says Maxwell (CC 5.0) GPUs are supported (#24493)
* docs: hardware page still says Maxwell (CC 5.0) GPUs are supported

Signed-off-by: lights <115397533+lightsabit@users.noreply.github.com>

* docs: replace em dash in Maxwell GPU note (greptile suggestion)

* docs: state oldest supported GPU generation, link community workaround

---------

Signed-off-by: lights <115397533+lightsabit@users.noreply.github.com>
2026-10-07 05:40:23 -06:00
Josh HawkinsandGitHub f1c4a9f769 Improve Reolink camera wizard detection (#24570)
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 / Synaptics Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* try http-flv first for reolink cameras above 5MP in the camera wizard

The wizard picked RTSP for every Reolink above 5MP and then warned that RTSP isn't recommended, telling users to enable HTTP and restart, which just produced the same result. It now tries the http-flv stream first for those cameras and falls back to RTSP when the stream doesn't probe, since only the newer models serve H.265 over enhanced FLV. The RTSP warning is skipped when the camera's resolution made RTSP a valid choice.

http-flv no longer needs go2rtc's ffmpeg module, so the compatibility mode warning for Reolink HTTP streams is removed and the docs drop the `ffmpeg:` prefix from http-flv sources that have no transcoding modifiers.

* add reolink tests

* keep using ffmpeg module
2026-10-06 15:42:21 -06:00
Josh HawkinsandGitHub 28d8f5bbfa wake the mqtt worker when a publish is queued (#24580)
publish() only queued the message, and the worker was blocked waiting on the broker socket for up to a second, so on a quiet connection messages went out up to 1s late. A socketpair registered in the worker's selector now interrupts the wait when a publish is queued or stop() is called.
2026-10-06 15:20:07 -06:00
Josh HawkinsandGitHub 8b89b79ef5 Deps updates (#24581)
* update fastapi and unpin starlette

fastapi 0.116 capped starlette below 0.49, which kept us on a version with several open advisories, the earliest of which is fixed in 0.49.1. fastapi moves to 0.142 and the starlette pin is removed so the latest (1.7.0 today) is pulled in.

fastapi 0.137 and later stopped flattening included routers into app.routes, so the spec generator found no routes to classify and every endpoint failed validation. The generator now reads routes from the routers directly. The spec is regenerated on the new versions.

* pin starlette
2026-10-06 15:19:31 -06:00
RonnieandGitHub 6b1e084fdc Fix MQTT network loop with high socket file descriptors (#24573)
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
2026-10-06 07:38:40 -05:00
Josh HawkinsandGitHub 9ab52c2be6 Clean up onvif and autotracking (#24559)
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
* 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.

* 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.

* compute max target box from live zoom factor

* fix autotracking debug overlay max target box lookup

* make max target box a function of zoom factor
2026-10-05 11:25:24 -06:00
Josh HawkinsandGitHub dab0d85830 Add overflow menu to system tabs on mobile (#24543)
* add overflow menu to system tabs on mobile

* add default icon, observe measure div, fix tests
2026-10-05 11:24:19 -06: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
Blake Blackshear 9b02a077e9 Merge remote-tracking branch 'origin/master' into dev 2026-10-02 06:39:23 -05:00
Nicolas MowenandGitHub d1cb8395ea Reserve other volumes (#24538)
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
2026-10-01 10:07:15 -06:00
Nicolas MowenandGitHub 3d0d12366f Improve build cleanup step to reduce failures (#24536) 2026-10-01 09:43:22 -06:00
Josh HawkinsandGitHub 321e78e65b Fix record status stuck offline after ffmpeg restart (#24522)
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 / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* fix record status never returning online after a record ffmpeg restart

The record ffmpeg restart paths sent `offline` directly, so the cached record status still read `online` and the recovery was never published. Detect and record also shared one resend timestamp, and detect's resend always ran first, so record's periodic resend never fired either. Record's offline now goes through the cache and each status has its own timestamp.

* don't publish record online when the record process has exited
2026-10-01 08:56:27 -06:00
Josh HawkinsandGitHub 17a8efa09c Fix camera name collision bypassing admin check (#24516)
* don't let camera names waive the admin check on non-camera routes

The global admin guard skipped the admin check for any request whose first path segment matched a configured camera name, without looking at which route actually handled it. A camera named `faces`, `lpr`, `audio`, or `classification` let viewers reach the face, LPR, audio transcription, and classification endpoints that rely only on the global guard. The exemption now also requires the matched route to be a `/{camera_name}` route, so camera routes behave exactly as before.

* add test
2026-10-01 08:56:04 -06:00
Nicolas MowenandGitHub 0936f4b4ef Fix detection not correctly being started after an alert ends (#24534)
* Fix detection not correctly being started after an alert ends

* Improve coverage

* Fix incorrect behavior

* Handle unsent thumbs

* Add more tests
2026-10-01 09:51:10 -05:00
007hacky007andGitHub 160d213025 Add frigate-abr to third party extensions (#24526) 2026-10-01 07:36:39 -06:00
Josh HawkinsandGitHub 24a236a152 Miscellaneous fixes (#24528)
* revert disable save buttons when there are no changes in config editor

* pass migrated config to each step in the config migration chain

* use 150 as the default max when typing a min speed in the search filter

* fix preview export outpoint to be relative to the start of the preview file

* don't crash on invalid trusted proxy entries or non-ip forwarded hops

* translate the camera count badge in the roles table

* run every batch through the lpr recognition model

* log rejected motion and notification mqtt payloads

* log invalid addresses in x-forwarded-for

* add test

* remove unused autotracked_object_region

* remove unreachable autotracker setup call in camera maintenance

* apply onvif retry limit when initialization fails

* fix reindex progress overcounting when there are fewer events than a batch

* match the register device button aria label to its text

* reset the add profile form on cancel

* ignore case when filtering search suggestions

* tweak comment
2026-10-01 07:19:31 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
16eac66463 Bump brace-expansion in /web (#24530)
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 1.1.11 to 1.1.21
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.21)

Updates `brace-expansion` from 5.0.9 to 5.0.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.21)

Updates `brace-expansion` from 5.0.4 to 5.0.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.21)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 5.0.12
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 5.0.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 07:59:27 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3c57ba5020 Bump dompurify from 3.4.13 to 3.4.16 in /docs (#24532)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.13 to 3.4.16.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.13...3.4.16)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 07:34:22 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
be497bdb28 Bump fast-uri from 3.1.7 to 3.1.8 in /docs (#24531)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.7 to 3.1.8.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.7...v3.1.8)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 07:33:50 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
068068ce19 Bump fast-uri in /web (#24524)
Bumps  and [fast-uri](https://github.com/fastify/fast-uri). These dependencies needed to be updated together.

Updates `fast-uri` from 3.1.7 to 3.1.8
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.7...v3.1.8)

Updates `fast-uri` from 4.1.4 to 4.2.1
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.7...v3.1.8)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.8
  dependency-type: indirect
- dependency-name: fast-uri
  dependency-version: 4.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 07:27:21 -05:00
Josh HawkinsandGitHub 2754f788cc Add Auto live view option with transcoding options (#24519)
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
* implement auto mode for single camera live view

* add transcoded live streams and stream ordering for auto mode

Cameras can now add lower quality live streams that go2rtc transcodes to H.264 on demand. `live.transcode` takes a source stream and a list of heights and bitrates, and each quality becomes a `{camera}_transcode_{height}p` stream. The config validator adds them to `live.streams` without moving any the user already placed, and drops them when transcoding is disabled or a height changes. `create_config.py` writes them into go2rtc's generated config at startup, and saving the config or deleting a camera syncs them through go2rtc's API, so no restart is needed. They use `#hardware`, so go2rtc picks a hardware encoder and falls back to the CPU when there isn't one.

The Live playback settings stream list can be reordered by drag, since its order is the auto ladder. Auto order sorts it by bitrate, measuring native streams through a new admin-only `/go2rtc/streams/{name}/bitrate` endpoint and using the configured bitrate for transcoded ones. A pure reorder wasn't saved before because RJSF, the settings form, and `update_yaml` all ignore map key order. Sections can now mark a map with `orderedMaps`, which sends the whole map with `replace_paths` so `config_set` rewrites it in order.

Transcoded streams aren't in `go2rtc.streams`, which only lists yaml streams, so the frontend treated them as not restreamed and fell back to jsmpeg. Every restream check now goes through `isRestreamedStream`.

Auto treated any stall with no bytes in the last 2 seconds as a dead camera and handed it to the error fallback, which went straight to jsmpeg. Heavy congestion can stop delivery completely, so congested viewers skipped every lower stream. Auto now declines only when stats show the camera offline, and a stall on a live camera steps down. The stream picker also has a Try highest quality button that sends auto back to the top stream.

* fixes
2026-09-30 15:10:49 -06:00
Nicolas MowenandGitHub b749b0bbc5 Apply data check to all cases when validating a segment (#24515)
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 / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-09-30 08:29:45 -05:00
Josh HawkinsandGitHub e37041c879 delete timeline entries when expiring events without clips (#24510)
Events without a clip were deleted once their snapshot expired, but their timeline rows were only removed when clip retention expired, so they were orphaned indefinitely. The timeline cache also held entries forever for events that ended without ever being saved.
2026-09-30 06:02:45 -06:00
Nicolas MowenandGitHub 1bb61eb808 Refactor model scene definitions (#24508)
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
* Refactor model scene definitions

* Cleanup

* Validate model paths in the UI

* Handle form validation
2026-09-30 06:02:14 -06:00
Nicolas MowenandGitHub 5e87d101da Integrate state changes with review items (#24503)
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
* Integrate state changes with review items

* Cleanup and fixes

* Improve report context management
2026-09-29 12:43:08 -05:00
Hosted WeblateandJosh Hawkins d092189b8e Update translation files
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
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 6d377e56e4 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
b13164cd3d Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 93.5% (247 of 264 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 93.5% (247 of 264 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: GuoQing Liu <842607283@qq.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/common
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 2cdb10a96a Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
6051374852 Translated using Weblate (Urdu)
Currently translated at 10.0% (13 of 129 strings)

Translated using Weblate (Urdu)

Currently translated at 27.6% (138 of 500 strings)

Translated using Weblate (Urdu)

Currently translated at 6.2% (8 of 129 strings)

Translated using Weblate (Urdu)

Currently translated at 16.0% (80 of 500 strings)

Co-authored-by: Abdullah Kaleem <abdullahkaleemlive@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mohsin <mohsinrafiq@ymail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/ur/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/ur/
Translation: Frigate NVR/audio
Translation: Frigate NVR/objects
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins abe99e5838 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 698f3c1904 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 22a21c761d Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translation: Frigate NVR/views-settings
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins b5613ca06c Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins dfd425655e Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translation: Frigate NVR/views-settings
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 19a7f35c00 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 8266a3d9dd Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 2755f7c8c3 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
6259872314 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Spanish)

Currently translated at 91.8% (697 of 759 strings)

Translated using Weblate (Spanish)

Currently translated at 93.8% (477 of 508 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Spanish)

Currently translated at 97.4% (115 of 118 strings)

Translated using Weblate (Spanish)

Currently translated at 95.7% (113 of 118 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Gerard Ricart Castells <gerard.ricart@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jaime Martin <jycmarting@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
bd4f7a3656 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Dutch)

Currently translated at 93.2% (708 of 759 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Dutch)

Currently translated at 93.2% (708 of 759 strings)

Translated using Weblate (Dutch)

Currently translated at 95.4% (485 of 508 strings)

Translated using Weblate (Dutch)

Currently translated at 97.1% (34 of 35 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Herik <wvdh2002@hotmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
0ea571abfe Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Indonesian)

Currently translated at 100.0% (1364 of 1364 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (119 of 119 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Arif Budiman <arifpedia@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins bf032bc92a Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 8b035e36d3 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins b7fae3bafd Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins ae88705b88 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins c5f979d522 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
0ea9c96f89 Translated using Weblate (Vietnamese)
Currently translated at 0.9% (5 of 508 strings)

Translated using Weblate (Vietnamese)

Currently translated at 80.7% (214 of 265 strings)

Translated using Weblate (Vietnamese)

Currently translated at 86.4% (432 of 500 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: voquangminh <voquangminh90@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/vi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/vi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/vi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins ca6a85be29 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 6637103b58 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
19a378af43 Translated using Weblate (Catalan)
Currently translated at 99.4% (1366 of 1374 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1366 of 1366 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Catalan)

Currently translated at 100.0% (1364 of 1364 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Gerard Ricart Castells <gerard.ricart@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: anton garcias <isaga.percompartir@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ca/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 3e075885f7 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 8858f06624 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
f6e119c89e Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Ukrainian)

Currently translated at 53.6% (407 of 759 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Ukrainian)

Currently translated at 98.3% (116 of 118 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (264 of 264 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: IvanKovalenko32 <koval.vana123123@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 32d15f2cbd Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
94146e2051 Translated using Weblate (Romanian)
Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1366 of 1366 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Romanian)

Currently translated at 100.0% (1364 of 1364 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lukasig <lukasig@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ro/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
f08e880099 Translated using Weblate (Russian)
Currently translated at 85.7% (30 of 35 strings)

Translated using Weblate (Russian)

Currently translated at 94.9% (112 of 118 strings)

Translated using Weblate (Russian)

Currently translated at 98.4% (261 of 265 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Russian)

Currently translated at 94.3% (249 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: internetson <sockmancore@gmail.com>
Co-authored-by: Алексей Лисевский <yokainfromabyss@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ru/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ru/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ru/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
e155f67f22 Translated using Weblate (Estonian)
Currently translated at 100.0% (119 of 119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/et/
Translation: Frigate NVR/views-live
2026-09-29 08:53:42 -05:00
c26eb023fc Translated using Weblate (Danish)
Currently translated at 69.6% (348 of 500 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jan Eenholt <jan@eenholt.dk>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/da/
Translation: Frigate NVR/audio
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins bd75d4bba0 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
c95d7f1808 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1366 of 1366 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (68 of 68 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ítalo Peixoto <italoopeixoto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/pt_BR/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins e36c7ae118 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
008a02eca8 Translated using Weblate (Turkish)
Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1374 of 1374 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1374 of 1374 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 99.7% (1370 of 1374 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (265 of 265 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Turkish)

Currently translated at 99.4% (1366 of 1374 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (265 of 265 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1366 of 1366 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (6 of 6 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (25 of 25 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1366 of 1366 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (2 of 2 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (264 of 264 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1364 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Turkish)

Currently translated at 96.8% (1321 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Turkish)

Currently translated at 95.3% (1300 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 95.2% (1299 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 86.1% (1175 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 85.2% (1163 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 58.3% (157 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 84.6% (1155 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 84.6% (1155 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 82.7% (1129 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 58.3% (157 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 74.7% (1019 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (25 of 25 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 58.3% (157 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 69.5% (949 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Turkish)

Currently translated at 91.5% (695 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 86.5% (122 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 48.6% (663 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Turkish)

Currently translated at 94.4% (137 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 91.4% (118 of 129 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Turkish)

Currently translated at 31.2% (237 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 31.2% (237 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 31.2% (237 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 56.1% (285 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 56.1% (285 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 56.1% (285 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 48.6% (663 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 94.4% (137 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Turkish)

Currently translated at 1.5% (1 of 66 strings)

Translated using Weblate (Turkish)

Currently translated at 54.1% (13 of 24 strings)

Translated using Weblate (Turkish)

Currently translated at 16.0% (122 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 25.3% (129 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 86.5% (122 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 57.9% (156 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 48.6% (665 of 1366 strings)

Translated using Weblate (Turkish)

Currently translated at 80.6% (96 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 94.4% (137 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (264 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Murat Odabasi <murat12@gmail.com>
Co-authored-by: pcislocked <git@pcislocked.net>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-auth/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-filter/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-icons/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-configeditor/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-recording/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/tr/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-auth
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-filter
Translation: Frigate NVR/components-icons
Translation: Frigate NVR/components-player
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-configeditor
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-recording
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Mitchell CurrieandGitHub 61e50a366f Detail XDNA2 community detector (#24480)
* Update hardware.md

Fix stray qualification

* docs: add XDNA2 detector configuration

* docs: add XDNA2 ZMQ model config
2026-09-28 22:49:37 -06:00
Nicolas MowenandGitHub dc97f294a0 Support newer and cleaner llama.cpp embeddings api (#24499)
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
2026-09-28 15:22:59 -05:00
Josh HawkinsandGitHub a1c8bf99a7 Miscellaneous fixes (#24498)
* fix auto quality recovery after a downswitch

The downswitch callback armed the upswitch probe, but `triggerDownswitch` reset the stall history right after the callback returned, which disarmed it again. Auto quality stayed on the sub stream until the next chunk boundary no matter how much the connection recovered. The governor now arms the probe itself after the reset.

The chunk boundary effect also ran on mount, so when coverage was already cached it immediately undid the low quality cold start the seed effect had just picked. It now only runs when the chunk actually changes.

* fix recording playback quality switches and silent codec failures

A quality switch changes `bufferLength` a commit before the new source arrives, and since it was a dependency of the hls.js setup effect, the player rebuilt once on the outgoing playlist (jumping back to its original `startPosition`) and again on the new one. The buffer length now updates the running instance's config instead.

A fatal codec error with no lower quality stream to fall back to did nothing at all, so playback just sat there with no message. It now shows the playback failure toast, which is also limited to once per source since hls.js and the video element can both report the same failure.

* retry failed WebRTC probes and skip offline streams

The connectivity probe only ever tried the first go2rtc stream and cached its result for the whole page session, so a single offline camera at the top of the go2rtc config marked WebRTC unreachable for every camera until a reload, as did any brief network hiccup. The probe now starts with the stream being viewed and moves on to the next one when go2rtc reports that it can't open the stream's source. A failed result is only reused for 30 seconds, and it's retried on the next mount or when the page becomes visible again.

* fix two-way talk on cameras with AAC audio

The mic button was enabled whenever WebRTC was globally available, but the live view only switched to the WebRTC player when the stream itself qualified for WebRTC, and AAC playback audio disqualifies it. On most cameras the mic showed as on while nothing was sent. Two-way talk only needs the stream's video to connect since the backchannel is sent, not received, so AAC playback audio no longer blocks it. The mic is also turned off when a stream switch makes talk unavailable.

* keep Frigate+ model references when saving the models section

`/api/config` served a Frigate+ model's path as the resolved `/config/model_cache/<id>` file, and since the models list is saved whole, editing any model in the settings UI wrote that cache path back to the config in place of `plus://<id>`. After a restart the model loaded as a custom model with the default labelmap. The config API now reports the `plus://<id>` reference the model was configured with, and the models section drops the fields the Frigate+ model info supplies (size, tensor, pixel format, dtype, and type) instead of pinning them in the config.

* allow models to share shareable detection hardware

The hardware picker treated every device another model listed as taken, so a second model couldn't pick an Intel GPU or the CPU that the first one already used. The backend only rejects reuse of devices that can't be shared (Coral, MemryX), so the picker now matches that and only marks exclusive units as claimed.

* fix model card state and camera counts in the models editor

Model cards were keyed by index, so deleting one handed its state (such as the selected model source tab) to the card after it. Cards are keyed by scene now, which is unique per model.

The camera count on each card also ignored the backend's fallback to the `all` model, so a camera whose detect scene had no model of its own wasn't counted anywhere. It's counted under `all` now, which also feeds the recommended detector count.

* share a unit's temperature across repeated detector devices

Detector temperatures were matched to units by counting detectors of each type, so a device listed twice to run a second inference process (`hailo:PCIe` and `hailo:PCIe#2`) showed the next unit's temperature, or none at all. Distinct devices are numbered now and repeats share their unit's reading.

* update monitored hardware after a runtime config swap

`swap_runtime_config` rebound the stats emitter to the new config but not its `HardwareStats`, which kept polling hardware for the old config and applied camera updates to the discarded object. It now follows the swap along with its camera update subscriber.

* time out model downloads that never respond

`download_from_url` had no timeout, so a proxy or server that accepted the connection and never answered hung the download forever, including runtime downloads during startup. Connect and read timeouts now fail it like any other download error. The read timeout applies per socket read, so large models still finish.

* resolve segment start times in segment order

A camera stream's cached segments are probed concurrently, and each one chained its start off `last_segment_end` as soon as its own probe finished. When segments backed up in the cache and a later probe finished first, it chained off the wrong segment and the earlier one then moved `last_segment_end` backwards, so rows lost their exact adjacency. Probes still run concurrently, but each segment now waits for the one before it to settle its start before resolving its own.

* plan exports from the same coverage the vod route serves

The vod manifest nulls video-only glitch rows on audio-bearing streams, but exports planned their stream runs from the raw coverage, so a glitch row could produce a mixed-stream file or a 404 that failed the export. `null_audio_glitches` now works out each stream's audio composition itself, and exports go through it like the manifest and its realized timelines do.

An unstaged auto export also paged its playlist and chapters over main whenever main had any rows in range, even when the manifest served the range from sub and main only contributed glitches or slivers at the edges. It now reads the rows of the stream its single run actually uses.

* keep staged export chapters aligned across stream hand-offs

Each staged run of a mixed-stream export is rendered from its own pinned vod playlist, and that playlist's first clip snaps back to the preceding keyframe, so every staged file runs up to a GOP longer than its slice of the merged timeline. Chapters were placed on the merged timeline, so they drifted further from the video at every hand-off. Chapter windows for staged exports are now planned the same way each run's playlist is, carrying that keyframe lead-in into the offsets.

* clarify which hardware units only one model can use

* add e2e tests for shareable hardware and Frigate+ model saves

* only show the path field for a Frigate+ model without an API key

Without `PLUS_API_KEY` the models editor has no Frigate+ tab, so a `plus://` model showed every custom model field. The size, format, type, and labelmap fields are all supplied by the Frigate+ model info and ignored for a Frigate+ model, so editing them did nothing. Only the path is shown now, which still lets the model be switched to a custom one.

* keep a configured input_dtype when saving a Frigate+ model

The backend only overwrites `input_dtype` when the Frigate+ model info supplies `inputDataType`, which older models don't, so a configured dtype still matters for them. Saving the models section was dropping it along with the fields the backend always overwrites.

* probe every go2rtc stream until one isn't refused

The probe stopped after three streams, so with three offline cameras ahead of a working one, WebRTC was marked unreachable everywhere. It only moves past a stream when go2rtc refuses it, which is quick, and a stream that hangs still ends the probe at its timeout, so the cap bought nothing.

* only reuse a failed WebRTC probe for the stream it started from

A failed probe was reused for 30 seconds by every caller, so a camera whose stream timed out kept WebRTC unavailable for the next camera viewed, including one opened while that probe was still running. A pass still counts for every stream since it proves the connection, but a failure is only reused by probes that start from the same stream.

* strip input_dtype from Frigate+ models again

A Frigate+ model's config comes entirely from its model info, and a missing `inputDataType` means the `int` default. Keeping `input_dtype` meant switching from a custom model with `input_dtype: float` to a Frigate+ model carried the stale dtype over, with the field hidden so it couldn't be corrected.
2026-09-28 13:45:05 -06:00
Josh HawkinsandGitHub f9a37d4f4f remember last selected history sidebar tab (#24488)
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
The History view always opened on Timeline unless the link carried a tab. The selected tab is now saved with useUserPersistence and used as the default when no tab is passed. Notification and shared review links no longer default to "timeline", so they pick up the saved tab too, and `?tab=` still overrides it.
2026-09-27 17:21:46 -06:00
Josh HawkinsandGitHub 0977fa5e15 Improve camera group live view grids (#24461)
* rework live dashboard grid layout and add natural mode

* hide natural aspect and layout import on phones

* fix merge conflict

* add confirmation dialog for clearing groups and streaming settings

* add confirmation dialog for natural aspect switch

* clean up

* remove note

* wording tweak

* remove unused

* fixes
2026-09-27 17:09:31 -06:00
Josh HawkinsandGitHub 4e196516dd Tweaks (#24484)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / Assemble and push default build (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
* fix long press on mobile in face and classification

the listener was on the image only, so long pressing on any overlaid div/text area would cause iOS to select the text instead of adding the blue outline

* improve navigation to and from explore

when viewing a tracked object in explore from a classification card, triggers, or the detail stream, explore would open and show a single tracked object. on mobile (noted especially on iOS with frigate in HA), there is no obvious way to navigate back, so add a back button in its usual spot.

also, when going back to the classification view from explore, it may not be obvious which thumbnail you were last viewing, so add a temporary blue outline around the card like review and explore already does

* test tweaks
2026-09-27 07:45:32 -06:00
Josh HawkinsandGitHub 3941355051 add note to mqtt docs to use ID rather than friendly_name (#24441) 2026-09-24 06:31:40 -06:00
lin-xianmingandGitHub 1a278630da Fix ffmpeg default record preset in reference config (#24451)
Default was changed in b733355
2026-09-23 17:33:53 -06:00
Josh HawkinsandGitHub 9d0d8a99bb use resolved camera config in object processor to avoid race on replay stop (#24450)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / Assemble and push default build (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
2026-09-23 15:23:20 -06:00
Nicolas MowenandGitHub bbc412763d Update keywords used in docs to match UI (#24436) 2026-09-21 18:45:08 -05:00
Josh HawkinsandGitHub ac9ac50df5 back off restarts when a recording stream goes stale (#24420)
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 / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
The watchdog loop runs every second and the record staleness check restarted ffmpeg on every pass, so once a camera's segments went stale it got one restart per second and never had time to finish a 10 second segment. The restart is now gated on `can_restart` like the detect paths and grants 90 seconds of grace afterward. Backport of https://github.com/blakeblackshear/frigate/pull/24072, already in 0.19.
2026-09-20 12:44:47 -06:00
Josh HawkinsandGitHub 93aa6c4174 Add version/release link to docs site (#24410)
* add version/release link to docs

* link to full releases page
2026-09-20 07:40:09 -06:00
Josh HawkinsandGitHub 26e6adee88 Fix semantic search reindex (#24407)
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 / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* fix semantic search reindex

sqlite-vec added the `_info` shadow table in 0.1.6 and drops it unconditionally when a vec0 table is destroyed, so `DROP TABLE` on a table written by 0.17 failed with "SQL logic error" once 0.18 moved to 0.1.9. `SqliteQueueDatabase` queues non-SELECT statements and stores the exception on the cursor it returns, and nothing read those cursors, so the failed drop and every write after it went unreported while reindex still logged "Embedded N thumbnails". `drop_embeddings_tables()` now recreates the missing `_info` stub before dropping, and writes go through `execute_write()`, which waits on the cursor so failures raise. `INSERT OR REPLACE` is gone too, since vec0 implements neither REPLACE nor UPSERT and it always failed on an id already in the table, including under the 0.1.3 build 0.17 shipped.

* use lock

* show reindex failure in status bar
2026-09-19 08:10:36 -06:00
Nicolas MowenandGitHub de416b7ae7 Remove invalid hardware acceleration step in recording troubleshooting (#24395)
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 / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Remove the invalid suggestion in docs
2026-09-17 13:28:51 -05:00
Josh HawkinsandGitHub 06967fec91 Fix explore paging for non-date sorts (#24392)
* fix explore paging for non-date sorts

Explore paged every sort by passing the last row's `start_time` as a `before` or `after` cursor, which only works when rows are ordered by `start_time`. For score, speed, and relevance sorts, each page dropped every match newer than that row and repeated older rows from earlier pages, so infinite scroll stopped after a few pages. `/events` and `/events/search` now accept `offset`, and Explore pages non-date sorts by offset. Date sorts keep the cursor because `useSWRInfinite` only revalidates the first page, and cursor keys for later pages follow it while offset keys don't. Score and speed sorts on `/events` break ties on `id` so offset pages stay stable.

* order search ties by id and reject negative offsets

`/events/search` sorted in Python over a query with no `ORDER BY`, so tied scores, speeds, or distances kept whatever order SQLite returned, which isn't guaranteed to match across page requests. The query is now ordered by id and the stable sorts keep that order for ties. `offset` also accepted negative values, which sliced from the end of the search results.
2026-09-17 11:14:20 -06:00
Nicolas MowenandGitHub d69107de33 Handle sub labeled objects to still show up in review filter (#24391) 2026-09-17 11:54:54 -05:00
Nicolas MowenandGitHub 04480a18b6 Revert QSV ffmpeg framerate filter (#24384)
* Update version

* Move back to QSV framerate filter

* Use standard fps filter instead
2026-09-17 11:17:45 -05:00
Josh HawkinsandGitHub b02aea03cd add field messages for recording and notifications (#24272)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / Assemble and push default build (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
recording and notifications require enabled_in_config to be true at startup to build the correct ffmpeg commands and start the notifications worker
2026-09-13 11:53:44 -06:00
Josh HawkinsandGitHub 51171319a4 Clarify profile docs (#24267)
* Recording must always be enabled in the config to be toggled later by a profile

* add faq
2026-09-13 06:41:33 -06:00
Blake BlackshearandGitHub b1b725b80a Merge pull request #24249 from blakeblackshear/dev
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 / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
0.18.0 Release
2026-09-12 08:09:11 -05:00
Blake BlackshearandGitHub 50a2b6729e update labels/faq (#23759) 2026-07-18 11:19:12 -06:00
338 changed files with 22129 additions and 4333 deletions
+5
View File
@@ -17,9 +17,14 @@ runs:
shell: bash shell: bash
# This creates a virtual volume at /var/lib/docker to maximize the size # This creates a virtual volume at /var/lib/docker to maximize the size
# As of 2/14/2024, this results in 97G for docker images # As of 2/14/2024, this results in 97G for docker images
# Runners no longer have a separate /mnt disk, so the temp PV is also carved
# from root and temp-reserve-mb is what actually stays free on root. Keep 4G
# there for setup-qemu/buildx caches in ~/.docker and the tool cache
- name: Maximize build space - name: Maximize build space
uses: easimon/maximize-build-space@master uses: easimon/maximize-build-space@master
with: with:
root-reserve-mb: 8192
temp-reserve-mb: 4096
remove-dotnet: 'true' remove-dotnet: 'true'
remove-android: 'true' remove-android: 'true'
remove-haskell: 'true' remove-haskell: 'true'
+2 -2
View File
@@ -2,9 +2,9 @@ aiofiles == 25.1.*
click == 8.5.* click == 8.5.*
# FastAPI # FastAPI
aiohttp == 3.12.* aiohttp == 3.12.*
starlette == 0.47.* starlette == 1.7.*
starlette-context == 0.5.* starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.116.* fastapi[standard-no-fastapi-cloud-cli] == 0.142.*
uvicorn == 0.52.* uvicorn == 0.52.*
slowapi == 0.1.* slowapi == 0.1.*
joserfc == 1.6.* joserfc == 1.6.*
@@ -15,6 +15,7 @@ from frigate.const import (
) )
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file, resolve_ffmpeg_path from frigate.util.config import find_config_file, resolve_ffmpeg_path
from frigate.util.live_streams import raw_transcode_streams
from frigate.util.services import ( from frigate.util.services import (
is_go2rtc_arbitrary_exec_allowed, is_go2rtc_arbitrary_exec_allowed,
is_restricted_go2rtc_source, is_restricted_go2rtc_source,
@@ -174,6 +175,17 @@ for name in list(go2rtc_config.get("streams", {})):
del go2rtc_config["streams"][name] del go2rtc_config["streams"][name]
continue continue
# add transcoded live streams; a user stream with the same name wins here and
# fails Frigate's config validation
transcoded_streams = raw_transcode_streams(config)
if transcoded_streams:
if go2rtc_config.get("streams") is None:
go2rtc_config["streams"] = {}
for name, source in transcoded_streams.items():
go2rtc_config["streams"].setdefault(name, source)
# add birdseye restream stream if enabled # add birdseye restream stream if enabled
if config.get("birdseye", {}).get("restream", False): if config.get("birdseye", {}).get("restream", False):
birdseye: dict[str, Any] = config.get("birdseye") birdseye: dict[str, Any] = config.get("birdseye")
+32
View File
@@ -824,6 +824,38 @@ cpu:
models: models:
- devices: - devices:
- cpu:3 - cpu:3
xdna2:
title: AMD XDNA2
models:
- key: yolov9
label: YOLOv9
recommended: true
download: |-
Prepare the model using the frigate-xdna setup instructions linked above. For local YOLO models, Frigate must have access to the same ONNX file bytes as the sidecar. The example below uses YOLOv9-C at 320x320. Frigate+ models may instead use the same `plus://MODEL_ID` in Frigate and the sidecar.
ui: |-
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://xdna:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------ |
| **Custom object detector model path** | `/config/models/yolov9-c-320.onnx` |
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
| **Object detection model input width** | `320` |
| **Object detection model input height** | `320` |
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
| **Model Input Tensor Shape** | `nchw` |
| **Model Input D Type** | `float` |
| **Object Detection Model Type** | `yolo-generic` |
yaml: |-
models:
- devices:
- zmq:tcp://xdna:5555
model_type: yolo-generic
width: 320
height: 320
input_tensor: nchw
input_dtype: float
path: /config/models/yolov9-c-320.onnx
labelmap_path: /labelmap/coco-80.txt
memryx: memryx:
title: MemryX title: MemryX
models: models:
+26 -8
View File
@@ -152,9 +152,10 @@ auth:
models: models:
# Optional: the camera environment this model is for (default: shown below) # Optional: the camera environment this model is for (default: shown below)
# Cameras select a model by setting detect -> scene to a matching value, and # Cameras select a model by setting detect -> scene to a matching value, and
# a model with a scene of all is used by any camera that does not set one. # the model with a scene of default is used by any camera that does not set one.
# Valid values are all, indoor, outdoor, indoor_thermal, outdoor_thermal # Any name made up of letters, numbers, _ and - is valid, such as thermal.
- scene: all # Models that use the same model file are combined into one model.
- scene: default
# Required: hardware this model runs on, as <detector> or <detector>:<device> # Required: hardware this model runs on, as <detector> or <detector>:<device>
# See https://docs.frigate.video/configuration/object_detectors for the # See https://docs.frigate.video/configuration/object_detectors for the
# detectors available and the devices each one accepts. All of a model's # detectors available and the devices each one accepts. All of a model's
@@ -293,9 +294,9 @@ ffmpeg:
# Optional: output args for detect streams (default: shown below) # Optional: output args for detect streams (default: shown below)
detect: -threads 2 -f rawvideo -pix_fmt yuv420p detect: -threads 2 -f rawvideo -pix_fmt yuv420p
# Optional: output args for record streams (default: shown below) # Optional: output args for record streams (default: shown below)
record: preset-record-generic record: preset-record-generic-audio-aac
# Optional: output args for sub stream record streams (default: the record output args above) # Optional: output args for sub stream record streams (default: the record output args above)
# record_sub: preset-record-generic # record_sub: preset-record-generic-audio-aac
# Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below) # Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below)
# If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once # If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once
# If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage # If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage
@@ -316,9 +317,9 @@ detect:
# Optional: height of the frame for the input with the detect role (default: use native stream resolution) # Optional: height of the frame for the input with the detect role (default: use native stream resolution)
height: 720 height: 720
# Optional: the environment this camera looks at, which picks the model it runs on # Optional: the environment this camera looks at, which picks the model it runs on
# (default: the model with a scene of all) # (default: the model with a scene of default)
# Valid values are all, indoor, outdoor, indoor_thermal, outdoor_thermal # Must match the scene of a configured model
scene: outdoor scene: thermal
# Optional: desired fps for your camera for the input with the detect role (default: shown below) # Optional: desired fps for your camera for the input with the detect role (default: shown below)
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera. # NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
fps: 5 fps: 5
@@ -570,6 +571,8 @@ notifications:
enabled: False enabled: False
# Optional: Email for push service to reach out to # Optional: Email for push service to reach out to
# NOTE: This is required to use notifications # 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" email: "admin@example.com"
# Optional: Cooldown time for notifications in seconds (default: shown below) # Optional: Cooldown time for notifications in seconds (default: shown below)
cooldown: 0 cooldown: 0
@@ -900,6 +903,21 @@ live:
streams: streams:
main_stream: main_stream_name main_stream: main_stream_name
sub_stream: sub_stream_name sub_stream: sub_stream_name
# Optional: Lower-quality live streams transcoded by go2rtc while someone is watching.
# NOTE: Set at the camera level only.
transcode:
# Optional: Enable transcoded streams (default: shown below)
enabled: False
# Optional: go2rtc stream to transcode (default: the first live stream)
source: main_stream_name
# Optional: One transcoded stream per quality (default: shown below)
qualities:
- height: 720
bitrate: 1200
- height: 480
bitrate: 500
- height: 360
bitrate: 250
# Optional: Set the height of the jsmpeg stream. (default: 720) # Optional: Set the height of the jsmpeg stream. (default: 720)
# This must be less than or equal to the height of the detect stream. Lower resolutions # This must be less than or equal to the height of the detect stream. Lower resolutions
# reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio. # reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio.
+3 -3
View File
@@ -169,10 +169,10 @@ The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) i
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />. 1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand. 2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower, or an RTSP stream for higher resolution cameras. 3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower. For higher resolution cameras, it tries the http-flv stream first and falls back to RTSP when the camera does not support it.
4. In the validation step, enable **Use stream compatibility mode** for http-flv streams when the wizard recommends it. 4. The wizard turns on **Use stream compatibility mode** for the http-flv stream it selects. Enable it for any other http-flv stream you add, such as the sub stream.
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras where http-flv is recommended, the wizard will show a warning in the validation step. If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras 5MP and lower, where http-flv is recommended, the wizard will show a warning in the validation step.
The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below. The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below.
@@ -85,6 +85,14 @@ An optional config, `save_attempts`, can be set as a key under the model name. T
</TabItem> </TabItem>
</ConfigTabs> </ConfigTabs>
## Review items
When a model's state changes while its camera has an active review item, the change is recorded on that review item. This includes changes in the few seconds before the item starts, such as a garage door opening just before the car is detected. State changes never create or extend review items on their own, and the first state reported after Frigate starts is not recorded as a change.
Recorded changes appear in the review item's data as `classification_state_changes` (see the [`frigate/reviews`](/integrations/mqtt#frigatereviews) MQTT topic) and are passed to [GenAI review summaries](/configuration/genai/genai_review) as facts, so a description can note that a gate was opened during the activity.
Change times are most accurate with `motion: true`. A model that only runs on an `interval` notices a change at its next run, so the change may be recorded late or attached to a later review item.
## Training the model ## Training the model
Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps: Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps:
@@ -201,6 +201,8 @@ Review items are sent to the model as a sequence of still frames. Some models fo
The notes come from tracking data rather than from the images, so they describe activity the model may not have picked up on its own. In testing with a person carrying three waste bins to the curb one at a time, `gemma4` described a single trip on every attempt with `frames`, and consistently described multiple trips with `annotated_frames`. Models that already handle these sequences well, such as the `qwen3-vl` family, gain little and should stay on `frames`. The notes come from tracking data rather than from the images, so they describe activity the model may not have picked up on its own. In testing with a person carrying three waste bins to the curb one at a time, `gemma4` described a single trip on every attempt with `frames`, and consistently described multiple trips with `annotated_frames`. Models that already handle these sequences well, such as the `qwen3-vl` family, gain little and should stay on `frames`.
Changes reported by [state classification](/configuration/custom_classification/state_classification#review-items) models during the review item are listed in the prompt in both modes. `annotated_frames` also notes each change before the frame where it happened.
Annotated mode also caps the number of frames, since the notes already establish the order of events and extra near-duplicate frames tend to crowd out the middle of a clip. Longer review items are sampled more sparsely as a result, and typically use fewer tokens than `frames` mode for the same item. Annotated mode also caps the number of frames, since the notes already establish the order of events and extra near-duplicate frames tend to crowd out the middle of a clip. Longer review items are sampled more sparsely as a result, and typically use fewer tokens than `frames` mode for the same item.
:::note :::note
@@ -379,9 +379,9 @@ Navigate to <NavPath path="Settings > Camera configuration > Object detection" /
Navigate to <NavPath path="Settings > Camera configuration > Objects" />. Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
| Field | Description | | Field | Description |
| ---------------------------------------------- | ------------------- | | --------------------------------------------------------- | ------------------- |
| **Objects to track** | Add `license_plate` | | **Objects to track** | Add `license_plate` |
| **Object filters > License Plate > Threshold** | Set to `0.7` | | **Object filters > License Plate > Confidence threshold** | Set to `0.7` |
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />. Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
+28 -1
View File
@@ -92,7 +92,7 @@ go2rtc:
### Setting Streams For Live UI ### Setting Streams For Live UI
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. When a camera has more than one stream, the dropdown also offers **Auto**, which is used until you pick a specific stream. Auto starts on the first stream, steps down the list when your connection can't keep up, and steps back up when it recovers. To retry the top stream right away, select **Try highest quality** under the stream picker. List streams from highest to lowest quality, and avoid names that are plain numbers (such as `720`), which the browser sorts ahead of the others. In the UI, drag streams to reorder them, or use **Auto order** to sort them by measured bitrate.
Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard. Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard.
@@ -158,6 +158,26 @@ cameras:
</TabItem> </TabItem>
</ConfigTabs> </ConfigTabs>
### Transcoded streams
When a camera has no suitable sub stream, Frigate can add lower-quality streams that go2rtc transcodes to H.264 while someone is watching. They appear in the stream list like any other stream, so Auto mode can step down to them. Enable them under <NavPath path="Settings > Camera configuration > Live playback" />, or in YAML:
```yaml
cameras:
test_cam:
live:
transcode:
enabled: true
source: test_cam # optional, defaults to the first live stream
qualities:
- height: 720
bitrate: 1200 # kbps
- height: 480
bitrate: 500
```
Each quality becomes a go2rtc stream named `<camera>_transcode_<height>p`. go2rtc picks a hardware encoder automatically and falls back to the CPU, which costs CPU for each transcode while it is being watched. Check go2rtc's `api/ffmpeg/hardware` page to see which encoder it found. Using a sub stream as the `source` lowers the cost.
### WebRTC extra configuration: ### WebRTC extra configuration:
WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration: WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration:
@@ -363,6 +383,13 @@ When your browser runs into problems playing back your camera streams, it will l
- `Safari reported InvalidStateError.` - `Safari reported InvalidStateError.`
- `Safari reported decoding errors.` - `Safari reported decoding errors.`
- **mse-codec**
- What it means: go2rtc has no codec for this stream that the browser can play.
- What to try: Pick a stream with a codec the browser supports (H.264 is the most compatible), or use a browser that supports the stream's codec. In Auto, Frigate skips this stream for the rest of the session.
- Possible console messages from the player code:
- `mse: streams: codecs not matched: ...`
- **stalled** - **stalled**
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving). - What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> . - What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
+37 -10
View File
@@ -30,6 +30,7 @@ Frigate supports multiple different detectors that work on different types of ha
- [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection. - [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection.
- [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured. - [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured.
- <CommunityBadge /> [XDNA2](#amd-xdna2): AMD Ryzen AI / XDNA2 NPUs can run object detection through the community-maintained `frigate-xdna` ZMQ sidecar.
**Apple Silicon** **Apple Silicon**
@@ -103,32 +104,36 @@ Coral EdgeTPU and MemryX accelerators can only be opened by one process, so thos
### Running more than one model ### Running more than one model
Cameras can be split across models by scene, which is useful when indoor and outdoor cameras benefit from differently trained models. Each model declares the `scene` it is for, and each camera picks one with `detect -> scene`: Cameras can be split across models by scene, which is useful when some cameras benefit from a differently trained model, such as thermal cameras. Each model declares the `scene` it is for, and each camera picks one with `detect -> scene`:
```yaml ```yaml
models: models:
- scene: outdoor - scene: default
path: plus://your-outdoor-model path: plus://your-model
devices: devices:
- edgetpu:pci:0 - edgetpu:pci:0
- scene: indoor - scene: thermal
path: /config/model_cache/indoor.onnx path: /config/model_cache/thermal.onnx
model_type: yolo-generic model_type: yolo-generic
devices: devices:
- openvino:GPU - openvino:GPU
cameras: cameras:
driveway: driveway:
detect:
scene: outdoor
... ...
hallway: backyard_thermal:
detect: detect:
scene: indoor scene: thermal
... ...
``` ```
Available scenes are `all`, `indoor`, `outdoor`, `indoor_thermal`, and `outdoor_thermal`. A model with a scene of `all` is used by every camera that does not set one, and `all` is the default when a model does not declare a scene. Changing a camera's scene requires a restart. A scene is any name made up of letters, numbers, `_`, and `-`. The model with a scene of `default` is used by every camera that does not set one (or sets a scene that no model is configured for), and `default` is used when a model does not declare a scene. Changing a camera's scene requires a restart.
:::warning
Scenes are for running **different** models. Do not configure the same model under several scenes to dedicate a detector to specific cameras: every detector of a model already serves every camera using it, and splitting them only leaves some detectors idle while others fall behind. Frigate detects models that use the same model file, even under a different path or file name, combines them into one model with all of their hardware, and logs a warning.
:::
### Choosing a model size ### Choosing a model size
@@ -563,6 +568,28 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d
# Community Supported Detectors # Community Supported Detectors
## AMD XDNA2
AMD Ryzen AI / XDNA2 NPUs can be used through the community-maintained
[frigate-xdna](https://github.com/mitchins/frigate-xdna) detector sidecar.
The sidecar runs separately from Frigate and connects using Frigate's ZMQ
detector interface.
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2 devices
are not yet qualified; XDNA1 is unsupported.
Follow the frigate-xdna setup instructions to prepare and start the sidecar
before starting Frigate.
### Configuration {#configuration-xdna2}
Using the detector config below will connect Frigate to the sidecar:
<ModelConfigDropdown detectorTitle="AMD XDNA2" models={objectDetectorsModels.xdna2.models} />
The example assumes Frigate and the sidecar share a Docker network where the
sidecar is named `xdna`.
## MemryX MX3 ## MemryX MX3
This detector is available for use with the MemryX MX3 accelerator M.2 module. Frigate supports the MX3 on compatible hardware platforms, providing efficient and high-performance object detection. This detector is available for use with the MemryX MX3 accelerator M.2 module. Frigate supports the MX3 on compatible hardware platforms, providing efficient and high-performance object detection.
+8 -8
View File
@@ -46,9 +46,9 @@ Any detection below `min_score` will be immediately thrown out and never tracked
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set score filters globally. Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set score filters globally.
| Field | Description | | Field | Description |
| --------------------------------------- | ---------------------------------------------------------------- | | -------------------------------------------------- | ---------------------------------------------------------------- |
| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking | | **Object filters > Person > Minimum confidence** | Minimum score for a single detection to initiate tracking |
| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive | | **Object filters > Person > Confidence threshold** | Minimum computed (median) score to be considered a true positive |
To override score filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera. To override score filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
@@ -104,11 +104,11 @@ Conceptually, a ratio of 1 is a square, 0.5 is a "tall skinny" box, and 2 is a "
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set shape filters globally. Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set shape filters globally.
| Field | Description | | Field | Description |
| --------------------------------------- | ------------------------------------------------------------------------ | | -------------------------------------------------- | ------------------------------------------------------------------------ |
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) | | **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) | | **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box | | **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box | | **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
To override shape filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera. To override shape filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
+7 -7
View File
@@ -71,13 +71,13 @@ Object filters help reduce false positives by constraining the size, shape, and
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Navigate to <NavPath path="Settings > Global configuration > Objects" />.
| Field | Description | | Field | Description |
| --------------------------------------- | ------------------------------------------------------------------------ | | -------------------------------------------------- | ------------------------------------------------------------------------ |
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) | | **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) | | **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box | | **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box | | **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
| **Object filters > Person > Min Score** | Minimum score for the object to initiate tracking | | **Object filters > Person > Minimum confidence** | Minimum score for the object to initiate tracking |
| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive | | **Object filters > Person > Confidence threshold** | Minimum computed score to be considered a true positive |
To override filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" />. To override filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" />.
+7 -3
View File
@@ -191,14 +191,12 @@ cameras:
detect: detect:
enabled: false enabled: false
record: record:
enabled: false enabled: true
profiles: profiles:
away: away:
enabled: true enabled: true
detect: detect:
enabled: true enabled: true
record:
enabled: true
home: home:
enabled: false enabled: false
``` ```
@@ -251,6 +249,12 @@ Leaving the `objects` section empty (or omitting `track`) does not clear the lis
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration. Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
### Why can't a profile enable recording when it's disabled in the base config?
Frigate only sets up a camera's recording stream at startup when recording is enabled in the base config, so enabling it later from a profile has no effect. The same applies to turning recording on from the UI or MQTT.
To keep recording off by default, leave `record.enabled: true` in the base config and create a profile that sets `record.enabled: false`. Activate that profile and it will be restored automatically when Frigate starts.
### Can I schedule profiles to be enabled or disabled at certain times? ### Can I schedule profiles to be enabled or disabled at certain times?
Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could. Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could.
+2 -2
View File
@@ -245,8 +245,8 @@ Triggers are best configured through the Frigate UI.
1. Navigate to <NavPath path="Settings > Enrichments > Triggers" /> and select a camera from the dropdown menu. 1. Navigate to <NavPath path="Settings > Enrichments > Triggers" /> and select a camera from the dropdown menu.
2. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one. 2. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one.
3. In the **Create Trigger** wizard: 3. In the **Create Trigger** wizard:
- Enter a **Name** for the trigger (e.g., "Red Car Alert"). - Enter a **Name** for the trigger (e.g., "Red Car Alert"). Frigate derives the trigger's
- Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera"). internal **ID** from this name, which can be revealed and edited with the show/hide toggle.
- Select the **Type** (`Thumbnail` or `Description`). - Select the **Type** (`Thumbnail` or `Description`).
- For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold. - For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold.
- For `Description`, enter text to trigger this action when a similar tracked object description is detected. - For `Description`, enter text to trigger this action when a similar tracked object description is detected.
+4 -4
View File
@@ -28,7 +28,7 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera. 1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Under the **Zones** section, click the plus icon to add a new zone. 2. Under the **Zones** section, click the plus icon to add a new zone.
3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon. 3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon.
4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor. 4. Configure zone options such as **Name**, **Objects**, **Loitering Time**, and **Inertia** in the zone editor.
5. Press **Save** when finished. 5. Press **Save** when finished.
</TabItem> </TabItem>
@@ -200,7 +200,7 @@ When using loitering zones, a review item will behave in the following way:
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera. 1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Edit or create the zone (e.g., `sidewalk`). 2. Edit or create the zone (e.g., `sidewalk`).
- Set **Loitering time** to the desired number of seconds (e.g., `4`) - Set **Loitering Time** to the desired number of seconds (e.g., `4`)
- Under **Objects**, add the relevant object types (e.g., `person`) - Under **Objects**, add the relevant object types (e.g., `person`)
</TabItem> </TabItem>
@@ -291,7 +291,7 @@ Accurate real-world distance measurements are required to estimate speeds. These
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera. 1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Create or edit a zone with exactly 4 points aligned to the ground plane. 2. Create or edit a zone with exactly 4 points aligned to the ground plane.
3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points. 3. In the zone editor, enable **Speed Estimation** and enter the real-world **Line A distance**, **Line B distance**, **Line C distance**, and **Line D distance** between each pair of consecutive points.
- For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc. - For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc.
4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting. 4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting.
@@ -358,7 +358,7 @@ Zones can be configured with a minimum speed requirement, meaning an object must
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera. 1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Edit or create the zone with distances configured. 2. Edit or create the zone with distances configured.
- Set **Speed threshold** to the desired minimum speed (e.g., `20`) - Set **Speed Threshold** to the desired minimum speed (e.g., `20`)
- The unit is kph or mph, depending on the **Unit system** setting - The unit is kph or mph, depending on the **Unit system** setting
</TabItem> </TabItem>
+2 -2
View File
@@ -54,7 +54,7 @@ An object filter mask drops any [bounding box](#bounding-box) whose bottom cente
## Min Score ## Min Score
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. Set with `min_score` in the config, shown as **Minimum confidence** in the settings UI.
## Model ## Model
@@ -86,7 +86,7 @@ A more specific identity assigned to a [tracked object](#tracked-object-event-in
## Threshold ## Threshold
The median score an object must reach to be considered a true positive. The median score an object must reach to be considered a true positive. Set with `threshold` in the config, shown as **Confidence threshold** in the settings UI.
## Top Score ## Top Score
+30 -1
View File
@@ -75,6 +75,9 @@ Frigate supports multiple different detectors that work on different types of ha
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection - [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector) - [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
- Runs best on discrete AMD GPUs - Runs best on discrete AMD GPUs
- <CommunityBadge /> [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms
- Has only been tested with YOLOv9, in theory other graphs may be compiled too.
- Runs via ZMQ proxy which adds some latency, only recommended for local connection
**Apple Silicon** **Apple Silicon**
@@ -186,7 +189,7 @@ Frigate is able to utilize an Nvidia GPU which supports the 12.x series of CUDA
#### Minimum Hardware Support #### Minimum Hardware Support
12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. Also the GPU must support a Compute Capability of `5.0` or greater. This generally correlates to a Maxwell-era GPU or newer, check the NVIDIA GPU Compute Capability table linked below. 12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. The oldest supported GPU generation for object detection is Pascal (Compute Capability 6.0 or greater). Older generations such as Maxwell are not supported but can still be used by following a [community workaround](https://github.com/blakeblackshear/frigate/discussions/20088). For other GPUs, check the NVIDIA GPU Compute Capability table linked below.
Make sure your host system has the [nvidia-container-runtime](https://docs.docker.com/config/containers/resource_constraints/#access-an-nvidia-gpu) installed to pass through the GPU to the container and the host system has a compatible driver installed for your GPU. Make sure your host system has the [nvidia-container-runtime](https://docs.docker.com/config/containers/resource_constraints/#access-an-nvidia-gpu) installed to pass through the GPU to the container and the host system has a compatible driver installed for your GPU.
@@ -338,6 +341,32 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
| ---------------- | ----------------------------------- | | ---------------- | ----------------------------------- |
| yolov9-tiny | ~ 4 ms | | yolov9-tiny | ~ 4 ms |
### AMD Ryzen AI / XDNA2
Frigate supports AMD XDNA2 NPUs through the community-maintained
frigate-xdna ZMQ sidecar. It works with stock Frigate and supports
Frigate+ models or compatible local YOLO ONNX models. Models are compiled
once on the target system and cached for subsequent use.
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2
devices are not yet qualified; XDNA1 is unsupported.
Measured YOLOv9 detector latency on Strix Halo:
| Model | 320 | 640 |
| ----- | ---: | ---: |
| YOLOv9-T | ~7.4 ms | unsupported |
| YOLOv9-S | ~9.0 ms | ~20.0 ms |
| YOLOv9-M | ~13.1 ms | ~34.4 ms |
| YOLOv9-C | ~14.1 ms | ~35.2 ms |
| YOLOv9-E | ~69.4 ms | ~224.8 ms |
**YOLOv9-C at 320 is the recommended quality/performance balance.**
C at 640 is also usable where the lower throughput is acceptable.
Setup, model preparation, and compatibility details are available
[in the frigate-xdna documentation](https://github.com/mitchins/frigate-xdna).
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version) ## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity. This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
+19 -2
View File
@@ -11,6 +11,12 @@ MQTT requires a network connection to your broker. This is typically local, but
::: :::
:::note
Wherever a topic below includes a camera, mask, or zone name, use its `ID` from the config, not its `friendly_name`. For example, a camera with `friendly_name: "Back Yard"` and ID `back_yard` publishes to `frigate/back_yard/...`, not `frigate/Back Yard/...`.
:::
## General Frigate Topics ## General Frigate Topics
### `frigate/available` ### `frigate/available`
@@ -212,6 +218,7 @@ An `update` with the same ID will be published when:
- The severity changes from `detection` to `alert` - The severity changes from `detection` to `alert`
- Additional objects are detected - Additional objects are detected
- An object is recognized via face, lpr, etc. - An object is recognized via face, lpr, etc.
- A [state classification](/configuration/custom_classification/state_classification#review-items) model changes state
When the review activity has ended a final `end` message is published. When the review activity has ended a final `end` message is published.
@@ -235,7 +242,8 @@ When the review activity has ended a final `end` message is published.
"objects": ["person", "car"], "objects": ["person", "car"],
"sub_labels": [], "sub_labels": [],
"zones": [], "zones": [],
"audio": [] "audio": [],
"classification_state_changes": []
} }
}, },
"after": { "after": {
@@ -254,7 +262,16 @@ When the review activity has ended a final `end` message is published.
"objects": ["person", "car"], "objects": ["person", "car"],
"sub_labels": ["Bob"], "sub_labels": ["Bob"],
"zones": ["front_yard"], "zones": ["front_yard"],
"audio": [] "audio": [],
"classification_state_changes": [
// verified changes of state classification models on this camera
{
"model": "front_gate",
"from": "closed",
"to": "open",
"timestamp": 1718987131.52
}
]
} }
} }
} }
@@ -27,6 +27,10 @@ The [Advanced Camera Card](https://card.camera/#/README) is a Home Assistant das
It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized. It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized.
This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author. This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author.
## [frigate-abr](https://github.com/007hacky007/frigate-abr)
[frigate-abr](https://github.com/007hacky007/frigate-abr) is a drop-in Docker image of Frigate that adds adaptive bitrate (ABR) playback for recordings: a sidecar transcodes footage to lower quality tiers on demand, for reviewing over slow remote connections. Segments are transcoded when played and cached, so no additional stream is recorded. Frigate itself is not modified.
## [Frigate Notify](https://github.com/0x2142/frigate-notify) ## [Frigate Notify](https://github.com/0x2142/frigate-notify)
[Frigate Notify](https://github.com/0x2142/frigate-notify) is a simple app designed to send notifications from Frigate to your favorite platforms. Intended to be used with standalone Frigate installations - Home Assistant not required, MQTT is optional but recommended. [Frigate Notify](https://github.com/0x2142/frigate-notify) is a simple app designed to send notifications from Frigate to your favorite platforms. Intended to be used with standalone Frigate installations - Home Assistant not required, MQTT is optional but recommended.
+7 -1
View File
@@ -21,7 +21,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
### Can I keep using my Frigate+ models even if I do not renew my subscription? ### Can I keep using my Frigate+ models even if I do not renew my subscription?
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models. Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions.
### Can I use Frigate+ models commercially?
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
### Why can't I submit images to Frigate+? ### Why can't I submit images to Frigate+?
+3 -3
View File
@@ -63,10 +63,10 @@ Frigate+ models generally have much higher scores than the default model provide
<ConfigTabs> <ConfigTabs>
<TabItem value="ui"> <TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Min Score** and **Threshold** for each object type, then click **Save**. Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Minimum confidence** and **Confidence threshold** for each object type, then click **Save**.
| Object | Min Score | Threshold | | Object | Minimum confidence | Confidence threshold |
| ----------------- | --------- | --------- | | ----------------- | ------------------ | -------------------- |
| **dog** | .7 | .9 | | **dog** | .7 | .9 |
| **cat** | .65 | .8 | | **cat** | .65 | .8 |
| **face** | .7 | | | **face** | .7 | |
+9 -6
View File
@@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported: Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
- **People**: `person`, `face` - **People**: `person`, `face`, `baby`
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate` - **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail` - **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo` - **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella` - **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases. Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
@@ -77,9 +77,12 @@ Other object types available in the default Frigate model are not available. Add
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings. Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added. Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added.
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday` - **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
- **Other**: `sports_ball`, `drone`, `lawnmower`
Candidate labels are not available for automatic suggestions. Candidate labels are not available for automatic suggestions.
+2 -10
View File
@@ -397,19 +397,11 @@ dmesg | grep -i -E "gpu|drm|reset|hang"
Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date. Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date.
#### Step 6: Verify hardware acceleration configuration #### Step 6: Verify go2rtc stream configuration
An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources.
- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`).
- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`).
- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream.
#### Step 7: Verify go2rtc stream configuration
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely. Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
#### Step 8: Check system resources #### Step 7: Check system resources
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host: If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
+29 -21
View File
@@ -3,6 +3,9 @@ import * as path from "node:path";
import type { Config, PluginConfig } from "@docusaurus/types"; import type { Config, PluginConfig } from "@docusaurus/types";
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs"; import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
// Bump when a new stable release ships
const STABLE_VERSION = "0.18";
const config: Config = { const config: Config = {
title: "Frigate", title: "Frigate",
tagline: "NVR With Realtime Object Detection for IP Cameras", tagline: "NVR With Realtime Object Detection for IP Cameras",
@@ -23,17 +26,17 @@ const config: Config = {
mermaid: true, mermaid: true,
}, },
i18n: { i18n: {
defaultLocale: 'en', defaultLocale: "en",
locales: ['en'], locales: ["en"],
localeConfigs: { localeConfigs: {
en: { en: {
label: 'English', label: "English",
} },
}, },
}, },
themeConfig: { themeConfig: {
announcementBar: { announcementBar: {
id: 'frigate_plus', id: "frigate_plus",
content: ` content: `
<span style="margin-right: 8px; display: inline-block; animation: pulse 2s infinite;">🚀</span> <span style="margin-right: 8px; display: inline-block; animation: pulse 2s infinite;">🚀</span>
Get more relevant and accurate detections with Frigate+ models. Get more relevant and accurate detections with Frigate+ models.
@@ -45,8 +48,8 @@ const config: Config = {
50% { transform: scale(1.1); } 50% { transform: scale(1.1); }
} }
</style>`, </style>`,
backgroundColor: '#005f73', backgroundColor: "#005f73",
textColor: '#e0fbfc', textColor: "#e0fbfc",
isCloseable: false, isCloseable: false,
}, },
docs: { docs: {
@@ -85,13 +88,13 @@ const config: Config = {
prism: { prism: {
magicComments: [ magicComments: [
{ {
className: 'theme-code-block-highlighted-line', className: "theme-code-block-highlighted-line",
line: 'highlight-next-line', line: "highlight-next-line",
block: {start: 'highlight-start', end: 'highlight-end'}, block: { start: "highlight-start", end: "highlight-end" },
}, },
{ {
className: 'code-block-error-line', className: "code-block-error-line",
line: 'highlight-error-line', line: "highlight-error-line",
}, },
], ],
additionalLanguages: ["bash", "json"], additionalLanguages: ["bash", "json"],
@@ -131,6 +134,11 @@ const config: Config = {
srcDark: "img/branding/logo-dark.svg", srcDark: "img/branding/logo-dark.svg",
}, },
items: [ items: [
{
href: "https://github.com/blakeblackshear/frigate/releases",
label: `${STABLE_VERSION}`,
position: "left",
},
{ {
to: "/", to: "/",
activeBasePath: "docs", activeBasePath: "docs",
@@ -148,19 +156,19 @@ const config: Config = {
position: "right", position: "right",
}, },
{ {
type: 'localeDropdown', type: "localeDropdown",
position: 'right', position: "right",
dropdownItemsAfter: [ dropdownItemsAfter: [
{ {
label: '简体中文(社区翻译)', label: "简体中文(社区翻译)",
href: 'https://docs.frigate-cn.video', href: "https://docs.frigate-cn.video",
} },
] ],
}, },
{ {
href: 'https://github.com/blakeblackshear/frigate', href: "https://github.com/blakeblackshear/frigate",
label: 'GitHub', label: "GitHub",
position: 'right', position: "right",
}, },
], ],
}, },
+30 -21
View File
@@ -7030,9 +7030,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.18", "version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -7698,14 +7698,15 @@
} }
}, },
"node_modules/compression": { "node_modules/compression": {
"version": "1.8.1", "version": "1.8.2",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "3.1.2", "bytes": "3.1.2",
"compressible": "~2.0.18", "compressible": "~2.0.18",
"debug": "2.6.9", "debug": "2.6.9",
"destroy": "1.2.0",
"negotiator": "~0.6.4", "negotiator": "~0.6.4",
"on-headers": "~1.1.0", "on-headers": "~1.1.0",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
@@ -7713,6 +7714,10 @@
}, },
"engines": { "engines": {
"node": ">= 0.8.0" "node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/compression/node_modules/bytes": { "node_modules/compression/node_modules/bytes": {
@@ -9460,9 +9465,9 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.13", "version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@@ -10125,9 +10130,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.7", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -17448,9 +17453,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"forwarded": "0.2.0", "forwarded": "0.2.0",
@@ -17458,6 +17463,10 @@
}, },
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/proxy-addr/node_modules/ipaddr.js": { "node_modules/proxy-addr/node_modules/ipaddr.js": {
@@ -19229,9 +19238,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.10.0", "version": "1.12.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -19513,9 +19522,9 @@
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
+65 -2
View File
@@ -412,6 +412,39 @@ paths:
security: security:
- frigateAdminAuth: [] - frigateAdminAuth: []
x-required-role: admin x-required-role: admin
/go2rtc/streams/{stream_name}/bitrate:
get:
tags:
- Camera
summary: Go2Rtc Stream Bitrate
description: |-
**Access:** Admin role required.
Measure a go2rtc stream's bitrate over a few seconds.
operationId:
go2rtc_stream_bitrate_go2rtc_streams__stream_name__bitrate_get
parameters:
- name: stream_name
in: path
required: true
schema:
type: string
title: Stream Name
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/ffprobe: /ffprobe:
get: get:
tags: tags:
@@ -4460,6 +4493,16 @@ paths:
- type: 'null' - type: 'null'
default: 100 default: 100
title: Limit title: Limit
- name: offset
in: query
required: false
schema:
anyOf:
- type: integer
minimum: 0
- type: 'null'
default: 0
title: Offset
- name: after - name: after
in: query in: query
required: false required: false
@@ -4765,6 +4808,16 @@ paths:
- type: 'null' - type: 'null'
default: 50 default: 50
title: Limit title: Limit
- name: offset
in: query
required: false
schema:
anyOf:
- type: integer
minimum: 0
- type: 'null'
default: 0
title: Offset
- name: cameras - name: cameras
in: query in: query
required: false required: false
@@ -7731,6 +7784,11 @@ components:
type: boolean type: boolean
title: Skip Save title: Skip Save
default: false default: false
replace_paths:
items:
type: string
type: array
title: Replace Paths
type: object type: object
title: AppConfigSetBody title: AppConfigSetBody
AppPostLoginBody: AppPostLoginBody:
@@ -7959,7 +8017,7 @@ components:
properties: properties:
file: file:
type: string type: string
format: binary contentMediaType: application/octet-stream
title: File title: File
type: object type: object
required: required:
@@ -7969,7 +8027,7 @@ components:
properties: properties:
file: file:
type: string type: string
format: binary contentMediaType: application/octet-stream
title: File title: File
type: object type: object
required: required:
@@ -9543,6 +9601,11 @@ components:
type: type:
type: string type: string
title: Error Type title: Error Type
input:
title: Input
ctx:
type: object
title: Context
type: object type: object
required: required:
- loc - loc
+55 -3
View File
@@ -63,6 +63,7 @@ from frigate.util.builtin import (
flatten_config_data, flatten_config_data,
load_labels, load_labels,
process_config_query_string, process_config_query_string,
split_config_key_path,
update_yaml_file_bulk, update_yaml_file_bulk,
) )
from frigate.util.config import ( from frigate.util.config import (
@@ -70,6 +71,10 @@ from frigate.util.config import (
find_config_file, find_config_file,
redact_credential, redact_credential,
) )
from frigate.util.live_streams import (
generated_transcode_streams,
sync_transcode_streams,
)
from frigate.util.object_names import get_categorized_object_names from frigate.util.object_names import get_categorized_object_names
from frigate.util.schema import get_config_schema from frigate.util.schema import get_config_schema
from frigate.util.services import ( from frigate.util.services import (
@@ -306,9 +311,12 @@ def config(request: Request):
mode="json", warnings="none", exclude_none=True mode="json", warnings="none", exclude_none=True
) )
# remove environment_vars for non-admin users is_admin = request.headers.get("remote-role") == "admin"
if 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) config.pop("environment_vars", None)
redact_credential(config["notifications"], "email")
# redact mqtt credentials # redact mqtt credentials
redact_credential(config["mqtt"], "password") redact_credential(config["mqtt"], "password")
@@ -365,7 +373,15 @@ def config(request: Request):
camera_name camera_name
) )
if base_sections: 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 # remove go2rtc stream passwords
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump( go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
@@ -393,6 +409,11 @@ def config(request: Request):
model_dict["non_logo_attributes"] = model.non_logo_attributes model_dict["non_logo_attributes"] = model.non_logo_attributes
model_dict["labelmap"] = model.merged_labelmap model_dict["labelmap"] = model.merged_labelmap
# report the configured reference rather than the resolved cache path,
# so saving the config back doesn't lose the Frigate+ model
if model.plus_id:
model_dict["path"] = f"plus://{model.plus_id}"
if not config["plus"]["enabled"]: if not config["plus"]["enabled"]:
continue continue
@@ -810,6 +831,17 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
) )
def _config_path_exists(data: Any, key_path: str) -> bool:
"""Return whether a dotted config path is present in parsed yaml."""
for key in split_config_key_path(key_path):
if not isinstance(data, dict) or key not in data:
return False
data = data[key]
return True
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) @router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
def config_set(request: Request, body: AppConfigSetBody): def config_set(request: Request, body: AppConfigSetBody):
config_file = find_config_file() config_file = find_config_file()
@@ -864,6 +896,19 @@ def config_set(request: Request, body: AppConfigSetBody):
status_code=400, status_code=400,
) )
# delete replaced paths first so their maps are rewritten in
# the order sent; update_yaml would otherwise keep old order
if body.replace_paths:
old_yaml = ruamel.yaml.YAML(typ="safe").load(old_raw_config) or {}
updates = {
**{
path: ""
for path in body.replace_paths
if _config_path_exists(old_yaml, path)
},
**updates,
}
# apply all updates in a single operation # apply all updates in a single operation
update_yaml_file_bulk(config_file, updates) update_yaml_file_bulk(config_file, updates)
@@ -927,9 +972,15 @@ def config_set(request: Request, body: AppConfigSetBody):
if request.app.dispatcher is not None: if request.app.dispatcher is not None:
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys()) request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
go2rtc_synced = True
if body.requires_restart == 0 or body.update_topic: if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config old_config: FrigateConfig = request.app.frigate_config
swap_runtime_config(request.app, config) swap_runtime_config(request.app, config)
go2rtc_synced = sync_transcode_streams(
generated_transcode_streams(old_config),
generated_transcode_streams(config),
)
if body.update_topic: if body.update_topic:
if body.update_topic.startswith("config/cameras/"): if body.update_topic.startswith("config/cameras/"):
@@ -989,6 +1040,7 @@ def config_set(request: Request, body: AppConfigSetBody):
if body.requires_restart == 0 if body.requires_restart == 0
else "Config successfully updated, restart to apply" else "Config successfully updated, restart to apply"
), ),
"go2rtc_synced": go2rtc_synced,
} }
), ),
status_code=200, status_code=200,
+20 -20
View File
@@ -130,23 +130,18 @@ def require_admin_by_default():
if path.startswith(EXEMPT_PREFIXES): if path.startswith(EXEMPT_PREFIXES):
return return
# Dynamic camera path exemption: # Camera routes enforce per-camera access via route-level dependencies
# Any path whose first segment matches a configured camera name should # (e.g. require_camera_access). Match on the route template, not the raw
# bypass the global admin requirement. These endpoints enforce access # path, so a camera named like another namespace (e.g. "faces") can't
# via route-level dependencies (e.g. require_camera_access) to ensure # waive the admin check for that namespace's routes.
# per-camera authorization. This allows non-admin authenticated users route = request.scope.get("route")
# (e.g. viewer role) to access camera-specific resources without
# needing admin privileges.
try:
if path.startswith("/"):
first_segment = path.split("/", 2)[1]
if ( if (
first_segment route is not None
and first_segment in request.app.frigate_config.cameras and route.path.startswith("/{camera_name}")
and request.path_params.get("camera_name")
in request.app.frigate_config.cameras
): ):
return return
except Exception:
pass
# For all other paths, require admin role # For all other paths, require admin role
# Internal port requests have admin role set automatically # Internal port requests have admin role set automatically
@@ -324,11 +319,17 @@ def get_remote_addr(request: Request):
network = ipaddress.ip_network(proxy) network = ipaddress.ip_network(proxy)
except ValueError: except ValueError:
logger.warning(f"Unable to parse trusted network: {proxy}") logger.warning(f"Unable to parse trusted network: {proxy}")
continue
trusted_proxies.append(network) trusted_proxies.append(network)
# return the first remote address that is not trusted # return the first remote address that is not trusted
for addr in route: for addr in route:
try:
ip = ipaddress.ip_address(addr.strip()) ip = ipaddress.ip_address(addr.strip())
except ValueError:
logger.debug("Invalid address in X-Forwarded-For header")
return direct_addr or "127.0.0.1"
logger.debug(f"Checking {ip} (v{ip.version})") logger.debug(f"Checking {ip} (v{ip.version})")
trusted = False trusted = False
for trusted_proxy in trusted_proxies: for trusted_proxy in trusted_proxies:
@@ -473,12 +474,11 @@ def create_encoded_jwt(user, role, expiration, secret):
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure): def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
# TODO: ideally this would set secure as well, but that requires TLS # TODO: ideally this would set secure as well, but that requires TLS
# SameSite is intentionally left unset (browsers default to Lax). Setting # Starlette sets SameSite=Lax by default. The cookie is still sent to
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin # same-site iframes (e.g. Home Assistant on the same host or domain), but
# iframes, breaking embedded views such as the Home Assistant Frigate card. # not to cross-site ones. CSRF is also mitigated by requiring a custom
# CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which # X-CSRF-TOKEN header, which cross-origin pages cannot set without a CORS
# cross-origin pages cannot set without a CORS preflight that Frigate never # preflight that Frigate never grants (see check_csrf in api/fastapi_app.py).
# grants (see check_csrf in api/fastapi_app.py).
response.set_cookie( response.set_cookie(
key=cookie_name, key=cookie_name,
value=encoded_jwt, value=encoded_jwt,
+39
View File
@@ -39,6 +39,11 @@ from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file from frigate.util.config import find_config_file
from frigate.util.image import run_ffmpeg_snapshot from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.live_streams import (
generated_transcode_streams,
measure_stream_bitrate,
sync_transcode_streams,
)
from frigate.util.services import ( from frigate.util.services import (
analyze_record_keyframes, analyze_record_keyframes,
ffprobe_stream, ffprobe_stream,
@@ -248,6 +253,34 @@ def go2rtc_delete_stream(stream_name: str):
) )
@router.get(
"/go2rtc/streams/{stream_name}/bitrate",
dependencies=[Depends(require_role(["admin"]))],
)
async def go2rtc_stream_bitrate(request: Request, stream_name: str):
"""Measure a go2rtc stream's bitrate over a few seconds."""
config: FrigateConfig = request.app.frigate_config
known = set(config.go2rtc.model_dump().get("streams") or {}) | set(
generated_transcode_streams(config)
)
if stream_name not in known:
return JSONResponse(
content={"success": False, "message": "Unknown stream"},
status_code=404,
)
kbps = await asyncio.to_thread(measure_stream_bitrate, stream_name)
if kbps is None:
return JSONResponse(
content={"success": False, "message": "Stream sent no data"},
status_code=502,
)
return JSONResponse(content={"success": True, "kbps": round(kbps)})
@router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))]) @router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
def ffprobe(request: Request, paths: str = "", detailed: bool = False): def ffprobe(request: Request, paths: str = "", detailed: bool = False):
path_param = paths path_param = paths
@@ -1342,6 +1375,12 @@ async def delete_camera(
except Exception: except Exception:
logger.debug("Failed to remove go2rtc stream for %s", camera_name) logger.debug("Failed to remove go2rtc stream for %s", camera_name)
await asyncio.to_thread(
sync_transcode_streams,
generated_transcode_streams(frigate_config),
generated_transcode_streams(request.app.frigate_config),
)
return JSONResponse( return JSONResponse(
content={ content={
"success": True, "success": True,
+1
View File
@@ -51,6 +51,7 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
if app.stats_emitter is not None: if app.stats_emitter is not None:
app.stats_emitter.config = config app.stats_emitter.config = config
app.stats_emitter.hardware_stats.set_config(config)
if app.dispatcher is not None: if app.dispatcher is not None:
app.dispatcher.config = config app.dispatcher.config = config
@@ -14,6 +14,7 @@ class EventsQueryParams(BaseModel):
zone: str | None = "all" zone: str | None = "all"
zones: str | None = "all" zones: str | None = "all"
limit: int | None = 100 limit: int | None = 100
offset: int | None = Field(0, ge=0)
after: float | None = None after: float | None = None
before: float | None = None before: float | None = None
time_range: str | None = DEFAULT_TIME_RANGE time_range: str | None = DEFAULT_TIME_RANGE
@@ -55,6 +56,7 @@ class EventsSearchQueryParams(BaseModel):
deprecated=True, deprecated=True,
) )
limit: int | None = 50 limit: int | None = 50
offset: int | None = Field(0, ge=0)
cameras: str | None = "all" cameras: str | None = "all"
labels: str | None = "all" labels: str | None = "all"
sub_labels: str | None = "all" sub_labels: str | None = "all"
+2
View File
@@ -10,6 +10,8 @@ class AppConfigSetBody(BaseModel):
update_topic: str | None = None update_topic: str | None = None
config_data: dict[str, Any] | None = None config_data: dict[str, Any] | None = None
skip_save: bool = False skip_save: bool = False
# paths rewritten whole, so a map saves in the order sent
replace_paths: list[str] = Field(default_factory=list)
class GenAIProbeBody(BaseModel): class GenAIProbeBody(BaseModel):
+11 -2
View File
@@ -129,6 +129,7 @@ def events(
zones = zone zones = zone
limit = params.limit limit = params.limit
offset = params.offset
after = params.after after = params.after
before = params.before before = params.before
time_range = params.time_range time_range = params.time_range
@@ -361,11 +362,15 @@ def events(
else: else:
order_by = Event.start_time.desc() order_by = Event.start_time.desc()
# offset paging needs a stable order when scores or speeds tie
tiebreaker = [Event.id] if sort and sort.startswith(("score", "speed")) else []
events = ( events = (
Event.select(*selected_columns) Event.select(*selected_columns)
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.order_by(order_by) .order_by(order_by, *tiebreaker)
.limit(limit) .limit(limit)
.offset(offset)
.dicts() .dicts()
.iterator() .iterator()
) )
@@ -534,6 +539,7 @@ def events_search(
search_type = params.search_type search_type = params.search_type
include_thumbnails = params.include_thumbnails include_thumbnails = params.include_thumbnails
limit = params.limit limit = params.limit
offset = params.offset
sort = params.sort sort = params.sort
# Filters # Filters
@@ -840,6 +846,9 @@ def events_search(
if search_results: if search_results:
events_query = events_query.where(Event.id << list(search_results.keys())) events_query = events_query.where(Event.id << list(search_results.keys()))
# sorts below are stable, so this orders ties for offset paging
events_query = events_query.order_by(Event.id)
# Fetch events and process them in a single pass # Fetch events and process them in a single pass
processed_events = [] processed_events = []
for event in events_query.dicts(): for event in events_query.dicts():
@@ -897,7 +906,7 @@ def events_search(
processed_events.sort(key=lambda x: x["start_time"], reverse=True) processed_events.sort(key=lambda x: x["start_time"], reverse=True)
# Limit the number of events returned # Limit the number of events returned
processed_events = processed_events[:limit] processed_events = processed_events[offset:][:limit]
return JSONResponse(content=processed_events) return JSONResponse(content=processed_events)
+1 -7
View File
@@ -63,7 +63,6 @@ from frigate.util.recording_coverage import (
null_audio_glitches, null_audio_glitches,
plan_clip, plan_clip,
resolve_coverage, resolve_coverage,
stream_has_audio,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -681,15 +680,10 @@ async def _vod_response(
end_ts, end_ts,
force_discontinuity, force_discontinuity,
) )
intervals = resolve_coverage(camera_name, start_ts, end_ts)
# rows contradicting their stream's audio composition are # rows contradicting their stream's audio composition are
# truncated-shutdown glitches # truncated-shutdown glitches
main_audio = stream_has_audio(intervals, main=True)
sub_audio = stream_has_audio(intervals, main=False)
spans = build_spans( spans = build_spans(
null_audio_glitches(intervals, main_audio, sub_audio), null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
stream_preference, stream_preference,
) )
+20 -11
View File
@@ -44,6 +44,22 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.review]) router = APIRouter(tags=[Tags.review])
def get_label_clause(label: str, include_audio: bool = True):
"""Build a clause matching a label within a review segment's data.
Verified objects are stored with a `-verified` suffix (eg. `person-verified`)
so that variant is matched as well.
"""
clause = (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | (
ReviewSegment.data["objects"].cast("text") % f'*"{label}-verified"*'
)
if include_audio:
clause |= ReviewSegment.data["audio"].cast("text") % f'*"{label}"*'
return clause
@router.get( @router.get(
"/review", "/review",
response_model=list[ReviewSegmentResponse], response_model=list[ReviewSegmentResponse],
@@ -93,10 +109,7 @@ async def review(
filtered_labels = labels.split(",") filtered_labels = labels.split(",")
for label in filtered_labels: for label in filtered_labels:
label_clauses.append( label_clauses.append(get_label_clause(label))
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
)
clauses.append(reduce(operator.or_, label_clauses)) clauses.append(reduce(operator.or_, label_clauses))
if zones != "all": if zones != "all":
@@ -239,10 +252,7 @@ async def review_summary(
filtered_labels = labels.split(",") filtered_labels = labels.split(",")
for label in filtered_labels: for label in filtered_labels:
label_clauses.append( label_clauses.append(get_label_clause(label))
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
)
clauses.append(reduce(operator.or_, label_clauses)) clauses.append(reduce(operator.or_, label_clauses))
if zones != "all": if zones != "all":
# use matching so segments with multiple zones # use matching so segments with multiple zones
@@ -340,9 +350,8 @@ async def review_summary(
filtered_labels = labels.split(",") filtered_labels = labels.split(",")
for label in filtered_labels: for label in filtered_labels:
label_clauses.append( label_clauses.append(get_label_clause(label, include_audio=False))
ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
)
clauses.append(reduce(operator.or_, label_clauses)) clauses.append(reduce(operator.or_, label_clauses))
# Find the time range of available data # Find the time range of available data
+5 -10
View File
@@ -49,7 +49,6 @@ from frigate.debug_replay import (
DebugReplayManager, DebugReplayManager,
cleanup_replay_cameras, cleanup_replay_cameras,
) )
from frigate.detectors.detector_config import SceneEnum
from frigate.detectors.detector_types import api_types from frigate.detectors.detector_types import api_types
from frigate.detectors.device import build_detector_config, runner_names from frigate.detectors.device import build_detector_config, runner_names
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
@@ -78,7 +77,7 @@ from frigate.notices.registry import NoticeRegistry
from frigate.object_detection.base import ObjectDetectProcess from frigate.object_detection.base import ObjectDetectProcess
from frigate.object_detection.util import detection_frame_size from frigate.object_detection.util import detection_frame_size
from frigate.output.output import OutputProcess 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.ptz.onvif import OnvifController
from frigate.record.cleanup import RecordingCleanup from frigate.record.cleanup import RecordingCleanup
from frigate.record.export import migrate_exports from frigate.record.export import migrate_exports
@@ -108,7 +107,7 @@ class FrigateApp:
self.metrics_manager = manager self.metrics_manager = manager
self.audio_process: mp.Process | None = None self.audio_process: mp.Process | None = None
self.stop_event = stop_event self.stop_event = stop_event
self.detection_queues: dict[SceneEnum, Queue] = { self.detection_queues: dict[str, Queue] = {
model.scene: mp.Queue() for model in config.models model.scene: mp.Queue() for model in config.models
} }
self.detectors: dict[str, ObjectDetectProcess] = {} self.detectors: dict[str, ObjectDetectProcess] = {}
@@ -167,11 +166,7 @@ class FrigateApp:
# create camera_metrics # create camera_metrics
for camera_name in self.config.cameras.keys(): for camera_name in self.config.cameras.keys():
self.camera_metrics[camera_name] = CameraMetrics(self.metrics_manager) self.camera_metrics[camera_name] = CameraMetrics(self.metrics_manager)
self.ptz_metrics[camera_name] = PTZMetrics( self.ptz_metrics[camera_name] = PTZMetrics()
autotracker_enabled=self.config.cameras[
camera_name
].onvif.autotracking.enabled
)
def init_queues(self) -> None: def init_queues(self) -> None:
# Queue for cameras to push tracked objects to # Queue for cameras to push tracked objects to
@@ -395,7 +390,7 @@ class FrigateApp:
logger.error("Unable to prepare the %s runtime: %s", detector_type, err) logger.error("Unable to prepare the %s runtime: %s", detector_type, err)
def start_detectors(self) -> None: def start_detectors(self) -> None:
model_cameras: dict[SceneEnum, list[str]] = { model_cameras: dict[str, list[str]] = {
model.scene: [] for model in self.config.models model.scene: [] for model in self.config.models
} }
@@ -444,7 +439,7 @@ class FrigateApp:
) )
def start_ptz_autotracker(self) -> None: def start_ptz_autotracker(self) -> None:
self.ptz_autotracker_thread = PtzAutoTrackerThread( self.ptz_autotracker_thread = PtzAutoTracker(
self.config, self.config,
self.onvif_controller, self.onvif_controller,
self.ptz_metrics, self.ptz_metrics,
+1 -7
View File
@@ -43,8 +43,6 @@ class CameraMetrics:
class PTZMetrics: class PTZMetrics:
autotracker_enabled: Synchronized
start_time: Synchronized start_time: Synchronized
stop_time: Synchronized stop_time: Synchronized
frame_time: Synchronized frame_time: Synchronized
@@ -52,13 +50,10 @@ class PTZMetrics:
max_zoom: Synchronized max_zoom: Synchronized
min_zoom: Synchronized min_zoom: Synchronized
tracking_active: Event
motor_stopped: Event motor_stopped: Event
reset: Event reset: Event
def __init__(self, *, autotracker_enabled: bool): def __init__(self) -> None:
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment]
self.start_time = mp.Value("d", 0) # type: ignore[assignment] self.start_time = mp.Value("d", 0) # type: ignore[assignment]
self.stop_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] 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.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.min_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.motor_stopped = mp.Event()
self.reset = mp.Event() self.reset = mp.Event()
+6 -4
View File
@@ -104,12 +104,13 @@ class CameraActivityManager:
all_objects: list[dict[str, Any]] = [] all_objects: list[dict[str, Any]] = []
for camera in new_activity.keys(): for camera in new_activity.keys():
if camera not in self.config.cameras: camera_config = self.config.cameras.get(camera)
if camera_config is None:
continue continue
# handle cameras that were added dynamically # handle cameras that were added dynamically
if camera not in self.camera_all_object_counts: if camera not in self.camera_all_object_counts:
self.__init_camera(self.config.cameras[camera]) self.__init_camera(camera_config)
new_objects = new_activity[camera].get("objects", []) new_objects = new_activity[camera].get("objects", [])
all_objects.extend(new_objects) all_objects.extend(new_objects)
@@ -234,12 +235,13 @@ class AudioActivityManager:
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
for camera in new_activity.keys(): for camera in new_activity.keys():
if camera not in self.config.cameras: camera_config = self.config.cameras.get(camera)
if camera_config is None:
continue continue
# handle cameras that were added dynamically # handle cameras that were added dynamically
if camera not in self.current_audio_detections: if camera not in self.current_audio_detections:
self.__init_camera(self.config.cameras[camera]) self.__init_camera(camera_config)
new_detections = new_activity[camera].get("detections", []) new_detections = new_activity[camera].get("detections", [])
if self.compare_audio_activity(camera, new_detections, now): if self.compare_audio_activity(camera, new_detections, now):
+2 -5
View File
@@ -15,7 +15,6 @@ from frigate.config.camera.updater import (
CameraConfigUpdateSubscriber, CameraConfigUpdateSubscriber,
) )
from frigate.const import REPLAY_CAMERA_PREFIX from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.detectors.detector_config import SceneEnum
from frigate.models import Regions from frigate.models import Regions
from frigate.object_detection.util import detection_frame_size from frigate.object_detection.util import detection_frame_size
from frigate.util.builtin import empty_and_close_queue from frigate.util.builtin import empty_and_close_queue
@@ -31,7 +30,7 @@ class CameraMaintainer(threading.Thread):
def __init__( def __init__(
self, self,
config: FrigateConfig, config: FrigateConfig,
detection_queues: dict[SceneEnum, Queue], detection_queues: dict[str, Queue],
detected_frames_queue: Queue, detected_frames_queue: Queue,
camera_metrics: DictProxy, camera_metrics: DictProxy,
ptz_metrics: dict[str, PTZMetrics], ptz_metrics: dict[str, PTZMetrics],
@@ -121,9 +120,7 @@ class CameraMaintainer(threading.Thread):
if runtime: if runtime:
self.camera_metrics[name] = CameraMetrics(self.metrics_manager) self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
self.ptz_metrics[name] = PTZMetrics( self.ptz_metrics[name] = PTZMetrics()
autotracker_enabled=config.onvif.autotracking.enabled
)
self.region_grids[name] = get_camera_regions_grid( self.region_grids[name] = get_camera_regions_grid(
name, name,
config.detect, config.detect,
+9 -13
View File
@@ -16,7 +16,7 @@ from frigate.config import (
ZoomingModeEnum, ZoomingModeEnum,
) )
from frigate.const import CLIPS_DIR, THUMB_DIR 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.track.tracked_object import TrackedObject
from frigate.util.image import ( from frigate.util.image import (
SharedMemoryFrameManager, SharedMemoryFrameManager,
@@ -35,7 +35,7 @@ class CameraState:
name: str, name: str,
config: FrigateConfig, config: FrigateConfig,
frame_manager: SharedMemoryFrameManager, frame_manager: SharedMemoryFrameManager,
ptz_autotracker_thread: PtzAutoTrackerThread, ptz_autotracker_thread: PtzAutoTracker,
) -> None: ) -> None:
self.name = name self.name = name
self.config = config self.config = config
@@ -115,17 +115,13 @@ class CameraState:
# draw thicker box around ptz autotracked object # draw thicker box around ptz autotracked object
if ( if (
self.camera_config.onvif.autotracking.enabled self.camera_config.onvif.autotracking.enabled
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get( and self.ptz_autotracker_thread.autotracker_init.get(self.name)
self.name and self.ptz_autotracker_thread.tracked_object[self.name]
)
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
self.name
]
is not None is not None
and obj["id"] and obj["id"]
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[ == self.ptz_autotracker_thread.tracked_object[ # type: ignore[union-attr]
self.name self.name
].obj_data["id"] # type: ignore[attr-defined] ].obj_data["id"]
and obj["frame_time"] == frame_time and obj["frame_time"] == frame_time
): ):
thickness = 5 thickness = 5
@@ -138,9 +134,9 @@ class CameraState:
and self.camera_config.detect.width is not None and self.camera_config.detect.width is not None
and self.camera_config.detect.height is not None and self.camera_config.detect.height is not None
): ):
max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[ max_target_box = calculate_max_target_box(
self.name self.camera_config.onvif.autotracking.zoom_factor
]["max_target_box"] # type: ignore[index] )
side_length = max_target_box * ( side_length = max_target_box * (
max( max(
self.camera_config.detect.width, self.camera_config.detect.width,
+1
View File
@@ -12,6 +12,7 @@ class DetectionTypeEnum(str, Enum):
video = "video" video = "video"
audio = "audio" audio = "audio"
lpr = "lpr" lpr = "lpr"
classification_state = "classification_state"
class DetectionPublisher(Publisher): class DetectionPublisher(Publisher):
+8 -4
View File
@@ -759,15 +759,13 @@ class Dispatcher:
"Autotracking must be enabled in the config to be turned on via MQTT." "Autotracking must be enabled in the config to be turned on via MQTT."
) )
return 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}") 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 self.ptz_metrics[camera_name].start_time.value = 0
ptz_autotracker_settings.enabled = True ptz_autotracker_settings.enabled = True
elif payload == "OFF": 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}") 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 self.ptz_metrics[camera_name].start_time.value = 0
ptz_autotracker_settings.enabled = False ptz_autotracker_settings.enabled = False
@@ -782,7 +780,9 @@ class Dispatcher:
try: try:
payload = int(payload) payload = int(payload)
except ValueError: except ValueError:
logger.warning(
f"Received unsupported value for motion contour area: {payload}" f"Received unsupported value for motion contour area: {payload}"
)
return return
motion_settings = self.config.cameras[camera_name].motion motion_settings = self.config.cameras[camera_name].motion
@@ -799,7 +799,9 @@ class Dispatcher:
try: try:
payload = int(payload) payload = int(payload)
except ValueError: except ValueError:
logger.warning(
f"Received unsupported value for motion threshold: {payload}" f"Received unsupported value for motion threshold: {payload}"
)
return return
motion_settings = self.config.cameras[camera_name].motion motion_settings = self.config.cameras[camera_name].motion
@@ -814,7 +816,9 @@ class Dispatcher:
def _on_global_notification_command(self, payload: str) -> None: def _on_global_notification_command(self, payload: str) -> None:
"""Callback for global notification topic.""" """Callback for global notification topic."""
if payload != "ON" and payload != "OFF": if payload != "ON" and payload != "OFF":
logger.warning(
f"Received unsupported value for all notification: {payload}" f"Received unsupported value for all notification: {payload}"
)
return return
notification_settings = self.config.notifications notification_settings = self.config.notifications
+58 -3
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import logging import logging
import queue import queue
import selectors
import socket
import threading import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
@@ -56,6 +58,11 @@ class MqttClient(Communicator):
self._next_connect_time = 0.0 self._next_connect_time = 0.0
self._last_on_connect_dispatch = 0.0 self._last_on_connect_dispatch = 0.0
# lets other threads interrupt the worker's socket wait
self._wake_recv, self._wake_send = socket.socketpair()
self._wake_recv.setblocking(False)
self._wake_send.setblocking(False)
def subscribe(self, receiver: Callable) -> None: def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe.""" """Wrapper for allowing dispatcher to subscribe."""
self._dispatcher = receiver self._dispatcher = receiver
@@ -85,6 +92,7 @@ class MqttClient(Communicator):
return return
self._publish_queue.put(QueuedPublish(full_topic, payload, retain)) self._publish_queue.put(QueuedPublish(full_topic, payload, retain))
self._wake_worker()
def stop(self) -> None: def stop(self) -> None:
if self._worker is None: if self._worker is None:
@@ -100,9 +108,11 @@ class MqttClient(Communicator):
publish_done, publish_done,
) )
) )
self._wake_worker()
publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT) publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT)
self._stop_event.set() self._stop_event.set()
self._wake_worker()
if self.client is not None: if self.client is not None:
try: try:
@@ -358,7 +368,7 @@ class MqttClient(Communicator):
deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT
while not message_info.is_published() and time.monotonic() < deadline: while not message_info.is_published() and time.monotonic() < deadline:
if ( if (
self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL) self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
!= mqtt.MQTT_ERR_SUCCESS != mqtt.MQTT_ERR_SUCCESS
): ):
break break
@@ -368,6 +378,51 @@ class MqttClient(Communicator):
exc_info=True, exc_info=True,
) )
def _wake_worker(self) -> None:
try:
self._wake_send.send(b"\0")
except BlockingIOError:
# the buffer is full, so a wake is already pending
pass
def _loop_client(self, timeout: float) -> int:
"""Drive Paho without select()'s limit on socket file descriptors."""
assert self.client is not None
client = self.client
sock = client.socket()
if sock is None:
return mqtt.MQTT_ERR_NO_CONN
events = selectors.EVENT_READ
if client.want_write():
events |= selectors.EVENT_WRITE
# TLS can have decrypted bytes buffered even when the socket is not ready.
pending = hasattr(sock, "pending") and sock.pending() > 0
with selectors.DefaultSelector() as selector:
selector.register(sock, events)
selector.register(self._wake_recv, selectors.EVENT_READ)
ready = {
key.fileobj: mask
for key, mask in selector.select(0.0 if pending else timeout)
}
if self._wake_recv in ready:
try:
self._wake_recv.recv(4096)
except BlockingIOError:
pass
ready_events = ready.get(sock, 0)
if pending or ready_events & selectors.EVENT_READ:
result = client.loop_read()
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
return result
if ready_events & selectors.EVENT_WRITE:
result = client.loop_write()
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
return result
return client.loop_misc()
def _mqtt_loop_worker(self) -> None: def _mqtt_loop_worker(self) -> None:
# The worker owns all socket I/O so reconnect, subscribe, and publish # The worker owns all socket I/O so reconnect, subscribe, and publish
# ordering stays serialized in one place. # ordering stays serialized in one place.
@@ -384,7 +439,7 @@ class MqttClient(Communicator):
assert self.client is not None assert self.client is not None
try: try:
result = self.client.loop(timeout=MQTT_LOOP_TIMEOUT) result = self._loop_client(timeout=MQTT_LOOP_TIMEOUT)
except (OSError, mqtt.WebsocketConnectionError) as err: except (OSError, mqtt.WebsocketConnectionError) as err:
logger.warning("MQTT loop error: %s", err) logger.warning("MQTT loop error: %s", err)
self._schedule_reconnect() self._schedule_reconnect()
@@ -617,7 +672,7 @@ class MqttClient(Communicator):
return return
try: try:
result = self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL) result = self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
except (OSError, mqtt.WebsocketConnectionError) as err: except (OSError, mqtt.WebsocketConnectionError) as err:
logger.warning("MQTT publish wait failed: %s", err) logger.warning("MQTT publish wait failed: %s", err)
self._schedule_reconnect() self._schedule_reconnect()
+5 -4
View File
@@ -1,6 +1,6 @@
from pydantic import Field, model_validator from pydantic import Field, model_validator
from frigate.detectors.detector_config import SceneEnum from frigate.detectors.detector_config import DEFAULT_SCENE, SCENE_PATTERN
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
@@ -62,10 +62,11 @@ class DetectConfig(FrigateBaseModel):
title="Detect width", title="Detect width",
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
) )
scene: SceneEnum = Field( scene: str = Field(
default=SceneEnum.all, default=DEFAULT_SCENE,
pattern=SCENE_PATTERN,
title="Detect scene", title="Detect scene",
description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'.", description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'.",
) )
fps: int = Field( fps: int = Field(
default=5, default=5,
+56 -2
View File
@@ -1,8 +1,57 @@
from pydantic import Field from pydantic import Field, field_validator
from frigate.util.live_streams import DEFAULT_TRANSCODE_QUALITIES
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
__all__ = ["CameraLiveConfig"] __all__ = ["CameraLiveConfig", "LiveTranscodeConfig", "LiveTranscodeQualityConfig"]
class LiveTranscodeQualityConfig(FrigateBaseModel):
height: int = Field(
ge=144,
le=2160,
title="Height",
description="Output height in pixels; width follows the source aspect ratio.",
)
bitrate: int = Field(
ge=64,
title="Bitrate",
description="Target and maximum video bitrate in kbps.",
)
class LiveTranscodeConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable transcoded streams",
description="Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.",
)
source: str | None = Field(
default=None,
title="Source stream",
description="go2rtc stream to transcode. Defaults to the first live stream.",
)
qualities: list[LiveTranscodeQualityConfig] = Field(
default_factory=lambda: [
LiveTranscodeQualityConfig(**quality)
for quality in DEFAULT_TRANSCODE_QUALITIES
],
title="Qualities",
description="One transcoded stream is added per quality.",
)
@field_validator("qualities")
@classmethod
def validate_unique_heights(
cls, qualities: list[LiveTranscodeQualityConfig]
) -> list[LiveTranscodeQualityConfig]:
heights = [quality.height for quality in qualities]
if len(heights) != len(set(heights)):
raise ValueError("Transcoded stream heights must be unique.")
return qualities
class CameraLiveConfig(FrigateBaseModel): class CameraLiveConfig(FrigateBaseModel):
@@ -11,6 +60,11 @@ class CameraLiveConfig(FrigateBaseModel):
title="Live stream names", title="Live stream names",
description="Mapping of configured stream names to restream/go2rtc names used for live playback.", description="Mapping of configured stream names to restream/go2rtc names used for live playback.",
) )
transcode: LiveTranscodeConfig = Field(
default_factory=LiveTranscodeConfig,
title="Transcoded streams",
description="Lower-quality live streams transcoded on demand by go2rtc.",
)
height: int = Field( height: int = Field(
default=720, default=720,
title="Live height", title="Live height",
+2 -1
View File
@@ -1,6 +1,7 @@
from pydantic import Field from pydantic import Field
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["NotificationConfig"] __all__ = ["NotificationConfig"]
@@ -11,7 +12,7 @@ class NotificationConfig(FrigateBaseModel):
title="Enable notifications", title="Enable notifications",
description="Enable or disable notifications for all cameras; can be overridden per-camera.", description="Enable or disable notifications for all cameras; can be overridden per-camera.",
) )
email: str | None = Field( email: EnvString | None = Field(
default=None, default=None,
title="Notification email", title="Notification email",
description="Email address used for push notifications or required by certain notification providers.", description="Email address used for push notifications or required by certain notification providers.",
+180 -22
View File
@@ -19,7 +19,7 @@ from ruamel.yaml import YAML
from frigate.const import REGEX_JSON from frigate.const import REGEX_JSON
from frigate.detectors import ModelConfig from frigate.detectors import ModelConfig
from frigate.detectors.detector_config import SceneEnum from frigate.detectors.detector_config import DEFAULT_SCENE
from frigate.detectors.device import DeviceParseError, DeviceSpec, parse_device from frigate.detectors.device import DeviceParseError, DeviceSpec, parse_device
from frigate.plus import PlusApi from frigate.plus import PlusApi
from frigate.util.builtin import ( from frigate.util.builtin import (
@@ -35,6 +35,13 @@ from frigate.util.config import (
migrate_frigate_config, migrate_frigate_config,
) )
from frigate.util.image import create_mask from frigate.util.image import create_mask
from frigate.util.live_streams import (
default_transcode_source,
is_transcode_stream_name,
transcode_stream_name,
transcode_streams,
)
from frigate.util.runtime_deps import sha256_of
from frigate.util.services import auto_detect_hwaccel from frigate.util.services import auto_detect_hwaccel
from .auth import AuthConfig from .auth import AuthConfig
@@ -282,11 +289,78 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
) )
def apply_live_transcode_streams(
frigate_config: FrigateConfig, camera_config: CameraConfig
) -> None:
"""Fold a camera's transcoded streams into its live stream list.
Enabled qualities missing from live.streams are appended, and entries the
user placed keep their position. Transcoded names that are no longer
generated are dropped unless they name a real go2rtc stream.
"""
live = camera_config.live
transcode = live.transcode
go2rtc_streams = frigate_config.go2rtc.model_dump().get("streams") or {}
generated: dict[str, str] = {}
if transcode.enabled:
if transcode.source is None:
transcode.source = default_transcode_source(
camera_config.name, live.streams
)
if transcode.source not in go2rtc_streams:
raise ValueError(
f"Camera {camera_config.name} has transcoded streams enabled, but its source {transcode.source} is not a go2rtc stream."
)
generated = transcode_streams(
camera_config.name,
transcode.source,
[quality.model_dump() for quality in transcode.qualities],
)
for name in generated:
if name in go2rtc_streams:
raise ValueError(
f"Camera {camera_config.name} generates transcoded stream {name}, which collides with a go2rtc stream of the same name."
)
streams = {
label: name
for label, name in live.streams.items()
if name in generated
or name in go2rtc_streams
or not is_transcode_stream_name(camera_config.name, name)
}
placed = set(streams.values())
for quality in transcode.qualities if transcode.enabled else []:
name = transcode_stream_name(camera_config.name, quality.height)
if name in placed:
continue
label = f"{quality.height}p"
if label in streams:
raise ValueError(
f"Camera {camera_config.name} already has a live stream named {label}; rename it or place the transcoded stream under another name."
)
streams[label] = name
live.streams = streams
def verify_valid_live_stream_names( def verify_valid_live_stream_names(
frigate_config: FrigateConfig, camera_config: CameraConfig frigate_config: FrigateConfig, camera_config: CameraConfig
) -> ValueError | None: ) -> ValueError | None:
"""Verify that a restream exists to use for live view.""" """Verify that a restream exists to use for live view."""
for _, stream_name in camera_config.live.streams.items(): for _, stream_name in camera_config.live.streams.items():
if is_transcode_stream_name(camera_config.name, stream_name):
continue
if ( if (
stream_name stream_name
not in frigate_config.go2rtc.model_dump().get("streams", {}).keys() not in frigate_config.go2rtc.model_dump().get("streams", {}).keys()
@@ -536,7 +610,7 @@ class FrigateConfig(FrigateBaseModel):
models: list[ModelConfig] = Field( models: list[ModelConfig] = Field(
default_factory=_default_models, default_factory=_default_models,
title="Detection models", title="Detection models",
description="Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene.", description="Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene, falling back to the 'default' model.",
) )
# GenAI config (named provider configs: name -> GenAIConfig) # GenAI config (named provider configs: name -> GenAIConfig)
@@ -651,7 +725,9 @@ class FrigateConfig(FrigateBaseModel):
) )
_plus_api: PlusApi _plus_api: PlusApi
_model_devices: dict[SceneEnum, list[DeviceSpec]] _model_devices: dict[str, list[DeviceSpec]]
# scene -> model, including the scenes of duplicate models folded into another
_scene_models: dict[str, ModelConfig]
_camera_models: dict[str, ModelConfig] _camera_models: dict[str, ModelConfig]
_all_attributes: list[str] _all_attributes: list[str]
_all_attribute_logos: list[str] _all_attribute_logos: list[str]
@@ -686,7 +762,7 @@ class FrigateConfig(FrigateBaseModel):
def primary_model(self) -> ModelConfig: def primary_model(self) -> ModelConfig:
"""The model used when no specific camera is in play.""" """The model used when no specific camera is in play."""
for model in self.models: for model in self.models:
if model.scene == SceneEnum.all: if model.scene == DEFAULT_SCENE:
return model return model
return self.models[0] return self.models[0]
@@ -708,7 +784,7 @@ class FrigateConfig(FrigateBaseModel):
if model is None: if model is None:
camera = self.cameras.get(camera_name) camera = self.cameras.get(camera_name)
scene = camera.detect.scene if camera is not None else SceneEnum.all scene = camera.detect.scene if camera is not None else DEFAULT_SCENE
model = self._resolve_camera_model(camera_name, scene) model = self._resolve_camera_model(camera_name, scene)
self._camera_models[camera_name] = model self._camera_models[camera_name] = model
@@ -768,14 +844,14 @@ class FrigateConfig(FrigateBaseModel):
if not self.models: if not self.models:
raise ValueError("At least one model must be configured under models") raise ValueError("At least one model must be configured under models")
model_devices: dict[SceneEnum, list[DeviceSpec]] = {} model_devices: dict[str, list[DeviceSpec]] = {}
# device string -> the scene of the model that already claimed it # device string -> the scene of the model that already claimed it
claimed_devices: dict[str, SceneEnum] = {} claimed_devices: dict[str, str] = {}
for index, model in enumerate(self.models): for index, model in enumerate(self.models):
scene = model.scene.value scene = model.scene
if model.scene in model_devices: if scene in model_devices:
raise ValueError( raise ValueError(
f"Multiple models are configured with a scene of '{scene}'. Each model must use a different scene." f"Multiple models are configured with a scene of '{scene}'. Each model must use a different scene."
) )
@@ -804,17 +880,20 @@ class FrigateConfig(FrigateBaseModel):
other = claimed_devices[device.raw] other = claimed_devices[device.raw]
where = ( where = (
f"twice by model '{scene}'" f"twice by model '{scene}'"
if other == model.scene if other == scene
else f"by both the '{other.value}' and '{scene}' models" else f"by both the '{other}' and '{scene}' models"
) )
raise ValueError( raise ValueError(
f"Device '{device.raw}' is used {where}, but it can only run one detection process." f"Device '{device.raw}' is used {where}, but it can only run one detection process."
) )
claimed_devices[device.raw] = model.scene claimed_devices[device.raw] = scene
self.models[index] = self._load_model(model, devices[0].detector) self.models[index] = self._load_model(model, devices[0].detector)
model_devices[model.scene] = devices model_devices[scene] = devices
self._scene_models = {model.scene: model for model in self.models}
self._consolidate_duplicate_models(model_devices)
attributes: set[str] = set() attributes: set[str] = set()
attribute_logos: set[str] = set() attribute_logos: set[str] = set()
@@ -838,36 +917,107 @@ class FrigateConfig(FrigateBaseModel):
} }
self._all_labels = labels self._all_labels = labels
def _resolve_camera_model(self, name: str, scene: SceneEnum) -> ModelConfig: def _consolidate_duplicate_models(
self, model_devices: dict[str, list[DeviceSpec]]
) -> None:
"""Fold models that load the same model file into a single model.
Separate scenes for one model only split the same work across separate
detection queues, so each device serves fewer cameras and is slower
overall than one shared model. The duplicate's devices are moved to the
model it duplicates and its scene resolves to that model.
Args:
model_devices: Scene to parsed devices, updated in place
"""
kept: list[ModelConfig] = []
hashes: dict[str, str | None] = {}
def file_hash(path: str) -> str | None:
if path not in hashes:
hashes[path] = sha256_of(path) if os.path.isfile(path) else None
return hashes[path]
def same_model(a: ModelConfig, b: ModelConfig) -> bool:
# a model's devices all share a detector, so folding across
# detectors would produce an invalid model
if model_devices[a.scene][0].detector != model_devices[b.scene][0].detector:
return False
if not a.path or not b.path:
return False
if os.path.realpath(a.path) == os.path.realpath(b.path):
return True
a_hash = file_hash(a.path)
return a_hash is not None and a_hash == file_hash(b.path)
for model in self.models:
original = next((other for other in kept if same_model(other, model)), None)
if original is None:
kept.append(model)
continue
# keep the default model so cameras without a scene still find it
if model.scene == DEFAULT_SCENE:
kept[kept.index(original)] = model
original, model = model, original
logger.warning(
"Models '%s' and '%s' use the same model file, so they have been combined into the '%s' model. Defining one model under several scenes to assign detectors to specific cameras is slower and less efficient than letting every detector serve every camera. Remove the '%s' model and list its devices under the '%s' model instead",
original.scene,
model.scene,
original.scene,
model.scene,
original.scene,
)
original.devices = [*original.devices, *model.devices]
model_devices[original.scene] = [
*model_devices[original.scene],
*model_devices.pop(model.scene),
]
self._scene_models[model.scene] = original
# anything already folded into the duplicate follows it
for scene, target in self._scene_models.items():
if target is model:
self._scene_models[scene] = original
self.models = kept
def _resolve_camera_model(self, name: str, scene: str) -> ModelConfig:
"""Resolve which model a camera runs on. """Resolve which model a camera runs on.
A camera may name a scene no model is configured for, which is valid as A camera may name a scene no model is configured for, which is valid as
long as an 'all' model is there to fall back to. long as a 'default' model is there to fall back to.
Args: Args:
name: Name of the camera name: Name of the camera
scene: The camera's detect scene, which defaults to 'all' scene: The camera's detect scene, which defaults to 'default'
Returns: Returns:
The model the camera runs on The model the camera runs on
""" """
by_scene = {model.scene: model for model in self.models} model = self._scene_models.get(scene)
model = by_scene.get(scene)
if model is not None: if model is not None:
return model return model
default = by_scene.get(SceneEnum.all) default = self._scene_models.get(DEFAULT_SCENE)
if default is None: if default is None:
raise ValueError( raise ValueError(
f"Camera '{name}' has a detect scene of '{scene.value}', but no model is configured for that scene or for 'all'." f"Camera '{name}' has a detect scene of '{scene}', but no model is configured for that scene or for '{DEFAULT_SCENE}'."
) )
logger.warning( logger.warning(
"Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the 'all' model is used", "Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the '%s' model is used",
name, name,
scene.value, scene,
DEFAULT_SCENE,
) )
return default return default
@@ -978,6 +1128,8 @@ class FrigateConfig(FrigateBaseModel):
"face_recognition": ["enabled", "min_area"], "face_recognition": ["enabled", "min_area"],
"lpr": ["enabled", "expire_time", "min_area", "enhancement"], "lpr": ["enabled", "expire_time", "min_area", "enhancement"],
"audio_transcription": ["enabled", "live_enabled"], "audio_transcription": ["enabled", "live_enabled"],
# transcode is camera-level only
"live": ["streams", "height", "quality"],
} }
for section in allowed_fields_map: for section in allowed_fields_map:
@@ -998,6 +1150,10 @@ class FrigateConfig(FrigateBaseModel):
camera_model = self._resolve_camera_model(name, camera_config.detect.scene) camera_model = self._resolve_camera_model(name, camera_config.detect.scene)
self._camera_models[name] = camera_model self._camera_models[name] = camera_model
# point cameras at the model their duplicate scene was folded into
if camera_config.detect.scene in self._scene_models:
camera_config.detect.scene = camera_model.scene
if camera_config.ffmpeg.hwaccel_args == "auto": if camera_config.ffmpeg.hwaccel_args == "auto":
camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args
@@ -1206,6 +1362,8 @@ class FrigateConfig(FrigateBaseModel):
if not camera_config.live.streams: if not camera_config.live.streams:
camera_config.live.streams = {name: name} camera_config.live.streams = {name: name}
apply_live_transcode_streams(self, camera_config)
# generate the ffmpeg commands # generate the ffmpeg commands
camera_config.create_ffmpeg_cmds() camera_config.create_ffmpeg_cmds()
self.cameras[name] = camera_config self.cameras[name] = camera_config
@@ -176,6 +176,7 @@ class LicensePlateProcessingMixin:
""" """
input_shape = [3, 48, 320] input_shape = [3, 48, 320]
num_images = len(images) num_images = len(images)
outputs: list[np.ndarray] = []
for index in range(0, num_images, self.batch_size): for index in range(0, num_images, self.batch_size):
input_h, input_w = input_shape[1], input_shape[2] input_h, input_w = input_shape[1], input_shape[2]
@@ -196,7 +197,7 @@ class LicensePlateProcessingMixin:
norm_images.append(norm_image) norm_images.append(norm_image)
try: try:
outputs = self.model_runner.recognition_model(norm_images) # type: ignore[arg-type] outputs.extend(self.model_runner.recognition_model(norm_images)) # type: ignore[arg-type]
except Exception as e: except Exception as e:
logger.warning(f"Error running LPR recognition model: {e}") logger.warning(f"Error running LPR recognition model: {e}")
return [], [] return [], []
@@ -1,10 +1,10 @@
"""Frame annotations derived from object tracking data. """Frame annotations derived from object tracking data.
Builds short notes describing what changed during a review item, keyed to the Builds short notes describing what changed during a review item, keyed to the
frames sampled from it. Everything here comes from tracked object data already frames sampled from it. Everything here comes from data already recorded
in the database (each event's `path_data` trajectory and the timeline's (each event's `path_data` trajectory, the timeline's stationary/active
stationary/active changes), so the notes can be stated to the model as fact changes, and the review item's state classification changes), so the notes
rather than as something it must perceive. can be stated to the model as fact rather than as something it must perceive.
""" """
import logging import logging
@@ -95,6 +95,15 @@ def event_name(event: dict[str, Any]) -> str:
return f"{article} {label}" return f"{article} {label}"
def describe_classification_change(change: dict[str, Any]) -> str:
"""Phrase a state classification change, e.g. 'front gate changed from
closed to open'."""
model = str(change["model"]).replace("_", " ")
before = str(change["from"]).replace("_", " ")
after = str(change["to"]).replace("_", " ")
return f"{model} changed from {before} to {after}"
def path_legs(points: list[Point]) -> list[Leg]: def path_legs(points: list[Point]) -> list[Leg]:
"""Split a trajectory into runs of travel in a consistent direction. """Split a trajectory into runs of travel in a consistent direction.
@@ -359,25 +368,38 @@ def get_state_changes(detection_ids: list[str]) -> list[dict[str, Any]]:
def build_frame_captions( def build_frame_captions(
detection_ids: list[str], detection_ids: list[str],
frame_times: list[float], frame_times: list[float],
classification_changes: Sequence[dict[str, Any]] = (),
) -> list[str]: ) -> list[str]:
"""A caption for each sampled frame, in frame order. """A caption for each sampled frame, in frame order.
Every frame gets its index and elapsed time so the model can tell them Every frame gets its index and elapsed time so the model can tell them
apart; frames where something changed also carry the tracker notes for apart; frames where something changed also carry the tracker and state
that moment. Returns an empty list when there is nothing to say, which classification notes for that moment. Returns an empty list when there is
callers treat as a reason to fall back to sending plain frames. nothing to say, which callers treat as a reason to fall back to sending
plain frames.
""" """
if not frame_times: if not frame_times:
return [] return []
span_end = frame_times[-1]
timeline: list[tuple[float, str]] = []
events = get_tracked_events(detection_ids) events = get_tracked_events(detection_ids)
if not events: # audio and manual review items can have state changes but no tracked objects
logger.debug("No tracked events found for review item, skipping annotations") if events:
return [] timeline.extend(
(timestamp, f"[tracker] {note}")
for timestamp, note in build_timeline(
events, span_end, get_state_changes(detection_ids)
)
)
timeline = build_timeline(events, frame_times[-1], get_state_changes(detection_ids)) timeline.extend(
buckets = annotations_by_frame(timeline, frame_times) (change["timestamp"], f"[state] {describe_classification_change(change)}")
for change in classification_changes
if change["timestamp"] <= span_end
)
buckets = annotations_by_frame(sorted(timeline, key=lambda m: m[0]), frame_times)
if not buckets: if not buckets:
return [] return []
@@ -388,7 +410,7 @@ def build_frame_captions(
for index, timestamp in enumerate(frame_times): for index, timestamp in enumerate(frame_times):
lines = [f"Frame {index + 1} of {total} (+{timestamp - origin:.1f}s):"] lines = [f"Frame {index + 1} of {total} (+{timestamp - origin:.1f}s):"]
lines.extend(f"[tracker] {note}" for note in buckets.get(index, [])) lines.extend(buckets.get(index, []))
captions.append("\n".join(lines)) captions.append("\n".join(lines))
return captions return captions
@@ -40,7 +40,7 @@ from frigate.util.image import get_image_from_recording
from ..post.api import PostProcessorApi from ..post.api import PostProcessorApi
from ..types import DataProcessorMetrics from ..types import DataProcessorMetrics
from .review_annotations import build_frame_captions from .review_annotations import build_frame_captions, describe_classification_change
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -254,6 +254,10 @@ class ReviewDescriptionProcessor(PostProcessorApi):
"start_time": r["start_time"], "start_time": r["start_time"],
"end_time": r["end_time"], "end_time": r["end_time"],
"metadata": r["data"]["metadata"], "metadata": r["data"]["metadata"],
"state_changes": [
describe_classification_change(change)
for change in sorted_classification_state_changes(r["data"])
],
} }
for r in ( for r in (
ReviewSegment.select( ReviewSegment.select(
@@ -298,6 +302,9 @@ class ReviewDescriptionProcessor(PostProcessorApi):
primary_item["start_time"] = primary_seg["start_time"] primary_item["start_time"] = primary_seg["start_time"]
primary_item["end_time"] = primary_seg["end_time"] primary_item["end_time"] = primary_seg["end_time"]
if primary_seg["state_changes"]:
primary_item["state_changes"] = primary_seg["state_changes"]
# Find overlapping contextual items from other cameras # Find overlapping contextual items from other cameras
primary_start = primary_seg["start_time"] primary_start = primary_seg["start_time"]
primary_end = primary_seg["end_time"] primary_end = primary_seg["end_time"]
@@ -318,12 +325,23 @@ class ReviewDescriptionProcessor(PostProcessorApi):
seg_end = seg["end_time"] seg_end = seg["end_time"]
if seg_start < primary_end and primary_start < seg_end: if seg_start < primary_end and primary_start < seg_end:
# Avoid duplicates if same camera has multiple overlapping segments # Avoid duplicates if same camera has multiple overlapping
if seg_camera not in seen_contextual_cameras: # segments. One with state changes is kept as its own item
# so each change stays within its item's time range.
if (
seg_camera in seen_contextual_cameras
and not seg["state_changes"]
):
continue
contextual_item = copy.deepcopy(seg["metadata"]) contextual_item = copy.deepcopy(seg["metadata"])
contextual_item["camera"] = seg_camera contextual_item["camera"] = seg_camera
contextual_item["start_time"] = seg_start contextual_item["start_time"] = seg_start
contextual_item["end_time"] = seg_end contextual_item["end_time"] = seg_end
if seg["state_changes"]:
contextual_item["state_changes"] = seg["state_changes"]
contextual_items.append(contextual_item) contextual_items.append(contextual_item)
seen_contextual_cameras.add(seg_camera) seen_contextual_cameras.add(seg_camera)
@@ -439,6 +457,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
captions = build_frame_captions( captions = build_frame_captions(
final_data["data"].get("detections") or [], final_data["data"].get("detections") or [],
[timestamp for _, timestamp in frames], [timestamp for _, timestamp in frames],
sorted_classification_state_changes(final_data["data"]),
) )
if not captions: if not captions:
@@ -688,6 +707,39 @@ def get_recording_buffer_extension(duration: float) -> float:
return buffer_extension return buffer_extension
def sorted_classification_state_changes(
review_data: dict[str, Any],
) -> list[dict[str, Any]]:
"""A review item's state classification changes in time order."""
return sorted(
review_data.get("classification_state_changes") or [],
key=lambda change: change["timestamp"],
)
def format_classification_state_changes(
changes: list[dict[str, Any]], start_time: float, end_time: float
) -> list[str]:
"""Phrase state classification changes with their timing in the activity.
Changes are attached while the review item is active, which runs past its
end_time by the review cutoff, and a few seconds before its start.
"""
lines = []
for change in changes:
if change["timestamp"] < start_time:
when = "just before the activity started"
elif change["timestamp"] > end_time:
when = "after the activity ended"
else:
when = f"{round(change['timestamp'] - start_time)}s into the activity"
lines.append(f"{describe_classification_change(change)}, {when}")
return lines
def run_analysis( def run_analysis(
requestor: InterProcessRequestor, requestor: InterProcessRequestor,
genai_client: GenAIClient, genai_client: GenAIClient,
@@ -743,6 +795,13 @@ def run_analysis(
unified_objects.append(object_type) unified_objects.append(object_type)
analytics_data["unified_objects"] = unified_objects analytics_data["unified_objects"] = unified_objects
analytics_data["classification_state_changes"] = (
format_classification_state_changes(
sorted_classification_state_changes(final_data["data"]),
final_data["start_time"],
final_data["end_time"],
)
)
metadata = genai_client.generate_review_description( metadata = genai_client.generate_review_description(
analytics_data, analytics_data,
@@ -91,8 +91,23 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
self.tensor_input_details = self.interpreter.get_input_details() self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details() self.tensor_output_details = self.interpreter.get_output_details()
self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False) self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
self._forget_unknown_states()
self.classifications_per_second.start() self.classifications_per_second.start()
def _forget_unknown_states(self) -> None:
"""Drop verified states that are not labels of the loaded model.
A retrained model can rename or remove labels. Keeping a state it can
no longer produce would report its first verified state as a change
from that obsolete label.
"""
labels = set(self.labelmap.values())
self.state_history = {
camera: history
for camera, history in self.state_history.items()
if history["current_state"] in labels
}
def __update_metrics(self, duration: float) -> None: def __update_metrics(self, duration: float) -> None:
self.classifications_per_second.update() self.classifications_per_second.update()
if self.inference_speed: if self.inference_speed:
@@ -134,15 +149,20 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
# Don't save if state is stable (detected_state == current_state) AND score is 100% # Don't save if state is stable (detected_state == current_state) AND score is 100%
return False return False
def verify_state_change(self, camera: str, detected_state: str) -> str | None: def verify_state_change(
self, camera: str, detected_state: str, timestamp: float
) -> tuple[str | None, float] | None:
""" """
Verify state change requires 3 consecutive identical states before publishing. Verify state change requires 3 consecutive identical states before publishing.
Returns state to publish or None if verification not complete. Returns (previous state, time the new state was first seen) once verified,
or None if verification not complete. The previous state is None for the
first state verified on a camera.
""" """
if camera not in self.state_history: if camera not in self.state_history:
self.state_history[camera] = { self.state_history[camera] = {
"current_state": None, "current_state": None,
"pending_state": None, "pending_state": None,
"pending_since": 0.0,
"consecutive_count": 0, "consecutive_count": 0,
} }
@@ -157,12 +177,14 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
verification["consecutive_count"] += 1 verification["consecutive_count"] += 1
if verification["consecutive_count"] >= 3: if verification["consecutive_count"] >= 3:
previous_state = verification["current_state"]
verification["current_state"] = detected_state verification["current_state"] = detected_state
verification["pending_state"] = None verification["pending_state"] = None
verification["consecutive_count"] = 0 verification["consecutive_count"] = 0
return detected_state return previous_state, verification["pending_since"]
else: else:
verification["pending_state"] = detected_state verification["pending_state"] = detected_state
verification["pending_since"] = timestamp
verification["consecutive_count"] = 1 verification["consecutive_count"] = 1
logger.debug( logger.debug(
f"New state '{detected_state}' detected for {camera}, need {3 - verification['consecutive_count']} more consecutive detections" f"New state '{detected_state}' detected for {camera}, need {3 - verification['consecutive_count']} more consecutive detections"
@@ -340,16 +362,19 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
) )
return return
verified_state = self.verify_state_change(camera, detected_state) verified = self.verify_state_change(camera, detected_state, timestamp)
if verified_state is not None: if verified is not None:
previous_state, changed_at = verified
self._emit_result( self._emit_result(
{ {
"type": "classification", "type": "classification",
"processor": "state", "processor": "state",
"model_name": self.model_config.name, "model_name": self.model_config.name,
"camera": camera, "camera": camera,
"state": verified_state, "state": detected_state,
"previous_state": previous_state,
"timestamp": changed_at,
} }
) )
+76 -15
View File
@@ -1,8 +1,10 @@
import logging import logging
import sqlite3 import sqlite3
import threading
from typing import Any from typing import Any
import regex import regex
from peewee import DatabaseError
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,6 +19,7 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
self.load_vec_extension: bool = load_vec_extension self.load_vec_extension: bool = load_vec_extension
# no extension necessary, sqlite will load correctly for each platform # no extension necessary, sqlite will load correctly for each platform
self.sqlite_vec_path = "/usr/local/lib/vec0" self.sqlite_vec_path = "/usr/local/lib/vec0"
self.upsert_lock = threading.Lock()
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection: def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
@@ -53,6 +56,22 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
conn.create_function("REGEXP", 2, regexp) conn.create_function("REGEXP", 2, regexp)
def execute_write(self, sql: str, params: Any = None) -> None:
"""Run a write and wait for it, so that failures are raised here.
SqliteQueueDatabase hands non-SELECT statements to a writer thread and
stores any exception on the cursor it returns, so callers that ignore
that cursor never learn the write failed.
"""
self.execute_sql(sql, params).fetchall()
def _table_exists(self, table: str) -> bool:
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
return cursor.fetchone() is not None
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None: def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
"""Delete embeddings for the given events, if the table exists. """Delete embeddings for the given events, if the table exists.
@@ -63,17 +82,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
return return
# the embeddings tables are only created once semantic search has run # the embeddings tables are only created once semantic search has run
cursor = self.execute_sql( if not self._table_exists(table):
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
if cursor.fetchone() is None:
logger.debug("Skipping %s cleanup, table does not exist", table) logger.debug("Skipping %s cleanup, table does not exist", table)
return return
ids = ",".join(["?" for _ in event_ids]) ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
# callers treat cleanup as best effort, so log rather than propagate
try:
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
except DatabaseError:
logger.exception("Failed to delete embeddings from %s", table)
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_thumbnails", event_ids) self._delete_embeddings("vec_thumbnails", event_ids)
@@ -81,25 +100,67 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
def delete_embeddings_description(self, event_ids: list[str]) -> None: def delete_embeddings_description(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_descriptions", event_ids) self._delete_embeddings("vec_descriptions", event_ids)
def _restore_vec_info_table(self, table: str) -> None:
"""Recreate the _info shadow table a legacy vec0 table is missing.
sqlite-vec added _info in 0.1.6 and drops it unconditionally when a
table is destroyed, so tables written by Frigate 0.17 and earlier fail
to drop. An empty stub is enough, and leaving it unseeded keeps the
table reading as pre-0.1.10 if the drop does not follow.
"""
if not self._table_exists(table) or self._table_exists(f"{table}_info"):
return
logger.debug("Restoring the %s_info shadow table before dropping", table)
self.execute_write(
f'CREATE TABLE "{table}_info" (key TEXT PRIMARY KEY, value ANY)'
)
def drop_embeddings_tables(self) -> None: def drop_embeddings_tables(self) -> None:
self.execute_sql(""" for table in ("vec_descriptions", "vec_thumbnails"):
DROP TABLE vec_descriptions; self._restore_vec_info_table(table)
""") self.execute_write(f"DROP TABLE IF EXISTS {table}")
self.execute_sql("""
DROP TABLE vec_thumbnails;
""")
def create_embeddings_tables(self) -> None: def create_embeddings_tables(self) -> None:
"""Create vec0 virtual table for embeddings""" """Create vec0 virtual table for embeddings"""
self.execute_sql(""" self.execute_write("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0( CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
thumbnail_embedding FLOAT[768] distance_metric=cosine thumbnail_embedding FLOAT[768] distance_metric=cosine
); );
""") """)
self.execute_sql(""" self.execute_write("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0( CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
description_embedding FLOAT[768] distance_metric=cosine description_embedding FLOAT[768] distance_metric=cosine
); );
""") """)
def upsert_embeddings(
self, table: str, column: str, embeddings: dict[str, bytes]
) -> None:
"""Write embeddings for the given event ids, replacing any that exist.
vec0 implements neither REPLACE nor UPSERT, so rows that are already
there have to be deleted first.
"""
if not embeddings:
return
event_ids = list(embeddings.keys())
ids = ",".join(["?" for _ in event_ids])
params: list[Any] = []
for event_id in event_ids:
params.extend((event_id, embeddings[event_id]))
values = ", ".join(["(?, ?)"] * len(event_ids))
# reindexing and live embedding run on separate threads, and each write
# is queued separately, so the delete and the insert have to be held
# together or an interleaved pair fails on the vec0 primary key
with self.upsert_lock:
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
self.execute_write(
f"INSERT INTO {table}(id, {column}) VALUES {values}", params
)
+28 -12
View File
@@ -47,21 +47,17 @@ class ModelTypeEnum(str, Enum):
yologeneric = "yolo-generic" yologeneric = "yolo-generic"
class SceneEnum(str, Enum): # the scene of the model used by cameras that don't name one
"""The camera environment a detection model is intended for.""" DEFAULT_SCENE = "default"
SCENE_PATTERN = r"^[A-Za-z0-9_-]+$"
all = "all"
indoor = "indoor"
outdoor = "outdoor"
indoor_thermal = "indoor_thermal"
outdoor_thermal = "outdoor_thermal"
class ModelConfig(BaseModel): class ModelConfig(BaseModel):
scene: SceneEnum = Field( scene: str = Field(
default=SceneEnum.all, default=DEFAULT_SCENE,
pattern=SCENE_PATTERN,
title="Model scene", title="Model scene",
description="The camera environment this model is used for. Cameras select a model by setting detect.scene to a matching value, and 'all' is used by any camera that does not set one.", description="A name for the camera environment this model is used for, such as 'thermal'. Cameras select a model by setting detect.scene to a matching value, and the 'default' model is used by any camera that does not set one.",
) )
devices: list[str] = Field( devices: list[str] = Field(
default_factory=list, default_factory=list,
@@ -123,6 +119,7 @@ class ModelConfig(BaseModel):
_all_attributes: list[str] = PrivateAttr() _all_attributes: list[str] = PrivateAttr()
_all_attribute_logos: list[str] = PrivateAttr() _all_attribute_logos: list[str] = PrivateAttr()
_model_hash: str = PrivateAttr() _model_hash: str = PrivateAttr()
_plus_id: str | None = PrivateAttr(default=None)
@property @property
def merged_labelmap(self) -> dict[int, str]: def merged_labelmap(self) -> dict[int, str]:
@@ -148,6 +145,11 @@ class ModelConfig(BaseModel):
def model_hash(self) -> str: def model_hash(self) -> str:
return self._model_hash return self._model_hash
@property
def plus_id(self) -> str | None:
"""The Frigate+ model id, once a plus:// path has been resolved."""
return self._plus_id
def __init__(self, **config): def __init__(self, **config):
super().__init__(**config) super().__init__(**config)
@@ -178,20 +180,34 @@ class ModelConfig(BaseModel):
os.makedirs(MODEL_CACHE_DIR, exist_ok=True) os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
model_id = self.path[7:] model_id = self.path[7:]
self._plus_id = model_id
self.path = os.path.join(MODEL_CACHE_DIR, model_id) self.path = os.path.join(MODEL_CACHE_DIR, model_id)
model_info_path = f"{self.path}.json" model_info_path = f"{self.path}.json"
# download the model if it doesn't exist # download the model if it doesn't exist
if not os.path.isfile(self.path): if not os.path.isfile(self.path):
try:
download_url = plus_api.get_model_download_url(model_id) download_url = plus_api.get_model_download_url(model_id)
r = requests.get(download_url) 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: with open(self.path, "wb") as f:
f.write(r.content) f.write(r.content)
# download the model info if it doesn't exist # download the model info if it doesn't exist
if not os.path.isfile(model_info_path): 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: 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) model_info = load_plus_model_info(model_id)
+37 -34
View File
@@ -6,9 +6,10 @@ import logging
import os import os
import threading import threading
import time import time
from typing import Any
import numpy as np import numpy as np
from peewee import DoesNotExist, IntegrityError from peewee import DatabaseError, DoesNotExist, IntegrityError
from PIL import Image from PIL import Image
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
@@ -207,12 +208,10 @@ class Embeddings:
embedding = self.vision_embedding([thumbnail])[0] embedding = self.vision_embedding([thumbnail])[0]
if upsert: if upsert:
self.db.execute_sql( self.db.upsert_embeddings(
""" "vec_thumbnails",
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding) "thumbnail_embedding",
VALUES(?, ?) {event_id: serialize(embedding)},
""",
(event_id, serialize(embedding)),
) )
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start) self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -251,19 +250,12 @@ class Embeddings:
embeddings = self.vision_embedding(valid_thumbs) embeddings = self.vision_embedding(valid_thumbs)
if upsert: if upsert:
items = [] items = {}
for i in range(len(valid_ids)): for i in range(len(valid_ids)):
items.append(valid_ids[i]) items[valid_ids[i]] = serialize(embeddings[i])
items.append(serialize(embeddings[i]))
self.image_eps.update() self.image_eps.update()
self.db.execute_sql( self.db.upsert_embeddings("vec_thumbnails", "thumbnail_embedding", items)
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(valid_ids))),
items,
)
duration = datetime.datetime.now().timestamp() - start duration = datetime.datetime.now().timestamp() - start
self.image_inference_speed.update(duration / len(valid_ids)) self.image_inference_speed.update(duration / len(valid_ids))
@@ -277,12 +269,10 @@ class Embeddings:
embedding = self.text_embedding([description])[0] embedding = self.text_embedding([description])[0]
if upsert: if upsert:
self.db.execute_sql( self.db.upsert_embeddings(
""" "vec_descriptions",
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding) "description_embedding",
VALUES(?, ?) {event_id: serialize(embedding)},
""",
(event_id, serialize(embedding)),
) )
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start) self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -302,19 +292,14 @@ class Embeddings:
if upsert: if upsert:
ids = list(event_descriptions.keys()) ids = list(event_descriptions.keys())
items = [] items = {}
for i in range(len(ids)): for i in range(len(ids)):
items.append(ids[i]) items[ids[i]] = serialize(embeddings[i])
items.append(serialize(embeddings[i]))
self.text_eps.update() self.text_eps.update()
self.db.execute_sql( self.db.upsert_embeddings(
""" "vec_descriptions", "description_embedding", items
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(ids))),
items,
) )
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start) self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -322,6 +307,17 @@ class Embeddings:
return embeddings return embeddings
def reindex(self) -> None: def reindex(self) -> None:
"""Rebuild every tracked object embedding from scratch."""
totals: dict[str, Any] = {"status": "indexing"}
try:
self._reindex(totals)
except DatabaseError:
logger.exception("Unable to reindex tracked object embeddings")
totals["status"] = "failed"
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
def _reindex(self, totals: dict[str, Any]) -> None:
logger.info("Indexing tracked object embeddings...") logger.info("Indexing tracked object embeddings...")
self.db.drop_embeddings_tables() self.db.drop_embeddings_tables()
@@ -346,17 +342,24 @@ class Embeddings:
batch_size = 32 batch_size = 32
current_page = 1 current_page = 1
totals = { totals.update(
{
"thumbnails": 0, "thumbnails": 0,
"descriptions": 0, "descriptions": 0,
"processed_objects": total_events - 1 if total_events < batch_size else 0, "processed_objects": total_events - 1
if total_events < batch_size
else 0,
"total_objects": total_events, "total_objects": total_events,
"time_remaining": 0 if total_events < batch_size else -1, "time_remaining": 0 if total_events < batch_size else -1,
"status": "indexing", "status": "indexing",
} }
)
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals) self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
# a single batch sends no progress, so the first message shows it nearly done
totals["processed_objects"] = 0
events = ( events = (
Event.select() Event.select()
.order_by(Event.start_time.desc()) .order_by(Event.start_time.desc())
+22 -1
View File
@@ -11,7 +11,11 @@ from typing import Any
from peewee import DoesNotExist from peewee import DoesNotExist
from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.detections_updater import (
DetectionPublisher,
DetectionSubscriber,
DetectionTypeEnum,
)
from frigate.comms.embeddings_updater import ( from frigate.comms.embeddings_updater import (
EmbeddingsRequestEnum, EmbeddingsRequestEnum,
EmbeddingsResponder, EmbeddingsResponder,
@@ -168,6 +172,7 @@ class EmbeddingMaintainer(threading.Thread):
) )
self.review_subscriber = ReviewDataSubscriber("") self.review_subscriber = ReviewDataSubscriber("")
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value) self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value)
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.all.value)
self.embeddings_responder = EmbeddingsResponder() self.embeddings_responder = EmbeddingsResponder()
self.frame_manager = SharedMemoryFrameManager() self.frame_manager = SharedMemoryFrameManager()
@@ -356,6 +361,7 @@ class EmbeddingMaintainer(threading.Thread):
self.event_end_subscriber.stop() self.event_end_subscriber.stop()
self.recordings_subscriber.stop() self.recordings_subscriber.stop()
self.detection_subscriber.stop() self.detection_subscriber.stop()
self.detection_publisher.stop()
self.event_metadata_publisher.stop() self.event_metadata_publisher.stop()
self.event_metadata_subscriber.stop() self.event_metadata_subscriber.stop()
self.embeddings_responder.stop() self.embeddings_responder.stop()
@@ -851,6 +857,21 @@ class EmbeddingMaintainer(threading.Thread):
f"{result['camera']}/classification/{result['model_name']}", f"{result['camera']}/classification/{result['model_name']}",
result["state"], result["state"],
) )
# the first state verified after startup is not a change
if result["previous_state"] is not None:
self.detection_publisher.publish(
(
result["camera"],
{
"model": result["model_name"],
"from": result["previous_state"],
"to": result["state"],
"timestamp": result["timestamp"],
},
),
DetectionTypeEnum.classification_state.value,
)
elif result["processor"] == "object": elif result["processor"] == "object":
object_id = result["object_id"] object_id = result["object_id"]
camera = result["camera"] camera = result["camera"]
+1
View File
@@ -365,6 +365,7 @@ class EventCleanup(threading.Thread):
chunk = ids_to_delete[i : i + CHUNK_SIZE] chunk = ids_to_delete[i : i + CHUNK_SIZE]
logger.debug(f"Deleting {len(chunk)} events from the database") logger.debug(f"Deleting {len(chunk)} events from the database")
Event.delete().where(Event.id << chunk).execute() Event.delete().where(Event.id << chunk).execute()
Timeline.delete().where(Timeline.source_id << chunk).execute()
# embeddings are always cleaned up, even when semantic search # embeddings are always cleaned up, even when semantic search
# is disabled, so that they don't outlive their events # is disabled, so that they don't outlive their events
+2 -2
View File
@@ -126,8 +126,8 @@ PRESETS_HW_ACCEL_SCALE = {
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}", "preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}", "preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}",
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12", FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p", "preset-intel-qsv-h264": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p", "preset-intel-qsv-h265": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12", FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
"preset-jetson-h264": "-r {0}", # scaled in decoder "preset-jetson-h264": "-r {0}", # scaled in decoder
"preset-jetson-h265": "-r {0}", # scaled in decoder "preset-jetson-h265": "-r {0}", # scaled in decoder
+32 -85
View File
@@ -103,11 +103,10 @@ class LlamaCppClient(GenAIClient):
_supports_reasoning: bool _supports_reasoning: bool
_image_token_cache: dict[tuple[int, int], int] _image_token_cache: dict[tuple[int, int], int]
_text_baseline_tokens: int | None _text_baseline_tokens: int | None
_media_marker: str
@property @property
def supports_embeddings(self) -> bool: def supports_embeddings(self) -> bool:
"""llama.cpp exposes an /embeddings endpoint for any loaded model.""" """llama.cpp exposes a /v1/embeddings endpoint for any loaded model."""
return True return True
def _auth_headers(self) -> dict | None: def _auth_headers(self) -> dict | None:
@@ -159,7 +158,6 @@ class LlamaCppClient(GenAIClient):
self._supports_reasoning = False self._supports_reasoning = False
self._image_token_cache = {} self._image_token_cache = {}
self._text_baseline_tokens = None self._text_baseline_tokens = None
self._media_marker = "<__media__>"
base_url = ( base_url = (
self.genai_config.base_url.rstrip("/") self.genai_config.base_url.rstrip("/")
@@ -187,7 +185,6 @@ class LlamaCppClient(GenAIClient):
self._supports_audio = info["supports_audio"] self._supports_audio = info["supports_audio"]
self._supports_tools = info["supports_tools"] self._supports_tools = info["supports_tools"]
self._supports_reasoning = info["supports_reasoning"] self._supports_reasoning = info["supports_reasoning"]
self._media_marker = info["media_marker"]
logger.info( logger.info(
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s", "llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
@@ -215,9 +212,7 @@ class LlamaCppClient(GenAIClient):
`architecture.input_modalities` (text/image/audio) — the primary `architecture.input_modalities` (text/image/audio) — the primary
source. When proxied through llama-swap, the same entry carries source. When proxied through llama-swap, the same entry carries
`status.args` (server launch argv) and, for the loaded model, `status.args` (server launch argv) and, for the loaded model,
`meta.n_ctx`. /props remains the only source for `media_marker`, `meta.n_ctx`.
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
is set.
""" """
info: dict[str, Any] = { info: dict[str, Any] = {
"context_size": None, "context_size": None,
@@ -225,7 +220,6 @@ class LlamaCppClient(GenAIClient):
"supports_audio": False, "supports_audio": False,
"supports_tools": False, "supports_tools": False,
"supports_reasoning": False, "supports_reasoning": False,
"media_marker": "<__media__>",
} }
model_entry: dict[str, Any] | None = None model_entry: dict[str, Any] | None = None
@@ -314,16 +308,8 @@ class LlamaCppClient(GenAIClient):
# in the Jinja chat template itself. # in the Jinja chat template itself.
chat_template = props.get("chat_template") or "" chat_template = props.get("chat_template") or ""
info["supports_reasoning"] = "enable_thinking" in chat_template info["supports_reasoning"] = "enable_thinking" in chat_template
media_marker = props.get("media_marker")
if isinstance(media_marker, str) and media_marker:
info["media_marker"] = media_marker
except Exception as e: except Exception as e:
logger.warning( logger.warning("Failed to query llama.cpp /props endpoint: %s", e)
"Failed to query llama.cpp /props endpoint: %s. "
"Image embeddings may fail if the server randomized its media marker.",
e,
)
return info return info
@@ -474,9 +460,6 @@ class LlamaCppClient(GenAIClient):
def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None: def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None:
"""Transcribe through /v1/chat/completions, for servers without the """Transcribe through /v1/chat/completions, for servers without the
transcriptions route. transcriptions route.
The _media_marker / multimodal_data convention is an /embeddings-only
protocol, so no marker-refresh retry is needed here.
""" """
prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech." prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech."
@@ -794,41 +777,16 @@ class LlamaCppClient(GenAIClient):
) )
return result if result else None return result if result else None
def _refresh_media_marker(self) -> bool:
"""Re-fetch /props and update the cached media marker if it changed.
The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
is set), so a stale marker indicates a restart. Returns True iff the
marker was updated to a new value — used to gate a one-shot retry of
a failed embeddings request.
"""
if self.provider is None:
return False
try:
props = self._fetch_llama_props(self.provider, self.genai_config.model)
except Exception as e:
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
return False
marker = props.get("media_marker")
if not isinstance(marker, str) or not marker or marker == self._media_marker:
return False
logger.info("llama.cpp media marker changed (server restart); refreshed")
self._media_marker = marker
return True
def embed( def embed(
self, self,
texts: list[str] | None = None, texts: list[str] | None = None,
images: list[bytes] | None = None, images: list[bytes] | None = None,
) -> list[np.ndarray]: ) -> list[np.ndarray]:
"""Generate embeddings via llama.cpp /embeddings endpoint. """Generate embeddings via llama.cpp /v1/embeddings endpoint.
Supports batch requests. Uses content format with prompt_string and Each text or image is one entry in `input`, using the chat-style
multimodal_data for images (PR #15108). Server must be started with content array from ggml-org/llama.cpp#29556. Server must be started
--embeddings and --mmproj for multimodal support. with --embeddings, and --mmproj for image support.
""" """
if self.provider is None: if self.provider is None:
logger.warning( logger.warning(
@@ -843,49 +801,42 @@ class LlamaCppClient(GenAIClient):
EMBEDDING_DIM = 768 EMBEDDING_DIM = 768
encoded_images: list[str] = [] inputs: list[dict[str, Any]] = [
{"content": [{"type": "text", "text": text}]} for text in texts
]
for img in images: for img in images:
# llama.cpp uses STB which does not support WebP; convert to JPEG # llama.cpp uses STB which does not support WebP; convert to JPEG
jpeg_bytes = _to_jpeg(img) jpeg_bytes = _to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img to_encode = jpeg_bytes if jpeg_bytes is not None else img
encoded_images.append(base64.b64encode(to_encode).decode("utf-8")) encoded = base64.b64encode(to_encode).decode("utf-8")
# The trailing newline keeps tokenization identical to the older
def build_content() -> list[dict[str, Any]]: # "<__media__>\n" prompt_string format, so indexed vectors stay valid
# prompt_string must contain the server's media marker placeholder inputs.append(
# for each image. The marker is randomized per server startup.
content: list[dict[str, Any]] = []
for text in texts:
content.append({"prompt_string": text})
for encoded in encoded_images:
content.append(
{ {
"prompt_string": f"{self._media_marker}\n", "content": [
"multimodal_data": [encoded], {
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
},
{"type": "text", "text": "\n"},
]
} }
) )
return content
def post_embeddings() -> requests.Response: try:
return self._post( response = self._post(
f"{self.provider}/embeddings", f"{self.provider}/v1/embeddings",
json={"model": self.genai_config.model, "content": build_content()}, json={
"model": self.genai_config.model,
"input": inputs,
"encoding_format": "float",
},
timeout=self.timeout, timeout=self.timeout,
) )
try:
try:
response = post_embeddings()
response.raise_for_status() response.raise_for_status()
except requests.exceptions.RequestException: items = response.json().get("data")
# The server may have restarted with a new media marker.
# Refresh from /props; only retry if the marker actually changed.
if not encoded_images or not self._refresh_media_marker():
raise
response = post_embeddings()
response.raise_for_status()
result = response.json()
items = result.get("data", result) if isinstance(result, dict) else result
if not isinstance(items, list): if not isinstance(items, list):
logger.warning("llama.cpp embeddings returned unexpected format") logger.warning("llama.cpp embeddings returned unexpected format")
return [] return []
@@ -896,11 +847,7 @@ class LlamaCppClient(GenAIClient):
if emb is None: if emb is None:
logger.warning("llama.cpp embeddings item missing embedding field") logger.warning("llama.cpp embeddings item missing embedding field")
continue continue
arr = np.array(emb, dtype=np.float32) arr = np.array(emb, dtype=np.float32).flatten()
if arr.ndim > 1:
# llama.cpp can return token-level embeddings; pool per item
arr = arr.mean(axis=0)
arr = arr.flatten()
orig_dim = arr.size orig_dim = arr.size
if orig_dim != EMBEDDING_DIM: if orig_dim != EMBEDDING_DIM:
if orig_dim > EMBEDDING_DIM: if orig_dim > EMBEDDING_DIM:
+28 -2
View File
@@ -104,6 +104,21 @@ def build_review_description_prompt(
else: else:
return "\n- (No objects detected)" return "\n- (No objects detected)"
def get_state_changes_section() -> str:
# empty when nothing changed so the prompt is otherwise unaffected
changes = review_data.get("classification_state_changes")
if not changes:
return ""
return (
"\n\n## State Changes\n\n"
"The camera's state classifiers watch fixed areas of the scene and "
"reported these changes. They come from the classifiers rather than "
"from the images, and they are reliable. Describe each one where it "
"fits in the sequence of events.\n- " + "\n- ".join(changes)
)
fields = get_review_field_guidelines(response_style) fields = get_review_field_guidelines(response_style)
frame_guidance = f"\n{FRAME_ANNOTATION_GUIDANCE}" if frame_captions else "" frame_guidance = f"\n{FRAME_ANNOTATION_GUIDANCE}" if frame_captions else ""
@@ -145,7 +160,7 @@ Respond with a JSON object matching the provided schema. Field-specific guidance
- Camera: {review_data["camera"]} - Camera: {review_data["camera"]}
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest){frame_guidance} - Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest){frame_guidance}
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds - Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"} - Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}{get_state_changes_section()}
## Objects in Scene ## Objects in Scene
@@ -196,6 +211,17 @@ def build_review_summary_prompt(
f" to " f" to "
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}" f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
) )
has_state_changes = any(
"state_changes" in item
for event in events
for item in [event, *event.get("context", [])]
)
state_changes_format = (
'\n- "state_changes" (only on some events): changes to monitored areas '
"reported by the camera's state classifiers, which are reliable"
if has_state_changes
else ""
)
prompt = f""" prompt = f"""
You are a security officer writing a concise security report. You are a security officer writing a concise security report.
@@ -203,7 +229,7 @@ Time range: {time_range}
Input format: Each event is a JSON object with: Input format: Each event is a JSON object with:
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time" - "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
- "context": array of related events from other cameras that occurred during overlapping time periods - "context": array of related events from other cameras that occurred during overlapping time periods{state_changes_format}
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.** **Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
+4 -3
View File
@@ -19,6 +19,7 @@ class ImprovedMotionDetector(MotionDetector):
config: RuntimeMotionConfig, config: RuntimeMotionConfig,
fps: int, fps: int,
ptz_metrics: PTZMetrics | None = None, ptz_metrics: PTZMetrics | None = None,
autotracking_enabled: bool = False,
name: str = "improved", name: str = "improved",
blur_radius: int = 1, blur_radius: int = 1,
interpolation: int = cv2.INTER_NEAREST, interpolation: int = cv2.INTER_NEAREST,
@@ -45,6 +46,7 @@ class ImprovedMotionDetector(MotionDetector):
self.contrast_values[:, 1:2] = 255 self.contrast_values[:, 1:2] = 255
self.contrast_values_index = 0 self.contrast_values_index = 0
self.ptz_metrics = ptz_metrics self.ptz_metrics = ptz_metrics
self.autotracking_enabled = autotracking_enabled
self.last_stop_time: float | None = None self.last_stop_time: float | None = None
def is_calibrating(self) -> bool: def is_calibrating(self) -> bool:
@@ -59,8 +61,7 @@ class ImprovedMotionDetector(MotionDetector):
# if ptz motor is moving from autotracking, quickly return # if ptz motor is moving from autotracking, quickly return
# a single box that is 80% of the frame # a single box that is 80% of the frame
if self.ptz_metrics is not None and ( if self.ptz_metrics is not None and (
self.ptz_metrics.autotracker_enabled.value self.autotracking_enabled and not self.ptz_metrics.motor_stopped.is_set()
and not self.ptz_metrics.motor_stopped.is_set()
): ):
return [ 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 so, reassign the average to the current frame so we begin with a new baseline
if self.ptz_metrics is not None and ( if self.ptz_metrics is not None and (
# ensure we only do this for cameras with autotracking enabled # 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.ptz_metrics.motor_stopped.is_set()
and ( and (
self.last_stop_time is None self.last_stop_time is None
+15 -4
View File
@@ -9,7 +9,9 @@ from typing import Any
import cv2 import cv2
import requests import requests
from numpy import ndarray from numpy import ndarray
from requests.adapters import HTTPAdapter
from requests.models import Response from requests.models import Response
from urllib3.util.retry import Retry
from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR 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._is_active: bool = self.key is not None
self._token_data: dict = {} 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: def _refresh_token_if_needed(self) -> None:
if ( if (
self._token_data.get("expires") is None 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" "Plus API key not set. See https://docs.frigate.video/integrations/plus#set-your-api-key"
) )
parts = self.key.split(":") 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: if not r.ok:
raise Exception(f"Unable to refresh API token: {r.text}") raise Exception(f"Unable to refresh API token: {r.text}")
self._token_data = r.json() self._token_data = r.json()
@@ -121,19 +132,19 @@ class PlusApi:
return {"authorization": f"Bearer {self._token_data.get('accessToken')}"} return {"authorization": f"Bearer {self._token_data.get('accessToken')}"}
def _get(self, path: str) -> Response: def _get(self, path: str) -> Response:
return requests.get( return self._session.get(
f"{self.host}/v1/{path}", headers=self._get_authorization_header() f"{self.host}/v1/{path}", headers=self._get_authorization_header()
) )
def _post(self, path: str, data: dict) -> Response: def _post(self, path: str, data: dict) -> Response:
return requests.post( return self._session.post(
f"{self.host}/v1/{path}", f"{self.host}/v1/{path}",
headers=self._get_authorization_header(), headers=self._get_authorization_header(),
json=data, json=data,
) )
def _put(self, path: str, data: dict) -> Response: def _put(self, path: str, data: dict) -> Response:
return requests.put( return self._session.put(
f"{self.host}/v1/{path}", f"{self.host}/v1/{path}",
headers=self._get_authorization_header(), headers=self._get_authorization_header(),
json=data, json=data,
+148 -298
View File
@@ -23,6 +23,7 @@ from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
from frigate.config.camera.updater import ( from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber, CameraConfigUpdateSubscriber,
CameraConfigUpdateTopic,
) )
from frigate.const import ( from frigate.const import (
AUTOTRACKING_MAX_AREA_RATIO, AUTOTRACKING_MAX_AREA_RATIO,
@@ -42,6 +43,11 @@ from frigate.util.image import SharedMemoryFrameManager, intersection_over_union
logger = logging.getLogger(__name__) 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): 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 # Determine if the PTZ was in motion at the set frame time
# for non ptz/autotracking cameras, this will always return False # for non ptz/autotracking cameras, this will always return False
@@ -180,7 +186,7 @@ class PtzMotionEstimator:
return self.coord_transformations return self.coord_transformations
class PtzAutoTrackerThread(threading.Thread): class PtzAutoTracker(threading.Thread):
def __init__( def __init__(
self, self,
config: FrigateConfig, config: FrigateConfig,
@@ -190,56 +196,14 @@ class PtzAutoTrackerThread(threading.Thread):
stop_event: MpEvent, stop_event: MpEvent,
) -> None: ) -> None:
super().__init__(name="ptz_autotracker") 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.config = config
self.onvif = onvif self.onvif = onvif
self.ptz_metrics = ptz_metrics self.ptz_metrics = ptz_metrics
self.dispatcher = dispatcher self.dispatcher = dispatcher
self.stop_event = stop_event 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_history: dict[str, object] = {}
self.tracked_object_metrics: dict[str, object] = {} self.tracked_object_metrics: dict[str, dict[str, Any]] = {}
self.object_types: dict[str, object] = {}
self.required_zones: dict[str, object] = {}
self.move_queues: dict[str, object] = {} self.move_queues: dict[str, object] = {}
self.move_queue_locks: dict[str, object] = {} self.move_queue_locks: dict[str, object] = {}
self.move_threads: dict[str, object] = {} self.move_threads: dict[str, object] = {}
@@ -249,7 +213,6 @@ class PtzAutoTracker:
self.intercept: dict[str, object] = {} self.intercept: dict[str, object] = {}
self.move_coefficients: dict[str, object] = {} self.move_coefficients: dict[str, object] = {}
self.zoom_time: dict[str, float] = {} self.zoom_time: dict[str, float] = {}
self.zoom_factor: dict[str, object] = {}
self.config_subscriber = CameraConfigUpdateSubscriber( self.config_subscriber = CameraConfigUpdateSubscriber(
self.config, self.config,
@@ -277,44 +240,37 @@ class PtzAutoTracker:
# Wait for the coroutine to complete # Wait for the coroutine to complete
future.result() future.result()
def check_for_updates(self) -> None: def run(self) -> None:
"""Apply camera config updates and mirror autotracking state to ptz metrics. 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 for camera, camera_config in list(self.config.cameras.items()):
has to follow every path that can change autotracking, not just the mqtt if not camera_config.enabled:
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:
continue continue
metrics.autotracker_enabled.value = ( if camera_config.onvif.autotracking.enabled:
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): async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
logger.debug(f"{camera}: Autotracker init") 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[camera] = None
self.tracked_object_history[camera] = deque( self.tracked_object_history[camera] = deque(
maxlen=round(camera_config.detect.fps * 1.5) maxlen=round(camera_config.detect.fps * 1.5)
) )
self.tracked_object_metrics[camera] = { self._reset_tracked_object_metrics(camera)
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
** (1 / self.zoom_factor[camera])
}
self.calibrating[camera] = False self.calibrating[camera] = False
self.move_metrics[camera] = [] self.move_metrics[camera] = []
@@ -326,43 +282,22 @@ class PtzAutoTracker:
# handle onvif constructor failing due to no connection # handle onvif constructor failing due to no connection
if camera not in self.onvif.cams: if camera not in self.onvif.cams:
logger.warning( self._disable(camera, "onvif connection failed")
f"Disabling autotracking for {camera}: onvif connection failed"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
return return
if not self.onvif.cams[camera]["init"]: if not self.onvif.cams[camera]["init"]:
if not await self.onvif._init_onvif(camera): if not await self.onvif._init_onvif(camera):
logger.warning( self._disable(camera, "Unable to initialize onvif")
f"Disabling autotracking for {camera}: Unable to initialize onvif"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
return return
if "pt-r-fov" not in self.onvif.cams[camera]["features"]: if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
logger.warning( self._disable(camera, "FOV relative movement not supported")
f"Disabling autotracking for {camera}: FOV relative movement not supported"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
return return
move_status_supported = await self.onvif.get_service_capabilities(camera) move_status_supported = await self.onvif.get_service_capabilities(camera)
if not ( if str(move_status_supported).lower() != "true":
isinstance(move_status_supported, bool) and move_status_supported self._disable(camera, "ONVIF MoveStatus not 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
return return
if self.onvif.cams[camera]["init"]: if self.onvif.cams[camera]["init"]:
@@ -374,59 +309,41 @@ class PtzAutoTracker:
) )
if camera_config.onvif.autotracking.movement_weights: if camera_config.onvif.autotracking.movement_weights:
if len(camera_config.onvif.autotracking.movement_weights) == 6: (
camera_config.onvif.autotracking.movement_weights = [ self.ptz_metrics[camera].min_zoom.value,
float(val) self.ptz_metrics[camera].max_zoom.value,
for val in camera_config.onvif.autotracking.movement_weights self.intercept[camera],
] *self.move_coefficients[camera],
self.ptz_metrics[ self.zoom_time[camera],
camera ) = map(float, camera_config.onvif.autotracking.movement_weights)
].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."
)
if camera_config.onvif.autotracking.calibrate_on_startup: if camera_config.onvif.autotracking.calibrate_on_startup:
await self._calibrate_camera(camera) await self._calibrate_camera(camera)
self.ptz_metrics[camera].tracking_active.clear()
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False) self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
self.autotracker_init[camera] = True self.autotracker_init[camera] = True
def _write_config(self, camera): def _disable(self, camera: str, reason: str) -> None:
config_file = find_config_file() logger.warning(f"Disabling autotracking for {camera}: {reason}")
autotracking_config = self.config.cameras[camera].onvif.autotracking
autotracking_config.enabled = False
logger.debug( # the camera process holds its own copy of the config
f"{camera}: Writing new config with autotracker motion coefficients: {self.config.cameras[camera].onvif.autotracking.movement_weights}" self.dispatcher.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
autotracking_config,
) )
update_yaml_file_bulk( def _reset_tracked_object_metrics(self, camera: str) -> None:
config_file, self.tracked_object_metrics[camera] = {}
{
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[ async def _wait_until_stopped(
camera self, camera: str, metrics: PTZMetrics | None = None
].onvif.autotracking.movement_weights ) -> 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): async def _calibrate_camera(self, camera):
# move the camera from the preset in steps and measure the time it takes to move that amount # 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, 1,
) )
while not self.ptz_metrics[camera].motor_stopped.is_set(): await self._wait_until_stopped(camera)
await self.onvif.get_camera_status(camera)
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value) zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
@@ -470,8 +386,7 @@ class PtzAutoTracker:
1, 1,
) )
while not self.ptz_metrics[camera].motor_stopped.is_set(): await self._wait_until_stopped(camera)
await self.onvif.get_camera_status(camera)
zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value) zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value)
@@ -488,8 +403,7 @@ class PtzAutoTracker:
1, 1,
) )
while not self.ptz_metrics[camera].motor_stopped.is_set(): await self._wait_until_stopped(camera)
await self.onvif.get_camera_status(camera)
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value) zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
@@ -503,8 +417,7 @@ class PtzAutoTracker:
1, 1,
) )
while not self.ptz_metrics[camera].motor_stopped.is_set(): await self._wait_until_stopped(camera)
await self.onvif.get_camera_status(camera)
zoom_stop_time = time.time() zoom_stop_time = time.time()
@@ -518,8 +431,7 @@ class PtzAutoTracker:
1, 1,
) )
while not self.ptz_metrics[camera].motor_stopped.is_set(): await self._wait_until_stopped(camera)
await self.onvif.get_camera_status(camera)
full_relative_stop_time = time.time() full_relative_stop_time = time.time()
@@ -531,8 +443,7 @@ class PtzAutoTracker:
1, 1,
) )
while not self.ptz_metrics[camera].motor_stopped.is_set(): await self._wait_until_stopped(camera)
await self.onvif.get_camera_status(camera)
self.zoom_time[camera] = ( self.zoom_time[camera] = (
full_relative_stop_time - full_relative_start_time full_relative_stop_time - full_relative_start_time
@@ -558,9 +469,7 @@ class PtzAutoTracker:
self.ptz_metrics[camera].reset.set() self.ptz_metrics[camera].reset.set()
self.ptz_metrics[camera].motor_stopped.clear() self.ptz_metrics[camera].motor_stopped.clear()
# Wait until the camera finishes moving await self._wait_until_stopped(camera)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
for step in range(num_steps): for step in range(num_steps):
pan = step_sizes[step] pan = step_sizes[step]
@@ -569,9 +478,7 @@ class PtzAutoTracker:
start_time = time.time() start_time = time.time()
await self.onvif._move_relative(camera, pan, tilt, 0, 1) await self.onvif._move_relative(camera, pan, tilt, 0, 1)
# Wait until the camera finishes moving await self._wait_until_stopped(camera)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
stop_time = time.time() stop_time = time.time()
self.move_metrics[camera].append( self.move_metrics[camera].append(
@@ -590,9 +497,7 @@ class PtzAutoTracker:
self.ptz_metrics[camera].reset.set() self.ptz_metrics[camera].reset.set()
self.ptz_metrics[camera].motor_stopped.clear() self.ptz_metrics[camera].motor_stopped.clear()
# Wait until the camera finishes moving await self._wait_until_stopped(camera)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
logger.info( logger.info(
f"Calibration for {camera} in progress: {round((step / num_steps) * 100)}% complete" 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]}" 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): def _predict_movement_time(self, camera, pan, tilt):
combined_movement = abs(pan) + abs(tilt) combined_movement = abs(pan) + abs(tilt)
@@ -682,6 +594,18 @@ class PtzAutoTracker:
[self.tracked_object_history[camera][-1]["frame_time"] + time], [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 _calculate_tracked_object_metrics(self, camera, obj):
def remove_outliers(data): def remove_outliers(data):
areas = [item["area"] for item in data] areas = [item["area"] for item in data]
@@ -703,19 +627,20 @@ class PtzAutoTracker:
return filtered_data return filtered_data
camera_config = self.config.cameras[camera] 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_width = camera_config.frame_shape[1]
camera_height = camera_config.frame_shape[0] camera_height = camera_config.frame_shape[0]
# Extract areas and calculate weighted average # Extract areas and calculate weighted average
# grab the largest dimension of the bounding box and create a square from that # 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"] current_time = obj.obj_data["frame_time"]
time_window = 1.5 # seconds time_window = 1.5 # seconds
history = [ history = [
entry entry
for entry in self.tracked_object_history[camera] for entry in self.tracked_object_history[camera]
if not entry.get("is_initial_frame", False) if current_time - entry["frame_time"] <= time_window
and current_time - entry["frame_time"] <= time_window
] ]
if not history: # Fallback to latest if no recent entries if not history: # Fallback to latest if no recent entries
history = [self.tracked_object_history[camera][-1]] 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]) y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
self.tracked_object_metrics[camera]["area_coefficients"] = np.linalg.lstsq( tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
X.reshape(-1, 1), y, rcond=None 0
)[0] ]
else: 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) weights = np.arange(1, len(filtered_areas) + 1)
weighted_area = np.average( weighted_area = np.average(
[item["area"] for item in filtered_areas], weights=weights [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) weighted_area / (camera_width * camera_height)
) ** self.zoom_factor[camera] ) ** zoom_factor
if "original_target_box" not in self.tracked_object_metrics[camera]: if "original_target_box" not in tom:
self.tracked_object_metrics[camera]["original_target_box"] = ( tom["original_target_box"] = tom["target_box"]
self.tracked_object_metrics[camera]["target_box"]
)
( (
self.tracked_object_metrics[camera]["valid_velocity"], tom["valid_velocity"],
self.tracked_object_metrics[camera]["velocity"], tom["velocity"],
) = self._get_valid_velocity(camera, obj) ) = self._get_valid_velocity(camera, obj)
self.tracked_object_metrics[camera]["distance"] = self._get_distance_threshold( tom["distance"] = self._get_distance_threshold(camera, obj)
camera, obj
)
centroid_distance = np.linalg.norm( centroid_distance = np.linalg.norm(
[ [
@@ -785,9 +706,7 @@ class PtzAutoTracker:
logger.debug(f"{camera}: Centroid distance: {centroid_distance}") logger.debug(f"{camera}: Centroid distance: {centroid_distance}")
self.tracked_object_metrics[camera]["below_distance_threshold"] = ( tom["below_distance_threshold"] = centroid_distance < tom["distance"]
centroid_distance < self.tracked_object_metrics[camera]["distance"]
)
async def _process_move_queue(self, camera): async def _process_move_queue(self, camera):
move_queue = self.move_queues[camera] move_queue = self.move_queues[camera]
@@ -833,16 +752,12 @@ class PtzAutoTracker:
if pan != 0 or tilt != 0: if pan != 0 or tilt != 0:
await self.onvif._move_relative(camera, pan, tilt, 0, 1) await self.onvif._move_relative(camera, pan, tilt, 0, 1)
# Wait until the camera finishes moving await self._wait_until_stopped(camera, metrics)
while not metrics.motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
if zoom > 0 and metrics.zoom_level.value != zoom: if zoom > 0 and metrics.zoom_level.value != zoom:
await self.onvif._zoom_absolute(camera, zoom, 1) await self.onvif._zoom_absolute(camera, zoom, 1)
# Wait until the camera finishes moving await self._wait_until_stopped(camera, metrics)
while not metrics.motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
if camera_config.onvif.autotracking.movement_weights: if camera_config.onvif.autotracking.movement_weights:
logger.debug( logger.debug(
@@ -878,42 +793,21 @@ class PtzAutoTracker:
await move_queue.get() await move_queue.get()
def _enqueue_move(self, camera, frame_time, pan, tilt, zoom): def _enqueue_move(self, camera, frame_time, pan, tilt, zoom):
def split_value(value, suppress_diff=True): pan, tilt, zoom = (np.clip(value, -1, 1) for value in (pan, tilt, zoom))
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
if ( 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 frame_time > self.ptz_metrics[camera].stop_time.value
and not self.move_queue_locks[camera].locked() 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( logger.debug(
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}" 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.onvif.loop.call_soon_threadsafe(
self.move_queues[camera].put_nowait, move_data self.move_queues[camera].put_nowait, (frame_time, pan, tilt, zoom)
) )
# reset values to not split up large movements
pan = 0
tilt = 0
zoom = 0
def _touching_frame_edges(self, camera, box): def _touching_frame_edges(self, camera, box):
camera_config = self.config.cameras[camera] camera_config = self.config.cameras[camera]
camera_width = camera_config.frame_shape[1] camera_width = camera_config.frame_shape[1]
@@ -1046,16 +940,17 @@ class PtzAutoTracker:
return distance_threshold return distance_threshold
def _should_zoom_in( def _should_zoom_in(self, camera: str, box, predicted_time):
self, camera: str, obj: TrackedObject, box, predicted_time, debug_zooming=False
):
# returns True if we should zoom in, False if we should zoom out, None to do nothing # returns True if we should zoom in, False if we should zoom out, None to do nothing
camera_config = self.config.cameras[camera] 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_width = camera_config.frame_shape[1]
camera_height = camera_config.frame_shape[0] camera_height = camera_config.frame_shape[0]
camera_fps = camera_config.detect.fps 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 bb_left, bb_top, bb_right, bb_bottom = box
@@ -1073,13 +968,11 @@ class PtzAutoTracker:
touching_frame_edges = self._touching_frame_edges(camera, box) touching_frame_edges = self._touching_frame_edges(camera, box)
# make sure object is centered in the frame # make sure object is centered in the frame
below_distance_threshold = self.tracked_object_metrics[camera][ below_distance_threshold = tom["below_distance_threshold"]
"below_distance_threshold"
]
below_dimension_threshold = (bb_right - bb_left) <= camera_width * ( below_dimension_threshold = (bb_right - bb_left) <= camera_width * (
self.zoom_factor[camera] + 0.1 zoom_factor + 0.1
) and (bb_bottom - bb_top) <= camera_height * (self.zoom_factor[camera] + 0.1) ) and (bb_bottom - bb_top) <= camera_height * (zoom_factor + 0.1)
# ensure object is not moving quickly # ensure object is not moving quickly
below_velocity_threshold = np.all( below_velocity_threshold = np.all(
@@ -1087,30 +980,16 @@ class PtzAutoTracker:
< np.tile([velocity_threshold_x, velocity_threshold_y], 2) < np.tile([velocity_threshold_x, velocity_threshold_y], 2)
) or np.all(average_velocity == 0) ) or np.all(average_velocity == 0)
if not predicted_time: calculated_target_box = self._predict_target_box(camera, 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
)
below_area_threshold = ( below_area_threshold = calculated_target_box < max_target_box
calculated_target_box
< self.tracked_object_metrics[camera]["max_target_box"]
)
# introduce some hysteresis to prevent a yo-yo zooming effect # introduce some hysteresis to prevent a yo-yo zooming effect
zoom_out_hysteresis = ( zoom_out_hysteresis = (
calculated_target_box calculated_target_box > max_target_box * AUTOTRACKING_ZOOM_OUT_HYSTERESIS
> self.tracked_object_metrics[camera]["max_target_box"]
* AUTOTRACKING_ZOOM_OUT_HYSTERESIS
) )
zoom_in_hysteresis = ( zoom_in_hysteresis = (
calculated_target_box calculated_target_box < max_target_box * AUTOTRACKING_ZOOM_IN_HYSTERESIS
< self.tracked_object_metrics[camera]["max_target_box"]
* AUTOTRACKING_ZOOM_IN_HYSTERESIS
) )
at_max_zoom = ( at_max_zoom = (
@@ -1122,8 +1001,6 @@ class PtzAutoTracker:
== self.ptz_metrics[camera].min_zoom.value == self.ptz_metrics[camera].min_zoom.value
) )
# debug zooming
if debug_zooming:
logger.debug( 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}" 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}"
) )
@@ -1131,10 +1008,10 @@ class PtzAutoTracker:
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}" f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
) )
logger.debug( 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']}" f"{camera}: Zoom test: below area threshold: {(below_area_threshold)} target: {tom['target_box']}, calculated: {calculated_target_box}, max: {max_target_box}"
) )
logger.debug( 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)}" 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( 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}" 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}"
@@ -1142,10 +1019,10 @@ class PtzAutoTracker:
logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}") 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: at min zoom: {at_min_zoom}")
logger.debug( 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']}" 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( 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']}" 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) # Zoom in conditions (and)
@@ -1237,7 +1114,7 @@ class PtzAutoTracker:
) )
zoom = self._get_zoom_amount( zoom = self._get_zoom_amount(
camera, obj, predicted_box, predicted_movement_time, debug_zoom=True camera, obj, predicted_box, predicted_movement_time
) )
if ( if (
@@ -1298,9 +1175,11 @@ class PtzAutoTracker:
obj: TrackedObject, obj: TrackedObject,
predicted_box, predicted_box,
predicted_movement_time, predicted_movement_time,
debug_zoom=True,
): ):
camera_config = self.config.cameras[camera] 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 # frame width and height
camera_width = camera_config.frame_shape[1] camera_width = camera_config.frame_shape[1]
@@ -1317,16 +1196,12 @@ class PtzAutoTracker:
# absolute zooming separately from pan/tilt # absolute zooming separately from pan/tilt
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.absolute: if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.absolute:
# don't zoom on initial move # 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 zoom = current_zoom_level
else: else:
if ( if (
result := self._should_zoom_in( result := self._should_zoom_in(
camera, camera, obj.obj_data["box"], predicted_movement_time
obj,
obj.obj_data["box"],
predicted_movement_time,
debug_zoom,
) )
) is not None: ) is not None:
# divide zoom in 10 increments and always zoom out more than in # divide zoom in 10 increments and always zoom out more than in
@@ -1342,46 +1217,35 @@ class PtzAutoTracker:
# relative zooming concurrently with pan/tilt # relative zooming concurrently with pan/tilt
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative: if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative:
# this is our initial zoom in on a new object # this is our initial zoom in on a new object
if "target_box" not in self.tracked_object_metrics[camera]: if "target_box" not in tom:
zoom = target_box ** self.zoom_factor[camera] zoom = target_box**zoom_factor
if zoom > self.tracked_object_metrics[camera]["max_target_box"]: if zoom > max_target_box:
zoom = -(1 - zoom) zoom = -(1 - zoom)
logger.debug( 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: else:
if ( if (
result := self._should_zoom_in( result := self._should_zoom_in(
camera, camera,
obj,
predicted_box predicted_box
if camera_config.onvif.autotracking.movement_weights if camera_config.onvif.autotracking.movement_weights
else obj.obj_data["box"], else obj.obj_data["box"],
predicted_movement_time, predicted_movement_time,
debug_zoom,
) )
) is not None: ) is not None:
if predicted_movement_time: calculated_target_box = self._predict_target_box(
calculated_target_box = self.tracked_object_metrics[camera][
"target_box"
] + self._predict_area_after_time(
camera, predicted_movement_time camera, predicted_movement_time
) / (camera_width * camera_height) )
if predicted_movement_time:
logger.debug( 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}" f"{camera}: Zooming prediction: predicted movement time: {predicted_movement_time}, original box: {tom['target_box']}, calculated box: {calculated_target_box}"
) )
else:
calculated_target_box = self.tracked_object_metrics[camera][
"target_box"
]
# zoom value # zoom value
ratio = ( ratio = max_target_box / calculated_target_box
self.tracked_object_metrics[camera]["max_target_box"]
/ calculated_target_box
)
zoom = (ratio - 1) / (ratio + 1) zoom = (ratio - 1) / (ratio + 1)
logger.debug( 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: if not result:
# zoom out with special condition if zooming out because of velocity, edges, etc. # zoom out with special condition if zooming out because of velocity, edges, etc.
@@ -1394,12 +1258,6 @@ class PtzAutoTracker:
return zoom return zoom
def is_autotracking(self, camera: str):
return self.tracked_object[camera] is not None
def autotracked_object_region(self, camera: str):
return self.tracked_object[camera]["region"]
def autotrack_object(self, camera: str, obj: TrackedObject): def autotrack_object(self, camera: str, obj: TrackedObject):
if camera not in self.config.cameras: if camera not in self.config.cameras:
return return
@@ -1423,8 +1281,9 @@ class PtzAutoTracker:
# new object # new object
self.tracked_object[camera] is None self.tracked_object[camera] is None
and obj.camera_config.name == camera 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 set(obj.entered_zones) & set(self.required_zones[camera]) and set(obj.entered_zones)
& set(camera_config.onvif.autotracking.required_zones)
and not obj.previous["false_positive"] and not obj.previous["false_positive"]
and not obj.false_positive and not obj.false_positive
and not self.tracked_object_history[camera] and not self.tracked_object_history[camera]
@@ -1433,7 +1292,6 @@ class PtzAutoTracker:
logger.debug( logger.debug(
f"{camera}: New object: {obj.obj_data['id']} {obj.obj_data['box']} {obj.obj_data['frame_time']}" 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( self.dispatcher.publish(
f"{camera}/ptz_autotracker/active", "ON", retain=False f"{camera}/ptz_autotracker/active", "ON", retain=False
) )
@@ -1482,7 +1340,7 @@ class PtzAutoTracker:
# Should we check region (maybe too broad) or expand the previous object's box a bit and check that? # 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 self.tracked_object[camera] is None
and obj.camera_config.name == camera 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.previous["false_positive"]
and not obj.false_positive and not obj.false_positive
and self.tracked_object_history[camera] and self.tracked_object_history[camera]
@@ -1518,10 +1376,7 @@ class PtzAutoTracker:
f"{camera}: End object: {obj.obj_data['id']} {obj.obj_data['box']}" f"{camera}: End object: {obj.obj_data['id']} {obj.obj_data['box']}"
) )
self.tracked_object[camera] = None self.tracked_object[camera] = None
self.tracked_object_metrics[camera] = { self._reset_tracked_object_metrics(camera)
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
** (1 / self.zoom_factor[camera])
}
async def camera_maintenance(self, camera): async def camera_maintenance(self, camera):
# bail and don't check anything if we're not set up yet, calibrating, or # bail and don't check anything if we're not set up yet, calibrating, or
@@ -1538,8 +1393,6 @@ class PtzAutoTracker:
# returns camera to preset after timeout when tracking is over # returns camera to preset after timeout when tracking is over
autotracker_config = self.config.cameras[camera].onvif.autotracking autotracker_config = self.config.cameras[camera].onvif.autotracking
if not self.autotracker_init[camera]:
self._autotracker_setup(self.config.cameras[camera], camera)
# regularly update camera status # regularly update camera status
if not self.ptz_metrics[camera].motor_stopped.is_set(): if not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera) await self.onvif.get_camera_status(camera)
@@ -1560,8 +1413,7 @@ class PtzAutoTracker:
self.tracked_object[camera] = None self.tracked_object[camera] = None
self.tracked_object_history[camera].clear() self.tracked_object_history[camera].clear()
while not self.ptz_metrics[camera].motor_stopped.is_set(): await self._wait_until_stopped(camera)
await self.onvif.get_camera_status(camera)
logger.debug( logger.debug(
f"{camera}: Time is {self.ptz_metrics[camera].frame_time.value}, returning to preset: {autotracker_config.return_preset}" f"{camera}: Time is {self.ptz_metrics[camera].frame_time.value}, returning to preset: {autotracker_config.return_preset}"
) )
@@ -1571,10 +1423,8 @@ class PtzAutoTracker:
) )
# update stored zoom level from preset # update stored zoom level from preset
while not self.ptz_metrics[camera].motor_stopped.is_set(): await self._wait_until_stopped(camera)
await self.onvif.get_camera_status(camera)
self.ptz_metrics[camera].tracking_active.clear()
self.dispatcher.publish( self.dispatcher.publish(
f"{camera}/ptz_autotracker/active", "OFF", retain=False f"{camera}/ptz_autotracker/active", "OFF", retain=False
) )
+203 -298
View File
@@ -10,8 +10,7 @@ from pathlib import Path
from typing import Any from typing import Any
import numpy import numpy
from onvif import ONVIFCamera, ONVIFError, ONVIFService from onvif import ONVIFCamera, ONVIFService
from zeep.exceptions import Fault, TransportError
from frigate.camera import PTZMetrics from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig, ZoomingModeEnum from frigate.config import FrigateConfig, ZoomingModeEnum
@@ -41,6 +40,14 @@ class OnvifCommandEnum(str, Enum):
focus_out = "focus_out" 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: class OnvifController:
ptz_metrics: dict[str, PTZMetrics] 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 = threading.Thread(target=self._run_event_loop, daemon=True)
self.loop_thread.start() 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_subscriber = CameraConfigUpdateSubscriber(
self.config, self.config,
self.config.cameras, self.config.cameras,
@@ -92,7 +91,8 @@ class OnvifController:
async def _init_cameras(self) -> None: async def _init_cameras(self) -> None:
"""Initialize all configured cameras.""" """Initialize all configured cameras."""
for cam_name in self.camera_configs: for cam_name, cam in list(self.config.cameras.items()):
if cam.enabled and cam.onvif.host:
await self._init_single_camera(cam_name) await self._init_single_camera(cam_name)
async def _poll_config_updates(self) -> None: async def _poll_config_updates(self) -> None:
@@ -129,13 +129,9 @@ class OnvifController:
async def _remove_camera(self, cam_name: str) -> None: async def _remove_camera(self, cam_name: str) -> None:
"""Tear down the ONVIF session for a camera removed at runtime.""" """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") logger.debug(f"Tearing down ONVIF for {cam_name} after camera removal")
await self._close_camera(cam_name) await self._close_camera(cam_name)
self.cams.pop(cam_name, None) self.cams.pop(cam_name, None)
self.camera_configs.pop(cam_name, None)
self.failed_cams.pop(cam_name, None) self.failed_cams.pop(cam_name, None)
self.status_locks.pop(cam_name, None) self.status_locks.pop(cam_name, None)
@@ -143,24 +139,13 @@ class OnvifController:
"""Re-initialize a camera after config change.""" """Re-initialize a camera after config change."""
logger.info(f"Re-initializing ONVIF for {cam_name} due to 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) await self._close_camera(cam_name)
self.cams.pop(cam_name, None)
self.failed_cams.pop(cam_name, None)
cam = self.config.cameras.get(cam_name) cam = self.config.cameras.get(cam_name)
if not cam or not cam.onvif.host: if cam and 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) await self._init_single_camera(cam_name)
async def _init_single_camera(self, cam_name: str) -> bool: async def _init_single_camera(self, cam_name: str) -> bool:
@@ -172,11 +157,12 @@ class OnvifController:
Returns: Returns:
bool: True if initialization succeeded, False otherwise 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}") logger.error(f"No configuration found for camera {cam_name}")
return False return False
cam = self.camera_configs[cam_name] self.status_locks.setdefault(cam_name, asyncio.Lock())
try: try:
self.cams[cam_name] = { self.cams[cam_name] = {
"onvif": ONVIFCamera( "onvif": ONVIFCamera(
@@ -195,12 +181,11 @@ class OnvifController:
"profiles": [], "profiles": [],
} }
return True 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}") logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
# track initial failures # track initial failures
self.failed_cams[cam_name] = { self.failed_cams[cam_name] = {
"retry_attempts": 0, "retry_attempts": 0,
"last_error": str(e),
"last_attempt": time.time(), "last_attempt": time.time(),
} }
return False return False
@@ -211,7 +196,8 @@ class OnvifController:
if camera_config is None: if camera_config is None:
return False return False
onvif: ONVIFCamera = self.cams[camera_name]["onvif"] cam = self.cams[camera_name]
onvif: ONVIFCamera = cam["onvif"]
try: try:
await onvif.update_xaddrs() await onvif.update_xaddrs()
except Exception as e: except Exception as e:
@@ -226,7 +212,7 @@ class OnvifController:
# this will fire an exception if camera is not a ptz # this will fire an exception if camera is not a ptz
capabilities = onvif.get_definition("ptz") capabilities = onvif.get_definition("ptz")
logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}") logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}")
except (Fault, ONVIFError, TransportError, Exception) as e: except Exception as e:
logger.error( logger.error(
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}" f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
) )
@@ -235,7 +221,7 @@ class OnvifController:
try: try:
profiles = await media.GetProfiles() profiles = await media.GetProfiles()
logger.debug(f"Onvif profiles for {camera_name}: {profiles}") logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
except (Fault, ONVIFError, TransportError, Exception) as e: except Exception as e:
logger.error( logger.error(
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}" 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 # 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} {"name": getattr(p, "Name", None) or p.token, "token": p.token}
for p in valid_profiles for p in valid_profiles
] ]
@@ -267,19 +253,18 @@ class OnvifController:
) )
configured_profile = camera_config.onvif.profile configured_profile = camera_config.onvif.profile
profile = None
if configured_profile is not None: if configured_profile is not None:
# match by exact token first, then by name # match by exact token first, then by name
for p in valid_profiles: profile = next(
if p.token == configured_profile: (
profile = p p
break for key in ("token", "Name")
if profile is None: for p in valid_profiles
for p in valid_profiles: if getattr(p, key, None) == configured_profile
if getattr(p, "Name", None) == configured_profile: ),
profile = p None,
break )
if profile is None: if profile is None:
available = [ available = [
f"name='{getattr(p, 'Name', None)}', token='{p.token}'" 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}") logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
# get the PTZ config for the profile
try:
configs = profile.PTZConfiguration configs = profile.PTZConfiguration
logger.debug( logger.debug(f"Onvif ptz config for media profile in {camera_name}: {configs}")
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
ptz: ONVIFService = await onvif.create_ptz_service() ptz: ONVIFService = await onvif.create_ptz_service()
self.cams[camera_name]["ptz"] = ptz cam["ptz"] = ptz
try: try:
imaging: ONVIFService = await onvif.create_imaging_service() 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}") logger.debug(f"Imaging service not supported for {camera_name}: {e}")
imaging = None imaging = None
self.cams[camera_name]["imaging"] = imaging cam["imaging"] = imaging
try: try:
video_sources = await media.GetVideoSources() video_sources = await media.GetVideoSources()
if video_sources and len(video_sources) > 0: if video_sources and len(video_sources) > 0:
self.cams[camera_name]["video_source_token"] = video_sources[0].token cam["video_source_token"] = video_sources[0].token
except (Fault, ONVIFError, TransportError, Exception) as e: except Exception as e:
logger.debug(f"Unable to get video sources for {camera_name}: {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 # setup continuous moving request
move_request = ptz.create_type("ContinuousMove") move_request = ptz.create_type("ContinuousMove")
move_request.ProfileToken = profile.token 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 # get PTZ configuration options for feature detection and relative movement
ptz_config = None ptz_config = None
@@ -349,7 +325,7 @@ class OnvifController:
logger.debug( logger.debug(
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}" f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
) )
except (Fault, ONVIFError, TransportError, Exception) as e: except Exception as e:
logger.debug( logger.debug(
f"Unable to get PTZ configuration options for {camera_name}: {e}" 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 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 # setup relative move request when FOV relative movement is supported
if ( if (
fov_space_id is not None fov_space_id is not None
@@ -395,9 +359,7 @@ class OnvifController:
# one-off GetStatus to seed Translation field # one-off GetStatus to seed Translation field
status = None status = None
try: try:
one_off_status_request = ptz.create_type("GetStatus") status = await ptz.GetStatus({"ProfileToken": profile.token})
one_off_status_request.ProfileToken = profile.token
status = await ptz.GetStatus(one_off_status_request)
logger.debug(f"Onvif status for {camera_name}: {status}") logger.debug(f"Onvif status for {camera_name}: {status}")
except Exception as e: except Exception as e:
logger.warning(f"Unable to get status from camera {camera_name}: {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 # configure zoom on relative move request
if ( if (
autotracking_enabled autotracking_enabled
and autotracking_config.zooming != ZoomingModeEnum.disabled and autotracking_config.zooming == ZoomingModeEnum.relative
): ):
zoom_space_id = next( zoom_space_id = next(
( (
@@ -463,21 +425,16 @@ class OnvifController:
) )
if rel_move_request.Speed is None: 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( logger.debug(
f"{camera_name}: Relative move request after setup: {rel_move_request}" f"{camera_name}: Relative move request after setup: {rel_move_request}"
) )
self.cams[camera_name]["relative_move_request"] = rel_move_request cam["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
# setup existing presets # setup existing presets
try: try:
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token}) 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}") logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
presets = [] presets = []
@@ -490,7 +447,7 @@ class OnvifController:
preset_name = preset_name.encode("latin-1").decode("utf-8") preset_name = preset_name.encode("latin-1").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError): except (UnicodeEncodeError, UnicodeDecodeError):
pass pass
self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"] cam["presets"][preset_name.lower()] = preset["token"]
# get list of supported features # get list of supported features
supported_features = [] supported_features = []
@@ -504,64 +461,40 @@ class OnvifController:
if configs.DefaultRelativePanTiltTranslationSpace: if configs.DefaultRelativePanTiltTranslationSpace:
supported_features.append("pt-r") supported_features.append("pt-r")
spaces = getattr(ptz_config, "Spaces", None)
if configs.DefaultRelativeZoomTranslationSpace: if configs.DefaultRelativeZoomTranslationSpace:
supported_features.append("zoom-r") supported_features.append("zoom-r")
if ptz_config is not None: if getattr(spaces, "RelativeZoomTranslationSpace", None):
try: cam["relative_zoom_range"] = spaces.RelativeZoomTranslationSpace[0]
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 configs.DefaultAbsoluteZoomPositionSpace: if configs.DefaultAbsoluteZoomPositionSpace:
supported_features.append("zoom-a") supported_features.append("zoom-a")
if ptz_config is not None: if getattr(spaces, "AbsoluteZoomPositionSpace", None):
try: cam["absolute_zoom_range"] = spaces.AbsoluteZoomPositionSpace[0]
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}"
)
# disable autotracking zoom if required ranges are unavailable # autotracking zoom needs the range for its mode, and get_camera_status
if autotracking_config.zooming != ZoomingModeEnum.disabled: # reads the absolute range in both modes
if autotracking_config.zooming == ZoomingModeEnum.relative: zooming = autotracking_config.zooming
if "relative_zoom_range" not in self.cams[camera_name]: if zooming != ZoomingModeEnum.disabled and (
autotracking_config.zooming = ZoomingModeEnum.disabled "absolute_zoom_range" not in cam or f"{zooming.value}_zoom_range" not in cam
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_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: try:
imaging_capabilities = await imaging.GetImagingSettings( imaging_capabilities = await imaging.GetImagingSettings(
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]} {"VideoSourceToken": cam["video_source_token"]}
) )
if ( if (
hasattr(imaging_capabilities, "Focus") hasattr(imaging_capabilities, "Focus")
and imaging_capabilities.Focus and imaging_capabilities.Focus
): ):
supported_features.append("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}") logger.debug(f"Focus not supported for {camera_name}: {e}")
# detect FOV relative movement support # detect FOV relative movement support
@@ -570,17 +503,18 @@ class OnvifController:
and configs.DefaultRelativePanTiltTranslationSpace is not None and configs.DefaultRelativePanTiltTranslationSpace is not None
): ):
supported_features.append("pt-r-fov") supported_features.append("pt-r-fov")
self.cams[camera_name]["relative_fov_range"] = ( cam["relative_fov_range"] = (
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id] ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
) )
self.cams[camera_name]["features"] = supported_features cam["features"] = supported_features
self.cams[camera_name]["init"] = True cam["init"] = True
return True return True
async def _stop(self, camera_name: str) -> None: async def _stop(self, camera_name: str) -> None:
move_request = self.cams[camera_name]["move_request"] cam = self.cams[camera_name]
await self.cams[camera_name]["ptz"].Stop( move_request = cam["move_request"]
await cam["ptz"].Stop(
{ {
"ProfileToken": move_request.ProfileToken, "ProfileToken": move_request.ProfileToken,
"PanTilt": True, "PanTilt": True,
@@ -588,88 +522,75 @@ class OnvifController:
} }
) )
if ( if (
"focus" in self.cams[camera_name]["features"] "focus" in cam["features"]
and self.cams[camera_name]["video_source_token"] and cam["video_source_token"]
and self.cams[camera_name]["imaging"] is not None and cam["imaging"] is not None
): ):
try: try:
stop_request = self.cams[camera_name]["imaging"].create_type("Stop") stop_request = cam["imaging"].create_type("Stop")
stop_request.VideoSourceToken = self.cams[camera_name][ stop_request.VideoSourceToken = cam["video_source_token"]
"video_source_token" await cam["imaging"].Stop(stop_request)
] except Exception as e:
await self.cams[camera_name]["imaging"].Stop(stop_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.warning(f"Failed to stop focus for {camera_name}: {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: 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( logger.warning(
f"{camera_name} is already performing an action, stopping..." f"{camera_name} is already performing an action, stopping..."
) )
await self._stop(camera_name) 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.") logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
return return
self.cams[camera_name]["active"] = True cam["active"] = True
move_request = self.cams[camera_name]["move_request"] move_request = cam["move_request"]
if command == OnvifCommandEnum.move_left: x, y = PAN_TILT_VELOCITY[command]
move_request.Velocity = {"PanTilt": {"x": -0.5, "y": 0}} move_request.Velocity = {"PanTilt": {"x": x, "y": y}}
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,
}
}
try: try:
await self.cams[camera_name]["ptz"].ContinuousMove(move_request) await cam["ptz"].ContinuousMove(move_request)
except (Fault, ONVIFError, TransportError, Exception) as e: except Exception as e:
logger.warning(f"Onvif sending move request to {camera_name} failed: {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: 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).") logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
return return
metrics = self.ptz_metrics.get(camera_name) 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 return
logger.debug( logger.debug(
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}" f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
) )
if self.cams[camera_name]["active"]: if cam["active"]:
logger.warning( logger.warning(
f"{camera_name} is already performing an action, not moving..." f"{camera_name} is already performing an action, not moving..."
) )
return return
self.cams[camera_name]["active"] = True cam["active"] = True
# only track start_time for autotracking # only track start_time for autotracking
if metrics.autotracker_enabled.value: if camera_config.onvif.autotracking.enabled:
metrics.motor_stopped.clear() metrics.motor_stopped.clear()
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}") logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
metrics.start_time.value = metrics.frame_time.value metrics.start_time.value = metrics.frame_time.value
metrics.stop_time.value = 0 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. # 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 # 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, pan,
[-1, 1], [-1, 1],
[ [
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"], cam["relative_fov_range"]["XRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"], cam["relative_fov_range"]["XRange"]["Max"],
], ],
) )
tilt = numpy.interp( tilt = numpy.interp(
tilt, tilt,
[-1, 1], [-1, 1],
[ [
self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"], cam["relative_fov_range"]["YRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"], cam["relative_fov_range"]["YRange"]["Max"],
], ],
) )
move_request.Speed = { move_speed = {"PanTilt": {"x": speed, "y": speed}}
"PanTilt": {
"x": speed,
"y": speed,
},
}
move_request.Translation.PanTilt.x = pan move_request.Translation.PanTilt.x = pan
move_request.Translation.PanTilt.y = tilt move_request.Translation.PanTilt.y = tilt
# include zoom if requested and camera supports relative zoom # include zoom if requested and camera supports relative zoom
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]: include_zoom = zoom != 0 and "zoom-r" in cam["features"]
move_request.Speed = {
"PanTilt": { if include_zoom:
"x": speed, move_speed["Zoom"] = {"x": speed}
"y": speed,
},
"Zoom": {"x": speed},
}
move_request["Translation"]["Zoom"] = {"x": zoom} 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 # reset after the move request
move_request.Translation.PanTilt.x = 0 move_request.Translation.PanTilt.x = 0
move_request.Translation.PanTilt.y = 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"] 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: async def _move_to_preset(self, camera_name: str, preset: str) -> None:
cam = self.cams[camera_name]
preset = preset.lower() 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}") logger.error(f"{preset} is not a valid preset for {camera_name}")
return return
@@ -734,44 +649,48 @@ class OnvifController:
if metrics is None: if metrics is None:
return return
self.cams[camera_name]["active"] = True cam["active"] = True
metrics.start_time.value = 0 metrics.start_time.value = 0
metrics.stop_time.value = 0 metrics.stop_time.value = 0
move_request = self.cams[camera_name]["move_request"] move_request = cam["move_request"]
preset_token = self.cams[camera_name]["presets"][preset] preset_token = cam["presets"][preset]
await self.cams[camera_name]["ptz"].GotoPreset( await cam["ptz"].GotoPreset(
{ {
"ProfileToken": move_request.ProfileToken, "ProfileToken": move_request.ProfileToken,
"PresetToken": preset_token, "PresetToken": preset_token,
} }
) )
self.cams[camera_name]["active"] = False cam["active"] = False
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None: 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( logger.warning(
f"{camera_name} is already performing an action, stopping..." f"{camera_name} is already performing an action, stopping..."
) )
await self._stop(camera_name) 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.") logger.error(f"{camera_name} does not support ONVIF zooming.")
return return
self.cams[camera_name]["active"] = True cam["active"] = True
move_request = self.cams[camera_name]["move_request"] move_request = cam["move_request"]
if command == OnvifCommandEnum.zoom_in: if command == OnvifCommandEnum.zoom_in:
move_request.Velocity = {"Zoom": {"x": 0.5}} move_request.Velocity = {"Zoom": {"x": 0.5}}
elif command == OnvifCommandEnum.zoom_out: elif command == OnvifCommandEnum.zoom_out:
move_request.Velocity = {"Zoom": {"x": -0.5}} 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: 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.") logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
return return
@@ -782,56 +701,59 @@ class OnvifController:
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}") logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
if self.cams[camera_name]["active"]: if cam["active"]:
logger.warning( logger.warning(
f"{camera_name} is already performing an action, not moving..." f"{camera_name} is already performing an action, not moving..."
) )
return return
self.cams[camera_name]["active"] = True cam["active"] = True
metrics.motor_stopped.clear() metrics.motor_stopped.clear()
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}") logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
metrics.start_time.value = metrics.frame_time.value metrics.start_time.value = metrics.frame_time.value
metrics.stop_time.value = 0 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. # function takes in 0 to 1 for zoom, interpolate to the values of the camera.
zoom = numpy.interp( zoom = numpy.interp(
zoom, zoom,
[0, 1], [0, 1],
[ [
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"], cam["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"], cam["absolute_zoom_range"]["XRange"]["Max"],
], ],
) )
move_request.Speed = {"Zoom": speed}
move_request.Position = {"Zoom": zoom}
logger.debug(f"{camera_name}: Absolute 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: 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( logger.warning(
f"{camera_name} is already performing an action, not moving..." f"{camera_name} is already performing an action, not moving..."
) )
await self._stop(camera_name) await self._stop(camera_name)
if ( if (
"focus" not in self.cams[camera_name]["features"] "focus" not in cam["features"]
or not self.cams[camera_name]["video_source_token"] or not cam["video_source_token"]
or self.cams[camera_name]["imaging"] is None or cam["imaging"] is None
): ):
logger.error(f"{camera_name} does not support ONVIF continuous focus.") logger.error(f"{camera_name} does not support ONVIF continuous focus.")
return return
self.cams[camera_name]["active"] = True cam["active"] = True
move_request = self.cams[camera_name]["imaging"].create_type("Move") move_request = cam["imaging"].create_type("Move")
move_request.VideoSourceToken = self.cams[camera_name]["video_source_token"] move_request.VideoSourceToken = cam["video_source_token"]
move_request.Focus = { move_request.Focus = {
"Continuous": { "Continuous": {
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5 "Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
@@ -839,10 +761,10 @@ class OnvifController:
} }
try: try:
await self.cams[camera_name]["imaging"].Move(move_request) await cam["imaging"].Move(move_request)
except (Fault, ONVIFError, TransportError, Exception) as e: except Exception as e:
logger.warning(f"Onvif sending focus request to {camera_name} failed: {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( async def handle_command_async(
self, camera_name: str, command: OnvifCommandEnum, param: str = "" self, camera_name: str, command: OnvifCommandEnum, param: str = ""
@@ -883,7 +805,7 @@ class OnvifController:
await self._focus(camera_name, command) await self._focus(camera_name, command)
else: else:
await self._move(camera_name, command) 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}") logger.error(f"Unable to handle onvif command: {e}")
def handle_command( def handle_command(
@@ -906,6 +828,15 @@ class OnvifController:
f"Error executing command {command} for camera {camera_name}: {e}" 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]: async def get_camera_info(self, camera_name: str) -> dict[str, Any]:
""" """
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
@@ -929,56 +860,21 @@ class OnvifController:
return {} return {}
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]: if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
return { return self._camera_info(camera_name)
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
"profiles": self.cams[camera_name].get("profiles", []),
}
if camera_name not in self.cams.keys() and camera_name in self.config.cameras: if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
success = await self._init_single_camera(camera_name) success = await self._init_single_camera(camera_name)
if not success: if not success:
return {} return {}
# Reset retry count after timeout failed = self.failed_cams.get(camera_name, {})
attempts = self.failed_cams.get(camera_name, {}).get("retry_attempts", 0) attempts = failed.get("retry_attempts", 0)
last_attempt = self.failed_cams.get(camera_name, {}).get("last_attempt", 0) last_attempt = failed.get("last_attempt", 0)
# Reset retry count after timeout
if last_attempt and (time.time() - last_attempt) > self.reset_timeout: if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
logger.debug(f"Resetting retry count for {camera_name} after timeout") logger.debug(f"Resetting retry count for {camera_name} after timeout")
attempts = 0 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}")
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: if attempts >= self.max_retries:
remaining_time = max( remaining_time = max(
@@ -987,8 +883,24 @@ class OnvifController:
logger.error( logger.error(
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}" 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 {} return {}
async def get_service_capabilities(self, camera_name: str) -> None: async def get_service_capabilities(self, camera_name: str) -> None:
@@ -996,16 +908,13 @@ class OnvifController:
logger.error(f"ONVIF is not configured for {camera_name}") logger.error(f"ONVIF is not configured for {camera_name}")
return {} return {}
if not self.cams[camera_name]["init"]: cam = self.cams[camera_name]
if not cam["init"]:
await self._init_onvif(camera_name) await self._init_onvif(camera_name)
service_capabilities_request = self.cams[camera_name][
"service_capabilities_request"
]
try: try:
service_capabilities = await self.cams[camera_name][ service_capabilities = await cam["ptz"].GetServiceCapabilities()
"ptz"
].GetServiceCapabilities(service_capabilities_request)
logger.debug( logger.debug(
f"Onvif service capabilities for {camera_name}: {service_capabilities}" f"Onvif service capabilities for {camera_name}: {service_capabilities}"
@@ -1031,13 +940,16 @@ class OnvifController:
if metrics is None or camera_config is None: if metrics is None or camera_config is None:
return 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): if not await self._init_onvif(camera_name):
return return
status_request = self.cams[camera_name]["status_request"]
try: try:
status = await self.cams[camera_name]["ptz"].GetStatus(status_request) status = await cam["ptz"].GetStatus(
{"ProfileToken": cam["move_request"].ProfileToken}
)
except Exception: except Exception:
pass # We're unsupported, that'll be reported in the next check. pass # We're unsupported, that'll be reported in the next check.
@@ -1068,7 +980,7 @@ class OnvifController:
if pan_tilt_status == "IDLE" and ( if pan_tilt_status == "IDLE" and (
zoom_status is None or zoom_status == "IDLE" zoom_status is None or zoom_status == "IDLE"
): ):
self.cams[camera_name]["active"] = False cam["active"] = False
if not metrics.motor_stopped.is_set(): if not metrics.motor_stopped.is_set():
metrics.motor_stopped.set() metrics.motor_stopped.set()
@@ -1078,7 +990,7 @@ class OnvifController:
metrics.stop_time.value = metrics.frame_time.value metrics.stop_time.value = metrics.frame_time.value
else: else:
self.cams[camera_name]["active"] = True cam["active"] = True
if metrics.motor_stopped.is_set(): if metrics.motor_stopped.is_set():
metrics.motor_stopped.clear() metrics.motor_stopped.clear()
@@ -1094,8 +1006,8 @@ class OnvifController:
metrics.zoom_level.value = numpy.interp( metrics.zoom_level.value = numpy.interp(
round(status.Position.Zoom.x, 2), round(status.Position.Zoom.x, 2),
[ [
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"], cam["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"], cam["absolute_zoom_range"]["XRange"]["Max"],
], ],
[0, 1], [0, 1],
) )
@@ -1134,7 +1046,7 @@ class OnvifController:
def close(self) -> None: def close(self) -> None:
"""Gracefully shut down the ONVIF controller.""" """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") logger.debug("ONVIF controller already closed")
return return
@@ -1151,13 +1063,6 @@ class OnvifController:
self.config_subscriber.stop() self.config_subscriber.stop()
def stop_and_cleanup(): self.loop.call_soon_threadsafe(self.loop.stop)
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_thread.join() self.loop_thread.join()
+91 -23
View File
@@ -42,6 +42,8 @@ from frigate.util.ownership import chown_to_runtime
from frigate.util.recording_coverage import ( from frigate.util.recording_coverage import (
build_spans, build_spans,
known_video_codecs, known_video_codecs,
null_audio_glitches,
realized_timeline,
resolve_coverage, resolve_coverage,
stream_media_summary, stream_media_summary,
) )
@@ -84,10 +86,20 @@ class StreamRun:
@dataclass @dataclass
class _ChapterWindow: class _ChapterWindow:
"""A merged-timeline slice, shaped like the recording rows chapters read.""" """A merged-timeline slice, shaped like the recording rows chapters read.
lead_in is the output time the slice's vod clip plays before start_time,
from snapping its first frame back to a keyframe.
"""
start_time: float start_time: float
end_time: float end_time: float
lead_in: float = 0.0
def _lead_in(recording: Any) -> float:
"""Output seconds a chapter source plays before its first wall second."""
return recording.lead_in if isinstance(recording, _ChapterWindow) else 0.0
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS). # Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
@@ -376,15 +388,18 @@ class RecordingExporter(threading.Thread):
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]: def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
"""Resolve the export range into the spans the VOD manifest will serve. """Resolve the export range into the spans the VOD manifest will serve.
Delegates to the same coverage resolution the manifest builder Delegates to the same coverage resolution and glitch nulling the
uses, so what we plan around and what nginx-vod emits agree by manifest builder uses, so what we plan around and what nginx-vod
construction. Returns the spans (each [row, start, end, is_main]), emits agree by construction. Returns the spans (each [row, start,
the known video codecs, and whether audio survives the range. end, is_main]), the known video codecs, and whether audio survives
the range.
Memoized: several stages of the export ask the same question, and Memoized: several stages of the export ask the same question, and
the recordings backing a finished range do not change under us. the recordings backing a finished range do not change under us.
""" """
if self._coverage is None: if self._coverage is None:
intervals = resolve_coverage(self.camera, self.start_time, self.end_time) intervals = null_audio_glitches(
resolve_coverage(self.camera, self.start_time, self.end_time)
)
self._coverage = ( self._coverage = (
build_spans(intervals, self.pinned_stream), build_spans(intervals, self.pinned_stream),
known_video_codecs(intervals), known_video_codecs(intervals),
@@ -433,17 +448,57 @@ class RecordingExporter(threading.Thread):
# hand-off to stage around # hand-off to stage around
return True return True
spans, codecs, keep_audio = self._resolve_coverage() _spans, codecs, keep_audio = self._resolve_coverage()
runs = self._stream_runs(spans) runs = self._planned_stream_runs()
# a range one stream covers end to end has nothing to hand off, # a range one stream covers end to end has nothing to hand off,
# so it stays on the existing path however long it is # so it stays on the existing path however long it is
if len(runs) < 2: if len(runs) < 2:
return True return True
runs = [piece for run in runs for piece in self._split_long_run(run)]
return self._stage_stream_runs(runs, codecs, keep_audio) return self._stage_stream_runs(runs, codecs, keep_audio)
def _planned_stream_runs(self) -> list[StreamRun]:
"""The runs a mixed range is staged as, one pinned vod playlist each."""
runs = self._stream_runs(self._merged_spans())
if len(runs) < 2:
return runs
return [piece for run in runs for piece in self._split_long_run(run)]
def _staged_chapter_windows(self) -> list[_ChapterWindow]:
"""Chapter windows for the staged files as they were rendered.
Each staged run comes from its own pinned vod playlist, whose first
clip snaps back to the preceding keyframe, so a staged file runs up
to a GOP longer than its slice of the merged timeline. Planning each
run the way its playlist does carries that lead-in into the chapter
offsets instead of letting it accumulate at every hand-off.
"""
windows: list[_ChapterWindow] = []
for run in self._planned_stream_runs():
intervals = null_audio_glitches(
resolve_coverage(self.camera, run.start_time, run.end_time)
)
for clip in realized_timeline(intervals, run.stream_type):
# a skipped clip is absent from the playlist and the file
if clip["duration"] <= 0:
continue
span = clip["end_time"] - clip["start_time"]
windows.append(
_ChapterWindow(
clip["start_time"],
clip["end_time"],
max(0.0, clip["duration"] / 1000 - span),
)
)
return windows
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]: def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
"""Collapse the merged spans into contiguous runs of one stream type. """Collapse the merged spans into contiguous runs of one stream type.
@@ -840,6 +895,8 @@ class RecordingExporter(threading.Thread):
clipped_end = min(float(rec.end_time), float(self.end_time)) clipped_end = min(float(rec.end_time), float(self.end_time))
if clipped_end <= clipped_start: if clipped_end <= clipped_start:
continue continue
# a staged window's keyframe lead-in plays before it
output_offset += _lead_in(rec)
windows.append((clipped_start, clipped_end, output_offset)) windows.append((clipped_start, clipped_end, output_offset))
output_offset += clipped_end - clipped_start output_offset += clipped_end - clipped_start
@@ -987,9 +1044,13 @@ class RecordingExporter(threading.Thread):
if duration_ms <= 0: if duration_ms <= 0:
continue continue
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat( # a staged window's keyframe lead-in opens its chapter, with
timespec="seconds" # frames captured that long before the window
) lead_in = _lead_in(rec)
duration_ms += int(round(lead_in * 1000))
title = datetime.datetime.fromtimestamp(
clipped_start - lead_in, tz=tz
).isoformat(timespec="seconds")
chapter_blocks.append( chapter_blocks.append(
"[CHAPTER]\n" "[CHAPTER]\n"
"TIMEBASE=1/1000\n" "TIMEBASE=1/1000\n"
@@ -1128,12 +1189,12 @@ class RecordingExporter(threading.Thread):
if self.staged_runs: if self.staged_runs:
# each run was already rendered to a temp file with a common # each run was already rendered to a temp file with a common
# track timescale, so the concat demuxer has nothing left to # track timescale, so the concat demuxer has nothing left to
# reconcile and every chapter offset lines up with the merged # reconcile
# timeline the staged files reproduce recordings = (
recordings = [ self._staged_chapter_windows()
_ChapterWindow(span_start, span_end) if self.chapters not in (None, ChaptersEnum.none)
for _row, span_start, span_end, _is_main in self._merged_spans() else []
] )
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs] playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
ffmpeg_input = ( ffmpeg_input = (
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin" "-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
@@ -1149,11 +1210,18 @@ class RecordingExporter(threading.Thread):
# its own rows are the ones the chapters describe # its own rows are the ones the chapters describe
recordings = self._get_recordings_for_range(pin) recordings = self._get_recordings_for_range(pin)
else: else:
# never mix streams in one playlist; use main when available # an unstaged auto range resolves to at most one stream run, and
# and fall back to sub for expired-main history # its rows are the ones the chapters describe. Main rows the
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN) # manifest drops (glitches, slivers at the edges of a sub range)
# must not stand in for it.
runs = self._stream_runs(self._merged_spans())
recordings = self._get_recordings_for_range(
runs[0].stream_type if runs else STREAM_TYPE_MAIN
)
if not recordings: # never mix streams in one playlist; fall back to sub for
# expired-main history
if not recordings and not runs:
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB) recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
playlist_lines = [] playlist_lines = []
@@ -1307,7 +1375,7 @@ class RecordingExporter(threading.Thread):
if preview.end_time > self.end_time: if preview.end_time > self.end_time:
playlist_lines.append( playlist_lines.append(
f"outpoint {int(preview.end_time - self.end_time)}" f"outpoint {int(self.end_time - preview.start_time)}"
) )
ffmpeg_input = ( ffmpeg_input = (
+58 -23
View File
@@ -490,9 +490,17 @@ class RecordingMaintainer(threading.Thread):
) )
reviews = reviews_by_camera[camera] reviews = reviews_by_camera[camera]
tasks.extend( # probes run concurrently, but each segment's start chains off the
[self.validate_and_move_segment(camera, reviews, r) for r in recordings] # previous segment's end, so starts resolve in segment order
previous: asyncio.Event | None = None
for recording in recordings:
resolved = asyncio.Event()
tasks.append(
self._validate_in_order(
camera, reviews, recording, previous, resolved
) )
)
previous = resolved
# publish most recently available recording time and None if disabled # publish most recently available recording time and None if disabled
if stream_type == STREAM_TYPE_MAIN: if stream_type == STREAM_TYPE_MAIN:
@@ -550,12 +558,33 @@ class RecordingMaintainer(threading.Thread):
while info and info[0][0] < expire_before: while info and info[0][0] < expire_before:
info.pop(0) info.pop(0)
async def _validate_in_order(
self,
camera: str,
reviews: Any,
recording: dict[str, Any],
previous_start: asyncio.Event | None,
start_resolved: asyncio.Event,
) -> dict[str, Any] | None:
"""Validate a segment, always releasing the next one in its stream."""
try:
return await self.validate_and_move_segment(
camera, reviews, recording, previous_start, start_resolved
)
finally:
start_resolved.set()
def drop_segment(self, cache_path: str) -> None: def drop_segment(self, cache_path: str) -> None:
Path(cache_path).unlink(missing_ok=True) Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None) self.end_time_cache.pop(cache_path, None)
async def validate_and_move_segment( async def validate_and_move_segment(
self, camera: str, reviews: Any, recording: dict[str, Any] self,
camera: str,
reviews: Any,
recording: dict[str, Any],
previous_start: asyncio.Event | None = None,
start_resolved: asyncio.Event | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
cache_path: str = recording["cache_path"] cache_path: str = recording["cache_path"]
start_time: datetime.datetime = recording["start_time"] start_time: datetime.datetime = recording["start_time"]
@@ -617,6 +646,9 @@ class RecordingMaintainer(threading.Thread):
async with self.probe_semaphore: async with self.probe_semaphore:
keyframes = await get_keyframe_offsets(cache_path) keyframes = await get_keyframe_offsets(cache_path)
if previous_start is not None:
await previous_start.wait()
start_time = self._resolve_segment_start( start_time = self._resolve_segment_start(
camera, stream_type, start_time, duration, cache_path camera, stream_type, start_time, duration, cache_path
) )
@@ -654,6 +686,27 @@ class RecordingMaintainer(threading.Thread):
RecordingsDataTypeEnum.valid.value, RecordingsDataTypeEnum.valid.value,
) )
# the start is settled, so the next segment of the stream can chain
# off it while this one waits on retention and the move
if start_resolved is not None:
start_resolved.set()
# assume that empty means the relevant recording info has not been received yet
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
)
# ensure delayed segment info does not lead to lost segments, every
# retention decision below depends on complete stats for the segment
if (
datetime.datetime.fromtimestamp(
most_recently_processed_frame_time
).astimezone(datetime.UTC)
< end_time
):
return None
record_config = self.config.cameras[camera].record record_config = self.config.cameras[camera].record
# sub's alerts/detections carry the retain mode directly, unlike # sub's alerts/detections carry the retain mode directly, unlike
@@ -681,23 +734,8 @@ class RecordingMaintainer(threading.Thread):
# we should first just check if this segment matches that # we should first just check if this segment matches that
# and avoid any DB calls # and avoid any DB calls
if highest is not None: if highest is not None:
# assume that empty means the relevant recording info has not been received yet
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
)
# ensure delayed segment info does not lead to lost segments
if (
datetime.datetime.fromtimestamp(
most_recently_processed_frame_time
).astimezone(datetime.UTC)
>= end_time
):
record_mode = ( record_mode = (
RetainModeEnum.all RetainModeEnum.all if highest == "continuous" else RetainModeEnum.motion
if highest == "continuous"
else RetainModeEnum.motion
) )
segment_stats = self.segment_stats(camera, start_time, end_time) segment_stats = self.segment_stats(camera, start_time, end_time)
@@ -779,10 +817,6 @@ class RecordingMaintainer(threading.Thread):
# continuous/motion retention (either disabled or segment_stats said # continuous/motion retention (either disabled or segment_stats said
# discard), so waiting longer just fills the cache. # discard), so waiting longer just fills the cache.
else: else:
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
)
retain_cutoff = datetime.datetime.fromtimestamp( retain_cutoff = datetime.datetime.fromtimestamp(
most_recently_processed_frame_time - record_config.event_pre_capture most_recently_processed_frame_time - record_config.event_pre_capture
).astimezone(datetime.UTC) ).astimezone(datetime.UTC)
@@ -1090,6 +1124,7 @@ class RecordingMaintainer(threading.Thread):
elif ( elif (
topic == DetectionTypeEnum.api.value topic == DetectionTypeEnum.api.value
or topic == DetectionTypeEnum.lpr.value or topic == DetectionTypeEnum.lpr.value
or topic == DetectionTypeEnum.classification_state.value
): ):
continue continue
+187 -50
View File
@@ -40,6 +40,10 @@ logger = logging.getLogger(__name__)
THUMB_HEIGHT = 180 THUMB_HEIGHT = 180
THUMB_WIDTH = 320 THUMB_WIDTH = 320
# seconds before a review item starts that a state classification change is
# still attached to it, e.g. a garage door opening before the car is visible
CLASSIFICATION_STATE_PRE_ROLL = 5
class PendingReviewSegment: class PendingReviewSegment:
def __init__( def __init__(
@@ -61,6 +65,10 @@ class PendingReviewSegment:
self.sub_labels = sub_labels self.sub_labels = sub_labels
self.zones = zones self.zones = zones
self.audio = audio self.audio = audio
self.classification_state_changes: list[dict[str, Any]] = []
# detection-level activity after the last alert activity, split by the
# detection cutoff, these are published when the alert is cut off
self.pending_detections: list[PendingReviewSegment] = []
self.thumb_time: float | None = None self.thumb_time: float | None = None
self.last_alert_time: float | None = None self.last_alert_time: float | None = None
self.last_detection_time: float = frame_time self.last_detection_time: float = frame_time
@@ -78,6 +86,20 @@ class PendingReviewSegment:
CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.webp" CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.webp"
) )
def add_object(self, obj: dict[str, Any], attributes: list[str]) -> None:
"""Add a tracked object's label, sub label, and zones to the segment."""
if not obj["sub_label"]:
self.detections[obj["id"]] = obj["label"]
elif obj["sub_label"][0] in attributes:
self.detections[obj["id"]] = obj["sub_label"][0]
else:
self.detections[obj["id"]] = f"{obj['label']}-verified"
self.sub_labels[obj["id"]] = obj["sub_label"][0]
for zone in obj["current_zones"]:
if zone not in self.zones:
self.zones.append(zone)
def update_frame( def update_frame(
self, self,
camera_config: CameraConfig, camera_config: CameraConfig,
@@ -162,6 +184,7 @@ class PendingReviewSegment:
"sub_labels": list(self.sub_labels.values()), "sub_labels": list(self.sub_labels.values()),
"zones": self.zones, "zones": self.zones,
"audio": list(self.audio), "audio": list(self.audio),
"classification_state_changes": self.classification_state_changes,
"thumb_time": self.thumb_time, "thumb_time": self.thumb_time,
"metadata": None, "metadata": None,
}, },
@@ -293,6 +316,9 @@ class ReviewSegmentMaintainer(threading.Thread):
# manual events # manual events
self.indefinite_events: dict[str, dict[str, Any]] = {} self.indefinite_events: dict[str, dict[str, Any]] = {}
# state classification changes seen while a camera had no review item
self.recent_classification_state_changes: dict[str, list[dict[str, Any]]] = {}
# ensure dirs # ensure dirs
Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True) Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True)
@@ -374,6 +400,43 @@ class ReviewSegmentMaintainer(threading.Thread):
self.active_review_segments[segment.camera] = None self.active_review_segments[segment.camera] = None
return end_time return end_time
def _activate_segment(self, segment: PendingReviewSegment) -> None:
"""Make a segment the camera's active one, attaching any state
classification changes seen just before it started."""
self.active_review_segments[segment.camera] = segment
recent = self.recent_classification_state_changes.pop(segment.camera, [])
segment.classification_state_changes.extend(
c
for c in recent
if c["timestamp"] >= segment.start_time - CLASSIFICATION_STATE_PRE_ROLL
)
def handle_classification_state_change(
self, camera: str, change: dict[str, Any]
) -> None:
"""Attach a verified state classification change to the active segment.
State changes never start, extend, or upgrade a segment. A change seen
with no active segment is held briefly for a segment starting right
after it.
"""
segment = self.active_review_segments.get(camera)
if segment is None:
cutoff = change["timestamp"] - CLASSIFICATION_STATE_PRE_ROLL
self.recent_classification_state_changes[camera] = [
c
for c in self.recent_classification_state_changes.get(camera, [])
if c["timestamp"] >= cutoff
] + [change]
return
prev_data = segment.get_data(False)
segment.classification_state_changes.append(change)
self._publish_segment_update(
segment, self.config.cameras[camera], None, [], prev_data
)
def forcibly_end_segment(self, camera: str) -> Any: def forcibly_end_segment(self, camera: str) -> Any:
"""Forcibly end the pending segment for a camera.""" """Forcibly end the pending segment for a camera."""
segment = self.active_review_segments.get(camera) segment = self.active_review_segments.get(camera)
@@ -389,9 +452,29 @@ class ReviewSegmentMaintainer(threading.Thread):
segment.last_detection_time = now segment.last_detection_time = now
prev_data = segment.get_data(False) prev_data = segment.get_data(False)
return self._publish_segment_end(segment, prev_data) end_time = self._publish_segment_end(segment, prev_data)
self._publish_pending_detections(segment, None)
return end_time
return None return None
def _publish_pending_detections(
self, segment: PendingReviewSegment, ongoing_since: float | None
) -> None:
"""Publish the detections held while an ended alert was active.
A detection with activity after ongoing_since stays open, only the
latest can. With None every detection is ended, this does not read the
camera config since a removed camera is no longer in it.
"""
for pending in segment.pending_detections:
self._activate_segment(pending)
self._publish_segment_start(pending)
if ongoing_since is None or pending.last_detection_time < ongoing_since:
self._publish_segment_end(pending, pending.get_data(False))
segment.pending_detections = []
def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None: def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None:
"""Determine the review severity for a manual event label. """Determine the review severity for a manual event label.
@@ -422,6 +505,56 @@ class ReviewSegmentMaintainer(threading.Thread):
"""Close out a deleted camera's segment so a reused name cannot inherit it.""" """Close out a deleted camera's segment so a reused name cannot inherit it."""
self.forcibly_end_segment(camera) self.forcibly_end_segment(camera)
self.indefinite_events.pop(camera, None) self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None)
def _track_pending_detection(
self,
segment: PendingReviewSegment,
camera_config: CameraConfig,
frame_name: str,
frame_time: float,
objects: list[dict[str, Any]],
) -> None:
"""Hold detection-level activity seen after an alert's last alert
activity, starting a separate detection when the gap since the previous
activity exceeds the detection cutoff."""
pending = segment.pending_detections[-1] if segment.pending_detections else None
if pending is None or frame_time > (
pending.last_detection_time + camera_config.review.detections.cutoff_time
):
pending = PendingReviewSegment(
segment.camera,
frame_time,
SeverityEnum.detection,
{},
sub_labels={},
audio=set(),
zones=[],
)
segment.pending_detections.append(pending)
pending.last_detection_time = frame_time
for obj in objects:
pending.add_object(obj, self.config.all_attributes)
if len(objects) <= pending.frame_active_count:
return
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
)
except FileNotFoundError:
return
if yuv_frame is None:
logger.debug(f"Failed to get frame {frame_name} from SHM")
return
pending.update_frame(camera_config, yuv_frame, objects)
self.frame_manager.close(frame_name)
def update_existing_segment( def update_existing_segment(
self, self,
@@ -458,6 +591,21 @@ class ReviewSegmentMaintainer(threading.Thread):
should_update_state = True should_update_state = True
should_update_image = True should_update_image = True
# alert activity resumed, so the pending detection activity
# falls within this alert
for pending in segment.pending_detections:
segment.detections.update(pending.detections)
segment.sub_labels.update(pending.sub_labels)
for zone in pending.zones:
if zone not in segment.zones:
segment.zones.append(zone)
Path(pending.frame_path).unlink(missing_ok=True)
should_update_state = True
segment.pending_detections = []
if activity.has_activity_category(SeverityEnum.detection): if activity.has_activity_category(SeverityEnum.detection):
if ( if (
segment.last_detection_time is None segment.last_detection_time is None
@@ -465,6 +613,8 @@ class ReviewSegmentMaintainer(threading.Thread):
): ):
segment.last_detection_time = frame_time segment.last_detection_time = frame_time
pending_objects: list[dict[str, Any]] = []
for object in activity.get_all_objects(): for object in activity.get_all_objects():
# Alert-level objects should always be added (they extend/upgrade the segment) # Alert-level objects should always be added (they extend/upgrade the segment)
# Detection-level objects should only be added if: # Detection-level objects should only be added if:
@@ -475,23 +625,22 @@ class ReviewSegmentMaintainer(threading.Thread):
if not is_alert_object and segment.severity == SeverityEnum.alert: if not is_alert_object and segment.severity == SeverityEnum.alert:
# This is a detection-level object # This is a detection-level object
if (
segment.last_alert_time is not None
and frame_time > segment.last_alert_time
):
pending_objects.append(object)
# Only add if it started during the alert's active period # Only add if it started during the alert's active period
if object["start_time"] > segment.last_alert_time: if object["start_time"] > segment.last_alert_time:
continue continue
if not object["sub_label"]: segment.add_object(object, self.config.all_attributes)
segment.detections[object["id"]] = object["label"]
elif object["sub_label"][0] in self.config.all_attributes:
segment.detections[object["id"]] = object["sub_label"][0]
else:
segment.detections[object["id"]] = f"{object['label']}-verified"
segment.sub_labels[object["id"]] = object["sub_label"][0]
# keep zones up to date if pending_objects:
if len(object["current_zones"]) > 0: self._track_pending_detection(
for zone in object["current_zones"]: segment, camera_config, frame_name, frame_time, pending_objects
if zone not in segment.zones: )
segment.zones.append(zone)
if len(activity.get_all_objects()) > segment.frame_active_count: if len(activity.get_all_objects()) > segment.frame_active_count:
should_update_state = True should_update_state = True
@@ -543,48 +692,26 @@ class ReviewSegmentMaintainer(threading.Thread):
except FileNotFoundError: except FileNotFoundError:
return return
# detection-level activity must not keep an alert open, it continues
# in a new detection segment once the alert is cut off
if ( if (
segment.severity == SeverityEnum.alert segment.severity == SeverityEnum.alert
and segment.last_alert_time is not None and segment.last_alert_time is not None
and frame_time and frame_time
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time) > (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
): ):
needs_new_detection = ( self._publish_segment_end(segment, prev_data)
segment.last_detection_time > segment.last_alert_time self._publish_pending_detections(
and ( segment, frame_time - camera_config.review.detections.cutoff_time
)
elif (
not has_activity
and segment.severity == SeverityEnum.detection
and frame_time
> (
segment.last_detection_time segment.last_detection_time
+ camera_config.review.detections.cutoff_time + camera_config.review.detections.cutoff_time
) )
> frame_time
)
last_detection_time = segment.last_detection_time
end_time = self._publish_segment_end(segment, prev_data)
if needs_new_detection:
new_detections: dict[str, str] = {}
new_zones = set()
for o in activity.categorized_objects["detections"]:
new_detections[o["id"]] = o["label"]
new_zones.update(o["current_zones"])
if new_detections:
new_segment = PendingReviewSegment(
segment.camera,
end_time,
SeverityEnum.detection,
new_detections,
sub_labels={},
audio=set(),
zones=list(new_zones),
)
self.active_review_segments[segment.camera] = new_segment
self._publish_segment_start(new_segment)
new_segment.last_detection_time = last_detection_time
elif segment.severity == SeverityEnum.detection and frame_time > (
segment.last_detection_time
+ camera_config.review.detections.cutoff_time
): ):
self._publish_segment_end(segment, prev_data) self._publish_segment_end(segment, prev_data)
@@ -639,7 +766,7 @@ class ReviewSegmentMaintainer(threading.Thread):
audio=set(), audio=set(),
zones=zones, zones=zones,
) )
self.active_review_segments[camera] = new_segment self._activate_segment(new_segment)
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
@@ -713,6 +840,10 @@ class ReviewSegmentMaintainer(threading.Thread):
if camera not in self.indefinite_events: if camera not in self.indefinite_events:
self.indefinite_events[camera] = {} self.indefinite_events[camera] = {}
elif topic == DetectionTypeEnum.classification_state.value:
(camera, classification_change) = data
else:
continue
if camera not in self.config.cameras: if camera not in self.config.cameras:
continue continue
@@ -723,6 +854,10 @@ class ReviewSegmentMaintainer(threading.Thread):
): ):
continue continue
if topic == DetectionTypeEnum.classification_state:
self.handle_classification_state_change(camera, classification_change)
continue
current_segment = self.active_review_segments.get(camera) current_segment = self.active_review_segments.get(camera)
# Check if the current segment should be processed based on enabled settings # Check if the current segment should be processed based on enabled settings
@@ -864,7 +999,8 @@ class ReviewSegmentMaintainer(threading.Thread):
severity = SeverityEnum.detection severity = SeverityEnum.detection
if severity: if severity:
self.active_review_segments[camera] = PendingReviewSegment( self._activate_segment(
PendingReviewSegment(
camera, camera,
frame_time, frame_time,
severity, severity,
@@ -873,6 +1009,7 @@ class ReviewSegmentMaintainer(threading.Thread):
[], [],
detections, detections,
) )
)
elif topic == DetectionTypeEnum.api: elif topic == DetectionTypeEnum.api:
severity = self.get_manual_event_severity( severity = self.get_manual_event_severity(
camera, manual_info["label"] camera, manual_info["label"]
@@ -888,7 +1025,7 @@ class ReviewSegmentMaintainer(threading.Thread):
[], [],
set(), set(),
) )
self.active_review_segments[camera] = api_segment self._activate_segment(api_segment)
if manual_info["state"] == ManualEventState.start: if manual_info["state"] == ManualEventState.start:
self.indefinite_events[camera][manual_info["event_id"]] = ( self.indefinite_events[camera][manual_info["event_id"]] = (
@@ -915,7 +1052,7 @@ class ReviewSegmentMaintainer(threading.Thread):
[], [],
set(), set(),
) )
self.active_review_segments[camera] = lpr_segment self._activate_segment(lpr_segment)
if manual_info["state"] == ManualEventState.start: if manual_info["state"] == ManualEventState.start:
self.indefinite_events[camera][manual_info["event_id"]] = ( self.indefinite_events[camera][manual_info["event_id"]] = (
+11
View File
@@ -256,6 +256,17 @@ class HardwareStats:
) )
self.update_config() self.update_config()
def set_config(self, config: FrigateConfig) -> None:
"""Follow a runtime config swap and recalculate the monitored hardware.
The camera update subscriber has to follow too, or later camera updates
would land on the discarded config.
"""
self.config = config
self._config_subscriber.config = config
self._config_subscriber.camera_configs = config.cameras
self.update_config()
def update_config(self) -> None: def update_config(self) -> None:
"""Recalculate all hardware that needs to be monitored from the config.""" """Recalculate all hardware that needs to be monitored from the config."""
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments() names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
+29 -9
View File
@@ -111,15 +111,18 @@ def get_detector_stats(
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
"""Get stats for all detectors, including temperatures based on detector type.""" """Get stats for all detectors, including temperatures based on detector type."""
detector_stats: dict[str, dict[str, Any]] = {} detector_stats: dict[str, dict[str, Any]] = {}
detector_type_indices: dict[str, int] = {} # detector type -> device -> index into that type's temperatures
device_indices: dict[str, dict[str, int]] = {}
for name, detector in stats_tracking["detectors"].items(): for name, detector in stats_tracking["detectors"].items():
pid = detector.detect_process.pid if detector.detect_process else None pid = detector.detect_process.pid if detector.detect_process else None
detector_type = detector.detector_config.type detector_type = detector.detector_config.type
# Keep track of the index for each detector type to match temperatures correctly # temperatures are per physical unit, so a repeated device
current_index = detector_type_indices.get(detector_type, 0) # ("hailo:PCIe#2", see runner_names) shares its unit's reading
detector_type_indices[detector_type] = current_index + 1 device = name.partition("#")[0]
type_devices = device_indices.setdefault(detector_type, {})
current_index = type_devices.setdefault(device, len(type_devices))
detector_stat = { detector_stat = {
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined] "inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
@@ -233,6 +236,15 @@ def skipped_percent(skipped_fps: float, camera_fps: float, enabled: bool) -> flo
return round(skipped_fps / camera_fps * 100, 1) 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( def stats_snapshot(
config: FrigateConfig, config: FrigateConfig,
stats_tracking: StatsTrackingTypes, stats_tracking: StatsTrackingTypes,
@@ -245,8 +257,9 @@ def stats_snapshot(
total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0 total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0
stats["cameras"] = {} stats["cameras"] = {}
for name, camera_stats in camera_metrics.items(): for name, camera_stats in list(camera_metrics.items()):
if name not in config.cameras: camera_config = config.cameras.get(name)
if camera_config is None:
continue continue
total_camera_fps += camera_stats.camera_fps.value total_camera_fps += camera_stats.camera_fps.value
@@ -263,7 +276,7 @@ def stats_snapshot(
# Calculate connection quality based on current state # Calculate connection quality based on current state
# This is computed at stats-collection time so offline cameras # This is computed at stats-collection time so offline cameras
# correctly show as unusable rather than excellent # correctly show as unusable rather than excellent
expected_fps = config.cameras[name].detect.fps expected_fps = camera_config.detect.fps
current_fps = camera_stats.camera_fps.value current_fps = camera_stats.camera_fps.value
reconnects = camera_stats.reconnects_last_hour.value reconnects = camera_stats.reconnects_last_hour.value
stalls = camera_stats.stalls_last_hour.value stalls = camera_stats.stalls_last_hour.value
@@ -296,7 +309,7 @@ def stats_snapshot(
config.cameras[name].enabled, config.cameras[name].enabled,
), ),
"detection_fps": round(camera_stats.detection_fps.value, 2), "detection_fps": round(camera_stats.detection_fps.value, 2),
"detection_enabled": config.cameras[name].detect.enabled, "detection_enabled": camera_config.detect.enabled,
"pid": pid, "pid": pid,
"capture_pid": capture_pid, "capture_pid": capture_pid,
"ffmpeg_pid": ffmpeg_pid, "ffmpeg_pid": ffmpeg_pid,
@@ -352,6 +365,14 @@ def stats_snapshot(
stats["service"]["storage"]["/dev/shm"] = calculate_shm_requirements(config) 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"] = {} stats["processes"] = {}
for name, pid in stats_tracking["processes"].items(): for name, pid in stats_tracking["processes"].items():
stats["processes"][name] = { stats["processes"][name] = {
@@ -360,7 +381,6 @@ def stats_snapshot(
# Embed cpu/mem stats into detectors, cameras, and processes # Embed cpu/mem stats into detectors, cameras, and processes
# so history consumers don't need the full cpu_usages dict # so history consumers don't need the full cpu_usages dict
cpu_usages = stats.get("cpu_usages", {})
for det_stats in stats["detectors"].values(): for det_stats in stats["detectors"].values():
pid_str = str(det_stats.get("pid", "")) pid_str = str(det_stats.get("pid", ""))
+6 -2
View File
@@ -145,7 +145,11 @@ class BaseTestHttp(unittest.TestCase):
pass pass
def create_app( def create_app(
self, stats=None, event_metadata_publisher=None, notice_registry=None self,
stats=None,
event_metadata_publisher=None,
notice_registry=None,
enforce_default_admin=False,
): ):
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
@@ -160,7 +164,7 @@ class BaseTestHttp(unittest.TestCase):
event_metadata_publisher, event_metadata_publisher,
None, None,
DebugReplayManager(), DebugReplayManager(),
enforce_default_admin=False, enforce_default_admin=enforce_default_admin,
notice_registry=notice_registry, notice_registry=notice_registry,
) )
+85 -1
View File
@@ -1,8 +1,11 @@
import json
import os
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import frigate.genai import frigate.genai
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.const import REDACTED_CREDENTIAL_SENTINEL 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.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
@@ -47,6 +50,25 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["front_door"]["usage_percent"] == 25.0 assert response.json()["front_door"]["usage_percent"] == 25.0
def test_camera_name_collision_keeps_admin_default(self):
self.minimal_config["cameras"]["faces"] = self.minimal_config["cameras"].pop(
"front_door"
)
app = super().create_app(enforce_default_admin=True)
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
with AuthTestClient(app) as client:
assert client.get("/faces", headers=viewer).status_code == 403
assert client.get("/faces").status_code == 200
assert (
client.post("/faces/train/person/classify", headers=viewer).status_code
== 403
)
# Camera routes for the same name stay reachable by viewers
response = client.get("/faces/recordings/summary", headers=viewer)
assert response.status_code == 200
def test_config_set_in_memory_replaces_objects_track_list(self): def test_config_set_in_memory_replaces_objects_track_list(self):
self.minimal_config["cameras"]["front_door"]["objects"] = { self.minimal_config["cameras"]["front_door"]["objects"] = {
"track": ["person", "car"], "track": ["person", "car"],
@@ -90,6 +112,68 @@ class TestHttpApp(BaseTestHttp):
mqtt = response.json()["mqtt"] mqtt = response.json()["mqtt"]
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL 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)
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
with open(model_path, "w") as f:
f.write("model")
with open(f"{model_path}.json", "w") as f:
json.dump(
{
"id": model_id,
"type": "ssd",
"supportedDetectors": ["cpu"],
"width": 320,
"height": 320,
"inputShape": "nhwc",
"pixelFormat": "rgb",
"labelMap": {"0": "person"},
},
f,
)
self.addCleanup(os.remove, model_path)
self.addCleanup(os.remove, f"{model_path}.json")
self.minimal_config["models"] = [
{"path": f"plus://{model_id}", "devices": ["cpu"]}
]
app = super().create_app()
with AuthTestClient(app) as client:
response = client.get("/config")
assert response.status_code == 200
assert response.json()["models"][0]["path"] == f"plus://{model_id}"
# detection still loads the resolved cache file
assert app.frigate_config.models[0].path == model_path
#################################################################################################################### ####################################################################################################################
################################### POST /genai/probe Endpoint ################################################## ################################### POST /genai/probe Endpoint ##################################################
#################################################################################################################### ####################################################################################################################
@@ -0,0 +1,173 @@
"""Tests for config_set live stream ordering and go2rtc transcode sync."""
import os
import tempfile
from unittest.mock import MagicMock, Mock, patch
import ruamel.yaml
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
from frigate.util.live_streams import transcode_stream_source
STREAMS_PATH = "cameras.front_door.live.streams"
class TestConfigSetLiveStreams(BaseTestHttp):
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config["go2rtc"] = {
"streams": {
"front_main": ["rtsp://10.0.0.1:554/main"],
"front_sub": ["rtsp://10.0.0.1:554/sub"],
}
}
self.minimal_config["cameras"]["front_door"]["live"] = {
"streams": {"Main": "front_main", "Sub": "front_sub"}
}
def _write_config_file(self) -> str:
yaml = ruamel.yaml.YAML()
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
yaml.dump(self.minimal_config, f)
f.close()
self.addCleanup(os.unlink, f.name)
return f.name
def _read_config_file(self, path: str) -> dict:
with open(path) as f:
return ruamel.yaml.YAML(typ="safe").load(f)
def _app(self):
publisher = Mock(spec=CameraConfigUpdatePublisher)
publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
publisher,
None,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
return {
"username": request.headers.get("remote-user"),
"role": request.headers.get("remote-role"),
}
async def mock_get_allowed_cameras_for_filter(request: Request):
return ["front_door"]
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app
def _save(self, app, live: dict, replace_paths: list[str] | None = None):
body = {
"config_data": {"cameras": {"front_door": {"live": live}}},
"requires_restart": 0,
"update_topic": "config/cameras/front_door/live",
}
if replace_paths is not None:
body["replace_paths"] = replace_paths
with AuthTestClient(app) as client:
return client.put("/config/set", json=body)
@patch("frigate.api.app.find_config_file")
def test_replace_paths_saves_map_in_sent_order(self, mock_find_config):
path = self._write_config_file()
mock_find_config.return_value = path
resp = self._save(
self._app(),
{"streams": {"Sub": "front_sub", "Main": "front_main"}},
[STREAMS_PATH],
)
self.assertEqual(resp.status_code, 200)
streams = self._read_config_file(path)["cameras"]["front_door"]["live"][
"streams"
]
self.assertEqual(list(streams), ["Sub", "Main"])
@patch("frigate.api.app.find_config_file")
def test_without_replace_paths_order_is_kept(self, mock_find_config):
path = self._write_config_file()
mock_find_config.return_value = path
self._save(self._app(), {"streams": {"Sub": "front_sub", "Main": "front_main"}})
streams = self._read_config_file(path)["cameras"]["front_door"]["live"][
"streams"
]
self.assertEqual(list(streams), ["Main", "Sub"])
@patch("frigate.api.app.find_config_file")
def test_replace_path_missing_from_yaml_is_skipped(self, mock_find_config):
del self.minimal_config["cameras"]["front_door"]["live"]
path = self._write_config_file()
mock_find_config.return_value = path
resp = self._save(
self._app(), {"streams": {"Main": "front_main"}}, [STREAMS_PATH]
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(
self._read_config_file(path)["cameras"]["front_door"]["live"]["streams"],
{"Main": "front_main"},
)
@patch("frigate.api.app.sync_transcode_streams", return_value=True)
@patch("frigate.api.app.find_config_file")
def test_enabling_transcode_syncs_go2rtc(self, mock_find_config, mock_sync):
path = self._write_config_file()
mock_find_config.return_value = path
resp = self._save(
self._app(),
{
"transcode": {
"enabled": True,
"qualities": [{"height": 480, "bitrate": 500}],
}
},
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["go2rtc_synced"])
mock_sync.assert_called_once_with(
{},
{
"front_door_transcode_480p": transcode_stream_source(
"front_main", 480, 500
)
},
)
@patch("frigate.api.app.sync_transcode_streams", return_value=False)
@patch("frigate.api.app.find_config_file")
def test_sync_failure_is_reported(self, mock_find_config, mock_sync):
path = self._write_config_file()
mock_find_config.return_value = path
resp = self._save(self._app(), {"transcode": {"enabled": True}})
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["success"])
self.assertFalse(resp.json()["go2rtc_synced"])
+102
View File
@@ -168,6 +168,29 @@ class TestHttpApp(BaseTestHttp):
assert events[0]["id"] == id assert events[0]["id"] == id
assert events[1]["id"] == id2 assert events[1]["id"] == id2
def test_get_event_list_offset_pages_score_sort(self):
now = datetime.now().timestamp()
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
with AuthTestClient(self.app) as client:
for i, score in enumerate(scores):
super().insert_mock_event(
f"event-{i}", start_time=now + i, data={"score": score}
)
params = {"sort": "score_desc"}
full = [e["id"] for e in client.get("/events", params=params).json()]
paged = [
e["id"]
for offset in (0, 2, 4)
for e in client.get(
"/events", params={**params, "limit": 2, "offset": offset}
).json()
]
assert full == ["event-3", "event-1", "event-4", "event-2", "event-0"]
assert paged == full
def test_get_event_list_match_multilingual_attribute(self): def test_get_event_list_match_multilingual_attribute(self):
event_id = "123456.zh" event_id = "123456.zh"
attribute = "中文标签" attribute = "中文标签"
@@ -219,6 +242,85 @@ class TestHttpApp(BaseTestHttp):
assert len(events) == 1 assert len(events) == 1
assert events[0]["id"] == event_id assert events[0]["id"] == event_id
def test_events_search_offset_pages_score_sort(self):
now = datetime.now().timestamp()
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
ids = [f"event-{i}" for i in range(len(scores))]
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [
(event_id, 0.1 * i) for i, event_id in enumerate(ids)
]
self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings
with AuthTestClient(self.app) as client:
for i, score in enumerate(scores):
super().insert_mock_event(
ids[i], start_time=now + i, data={"score": score}
)
params = {
"search_type": "similarity",
"event_id": ids[0],
"sort": "score_desc",
}
paged = [
e["id"]
for offset in (0, 2, 4)
for e in client.get(
"/events/search",
params={**params, "limit": 2, "offset": offset},
).json()
]
assert paged == ["event-3", "event-1", "event-4", "event-2", "event-0"]
def test_events_search_offset_pages_orders_ties_by_id(self):
now = datetime.now().timestamp()
ids = ["event-c", "event-a", "event-b"]
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [
(event_id, 0.1) for event_id in ids
]
self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings
with AuthTestClient(self.app) as client:
for i, event_id in enumerate(ids):
super().insert_mock_event(
event_id, start_time=now + i, data={"score": 0.8}
)
for sort in ("score_desc", "relevance"):
params = {
"search_type": "similarity",
"event_id": ids[0],
"sort": sort,
}
paged = [
e["id"]
for offset in (0, 1, 2)
for e in client.get(
"/events/search",
params={**params, "limit": 1, "offset": offset},
).json()
]
assert paged == ["event-a", "event-b", "event-c"]
def test_event_list_rejects_negative_offset(self):
with AuthTestClient(self.app) as client:
response = client.get("/events", params={"offset": -5})
assert response.status_code == 422
response = client.get(
"/events/search",
params={"query": "car", "offset": -5},
)
assert response.status_code == 422
def test_similarity_search_hides_unauthorized_anchor_event(self): def test_similarity_search_hides_unauthorized_anchor_event(self):
mock_embeddings = Mock() mock_embeddings = Mock()
self.app.frigate_config.semantic_search.enabled = True self.app.frigate_config.semantic_search.enabled = True
@@ -0,0 +1,61 @@
"""Tests for the go2rtc stream bitrate endpoint."""
from unittest.mock import patch
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpGo2rtcBitrate(BaseTestHttp):
def setUp(self):
super().setUp([])
self.minimal_config["go2rtc"] = {
"streams": {"front_main": ["rtsp://10.0.0.1:554/main"]}
}
self.minimal_config["cameras"]["front_door"]["live"] = {
"streams": {"Main": "front_main"},
"transcode": {
"enabled": True,
"qualities": [{"height": 480, "bitrate": 500}],
},
}
self.app = self.create_app()
@patch("frigate.api.camera.measure_stream_bitrate", return_value=812.4)
def test_returns_rounded_kbps(self, mock_measure):
with AuthTestClient(self.app) as client:
resp = client.get("/go2rtc/streams/front_main/bitrate")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["kbps"], 812)
mock_measure.assert_called_once_with("front_main")
@patch("frigate.api.camera.measure_stream_bitrate", return_value=480.0)
def test_generated_streams_are_known(self, _):
with AuthTestClient(self.app) as client:
resp = client.get("/go2rtc/streams/front_door_transcode_480p/bitrate")
self.assertEqual(resp.status_code, 200)
@patch("frigate.api.camera.measure_stream_bitrate")
def test_unknown_stream_is_404(self, mock_measure):
with AuthTestClient(self.app) as client:
resp = client.get("/go2rtc/streams/rtsp_somewhere/bitrate")
self.assertEqual(resp.status_code, 404)
mock_measure.assert_not_called()
@patch("frigate.api.camera.measure_stream_bitrate", return_value=None)
def test_no_data_is_502(self, _):
with AuthTestClient(self.app) as client:
resp = client.get("/go2rtc/streams/front_main/bitrate")
self.assertEqual(resp.status_code, 502)
def test_viewer_is_forbidden(self):
with AuthTestClient(self.app) as client:
resp = client.get(
"/go2rtc/streams/front_main/bitrate",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
self.assertEqual(resp.status_code, 403)
+92
View File
@@ -240,9 +240,101 @@ class TestHttpReview(BaseTestHttp):
assert len(response_json) == 1 assert len(response_json) == 1
assert response_json[0]["id"] == id_reviewed assert response_json[0]["id"] == id_reviewed
def test_get_review_with_label_filter_matches_verified(self):
"""Test that a label filter also matches the `-verified` variant."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.person", now, now + 2, data={"objects": ["person"]}
)
super().insert_mock_review_segment(
"123456.verified", now, now + 2, data={"objects": ["person-verified"]}
)
super().insert_mock_review_segment(
"123456.car", now, now + 2, data={"objects": ["car"]}
)
params = {
"labels": "person",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert {r["id"] for r in response_json} == {
"123456.person",
"123456.verified",
}
def test_get_review_with_label_filter_does_not_match_prefix(self):
"""Test that a label filter does not match labels that only share a prefix."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.carrot", now, now + 2, data={"objects": ["carrot"]}
)
params = {
"labels": "car",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
assert len(response.json()) == 0
def test_get_review_with_audio_label_filter(self):
"""Test that a label filter still matches audio labels."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.audio", now, now + 2, data={"audio": ["speech"]}
)
params = {
"labels": "speech",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["id"] == "123456.audio"
#################################################################################################################### ####################################################################################################################
################################### GET /review/summary Endpoint ################################################# ################################### GET /review/summary Endpoint #################################################
#################################################################################################################### ####################################################################################################################
def test_get_review_summary_label_filter_matches_verified(self):
"""Test that the summary label filter also matches the `-verified` variant."""
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.verified", data={"objects": ["person-verified"]}
)
super().insert_mock_review_segment(
"123456.car", data={"objects": ["car"]}, severity=SeverityEnum.detection
)
params = {
"cameras": "front_door",
"labels": "person",
"zones": "all",
"timezone": "utc",
}
response = client.get("/review/summary", params=params)
assert response.status_code == 200
response_json = response.json()
assert response_json["last24Hours"]["total_alert"] == 1
assert response_json["last24Hours"]["total_detection"] == 0
today_formatted = datetime.today().strftime("%Y-%m-%d")
assert response_json[today_formatted]["total_alert"] == 1
assert response_json[today_formatted]["total_detection"] == 0
def test_get_review_summary_all_filters(self): def test_get_review_summary_all_filters(self):
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
+2 -3
View File
@@ -9,7 +9,6 @@ from frigate.config.camera.updater import (
CameraConfigUpdateSubscriber, CameraConfigUpdateSubscriber,
) )
from frigate.const import SUB_CACHE_TAG from frigate.const import SUB_CACHE_TAG
from frigate.detectors.detector_config import SceneEnum
def _build_scene_frigate_config(scene: str | None) -> FrigateConfig: def _build_scene_frigate_config(scene: str | None) -> FrigateConfig:
@@ -119,7 +118,7 @@ class TestRecordUpdateRecreatesFfmpegCmds(unittest.TestCase):
subscriber = CameraConfigUpdateSubscriber( subscriber = CameraConfigUpdateSubscriber(
config, {}, [CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove] config, {}, [CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove]
) )
assert config.model_for_camera("front_door").scene == SceneEnum.outdoor assert config.model_for_camera("front_door").scene == "outdoor"
subscriber.subscriber.check_for_update.side_effect = [ subscriber.subscriber.check_for_update.side_effect = [
("config/cameras/front_door/remove", config.cameras["front_door"]), ("config/cameras/front_door/remove", config.cameras["front_door"]),
@@ -136,7 +135,7 @@ class TestRecordUpdateRecreatesFfmpegCmds(unittest.TestCase):
] ]
subscriber.check_for_updates() subscriber.check_for_updates()
assert config.model_for_camera("front_door").scene == SceneEnum.all assert config.model_for_camera("front_door").scene == "default"
def test_unchanged_record_update_keeps_existing_cmds(self): def test_unchanged_record_update_keeps_existing_cmds(self):
camera_config = _build_camera_config(sub_enabled=False) camera_config = _build_camera_config(sub_enabled=False)
+4
View File
@@ -179,12 +179,16 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer) maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
maintainer.active_review_segments = {"deleted_cam": MagicMock()} maintainer.active_review_segments = {"deleted_cam": MagicMock()}
maintainer.indefinite_events = {"deleted_cam": {"1234.5-abcdef": 1.0}} maintainer.indefinite_events = {"deleted_cam": {"1234.5-abcdef": 1.0}}
maintainer.recent_classification_state_changes = {
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
}
maintainer.forcibly_end_segment = MagicMock() maintainer.forcibly_end_segment = MagicMock()
maintainer._handle_camera_removed("deleted_cam") maintainer._handle_camera_removed("deleted_cam")
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam") maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
self.assertNotIn("deleted_cam", maintainer.indefinite_events) self.assertNotIn("deleted_cam", maintainer.indefinite_events)
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
class TestAutotrackerMoveQueue(unittest.TestCase): class TestAutotrackerMoveQueue(unittest.TestCase):
+5 -6
View File
@@ -59,7 +59,6 @@ def build_watchdog(
MagicMock(), MagicMock(),
) )
watchdog.requestor = MagicMock()
return watchdog return watchdog
@@ -108,8 +107,8 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
def test_status_goes_to_the_matching_role_topic(self): def test_status_goes_to_the_matching_role_topic(self):
watchdog = self._build_watchdog() watchdog = self._build_watchdog()
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0) watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0) watchdog.record_status[STREAM_TYPE_SUB].send("offline", 100.0)
watchdog.requestor.send_data.assert_any_call( watchdog.requestor.send_data.assert_any_call(
"front_door/status/record", "online" "front_door/status/record", "online"
@@ -121,9 +120,9 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
def test_status_is_cached_per_stream(self): def test_status_is_cached_per_stream(self):
watchdog = self._build_watchdog() watchdog = self._build_watchdog()
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0) watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "online", 100.0) watchdog.record_status[STREAM_TYPE_SUB].send("online", 100.0)
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0) watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
assert watchdog.requestor.send_data.call_count == 2 assert watchdog.requestor.send_data.call_count == 2
+151 -7
View File
@@ -1,17 +1,18 @@
import json import json
import os import os
import tempfile
import unittest import unittest
from copy import deepcopy from copy import deepcopy
from unittest.mock import patch from unittest.mock import patch
import numpy as np import numpy as np
import requests
from pydantic import ValidationError from pydantic import ValidationError
from ruamel.yaml.constructor import DuplicateKeyError from ruamel.yaml.constructor import DuplicateKeyError
from frigate.config import BirdseyeModeEnum, FrigateConfig, RetainModeEnum from frigate.config import BirdseyeModeEnum, FrigateConfig, RetainModeEnum
from frigate.const import MODEL_CACHE_DIR from frigate.const import MODEL_CACHE_DIR
from frigate.detectors import DetectorTypeEnum from frigate.detectors import DetectorTypeEnum
from frigate.detectors.detector_config import SceneEnum
from frigate.detectors.device import build_detector_config, runner_names from frigate.detectors.device import build_detector_config, runner_names
from frigate.util.builtin import deep_merge from frigate.util.builtin import deep_merge
@@ -68,7 +69,7 @@ class TestConfig(unittest.TestCase):
def test_config_class(self): def test_config_class(self):
frigate_config = FrigateConfig(**self.minimal) frigate_config = FrigateConfig(**self.minimal)
model = frigate_config.primary_model model = frigate_config.primary_model
assert model.scene == SceneEnum.all assert model.scene == "default"
assert model.width == 320 assert model.width == 320
assert frigate_config.devices_for_model(model)[0].detector == ( assert frigate_config.devices_for_model(model)[0].detector == (
DetectorTypeEnum.cpu DetectorTypeEnum.cpu
@@ -140,8 +141,8 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal))) frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert frigate_config.model_for_camera("back").scene == SceneEnum.outdoor assert frigate_config.model_for_camera("back").scene == "outdoor"
assert frigate_config.model_for_camera("front").scene == SceneEnum.indoor assert frigate_config.model_for_camera("front").scene == "indoor"
assert frigate_config.model_for_camera("back").width == 320 assert frigate_config.model_for_camera("back").width == 320
assert frigate_config.model_for_camera("front").width == 300 assert frigate_config.model_for_camera("front").width == 300
@@ -177,7 +178,7 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal))) frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert frigate_config.model_for_camera("back").scene == SceneEnum.all assert frigate_config.model_for_camera("back").scene == "default"
@patch("frigate.detectors.detector_config.load_labels") @patch("frigate.detectors.detector_config.load_labels")
def test_model_for_camera_resolves_camera_added_after_parse(self, mock_labels): def test_model_for_camera_resolves_camera_added_after_parse(self, mock_labels):
@@ -205,7 +206,7 @@ class TestConfig(unittest.TestCase):
new_config = FrigateConfig(**(deep_merge(deepcopy(config), added))) new_config = FrigateConfig(**(deep_merge(deepcopy(config), added)))
frigate_config.cameras["new_cam"] = new_config.cameras["new_cam"] frigate_config.cameras["new_cam"] = new_config.cameras["new_cam"]
assert frigate_config.model_for_camera("new_cam").scene == SceneEnum.outdoor assert frigate_config.model_for_camera("new_cam").scene == "outdoor"
assert frigate_config.model_for_camera("new_cam").width == 416 assert frigate_config.model_for_camera("new_cam").width == 416
@patch("frigate.detectors.detector_config.load_labels") @patch("frigate.detectors.detector_config.load_labels")
@@ -221,7 +222,7 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal))) frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal)))
# a caller racing a runtime remove may still name the popped camera # a caller racing a runtime remove may still name the popped camera
assert frigate_config.model_for_camera("removed").scene == SceneEnum.all assert frigate_config.model_for_camera("removed").scene == "default"
@patch("frigate.detectors.detector_config.load_labels") @patch("frigate.detectors.detector_config.load_labels")
def test_camera_scene_without_a_model_or_a_default(self, mock_labels): def test_camera_scene_without_a_model_or_a_default(self, mock_labels):
@@ -256,6 +257,124 @@ class TestConfig(unittest.TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal))) FrigateConfig(**(deep_merge(config, self.minimal)))
@patch("frigate.detectors.detector_config.load_labels")
def test_scene_names_are_not_a_fixed_list(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"devices": ["cpu"]},
{"scene": "garage_thermal", "devices": ["openvino:CPU"]},
],
"cameras": {"back": {"detect": {"scene": "garage_thermal"}}},
}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert frigate_config.model_for_camera("back").scene == "garage_thermal"
@patch("frigate.detectors.detector_config.load_labels")
def test_scene_names_must_be_simple_identifiers(self, mock_labels):
mock_labels.return_value = {}
config = {"models": [{"scene": "front yard", "devices": ["cpu"]}]}
with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal)))
def _two_scene_config(self, default_path: str, outdoor_path: str) -> dict:
return {
"models": [
{"path": default_path, "devices": ["openvino:CPU"]},
{"scene": "outdoor", "path": outdoor_path, "devices": ["openvino:GPU"]},
],
"cameras": {"back": {"detect": {"scene": "outdoor"}}},
}
@patch("frigate.detectors.detector_config.load_labels")
def test_models_with_the_same_path_are_combined(self, mock_labels):
mock_labels.return_value = {}
config = self._two_scene_config("/etc/hosts", "/etc/hosts")
with self.assertLogs("frigate.config.config", level="WARNING") as logs:
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert len(frigate_config.models) == 1
model = frigate_config.primary_model
assert model.devices == ["openvino:CPU", "openvino:GPU"]
assert [d.raw for d in frigate_config.devices_for_model(model)] == [
"openvino:CPU",
"openvino:GPU",
]
assert frigate_config.model_for_camera("back") is model
assert frigate_config.cameras["back"].detect.scene == "default"
assert any("same model file" in line for line in logs.output)
@patch("frigate.detectors.detector_config.load_labels")
def test_models_with_the_same_file_contents_are_combined(self, mock_labels):
mock_labels.return_value = {}
with tempfile.TemporaryDirectory() as temp_dir:
first = os.path.join(temp_dir, "model.onnx")
copy = os.path.join(temp_dir, "renamed.onnx")
for path in (first, copy):
with open(path, "wb") as f:
f.write(b"same weights")
config = self._two_scene_config(first, copy)
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert len(frigate_config.models) == 1
assert frigate_config.model_for_camera("back").scene == "default"
@patch("frigate.detectors.detector_config.load_labels")
def test_models_with_different_files_are_kept_apart(self, mock_labels):
mock_labels.return_value = {}
with tempfile.TemporaryDirectory() as temp_dir:
first = os.path.join(temp_dir, "model.onnx")
other = os.path.join(temp_dir, "thermal.onnx")
for path, contents in ((first, b"visible"), (other, b"thermal")):
with open(path, "wb") as f:
f.write(contents)
config = self._two_scene_config(first, other)
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert len(frigate_config.models) == 2
assert frigate_config.model_for_camera("back").scene == "outdoor"
@patch("frigate.detectors.detector_config.load_labels")
def test_combined_models_keep_the_default_model(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"scene": "outdoor", "path": "/etc/hosts", "devices": ["openvino:GPU"]},
{"path": "/etc/hosts", "devices": ["openvino:CPU"]},
],
"cameras": {"back": {"detect": {"scene": "outdoor"}}},
}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert [model.scene for model in frigate_config.models] == ["default"]
assert frigate_config.primary_model.devices == ["openvino:CPU", "openvino:GPU"]
assert frigate_config.model_for_camera("back").scene == "default"
@patch("frigate.detectors.detector_config.load_labels")
def test_models_on_different_detectors_are_kept_apart(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"path": "/etc/hosts", "devices": ["openvino:CPU"]},
{"scene": "outdoor", "path": "/etc/hosts", "devices": ["onnx"]},
],
}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert len(frigate_config.models) == 2
@patch("frigate.detectors.detector_config.load_labels") @patch("frigate.detectors.detector_config.load_labels")
def test_model_devices_must_share_a_detector(self, mock_labels): def test_model_devices_must_share_a_detector(self, mock_labels):
mock_labels.return_value = {} mock_labels.return_value = {}
@@ -1477,6 +1596,31 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert frigate_config.primary_model.merged_labelmap[0] == "amazon" 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): def test_fails_on_invalid_role(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
+30 -5
View File
@@ -20,7 +20,7 @@ class TestMigrateModels(unittest.TestCase):
def test_single_cpu_detector(self): def test_single_cpu_detector(self):
migrated = migrate_models({"detectors": {"cpu": {"type": "cpu"}}}) migrated = migrate_models({"detectors": {"cpu": {"type": "cpu"}}})
self.assertEqual(migrated["models"], [{"scene": "all", "devices": ["cpu"]}]) self.assertEqual(migrated["models"], [{"scene": "default", "devices": ["cpu"]}])
self.assertNotIn("detectors", migrated) self.assertNotIn("detectors", migrated)
def test_model_settings_are_carried_over(self): def test_model_settings_are_carried_over(self):
@@ -35,7 +35,7 @@ class TestMigrateModels(unittest.TestCase):
migrated["models"], migrated["models"],
[ [
{ {
"scene": "all", "scene": "default",
"path": "plus://abc", "path": "plus://abc",
"width": 320, "width": 320,
"devices": ["edgetpu:pci:0"], "devices": ["edgetpu:pci:0"],
@@ -257,12 +257,37 @@ class TestMigrateConfigFile(unittest.TestCase):
self.assertEqual(migrated["models"][0]["devices"], ["openvino:GPU"]) self.assertEqual(migrated["models"][0]["devices"], ["openvino:GPU"])
self.assertNotIn("detectors", migrated) self.assertNotIn("detectors", migrated)
def test_top_level_changes_survive_later_steps(self):
# 0.16 adds detect and 0.17 splits genai, both at the top level
migrated = self._migrate(
"mqtt:\n"
" enabled: false\n"
"genai:\n"
" provider: ollama\n"
" model: llava\n"
" prompt: describe it\n"
"cameras: {}\n"
"version: 0.15-1\n"
)
self.assertEqual(migrated["version"], CURRENT_CONFIG_VERSION)
self.assertTrue(migrated["detect"]["enabled"])
self.assertEqual(migrated["objects"]["genai"], {"prompt": "describe it"})
self.assertEqual(
migrated["genai"]["default"],
{
"provider": "ollama",
"model": "llava",
"roles": ["descriptions", "chat"],
},
)
def test_a_migrated_config_is_left_alone(self): def test_a_migrated_config_is_left_alone(self):
migrated = self._migrate( migrated = self._migrate(
"mqtt:\n" "mqtt:\n"
" enabled: false\n" " enabled: false\n"
"models:\n" "models:\n"
" - scene: all\n" " - scene: default\n"
" devices:\n" " devices:\n"
" - openvino:GPU\n" " - openvino:GPU\n"
"cameras: {}\n" "cameras: {}\n"
@@ -270,7 +295,7 @@ class TestMigrateConfigFile(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
migrated["models"], [{"scene": "all", "devices": ["openvino:GPU"]}] migrated["models"], [{"scene": "default", "devices": ["openvino:GPU"]}]
) )
self.assertFalse( self.assertFalse(
os.path.exists(os.path.join(self.temp_dir.name, "backup_config.yaml")) os.path.exists(os.path.join(self.temp_dir.name, "backup_config.yaml"))
@@ -298,7 +323,7 @@ class TestMigrateConfigFile(unittest.TestCase):
"mqtt:\n" "mqtt:\n"
" enabled: false\n" " enabled: false\n"
"models:\n" "models:\n"
" - scene: all\n" " - scene: default\n"
" devices:\n" " devices:\n"
" - hailo8l:PCIe\n" " - hailo8l:PCIe\n"
"cameras: {}\n" "cameras: {}\n"
+1
View File
@@ -26,6 +26,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
app.genai_manager.update_config.assert_called_once_with(config) app.genai_manager.update_config.assert_called_once_with(config)
app.profile_manager.update_config.assert_called_once_with(config) app.profile_manager.update_config.assert_called_once_with(config)
self.assertIs(app.stats_emitter.config, config) self.assertIs(app.stats_emitter.config, config)
app.stats_emitter.hardware_stats.set_config.assert_called_once_with(config)
self.assertIs(app.dispatcher.config, config) self.assertIs(app.dispatcher.config, config)
for comm in app.dispatcher.comms: for comm in app.dispatcher.comms:
self.assertIs(comm.config, config) self.assertIs(comm.config, config)
+39
View File
@@ -0,0 +1,39 @@
"""Tests for per-detector stats."""
import unittest
from unittest.mock import MagicMock, patch
from frigate.stats.util import get_detector_stats
def _detector(detector_type: str) -> MagicMock:
detector = MagicMock()
detector.detector_config.type = detector_type
detector.avg_inference_speed.value = 0.01
detector.detection_start.value = 0.0
detector.detect_process.pid = 1
return detector
class TestDetectorTemperatures(unittest.TestCase):
def test_repeated_device_shares_its_unit_temperature(self):
stats_tracking = {
"detectors": {
"hailo:PCIe": _detector("hailo8l"),
"hailo:PCIe#2": _detector("hailo8l"),
"hailo:PCIe:1": _detector("hailo8l"),
}
}
with patch(
"frigate.stats.util.get_hardware_temperatures", return_value=[50.0, 60.0]
):
stats = get_detector_stats(stats_tracking)
self.assertEqual(stats["hailo:PCIe"]["temperature"], 50.0)
self.assertEqual(stats["hailo:PCIe#2"]["temperature"], 50.0)
self.assertEqual(stats["hailo:PCIe:1"]["temperature"], 60.0)
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -543,6 +543,56 @@ class TestPinnedStream(unittest.TestCase):
self.assertFalse(any("/vod/front/main/" in token for token in cmd)) self.assertFalse(any("/vod/front/main/" in token for token in cmd))
class TestExportTimelineAlignment(unittest.TestCase):
def test_unstaged_auto_reads_the_stream_it_serves(self) -> None:
# a sub-only range whose main rows are glitches the manifest drops
exporter = _make_exporter([_span("/s1.mp4", 1_000, 1_040, False)], {"h264"})
streams: list[str] = []
def rows(stream: str) -> list:
streams.append(stream)
return [_FakeRow(f"/{stream}.mp4")]
exporter._get_recordings_for_range = rows # type: ignore[method-assign]
exporter.get_record_export_command("/exports/out.mp4")
self.assertEqual(streams, ["sub"])
def test_staged_chapters_carry_keyframe_lead_in(self) -> None:
exporter = _make_exporter(
[
_span("/m1.mp4", 1_000, 1_020, True),
_span("/s1.mp4", 1_020, 1_040, False),
],
{"h264"},
)
exporter.config.ui.timezone = None
# each run's vod clip snaps 1.5s back to a keyframe
def timeline(_intervals: list, stream: str) -> list[dict]:
start, end = (1_000, 1_020) if stream == "main" else (1_020, 1_040)
return [
{
"start_time": start,
"end_time": end,
"duration": (end - start + 1.5) * 1000,
}
]
with (
patch("frigate.record.export.resolve_coverage", return_value=[]),
patch("frigate.record.export.realized_timeline", side_effect=timeline),
):
windows = exporter._staged_chapter_windows()
path = exporter._build_recording_segment_chapter_metadata_file(windows)
self.addCleanup(os.remove, path)
content = Path(path).read_text()
self.assertIn("START=0\nEND=21500", content)
self.assertIn("START=21500\nEND=43000", content)
class TestStagedFileCleanup(unittest.TestCase): class TestStagedFileCleanup(unittest.TestCase):
"""A staged path must be tracked before ffmpeg can write to it.""" """A staged path must be tracked before ffmpeg can write to it."""
+45
View File
@@ -7,6 +7,7 @@ from frigate.genai.prompts import (
REVIEW_DESCRIPTION_FIELD_GUIDELINES, REVIEW_DESCRIPTION_FIELD_GUIDELINES,
REVIEW_RESPONSE_STYLES, REVIEW_RESPONSE_STYLES,
build_review_description_prompt, build_review_description_prompt,
build_review_summary_prompt,
get_review_field_guidelines, get_review_field_guidelines,
) )
@@ -83,5 +84,49 @@ class TestReviewResponseStyle(unittest.TestCase):
) )
class TestClassificationStateChanges(unittest.TestCase):
def _build_prompt(self, **extra) -> str:
review_data = {
"camera": "Front Door",
"start": "Monday, 09:30 AM",
"duration": 25,
"zones": [],
"unified_objects": ["person"],
**extra,
}
return build_review_description_prompt(
review_data, [b"fake-image"], [], None, "activity context"
)
def test_no_changes_leaves_prompt_unchanged(self):
self.assertEqual(
self._build_prompt(classification_state_changes=[]),
self._build_prompt(),
)
self.assertNotIn("## State Changes", self._build_prompt())
def test_changes_are_listed_before_objects(self):
change = "front gate changed from closed to open, 12s into the activity"
prompt = self._build_prompt(classification_state_changes=[change])
self.assertIn(f"\n- {change}\n\n## Objects in Scene", prompt)
self.assertLess(
prompt.index("## Sequence Details"), prompt.index("## State Changes")
)
def test_summary_describes_state_changes_only_when_present(self):
event = {"title": "Person at door", "camera": "Front Door", "context": []}
without = build_review_summary_prompt(0, 3600, [event], None)
self.assertNotIn('"state_changes"', without)
context_event = {
**event,
"context": [
{"camera": "Driveway", "state_changes": ["gate changed from a to b"]}
],
}
with_changes = build_review_summary_prompt(0, 3600, [context_event], None)
self.assertIn('- "state_changes"', with_changes)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+58 -2
View File
@@ -19,6 +19,8 @@ import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import requests
from frigate.config import GenAIConfig, GenAIProviderEnum from frigate.config import GenAIConfig, GenAIProviderEnum
from frigate.genai import PROVIDERS, load_providers from frigate.genai import PROVIDERS, load_providers
@@ -566,7 +568,6 @@ class TestLlamaCppProvider(unittest.TestCase):
"supports_audio": False, "supports_audio": False,
"supports_tools": False, "supports_tools": False,
"supports_reasoning": False, "supports_reasoning": False,
"media_marker": "<__media__>",
} }
cls = PROVIDERS[GenAIProviderEnum.llamacpp] cls = PROVIDERS[GenAIProviderEnum.llamacpp]
with patch.object(cls, "_get_model_info", return_value=info): with patch.object(cls, "_get_model_info", return_value=info):
@@ -589,6 +590,62 @@ class TestLlamaCppProvider(unittest.TestCase):
with patch.object(client, "_fetch_models_data", return_value=models_data): with patch.object(client, "_fetch_models_data", return_value=models_data):
self.assertEqual(client.list_models(), ["g4", "gemma", "qwen3-asr"]) self.assertEqual(client.list_models(), ["g4", "gemma", "qwen3-asr"])
@staticmethod
def _embeddings_response(vectors):
response = MagicMock()
response.status_code = 200
response.json.return_value = {
"object": "list",
"data": [
{"object": "embedding", "index": i, "embedding": v}
for i, v in enumerate(vectors)
],
}
return response
def test_embed_posts_content_arrays_to_v1_embeddings(self):
client = self._client()
response = self._embeddings_response([[0.1] * 768, [0.2] * 768])
with patch.object(client, "_post", return_value=response) as post:
result = client.embed(texts=["a person"], images=[b"not an image"])
url = post.call_args.args[0]
payload = post.call_args.kwargs["json"]
self.assertEqual(url, "http://localhost:9999/v1/embeddings")
self.assertEqual(payload["model"], "m")
self.assertEqual(payload["encoding_format"], "float")
self.assertEqual(
payload["input"][0], {"content": [{"type": "text", "text": "a person"}]}
)
image_parts = payload["input"][1]["content"]
self.assertEqual(image_parts[0]["type"], "image_url")
self.assertEqual(
image_parts[0]["image_url"]["url"],
"data:image/jpeg;base64," + base64.b64encode(b"not an image").decode(),
)
self.assertEqual(image_parts[1], {"type": "text", "text": "\n"})
self.assertEqual(len(result), 2)
self.assertAlmostEqual(float(result[1][0]), 0.2, places=5)
def test_embed_normalizes_dimension(self):
client = self._client()
response = self._embeddings_response([[1.0] * 1024, [1.0] * 512])
with patch.object(client, "_post", return_value=response):
result = client.embed(texts=["long", "short"])
self.assertEqual([r.shape for r in result], [(768,), (768,)])
self.assertEqual(float(result[1][-1]), 0.0)
def test_embed_request_error_returns_empty(self):
client = self._client()
response = MagicMock()
response.raise_for_status.side_effect = requests.exceptions.HTTPError("400")
with patch.object(client, "_post", return_value=response):
self.assertEqual(client.embed(texts=["a"]), [])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# transcribe role # transcribe role
@@ -762,7 +819,6 @@ class TestLlamaCppTranscribe(unittest.TestCase):
"supports_audio": supports_audio, "supports_audio": supports_audio,
"supports_tools": False, "supports_tools": False,
"supports_reasoning": False, "supports_reasoning": False,
"media_marker": "<__media__>",
} }
cls = PROVIDERS[GenAIProviderEnum.llamacpp] cls = PROVIDERS[GenAIProviderEnum.llamacpp]
with patch.object(cls, "_get_model_info", return_value=info): with patch.object(cls, "_get_model_info", return_value=info):
+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"})
+11
View File
@@ -288,6 +288,17 @@ class TestUpdateConfig(HardwareStatsTestCase):
self.assertEqual(set(stats._monitored), {"rockchip"}) self.assertEqual(set(stats._monitored), {"rockchip"})
def test_follows_a_runtime_config_swap(self):
stats = self.make_stats(self.make_config())
self.assertEqual(set(stats._monitored), set())
swapped = self.make_config("preset-rk-h264")
stats.set_config(swapped)
self.assertEqual(set(stats._monitored), {"rockchip"})
self.assertIs(self.subscriber.return_value.config, swapped)
self.assertIs(self.subscriber.return_value.camera_configs, swapped.cameras)
class TestUpdateStats(HardwareStatsTestCase): class TestUpdateStats(HardwareStatsTestCase):
def run_stats(self, stats: HardwareStats) -> dict: def run_stats(self, stats: HardwareStats) -> dict:
+410
View File
@@ -0,0 +1,410 @@
"""Tests for Frigate-managed go2rtc live streams."""
import unittest
from unittest.mock import MagicMock, call, patch
import requests
from pydantic import ValidationError
from frigate.config import FrigateConfig
from frigate.util.live_streams import (
DEFAULT_TRANSCODE_QUALITIES,
default_transcode_source,
generated_transcode_streams,
is_transcode_stream_name,
measure_stream_bitrate,
raw_transcode_streams,
sync_transcode_streams,
transcode_stream_name,
transcode_stream_source,
)
class TestTranscodeNaming(unittest.TestCase):
def test_stream_name(self):
self.assertEqual(transcode_stream_name("front", 720), "front_transcode_720p")
def test_is_transcode_stream_name(self):
self.assertTrue(is_transcode_stream_name("front", "front_transcode_480p"))
self.assertFalse(is_transcode_stream_name("front", "front_sub"))
self.assertFalse(is_transcode_stream_name("front", "back_transcode_480p"))
self.assertFalse(is_transcode_stream_name("front", "front_transcode_480"))
def test_source_gives_each_ffmpeg_token_its_own_raw_param(self):
self.assertEqual(
transcode_stream_source("front", 480, 500),
"ffmpeg:front#video=h264#height=480#hardware#audio=copy"
"#raw=-b:v#raw=500k#raw=-maxrate#raw=500k#raw=-bufsize#raw=1000k",
)
def test_default_source_skips_transcoded_streams(self):
streams = {"Low": "front_transcode_360p", "Main": "front_main"}
self.assertEqual(default_transcode_source("front", streams), "front_main")
self.assertIsNone(default_transcode_source("front", {}))
class TestRawTranscodeStreams(unittest.TestCase):
def test_defaults_to_camera_stream_and_default_qualities(self):
config = {"cameras": {"front": {"live": {"transcode": {"enabled": True}}}}}
streams = raw_transcode_streams(config)
self.assertEqual(
list(streams),
[
transcode_stream_name("front", q["height"])
for q in DEFAULT_TRANSCODE_QUALITIES
],
)
self.assertTrue(streams["front_transcode_720p"].startswith("ffmpeg:front#"))
def test_uses_first_live_stream_and_explicit_qualities(self):
config = {
"cameras": {
"front": {
"live": {
"streams": {"Main": "front_main", "Sub": "front_sub"},
"transcode": {
"enabled": True,
"qualities": [{"height": 540, "bitrate": 800}],
},
}
}
}
}
self.assertEqual(
raw_transcode_streams(config),
{"front_transcode_540p": transcode_stream_source("front_main", 540, 800)},
)
def test_explicit_source_wins(self):
config = {
"cameras": {
"front": {
"live": {
"streams": {"Main": "front_main"},
"transcode": {
"enabled": True,
"source": "front_sub",
"qualities": [{"height": 360, "bitrate": 250}],
},
}
}
}
}
self.assertEqual(
raw_transcode_streams(config)["front_transcode_360p"],
transcode_stream_source("front_sub", 360, 250),
)
def test_malformed_qualities_are_skipped(self):
config = {
"cameras": {
"front": {
"live": {
"transcode": {
"enabled": True,
"qualities": [
{"height": 720},
"480",
{"height": 360, "bitrate": 250},
],
}
}
},
"back": {
"live": {"transcode": {"enabled": True, "qualities": {"a": 1}}}
},
}
}
self.assertEqual(list(raw_transcode_streams(config)), ["front_transcode_360p"])
def test_disabled_or_missing_generates_nothing(self):
self.assertEqual(raw_transcode_streams({}), {})
self.assertEqual(
raw_transcode_streams({"cameras": {"front": {"live": {}}}}), {}
)
self.assertEqual(
raw_transcode_streams(
{"cameras": {"front": {"live": {"transcode": {"enabled": False}}}}}
),
{},
)
def camera_config(live: dict | None = None, go2rtc: dict | None = None) -> dict:
config = {
"mqtt": {"host": "mqtt"},
"go2rtc": {
"streams": go2rtc
or {
"front": ["rtsp://10.0.0.1:554/main"],
"front_sub": ["rtsp://10.0.0.1:554/sub"],
}
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/main", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
}
},
}
if live is not None:
config["cameras"]["front"]["live"] = live
return config
class TestLiveTranscodeConfig(unittest.TestCase):
def streams(self, live: dict, go2rtc: dict | None = None) -> dict[str, str]:
config = FrigateConfig(**camera_config(live, go2rtc))
return config.cameras["front"].live.streams
def test_global_transcode_is_ignored(self):
config = camera_config()
config["live"] = {"transcode": {"enabled": True}}
streams = FrigateConfig(**config).cameras["front"].live.streams
self.assertEqual(streams, {"front": "front"})
def test_disabled_leaves_streams_alone(self):
self.assertEqual(self.streams({}), {"front": "front"})
def test_enabled_appends_default_qualities(self):
streams = self.streams({"transcode": {"enabled": True}})
self.assertEqual(
list(streams.items()),
[
("front", "front"),
("720p", "front_transcode_720p"),
("480p", "front_transcode_480p"),
("360p", "front_transcode_360p"),
],
)
def test_source_resolves_to_first_live_stream(self):
config = FrigateConfig(
**camera_config(
{
"streams": {"Sub": "front_sub", "Main": "front"},
"transcode": {"enabled": True},
}
)
)
self.assertEqual(config.cameras["front"].live.transcode.source, "front_sub")
def test_startup_and_validation_agree_on_inherited_source(self):
config = camera_config({"transcode": {"enabled": True}})
config["live"] = {"streams": {"Sub": "front_sub"}}
self.assertEqual(
raw_transcode_streams(config),
generated_transcode_streams(FrigateConfig(**config)),
)
self.assertEqual(
raw_transcode_streams(config)["front_transcode_360p"],
transcode_stream_source("front_sub", 360, 250),
)
def test_placed_entries_keep_position_and_label(self):
streams = self.streams(
{
"streams": {
"Main": "front",
"Low": "front_transcode_480p",
"Sub": "front_sub",
},
"transcode": {"enabled": True},
}
)
self.assertEqual(
list(streams.items()),
[
("Main", "front"),
("Low", "front_transcode_480p"),
("Sub", "front_sub"),
("720p", "front_transcode_720p"),
("360p", "front_transcode_360p"),
],
)
def test_disabling_drops_transcoded_entries(self):
streams = self.streams(
{
"streams": {"Main": "front", "720p": "front_transcode_720p"},
"transcode": {"enabled": False},
}
)
self.assertEqual(streams, {"Main": "front"})
def test_changed_height_drops_the_old_entry(self):
streams = self.streams(
{
"streams": {"Main": "front", "720p": "front_transcode_720p"},
"transcode": {
"enabled": True,
"qualities": [{"height": 540, "bitrate": 800}],
},
}
)
self.assertEqual(streams, {"Main": "front", "540p": "front_transcode_540p"})
def test_real_go2rtc_stream_with_transcode_name_is_kept(self):
streams = self.streams(
{"streams": {"Main": "front", "Odd": "front_transcode_720p"}},
go2rtc={
"front": ["rtsp://10.0.0.1:554/main"],
"front_transcode_720p": ["rtsp://10.0.0.1:554/odd"],
},
)
self.assertEqual(streams, {"Main": "front", "Odd": "front_transcode_720p"})
def test_generated_name_colliding_with_go2rtc_stream_fails(self):
with self.assertRaises(ValidationError):
self.streams(
{"transcode": {"enabled": True}},
go2rtc={
"front": ["rtsp://10.0.0.1:554/main"],
"front_transcode_720p": ["rtsp://10.0.0.1:554/odd"],
},
)
def test_source_must_be_a_go2rtc_stream(self):
with self.assertRaises(ValidationError):
self.streams({"transcode": {"enabled": True, "source": "missing"}})
def test_duplicate_heights_fail(self):
with self.assertRaises(ValidationError):
self.streams(
{
"transcode": {
"enabled": True,
"qualities": [
{"height": 480, "bitrate": 500},
{"height": 480, "bitrate": 600},
],
}
}
)
def test_label_collision_fails(self):
with self.assertRaises(ValidationError):
self.streams(
{
"streams": {"Main": "front", "720p": "front_sub"},
"transcode": {"enabled": True},
}
)
class TestGeneratedTranscodeStreams(unittest.TestCase):
def test_collects_enabled_cameras(self):
config = FrigateConfig(
**camera_config(
{
"transcode": {
"enabled": True,
"qualities": [{"height": 480, "bitrate": 500}],
}
}
)
)
self.assertEqual(
generated_transcode_streams(config),
{"front_transcode_480p": transcode_stream_source("front", 480, 500)},
)
def test_disabled_is_empty(self):
self.assertEqual(
generated_transcode_streams(FrigateConfig(**camera_config())), {}
)
class TestSyncTranscodeStreams(unittest.TestCase):
@patch("frigate.util.live_streams.requests.request")
def test_puts_added_and_changed_and_deletes_removed(self, mock_request):
mock_request.return_value = MagicMock(ok=True)
ok = sync_transcode_streams(
{"a": "src-a", "b": "src-b", "c": "src-c"},
{"a": "src-a", "b": "src-b2", "d": "src-d"},
)
self.assertTrue(ok)
url = "http://127.0.0.1:1984/api/streams"
self.assertCountEqual(
mock_request.call_args_list,
[
call("put", url, params={"name": "b", "src": "src-b2"}, timeout=5),
call("put", url, params={"name": "d", "src": "src-d"}, timeout=5),
call("delete", url, params={"src": "c"}, timeout=5),
],
)
@patch("frigate.util.live_streams.requests.request")
def test_unchanged_makes_no_calls(self, mock_request):
self.assertTrue(sync_transcode_streams({"a": "x"}, {"a": "x"}))
mock_request.assert_not_called()
@patch("frigate.util.live_streams.requests.request")
def test_failure_returns_false_and_keeps_going(self, mock_request):
mock_request.side_effect = [
requests.ConnectionError("down"),
MagicMock(ok=True),
]
self.assertFalse(sync_transcode_streams({}, {"a": "x", "b": "y"}))
self.assertEqual(mock_request.call_count, 2)
class TestMeasureStreamBitrate(unittest.TestCase):
@patch("frigate.util.live_streams.time.monotonic")
@patch("frigate.util.live_streams.requests.get")
def test_skips_warmup_and_averages(self, mock_get, mock_monotonic):
response = MagicMock(ok=True)
response.iter_content.return_value = [b"x" * 1000] * 6
mock_get.return_value.__enter__.return_value = response
# first byte, warmup, window start, then counted chunks up to 6 s
mock_monotonic.side_effect = [0.0, 0.5, 1.0, 2.0, 4.0, 7.0]
kbps = measure_stream_bitrate("front", duration=6.0, warmup=1.0)
self.assertAlmostEqual(kbps, 3000 * 8 / 1000 / 6.0)
mock_get.assert_called_once_with(
"http://127.0.0.1:1984/api/stream.mp4",
params={"src": "front"},
stream=True,
timeout=5,
)
@patch("frigate.util.live_streams.requests.get")
def test_no_data_returns_none(self, mock_get):
response = MagicMock(ok=True)
response.iter_content.return_value = []
mock_get.return_value.__enter__.return_value = response
self.assertIsNone(measure_stream_bitrate("front"))
@patch("frigate.util.live_streams.requests.get")
def test_request_error_returns_none(self, mock_get):
mock_get.side_effect = requests.ReadTimeout("slow")
self.assertIsNone(measure_stream_bitrate("front"))
if __name__ == "__main__":
unittest.main()
+81 -4
View File
@@ -1,7 +1,7 @@
import datetime import datetime
import sys import sys
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
# Mock complex imports before importing maintainer, saving originals so we can # Mock complex imports before importing maintainer, saving originals so we can
# restore them after import and avoid polluting sys.modules for other tests. # restore them after import and avoid polluting sys.modules for other tests.
@@ -16,7 +16,7 @@ for name in _MOCKED_MODULES:
sys.modules[name] = MagicMock() sys.modules[name] = MagicMock()
# Now import the class under test # Now import the class under test
from frigate.config import FrigateConfig # noqa: E402 from frigate.config import FrigateConfig, RetainModeEnum # noqa: E402
from frigate.record.maintainer import RecordingMaintainer # noqa: E402 from frigate.record.maintainer import RecordingMaintainer # noqa: E402
# Restore original modules (or remove mock if there was no original) # Restore original modules (or remove mock if there was no original)
@@ -48,8 +48,11 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
"frigate.record.maintainer.psutil.process_iter", return_value=[] "frigate.record.maintainer.psutil.process_iter", return_value=[]
): ):
with patch("frigate.record.maintainer.logger.warning") as warn: with patch("frigate.record.maintainer.logger.warning") as warn:
# Mock validate_and_move_segment to avoid further logic # Mock validate_and_move_segment to avoid further logic.
maintainer.validate_and_move_segment = MagicMock() # The requestor is real when another test imported the
# maintainer first, and it would block on a reply.
maintainer.validate_and_move_segment = AsyncMock()
maintainer.requestor = MagicMock()
try: try:
await maintainer.move_files() await maintainer.move_files()
@@ -121,6 +124,80 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(result) self.assertIsNone(result)
maintainer.drop_segment.assert_called_once_with(cache_path) maintainer.drop_segment.assert_called_once_with(cache_path)
async def test_defers_review_overlap_segment_until_metadata_catches_up(self):
# Regression: a segment overlapping an active_objects review must not
# be dropped while detection metadata lags behind the segment end,
# the missing frames may hold the active objects (or continuous
# retention would keep it anyway).
config = MagicMock(spec=FrigateConfig)
camera_config = MagicMock()
camera_config.record.enabled = True
camera_config.record.continuous.days = 7
camera_config.record.motion.days = 0
camera_config.record.alerts.retain.mode = RetainModeEnum.active_objects
camera_config.record.get_review_pre_capture.return_value = 5
camera_config.record.get_review_post_capture.return_value = 5
config.cameras = {"test_cam": camera_config}
stop_event = MagicMock()
maintainer = RecordingMaintainer(config, stop_event)
now = datetime.datetime.now(datetime.UTC)
start_time = now - datetime.timedelta(seconds=20)
end_time = now - datetime.timedelta(seconds=10)
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
maintainer.end_time_cache = {
cache_path: (end_time, 10.0, False, None, None, None, [])
}
# Metadata has only reached partway into the segment.
maintainer.object_recordings_info["test_cam"] = [
(end_time.timestamp() - 8, [], [], [])
]
maintainer.audio_recordings_info["test_cam"] = []
maintainer.drop_segment = MagicMock()
maintainer.move_segment = AsyncMock(return_value=None)
maintainer.recordings_publisher = MagicMock()
review = MagicMock()
review.severity = "alert"
review.start_time = start_time.timestamp() - 30
review.end_time = None
result = await maintainer.validate_and_move_segment(
"test_cam",
reviews=[review],
recording={
"start_time": start_time,
"cache_path": cache_path,
"stream_type": "main",
},
)
self.assertIsNone(result)
maintainer.drop_segment.assert_not_called()
maintainer.move_segment.assert_not_awaited()
# Once metadata passes the segment end, continuous retention keeps it.
maintainer.object_recordings_info["test_cam"].append(
(now.timestamp(), [], [], [])
)
await maintainer.validate_and_move_segment(
"test_cam",
reviews=[review],
recording={
"start_time": start_time,
"cache_path": cache_path,
"stream_type": "main",
},
)
maintainer.drop_segment.assert_not_called()
maintainer.move_segment.assert_awaited_once()
async def test_expire_stale_recordings_info_drops_only_absent_cameras(self): async def test_expire_stale_recordings_info_drops_only_absent_cameras(self):
config = MagicMock(spec=FrigateConfig) config = MagicMock(spec=FrigateConfig)
config.cameras = {} config.cameras = {}
-1
View File
@@ -29,7 +29,6 @@ class TestImprovedMotionDetector(unittest.TestCase):
class DummyPTZ: class DummyPTZ:
def __init__(self): def __init__(self):
self.autotracker_enabled = _Stub(False)
self.motor_stopped = _Stub(False) self.motor_stopped = _Stub(False)
self.stop_time = _Stub(0) self.stop_time = _Stub(0)
+28 -10
View File
@@ -126,12 +126,18 @@ class TestMqttClientLifecycle(unittest.TestCase):
os.makedirs(MODEL_CACHE_DIR) os.makedirs(MODEL_CACHE_DIR)
self.config = build_config() self.config = build_config()
self.client = MqttClient(self.config) self.client = self._build_client()
self.receiver = RuntimeSnapshotReceiver() self.receiver = RuntimeSnapshotReceiver()
self.client.attach_dispatcher(build_dispatcher(self.config, [])) self.client.attach_dispatcher(build_dispatcher(self.config, []))
def test_subscribe_stores_receiver_without_starting_worker(self) -> None: def _build_client(self) -> MqttClient:
client = MqttClient(self.config) client = MqttClient(self.config)
self.addCleanup(client._wake_recv.close)
self.addCleanup(client._wake_send.close)
return client
def test_subscribe_stores_receiver_without_starting_worker(self) -> None:
client = self._build_client()
with patch.object(client, "_start_worker") as mock_start_worker: with patch.object(client, "_start_worker") as mock_start_worker:
client.subscribe(self.receiver._receive) client.subscribe(self.receiver._receive)
@@ -142,7 +148,7 @@ class TestMqttClientLifecycle(unittest.TestCase):
mock_start_worker.assert_not_called() mock_start_worker.assert_not_called()
def test_attach_dispatcher_supplies_command_surface(self) -> None: def test_attach_dispatcher_supplies_command_surface(self) -> None:
client = MqttClient(self.config) client = self._build_client()
self.assertFalse(client._is_supported_command_topic("front/detect/set")) self.assertFalse(client._is_supported_command_topic("front/detect/set"))
@@ -295,6 +301,13 @@ class TestMqttClientLifecycle(unittest.TestCase):
self.assertEqual(self.client._subscription_mid, 42) self.assertEqual(self.client._subscription_mid, 42)
self.client.client.subscribe.assert_called_once_with("frigate/#", qos=0) self.client.client.subscribe.assert_called_once_with("frigate/#", qos=0)
def test_publish_wakes_worker(self) -> None:
self.client.connected = True
self.client.publish("events", "payload")
self.assertEqual(self.client._wake_recv.recv(16), b"\0")
def test_handle_connect_event_reconnects_on_recoverable_subscribe_error( def test_handle_connect_event_reconnects_on_recoverable_subscribe_error(
self, self,
) -> None: ) -> None:
@@ -448,7 +461,6 @@ class TestMqttClientLifecycle(unittest.TestCase):
def test_publish_direct_waits_for_flush_barrier(self) -> None: def test_publish_direct_waits_for_flush_barrier(self) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.loop.return_value = mqtt.MQTT_ERR_SUCCESS
self.client.client = mock_client self.client.client = mock_client
message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS, mid=1) message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS, mid=1)
# inflight tracking checks once, then _wait_for_publish polls # inflight tracking checks once, then _wait_for_publish polls
@@ -456,11 +468,14 @@ class TestMqttClientLifecycle(unittest.TestCase):
mock_client.publish.return_value = message_info mock_client.publish.return_value = message_info
barrier = MagicMock() barrier = MagicMock()
with patch.object(
self.client, "_loop_client", return_value=mqtt.MQTT_ERR_SUCCESS
) as mock_loop:
self.client._publish_direct( self.client._publish_direct(
QueuedPublish("frigate/available", "stopped", True, barrier) QueuedPublish("frigate/available", "stopped", True, barrier)
) )
mock_client.loop.assert_called_once() mock_loop.assert_called_once()
barrier.set.assert_called_once() barrier.set.assert_called_once()
def test_shutdown_barrier_releases_when_publish_raises(self) -> None: def test_shutdown_barrier_releases_when_publish_raises(self) -> None:
@@ -643,8 +658,7 @@ class TestMqttClientLifecycle(unittest.TestCase):
loop_calls[0] += 1 loop_calls[0] += 1
return mqtt.MQTT_ERR_SUCCESS return mqtt.MQTT_ERR_SUCCESS
mock_client.loop.side_effect = loop_side_effect with patch.object(self.client, "_loop_client", side_effect=loop_side_effect):
self.client._wait_for_publish(message_info) self.client._wait_for_publish(message_info)
self.assertEqual(loop_calls[0], 1) self.assertEqual(loop_calls[0], 1)
@@ -669,16 +683,20 @@ class TestMqttClientLifecycle(unittest.TestCase):
def test_mqtt_loop_worker_reconnects_on_recoverable_loop_error(self) -> None: def test_mqtt_loop_worker_reconnects_on_recoverable_loop_error(self) -> None:
self.client.client = MagicMock() self.client.client = MagicMock()
self.client.client.loop.side_effect = OSError("socket closed")
def stop_after_reconnect() -> None: def stop_after_reconnect() -> None:
self.client._stop_event.set() self.client._stop_event.set()
with patch.object( with (
patch.object(
self.client, "_loop_client", side_effect=OSError("socket closed")
),
patch.object(
self.client, self.client,
"_schedule_reconnect", "_schedule_reconnect",
side_effect=stop_after_reconnect, side_effect=stop_after_reconnect,
) as mock_schedule_reconnect: ) as mock_schedule_reconnect,
):
self.client._mqtt_loop_worker() self.client._mqtt_loop_worker()
mock_schedule_reconnect.assert_called_once() mock_schedule_reconnect.assert_called_once()
+146
View File
@@ -0,0 +1,146 @@
import fcntl
import resource
import selectors
import socket
import time
import unittest
from unittest.mock import MagicMock, patch
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from frigate.comms.mqtt import MqttClient
class TestMqttNetworkLoop(unittest.TestCase):
def setUp(self) -> None:
self.transport = object.__new__(MqttClient)
self.client = MagicMock()
self.client.want_write.return_value = False
self.client.loop_read.return_value = mqtt.MQTT_ERR_SUCCESS
self.client.loop_write.return_value = mqtt.MQTT_ERR_SUCCESS
self.client.loop_misc.return_value = mqtt.MQTT_ERR_SUCCESS
self.transport.client = self.client
self.sock, self.peer = socket.socketpair()
self.addCleanup(self.sock.close)
self.addCleanup(self.peer.close)
self.transport._wake_recv, self.transport._wake_send = socket.socketpair()
self.transport._wake_recv.setblocking(False)
self.transport._wake_send.setblocking(False)
self.addCleanup(self.transport._wake_recv.close)
self.addCleanup(self.transport._wake_send.close)
self.client.socket.return_value = self.sock
def test_high_fd_handles_connack_suback_publish_and_puback(self) -> None:
"""Process real MQTT packets on a socket beyond select()'s FD limit."""
if selectors.DefaultSelector is selectors.SelectSelector:
self.skipTest("This platform has no selector supporting high socket FDs")
original_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
soft, hard = original_limit
if soft <= 1024:
if hard != resource.RLIM_INFINITY and hard <= 1024:
self.skipTest("The hard file descriptor limit is too low")
new_soft = 2048 if hard == resource.RLIM_INFINITY else min(2048, hard)
resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard))
self.addCleanup(resource.setrlimit, resource.RLIMIT_NOFILE, original_limit)
fd = fcntl.fcntl(self.sock.fileno(), fcntl.F_DUPFD, 1024)
with socket.socket(fileno=fd) as high_sock:
high_sock.setblocking(False)
self.peer.settimeout(1)
client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="high-fd-test")
client._sock = high_sock
self.transport.client = client
connected, subscribed, received = [], [], []
client.on_connect = lambda *args: connected.append(args[3])
client.on_subscribe = lambda *args: subscribed.append(args[2])
client.on_message = lambda client, userdata, message: received.append(
message.payload
)
self.assertGreaterEqual(high_sock.fileno(), 1024)
self.peer.sendall(b"\x20\x02\x00\x00")
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
self.assertEqual(len(connected), 1)
self.assertTrue(client.is_connected())
result, mid = client.subscribe("diagnostic", qos=1)
self.assertEqual(result, mqtt.MQTT_ERR_SUCCESS)
self.peer.recv(1024)
self.peer.sendall(b"\x90\x03" + mid.to_bytes(2, "big") + b"\x01")
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
self.assertEqual(subscribed, [mid])
info = client.publish("diagnostic", b"outgoing", qos=1)
self.peer.recv(1024)
self.peer.sendall(b"\x40\x02" + info.mid.to_bytes(2, "big"))
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
self.assertTrue(info.is_published())
payload = b"\x00\x0adiagnosticincoming"
self.peer.sendall(b"\x30" + bytes([len(payload)]) + payload)
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
self.assertEqual(received, [b"incoming"])
def test_idle_socket_still_runs_keepalive(self) -> None:
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_SUCCESS)
self.client.loop_read.assert_not_called()
self.client.loop_write.assert_not_called()
self.client.loop_misc.assert_called_once()
def test_writable_socket_flushes_pending_packets(self) -> None:
self.client.want_write.return_value = True
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_SUCCESS)
self.client.loop_write.assert_called_once()
self.client.loop_read.assert_not_called()
def test_tls_buffer_is_read_without_waiting_for_socket_readiness(self) -> None:
tls_sock = MagicMock()
tls_sock.fileno.return_value = self.sock.fileno()
tls_sock.pending.return_value = 1
self.client.socket.return_value = tls_sock
with patch("frigate.comms.mqtt.selectors.DefaultSelector") as selector:
selector.return_value.__enter__.return_value.select.return_value = []
self.assertEqual(self.transport._loop_client(1), mqtt.MQTT_ERR_SUCCESS)
selector.return_value.__enter__.return_value.select.assert_called_once_with(
0.0
)
self.client.loop_read.assert_called_once()
def test_read_failure_does_not_write_or_run_keepalive(self) -> None:
self.peer.sendall(b"ready")
self.client.want_write.return_value = True
self.client.loop_read.return_value = mqtt.MQTT_ERR_CONN_LOST
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
self.client.loop_write.assert_not_called()
self.client.loop_misc.assert_not_called()
def test_socket_closed_during_read_does_not_write(self) -> None:
self.peer.sendall(b"ready")
self.client.want_write.return_value = True
self.client.socket.side_effect = [self.sock, None]
self.transport._loop_client(0)
self.client.loop_write.assert_not_called()
self.client.loop_misc.assert_not_called()
def test_write_failure_does_not_run_keepalive(self) -> None:
self.client.want_write.return_value = True
self.client.loop_write.return_value = mqtt.MQTT_ERR_CONN_LOST
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
self.client.loop_misc.assert_not_called()
def test_missing_socket_reports_no_connection(self) -> None:
self.client.socket.return_value = None
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
def test_wake_interrupts_wait_and_is_consumed(self) -> None:
self.transport._wake_worker()
start = time.monotonic()
self.assertEqual(self.transport._loop_client(5), mqtt.MQTT_ERR_SUCCESS)
self.assertLess(time.monotonic() - start, 1)
self.client.loop_read.assert_not_called()
start = time.monotonic()
self.transport._loop_client(0.2)
self.assertGreater(time.monotonic() - start, 0.15)
+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 - autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
constructed, so runtime-enabled cameras raised KeyError on lookup. constructed, so runtime-enabled cameras raised KeyError on lookup.
- ptz_metrics autotracker_enabled is what the camera processes read, but nothing - _disable only changed the main process config, so the camera process kept
updated it when autotracking was enabled through a config save, so it stayed running its motion estimator for a camera that could not autotrack.
False and the tracker never built a motion estimator.
""" """
import unittest import unittest
@@ -17,7 +16,8 @@ from unittest.mock import MagicMock
from frigate.camera import PTZMetrics from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig 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" 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.""" onvif over the network. Only the config/metrics state is relevant here."""
tracker = PtzAutoTracker.__new__(PtzAutoTracker) tracker = PtzAutoTracker.__new__(PtzAutoTracker)
tracker.config = _config(autotracking_enabled) tracker.config = _config(autotracking_enabled)
tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)} tracker.ptz_metrics = {CAMERA: PTZMetrics()}
tracker.onvif = MagicMock() tracker.onvif = MagicMock()
tracker.dispatcher = MagicMock()
tracker.config_subscriber = MagicMock() tracker.config_subscriber = MagicMock()
tracker.autotracker_init = {} tracker.autotracker_init = {}
tracker.calibrating = {} tracker.calibrating = {}
@@ -83,47 +84,49 @@ class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
tracker.onvif.get_camera_status.assert_not_called() tracker.onvif.get_camera_status.assert_not_called()
class TestAutotrackerMetricSync(unittest.TestCase): class TestAutotrackerEnqueueMove(unittest.TestCase):
def test_metric_follows_config_when_enabled_by_update(self) -> None: def _enqueue(self, pan: float, tilt: float, zoom: float) -> MagicMock:
# autotracking enabled via a config save: the metric was seeded False when tracker = _make_tracker()
# the camera was added and nothing else updates it 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) 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._disable(CAMERA, "onvif connection failed")
tracker.check_for_updates()
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: publish = tracker.dispatcher.config_updater.publish_update
tracker = _make_tracker(autotracking_enabled=False) publish.assert_called_once()
metrics = tracker.ptz_metrics[CAMERA] topic, payload = publish.call_args.args
metrics.autotracker_enabled.value = True 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) class TestMaxTargetBox(unittest.TestCase):
def test_follows_zoom_factor(self) -> None:
def test_metric_sync_skips_camera_without_metrics(self) -> None: self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
# `add` reaches the maintainer and the autotracker on separate threads with self.assertAlmostEqual(calculate_max_target_box(0.25), 0.6**4)
# 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()
if __name__ == "__main__": if __name__ == "__main__":
+17 -39
View File
@@ -2,14 +2,9 @@
Regression coverage for a camera that is initialized while autotracking is off and 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, 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 configure autotracking afterwards. get_camera_status skips its re-init branch when
be created only when autotracking was enabled at init time, so the camera was left init is True, so everything it reads must exist whether or not autotracking was
with init=True but no status_request. get_camera_status skips its re-init branch enabled at init time.
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 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. 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.config = config
controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}} controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
controller.failed_cams = {} controller.failed_cams = {}
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
controller.ptz_metrics = {CAMERA: MagicMock()} controller.ptz_metrics = {CAMERA: MagicMock()}
return controller return controller
@@ -110,7 +104,6 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
config = _config(autotracking_enabled) config = _config(autotracking_enabled)
controller = OnvifController.__new__(OnvifController) controller = OnvifController.__new__(OnvifController)
controller.config = config controller.config = config
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
controller.failed_cams = {} controller.failed_cams = {}
ptz = MagicMock() ptz = MagicMock()
@@ -128,45 +121,30 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
}, },
} }
} }
controller.ptz_metrics = { controller.ptz_metrics = {CAMERA: PTZMetrics()}
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
}
return controller return controller
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase): 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 # 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): for autotracking_enabled in (True, False):
with self.subTest(autotracking_enabled=autotracking_enabled): with self.subTest(autotracking_enabled=autotracking_enabled):
controller = _make_controller(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] status = MagicMock()
if cam["init"]: status.MoveStatus.PanTilt = "IDLE"
self.assertEqual(cam["status_request"].request_type, "GetStatus") 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: async def test_requests_built_without_contacting_camera(self) -> None:
# create_type is a local WSDL lookup; cameras that do not implement # create_type is a local WSDL lookup; cameras that do not implement
@@ -1,5 +1,6 @@
"""Tests for sub stream cache segment handling in the recording maintainer.""" """Tests for sub stream cache segment handling in the recording maintainer."""
import asyncio
import datetime import datetime
import os import os
import tempfile import tempfile
@@ -532,6 +533,59 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3) self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3)
self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3) self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3)
async def test_out_of_order_probes_chain_in_segment_order(self):
maintainer = _build_chaining_maintainer(self.T0)
async def probe(_ffmpeg, cache_path, get_duration=False):
# the earlier segment's probe finishes last
if "chain0" in cache_path:
await asyncio.sleep(0.05)
return {"has_valid_video": True, "duration": 10.4}
recordings = [
{
"start_time": datetime.datetime.fromtimestamp(
self.T0 + offset, tz=datetime.UTC
),
"cache_path": f"/tmp/cache/test_cam@chain{offset}.mp4",
"stream_type": "main",
}
for offset in (0, 10)
]
first_resolved = asyncio.Event()
with (
patch("frigate.record.maintainer.get_video_properties", probe),
patch(
"frigate.record.maintainer.get_keyframe_offsets",
AsyncMock(return_value=[0]),
),
patch(
"frigate.record.maintainer.os.path.getmtime",
MagicMock(side_effect=OSError("missing")),
),
):
await asyncio.gather(
maintainer._validate_in_order(
"test_cam", [], recordings[0], None, first_resolved
),
maintainer._validate_in_order(
"test_cam", [], recordings[1], first_resolved, asyncio.Event()
),
)
starts = sorted(
call.args[2].timestamp() for call in maintainer.move_segment.await_args_list
)
self.assertEqual(starts[0], self.T0)
self.assertAlmostEqual(starts[1], self.T0 + 10.4, places=3)
self.assertAlmostEqual(
maintainer.last_segment_end[("test_cam", "main")],
self.T0 + 20.8,
places=3,
)
async def test_genuine_gap_is_not_snapped(self): async def test_genuine_gap_is_not_snapped(self):
maintainer = _build_chaining_maintainer(self.T0) maintainer = _build_chaining_maintainer(self.T0)
+73
View File
@@ -1,10 +1,13 @@
"""Tests for tracker-derived review frame annotations.""" """Tests for tracker-derived review frame annotations."""
import unittest import unittest
from unittest.mock import patch
from frigate.data_processing.post.review_annotations import ( from frigate.data_processing.post.review_annotations import (
annotations_by_frame, annotations_by_frame,
build_frame_captions,
build_timeline, build_timeline,
describe_classification_change,
describe_heading, describe_heading,
describe_position, describe_position,
event_name, event_name,
@@ -312,5 +315,75 @@ class TestFrameBucketing(unittest.TestCase):
self.assertEqual(annotations_by_frame([(1.0, "x")], []), {}) self.assertEqual(annotations_by_frame([(1.0, "x")], []), {})
class TestClassificationChangeCaptions(unittest.TestCase):
def setUp(self):
person = track(
"1789481994.684479-lpyc2z",
"person",
0.0,
straight_path((0.9, 0.6), (0.4, 0.3), 10, 0.0),
)
patcher = patch(
"frigate.data_processing.post.review_annotations.get_tracked_events",
return_value=[person],
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = patch(
"frigate.data_processing.post.review_annotations.get_state_changes",
return_value=[],
)
patcher.start()
self.addCleanup(patcher.stop)
def test_change_is_phrased_without_underscores(self):
self.assertEqual(
describe_classification_change(
{"model": "trash_day", "from": "no_bins", "to": "bins_at_curb"}
),
"trash day changed from no bins to bins at curb",
)
def test_change_is_noted_before_the_frame_it_precedes(self):
change = {"model": "front_gate", "from": "closed", "to": "open"}
captions = build_frame_captions(
["1789481994.684479-lpyc2z"],
[0.0, 10.0, 20.0],
[{**change, "timestamp": 12.0}],
)
self.assertEqual(
captions[2],
"Frame 3 of 3 (+20.0s):\n[state] front gate changed from closed to open",
)
self.assertTrue(captions[0].splitlines()[1].startswith("[tracker] "))
def test_change_is_noted_without_tracked_objects(self):
with patch(
"frigate.data_processing.post.review_annotations.get_tracked_events",
return_value=[],
):
captions = build_frame_captions(
[],
[0.0, 10.0],
[{"model": "gate", "from": "a", "to": "b", "timestamp": 5.0}],
)
self.assertEqual(
captions,
[
"Frame 1 of 2 (+0.0s):",
"Frame 2 of 2 (+10.0s):\n[state] gate changed from a to b",
],
)
def test_change_after_the_last_frame_is_dropped(self):
captions = build_frame_captions(
["1789481994.684479-lpyc2z"],
[0.0, 10.0, 20.0],
[{"model": "gate", "from": "a", "to": "b", "timestamp": 25.0}],
)
self.assertFalse(any("[state]" in caption for caption in captions))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,258 @@
"""Tests for attaching state classification changes to review items."""
import unittest
from unittest.mock import MagicMock, patch
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
from frigate.config import FrigateConfig
from frigate.data_processing.post.review_descriptions import (
ReviewDescriptionProcessor,
format_classification_state_changes,
)
from frigate.data_processing.real_time.custom_classification import (
CustomStateClassificationProcessor,
)
from frigate.models import ReviewSegment
from frigate.review.maintainer import (
CLASSIFICATION_STATE_PRE_ROLL,
PendingReviewSegment,
ReviewSegmentMaintainer,
)
from frigate.review.types import SeverityEnum
CONFIG = """
mqtt:
enabled: False
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://10.0.0.1:554/video
roles:
- detect
detect:
width: 1920
height: 1080
fps: 5
"""
def gate_change(timestamp: float, before: str = "closed", after: str = "open"):
return {"model": "front_gate", "from": before, "to": after, "timestamp": timestamp}
class TestVerifyStateChange(unittest.TestCase):
def setUp(self):
self.processor = CustomStateClassificationProcessor.__new__(
CustomStateClassificationProcessor
)
self.processor.state_history = {}
def verify(self, state: str, timestamp: float):
return self.processor.verify_state_change("front_door", state, timestamp)
def test_first_verified_state_has_no_previous_state(self):
self.assertIsNone(self.verify("closed", 1.0))
self.assertIsNone(self.verify("closed", 2.0))
self.assertEqual(self.verify("closed", 3.0), (None, 1.0))
def test_change_reports_previous_state_and_first_sighting(self):
for timestamp in (1.0, 2.0, 3.0):
self.verify("closed", timestamp)
self.assertIsNone(self.verify("open", 10.0))
self.assertIsNone(self.verify("open", 11.0))
self.assertEqual(self.verify("open", 12.0), ("closed", 10.0))
def test_interrupted_verification_restarts_the_first_sighting(self):
for timestamp in (1.0, 2.0, 3.0):
self.verify("closed", timestamp)
self.verify("open", 10.0)
self.verify("closed", 11.0)
self.verify("open", 20.0)
self.verify("open", 21.0)
self.assertEqual(self.verify("open", 22.0), ("closed", 20.0))
def test_reload_forgets_states_the_model_no_longer_has(self):
for timestamp in (1.0, 2.0, 3.0):
self.verify("closed", timestamp)
self.processor.state_history["back_door"] = {"current_state": "open"}
self.processor.labelmap = {0: "open", 1: "shut"}
self.processor._forget_unknown_states()
self.assertEqual(list(self.processor.state_history), ["back_door"])
self.assertIsNone(self.verify("shut", 10.0))
self.assertIsNone(self.verify("shut", 11.0))
self.assertEqual(self.verify("shut", 12.0), (None, 10.0))
class TestReviewSegmentAttachment(unittest.TestCase):
def setUp(self):
self.maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
self.maintainer.config = FrigateConfig.parse_yaml(CONFIG)
self.maintainer.active_review_segments = {}
self.maintainer.recent_classification_state_changes = {}
self.maintainer._publish_segment_update = MagicMock()
def segment(self, start_time: float) -> PendingReviewSegment:
return PendingReviewSegment(
"front_door",
start_time,
SeverityEnum.alert,
{"1.0-abcdef": "person"},
{},
[],
set(),
)
def test_change_during_segment_is_attached_and_published(self):
segment = self.segment(100.0)
self.maintainer.active_review_segments["front_door"] = segment
self.maintainer.handle_classification_state_change(
"front_door", gate_change(110.0)
)
self.assertEqual(segment.classification_state_changes, [gate_change(110.0)])
self.maintainer._publish_segment_update.assert_called_once()
prev_data = self.maintainer._publish_segment_update.call_args.args[4]
self.assertEqual(prev_data["data"]["classification_state_changes"], [])
self.assertEqual(
segment.get_data(False)["data"]["classification_state_changes"],
[gate_change(110.0)],
)
def test_change_never_starts_a_segment(self):
self.maintainer.handle_classification_state_change(
"front_door", gate_change(110.0)
)
self.assertIsNone(self.maintainer.active_review_segments.get("front_door"))
self.maintainer._publish_segment_update.assert_not_called()
def test_change_just_before_a_segment_is_attached_when_it_starts(self):
self.maintainer.handle_classification_state_change(
"front_door", gate_change(98.0)
)
segment = self.segment(100.0)
self.maintainer._activate_segment(segment)
self.assertIs(self.maintainer.active_review_segments["front_door"], segment)
self.assertEqual(segment.classification_state_changes, [gate_change(98.0)])
self.assertNotIn(
"front_door", self.maintainer.recent_classification_state_changes
)
def test_change_long_before_a_segment_is_not_attached(self):
self.maintainer.handle_classification_state_change(
"front_door", gate_change(100.0 - CLASSIFICATION_STATE_PRE_ROLL - 1)
)
segment = self.segment(100.0)
self.maintainer._activate_segment(segment)
self.assertEqual(segment.classification_state_changes, [])
def test_held_changes_are_pruned(self):
self.maintainer.handle_classification_state_change(
"front_door", gate_change(10.0)
)
self.maintainer.handle_classification_state_change(
"front_door", gate_change(50.0, "open", "closed")
)
self.assertEqual(
self.maintainer.recent_classification_state_changes["front_door"],
[gate_change(50.0, "open", "closed")],
)
class TestChangeTiming(unittest.TestCase):
def test_changes_are_placed_relative_to_the_activity(self):
lines = format_classification_state_changes(
[
gate_change(98.0),
gate_change(112.4, "open", "closed"),
gate_change(140.0),
],
start_time=100.0,
end_time=130.0,
)
self.assertEqual(
lines,
[
"front gate changed from closed to open, "
"just before the activity started",
"front gate changed from open to closed, 12s into the activity",
"front gate changed from closed to open, after the activity ended",
],
)
class TestSummaryContext(unittest.TestCase):
def row(self, camera, start, end, threat, changes=()):
return {
"camera": camera,
"start_time": start,
"end_time": end,
"data": {
"metadata": {"title": camera, "potential_threat_level": threat},
"classification_state_changes": list(changes),
},
}
def summarize(self, rows):
processor = ReviewDescriptionProcessor.__new__(ReviewDescriptionProcessor)
processor.config = FrigateConfig.parse_yaml(CONFIG)
processor.genai_manager = MagicMock()
client = processor.genai_manager.description_client
with patch.object(ReviewSegment, "select") as select:
query = select.return_value.where.return_value.order_by.return_value
query.dicts.return_value.iterator.return_value = iter(rows)
processor.handle_request(
EmbeddingsRequestEnum.summarize_review.value,
{"start_ts": 0, "end_ts": 100},
)
return client.generate_review_summary.call_args.args[2]
def test_context_state_changes_stay_with_their_review(self):
events = self.summarize(
[
self.row("front_door", 10, 60, 1),
self.row("driveway", 15, 25, 0, [gate_change(20.0)]),
self.row("driveway", 30, 40, 0, [gate_change(35.0, "open", "closed")]),
]
)
self.assertEqual(
[
(item["start_time"], item["end_time"], item["state_changes"])
for item in events[0]["context"]
],
[
(15, 25, ["front gate changed from closed to open"]),
(30, 40, ["front gate changed from open to closed"]),
],
)
def test_later_context_review_without_changes_is_deduplicated(self):
events = self.summarize(
[
self.row("front_door", 10, 60, 1),
self.row("driveway", 15, 25, 0, [gate_change(20.0)]),
self.row("driveway", 30, 40, 0),
]
)
self.assertEqual(len(events[0]["context"]), 1)
self.assertEqual(events[0]["context"][0]["start_time"], 15)
if __name__ == "__main__":
unittest.main()
+857
View File
@@ -0,0 +1,857 @@
"""Tests for how tracked objects flow into review segments.
Frames are fed through the maintainer's run loop with mocked subscribers so
the dispatch between starting and updating segments is exercised, and the
published review updates are checked for the expected alert, detection, or
lack of a review item.
"""
import json
import tempfile
import threading
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import numpy as np
from frigate.comms.detections_updater import DetectionTypeEnum
from frigate.config import FrigateConfig
from frigate.review.maintainer import ReviewSegmentMaintainer
CAMERA = "front_door"
BASE_CONFIG = """
mqtt:
enabled: False
record:
enabled: True
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://10.0.0.1:554/video
roles:
- detect
detect:
width: 640
height: 360
fps: 5
zones:
driveway:
coordinates: 0,0,320,0,320,360,0,360
yard:
coordinates: 320,0,640,0,640,360,320,360
%s
"""
class ReviewFlowTestCase(unittest.TestCase):
review_config = ""
def setUp(self) -> None:
self.clips_dir = clips_dir = tempfile.TemporaryDirectory()
self.addCleanup(clips_dir.cleanup)
clips_patch = patch("frigate.review.maintainer.CLIPS_DIR", clips_dir.name)
clips_patch.start()
self.addCleanup(clips_patch.stop)
self.maintainer = self._make_maintainer(self.review_config)
def _make_maintainer(self, review_config: str) -> ReviewSegmentMaintainer:
"""Build a maintainer without invoking __init__ (avoids needing ZMQ
sockets and shared memory)."""
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
threading.Thread.__init__(maintainer)
maintainer.config = FrigateConfig.parse_yaml(BASE_CONFIG % review_config)
maintainer.active_review_segments = {}
maintainer.indefinite_events = {}
maintainer.recent_classification_state_changes = {}
maintainer.requestor = MagicMock()
maintainer.review_publisher = MagicMock()
maintainer.config_subscriber = MagicMock()
maintainer.config_subscriber.check_for_updates.return_value = {}
maintainer.detection_subscriber = MagicMock()
maintainer.frame_manager = MagicMock()
maintainer.frame_manager.get.side_effect = lambda _name, shape: np.zeros(
shape, np.uint8
)
return maintainer
@property
def stationary_threshold(self) -> int:
return self.maintainer.config.cameras[CAMERA].detect.stationary.threshold
@property
def alert_cutoff(self) -> int:
return self.maintainer.config.cameras[CAMERA].review.alerts.cutoff_time
@property
def detection_cutoff(self) -> int:
return self.maintainer.config.cameras[CAMERA].review.detections.cutoff_time
def tracked(
self,
obj_id: str,
label: str,
frame_time: float,
*,
start_time: float = 0,
zones: list[str] | None = None,
stationary: bool = False,
loitering: bool = False,
moved: bool = True,
false_positive: bool = False,
sub_label: tuple[str, float] | None = None,
) -> dict[str, Any]:
"""Build a tracked object as published by the object processor."""
return {
"id": obj_id,
"label": label,
"sub_label": sub_label,
"frame_time": frame_time,
"start_time": start_time,
"motionless_count": self.stationary_threshold if stationary else 0,
"pending_loitering": loitering,
"position_changes": 1 if moved else 0,
"false_positive": false_positive,
"current_zones": zones or [],
"box": (100, 100, 200, 200),
}
def feed(self, *frames: tuple[float, list[dict[str, Any]]]) -> None:
"""Run the maintainer loop over the given (frame_time, objects) frames."""
queue = [
(
DetectionTypeEnum.video.value,
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
)
for frame_time, objects in frames
]
self.maintainer.stop_event = threading.Event()
def next_update(timeout: float) -> Any:
if not queue:
self.maintainer.stop_event.set()
return None
return queue.pop(0)
self.maintainer.detection_subscriber.check_for_update.side_effect = next_update
self.maintainer.run()
def reviews(self) -> list[dict[str, Any]]:
"""All review updates published on the reviews topic."""
return [
json.loads(c.args[1])
for c in self.maintainer.requestor.send_data.call_args_list
if c.args[0] == "reviews"
]
def review_summary(self) -> list[tuple[str, str]]:
return [(r["type"], r["after"]["severity"]) for r in self.reviews()]
def non_update_summary(self) -> list[tuple[str, str]]:
return [(t, s) for t, s in self.review_summary() if t != "update"]
def thumbnails(self) -> set[str]:
"""Names of the review thumbnails on disk."""
return {t.name for t in Path(self.clips_dir.name, "review").iterdir()}
def assert_no_review(self) -> None:
self.assertEqual(self.reviews(), [])
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
class TestReviewSeverity(ReviewFlowTestCase):
def test_alert_label_creates_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "alert")])
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["person"])
def test_non_alert_label_creates_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["dog"])
def test_alert_and_detection_objects_create_single_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]))
self.assertEqual(self.review_summary(), [("new", "alert")])
self.assertCountEqual(
self.reviews()[0]["after"]["data"]["objects"], ["person", "dog"]
)
def test_no_objects_creates_nothing(self) -> None:
self.feed((1, []), (2, []))
self.assert_no_review()
class TestIgnoredObjects(ReviewFlowTestCase):
def test_stationary_object_creates_nothing(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, stationary=True)]))
self.assert_no_review()
def test_stationary_loitering_object_creates_alert(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, stationary=True, loitering=True)])
)
self.assertEqual(self.review_summary(), [("new", "alert")])
def test_object_that_never_moved_creates_nothing(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, moved=False)]))
self.assert_no_review()
def test_object_not_detected_in_current_frame_creates_nothing(self) -> None:
self.feed((2, [self.tracked("p1", "person", 1)]))
self.assert_no_review()
def test_false_positive_creates_nothing(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, false_positive=True)]))
self.assert_no_review()
class TestAlertRequiredZones(ReviewFlowTestCase):
review_config = """
review:
alerts:
required_zones: driveway
"""
def test_alert_label_in_required_zone_creates_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, zones=["driveway"])]))
self.assertEqual(self.review_summary(), [("new", "alert")])
self.assertEqual(self.reviews()[0]["after"]["data"]["zones"], ["driveway"])
def test_alert_label_outside_required_zone_creates_detection(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, zones=["yard"])]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_alert_label_in_no_zone_creates_detection(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_detection_upgrades_to_alert_when_object_enters_required_zone(
self,
) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, zones=["yard"])]),
(2, [self.tracked("p1", "person", 2, zones=["driveway"])]),
)
self.assertEqual(
self.review_summary(), [("new", "detection"), ("update", "alert")]
)
self.assertEqual(
self.reviews()[0]["after"]["id"], self.reviews()[1]["after"]["id"]
)
class TestDetectionRequiredZones(ReviewFlowTestCase):
review_config = """
review:
detections:
required_zones: yard
"""
def test_detection_label_in_required_zone_creates_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1, zones=["yard"])]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_detection_label_outside_required_zone_creates_nothing(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1, zones=["driveway"])]))
self.assert_no_review()
def test_alert_label_ignores_detection_required_zones(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, zones=["driveway"])]))
self.assertEqual(self.review_summary(), [("new", "alert")])
def test_activity_outside_required_zone_after_alert_is_not_held(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5, zones=["driveway"])]),
)
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].pending_detections, []
)
self.feed((2 + self.alert_cutoff, []))
self.assertEqual(
self.non_update_summary(), [("new", "alert"), ("end", "alert")]
)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
self.assertEqual(len(self.thumbnails()), 1)
def test_activity_inside_required_zone_after_alert_is_split_out(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5, zones=["yard"])]),
(2 + self.alert_cutoff, []),
)
# the dog is past the detection cutoff, so it is ended right away
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
detection = self.reviews()[-1]["after"]
self.assertEqual(detection["data"]["objects"], ["dog"])
self.assertEqual(detection["data"]["zones"], ["yard"])
class TestDetectionLabels(ReviewFlowTestCase):
review_config = """
review:
detections:
labels:
- dog
"""
def test_listed_label_creates_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_unlisted_label_creates_nothing(self) -> None:
self.feed((1, [self.tracked("c1", "cat", 1)]))
self.assert_no_review()
def test_unlisted_object_is_left_out_of_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1), self.tracked("c1", "cat", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["dog"])
class TestAlertsDisabled(ReviewFlowTestCase):
review_config = """
review:
alerts:
enabled: False
"""
def test_alert_label_creates_detection(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
class TestDetectionsDisabled(ReviewFlowTestCase):
review_config = """
review:
detections:
enabled: False
"""
def test_alert_label_creates_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "alert")])
def test_non_alert_label_creates_nothing(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1)]))
self.assert_no_review()
def test_activity_after_alert_is_not_held(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
)
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].pending_detections, []
)
self.feed((2 + self.alert_cutoff, []))
self.assertEqual(
self.non_update_summary(), [("new", "alert"), ("end", "alert")]
)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
self.assertEqual(len(self.thumbnails()), 1)
class TestAlertsAndDetectionsDisabled(ReviewFlowTestCase):
review_config = """
review:
alerts:
enabled: False
detections:
enabled: False
"""
def test_nothing_is_created(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]))
self.assert_no_review()
class TestReviewLifecycle(ReviewFlowTestCase):
def test_detection_upgrades_to_alert_when_alert_object_appears(self) -> None:
self.feed(
(1, [self.tracked("d1", "dog", 1)]),
(2, [self.tracked("d1", "dog", 2), self.tracked("p1", "person", 2)]),
)
self.assertEqual(
self.review_summary(), [("new", "detection"), ("update", "alert")]
)
new, update = self.reviews()
self.assertEqual(new["after"]["id"], update["after"]["id"])
self.assertCountEqual(update["after"]["data"]["objects"], ["dog", "person"])
def test_alert_does_not_downgrade_when_only_detection_objects_remain(
self,
) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]),
(2, [self.tracked("d1", "dog", 2)]),
(3, [self.tracked("d1", "dog", 3)]),
)
self.assertEqual({severity for _, severity in self.review_summary()}, {"alert"})
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].severity.value, "alert"
)
def test_alert_stays_open_until_cutoff(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(1 + self.alert_cutoff, []),
)
self.assertNotIn("end", [t for t, _ in self.review_summary()])
self.assertIsNotNone(self.maintainer.active_review_segments.get(CAMERA))
def test_alert_ends_after_cutoff_at_last_alert_activity(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(5, [self.tracked("p1", "person", 5)]),
(6, []),
(5 + self.alert_cutoff + 1, []),
)
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["severity"], "alert")
self.assertEqual(end["after"]["end_time"], 5)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
def test_ongoing_alert_activity_extends_alert(self) -> None:
last_activity = 1 + self.alert_cutoff * 2
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(
1 + self.alert_cutoff,
[self.tracked("p1", "person", 1 + self.alert_cutoff)],
),
(last_activity, [self.tracked("p1", "person", last_activity)]),
(last_activity + 1, []),
)
self.assertNotIn("end", [t for t, _ in self.review_summary()])
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].last_alert_time,
last_activity,
)
def test_detection_ends_after_cutoff_at_last_detection_activity(self) -> None:
self.feed(
(1, [self.tracked("d1", "dog", 1)]),
(5, [self.tracked("d1", "dog", 5)]),
(5 + self.detection_cutoff, []),
)
self.assertNotIn("end", [t for t, _ in self.review_summary()])
self.feed((5 + self.detection_cutoff + 1, []))
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["severity"], "detection")
self.assertEqual(end["after"]["end_time"], 5)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
def test_stationary_object_does_not_extend_alert(self) -> None:
self.feed(
(1, [self.tracked("c1", "car", 1)]),
(2, [self.tracked("c1", "car", 2, stationary=True)]),
(
2 + self.alert_cutoff,
[self.tracked("c1", "car", 2 + self.alert_cutoff, stationary=True)],
),
)
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["end_time"], 1)
def test_new_activity_after_end_creates_new_review(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(2 + self.alert_cutoff, []),
(3 + self.alert_cutoff, [self.tracked("d1", "dog", 3 + self.alert_cutoff)]),
)
self.assertEqual(
self.non_update_summary(),
[("new", "alert"), ("end", "alert"), ("new", "detection")],
)
ids = {r["after"]["id"] for r in self.reviews() if r["type"] != "update"}
self.assertEqual(len(ids), 2)
def test_alert_splits_into_detection_when_detection_activity_continues(
self,
) -> None:
# the person leaves after the first frame while the dog keeps moving
dog_frames = [
(t, [self.tracked("d1", "dog", t, start_time=1)])
for t in range(11, 2 + self.alert_cutoff + 10, 10)
]
self.feed(
(
1,
[
self.tracked("p1", "person", 1, start_time=1),
self.tracked("d1", "dog", 1, start_time=1),
],
),
*dog_frames,
)
self.assertEqual(
self.non_update_summary(),
[("new", "alert"), ("end", "alert"), ("new", "detection")],
)
alert_end = next(r for r in self.reviews() if r["type"] == "end")
self.assertEqual(alert_end["after"]["end_time"], 1)
detection = self.maintainer.active_review_segments[CAMERA]
self.assertEqual(detection.severity.value, "detection")
self.assertEqual(detection.start_time, 11)
self.assertEqual(list(detection.detections.values()), ["dog"])
# the detection ends once the dog stops moving
last_dog_time = dog_frames[-1][0]
self.feed((last_dog_time + self.detection_cutoff + 1, []))
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["severity"], "detection")
self.assertEqual(end["after"]["id"], detection.id)
self.assertEqual(end["after"]["end_time"], last_dog_time)
def test_detection_starting_after_alert_activity_is_split_out(self) -> None:
# the dog only shows up after the person has left
dog_frames = [
(t, [self.tracked("d1", "dog", t, start_time=11)])
for t in range(11, 2 + self.alert_cutoff + 10, 10)
]
self.feed((1, [self.tracked("p1", "person", 1, start_time=1)]), *dog_frames)
self.assertEqual(
self.non_update_summary(),
[("new", "alert"), ("end", "alert"), ("new", "detection")],
)
alert_end = next(r for r in self.reviews() if r["type"] == "end")
self.assertEqual(alert_end["after"]["end_time"], 1)
self.assertEqual(alert_end["after"]["data"]["objects"], ["person"])
detection = self.maintainer.active_review_segments[CAMERA]
self.assertEqual(detection.severity.value, "detection")
self.assertEqual(detection.start_time, 11)
self.assertEqual(list(detection.detections.values()), ["dog"])
def test_detection_leaving_before_alert_cutoff_gets_detection(self) -> None:
# the dog comes and goes after the person left, all before the alert
# cutoff, so nothing is active when the alert ends
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(11, [self.tracked("d1", "dog", 11, start_time=11)]),
(21, [self.tracked("d1", "dog", 21, start_time=11)]),
(31, []),
(2 + self.alert_cutoff, []),
(22 + self.detection_cutoff, []),
)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(ends[0]["end_time"], 1)
self.assertEqual(ends[0]["data"]["objects"], ["person"])
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
self.assertEqual(ends[1]["start_time"], 11)
self.assertEqual(ends[1]["end_time"], 21)
def test_detection_older_than_detection_cutoff_gets_detection(self) -> None:
# the dog is gone longer than the detection cutoff by the time the
# alert ends, its activity still needs a detection
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(3, [self.tracked("d1", "dog", 3, start_time=3)]),
(5, [self.tracked("d1", "dog", 5, start_time=3)]),
(6, []),
(2 + self.alert_cutoff, []),
(3 + self.alert_cutoff, []),
)
self.assertGreater(2 + self.alert_cutoff, 5 + self.detection_cutoff)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(ends[0]["end_time"], 1)
self.assertEqual(ends[0]["data"]["objects"], ["person"])
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
self.assertEqual(ends[1]["start_time"], 3)
self.assertEqual(ends[1]["end_time"], 5)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
def test_separate_detection_activity_after_alert_is_not_combined(self) -> None:
# the dog and cat are seen further apart than the detection cutoff
# while the alert is waiting to be cut off
cat_time = 3 + self.detection_cutoff + 6
self.assertLess(cat_time, 1 + self.alert_cutoff)
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(3, [self.tracked("d1", "dog", 3, start_time=3)]),
(4, []),
(cat_time, [self.tracked("c1", "cat", cat_time, start_time=cat_time)]),
(2 + self.alert_cutoff, []),
(cat_time + self.detection_cutoff + 1, []),
)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
("new", "detection"),
("end", "detection"),
],
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(ends[0]["data"]["objects"], ["person"])
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
self.assertEqual((ends[1]["start_time"], ends[1]["end_time"]), (3, 3))
self.assertEqual(ends[2]["data"]["objects"], ["cat"])
self.assertEqual(
(ends[2]["start_time"], ends[2]["end_time"]), (cat_time, cat_time)
)
for detection in ends[1:]:
self.assertTrue(Path(detection["thumb_path"]).is_file())
self.assertIsNotNone(detection["data"]["thumb_time"])
def test_resumed_alert_publishes_pending_detection_objects(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
(10, [self.tracked("p1", "person", 10, start_time=1)]),
)
latest = self.reviews()[-1]
self.assertEqual(latest["type"], "update")
self.assertEqual(latest["after"]["severity"], "alert")
self.assertCountEqual(latest["after"]["data"]["objects"], ["person", "dog"])
def test_split_detection_has_thumbnail_of_its_activity(self) -> None:
dog_frames = [
(t, [self.tracked("d1", "dog", t, start_time=11)])
for t in range(11, 2 + self.alert_cutoff + 10, 10)
]
self.feed((1, [self.tracked("p1", "person", 1, start_time=1)]), *dog_frames)
new_detection = next(
r["after"]
for r in self.reviews()
if r["type"] == "new" and r["after"]["severity"] == "detection"
)
self.assertTrue(Path(new_detection["thumb_path"]).is_file())
# captured from the dog's first frame, not a later fallback frame
self.assertIsNotNone(new_detection["data"]["thumb_time"])
self.assertIn(
f"{CAMERA}_11",
[c.args[0] for c in self.maintainer.frame_manager.get.call_args_list],
)
def assert_force_end_publishes_pending_detection(self, topic: str) -> None:
# the dog is held as a pending detection when the alert is ended
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
)
if topic == "remove":
# the config updater has already dropped the camera
self.maintainer.config.cameras.pop(CAMERA)
self.maintainer.config_subscriber.check_for_updates.side_effect = [
{topic: [CAMERA]}
]
self.feed()
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
end = self.reviews()[-1]["after"]
self.assertEqual(end["data"]["objects"], ["dog"])
self.assertEqual((end["start_time"], end["end_time"]), (5, 5))
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
# every thumbnail on disk belongs to a published review
published = {Path(r["after"]["thumb_path"]).name for r in self.reviews()}
self.assertEqual(self.thumbnails(), published)
def test_disabled_camera_publishes_pending_detections(self) -> None:
self.assert_force_end_publishes_pending_detection("enabled")
def test_removed_camera_publishes_pending_detections(self) -> None:
self.assert_force_end_publishes_pending_detection("remove")
def test_pending_thumbnail_removed_when_alert_resumes(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
)
alert = self.maintainer.active_review_segments[CAMERA]
pending_thumb = Path(alert.pending_detections[0].frame_path)
self.assertTrue(pending_thumb.is_file())
self.feed((10, [self.tracked("p1", "person", 10, start_time=1)]))
self.assertEqual(alert.pending_detections, [])
self.assertFalse(pending_thumb.exists())
self.assertEqual(self.thumbnails(), {Path(alert.frame_path).name})
def test_multiple_pending_objects_share_one_detection(self) -> None:
# objects within the detection cutoff of each other, whether seen
# together or later, make up one detection
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(
5,
[
self.tracked("d1", "dog", 5, start_time=5),
self.tracked("c1", "cat", 5, start_time=5),
],
),
(20, [self.tracked("b1", "bird", 20, start_time=20)]),
(21, []),
(2 + self.alert_cutoff, []),
(21 + self.detection_cutoff, []),
)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
end = self.reviews()[-1]["after"]
self.assertCountEqual(end["data"]["objects"], ["dog", "cat", "bird"])
self.assertCountEqual(end["data"]["detections"], ["d1", "c1", "b1"])
self.assertEqual((end["start_time"], end["end_time"]), (5, 20))
# the thumbnail is framed on both objects seen together, the bird
# alone is fewer objects so it does not replace it
frames = [c.args[0] for c in self.maintainer.frame_manager.get.call_args_list]
self.assertIn(f"{CAMERA}_5", frames)
self.assertNotIn(f"{CAMERA}_20", frames)
class TestSplitDetectionLabels(ReviewFlowTestCase):
review_config = """
review:
alerts:
labels:
- person
"""
def test_split_detection_keeps_sub_labels_and_zones(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(
5,
[
self.tracked(
"d1",
"dog",
5,
start_time=5,
zones=["yard"],
sub_label=("Rex", 0.95),
),
self.tracked(
"c1",
"car",
5,
start_time=5,
zones=["driveway"],
sub_label=("fedex", 0.9),
),
],
),
(2 + self.alert_cutoff, []),
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(len(ends), 2)
alert_end, detection = ends
self.assertEqual(alert_end["data"]["objects"], ["person"])
self.assertEqual(alert_end["data"]["sub_labels"], [])
self.assertEqual(alert_end["data"]["zones"], [])
self.assertEqual(detection["severity"], "detection")
# attributes replace the label, other sub labels verify it
self.assertCountEqual(detection["data"]["objects"], ["dog-verified", "fedex"])
self.assertEqual(detection["data"]["verified_objects"], ["dog-verified"])
self.assertEqual(detection["data"]["sub_labels"], ["Rex"])
self.assertCountEqual(detection["data"]["zones"], ["yard", "driveway"])
if __name__ == "__main__":
unittest.main()
+125 -1
View File
@@ -1,16 +1,24 @@
"""Tests for embedding cleanup on the main Frigate database. """Tests for embedding storage and cleanup on the main Frigate database.
Embeddings are deleted whether or not semantic search is currently enabled, so Embeddings are deleted whether or not semantic search is currently enabled, so
the delete path has to tolerate databases where the vec0 tables were never the delete path has to tolerate databases where the vec0 tables were never
created and installs where the sqlite-vec extension is unavailable. created and installs where the sqlite-vec extension is unavailable.
The write paths need the real extension, since the behavior under test belongs
to vec0 itself, so those tests are skipped when it is not installed.
""" """
import os import os
import struct
import tempfile import tempfile
import unittest import unittest
from peewee import OperationalError
from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.db.sqlitevecq import SqliteVecQueueDatabase
VEC_EXTENSION_PATH = "/usr/local/lib/vec0.so"
class TestDeleteEmbeddings(unittest.TestCase): class TestDeleteEmbeddings(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
@@ -52,6 +60,21 @@ class TestDeleteEmbeddings(unittest.TestCase):
self.assertEqual(self._thumbnail_ids(), ["b"]) self.assertEqual(self._thumbnail_ids(), ["b"])
def test_delete_failure_is_logged_not_raised(self) -> None:
self._create_thumbnails_table()
self.db.execute_sql(
"""
CREATE TRIGGER vec_thumbnails_no_delete BEFORE DELETE ON vec_thumbnails
BEGIN SELECT RAISE(ABORT, 'delete blocked'); END
"""
).fetchall()
with self.assertLogs("frigate.db.sqlitevecq", level="ERROR") as logs:
self.db.delete_embeddings_thumbnail(event_ids=["a"])
self.assertIn("Failed to delete embeddings", logs.output[0])
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
def test_delete_skipped_without_extension(self) -> None: def test_delete_skipped_without_extension(self) -> None:
self._create_thumbnails_table() self._create_thumbnails_table()
self.db.load_vec_extension = False self.db.load_vec_extension = False
@@ -61,3 +84,104 @@ class TestDeleteEmbeddings(unittest.TestCase):
# the vec0 tables cannot be written without the extension # the vec0 tables cannot be written without the extension
self.assertEqual(self._thumbnail_ids(), ["a", "b"]) self.assertEqual(self._thumbnail_ids(), ["a", "b"])
def _vector(value: float) -> bytes:
return struct.pack("768f", *([value] * 768))
@unittest.skipUnless(
os.path.exists(VEC_EXTENSION_PATH), "sqlite-vec extension is not installed"
)
class TestEmbeddingsTableWrites(unittest.TestCase):
"""Covers the vec0 writes behind semantic search reindexing."""
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.db = SqliteVecQueueDatabase(
os.path.join(self.tmp_dir.name, "test.db"), load_vec_extension=True
)
self.db.start()
self.db.create_embeddings_tables()
def tearDown(self) -> None:
self.db.stop()
self.db.close()
self.tmp_dir.cleanup()
def _vec_tables(self) -> list[str]:
return [
row[0]
for row in self.db.execute_sql(
"SELECT name FROM sqlite_master WHERE name LIKE 'vec_%' ORDER BY name"
)
]
def _make_legacy(self, table: str) -> None:
# sqlite-vec added the _info shadow table in 0.1.6, so tables written by
# Frigate 0.17 and earlier do not have one
self.db.execute_sql(f"DROP TABLE {table}_info").fetchall()
def _stored(self, table: str, column: str, event_id: str) -> str | None:
row = self.db.execute_sql(
f"SELECT vec_to_json({column}) FROM {table} WHERE id = ?", (event_id,)
).fetchone()
return row[0] if row else None
def test_write_error_is_raised(self) -> None:
# queued writes hide their exception in the returned cursor
with self.assertRaises(OperationalError):
self.db.execute_write("INSERT INTO vec_missing(id) VALUES ('a')")
def test_drop_tables_removes_legacy_tables(self) -> None:
self._make_legacy("vec_thumbnails")
self._make_legacy("vec_descriptions")
self.db.drop_embeddings_tables()
self.assertEqual(self._vec_tables(), [])
def test_drop_tables_without_any_tables_does_not_raise(self) -> None:
self.db.drop_embeddings_tables()
self.db.drop_embeddings_tables()
def test_upsert_replaces_existing_embedding(self) -> None:
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
)
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
)
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
self.assertTrue(stored.startswith("[0.990000"), stored)
def test_upsert_keeps_one_row_per_event(self) -> None:
for _ in range(3):
self.db.upsert_embeddings(
"vec_descriptions", "description_embedding", {"evt1": _vector(0.5)}
)
count = self.db.execute_sql(
"SELECT count(*) FROM vec_descriptions WHERE id = 'evt1'"
).fetchone()[0]
self.assertEqual(count, 1)
def test_reindex_cycle_rewrites_legacy_tables(self) -> None:
"""The 0.18 upgrade path: old vectors in, new vectors out."""
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
)
self._make_legacy("vec_thumbnails")
self._make_legacy("vec_descriptions")
self.db.drop_embeddings_tables()
self.db.create_embeddings_tables()
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
)
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
self.assertTrue(stored.startswith("[0.990000"), stored)
+66
View File
@@ -0,0 +1,66 @@
import unittest
from unittest.mock import MagicMock, patch
from frigate.events.types import EventStateEnum
from frigate.models import Timeline
from frigate.timeline import TimelineProcessor
def make_event(has_clip: bool, has_snapshot: bool) -> dict:
return {
"id": "event-1",
"frame_time": 1000.0,
"box": [0, 0, 10, 10],
"region": [0, 0, 100, 100],
"label": "car",
"sub_label": None,
"score": 0.8,
"has_clip": has_clip,
"has_snapshot": has_snapshot,
"current_zones": [],
"stationary": False,
"attributes": {},
"current_attributes": [],
}
class TestTimelineProcessor(unittest.TestCase):
def setUp(self):
camera_config = MagicMock()
camera_config.detect.width = 1280
camera_config.detect.height = 720
config = MagicMock()
config.cameras.get.return_value = camera_config
self.processor = TimelineProcessor(config, MagicMock(), MagicMock())
@patch.object(Timeline, "insert")
def test_unsaved_event_writes_no_timeline_rows(self, insert):
event = make_event(has_clip=False, has_snapshot=False)
self.processor.handle_object_detection(
"front", EventStateEnum.start, None, event
)
self.processor.handle_object_detection(
"front", EventStateEnum.end, event, event
)
insert.assert_not_called()
self.assertEqual(self.processor.pre_event_cache, {})
@patch.object(Timeline, "insert")
def test_cached_entries_flush_when_event_is_saved(self, insert):
start = make_event(has_clip=False, has_snapshot=False)
self.processor.handle_object_detection(
"front", EventStateEnum.start, None, start
)
insert.assert_not_called()
end = make_event(has_clip=True, has_snapshot=False)
self.processor.handle_object_detection("front", EventStateEnum.end, start, end)
class_types = [c.args[0][Timeline.class_type] for c in insert.call_args_list]
self.assertEqual(class_types, ["visible", "gone"])
self.assertEqual(self.processor.pre_event_cache, {})
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More