Compare commits

...
Author SHA1 Message Date
Josh Hawkins e0d48c98d9 show runtime overrides in the settings form
The settings form read a camera section's saved config value, but its dependent warnings (audio transcription requiring audio detection, snapshots requiring detect, etc.) read the live config instead. A runtime toggle from the live view, MQTT, or an active profile can turn a section off without touching yaml, and that override persists across restarts, so the Enable switch showed on while the warning said the feature wasn't enabled. This adds an "Overridden (Live)" badge to any field whose live value differs from what's saved, and swaps the affected warnings to runtime-specific wording when a runtime override is the actual cause instead of the config.
2026-09-17 20:42:41 -05:00
Josh Hawkins 1ba10bce2a fix restart failing under non-root
restart_frigate() called psutil.Process(1).terminate() to signal s6-svscan, but s6-svscan runs as root while frigate runs as uid 1000, so the call raised AccessDenied. That exception escaped every caller: the UI restart button dropped its websocket client, MQTT restart and Save & Restart just logged and did nothing, and the watchdog crashed its own monitoring thread on a dead detector. This catches AccessDenied and falls through to the existing SIGINT branch, which exits the process for s6 to restart it.
2026-09-17 20:42:15 -05:00
Josh Hawkins 8120a4b117 don't edit a chat message while a reply streams
The edit button stayed active while a reply streamed. `submitConversation` returns early while loading, but the message bubble still closed its editor, so the edit was silently lost. The edit button is hidden while a reply streams, and an editor that's already open keeps its draft with send disabled until the reply ends.
2026-09-17 16:55:39 -05:00
Josh Hawkins 03e6f54670 fix train image filtering for a class with a dash
The backend writes a class with a `-` as `_` in train file names, since it splits those names on `-`, while a dataset folder keeps the dash. Filtering the Train grid by `half-open` compared it with `half_open` and hid every attempt. Both sides are normalized the same way now.
2026-09-17 16:55:39 -05:00
Josh Hawkins 13a3af1957 don't run page shortcuts for keys a dialog already handled
Radix dismisses a dialog on Escape from a capture-phase keydown listener and calls `preventDefault()` without stopping propagation, so `useKeyboardListener` still ran the page's Escape shortcut: cancelling the delete dialog in the face library or a classification model also cleared the whole selection. Keys another shortcut hook handled still get through, since their listener order changes with every render.
2026-09-17 16:55:31 -05:00
Josh Hawkins 8aa0ddf740 find DST transitions to the second
`get_dst_transitions` probed the offset once every 24 hours from the start time and reported a change at the first probe after it, up to a day late, so events, review items and recordings near a transition were grouped into days with the old offset. A transition after the last daily probe wasn't found at all. The end of the range is probed too now, and a probe that sees the offset change bisects the interval to the second of the transition.
2026-09-17 16:55:25 -05:00
Josh Hawkins 5fc8d4f1c6 return 403 for a snapshot or thumbnail on another camera
The broad `except Exception` handlers in `event_snapshot` and `event_thumbnail` caught the `HTTPException` from `require_camera_access`, so a restricted user asking for another camera's snapshot got a 404 instead of a 403, and for an object still being tracked the snapshot was rendered before the check ran. Both endpoints now look up the event and check access in their own block, the way the other endpoints do, so a denial propagates.
2026-09-17 16:55:19 -05:00
Josh Hawkins a8c838aee2 fix the has_clip self-heal for events with no recordings
`vod_event` looked for a `(body, 404)` tuple, but `vod_ts` returns a `JSONResponse`, so the check never matched and an old event whose recordings are gone kept offering a clip that can't play. It now checks the response status code.
2026-09-17 16:55:12 -05:00
Josh Hawkins 4080c46428 check for a valid frame before using its shape
With the camera offline, no preview frame, and `camera-error.jpg` missing, `latest_frame` read `frame.shape` before its `frame is None` check, so it raised `AttributeError` and answered 500 with a traceback instead of the intended "Unable to get valid frame". The check now runs first.
2026-09-17 16:55:06 -05:00
Josh HawkinsandGitHub 10a0d5ea37 Improve UI zone operations (#24376)
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
* improve zone renaming

Zone rename now saves as one JSON body via config/set, moving required_zones and profile overrides instead of leaving stale references

* fixes
2026-09-16 12:07:05 -06:00
Josh HawkinsandGitHub 64d6366ac4 Live streaming tech selection (#24374)
* allow users to select live streaming technology

* fix webrtc being downgraded to mse on load

`useUserPersistence` seeds state with the default and loads asynchronously, so the first render always used `mse` instead of the saved choice, and `useWebRTCGloballyAvailable` reports `checking` until the probe settles and re-enters that state on every consumer mount, so a saved `webrtc` was rewritten to `mse` even after the probe had already passed. On Safari the MSE player then timed out and latched the jsmpeg fallback. A pending probe now counts as available, the player waits on `autoLive` until the stored preferences load, and `handleError` gates on the mode in use since the fallback flag no longer implies webrtc is untried. A rejected IndexedDB read also resolves `loaded` now, so a blocked store can't leave the player waiting forever.

* add support for configurable ICE servers in WebRTC player

* add mic error state, fix dialog overwriting saved choice and dashboard ignoring stream

* tweaks
2026-09-16 09:12:06 -05:00
Josh HawkinsandGitHub 79ca18d439 fix build warnings from tailwind, fonts, and fast refresh (#24359)
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
fix duplicated styles, move fonts to src/assets/fonts for vite to bundle (nginx already rewrites correctly), and fix fast refresh for PreviewController, auth context/provider, and statusbar context
2026-09-15 15:33:09 -06:00
Josh HawkinsandGitHub cabdffea20 Update more web deps and reformat with updated prettier (#24358)
* update date-fns, i18next, react-dropzone, react-markdown and @types/node

react-i18next 17 peers `i18next >= 26.2.0`, so the two move together. react-day-picker already depends on date-fns 4, so date-fns now dedupes to a single copy. react-dropzone 20 declares `node >=22` in `engines`, but npm only warns on Node 20 and nothing in the build needs Node 22.

* update js-yaml, @hookform/resolvers and prettier

js-yaml 5 has no default export, so `DictAsYamlField` imports `dump`, `load` and `YAMLException` by name. @hookform/resolvers 5 types `zodResolver` with the schema's input and output types separately, and fields with defaults are optional on input, so the zone and classification model forms pass both types to `useForm`. zod's range now starts at 3.25, which resolvers 5 requires.

* format with prettier 3.9
2026-09-15 15:18:49 -06:00
Josh HawkinsandGitHub ba41c90c07 Remove unused deps (#24355)
* remove unused web test deps

Nothing runs vitest. The CI step that called `npm run test` is commented out, `web/__test__/` was deleted in https://github.com/blakeblackshear/frigate/pull/8983 so `setupFiles` points at a missing file, and there are no unit tests, so `npx vitest run` only picks up the Playwright specs and fails. jsdom, `@testing-library/jest-dom`, msw and fake-indexeddb were only there for vitest.

* update contributing docs

* remove unused deps
2026-09-15 14:02:02 -06:00
Josh HawkinsandGitHub 0c52a3175d update radix, react, konva and other web dependencies (#24354)
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
konva 10.5 removed the private `Node._lastPos`, so `PolygonCanvas` now reads the dragged point from `getAbsolutePosition()`, which returns the position konva just applied. monaco-yaml 5.5 takes formatter options instead of a boolean for `format`. The radix packages move together so every shared primitive stays a single copy under the `react-slot` and `compose-refs` overrides.
2026-09-15 12:39:31 -05:00
Josh HawkinsandGitHub 4c648f8147 bump copy-to-clipboard to 4.0.2 (#24353) 2026-09-15 12:03:20 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e0d4337a25 Update memray requirement from ==1.15.* to ==1.20.* in /docker/main (#24343)
Updates the requirements on [memray](https://github.com/bloomberg/memray) to permit the latest version.
- [Release notes](https://github.com/bloomberg/memray/releases)
- [Changelog](https://github.com/bloomberg/memray/blob/main/NEWS.rst)
- [Commits](https://github.com/bloomberg/memray/compare/v1.15.0...v1.20.0)

---
updated-dependencies:
- dependency-name: memray
  dependency-version: 1.20.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:45 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5e689f2d85 Update uvicorn requirement from ==0.46.* to ==0.52.* in /docker/main (#24341)
Updates the requirements on [uvicorn](https://github.com/Kludex/uvicorn) to permit the latest version.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.46.0...0.52.4)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.52.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:40 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
57a2765d00 Update netaddr requirement from ==0.8.* to ==1.3.* in /docker/main (#24346)
Updates the requirements on [netaddr](https://github.com/netaddr/netaddr) to permit the latest version.
- [Release notes](https://github.com/netaddr/netaddr/releases)
- [Changelog](https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/netaddr/netaddr/compare/0.8.0...1.3.0)

---
updated-dependencies:
- dependency-name: netaddr
  dependency-version: 1.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:33 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dd77bae4f7 Bump @radix-ui/react-progress from 1.1.8 to 1.1.16 in /web (#24342)
Bumps [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) from 1.1.8 to 1.1.16.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:16 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8a98d7c9b1 Bump @radix-ui/react-radio-group from 1.3.8 to 1.4.7 in /web (#24339)
Bumps [@radix-ui/react-radio-group](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radio-group) from 1.3.8 to 1.4.7.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radio-group/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radio-group)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-radio-group"
  dependency-version: 1.4.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:33 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8a8da663c0 Bump lucide-react from 0.577.0 to 1.45.0 in /web (#24337)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.577.0 to 1.45.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.45.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:26 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
eddc9fccd1 Bump autoprefixer from 10.4.20 to 10.5.6 in /web (#24340)
Bumps [autoprefixer](https://github.com/postcss/autoprefixer) from 10.4.20 to 10.5.6.
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.20...10.5.6)

---
updated-dependencies:
- dependency-name: autoprefixer
  dependency-version: 10.5.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
84f981a77c Bump @radix-ui/react-toggle from 1.1.10 to 1.1.18 in /web (#24344)
Bumps [@radix-ui/react-toggle](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toggle) from 1.1.10 to 1.1.18.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toggle/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toggle)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-toggle"
  dependency-version: 1.1.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:12 -05:00
Josh HawkinsandGitHub 4100383738 Miscellaneous fixes (#24352)
* fix recordings unavailable endpoint when no params are provided

we already import the datetime class directly, so those attribute lookups raised AttributeError and the request returned 500

* reject JWTs whose role is no longer in the config

`/auth` trusted the role inside the JWT and re-signed it on refresh without checking the config, so a user whose restricted role was deleted kept a session carrying a role that isn't in `auth.roles`. The media, clip, recording, export and go2rtc checks treat a missing role the same as a role with no camera list, so that session could open every camera. `/auth` now returns 401 for a token whose role isn't configured, which sends the user back through login, and login already falls back to `viewer` for a role that's gone.

* delete the deleted camera group's layout, not the open one's

Deleting a camera group removed the layout of the group being viewed, because the dialog's delete was bound to `${activeGroup}-draggable-layout`. It also cleared the saved group, and both ran before the `config/set` request whether or not it succeeded, so deleting one group while viewing another lost the open group's layout and left the deleted group's layout behind. The deleted group's own layout is now removed after a successful save, and the saved group is only cleared when it was the open group.

* don't block API when querying PTZ info

camera_ptz_info is async but waited on the ONVIF controller's future with future.result(), blocking the API event loop for as long as a slow or unreachable ONVIF camera took to answer (including reconnect attempts), so every other async request stalled with it. Await the future with asyncio.wrap_future instead. The coroutine runs on the controller's own loop and thread, so this cannot deadlock.

* for custom exports, only allow admin users to add to existing cases

follows the existing convention where attaching an export to an existing case is admin-only on `POST /export/{camera}/...` and `POST /exports/batch`

* drop pending edits for a camera or profile that no longer exists

* match cached preview frames to their camera exactly

https://github.com/blakeblackshear/frigate/pull/22594 added a trailing `-` to the `preview_{camera}` prefix so `camera` stopped matching `camera2`'s frames, but camera names can contain `-`, so `front` still matched `front-door`'s. After a restart, `front`'s preview recorder deleted this hour's frames of a matching camera that sorted before it and added the timestamps of one that sorted after it, so ffmpeg was asked for files that don't exist and that hour's preview was lost. The offline fallback for `latest.jpg` could also return `front-door`'s frame for `front`, even to a user without access to `front-door`, and a short export could take its fallback thumbnail from the other camera. These now compare the full camera name taken from the file name.
2026-09-15 07:48:30 -05:00
Josh HawkinsandGitHub fa30a7e1ae Replace react-logviewer with virtua (#24334)
* bump react-logviewer to 6.5.5

* use virtua for UI logs
2026-09-15 05:44:04 -06:00
Josh HawkinsandGitHub eecc43ccef bump rjsf to 6.10.0 and add e2e test (#24332)
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-14 13:36:27 -05:00
Nicolas MowenandGitHub 7821ecbb43 Migrate Hailo detector key and support hailo device (#24327)
* Migrate Hailo detector key and support hailo device

* Fix missing check
2026-09-14 08:23:36 -06:00
Josh HawkinsandGitHub caa6edecac Migrate web to ESLint 10 flat config (#24326)
* migrate web to eslint 10 flat config

ESLint 10 dropped `.eslintrc` support, so `.eslintrc.cjs` is replaced with `eslint.config.js` and the lint scripts no longer pass `--ext` or `--ignore-path`. typescript-eslint moves to 8, react-hooks to 7, and react-refresh to 0.5, and the unused jest and vitest-globals plugins are removed. Lint behaves as it did before: catch variables aren't checked, unused disable directives aren't reported, and rules newly added to the recommended sets are off until the code passes them. typescript-eslint 8 flags constants used only in `typeof`, so those are now exported, or replaced with a union type where the export would trip react-refresh.

* fix lint findings from the eslint 10 recommended rules

Remove the rule overrides from the flat config migration and fix what they were hiding. Unused catch bindings are dropped, 20 disable directives that suppressed nothing are removed (react-hooks 5.2 and 7.1.1 report identical exhaustive-deps findings with inline config ignored), dead initial values are dropped, short-circuit calls become if statements or optional calls, rethrown errors pass `cause`, and the disabled "No recordings" tooltip in `ReviewTimeline` is removed along with its memo and the `getRecordingAvailability` prop. The 3 react-refresh warnings for files that export contexts or classes are left for a later refactor.
2026-09-14 07:53:45 -06:00
Josh HawkinsandGitHub acc740a976 Update deps (#24324)
* update docusaurus to 3.10.2

* bump @types/node to 25.9.6 and ES2022

target ES2022, which already includes ES2020 and ES2021.String, so the lib list was also trimmed

* bump vite to 8.3.0 and vitest to 4.1.11

Swap `@vitejs/plugin-react-swc` for `@vitejs/plugin-react` and add `esbuild` as a devDependency, since `vite-plugin-monaco-editor` requires it and Vite 8 no longer ships it. `keepNames` moves to `build.rolldownOptions.output` because Vite 8 ignores the `esbuild` block. Rolldown's minifier now writes the preload helper's base path as a template literal instead of a double-quoted string, so the nginx `sub_filter` for `return"/BASE_PATH/"` stopped matching and lazy-loaded chunks and their CSS were requested from a literal `/BASE_PATH/` under Home Assistant ingress. The rule now matches the backtick form.

* bump apexcharts to 7.3.0 and react-apexcharts to 2.1.1

Under Vite 8, a default import from a CommonJS package resolves to its whole `module.exports` when `package.json` has `"type": "module"`, so react-apexcharts 1.4.1 handed React an object and every chart crashed. 2.x ships an ESM build. apexcharts 7 no longer sets `window.ApexCharts`, so the chart components now import it for `ApexCharts.exec`, and `ApexAxisChartSeries` is derived in `types/graph.ts` because it's no longer a global type.

* remove unused immer dep

* remove unused cython pin from tensorrt requirements

The pin was added alongside tensorrt 8.5.3 and cuda-python 11.8, which needed Cython to build, and both have since been dropped from this file. Nothing imports Cython at runtime, and `pip3 wheel` runs with build isolation, so any source build gets its own build dependencies. The pin only installed an unused Cython wheel into the TensorRT image.

* require node 20.19 for docs
2026-09-14 07:16:31 -06:00
Josh HawkinsandGitHub 3931ab74a8 fix mypy errors from types-peewee 4.0 (#24323)
types-peewee 4.0 types model fields precisely, so 17 `type: ignore` comments and 2 `cast(str, ...)` calls are no longer needed. Its stubs type `.namedtuples()` and `.dicts()` queries as returning model instances, so the review cleanup reads namedtuple fields by name and the storage usage query casts its dict rows. `start_time` is declared `DateTimeField` but stores unix timestamps, so two reads cast it like `debug_replay.py` already does. `Export` gets an annotation for the `export_case_id` attribute peewee adds at runtime.
2026-09-14 07:52:23 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5be162a22c Update sherpa-onnx requirement from ==1.12.* to ==1.13.* in /docker/main (#24303)
Updates the requirements on [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) to permit the latest version.
- [Changelog](https://github.com/k2-fsa/sherpa-onnx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/k2-fsa/sherpa-onnx/compare/v1.12.0...v1.13.8)

---
updated-dependencies:
- dependency-name: sherpa-onnx
  dependency-version: 1.13.8
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 07:29:35 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e57eeb8288 Update unidecode requirement from ==1.3.* to ==1.4.* in /docker/main (#24320)
Updates the requirements on [unidecode](https://github.com/kmike/text-unidecode) to permit the latest version.
- [Release notes](https://github.com/kmike/text-unidecode/releases)
- [Commits](https://github.com/kmike/text-unidecode/commits)

---
updated-dependencies:
- dependency-name: unidecode
  dependency-version: 1.4.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 07:29:30 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cf03df8a98 Update prometheus-client requirement in /docker/main (#24313)
Updates the requirements on [prometheus-client](https://github.com/prometheus/client_python) to permit the latest version.
- [Release notes](https://github.com/prometheus/client_python/releases)
- [Commits](https://github.com/prometheus/client_python/compare/v0.21.0...v0.26.0)

---
updated-dependencies:
- dependency-name: prometheus-client
  dependency-version: 0.26.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 07:29:25 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fa36fa403b Update starlette-context requirement in /docker/main (#24305)
Updates the requirements on [starlette-context](https://github.com/tomwojcik/starlette-context) to permit the latest version.
- [Release notes](https://github.com/tomwojcik/starlette-context/releases)
- [Commits](https://github.com/tomwojcik/starlette-context/compare/v0.4.0...v0.5.1)

---
updated-dependencies:
- dependency-name: starlette-context
  dependency-version: 0.5.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 07:29:20 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
069315be33 Bump @radix-ui/react-aspect-ratio from 1.1.2 to 1.1.15 in /web (#24314)
Bumps [@radix-ui/react-aspect-ratio](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/aspect-ratio) from 1.1.2 to 1.1.15.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/aspect-ratio/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/aspect-ratio)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-aspect-ratio"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 07:28:06 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c583e21b70 Bump framer-motion from 12.38.0 to 13.2.0 in /web (#24307)
Bumps [framer-motion](https://github.com/motiondivision/motion) from 12.38.0 to 13.2.0.
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.38.0...v13.2.0)

---
updated-dependencies:
- dependency-name: framer-motion
  dependency-version: 13.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 07:28:01 -05:00
250 changed files with 9096 additions and 11834 deletions
-3
View File
@@ -46,9 +46,6 @@ jobs:
- name: Build web
run: npm run build
working-directory: ./web
# - name: Test
# run: npm run test
# working-directory: ./web
web_e2e:
name: Web - E2E Tests
+1 -1
View File
@@ -160,7 +160,7 @@ When reviewing code, do NOT comment on:
### Code Quality
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
- **Linting**: ESLint (see `web/eslint.config.js`)
- **Formatting**: Prettier with Tailwind CSS plugin
- **Type Safety**: TypeScript strict mode enabled
+1 -1
View File
@@ -1,4 +1,4 @@
ruff == 0.15.20
# types
types-peewee == 3.17.*
types-peewee == 4.0.*
+7 -7
View File
@@ -3,9 +3,9 @@ click == 8.5.*
# FastAPI
aiohttp == 3.12.*
starlette == 0.47.*
starlette-context == 0.4.*
starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
uvicorn == 0.46.*
uvicorn == 0.52.*
slowapi == 0.1.*
joserfc == 1.6.*
cryptography == 46.0.*
@@ -34,7 +34,7 @@ types-requests == 2.32.*
norfair == 2.3.*
setproctitle == 1.3.*
ws4py == 0.5.*
unidecode == 1.3.*
unidecode == 1.4.*
titlecase == 2.4.*
# Image Manipulation
numpy == 1.26.*
@@ -61,16 +61,16 @@ rapidfuzz==3.12.*
argcomplete==2.0.*
contextlib2==0.6.*
future==0.18.*
netaddr==0.8.*
netaddr==1.3.*
netifaces==0.10.*
prometheus-client == 0.21.*
prometheus-client == 0.26.*
# TFLite
tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64'
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
# audio transcription
sherpa-onnx==1.12.*
sherpa-onnx==1.13.*
faster-whisper==1.2.*
librosa==0.11.*
soundfile==0.13.*
# Memory profiling
memray == 1.15.*
memray == 1.20.*
@@ -343,13 +343,6 @@ http {
add_header Cache-Control "public";
}
location /fonts/ {
access_log off;
expires 1y;
include security_headers.conf;
add_header Cache-Control "public";
}
location /locales/ {
access_log off;
include security_headers.conf;
@@ -376,7 +369,7 @@ http {
sub_filter '"/BASE_PATH/assets/' '"$http_x_ingress_path/assets/';
sub_filter '"/BASE_PATH/locales/' '"$http_x_ingress_path/locales/';
sub_filter '"/BASE_PATH/monacoeditorwork/' '"$http_x_ingress_path/assets/';
sub_filter 'return"/BASE_PATH/"' 'return window.baseUrl';
sub_filter 'return`/BASE_PATH/`' 'return window.baseUrl';
sub_filter '<body>' '<body><script>window.baseUrl="$http_x_ingress_path/";</script>';
sub_filter_types text/css application/javascript;
sub_filter_once off;
-1
View File
@@ -1,6 +1,5 @@
# Nvidia ONNX Runtime GPU Support
--extra-index-url 'https://pypi.nvidia.com'
cython==3.0.*; platform_machine == 'x86_64'
nvidia-cuda-cupti-cu12==12.8.90; platform_machine == 'x86_64'
nvidia-cublas-cu12==12.8.4.1; platform_machine == 'x86_64'
nvidia-cudnn-cu12==9.8.0.87; platform_machine == 'x86_64'
+5 -5
View File
@@ -36,13 +36,13 @@ edgeTPU:
height: 320 # <--- should match the imgsize of the model, typically 320
path: /config/model_cache/yolov9-s-relu6-best_320_int8_edgetpu.tflite
labelmap_path: /config/labels-coco17.txt
hailo8l:
title: Hailo-8/Hailo-8L
hailo:
title: Hailo
models:
- key: yolo
label: YOLO
recommended: true
download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup based on the detected hardware. Once cached under `/config/model_cache/hailo`, the model works fully offline.
download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup, choosing the build that matches the attached device. Once cached under `/config/model_cache`, the model works fully offline.
ui: |-
Navigate to **Settings > System > Detection models** and select **Hailo** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
@@ -60,7 +60,7 @@ hailo8l:
yaml: |-
models:
- devices:
- hailo8l:PCIe
- hailo:PCIe
width: 320
height: 320
input_tensor: nhwc
@@ -101,7 +101,7 @@ hailo8l:
yaml: |-
models:
- devices:
- hailo8l:PCIe
- hailo:PCIe
width: 300
height: 300
input_tensor: nhwc
+31
View File
@@ -28,6 +28,24 @@ WebRTC may use an external STUN server for NAT traversal. MSE and HLS streaming
:::
### Selecting a streaming technology
Frigate [defaults to MSE](#why-does-frigate-prefer-mse-over-webrtc-for-live-view) for restreamed cameras by design. To use WebRTC, select it explicitly from a camera's single-camera Live view settings (the settings menu in the camera's Live view header on desktop, or the settings drawer on mobile). Three related controls work together:
- **Stream**: _what_ to play. This lists the [streams you've configured](#setting-streams-for-live-ui) (for example `Main Stream` and `Sub Stream`).
- **Force low-bandwidth mode**: a switch that always plays Frigate's built-in low-bandwidth feed (the stream assigned the `detect` role, using JSMpeg) instead of the selected stream. It works anywhere without go2rtc and is useful on slow or metered connections. While it is enabled, the stream and streaming technology selectors are disabled; your stream and technology choices are restored when you turn it off.
- **Streaming Technology**: _how_ to play the selected stream, listing **MSE** and **WebRTC**. It is only shown for a restreamed stream.
- The choices are saved **per device, per camera** in your browser's local storage.
- **WebRTC is only selectable when it can actually work for that stream.** When it can't, the option is shown disabled with the reason inline, and a more detailed reason (the failing codecs, or why the connectivity check failed) is logged to your browser's console. Common reasons:
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc` (see [WebRTC extra configuration](#webrtc-extra-configuration)).
- **Could not connect**: e.g. port `8555` isn't reachable, or a STUN/TURN server is misconfigured. Frigate runs a one-time WebRTC connectivity check when the Live view opens; the option may briefly show as "checking" while it runs.
- **Unsupported video codec**: the stream's video codec can't be played over WebRTC in your browser, most commonly H.265/HEVC in Firefox or Edge.
- **Unsupported audio codec**: WebRTC needs opus or G.711 audio, so a stream whose playback audio is only AAC (without an added opus/G.711 track) can't carry audio over WebRTC. See [Audio Support](#audio-support) for how to add one.
- **Unsupported browser**: the browser doesn't support WebRTC.
When WebRTC isn't available, Frigate automatically uses MSE (or falls back to JSMpeg), so live view keeps working regardless of the selection.
### Camera Settings Recommendations
If you are using go2rtc, you should adjust the following settings in your camera's firmware for the best experience with Live view:
@@ -157,6 +175,17 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req
- stun:8555
```
- The web UI uses the STUN and TURN servers in `ice_servers` and falls back to Google's public STUN server when none are set:
```yaml title="config.yml"
go2rtc:
webrtc:
ice_servers:
- urls: [turn:turn.example.com:3478]
username: frigate
credential: password
```
- For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block.
- Note that some browsers may not support H.265 (HEVC). You can check your browser's current version for H.265 compatibility [here](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness).
@@ -206,6 +235,8 @@ For devices that support two way talk, Frigate can be configured to use the feat
- Ensure you access Frigate via https (may require [opening port 8971](/frigate/installation/#ports)).
- For the Home Assistant Frigate card, [follow the docs](http://card.camera/#/usage/2-way-audio) for the correct source.
The two-way talk control in the single-camera Live view is only enabled when WebRTC is available; if WebRTC isn't configured or can't connect, the control is shown disabled.
To use the Reolink Doorbell with two way talk, you should use the [recommended Reolink configuration](/configuration/camera_specific#reolink-cameras)
As a starting point to check compatibility for your camera, view the list of cameras supported for two-way talk on the [go2rtc repository](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#two-way-audio). For cameras in the category `ONVIF Profile T`, you can use the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/)'s FeatureList to check for the presence of `AudioOutput`. A camera that supports `ONVIF Profile T` _usually_ supports this, but due to inconsistent support, a camera that explicitly lists this feature may still not work. If no entry for your camera exists on the database, it is recommended not to buy it or to consult with the manufacturer's support on the feature availability.
+5 -5
View File
@@ -22,7 +22,7 @@ Frigate supports multiple different detectors that work on different types of ha
**Most Hardware**
- [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB, Mini PCIe, and m.2 formats allowing for a wide range of compatibility with devices.
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
- [Hailo](#hailo): The Hailo-8, Hailo-8L and Hailo-8R AI Acceleration modules are available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms.
**AMD**
@@ -285,9 +285,9 @@ models:
---
## Hailo-8
## Hailo
This detector is available for use with both Hailo-8 and Hailo-8L AI Acceleration Modules. The integration automatically detects your hardware architecture via the Hailo CLI and selects the appropriate default model if no custom model is specified.
This detector is available for use with the Hailo-8, Hailo-8L and Hailo-8R AI Acceleration Modules. The integration identifies which of them is attached and selects the matching default model if no custom model is specified.
See the [installation docs](../frigate/installation.md#hailo-8) for information on configuring the Hailo hardware.
@@ -308,11 +308,11 @@ The HailoRT runtime is not part of the Frigate image. It is downloaded and insta
When configuring the Hailo detector, you have two options to specify the model: a local **path** or a **URL**.
If both are provided, the detector will first check for the model at the given local path. If the file is not found, it will download the model from the specified URL. The model file is cached under `/config/model_cache/hailo`.
<ModelConfigDropdown detectorTitle="Hailo-8/Hailo-8L" models={objectDetectorsModels.hailo8l.models} />
<ModelConfigDropdown detectorTitle="Hailo" models={objectDetectorsModels.hailo.models} />
For additional ready-to-use models, please visit: https://github.com/hailo-ai/hailo_model_zoo
Hailo8 supports all models in the Hailo Model Zoo that include HailoRT post-processing. You're welcome to choose any of these pre-configured models for your implementation.
Hailo supports all models in the Hailo Model Zoo that include HailoRT post-processing. You're welcome to choose any of these pre-configured models for your implementation.
> **Note:**
> The config.path parameter can accept either a local file path or a URL ending with .hef. When provided, the detector will first check if the path is a local file path. If the file exists locally, it will use it directly. If the file is not found locally or if a URL was provided, it will attempt to download the model from the specified URL.
+12 -3
View File
@@ -204,11 +204,20 @@ Light guidelines and advice:
npm run lint
```
- Add to unit tests and ensure they pass. As much as possible, you should strive to _increase_ test coverage whenever making changes. This will help ensure features do not accidentally become broken in the future.
- If you run into error messages like "TypeError: Cannot read properties of undefined (reading 'context')" when running tests, this may be due to these issues (https://github.com/vitest-dev/vitest/issues/1910, https://github.com/vitest-dev/vitest/issues/1652) in vitest, but I haven't been able to resolve them.
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
```shell
python3 -u -m unittest
```
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
```console
npm run test
# First-time setup
npx playwright install chromium
# Build the app and run all tests
npm run e2e:build && npm run e2e
```
- Test in different browsers. Firefox, Chrome, and Safari all have different quirks that make them unique targets to interact with.
+5 -4
View File
@@ -54,7 +54,7 @@ Frigate supports multiple different detectors that work on different types of ha
**Most Hardware**
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices offering a wide range of compatibility with devices.
- [Hailo](#hailo-8): The Hailo-8, Hailo-8L and Hailo-8R AI Acceleration modules are available in m.2 format with a HAT for RPi devices offering a wide range of compatibility with devices.
- [Supports many model architectures](../../configuration/object_detectors#configuration-hailo)
- Runs best with tiny or small size models
@@ -111,12 +111,13 @@ Frigate supports multiple different detectors that work on different types of ha
### Hailo-8
Frigate supports both the Hailo-8 and Hailo-8L AI Acceleration Modules on compatible hardware platforms, including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate automatically identifies your hardware type and selects the appropriate default model when a custom model isn’t provided.
Frigate supports the Hailo-8, Hailo-8L and Hailo-8R AI Acceleration Modules on compatible hardware platforms, including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate identifies which of them is attached and selects the matching default model when a custom model isn’t provided.
**Default Model Configuration:**
- **Hailo-8L:** Default model is **YOLOv6n**.
- **Hailo-8:** Default model is **YOLOv6n**.
- **Hailo-8L:** Default model is **YOLOv6n**, compiled for the Hailo-8L.
- **Hailo-8:** Default model is **YOLOv6n**, compiled for the Hailo-8.
- **Hailo-8R:** Default model is the **Hailo-8** build of **YOLOv6n**, since the Hailo Model Zoo publishes no Hailo-8R build.
In real-world deployments, even with multiple cameras running concurrently, Frigate has demonstrated consistent performance. Testing on x86 platforms, with dual PCIe lanes, yields further improvements in FPS, throughput, and latency compared to the Raspberry Pi setup.
+2 -2
View File
@@ -122,7 +122,7 @@ Additionally, the USB Coral draws a considerable amount of power. If using any o
### Hailo-8
The Hailo-8 and Hailo-8L AI accelerators are available in both M.2 and HAT form factors for the Raspberry Pi. The M.2 version typically connects to a carrier board for PCIe, which then interfaces with the Raspberry Pi 5 as part of the AI Kit. The HAT version can be mounted directly onto compatible Raspberry Pi models. Both form factors have been successfully tested on x86 platforms as well, making them versatile options for various computing environments.
The Hailo-8, Hailo-8L and Hailo-8R AI accelerators are available in both M.2 and HAT form factors for the Raspberry Pi. The M.2 version typically connects to a carrier board for PCIe, which then interfaces with the Raspberry Pi 5 as part of the AI Kit. The HAT version can be mounted directly onto compatible Raspberry Pi models. Both form factors have been successfully tested on x86 platforms as well, making them versatile options for various computing environments.
The HailoRT runtime is not part of the Frigate image; Frigate downloads and installs it at first start once a Hailo detector is configured. Containers without internet access can provide the files themselves, see [Detector runtimes](/frigate/network_requirements#detector-runtimes).
@@ -300,7 +300,7 @@ If you are using `docker run`, add this option to your command `--device /dev/ha
#### Configuration
Finally, configure [hardware object detection](/configuration/object_detectors#hailo-8) to complete the setup.
Finally, configure [hardware object detection](/configuration/object_detectors#hailo) to complete the setup.
### MemryX MX3
+8 -8
View File
@@ -47,7 +47,7 @@ If you are using one of the following hardware detectors and have not provided y
| Detector | Model Downloaded | Source |
| ------------------------------------------------------------------ | -------------------- | ------------------------ |
| [Rockchip RKNN](/configuration/object_detectors#rockchip-platform) | RKNN detection model | GitHub |
| [Hailo 8 / 8L](/configuration/object_detectors#hailo-8) | YOLOv6n (.hef) | Hailo Model Zoo (AWS S3) |
| [Hailo 8 / 8L / 8R](/configuration/object_detectors#hailo) | YOLOv6n (.hef) | Hailo Model Zoo (AWS S3) |
| [AXERA AXEngine](/configuration/object_detectors) | Detection model | HuggingFace |
:::note
@@ -60,16 +60,16 @@ The default CPU, EdgeTPU, and OpenVINO object detection models are bundled into
The SDKs for a few hardware detectors are not shipped in the Frigate image. They are downloaded the first time that detector is configured, verified against checksums pinned in the Frigate release, and installed into the Frigate user's home directory (`/config/.local` by default). Once installed they are not downloaded again until a Frigate release pins a new version.
| Detector | Version | Files | Source |
| -------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| [Hailo 8 / 8L](/configuration/object_detectors#hailo-8) | 4.21.0 | `hailort-debian12-amd64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_x86_64.whl` on x86, `hailort-debian12-arm64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_aarch64.whl` on arm64 | [GitHub release](https://github.com/frigate-nvr/hailort/releases/tag/v4.21.0) |
| [MemryX MX3](/configuration/object_detectors#memryx-mx3) | 2.1.0 | `mx_accl_frigate-2.1.0.zip` (the release source archive, renamed) | [GitHub archive](https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v2.1.0.zip) |
| [AXERA AXEngine](/configuration/object_detectors#axera) | 0.1.3 | `axengine-0.1.3-py3-none-any.whl` | [GitHub release](https://github.com/AXERA-TECH/pyaxengine/releases/tag/0.1.3-frigate) |
| Detector | Version | Files | Source |
| ---------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| [Hailo 8 / 8L / 8R](/configuration/object_detectors#hailo) | 4.21.0 | `hailort-debian12-amd64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_x86_64.whl` on x86, `hailort-debian12-arm64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_aarch64.whl` on arm64 | [GitHub release](https://github.com/frigate-nvr/hailort/releases/tag/v4.21.0) |
| [MemryX MX3](/configuration/object_detectors#memryx-mx3) | 2.1.0 | `mx_accl_frigate-2.1.0.zip` (the release source archive, renamed) | [GitHub archive](https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v2.1.0.zip) |
| [AXERA AXEngine](/configuration/object_detectors#axera) | 0.1.3 | `axengine-0.1.3-py3-none-any.whl` | [GitHub release](https://github.com/AXERA-TECH/pyaxengine/releases/tag/0.1.3-frigate) |
If the container cannot reach GitHub, provide the files yourself:
1. Download the files for your architecture on a machine with internet access.
2. Place them, with exactly the file names listed above, in `/config/model_cache/runtimes/<detector>/`, where `<detector>` is the detector `type` from your config (`hailo8l`, `memryx`, or `axengine`).
2. Place them, with exactly the file names listed above, in `/config/model_cache/runtimes/<detector>/`, where `<detector>` is the detector named in your config's `devices` (`hailo`, `memryx`, or `axengine`).
3. Start Frigate. Files whose checksum matches are installed without any download; a file with the wrong checksum is discarded and downloaded again, so a failed startup log names the file to replace.
The `GITHUB_ENDPOINT` mirror variable below applies to these downloads as well.
@@ -147,7 +147,7 @@ If an [MQTT broker](/integrations/mqtt) is configured, Frigate maintains a conne
For [WebRTC live streaming](/configuration/live), Frigate uses STUN for NAT traversal:
- **go2rtc** defaults to a local STUN listener (`stun:8555`), no internet required.
- **The web UI's WebRTC player** includes a fallback to Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet.
- **The web UI** uses the servers in `go2rtc.webrtc.ice_servers` for its WebRTC player and for the WebRTC connectivity check it runs when the Live view loads. If none are set, it uses Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet access from the browser. Set `ice_servers` to a STUN or TURN server on your network to avoid this.
## Home Assistant Supervisor
+2 -2
View File
@@ -41,7 +41,7 @@ Rockchip models are automatically converted as of 0.17. For 0.16, YOLOv9 onnx mo
## Supported detector types
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), Hailo (`hailo8l`), and Rockchip (`rknn`) detectors.
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), Hailo (`hailo`), and Rockchip (`rknn`) detectors.
| Hardware | Recommended Detector Type | Recommended Model Type |
| -------------------------------------------------------------------------------- | ------------------------- | ---------------------- |
@@ -50,7 +50,7 @@ Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVi
| [Intel](/configuration/object_detectors.md#openvino-detector) | `openvino` | `yolov9` |
| [NVidia GPU](/configuration/object_detectors#onnx) | `onnx` | `yolov9` |
| [AMD ROCm GPU](/configuration/object_detectors#amdrocm-gpu-detector) | `onnx` | `yolov9` |
| [Hailo8/Hailo8L/Hailo8R](/configuration/object_detectors#hailo-8) | `hailo8l` | `yolov9` |
| [Hailo8/Hailo8L/Hailo8R](/configuration/object_detectors#hailo) | `hailo` | `yolov9` |
| [Rockchip NPU](/configuration/object_detectors#rockchip-platform) | `rknn` | `yolov9` |
## Improving your model
+1632 -3641
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -18,16 +18,16 @@
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "^3.7.0",
"@docusaurus/plugin-content-docs": "^3.7.0",
"@docusaurus/preset-classic": "^3.7.0",
"@docusaurus/theme-mermaid": "^3.7.0",
"@docusaurus/core": "^3.10.2",
"@docusaurus/plugin-content-docs": "^3.10.2",
"@docusaurus/preset-classic": "^3.10.2",
"@docusaurus/theme-mermaid": "^3.10.2",
"@inkeep/docusaurus": "^2.0.16",
"@mdx-js/react": "^3.1.0",
"@types/js-yaml": "^4.0.9",
"clsx": "^2.1.1",
"docusaurus-plugin-openapi-docs": "^4.5.1",
"docusaurus-theme-openapi-docs": "^4.5.1",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
"js-yaml": "^4.3.2",
"marked": "^16.4.2",
"prism-react-renderer": "^2.4.1",
@@ -48,11 +48,11 @@
]
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.7.0",
"@docusaurus/types": "^3.7.0",
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/types": "^3.10.2",
"@types/react": "^18.3.27"
},
"engines": {
"node": ">=18.0"
"node": ">=20.19"
}
}
+2 -9
View File
@@ -2,7 +2,6 @@
import asyncio
import copy
import json
import logging
import os
import platform
@@ -11,7 +10,6 @@ import urllib
from datetime import datetime, timedelta
from functools import reduce
from io import StringIO
from pathlib import Path as FilePath
from typing import Any
import aiofiles
@@ -56,6 +54,7 @@ from frigate.jobs.media_sync import (
start_media_sync_job,
)
from frigate.models import Event, Timeline
from frigate.plus import load_plus_model_info
from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.types import JobStatusTypesEnum
from frigate.util.builtin import (
@@ -401,13 +400,7 @@ def config(request: Request):
model_dict["plus"] = None
if model.path:
model_json_path = FilePath(model.path).with_suffix(".json")
try:
with open(model_json_path) as f:
model_dict["plus"] = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
pass
model_dict["plus"] = load_plus_model_info(os.path.basename(model.path))
return JSONResponse(content=config)
+7
View File
@@ -786,6 +786,13 @@ def auth(request: Request):
user = token.claims.get("sub")
role = token.claims.get("role")
# the token keeps the role it was issued with, so a role removed from
# the config since then must send the user back through login
if role not in auth_config.roles:
logger.debug("jwt role %s is not in the config", role)
return fail_response
current_time = int(time.time())
# if the jwt is expired
+14 -4
View File
@@ -1038,6 +1038,10 @@ def export_recording_custom(
if camera_validation_error is not None:
return camera_validation_error
# Validate user-provided ffmpeg args to prevent injection and add to cases.
# Admin users are trusted and skip validation.
is_admin = request.headers.get("remote-role", "") == "admin"
playback_source = body.source
friendly_name = body.name
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
@@ -1048,6 +1052,16 @@ def export_recording_custom(
cpu_fallback = body.cpu_fallback
export_case_id = body.export_case_id
if export_case_id is not None and not is_admin:
return JSONResponse(
content={
"success": False,
"message": "Only admins can attach exports to an existing case.",
},
status_code=403,
)
case_validation_error = _validate_export_case(export_case_id)
if case_validation_error is not None:
return case_validation_error
@@ -1064,10 +1078,6 @@ def export_recording_custom(
status_code=400,
)
# Validate user-provided ffmpeg args to prevent injection.
# Admin users are trusted and skip validation.
is_admin = request.headers.get("remote-role", "") == "admin"
if not is_admin:
for args_label, args_value in [
("input", ffmpeg_input_args),
+83 -62
View File
@@ -189,7 +189,7 @@ async def camera_ptz_info(request: Request, camera_name: str):
future = asyncio.run_coroutine_threadsafe(
request.app.onvif.get_camera_info(camera_name), request.app.onvif.loop
)
result = future.result()
result = await asyncio.wrap_future(future)
return JSONResponse(content=result)
else:
return JSONResponse(
@@ -258,15 +258,15 @@ async def latest_frame(
frame = request.app.camera_error_image
height = int(params.height or str(frame.shape[0]))
width = int(height * frame.shape[1] / frame.shape[0])
if frame is None:
return JSONResponse(
content={"success": False, "message": "Unable to get valid frame"},
status_code=500,
)
height = int(params.height or str(frame.shape[0]))
width = int(height * frame.shape[1] / frame.shape[0])
if height < 1 or width < 1:
return JSONResponse(
content="Invalid height / width requested :: {} / {}".format(
@@ -886,9 +886,8 @@ async def vod_event(
# If the recordings are not found and the event started more than 5 minutes ago, set has_clip to false
if (
event.start_time < datetime.now().timestamp() - 300
and type(vod_response) is tuple
and len(vod_response) == 2
and vod_response[1] == 404
and isinstance(vod_response, JSONResponse)
and vod_response.status_code == 404
):
Event.update(has_clip=False).where(Event.id == event_id).execute()
@@ -956,64 +955,80 @@ async def event_snapshot(
event_complete = False
jpg_bytes = None
frame_time = 0
try:
event = Event.get(Event.id == event_id, Event.end_time != None)
event_complete = True
await require_camera_access(event.camera, request=request)
except DoesNotExist:
event = None
if event is not None:
event_complete = True
if not event.has_snapshot:
return JSONResponse(
content={"success": False, "message": "Snapshot not available"},
status_code=404,
)
snapshot_settings = _resolve_snapshot_settings(
request.app.frigate_config.cameras[event.camera].snapshots, params
)
jpg_bytes, frame_time = get_event_snapshot_bytes(
event,
ext="jpg",
timestamp=snapshot_settings["timestamp"],
bounding_box=snapshot_settings["bounding_box"],
crop=snapshot_settings["crop"],
height=snapshot_settings["height"],
quality=snapshot_settings["quality"],
timestamp_style=request.app.frigate_config.cameras[
event.camera
].timestamp_style,
colormap=request.app.frigate_config.model_for_camera(event.camera).colormap,
)
except DoesNotExist:
# see if the object is currently being tracked
try:
camera_states: list[CameraState] = (
request.app.detected_frames_processor.get_camera_states()
snapshot_settings = _resolve_snapshot_settings(
request.app.frigate_config.cameras[event.camera].snapshots, params
)
jpg_bytes, frame_time = get_event_snapshot_bytes(
event,
ext="jpg",
timestamp=snapshot_settings["timestamp"],
bounding_box=snapshot_settings["bounding_box"],
crop=snapshot_settings["crop"],
height=snapshot_settings["height"],
quality=snapshot_settings["quality"],
timestamp_style=request.app.frigate_config.cameras[
event.camera
].timestamp_style,
colormap=request.app.frigate_config.model_for_camera(
event.camera
).colormap,
)
for camera_state in camera_states:
if event_id in camera_state.tracked_objects:
tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is not None:
snapshot_settings = _resolve_snapshot_settings(
camera_state.camera_config.snapshots, params
)
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
ext="jpg",
timestamp=snapshot_settings["timestamp"],
bounding_box=snapshot_settings["bounding_box"],
crop=snapshot_settings["crop"],
height=snapshot_settings["height"],
quality=snapshot_settings["quality"],
)
await require_camera_access(camera_state.name, request=request)
except Exception:
return JSONResponse(
content={"success": False, "message": "Ongoing event not found"},
content={"success": False, "message": "Unknown error occurred"},
status_code=404,
)
except Exception:
return JSONResponse(
content={"success": False, "message": "Unknown error occurred"},
status_code=404,
else:
# see if the object is currently being tracked
camera_states: list[CameraState] = (
request.app.detected_frames_processor.get_camera_states()
)
for camera_state in camera_states:
tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is None:
continue
await require_camera_access(camera_state.name, request=request)
try:
snapshot_settings = _resolve_snapshot_settings(
camera_state.camera_config.snapshots, params
)
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
ext="jpg",
timestamp=snapshot_settings["timestamp"],
bounding_box=snapshot_settings["bounding_box"],
crop=snapshot_settings["crop"],
height=snapshot_settings["height"],
quality=snapshot_settings["quality"],
)
except Exception:
return JSONResponse(
content={"success": False, "message": "Ongoing event not found"},
status_code=404,
)
break
if jpg_bytes is None:
return JSONResponse(
content={"success": False, "message": "Live frame not available"},
@@ -1062,19 +1077,25 @@ async def event_thumbnail(
if not thumbnail_bytes:
# see if the object is currently being tracked
try:
camera_states = request.app.detected_frames_processor.get_camera_states()
for camera_state in camera_states:
if event_id in camera_state.tracked_objects:
tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is not None:
await require_camera_access(camera_state.name, request=request)
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
except Exception:
return JSONResponse(
content={"success": False, "message": "Event not found"},
status_code=404,
)
camera_states = request.app.detected_frames_processor.get_camera_states()
for camera_state in camera_states:
tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is None:
continue
await require_camera_access(camera_state.name, request=request)
try:
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
except Exception:
return JSONResponse(
content={"success": False, "message": "Event not found"},
status_code=404,
)
break
if not thumbnail_bytes:
return JSONResponse(
+2 -5
View File
@@ -412,11 +412,8 @@ async def no_recordings(
if not camera_list:
return JSONResponse(content=[])
before = params.before or datetime.datetime.now().timestamp()
after = (
params.after
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
)
before = params.before or datetime.now().timestamp()
after = params.after or (datetime.now() - timedelta(hours=1)).timestamp()
scale = params.scale
recordings: list[tuple[float, float]] = []
@@ -253,7 +253,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
# Crop snapshot based on region
# provide full image if region doesn't exist (manual events)
height, width = img.shape[:2]
x1_rel, y1_rel, width_rel, height_rel = event.data.get( # type: ignore[attr-defined]
x1_rel, y1_rel, width_rel, height_rel = event.data.get(
"region", [0, 0, 1, 1]
)
x1, y1 = int(x1_rel * width), int(y1_rel * height)
@@ -8,7 +8,7 @@ import os
import shutil
import threading
from pathlib import Path
from typing import Any
from typing import Any, cast
import cv2
from peewee import DoesNotExist
@@ -528,7 +528,8 @@ class ReviewDescriptionProcessor(PostProcessorApi):
.get()
)
time_in_segment = ts - recording.start_time
# start_time is a DateTimeField holding a unix timestamp
time_in_segment = ts - cast(float, recording.start_time)
return get_image_from_recording(
self.config.ffmpeg,
recording.path,
@@ -237,7 +237,7 @@ class SemanticTriggerProcessor(PostProcessorApi):
return
# Skip the event if not an object
if event.data.get("type") != "object": # type: ignore[attr-defined]
if event.data.get("type") != "object":
return
thumbnail_bytes = get_event_thumbnail_bytes(event)
+8 -7
View File
@@ -3,14 +3,14 @@ import json
import logging
import os
from enum import Enum
from typing import Any, ClassVar
from typing import ClassVar
import requests
from pydantic import BaseModel, ConfigDict, Field
from pydantic.fields import PrivateAttr
from frigate.const import DEFAULT_ATTRIBUTE_LABEL_MAP, MODEL_CACHE_DIR
from frigate.plus import PlusApi
from frigate.plus import PlusApi, load_plus_model_info
from frigate.util.builtin import generate_color_palette, load_labels
logger = logging.getLogger(__name__)
@@ -190,12 +190,13 @@ class ModelConfig(BaseModel):
# download the model info if it doesn't exist
if not os.path.isfile(model_info_path):
model_info = plus_api.get_model_info(model_id)
with open(model_info_path, "w") as f:
json.dump(model_info, f)
else:
with open(model_info_path) as f:
model_info: dict[str, Any] = json.load(f)
json.dump(plus_api.get_model_info(model_id), f)
model_info = load_plus_model_info(model_id)
if model_info is None:
raise ValueError(f"Unable to read the model info for {model_id}")
if detector and detector not in model_info["supportedDetectors"]:
raise ValueError(f"Model does not support detector type of {detector}")
+2 -2
View File
@@ -259,8 +259,8 @@ def detect_hailo() -> DetectionHardware | None:
# the hailo runtime schedules across every attached device itself, so there
# is nothing to address individually
units = [HardwareUnit(device="hailo8l:PCIe", label=os.path.basename(nodes[0]))]
return _hardware("hailo8l", "hailo8l", "Hailo", units)
units = [HardwareUnit(device="hailo:PCIe", label=os.path.basename(nodes[0]))]
return _hardware("hailo", "hailo", "Hailo", units)
def detect_memryx() -> DetectionHardware | None:
@@ -53,7 +53,7 @@ def preprocess_tensor(image: np.ndarray, model_w: int, model_h: int) -> np.ndarr
# ----------------- Global Constants ----------------- #
DETECTOR_KEY = "hailo8l"
DETECTOR_KEY = "hailo"
ARCH = None
H8_DEFAULT_MODEL = "yolov6n.hef"
H8L_DEFAULT_MODEL = "yolov6n.hef"
@@ -469,10 +469,10 @@ class HailoDetector(DetectionApi):
# ----------------- HailoDetectorConfig Class ----------------- #
class HailoDetectorConfig(BaseDetectorConfig):
"""Hailo-8/Hailo-8L detector using HEF models and the HailoRT SDK for inference on Hailo hardware."""
"""Hailo detector using HEF models and the HailoRT SDK for inference on Hailo hardware."""
model_config = ConfigDict(
title="Hailo-8/Hailo-8L",
title="Hailo",
)
type: Literal[DETECTOR_KEY]
+5 -5
View File
@@ -37,7 +37,7 @@ class EventCleanup(threading.Thread):
if self.removed_camera_labels is None:
self.removed_camera_labels = list(
Event.select(Event.label)
.where(Event.camera.not_in(self.camera_keys)) # type: ignore[arg-type,call-arg,misc]
.where(Event.camera.not_in(self.camera_keys))
.distinct()
.execute()
)
@@ -89,7 +89,7 @@ class EventCleanup(threading.Thread):
Event.thumbnail,
)
.where(
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
Event.camera.not_in(self.camera_keys),
Event.start_time < expire_after,
Event.label == event.label,
Event.retain_indefinitely == False,
@@ -111,7 +111,7 @@ class EventCleanup(threading.Thread):
# update the clips attribute for the db entry
query = Event.select(Event.id).where(
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
Event.camera.not_in(self.camera_keys),
Event.start_time < expire_after,
Event.label == event.label,
Event.retain_indefinitely == False,
@@ -218,7 +218,7 @@ class EventCleanup(threading.Thread):
Event.camera,
)
.where(
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
Event.camera.not_in(self.camera_keys),
Event.start_time < expire_after,
Event.retain_indefinitely == False,
)
@@ -249,7 +249,7 @@ class EventCleanup(threading.Thread):
# update the clips attribute for the db entry
query = Event.select(Event.id).where(
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
Event.camera.not_in(self.camera_keys),
Event.start_time < expire_after,
Event.retain_indefinitely == False,
)
+2 -2
View File
@@ -216,11 +216,11 @@ class ExportDebugReplaySource(DebugReplaySource):
"""
def __init__(self, export: Export, duration: float) -> None:
self._camera = cast(str, export.camera)
self._camera = export.camera
# Export.date is declared DateTimeField but Frigate writes raw unix
# timestamps to the column.
self._start_ts = float(cast(Any, export.date))
self._video_path = cast(str, export.video_path)
self._video_path = export.video_path
self._duration = duration
@property
+2
View File
@@ -109,6 +109,8 @@ class Export(Model):
backref="exports",
column_name="export_case_id",
)
# peewee adds this accessor for the export_case column at runtime
export_case_id: str | None
class ReviewSegment(Model):
+3 -3
View File
@@ -186,7 +186,7 @@ class NoticeRegistry:
deleted = (
Notice.delete()
.where(
Notice.kind.in_(camera_kinds), # type: ignore[call-arg, arg-type, misc]
Notice.kind.in_(camera_kinds),
Notice.scope == camera,
)
.execute()
@@ -257,7 +257,7 @@ class NoticeRegistry:
"""Dismissed config and stream check rows, newest first."""
rows = (
Notice.select()
.where(Notice.kind.in_(list(CHECK_KINDS))) # type: ignore[call-arg, arg-type, misc]
.where(Notice.kind.in_(list(CHECK_KINDS)))
.order_by(Notice.dismissed_at.desc())
)
return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows]
@@ -351,7 +351,7 @@ class NoticeRegistry:
)
Notice.delete().where(
Notice.kind == kind,
Notice.id.not_in(newest), # type: ignore[call-arg, misc]
Notice.id.not_in(newest),
).execute()
def _bump_occurrences(self, kind: str, count: int, now: float) -> None:
+8 -2
View File
@@ -66,6 +66,12 @@ def get_cache_image_name(camera: str, frame_time: float) -> str:
)
def is_camera_preview_frame(file_name: str, camera: str) -> bool:
"""Check whether a cached preview frame file belongs to the camera."""
# camera names may contain "-", so a prefix match would include "front-door"
return file_name.rsplit("-", 1)[0] == f"preview_{camera}"
def get_most_recent_preview_frame(
camera: str, before: float | None = None
) -> str | None:
@@ -79,7 +85,7 @@ def get_most_recent_preview_frame(
preview_files = [
f
for f in os.listdir(PREVIEW_CACHE_DIR)
if f.startswith(f"preview_{camera}-")
if is_camera_preview_frame(f, camera)
and f.endswith(f".{PREVIEW_FRAME_TYPE}")
]
@@ -276,7 +282,7 @@ class PreviewRecorder:
start_file = f"{file_start}{start_ts}.webp"
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
if not file.startswith(file_start):
if not is_camera_preview_frame(file, self.camera_name):
continue
if file < start_file:
+46 -2
View File
@@ -11,11 +11,50 @@ import requests
from numpy import ndarray
from requests.models import Response
from frigate.const import PLUS_API_HOST, PLUS_ENV_VAR
from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
logger = logging.getLogger(__name__)
def add_hailo_alias(model_info: dict[str, Any]) -> dict[str, Any]:
"""Name the hailo detector by its current key as well as its old one.
Frigate+ reports every Hailo model as supporting hailo8l, which this
detector was called before it was renamed to cover every Hailo device.
The old key is kept so an older Frigate still matches the model.
Args:
model_info: A Frigate+ model's metadata, edited in place
Returns:
The same metadata
"""
supported = model_info.get("supportedDetectors")
if supported and "hailo8l" in supported and "hailo" not in supported:
supported.append("hailo")
return model_info
def load_plus_model_info(model_id: str) -> dict[str, Any] | None:
"""Read a Frigate+ model's cached info file.
Args:
model_id: The Frigate+ model id
Returns:
The model info, or None when it has not been cached or cannot be read
"""
try:
with open(os.path.join(MODEL_CACHE_DIR, f"{model_id}.json")) as f:
model_info: dict[str, Any] = json.load(f)
except (OSError, ValueError):
return None
return add_hailo_alias(model_info)
def get_jpg_bytes(image: ndarray, max_dim: int, quality: int) -> bytes:
if image.shape[1] >= image.shape[0]:
width = min(max_dim, image.shape[1])
@@ -241,4 +280,9 @@ class PlusApi:
if not r.ok:
raise Exception(r.text)
return r.json()
models = r.json()
for model in models.get("list") or []:
add_hailo_alias(model)
return models
+6 -5
View File
@@ -5,6 +5,7 @@ import itertools
import logging
import os
import threading
from collections.abc import Iterable
from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
from typing import Any
@@ -28,7 +29,7 @@ logger = logging.getLogger(__name__)
def _filter_reviews_for_pass(
reviews: list[Any],
reviews: Iterable[Any],
now: datetime.datetime,
alerts_days: float,
detections_days: float,
@@ -121,14 +122,14 @@ class RecordingCleanup(threading.Thread):
)
maybe_empty_dirs = set()
thumbs_to_delete = list(map(lambda x: x[1], expired_reviews))
for thumb_path in thumbs_to_delete:
thumb_path = Path(thumb_path)
thumbs_to_delete = list(map(lambda x: x.thumb_path, expired_reviews))
for thumb in thumbs_to_delete:
thumb_path = Path(thumb)
thumb_path.unlink(missing_ok=True)
maybe_empty_dirs.add(thumb_path.parent)
max_deletes = 100000
deleted_reviews_list = list(map(lambda x: x[0], expired_reviews))
deleted_reviews_list = list(map(lambda x: x.id, expired_reviews))
for i in range(0, len(deleted_reviews_list), max_deletes):
ReviewSegment.delete().where(
ReviewSegment.id << deleted_reviews_list[i : i + max_deletes]
+7 -3
View File
@@ -14,7 +14,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any
from typing import Any, cast
import pytz # type: ignore[import-untyped]
from pathvalidate import sanitize_filename
@@ -36,6 +36,7 @@ from frigate.ffmpeg_presets import (
parse_preset_hardware_acceleration_encode,
)
from frigate.models import Export, Previews, Recordings, ReviewSegment
from frigate.output.preview import is_camera_preview_frame
from frigate.util.ffmpeg import run_ffmpeg_with_progress
from frigate.util.ownership import chown_to_runtime
from frigate.util.recording_coverage import (
@@ -1055,7 +1056,10 @@ class RecordingExporter(threading.Thread):
except DoesNotExist:
return ""
diff = max(0.0, float(self.start_time) - float(preview.start_time))
# start_time is a DateTimeField holding a unix timestamp
diff = max(
0.0, float(self.start_time) - float(cast(Any, preview.start_time))
)
ffmpeg_cmd = [
"/usr/lib/ffmpeg/8.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
"-hide_banner",
@@ -1095,7 +1099,7 @@ class RecordingExporter(threading.Thread):
fallback_preview = None
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
if not is_camera_preview_frame(file, self.camera):
continue
if file < start_file:
+1 -1
View File
@@ -467,7 +467,7 @@ def get_hardware_temperatures(detector_type: str) -> list[float | None]:
read_temperature(os.path.join(base, apex, "temp"))
for apex in sorted(os.listdir(base))
]
elif detector_type == "hailo8l":
elif detector_type == "hailo":
hailo_temps = get_hailo_temps()
return [hailo_temps[name] for name in sorted(hailo_temps.keys())]
+5 -2
View File
@@ -3,8 +3,10 @@
import logging
import shutil
import threading
from collections.abc import Iterable
from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
from typing import Any, cast
from peewee import SQL, Case, fn
@@ -191,14 +193,15 @@ class StorageMaintainer(threading.Thread):
stream_usages = {
row["stream_type"]: row["usage"] or 0
for row in (
for row in cast(
Iterable[dict[str, Any]],
Recordings.select(
Recordings.stream_type,
fn.SUM(Recordings.segment_size).alias("usage"),
)
.where(Recordings.camera == camera, Recordings.segment_size != 0)
.group_by(Recordings.stream_type)
.dicts()
.dicts(),
)
}
stream_bandwidths = self.camera_storage_stats.get(camera, {}).get(
@@ -0,0 +1,81 @@
"""Tests that /auth only accepts a JWT whose role is still in the config."""
import os
import time
from unittest.mock import MagicMock, Mock, patch
from frigate.api.auth import create_encoded_jwt
from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.const import JWT_SECRET_ENV_VAR
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
class TestAuthJwtRole(BaseTestHttp):
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
"networking": {"listen": {"internal": 5000, "external": 8971}},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
def _create_app(self):
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
return create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
enforce_default_admin=False,
)
def _auth(self, app, role: str):
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token)
with AuthTestClient(app) as client:
return client.get(
"/auth",
headers={
"x-server-port": "8971",
"authorization": f"Bearer {token}",
},
)
def test_configured_role_is_accepted(self):
resp = self._auth(self._create_app(), "garage")
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-user"], "bob")
self.assertEqual(resp.headers["remote-role"], "garage")
def test_role_removed_from_config_is_rejected(self):
resp = self._auth(self._create_app(), "removed_role")
self.assertEqual(resp.status_code, 401)
self.assertNotIn("remote-role", resp.headers)
@@ -0,0 +1,193 @@
"""Tests for renaming and deleting a zone through config_set's JSON body."""
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
class TestConfigSetZones(BaseTestHttp):
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"profiles": {"armed": {"friendly_name": "Armed"}},
"snapshots": {"required_zones": ["driveway"]},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"zones": {
"driveway": {
"coordinates": "0,0,1,0,1,1",
"inertia": 5,
"filters": {"person": {"min_area": 5000}},
},
"porch": {"coordinates": "0,0,0.5,0,0.5,0.5"},
},
"review": {
"alerts": {
"labels": ["person"],
"required_zones": ["driveway"],
},
},
"mqtt": {"required_zones": ["driveway", "porch"]},
"profiles": {
"armed": {
"zones": {
"driveway": {
"coordinates": "0,0,1,0,1,1",
"objects": ["car"],
},
},
},
},
},
},
}
yaml = ruamel.yaml.YAML()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yml", delete=False
) as config_file:
yaml.dump(self.minimal_config, config_file)
self.config_path = config_file.name
self.addCleanup(os.unlink, self.config_path)
def _create_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"]
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 _put(self, camera_data: dict):
with patch("frigate.api.app.find_config_file", return_value=self.config_path):
with AuthTestClient(self._create_app()) as client:
return client.put(
"/config/set",
json={
"config_data": {"cameras": {"front": camera_data}},
"requires_restart": 0,
"update_topic": "config/cameras/front/zones",
},
)
def _front(self) -> dict:
with open(self.config_path) as f:
return ruamel.yaml.YAML().load(f)["cameras"]["front"]
def test_rename_moves_zone_references_in_one_request(self):
"""The new base zone exists when validation checks the moved override."""
resp = self._put(
{
"zones": {
"driveway": None,
"front_drive": {
"coordinates": "0,0,1,0,1,1",
"enabled": True,
# as /api/config returns them, with defaults filled in
"filters": {
"person": {
"min_area": 5000,
"max_area": 24000000,
"min_ratio": 0.0,
"max_ratio": 24000000.0,
"threshold": 0.7,
"min_score": 0.5,
"mask": {},
}
},
"inertia": 5,
},
},
"review": {"alerts": {"required_zones": ["front_drive"]}},
# inherited from the global snapshots config, so no camera key
"snapshots": {"required_zones": ["front_drive"]},
"mqtt": {"required_zones": ["front_drive", "porch"]},
"profiles": {
"armed": {
"zones": {
"driveway": None,
"front_drive": {
"coordinates": "0,0,1,0,1,1",
"objects": ["car"],
},
},
},
},
}
)
self.assertEqual(resp.status_code, 200, resp.json())
front = self._front()
self.assertEqual(list(front["zones"]), ["porch", "front_drive"])
self.assertEqual(front["zones"]["front_drive"]["inertia"], 5)
self.assertEqual(
front["zones"]["front_drive"]["filters"]["person"]["min_area"], 5000
)
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
self.assertEqual(front["review"]["alerts"]["required_zones"], ["front_drive"])
self.assertEqual(front["snapshots"]["required_zones"], ["front_drive"])
self.assertEqual(front["mqtt"]["required_zones"], ["front_drive", "porch"])
self.assertEqual(
dict(front["profiles"]["armed"]["zones"]),
{"front_drive": {"coordinates": "0,0,1,0,1,1", "objects": ["car"]}},
)
def test_delete_empties_lists_without_dropping_their_section(self):
resp = self._put(
{
"zones": {"driveway": None},
"review": {"alerts": {"required_zones": []}},
"snapshots": {"required_zones": []},
"mqtt": {"required_zones": ["porch"]},
"profiles": {"armed": {"zones": {"driveway": None}}},
}
)
self.assertEqual(resp.status_code, 200, resp.json())
front = self._front()
self.assertEqual(list(front["zones"]), ["porch"])
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
self.assertEqual(front["review"]["alerts"]["required_zones"], [])
self.assertEqual(front["mqtt"]["required_zones"], ["porch"])
self.assertNotIn("driveway", front["profiles"]["armed"]["zones"] or {})
+87
View File
@@ -12,6 +12,7 @@ from frigate.util.config import (
CURRENT_CONFIG_VERSION,
migrate_frigate_config,
migrate_models,
rename_hailo_detector,
)
@@ -164,6 +165,61 @@ class TestMigrateModels(unittest.TestCase):
self.assertEqual(migrated["mqtt"], {"host": "mqtt"})
class TestMigrateRenamedDetectors(unittest.TestCase):
def test_a_hailo_detector_is_renamed(self):
migrated = migrate_models(
{"detectors": {"hailo": {"type": "hailo8l", "device": "PCIe"}}}
)
self.assertEqual(migrated["models"][0]["devices"], ["hailo:PCIe"])
def test_a_hailo_detector_without_a_device(self):
migrated = migrate_models({"detectors": {"hailo": {"type": "hailo8l"}}})
self.assertEqual(migrated["models"][0]["devices"], ["hailo"])
def test_two_hailo_detectors_stay_separate(self):
# the shareable lookup has to resolve through the renamed key
migrated = migrate_models(
{
"detectors": {
"hailo1": {"type": "hailo8l", "device": "PCIe"},
"hailo2": {"type": "hailo8l", "device": "PCIe"},
}
}
)
self.assertEqual(migrated["models"][0]["devices"], ["hailo:PCIe", "hailo:PCIe"])
class TestRenameHailoDetector(unittest.TestCase):
def test_a_renamed_detector_is_updated(self):
migrated = rename_hailo_detector(
{"models": [{"devices": ["hailo8l:PCIe", "hailo8l"]}]}
)
self.assertEqual(migrated["models"][0]["devices"], ["hailo:PCIe", "hailo"])
def test_other_detectors_are_untouched(self):
migrated = rename_hailo_detector(
{"models": [{"devices": ["openvino:GPU", "cpu"]}]}
)
self.assertEqual(migrated["models"][0]["devices"], ["openvino:GPU", "cpu"])
def test_renaming_is_idempotent(self):
once = rename_hailo_detector({"models": [{"devices": ["hailo8l:PCIe"]}]})
twice = rename_hailo_detector(once)
self.assertEqual(twice["models"][0]["devices"], ["hailo:PCIe"])
def test_a_config_without_models_is_left_alone(self):
self.assertEqual(
rename_hailo_detector({"mqtt": {"enabled": False}}),
{"mqtt": {"enabled": False}},
)
class TestMigrateConfigFile(unittest.TestCase):
"""The full file migration, which is gated on shape as well as version."""
@@ -220,6 +276,37 @@ class TestMigrateConfigFile(unittest.TestCase):
os.path.exists(os.path.join(self.temp_dir.name, "backup_config.yaml"))
)
def test_a_legacy_hailo_detector_lands_on_the_hailo_key(self):
# the detectors key is folded into models after the version chain has
# run, so the rename has to happen there too
migrated = self._migrate(
"mqtt:\n"
" enabled: false\n"
"detectors:\n"
" hailo:\n"
" type: hailo8l\n"
" device: PCIe\n"
"cameras: {}\n"
"version: 0.18-0\n"
)
self.assertEqual(migrated["models"][0]["devices"], ["hailo:PCIe"])
self.assertNotIn("detectors", migrated)
def test_a_hailo8l_device_is_renamed_at_the_current_version(self):
migrated = self._migrate(
"mqtt:\n"
" enabled: false\n"
"models:\n"
" - scene: all\n"
" devices:\n"
" - hailo8l:PCIe\n"
"cameras: {}\n"
f"version: {CURRENT_CONFIG_VERSION}\n"
)
self.assertEqual(migrated["models"][0]["devices"], ["hailo:PCIe"])
if __name__ == "__main__":
unittest.main(verbosity=2)
+1 -1
View File
@@ -170,7 +170,7 @@ class TestAccelerators(HardwareProbeTestCase):
def test_hailo_is_found_by_its_device_node(self):
write(os.path.join(self.dev_root, "hailo0"))
self.assertEqual(self.probe()["hailo8l"].units[0].device, "hailo8l:PCIe")
self.assertEqual(self.probe()["hailo"].units[0].device, "hailo:PCIe")
def test_each_memryx_node_is_a_unit(self):
write(os.path.join(self.dev_root, "memx0"))
+54
View File
@@ -0,0 +1,54 @@
"""Tests for get_dst_transitions."""
import datetime
import unittest
from frigate.util.time import get_dst_transitions
class TestDstTransitions(unittest.TestCase):
def test_dst_transition_splits_periods_at_the_transition(self):
start = datetime.datetime(2026, 3, 7, 12, tzinfo=datetime.UTC).timestamp()
end = start + 2 * 86400
spring = datetime.datetime(2026, 3, 8, 7, tzinfo=datetime.UTC).timestamp()
self.assertEqual(
get_dst_transitions("America/New_York", start, end),
[(start, spring, -18000), (spring, end, -14400)],
)
def test_dst_transition_is_not_reported_a_day_late(self):
# local midnight on the day of the change used to report the
# transition a full day after it actually happened
start = datetime.datetime(2024, 3, 10, 5, tzinfo=datetime.UTC).timestamp()
end = start + 3 * 86400
spring = datetime.datetime(2024, 3, 10, 7, tzinfo=datetime.UTC).timestamp()
self.assertEqual(
get_dst_transitions("America/New_York", start, end),
[(start, spring, -18000), (spring, end, -14400)],
)
def test_dst_transition_after_the_last_daily_probe_is_found(self):
start = datetime.datetime(2024, 11, 2, 12, tzinfo=datetime.UTC).timestamp()
end = datetime.datetime(2024, 11, 3, 10, tzinfo=datetime.UTC).timestamp()
fall = datetime.datetime(2024, 11, 3, 6, tzinfo=datetime.UTC).timestamp()
self.assertEqual(
get_dst_transitions("America/New_York", start, end),
[(start, fall, -14400), (fall, end, -18000)],
)
def test_no_transition_returns_a_single_period(self):
start = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC).timestamp()
end = start + 5 * 86400
self.assertEqual(
get_dst_transitions("America/New_York", start, end),
[(start, end, -14400)],
)
def test_invalid_zone_retains_utc_fallback(self):
self.assertEqual(
get_dst_transitions("Invalid/Timezone", 100, 200), [(100, 200, 0)]
)
if __name__ == "__main__":
unittest.main(verbosity=2)
+2 -2
View File
@@ -200,7 +200,7 @@ class TestScanDetectors(HardwareStatsTestCase):
self.scan([DeviceSpec("openvino:CPU", "openvino", "CPU")]), set()
)
self.assertEqual(self.scan([DeviceSpec("cpu", "cpu", None)]), set())
self.assertEqual(self.scan([DeviceSpec("hailo8l", "hailo8l", None)]), set())
self.assertEqual(self.scan([DeviceSpec("hailo", "hailo", None)]), set())
def test_onnx_resolves_to_present_gpu(self):
spec = DeviceSpec("onnx", "onnx", None)
@@ -412,7 +412,7 @@ class TestHardwareTemperatures(unittest.TestCase):
return_value={"hailo8l-1": 52.0, "hailo8l-0": 51.0},
)
def test_hailo_sorted_by_name(self, temps):
self.assertEqual(get_hardware_temperatures("hailo8l"), [51.0, 52.0])
self.assertEqual(get_hardware_temperatures("hailo"), [51.0, 52.0])
def test_unsupported_type(self):
self.assertEqual(get_hardware_temperatures("rknn"), [])
+79
View File
@@ -0,0 +1,79 @@
"""Tests for Frigate+ model metadata."""
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from frigate.plus import PlusApi, load_plus_model_info
class TestHailoAlias(unittest.TestCase):
"""Frigate+ reports Hailo models by the detector's pre-rename key.
Both the model list and a cached info file have to carry the current key,
or every Hailo model reads as unsupported.
"""
def _get_models(self, models: list[dict]) -> list[dict]:
api = PlusApi.__new__(PlusApi)
response = MagicMock(ok=True, json=lambda: {"list": models})
with patch.object(PlusApi, "_get", return_value=response):
return api.get_models()["list"]
def test_the_model_list_gains_the_current_key(self):
models = self._get_models([{"supportedDetectors": ["hailo8l"]}])
self.assertEqual(models[0]["supportedDetectors"], ["hailo8l", "hailo"])
def test_the_old_key_is_kept_for_older_versions(self):
models = self._get_models([{"supportedDetectors": ["hailo8l"]}])
self.assertIn("hailo8l", models[0]["supportedDetectors"])
def test_other_detectors_are_untouched(self):
models = self._get_models([{"supportedDetectors": ["openvino", "onnx"]}])
self.assertEqual(models[0]["supportedDetectors"], ["openvino", "onnx"])
def test_a_model_already_naming_both_is_unchanged(self):
models = self._get_models([{"supportedDetectors": ["hailo8l", "hailo"]}])
self.assertEqual(models[0]["supportedDetectors"], ["hailo8l", "hailo"])
def test_an_empty_list(self):
self.assertEqual(self._get_models([]), [])
class TestLoadPlusModelInfo(unittest.TestCase):
def setUp(self):
self.cache = tempfile.TemporaryDirectory()
self.addCleanup(self.cache.cleanup)
patcher = patch("frigate.plus.MODEL_CACHE_DIR", self.cache.name)
patcher.start()
self.addCleanup(patcher.stop)
def _write(self, model_id: str, content: str) -> None:
with open(os.path.join(self.cache.name, f"{model_id}.json"), "w") as f:
f.write(content)
def test_a_cached_hailo_model_gains_the_current_key(self):
self._write("abc", json.dumps({"supportedDetectors": ["hailo8l"]}))
self.assertEqual(
load_plus_model_info("abc")["supportedDetectors"], ["hailo8l", "hailo"]
)
def test_a_missing_file(self):
self.assertIsNone(load_plus_model_info("nope"))
def test_an_unreadable_file(self):
self._write("bad", "{not json")
self.assertIsNone(load_plus_model_info("bad"))
if __name__ == "__main__":
unittest.main(verbosity=2)
+15
View File
@@ -75,6 +75,21 @@ class TestPreviewLoader(unittest.TestCase):
self.assertIsNone(get_most_recent_preview_frame(camera))
def test_get_most_recent_preview_frame_hyphenated_camera(self):
for name in ("preview_front-2000.0", "preview_front-door-3000.0"):
with open(
os.path.join(PREVIEW_CACHE_DIR, f"{name}.{PREVIEW_FRAME_TYPE}"), "w"
) as f:
f.write("test")
expected_path = os.path.join(
PREVIEW_CACHE_DIR, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}"
)
self.assertEqual(get_most_recent_preview_frame("front"), expected_path)
self.assertEqual(
get_most_recent_preview_frame("front", before=5000.0), expected_path
)
def test_get_most_recent_preview_frame_no_directory(self):
shutil.rmtree(PREVIEW_CACHE_DIR)
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
+55
View File
@@ -0,0 +1,55 @@
"""Tests for restarting frigate under s6."""
import signal
import unittest
from unittest.mock import MagicMock, patch
import psutil
from frigate.util.services import restart_frigate
class TestRestartFrigate(unittest.TestCase):
def _s6_process(self) -> MagicMock:
proc = MagicMock()
proc.name.return_value = "s6-svscan"
return proc
@patch("frigate.util.services.os.kill")
@patch("frigate.util.services.psutil.Process")
def test_terminates_s6_when_permitted(self, mock_process, mock_kill):
proc = self._s6_process()
mock_process.return_value = proc
restart_frigate()
proc.terminate.assert_called_once()
mock_kill.assert_not_called()
@patch("frigate.util.services.os.getpid", return_value=99)
@patch("frigate.util.services.os.kill")
@patch("frigate.util.services.psutil.Process")
def test_exits_self_when_s6_signal_is_denied(
self, mock_process, mock_kill, _mock_getpid
):
"""Running unprivileged, frigate cannot signal root's s6-svscan."""
proc = self._s6_process()
proc.terminate.side_effect = psutil.AccessDenied(pid=1, name="s6-svscan")
mock_process.return_value = proc
restart_frigate()
mock_kill.assert_called_once_with(99, signal.SIGINT)
@patch("frigate.util.services.os.getpid", return_value=99)
@patch("frigate.util.services.os.kill")
@patch("frigate.util.services.psutil.Process")
def test_exits_self_without_s6(self, mock_process, mock_kill, _mock_getpid):
proc = MagicMock()
proc.name.return_value = "init"
mock_process.return_value = proc
restart_frigate()
proc.terminate.assert_not_called()
mock_kill.assert_called_once_with(99, signal.SIGINT)
+7 -7
View File
@@ -406,12 +406,12 @@ class TrackedObjectProcessor(threading.Thread):
tracked_obj.obj_data["sub_label"] = (sub_label, score)
if event:
event.sub_label = sub_label # type: ignore[assignment]
event.sub_label = sub_label
data = event.data
if sub_label is None:
data["sub_label_score"] = None # type: ignore[index]
data["sub_label_score"] = None
elif score is not None:
data["sub_label_score"] = score # type: ignore[index]
data["sub_label_score"] = score
event.data = data
event.save()
@@ -440,7 +440,7 @@ class TrackedObjectProcessor(threading.Thread):
objects_list = []
sub_labels = set()
events = Event.select(Event.id, Event.label, Event.sub_label).where(
Event.id.in_(detection_ids) # type: ignore[call-arg, misc]
Event.id.in_(detection_ids)
)
for det_event in events:
if det_event.sub_label:
@@ -506,11 +506,11 @@ class TrackedObjectProcessor(threading.Thread):
if event:
data = event.data
data[field_name] = field_value # type: ignore[index]
data[field_name] = field_value
if field_value is None:
data[f"{field_name}_score"] = None # type: ignore[index]
data[f"{field_name}_score"] = None
elif score is not None:
data[f"{field_name}_score"] = score # type: ignore[index]
data[f"{field_name}_score"] = score
event.data = data
event.save()
+61 -2
View File
@@ -168,7 +168,20 @@ def migrate_frigate_config(config_file: str):
# version and still use the pre-models detectors and model keys
needs_models = "detectors" in config or "model" in config
if previous_version == CURRENT_CONFIG_VERSION and not needs_models:
# likewise, it may already be on the models list and still name the hailo
# detector by its old key
needs_detector_rename = any(
isinstance(device, str) and device.partition(":")[0] == "hailo8l"
for model in (config.get("models") or [])
if isinstance(model, dict)
for device in (model.get("devices") or [])
)
if (
previous_version == CURRENT_CONFIG_VERSION
and not needs_models
and not needs_detector_rename
):
logger.info("frigate config does not need migration...")
return
@@ -242,6 +255,12 @@ def migrate_frigate_config(config_file: str):
with open(config_file, "w") as f:
yaml.dump(new_config, f)
if needs_detector_rename:
logger.info("Migrating renamed frigate detectors...")
new_config = rename_hailo_detector(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
logger.info("Finished frigate config migration...")
@@ -775,9 +794,44 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
return new_config
def rename_hailo_detector(
config: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
"""Rename the hailo8l detector, which drives every Hailo device.
Args:
config: The loaded config
Returns:
The config with every models entry naming the detector 'hailo'
"""
new_config = config.copy()
for model in new_config.get("models") or []:
if not isinstance(model, dict):
continue
devices = model.get("devices")
if not isinstance(devices, list):
continue
# assigned per index so ruamel keeps the comments on the list
for index, device in enumerate(devices):
if not isinstance(device, str):
continue
detector, separator, rest = device.partition(":")
if detector == "hailo8l":
devices[index] = f"hailo{separator}{rest}"
return new_config
def migrate_019_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Handle migrating Frigate config to 0.19-0."""
new_config = config.copy()
new_config = rename_hailo_detector(config)
_migrate_birdseye_mode(new_config.get("birdseye"))
@@ -822,6 +876,11 @@ def migrate_models(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any
detector = detector or {}
detector_type = detector.get("type", "cpu")
device = detector.get(DETECTOR_DEVICE_FIELDS.get(detector_type, "device"))
# hailo8l named one device, but the detector drives every Hailo device
if detector_type == "hailo8l":
detector_type = "hailo"
device_string = detector_type if device is None else f"{detector_type}:{device}"
# repeating a device now means running an extra inference process on it,
+10 -3
View File
@@ -35,12 +35,19 @@ logger = logging.getLogger(__name__)
def restart_frigate():
proc = psutil.Process(1)
# if this is running via s6, sigterm pid 1
if proc.name() == "s6-svscan":
proc.terminate()
try:
proc.terminate()
return
except psutil.AccessDenied:
# frigate runs unprivileged, so it cannot signal root's s6-svscan.
# exiting this process instead runs frigate/finish, which halts s6
logger.debug("Not permitted to signal s6-svscan, exiting instead")
# otherwise, just try and exit frigate
else:
os.kill(os.getpid(), signal.SIGINT)
os.kill(os.getpid(), signal.SIGINT)
def print_stack(sig, frame):
+40 -19
View File
@@ -2,6 +2,7 @@
import datetime
import logging
import math
from zoneinfo import ZoneInfoNotFoundError
import pytz
@@ -43,9 +44,33 @@ def is_current_hour(timestamp: int) -> bool:
return timestamp < start_of_next_hour
def _utc_offset(tz: datetime.tzinfo, timestamp: float) -> float:
dt = datetime.datetime.fromtimestamp(timestamp, tz=datetime.UTC)
return dt.astimezone(tz).utcoffset().total_seconds()
def _find_transition(
tz: datetime.tzinfo, lo: float, hi: float, lo_offset: float
) -> float:
"""Bisect (lo, hi] to the second where the UTC offset first differs from lo_offset."""
# whole seconds, so the midpoint always advances (a fractional bound can
# otherwise leave the midpoint sitting on lo) and lands on the transition
low = math.floor(lo)
high = math.ceil(hi)
while high - low > 1:
mid = (low + high) // 2
if _utc_offset(tz, mid) == lo_offset:
low = mid
else:
high = mid
return float(high)
def get_dst_transitions(
tz_name: str, start_time: float, end_time: float
) -> list[tuple[float, float]]:
) -> list[tuple[float, float, float]]:
"""
Find DST transition points and return time periods with consistent offsets.
@@ -66,28 +91,24 @@ def get_dst_transitions(
periods = []
current = start_time
# Get initial offset
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
local_dt = dt.astimezone(tz)
prev_offset = local_dt.utcoffset().total_seconds()
period_start = start_time
prev_offset = _utc_offset(tz, current)
# Check each day for offset changes
while current <= end_time:
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
local_dt = dt.astimezone(tz)
current_offset = local_dt.utcoffset().total_seconds()
# Probe at most a day ahead, capped at end_time so a transition after the
# last full day is still seen instead of silently kept in the last period.
while current < end_time:
next_probe = min(current + 86400, end_time)
next_offset = _utc_offset(tz, next_probe)
if current_offset != prev_offset:
# Found a transition - close previous period
periods.append((period_start, current, prev_offset))
period_start = current
prev_offset = current_offset
if next_offset != prev_offset:
transition = _find_transition(tz, current, next_probe, prev_offset)
periods.append((period_start, transition, prev_offset))
period_start = transition
prev_offset = _utc_offset(tz, transition)
current = transition
else:
current = next_probe
current += 86400 # Check daily
# Add final period
periods.append((period_start, end_time, prev_offset))
return periods
-74
View File
@@ -1,74 +0,0 @@
module.exports = {
root: true,
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"plugin:vitest-globals/recommended",
"plugin:prettier/recommended",
],
env: { browser: true, es2021: true, "vitest-globals/env": true },
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: "latest",
sourceType: "module",
},
settings: {
jest: {
version: 27,
},
},
ignorePatterns: ["*.d.ts", "/src/components/ui/*"],
plugins: ["react-hooks", "react-refresh"],
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"comma-dangle": [
"error",
{
objects: "always-multiline",
arrays: "always-multiline",
imports: "always-multiline",
},
],
"no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"no-console": "error",
"prettier/prettier": [
"warn",
{
plugins: ["prettier-plugin-tailwindcss"],
},
],
},
overrides: [
{
files: ["**/*.{ts,tsx}"],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
],
},
],
};
@@ -0,0 +1,450 @@
/**
* WebRTC streaming-technology availability gating.
*
* The connectivity probe needs a live go2rtc, so this covers the
* statically-determinable gate: with no webrtc candidates/ice_servers
* configured, the WebRTC option must be disabled in the stream-technology
* selector (label "Streaming Technology").
*/
import type { Page } from "@playwright/test";
import { test, expect } from "../fixtures/frigate-test";
import { LivePage } from "../pages/live.page";
// the mocked profile is admin, so useUserPersistence keys are namespaced
const STREAMING_KEY = "streaming-settings:admin";
async function writeIdb(page: Page, entries: Record<string, unknown>) {
await page.evaluate(async (data) => {
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open("keyval-store", 1);
request.onupgradeneeded = () =>
request.result.createObjectStore("keyval");
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const tx = request.result.transaction("keyval", "readwrite");
const store = tx.objectStore("keyval");
Object.entries(data).forEach(([key, value]) => store.put(value, key));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
};
});
}, entries);
}
async function readIdb(page: Page, key: string) {
return page.evaluate(async (target) => {
return new Promise((resolve, reject) => {
const request = indexedDB.open("keyval-store", 1);
request.onupgradeneeded = () =>
request.result.createObjectStore("keyval");
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const tx = request.result.transaction("keyval", "readonly");
const get = tx.objectStore("keyval").get(target);
get.onsuccess = () => resolve(get.result ?? null);
get.onerror = () => reject(get.error);
};
});
}, key);
}
test.describe("WebRTC availability gating @critical", () => {
test("desktop: WebRTC option is disabled when no candidates or ice_servers", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop dropdown only");
await frigateApp.installDefaults({
config: {
go2rtc: {
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
webrtc: { candidates: [], ice_servers: [] },
},
},
});
// The single-camera view fetches go2rtc stream metadata once restreamed.
// The default mock returns {} which lacks `producers` and crashes the
// capability parser, so return a minimal valid metadata payload.
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
route.fulfill({
json: {
producers: [{ medias: ["video, recvonly, H264"] }],
consumers: [],
},
}),
);
await frigateApp.goto("/#front_door");
const live = new LivePage(frigateApp.page, true);
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
// Open the desktop camera-settings dropdown (the FaCog gear is the last
// button-like trigger in the single-camera header).
const gearButtons = frigateApp.page.locator("button:has(svg)");
await gearButtons.last().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
// Open the "Streaming Technology" select. Anchor on its label, then click
// the combobox trigger that follows it within the same field container.
const technologyTrigger = menu
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
.first();
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
await technologyTrigger.click();
// The Radix select content is portaled to the document body; the WebRTC
// option must be present and disabled (aria-disabled="true").
const webrtcOption = frigateApp.page.getByRole("option", {
name: /WebRTC/,
});
await expect(webrtcOption).toBeVisible({ timeout: 3_000 });
await expect(webrtcOption).toHaveAttribute("aria-disabled", "true");
// Sanity check: a non-gated option (MSE) is enabled, proving the locator
// distinguishes enabled from disabled options.
const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ });
await expect(mseOption).not.toHaveAttribute("aria-disabled", "true");
});
test("the unavailable reason is logged to the console once", async ({
frigateApp,
}) => {
// Availability is consumed by several components at once, so the emitter
// dedupes; the count asserts that dedupe, not just the message.
const warnings: string[] = [];
frigateApp.page.on("console", (msg) => {
if (msg.type() === "warning" && msg.text().includes("WebRTC unavailable"))
warnings.push(msg.text());
});
await frigateApp.installDefaults({
config: {
go2rtc: {
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
webrtc: { candidates: [], ice_servers: [] },
},
},
});
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
route.fulfill({
json: {
producers: [{ medias: ["video, recvonly, H264"] }],
consumers: [],
},
}),
);
await frigateApp.goto("/#front_door");
await expect
.poll(() => warnings.length, { timeout: 10_000 })
.toBeGreaterThan(0);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("WebRTC unavailable 'not-configured'");
expect(warnings[0]).toContain("go2rtc.webrtc");
expect(warnings[0]).toContain("docs.frigate.video");
});
test("desktop: the WebRTC option reports the pending connectivity check", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop dropdown only");
// Hold the signaling socket open so the probe stays pending. Left alone it
// fails fast against the preview server and resolves to unreachable, which
// is the state the first test already covers.
await frigateApp.page.routeWebSocket("**/live/webrtc/api/ws**", () => {
// never answer the offer
});
// Candidates configured, so the gate reaches the probe rather than
// stopping at not-configured.
await frigateApp.installDefaults({
config: {
go2rtc: {
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
webrtc: { candidates: ["192.168.1.10:8555"], ice_servers: [] },
},
},
});
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
route.fulfill({
json: {
producers: [{ medias: ["video, recvonly, H264"] }],
consumers: [],
},
}),
);
await frigateApp.goto("/#front_door");
const live = new LivePage(frigateApp.page, true);
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
const gearButtons = frigateApp.page.locator("button:has(svg)");
await gearButtons.last().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
const technologyTrigger = menu
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
.first();
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
await technologyTrigger.click();
// Unselectable while the probe runs, but with the reason stated rather
// than a bare greyed-out row.
const webrtcOption = frigateApp.page.getByRole("option", {
name: /WebRTC/,
});
await expect(webrtcOption).toBeVisible({ timeout: 3_000 });
await expect(webrtcOption).toHaveAttribute("aria-disabled", "true");
await expect(webrtcOption).toContainText(/Checking WebRTC availability/i);
});
test("desktop: JSMpeg is no longer offered in the technology selector", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop dropdown only");
await frigateApp.installDefaults({
config: {
go2rtc: {
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
webrtc: { candidates: [], ice_servers: [] },
},
},
});
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
route.fulfill({
json: {
producers: [{ medias: ["video, recvonly, H264"] }],
consumers: [],
},
}),
);
await frigateApp.goto("/#front_door");
const live = new LivePage(frigateApp.page, true);
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
const gearButtons = frigateApp.page.locator("button:has(svg)");
await gearButtons.last().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
// Technology selector offers MSE/WebRTC but NOT JSMpeg (replaced by the
// "Force low-bandwidth mode" switch).
const technologyTrigger = menu
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
.first();
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
await technologyTrigger.click();
await expect(
frigateApp.page.getByRole("option", { name: /MSE/ }),
).toBeVisible({ timeout: 3_000 });
await expect(
frigateApp.page.getByRole("option", { name: /JSMpeg/ }),
).toHaveCount(0);
});
test("desktop: force low-bandwidth switch disables the technology and stream selectors", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop dropdown only");
await frigateApp.installDefaults({
config: {
go2rtc: {
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
webrtc: { candidates: [], ice_servers: [] },
},
},
});
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
route.fulfill({
json: {
producers: [{ medias: ["video, recvonly, H264"] }],
consumers: [],
},
}),
);
await frigateApp.goto("/#front_door");
const live = new LivePage(frigateApp.page, true);
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
const gearButtons = frigateApp.page.locator("button:has(svg)");
await gearButtons.last().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
// Both selectors are present and enabled to begin with (the switch is off).
await expect(menu.locator('label[for="streaming-mode"]')).toHaveCount(1);
const technologyTrigger = menu
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
.first();
const streamTrigger = menu
.locator('div:has(> label[for="streaming-method"]) [role="combobox"]')
.first();
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
await expect(technologyTrigger).toBeEnabled();
await expect(streamTrigger).toBeVisible({ timeout: 3_000 });
await expect(streamTrigger).toBeEnabled();
// Enabling the switch keeps both selectors visible but disables them (the
// low-bandwidth feed ignores the chosen stream and technology).
const lowBandwidthSwitch = menu.getByRole("switch", {
name: "Force low-bandwidth mode",
});
await expect(lowBandwidthSwitch).toBeVisible({ timeout: 3_000 });
await lowBandwidthSwitch.click();
await expect(menu.locator('label[for="streaming-mode"]')).toHaveCount(1);
await expect(technologyTrigger).toBeDisabled();
await expect(streamTrigger).toBeDisabled();
// Toggling it back off re-enables both.
await lowBandwidthSwitch.click();
await expect(technologyTrigger).toBeEnabled();
await expect(streamTrigger).toBeEnabled();
});
test("desktop: saving group streaming settings keeps an unavailable WebRTC choice", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop context menu only");
await frigateApp.installDefaults({
config: {
go2rtc: {
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
webrtc: { candidates: [], ice_servers: [] },
},
},
});
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
route.fulfill({
json: {
producers: [{ medias: ["video, recvonly, H264"] }],
consumers: [],
},
}),
);
const saved = {
streamName: "front_door",
streamType: "smart",
playerMode: "webrtc",
compatibilityMode: false,
playAudio: false,
volume: 1,
};
await frigateApp.goto("/");
await writeIdb(frigateApp.page, {
[STREAMING_KEY]: { outdoor: { front_door: saved } },
});
await frigateApp.goto("/?group=outdoor");
// With no candidates the dialog resolves WebRTC to MSE for display, but
// saving must not persist that fallback over the user's choice.
const live = new LivePage(frigateApp.page, true);
const menu = await live.openContextMenuOn("front_door");
await expect(menu).toBeVisible({ timeout: 5_000 });
await menu.getByText("Streaming Settings").click();
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Save" }).click();
await expect(dialog).toBeHidden();
await expect
.poll(() => readIdb(frigateApp.page, STREAMING_KEY))
.toMatchObject({ outdoor: { front_door: { playerMode: "webrtc" } } });
});
});
test.describe("WebRTC availability gating @critical @mobile", () => {
test("mobile: WebRTC option is disabled when no candidates or ice_servers", async ({
frigateApp,
}) => {
test.skip(!frigateApp.isMobile, "Mobile drawer only");
await frigateApp.installDefaults({
config: {
go2rtc: {
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
webrtc: { candidates: [], ice_servers: [] },
},
},
});
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
route.fulfill({
json: {
producers: [{ medias: ["video, recvonly, H264"] }],
consumers: [],
},
}),
);
await frigateApp.goto("/#front_door");
// Open the mobile camera-settings drawer. The camera controls render a
// second dialog-popup trigger (the FaCog settings toggle) after the global
// header menu; wait for both to exist, then click the last one.
const dialogTriggers = frigateApp.page.locator(
'button[aria-haspopup="dialog"]',
);
await expect(dialogTriggers).toHaveCount(2, { timeout: 10_000 });
const settingsTrigger = dialogTriggers.last();
await settingsTrigger.scrollIntoViewIfNeeded();
await settingsTrigger.click();
// The drawer renders a "Streaming Technology" label above its select.
// Anchor on that label, then open the combobox in the same field block.
await expect(
frigateApp.page.getByText("Streaming Technology", { exact: true }),
).toBeVisible({ timeout: 3_000 });
const technologyTrigger = frigateApp.page
.locator(
'div:has(> div:text-is("Streaming Technology")) [role="combobox"]',
)
.first();
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
await technologyTrigger.click();
const webrtcOption = frigateApp.page.getByRole("option", {
name: /WebRTC/,
});
await expect(webrtcOption).toBeVisible({ timeout: 3_000 });
await expect(webrtcOption).toHaveAttribute("aria-disabled", "true");
const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ });
await expect(mseOption).not.toHaveAttribute("aria-disabled", "true");
});
});
@@ -49,9 +49,43 @@ const PLUS_MODEL = {
height: 320,
};
// Frigate+ builds a Hailo model per device and names the detector by its
// pre-rename key; /api/plus/models adds the current one before serving it
const HAILO_PLUS_MODELS = [
{
...PLUS_MODEL,
id: "hailo8l1",
supportedDetectors: ["hailo8l", "hailo"],
hailoDevice: "hailo8l",
},
{
...PLUS_MODEL,
id: "hailo8r1",
supportedDetectors: ["hailo8l", "hailo"],
hailoDevice: "hailo8r",
},
];
const HAILO_HARDWARE = [
{
key: "hailo",
detector: "hailo",
name: "Hailo",
units: [{ device: "hailo:PCIe", label: "hailo0" }],
count: 1,
unlimited: true,
},
];
type SavedConfig = { config_data?: { models?: Model[] } };
async function installRoutes(page: Page, models: Model[], plusEnabled = false) {
async function installRoutes(
page: Page,
models: Model[],
plusEnabled = false,
plusModels: unknown[] = [PLUS_MODEL],
hailoHardware = false,
) {
const config = configFactory({
models,
plus: { enabled: plusEnabled },
@@ -70,8 +104,14 @@ async function installRoutes(page: Page, models: Model[], plusEnabled = false) {
route.fulfill({ json: { models } }),
);
await page.route("**/api/plus/models", (route) =>
route.fulfill({ json: [PLUS_MODEL] }),
route.fulfill({ json: plusModels }),
);
if (hailoHardware) {
await page.route("**/api/hardware/probe**", (route) =>
route.fulfill({ json: HAILO_HARDWARE }),
);
}
await page.route("**/api/config/set", async (route) => {
saves.push(route.request().postDataJSON() as SavedConfig);
await route.fulfill({ json: { success: true, require_restart: false } });
@@ -262,6 +302,35 @@ test.describe("Detection models settings @high", () => {
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
});
test("a Frigate+ Hailo model is listed by the device it was built for", async ({
frigateApp,
}) => {
// every Hailo model supports the one hailo detector, so the detector name
// says nothing; which device it was built for is what the user picks on
await installRoutes(
frigateApp.page,
[{ scene: "all", devices: ["hailo:PCIe"], path: "/config/custom.hef" }],
true,
HAILO_PLUS_MODELS,
true,
);
await openPage(frigateApp);
await frigateApp.page.getByRole("tab", { name: "Frigate+" }).click();
await frigateApp.page.getByRole("combobox").last().click();
const options = frigateApp.page.getByRole("option");
await expect(options).toHaveCount(2);
await expect(options.first()).toContainText("hailo8l");
await expect(options.last()).toContainText("hailo8r");
// knowing which device is attached is left to the user, so neither is
// ruled out here
await expect(options.first()).not.toHaveAttribute("aria-disabled", "true");
await expect(options.last()).not.toHaveAttribute("aria-disabled", "true");
});
test("a freshly opened page is not reported as modified", async ({
frigateApp,
}) => {
@@ -0,0 +1,141 @@
/**
* Object filters settings tests -- MEDIUM tier.
*
* `objects.filters` is an additionalProperties map, so each label's filter is
* an entry RJSF adds at runtime. RJSF has changed how a cleared field nested
* inside such an entry is stored (`""` before 6.9, omitted after). These tests
* pin what Frigate does with it: the save payload deletes only the cleared
* key, and restoring the value leaves the section clean.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const SETTINGS_URL = "/settings?page=cameraObjects&camera=front_door";
const UNSAVED = "You have unsaved changes";
// A non-default value can only come from the YAML, so deleting it is safe
const MIN_AREA = 5000;
async function installRoutes(page: Page) {
const config = configFactory({
cameras: {
front_door: {
objects: {
filters: {
person: {
min_area: MIN_AREA,
max_area: 24000000,
min_ratio: 0,
max_ratio: 24000000,
threshold: 0.7,
min_score: 0.5,
},
},
},
},
},
});
let lastSavedConfig: unknown = null;
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: {} }),
);
await page.route("**/api/config/set", async (route) => {
lastSavedConfig = route.request().postDataJSON();
await route.fulfill({ json: { success: true, require_restart: false } });
});
return { capturedConfig: () => lastSavedConfig };
}
async function openPersonMinArea(page: Page) {
await page.getByText("Object filters", { exact: true }).click();
await page.locator('[aria-expanded="false"]', { hasText: /^Person/ }).click();
const minArea = page.getByRole("textbox", { name: "Minimum object area" });
await expect(minArea).toHaveValue(String(MIN_AREA));
return minArea;
}
test.describe("object filters additionalProperties entries @medium", () => {
test("clearing a nested filter field deletes only that key on save", async ({
frigateApp,
}) => {
const capture = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
const minArea = await openPersonMinArea(frigateApp.page);
await minArea.fill("");
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
await frigateApp.page
.getByRole("button", { name: "Save", exact: true })
.click();
// Empty string is the backend's remove sentinel. Sibling filter fields
// must not appear, or the save would rewrite values the user didn't touch.
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
cameras: {
front_door: { objects: { filters: { person: { min_area: "" } } } },
},
},
});
const saved = capture.capturedConfig() as {
config_data: {
cameras: {
front_door: { objects: { filters: { person: object } } };
};
};
};
expect(saved.config_data.cameras.front_door.objects.filters.person).toEqual(
{ min_area: "" },
);
});
test("restoring a cleared nested filter field leaves the section clean", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
const minArea = await openPersonMinArea(frigateApp.page);
const save = frigateApp.page.getByRole("button", {
name: "Save",
exact: true,
});
await minArea.fill("");
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
await expect(save).toBeEnabled();
await minArea.fill(String(MIN_AREA));
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
await expect(save).toBeDisabled();
});
});
@@ -0,0 +1,117 @@
/**
* Runtime override tests -- MEDIUM tier.
*
* Live view, MQTT, and Home Assistant toggles change a camera's running config
* without touching yaml, and the change persists across restarts. The settings
* form edits yaml, so it keeps showing the saved value. Without a marker on the
* field and runtime-aware wording on dependent warnings, the two read as a
* contradiction: "audio detection is not enabled" next to a switch that is on.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const CAMERA = "front_door";
const TRANSCRIPTION_URL = `/settings?page=cameraAudioTranscription&camera=${CAMERA}`;
const AUDIO_URL = `/settings?page=cameraAudioEvents&camera=${CAMERA}`;
const CONFIG_DISABLED = /Audio detection is not enabled for this camera/;
const RUNTIME_DISABLED =
/Audio detection is enabled in your config, but it is currently turned off/;
type AudioState = { enabled: boolean; enabled_in_config: boolean };
async function installRoutes(page: Page, audio: AudioState) {
const config = configFactory({
cameras: {
[CAMERA]: {
audio,
// audio detection only runs on a stream carrying the audio role
ffmpeg: {
inputs: [
{
path: "rtsp://user:pass@host/front",
roles: ["record", "detect", "audio"],
},
],
},
audio_transcription: { enabled: true, enabled_in_config: true },
},
},
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: { cameras: { [CAMERA]: { ffmpeg: { inputs: [] } } } },
}),
);
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
}
test.describe("runtime overrides @medium", () => {
test("a runtime-only toggle gets its own wording and a field marker", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, {
enabled: false,
enabled_in_config: true,
});
await frigateApp.goto(TRANSCRIPTION_URL);
await expect(frigateApp.page.getByText(RUNTIME_DISABLED)).toBeVisible();
await expect(frigateApp.page.getByText(CONFIG_DISABLED)).toBeHidden();
// The audio section still shows the saved value, so the switch stays on and
// the marker carries the live state.
await frigateApp.goto(AUDIO_URL);
await expect(
frigateApp.page.getByRole("switch", { name: "Enable audio detection" }),
).toHaveAttribute("data-state", "checked");
// the boolean layout renders a mobile and a desktop label block, so only
// one of the two badges is on screen
await expect(
frigateApp.page.getByText("Overridden (Live)").filter({ visible: true }),
).toHaveCount(1);
});
test("a config-disabled section keeps the original wording", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, {
enabled: false,
enabled_in_config: false,
});
await frigateApp.goto(TRANSCRIPTION_URL);
await expect(frigateApp.page.getByText(CONFIG_DISABLED)).toBeVisible();
await expect(frigateApp.page.getByText(RUNTIME_DISABLED)).toBeHidden();
await frigateApp.goto(AUDIO_URL);
await expect(
frigateApp.page.getByRole("switch", { name: "Enable audio detection" }),
).toHaveAttribute("data-state", "unchecked");
await expect(
frigateApp.page.getByText("Overridden (Live)").filter({ visible: true }),
).toHaveCount(0);
});
});
@@ -18,15 +18,20 @@ const OUTDOOR_LAYOUT = [
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
];
// a camera as an export from before streaming technology selection carries
// it, so tests can assert both what a pre-feature file writes and what the
// technology adds on top
const FRONT_DOOR_WITHOUT_TECHNOLOGY = {
streamName: "front_door",
streamType: "smart",
compatibilityMode: false,
playAudio: false,
volume: 1,
};
const STREAMING_SETTINGS = {
outdoor: {
front_door: {
streamName: "front_door",
streamType: "smart",
compatibilityMode: false,
playAudio: false,
volume: 1,
},
front_door: { ...FRONT_DOOR_WITHOUT_TECHNOLOGY, playerMode: "webrtc" },
},
};
@@ -340,6 +345,7 @@ test.describe("UI settings import/export @medium", () => {
garage: {
streamName: "garage",
streamType: "continuous",
playerMode: "jsmpeg",
compatibilityMode: true,
playAudio: true,
volume: 0.5,
@@ -375,6 +381,77 @@ test.describe("UI settings import/export @medium", () => {
},
});
});
test("drops only the streaming technology when its value is unrecognized", async ({
frigateApp,
}) => {
// a hand-edited file, or an export from a future Frigate that added a
// technology this build does not know: the camera's other settings still
// import rather than the whole file failing validation
await frigateApp.goto("/settings?page=uiSettings");
await chooseImportFile(
frigateApp.page,
importPayload({
sections: {
layouts: {},
streaming: {
outdoor: {
front_door: {
...FRONT_DOOR_WITHOUT_TECHNOLOGY,
playerMode: "quantum",
},
},
},
preferences: {},
},
}),
);
await expect(
frigateApp.page.getByText("Streaming settings (1 camera)"),
).toBeVisible();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({
outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY },
});
});
test("clears a stored streaming technology when the file predates it", async ({
frigateApp,
}) => {
// import replaces whole camera objects, as it already does for streamName
// and volume, so a pre-feature export resets the technology rather than
// leaving the local choice in place
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, { [STREAMING_KEY]: STREAMING_SETTINGS });
await chooseImportFile(
frigateApp.page,
importPayload({
sections: {
layouts: {},
streaming: {
outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY },
},
preferences: {},
},
}),
);
await expect(
frigateApp.page.getByText("Streaming settings (1 camera)"),
).toBeVisible();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({
outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY },
});
});
test("rejects a file that is not valid JSON", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=uiSettings");
+251
View File
@@ -0,0 +1,251 @@
/**
* Zone rename and delete tests -- MEDIUM tier.
*
* A zone's name also lives in required_zones lists and profile overrides.
* These tests pin that renaming or deleting a zone sends one config/set JSON
* body that moves or drops every reference, with nothing in the query string,
* and flags a restart. Editing the name alone must not rename the zone.
*/
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const SETTINGS_URL = "/settings?page=masksAndZones&camera=front_door";
const COORDINATES = "0.1,0.1,0.5,0.1,0.5,0.5,0.1,0.5";
// /api/config returns zone filters with defaults filled in
const FILTERS = {
person: {
min_area: 5000,
max_area: 24000000,
min_ratio: 0,
max_ratio: 24000000,
threshold: 0.7,
min_score: 0.5,
mask: {},
},
};
type ConfigSetRequest = { url: string; body: Record<string, unknown> };
async function installRoutes(page: Page) {
const config = configFactory({
profiles: { armed: { friendly_name: "Armed" } },
cameras: {
front_door: {
zones: {
driveway: {
coordinates: COORDINATES,
enabled: true,
inertia: 3,
loitering_time: 0,
objects: [],
filters: FILTERS,
color: [128, 128, 0],
},
},
review: { alerts: { required_zones: ["driveway"] } },
snapshots: { required_zones: ["driveway"] },
mqtt: { required_zones: ["driveway"] },
profiles: {
armed: {
zones: { driveway: { coordinates: COORDINATES, inertia: 6 } },
review: { alerts: { required_zones: ["driveway"] } },
},
},
},
},
});
const requests: ConfigSetRequest[] = [];
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
await page.route("**/api/config/set**", async (route) => {
requests.push({
url: route.request().url(),
body: route.request().postDataJSON(),
});
await route.fulfill({ json: { success: true } });
});
return requests;
}
async function openZoneAction(
page: Page,
isMobile: boolean,
action: "Edit" | "Delete",
) {
const row = page.locator("[data-index]", { hasText: "Driveway" });
if (isMobile) {
await row.locator("button[aria-haspopup='menu']").click();
await page.getByRole("menuitem", { name: action }).click();
return;
}
// Desktop shows the actions on hover
await row.hover();
await row.getByLabel(action, { exact: true }).click();
}
test.describe("zone rename and delete @medium @mobile", () => {
test("editing the name of a referenced zone keeps its id", async ({
frigateApp,
}) => {
const requests = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
await frigateApp.page
.getByRole("textbox", { name: "Name", exact: true })
.fill("Main Driveway");
await expect(
frigateApp.page.getByRole("textbox", { name: "ID", exact: true }),
).toHaveValue("driveway");
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => requests.length).toBe(1);
expect(new URL(requests[0].url).search).toBe("");
expect(requests[0].body).toEqual({
requires_restart: 0,
update_topic: "config/cameras/front_door/zones",
config_data: {
cameras: {
front_door: {
zones: {
driveway: {
coordinates: COORDINATES,
enabled: true,
inertia: 3,
loitering_time: 0,
friendly_name: "Main Driveway",
},
},
},
},
},
});
});
test("turning speed estimation on and off sends no distances delete", async ({
frigateApp,
}) => {
// The zone has no distances in the YAML, and config/set fails to delete
// a missing key
const requests = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
const speedEstimation = frigateApp.page
.getByText("Speed Estimation", { exact: true })
.locator("..")
.getByRole("switch");
await speedEstimation.click();
for (const line of ["A", "B", "C", "D"]) {
await frigateApp.page
.getByRole("textbox", { name: new RegExp(`^Line ${line} distance`) })
.fill("5");
}
await speedEstimation.click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => requests.length).toBe(1);
expect(requests[0].body).toMatchObject({
config_data: { cameras: { front_door: { zones: { driveway: {} } } } },
});
const body = requests[0].body as {
config_data: {
cameras: { front_door: { zones: { driveway: object } } };
};
};
expect(
body.config_data.cameras.front_door.zones.driveway,
).not.toHaveProperty("distances");
});
test("renaming a referenced zone moves every reference in one request", async ({
frigateApp,
}) => {
const requests = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
await frigateApp.page
.getByRole("textbox", { name: "ID", exact: true })
.fill("front_drive");
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => requests.length).toBe(1);
expect(new URL(requests[0].url).search).toBe("");
expect(requests[0].body).toEqual({
requires_restart: 1,
update_topic: "config/cameras/front_door/zones",
config_data: {
cameras: {
front_door: {
zones: {
driveway: null,
front_drive: {
coordinates: COORDINATES,
enabled: true,
filters: FILTERS,
inertia: 3,
loitering_time: 0,
},
},
review: { alerts: { required_zones: ["front_drive"] } },
snapshots: { required_zones: ["front_drive"] },
mqtt: { required_zones: ["front_drive"] },
profiles: {
armed: {
review: { alerts: { required_zones: ["front_drive"] } },
zones: {
driveway: null,
front_drive: { coordinates: COORDINATES, inertia: 6 },
},
},
},
},
},
},
});
});
test("deleting a referenced zone drops every reference in one request", async ({
frigateApp,
}) => {
const requests = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Delete");
await frigateApp.page
.getByRole("alertdialog")
.getByRole("button", { name: "Delete" })
.click();
await expect.poll(() => requests.length).toBe(1);
expect(new URL(requests[0].url).search).toBe("");
expect(requests[0].body).toEqual({
requires_restart: 1,
update_topic: "config/cameras/front_door/zones",
config_data: {
cameras: {
front_door: {
zones: { driveway: null },
review: { alerts: { required_zones: [] } },
snapshots: { required_zones: [] },
mqtt: { required_zones: [] },
profiles: {
armed: {
review: { alerts: { required_zones: [] } },
zones: { driveway: null },
},
},
},
},
},
});
});
});
+2 -2
View File
@@ -422,7 +422,7 @@ test.describe("System — Health hardware pane @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["hailo8l"] }] },
config: { models: [{ scene: "all", devices: ["hailo"] }] },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
@@ -431,7 +431,7 @@ test.describe("System — Health hardware pane @medium", () => {
await expect(row).toHaveAttribute("data-state", "error", {
timeout: 15_000,
});
await expect(row).toContainText("hailo8l was not found on this system");
await expect(row).toContainText("hailo was not found on this system");
});
test("a generic device the probe cannot enumerate is judged by its runtime", async ({
+44
View File
@@ -0,0 +1,44 @@
import path from "node:path";
import js from "@eslint/js";
import { defineConfig, globalIgnores, includeIgnoreFile } from "eslint/config";
import prettierRecommended from "eslint-plugin-prettier/recommended";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default defineConfig([
includeIgnoreFile(path.join(import.meta.dirname, ".gitignore")),
globalIgnores(["src/components/ui/", "**/*.d.ts", "**/*.cjs", "**/*.mjs"]),
js.configs.recommended,
tseslint.configs.recommended,
prettierRecommended,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"no-console": "error",
"prettier/prettier": "warn",
},
},
]);
+2455 -6189
View File
File diff suppressed because it is too large Load Diff
+74 -88
View File
@@ -7,13 +7,11 @@
"dev": "vite --host",
"postinstall": "patch-package",
"build": "tsc && vite build --base=/BASE_PATH/",
"lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore . && npm run e2e:lint",
"lint": "eslint . && npm run e2e:lint",
"e2e:lint": "node e2e/scripts/lint-specs.mjs",
"lint:fix": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --fix .",
"lint:fix": "eslint --fix .",
"preview": "vite preview",
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
"test": "vitest",
"coverage": "vitest run --coverage",
"e2e:build": "tsc && vite build --base=/",
"e2e": "playwright test --config e2e/playwright.config.ts",
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
@@ -24,120 +22,108 @@
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^3.10.0",
"@melloware/react-logviewer": "^6.1.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.2.3",
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-hover-card": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.2.8",
"@rjsf/core": "^6.4.1",
"@rjsf/shadcn": "^6.5.2",
"@rjsf/utils": "^6.4.1",
"@rjsf/validator-ajv8": "^6.4.1",
"apexcharts": "^3.52.0",
"axios": "^1.18.0",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@rjsf/core": "^6.10.0",
"@rjsf/shadcn": "^6.10.0",
"@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"apexcharts": "^7.3.0",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"copy-to-clipboard": "^4.0.2",
"date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0",
"framer-motion": "^12.38.0",
"hls.js": "^1.6.15",
"i18next": "^24.2.0",
"framer-motion": "^13.3.0",
"hls.js": "^1.7.3",
"i18next": "^26.4.2",
"i18next-http-backend": "^4.0.2",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
"js-yaml": "^4.3.2",
"konva": "^10.2.3",
"idb-keyval": "^6.3.0",
"js-yaml": "^5.4.2",
"konva": "^10.5.0",
"lodash": "^4.18.1",
"lucide-react": "^0.577.0",
"monaco-yaml": "^5.4.1",
"lucide-react": "^1.46.0",
"monaco-yaml": "^5.5.1",
"next-themes": "^0.4.6",
"nosleep.js": "^0.12.0",
"react": "^19.2.4",
"react-apexcharts": "^1.4.1",
"react": "^19.3.0",
"react-apexcharts": "^2.1.1",
"react-day-picker": "^9.14.0",
"react-device-detect": "^2.2.3",
"react-dom": "^19.2.4",
"react-dropzone": "^14.3.8",
"react-grid-layout": "^2.2.2",
"react-hook-form": "^7.72.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.6.0",
"react-konva": "^19.2.3",
"react-markdown": "^9.0.1",
"react-dom": "^19.3.0",
"react-dropzone": "^20.1.2",
"react-grid-layout": "^2.2.4",
"react-hook-form": "^7.88.0",
"react-i18next": "^17.0.14",
"react-icons": "^5.7.0",
"react-konva": "^19.2.7",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.30.6",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"sonner": "^2.0.7",
"sort-by": "^1.2.0",
"strftime": "^0.10.3",
"swr": "^2.4.1",
"sonner": "^2.0.8",
"swr": "^2.5.1",
"tailwind-merge": "^2.4.0",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.2.0",
"use-long-press": "^3.3.0",
"vaul": "^1.1.2",
"virtua": "^0.51.3",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.23.8"
"zod": "^3.25.76"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/forms": "^0.5.9",
"@testing-library/jest-dom": "^6.6.2",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.63.0",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.12",
"@types/node": "^20.14.10",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react-swc": "^3.8.0",
"@vitest/coverage-v8": "^3.0.7",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.2.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.8",
"eslint-plugin-vitest-globals": "^1.6.1",
"fake-indexeddb": "^6.0.0",
"@types/lodash": "^4.17.25",
"@types/node": "^26.5.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"autoprefixer": "^10.6.0",
"esbuild": "^0.28.2",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.12.0",
"i18next-cli": "^1.5.11",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^24.1.1",
"monaco-editor": "^0.52.2",
"msw": "^2.3.5",
"patch-package": "^8.0.1",
"postcss": "^8.5.12",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^3.4.9",
"typescript": "^5.9.3",
"vite": "^6.4.2",
"vitest": "^3.0.7"
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0"
},
"overrides": {
"@radix-ui/react-compose-refs": "1.1.2",
@@ -36,14 +36,6 @@
"title": "Bandwidth:",
"short": "Bandwidth"
},
"latency": {
"title": "Latency:",
"value": "{{seconds}} seconds",
"short": {
"title": "Latency",
"value": "{{seconds}} sec"
}
},
"totalFrames": "Total Frames:",
"droppedFrames": {
"title": "Dropped Frames:",
+34 -3
View File
@@ -6,7 +6,12 @@
"lowBandwidthMode": "Low-bandwidth Mode",
"twoWayTalk": {
"enable": "Enable Two Way Talk",
"disable": "Disable Two Way Talk"
"disable": "Disable Two Way Talk",
"requiresWebRTC": "Two-way talk requires WebRTC, which is unavailable",
"error": {
"microphone": "Two-way talk could not access your microphone",
"refused": "Two-way talk failed to start. See the browser console for details."
}
},
"cameraAudio": {
"enable": "Enable Camera Audio",
@@ -135,7 +140,8 @@
"unavailable": "Audio is not available for this stream"
},
"debug": {
"picker": "Stream selection unavailable in debug mode. Debug view always uses the stream assigned the detect role."
"picker": "Stream selection unavailable in debug mode. Debug view always uses the stream assigned the detect role.",
"technology": "Stream technology selection is unavailable in debug mode."
},
"twoWayTalk": {
"tips": "Your device must support the feature and WebRTC must be configured for two-way talk.",
@@ -144,11 +150,36 @@
},
"lowBandwidth": {
"tips": "Live view is in low-bandwidth mode due to buffering or stream errors.",
"resetStream": "Reset stream"
"resetStream": "Reset stream",
"force": {
"label": "Force low-bandwidth mode",
"desc": "Always play Frigate's built-in low-bandwidth feed instead of the selected stream. Works on any connection, but has lower quality and no audio."
}
},
"playInBackground": {
"label": "Play in background",
"tips": "Enable this option to continue streaming when the player is hidden."
},
"mode": "Streaming Technology",
"technology": {
"description": "Choose your preferred streaming technology. Frigate may still fall back to low bandwidth mode on playback or network errors.",
"name": {
"mse": "MSE",
"webrtc": "WebRTC",
"jsmpeg": "JSMpeg"
},
"tips": {
"mse": "Recommended default, broad compatibility and smooth playback",
"webrtc": "Needs extra setup and isn't supported on every device"
},
"unavailable": {
"browser": "Your browser does not support WebRTC.",
"not-configured": "WebRTC is not configured. Set go2rtc webrtc candidates or ice_servers.",
"unreachable": "WebRTC could not connect. Check that port 8555 is reachable and any STUN/TURN server is correct.",
"video-codec": "This stream's video codec is not supported by WebRTC in this browser.",
"audio-codec": "This stream's audio codec is not supported by WebRTC. Transcode to opus or G.711 with go2rtc.",
"checking": "Checking WebRTC availability…"
}
}
},
"cameraSettings": {
+15 -4
View File
@@ -23,6 +23,9 @@
"overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:",
"overriddenGlobalHeading_other": "This camera overrides {{count}} fields from the global config:",
"overriddenGlobalNoDeltas": "This camera overrides the global config, but no field values differ.",
"overriddenLive": "Overridden (Live)",
"overriddenLiveTooltip": "This camera is running with a different value than the one saved in your config, which is shown here. The live view, MQTT, or an active profile can change it while Frigate is running.",
"overriddenLiveValue": "Running value: {{value}}",
"overriddenBaseConfig": "Overridden (Base Config)",
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section",
"overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:",
@@ -1941,19 +1944,25 @@
"configMessages": {
"review": {
"recordDisabled": "Recording is disabled, review items will not be generated.",
"recordRuntimeDisabled": "Recording is enabled in your config, but it is currently turned off for this camera, so review items will not be generated. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
"detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.",
"detectRuntimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Review items require detected objects to categorize alerts and detections. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
"allNonAlertDetections": "All non-alert activity will be included as detections.",
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images."
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
},
"audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
},
"audioTranscription": {
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active."
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active.",
"audioDetectionRuntimeDisabled": "Audio detection is enabled in your config, but it is currently turned off for this camera, so audio transcription will not run. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
"genaiProviderSelected": "A GenAI provider is selected, so the device and model size settings are ignored."
},
"detect": {
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
"runtimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function until it is turned back on from the camera's live view, or until the active profile stops disabling it.",
"sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the model with a scene of 'All cameras'. Add a model for this scene to give the camera its own.",
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image.",
@@ -1988,10 +1997,12 @@
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function."
},
"birdseye": {
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye."
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
"objectTrackingDetectRuntimeDisabled": "Birdseye includes tracked objects, but object detection is currently turned off for this camera even though your config enables it. The camera will not appear in Birdseye until it is turned back on from the camera's live view, or until the active profile stops disabling it."
},
"snapshots": {
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created.",
"detectRuntimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera, so snapshots will not be created. Turn it back on from the camera's live view, or check whether an active profile is disabling it."
},
"semanticSearch": {
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
+2 -2
View File
@@ -52,7 +52,7 @@ self.addEventListener("push", function (event) {
}
})
.then(show)
: show(), // eslint-disable-line comma-dangle
: show(),
);
} else {
// pass
@@ -77,7 +77,7 @@ self.addEventListener("notificationclick", (event) => {
"X-CSRF-TOKEN": 1,
},
body: JSON.stringify({ ids: [event.notification.data.id] }),
}), // eslint-disable-line comma-dangle
}),
);
}
break;
+5 -3
View File
@@ -92,10 +92,12 @@ function PagePanel({ failure }: PanelProps) {
const stale = isStaleAsset(failure);
const onCopy = () => {
// copy() falls back to a prompt and returns false when the clipboard is
const onCopy = async () => {
// copy() falls back to a prompt and resolves false when the clipboard is
// refused, so a success toast has to wait on the result.
const copied = copy(crashReport(failure, stats?.service.version));
const copied = await copy(crashReport(failure, stats?.service.version), {
fallbackToPrompt: true,
});
if (copied) {
toast.success(t("button.copiedToClipboard"), { position: "top-center" });
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEmbeddingsReindexProgress } from "@/api/ws";
import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-provider";
} from "@/context/statusbar-context";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import StatusBarNotices from "@/components/health/StatusBarNotices";
import { cn } from "@/lib/utils";
+1 -1
View File
@@ -27,7 +27,7 @@ import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { Card, CardContent } from "@/components/ui/card";
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
type UserAuthFormProps = React.HTMLAttributes<HTMLDivElement>;
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const { t } = useTranslation(["components/auth", "common"]);
@@ -39,8 +39,6 @@ export default function AutoUpdatingCameraImage({
timeoutRef.current = null;
}
};
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadInterval]);
const handleLoad = useCallback(() => {
@@ -75,7 +73,7 @@ export default function AutoUpdatingCameraImage({
const [isCached, setIsCached] = useState(false);
const cacheKey = useMemo(() => {
let baseParam = "";
let baseParam: string;
if (periodicCache && !isCached) {
const date = new Date(key);
@@ -4,13 +4,15 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { CameraConfig } from "@/types/frigateConfig";
import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name";
interface CameraNameLabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
interface CameraNameLabelProps extends React.ComponentPropsWithoutRef<
typeof LabelPrimitive.Root
> {
camera?: string | CameraConfig;
}
interface ZoneNameLabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
interface ZoneNameLabelProps extends React.ComponentPropsWithoutRef<
typeof LabelPrimitive.Root
> {
zone: string;
camera?: string;
}
@@ -80,7 +80,7 @@ export default function CameraImage({
const ctx = canvasRef.current.getContext("2d");
ctx?.drawImage(img, 0, 0, scaledWidth, scaledHeight);
}
onload && onload(event);
onload?.(event);
},
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef],
);
+3 -3
View File
@@ -71,10 +71,10 @@ export function MessageBubble({
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
const handleCopy = useCallback(async () => {
const text = content?.trim() || "";
if (!text) return;
if (copy(text)) {
if (await copy(text)) {
setCopied(true);
toast.success(t("button.copiedToClipboard", { ns: "common" }));
setTimeout(() => setCopied(false), 2000);
@@ -133,7 +133,7 @@ export function MessageBubble({
variant="select"
size="icon"
className="size-9 rounded-full"
disabled={!draftContent.trim()}
disabled={!draftContent.trim() || onEditSubmit == null}
onClick={handleEditSubmit}
aria-label={t("send")}
>
@@ -163,8 +163,18 @@ export default function ClassificationModelEditDialog({
}
}, [isObjectModel, t]);
const form = useForm<ObjectFormData | StateFormData>({
resolver: zodResolver(formSchema),
const form = useForm<
ObjectFormData | StateFormData,
unknown,
ObjectFormData | StateFormData
>({
resolver: zodResolver(
formSchema as z.ZodType<
ObjectFormData | StateFormData,
z.ZodTypeDef,
ObjectFormData | StateFormData
>,
),
defaultValues: isObjectModel
? ({
enabled: model.enabled,
@@ -386,6 +396,7 @@ export default function ClassificationModelEditDialog({
"Unknown error";
throw new Error(
`Failed to rename ${oldName} to ${newName}: ${errorMessage}`,
{ cause: err },
);
}
},
@@ -127,7 +127,7 @@ export default function ClassificationModelWizardDialog({
await axios.delete(
`/classification/${wizardState.step1Data.modelName}`,
);
} catch (error) {
} catch {
// Silently fail - user is already cancelling
}
}
@@ -189,7 +189,7 @@ export default function Step3ChooseExamples({
},
);
}
} catch (error) {
} catch {
// Silently fail - unselected images will remain but won't cause issues
// since the frontend filters out images that don't match expected format
}
@@ -9,6 +9,11 @@ const audioTranscription: SectionConfigOverrides = {
health: (ctx) =>
ctx.fullCameraConfig?.audio_transcription?.enabled === true,
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
runtimeOverride: {
section: "audio",
messageKey:
"configMessages.audioTranscription.audioDetectionRuntimeDisabled",
},
severity: "warning",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
@@ -7,6 +7,11 @@ const birdseye: SectionConfigOverrides = {
{
key: "object-tracking-detect-disabled",
messageKey: "configMessages.birdseye.objectTrackingDetectDisabled",
runtimeOverride: {
section: "detect",
messageKey:
"configMessages.birdseye.objectTrackingDetectRuntimeDisabled",
},
severity: "info",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
@@ -63,6 +63,10 @@ const objects: SectionConfigOverrides = {
{
key: "detect-disabled",
messageKey: "configMessages.detect.disabled",
runtimeOverride: {
section: "detect",
messageKey: "configMessages.detect.runtimeDisabled",
},
severity: "info",
condition: (ctx) =>
ctx.level === "camera" &&
@@ -7,6 +7,10 @@ const review: SectionConfigOverrides = {
{
key: "record-disabled",
messageKey: "configMessages.review.recordDisabled",
runtimeOverride: {
section: "record",
messageKey: "configMessages.review.recordRuntimeDisabled",
},
severity: "warning",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
@@ -18,6 +22,10 @@ const review: SectionConfigOverrides = {
{
key: "detect-disabled",
messageKey: "configMessages.review.detectDisabled",
runtimeOverride: {
section: "detect",
messageKey: "configMessages.review.detectRuntimeDisabled",
},
severity: "info",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
@@ -64,12 +72,16 @@ const review: SectionConfigOverrides = {
field: "genai.image_source",
messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
runtimeOverride: {
section: "record",
messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordRuntimeDisabled",
},
severity: "warning",
position: "after",
condition: (ctx) => {
const genai = ctx.formData?.genai as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
if (genai?.image_source !== "recordings") return false;
if (ctx.level === "camera" && ctx.fullCameraConfig) {
return ctx.fullCameraConfig.record?.enabled === false;
@@ -7,6 +7,10 @@ const snapshots: SectionConfigOverrides = {
{
key: "detect-disabled",
messageKey: "configMessages.snapshots.detectDisabled",
runtimeOverride: {
section: "detect",
messageKey: "configMessages.snapshots.detectRuntimeDisabled",
},
severity: "info",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
@@ -28,6 +28,17 @@ export type ConditionalMessage = {
values?: Record<string, unknown>;
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
docLink?: string;
/**
* Alternate wording for when the section this message depends on is enabled
* in the config but turned off on the running camera. Without it the message
* reads as a contradiction, since the form shows the saved config value.
*/
runtimeOverride?: {
/** Camera section whose runtime state explains the message, e.g. "audio". */
section: string;
/** Translation key used in place of `messageKey`. */
messageKey: string;
};
/**
* Whether the Health tab evaluates this message against the saved config.
* Absent or false: form only. true: shown whenever condition() holds. A
@@ -17,11 +17,9 @@ export function validateDetectDimensions(
const height = data.height;
const widthErrors = errors.width as
| { addError?: (message: string) => void }
| undefined;
{ addError?: (message: string) => void } | undefined;
const heightErrors = errors.height as
| { addError?: (message: string) => void }
| undefined;
{ addError?: (message: string) => void } | undefined;
const message = t("detect.dimensionMustBeEven", { ns: "config/validation" });

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