Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 62081b2227 Update peewee-migrate requirement in /docker/main
Updates the requirements on [peewee-migrate](https://github.com/klen/peewee_migrate) to permit the latest version.
- [Changelog](https://github.com/klen/peewee_migrate/blob/develop/Changelog)
- [Commits](https://github.com/klen/peewee_migrate/compare/1.14.0...2.3.0)

---
updated-dependencies:
- dependency-name: peewee-migrate
  dependency-version: 2.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-15 11:33:18 +00:00
168 changed files with 5965 additions and 5043 deletions
+3
View File
@@ -46,6 +46,9 @@ 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
+4 -4
View File
@@ -5,7 +5,7 @@ aiohttp == 3.12.*
starlette == 0.47.*
starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
uvicorn == 0.52.*
uvicorn == 0.46.*
slowapi == 0.1.*
joserfc == 1.6.*
cryptography == 46.0.*
@@ -21,7 +21,7 @@ onvif-zeep-async == 4.0.*
paho-mqtt == 2.1.*
pandas == 2.2.*
peewee == 3.17.*
peewee_migrate == 1.14.*
peewee_migrate == 2.3.*
psutil == 7.1.*
pydantic == 2.10.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
@@ -61,7 +61,7 @@ rapidfuzz==3.12.*
argcomplete==2.0.*
contextlib2==0.6.*
future==0.18.*
netaddr==1.3.*
netaddr==0.8.*
netifaces==0.10.*
prometheus-client == 0.26.*
# TFLite
@@ -73,4 +73,4 @@ faster-whisper==1.2.*
librosa==0.11.*
soundfile==0.13.*
# Memory profiling
memray == 1.20.*
memray == 1.15.*
@@ -343,6 +343,13 @@ 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;
-31
View File
@@ -28,24 +28,6 @@ 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:
@@ -175,17 +157,6 @@ 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).
@@ -235,8 +206,6 @@ 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.
+3 -12
View File
@@ -204,20 +204,11 @@ Light guidelines and advice:
npm run lint
```
- 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.
- 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.
```console
# First-time setup
npx playwright install chromium
# Build the app and run all tests
npm run e2e:build && npm run e2e
npm run test
```
- Test in different browsers. Firefox, Chrome, and Safari all have different quirks that make them unique targets to interact with.
+1 -1
View File
@@ -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** 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.
- **The web UI's WebRTC player** includes a fallback to Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet.
## Home Assistant Supervisor
-7
View File
@@ -786,13 +786,6 @@ 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
+4 -14
View File
@@ -1038,10 +1038,6 @@ 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)
@@ -1052,16 +1048,6 @@ 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
@@ -1078,6 +1064,10 @@ 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),
+62 -83
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 = await asyncio.wrap_future(future)
result = future.result()
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,8 +886,9 @@ 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 isinstance(vod_response, JSONResponse)
and vod_response.status_code == 404
and type(vod_response) is tuple
and len(vod_response) == 2
and vod_response[1] == 404
):
Event.update(has_clip=False).where(Event.id == event_id).execute()
@@ -955,80 +956,64 @@ 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)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
event = None
if event is not None:
event_complete = True
await require_camera_access(event.camera, request=request)
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:
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,
camera_states: list[CameraState] = (
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:
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": "Unknown error occurred"},
content={"success": False, "message": "Ongoing event not found"},
status_code=404,
)
else:
# see if the object is currently being tracked
camera_states: list[CameraState] = (
request.app.detected_frames_processor.get_camera_states()
except Exception:
return JSONResponse(
content={"success": False, "message": "Unknown error occurred"},
status_code=404,
)
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"},
@@ -1077,25 +1062,19 @@ async def event_thumbnail(
if not thumbnail_bytes:
# see if the object is currently being tracked
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
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,
)
if not thumbnail_bytes:
return JSONResponse(
+5 -2
View File
@@ -412,8 +412,11 @@ async def no_recordings(
if not camera_list:
return JSONResponse(content=[])
before = params.before or datetime.now().timestamp()
after = params.after or (datetime.now() - timedelta(hours=1)).timestamp()
before = params.before or datetime.datetime.now().timestamp()
after = (
params.after
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
)
scale = params.scale
recordings: list[tuple[float, float]] = []
+2 -8
View File
@@ -66,12 +66,6 @@ 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:
@@ -85,7 +79,7 @@ def get_most_recent_preview_frame(
preview_files = [
f
for f in os.listdir(PREVIEW_CACHE_DIR)
if is_camera_preview_frame(f, camera)
if f.startswith(f"preview_{camera}-")
and f.endswith(f".{PREVIEW_FRAME_TYPE}")
]
@@ -282,7 +276,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 is_camera_preview_frame(file, self.camera_name):
if not file.startswith(file_start):
continue
if file < start_file:
+1 -2
View File
@@ -36,7 +36,6 @@ 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 (
@@ -1099,7 +1098,7 @@ class RecordingExporter(threading.Thread):
fallback_preview = None
for file in sorted(os.listdir(preview_dir)):
if not is_camera_preview_frame(file, self.camera):
if not file.startswith(file_start):
continue
if file < start_file:
@@ -1,81 +0,0 @@
"""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)
@@ -1,193 +0,0 @@
"""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 {})
-54
View File
@@ -1,54 +0,0 @@
"""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)
-15
View File
@@ -75,21 +75,6 @@ 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
@@ -1,55 +0,0 @@
"""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)
+3 -10
View File
@@ -35,19 +35,12 @@ 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":
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")
proc.terminate()
# otherwise, just try and exit frigate
os.kill(os.getpid(), signal.SIGINT)
else:
os.kill(os.getpid(), signal.SIGINT)
def print_stack(sig, frame):
+19 -40
View File
@@ -2,7 +2,6 @@
import datetime
import logging
import math
from zoneinfo import ZoneInfoNotFoundError
import pytz
@@ -44,33 +43,9 @@ 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, float]]:
) -> list[tuple[float, float]]:
"""
Find DST transition points and return time periods with consistent offsets.
@@ -91,24 +66,28 @@ 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)
# 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)
# 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()
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
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
current += 86400 # Check daily
# Add final period
periods.append((period_start, end_time, prev_offset))
return periods
@@ -1,450 +0,0 @@
/**
* 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");
});
});
@@ -1,117 +0,0 @@
/**
* 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,20 +18,15 @@ 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: { ...FRONT_DOOR_WITHOUT_TECHNOLOGY, playerMode: "webrtc" },
front_door: {
streamName: "front_door",
streamType: "smart",
compatibilityMode: false,
playAudio: false,
volume: 1,
},
},
};
@@ -345,7 +340,6 @@ test.describe("UI settings import/export @medium", () => {
garage: {
streamName: "garage",
streamType: "continuous",
playerMode: "jsmpeg",
compatibilityMode: true,
playAudio: true,
volume: 0.5,
@@ -381,77 +375,6 @@ 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
@@ -1,251 +0,0 @@
/**
* 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 },
},
},
},
},
},
});
});
});
+4310 -799
View File
File diff suppressed because it is too large Load Diff
+69 -56
View File
@@ -12,6 +12,8 @@
"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",
@@ -22,108 +24,119 @@
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@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.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-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",
"@radix-ui/react-slot": "1.2.4",
"@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",
"@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.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",
"axios": "^1.18.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"copy-to-clipboard": "^4.0.2",
"date-fns": "^4.4.0",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"date-fns-tz": "^3.2.0",
"framer-motion": "^13.3.0",
"hls.js": "^1.7.3",
"i18next": "^26.4.2",
"framer-motion": "^13.2.0",
"hls.js": "^1.6.15",
"i18next": "^24.2.0",
"i18next-http-backend": "^4.0.2",
"idb-keyval": "^6.3.0",
"js-yaml": "^5.4.2",
"konva": "^10.5.0",
"idb-keyval": "^6.2.1",
"js-yaml": "^4.3.2",
"konva": "^10.2.3",
"lodash": "^4.18.1",
"lucide-react": "^1.46.0",
"monaco-yaml": "^5.5.1",
"lucide-react": "^0.577.0",
"monaco-yaml": "^5.4.1",
"next-themes": "^0.4.6",
"nosleep.js": "^0.12.0",
"react": "^19.3.0",
"react": "^19.2.4",
"react-apexcharts": "^2.1.1",
"react-day-picker": "^9.14.0",
"react-device-detect": "^2.2.3",
"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-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-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.8",
"swr": "^2.5.1",
"sonner": "^2.0.7",
"sort-by": "^1.2.0",
"strftime": "^0.10.3",
"swr": "^2.4.1",
"tailwind-merge": "^2.4.0",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.3.0",
"use-long-press": "^3.2.0",
"vaul": "^1.1.2",
"virtua": "^0.51.3",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.25.76"
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.63.0",
"@playwright/test": "^1.59.1",
"@tailwindcss/forms": "^0.5.9",
"@testing-library/jest-dom": "^6.6.2",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.25",
"@types/node": "^26.5.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/lodash": "^4.17.12",
"@types/node": "^25.9.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/strftime": "^0.9.8",
"@vitejs/plugin-react": "^6.1.1",
"autoprefixer": "^10.6.0",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.4.20",
"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",
"eslint-plugin-react-refresh": "^0.5.6",
"fake-indexeddb": "^6.0.0",
"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.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.9",
"typescript": "^5.9.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0"
"vite": "^8.3.0",
"vitest": "^4.1.11"
},
"overrides": {
"@radix-ui/react-compose-refs": "1.1.2",
@@ -36,6 +36,14 @@
"title": "Bandwidth:",
"short": "Bandwidth"
},
"latency": {
"title": "Latency:",
"value": "{{seconds}} seconds",
"short": {
"title": "Latency",
"value": "{{seconds}} sec"
}
},
"totalFrames": "Total Frames:",
"droppedFrames": {
"title": "Dropped Frames:",
+3 -34
View File
@@ -6,12 +6,7 @@
"lowBandwidthMode": "Low-bandwidth Mode",
"twoWayTalk": {
"enable": "Enable 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."
}
"disable": "Disable Two Way Talk"
},
"cameraAudio": {
"enable": "Enable Camera Audio",
@@ -140,8 +135,7 @@
"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.",
"technology": "Stream technology selection is unavailable in debug mode."
"picker": "Stream selection unavailable in debug mode. Debug view always uses the stream assigned the detect role."
},
"twoWayTalk": {
"tips": "Your device must support the feature and WebRTC must be configured for two-way talk.",
@@ -150,36 +144,11 @@
},
"lowBandwidth": {
"tips": "Live view is in low-bandwidth mode due to buffering or stream errors.",
"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."
}
"resetStream": "Reset stream"
},
"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": {
+4 -15
View File
@@ -23,9 +23,6 @@
"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:",
@@ -1944,25 +1941,19 @@
"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.",
"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."
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. 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.",
"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."
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active."
},
"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.",
@@ -1997,12 +1988,10 @@
"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.",
"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."
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye."
},
"snapshots": {
"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."
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
},
"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."
+3 -5
View File
@@ -92,12 +92,10 @@ function PagePanel({ failure }: PanelProps) {
const stale = isStaleAsset(failure);
const onCopy = async () => {
// copy() falls back to a prompt and resolves false when the clipboard is
const onCopy = () => {
// copy() falls back to a prompt and returns false when the clipboard is
// refused, so a success toast has to wait on the result.
const copied = await copy(crashReport(failure, stats?.service.version), {
fallbackToPrompt: true,
});
const copied = copy(crashReport(failure, stats?.service.version));
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-context";
} from "@/context/statusbar-provider";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import StatusBarNotices from "@/components/health/StatusBarNotices";
import { cn } from "@/lib/utils";
@@ -4,15 +4,13 @@ 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;
}
+3 -3
View File
@@ -71,10 +71,10 @@ export function MessageBubble({
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
const handleCopy = useCallback(() => {
const text = content?.trim() || "";
if (!text) return;
if (await copy(text)) {
if (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() || onEditSubmit == null}
disabled={!draftContent.trim()}
onClick={handleEditSubmit}
aria-label={t("send")}
>
@@ -163,18 +163,8 @@ export default function ClassificationModelEditDialog({
}
}, [isObjectModel, t]);
const form = useForm<
ObjectFormData | StateFormData,
unknown,
ObjectFormData | StateFormData
>({
resolver: zodResolver(
formSchema as z.ZodType<
ObjectFormData | StateFormData,
z.ZodTypeDef,
ObjectFormData | StateFormData
>,
),
const form = useForm<ObjectFormData | StateFormData>({
resolver: zodResolver(formSchema),
defaultValues: isObjectModel
? ({
enabled: model.enabled,
@@ -9,11 +9,6 @@ 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,11 +7,6 @@ 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,10 +63,6 @@ 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,10 +7,6 @@ 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) {
@@ -22,10 +18,6 @@ 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) {
@@ -72,16 +64,12 @@ 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,10 +7,6 @@ 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,17 +28,6 @@ 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,9 +17,11 @@ 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" });
@@ -38,7 +38,8 @@ export default function CameraReviewClassification({
const cameraName = formContext?.cameraName ?? selectedCamera;
const fullFormData = formContext?.formData as JsonObject | undefined;
const baselineFormData = formContext?.baselineFormData as
JsonObject | undefined;
| JsonObject
| undefined;
const cameraConfig = formContext?.fullCameraConfig;
const alertsZones = useMemo(
@@ -10,7 +10,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
@@ -371,9 +371,9 @@ export default function NotificationsSettingsExtras({
const shouldFetchPubKey = Boolean(
config &&
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
(watchAllEnabled ||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
(watchAllEnabled ||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
);
const { data: publicKey } = useSWR(
@@ -61,7 +61,7 @@ import {
JsonValue,
} from "@/types/configForm";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
cameraUpdateTopicMap,
globalCameraDefaultSections,
@@ -1022,7 +1022,6 @@ export function ConfigSection({
formContext={{
level: effectiveLevel,
cameraName,
sectionPath,
globalValue,
cameraValue,
hasChanges,
@@ -1,5 +1,5 @@
import type { FieldPathList, FieldProps } from "@rjsf/utils";
import { dump, load, YAMLException } from "js-yaml";
import yaml from "js-yaml";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -16,7 +16,7 @@ function formatYaml(value: unknown): string {
return "";
}
try {
return dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
return yaml.dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
} catch {
return "";
}
@@ -31,7 +31,7 @@ function parseYaml(text: string): {
return { value: {}, error: undefined };
}
try {
const parsed = load(trimmed);
const parsed = yaml.load(trimmed);
if (
typeof parsed !== "object" ||
parsed === null ||
@@ -41,7 +41,7 @@ function parseYaml(text: string): {
}
return { value: parsed as Record<string, unknown>, error: undefined };
} catch (e) {
const msg = e instanceof YAMLException ? e.reason : "Invalid YAML";
const msg = e instanceof yaml.YAMLException ? e.reason : "Invalid YAML";
return { value: undefined, error: msg };
}
}
@@ -217,7 +217,8 @@ function PlateCombobox({
export function KnownPlatesField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
ConfigFormContext | undefined;
| ConfigFormContext
| undefined;
const configNamespace =
formContext?.i18nNamespace ??
@@ -159,7 +159,8 @@ function StreamValueCombobox({
export function LiveStreamsField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
ConfigFormContext | undefined;
| ConfigFormContext
| undefined;
const configNamespace =
formContext?.i18nNamespace ??
@@ -96,7 +96,8 @@ const getItemProperties = (
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
const scene = getItemProperties(itemSchema).scene as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const values = scene?.enum;
return Array.isArray(values)
@@ -44,7 +44,8 @@ function getPropertyTitle(itemSchema: RJSFSchema | undefined, key: string) {
export function ReplaceRulesField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
ConfigFormContext | undefined;
| ConfigFormContext
| undefined;
const configNamespace =
formContext?.i18nNamespace ??
@@ -19,8 +19,6 @@ import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { requiresRestartForFieldPath } from "@/utils/configUtil";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import RuntimeOverrideIndicator from "@/components/indicators/RuntimeOverrideIndicator";
import { getRuntimeOverride } from "@/utils/runtimeOverrides";
import {
buildTranslationPath,
resolveConfigTranslation,
@@ -88,7 +86,8 @@ export function FieldTemplate(props: FieldTemplateProps) {
const formContext = registry?.formContext as ConfigFormContext | undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const sectionI18nPrefix = formContext?.sectionI18nPrefix as
string | undefined;
| string
| undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
const { t, i18n } = useTranslation([
@@ -213,18 +212,6 @@ export function FieldTemplate(props: FieldTemplateProps) {
defaultRequiresRestart,
);
// The form shows saved config values, so flag any field the running camera
// currently disagrees with. Profile editing shows that profile's overrides
// instead, where the comparison does not apply.
const runtimeOverride =
isCameraLevel && !formContext?.isProfile
? getRuntimeOverride(
formContext?.fullCameraConfig,
formContext?.sectionPath,
pathSegments.join("."),
)
: undefined;
// Use schema title/description as primary source (from JSON Schema)
const schemaTitle = schema.title;
const schemaDescription = schema.description;
@@ -516,12 +503,6 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
{runtimeOverride && (
<RuntimeOverrideIndicator
runtimeValue={runtimeOverride.runtime}
className="ml-2"
/>
)}
</Label>
);
};
@@ -539,12 +520,6 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
{runtimeOverride && (
<RuntimeOverrideIndicator
runtimeValue={runtimeOverride.runtime}
className="ml-2"
/>
)}
</Label>
);
};
@@ -566,12 +541,6 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
{runtimeOverride && (
<RuntimeOverrideIndicator
runtimeValue={runtimeOverride.runtime}
className="ml-2"
/>
)}
</Label>
);
};
@@ -25,7 +25,8 @@ export function MultiSchemaFieldTemplate<
const { schema, selector, optionSchemaField, uiSchema } = props;
const uiOptions = uiSchema?.["ui:options"] as
UiSchema["ui:options"] | undefined;
| UiSchema["ui:options"]
| undefined;
const suppressMultiSchema = uiOptions?.suppressMultiSchema === true;
// Check if this is a simple nullable field that should be handled specially
@@ -55,7 +55,8 @@ export function CameraPathWidget(props: WidgetProps) {
const [showCredentials, setShowCredentials] = useState(false);
const formContext = props.registry?.formContext as
ConfigFormContext | undefined;
| ConfigFormContext
| undefined;
const isCameraLevel = formContext?.level === "camera";
const cameraName = formContext?.cameraName;
const inputIndex = useMemo(() => getInputIndexFromWidgetId(id), [id]);
@@ -103,7 +103,8 @@ const normalizeManualText = (value: unknown): string => {
export function FfmpegArgsWidget(props: WidgetProps) {
const formContext = props.registry?.formContext as
ConfigFormContext | undefined;
| ConfigFormContext
| undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
@@ -27,7 +27,8 @@ import type { GenAIModelsResponse } from "@/types/chat";
import { getSizedFieldClassName } from "../utils";
type ProbeResponse =
{ success: true; models: string[] } | { success: false; message: string };
| { success: true; models: string[] }
| { success: false; message: string };
type ProbeStatus = "idle" | "probing" | "success" | "error";
@@ -21,7 +21,8 @@ export function OnvifProfileWidget(props: WidgetProps) {
const { t } = useTranslation(["views/settings"]);
const formContext = props.registry?.formContext as
ConfigFormContext | undefined;
| ConfigFormContext
| undefined;
const cameraName = formContext?.cameraName;
const isCameraLevel = formContext?.level === "camera";
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
@@ -98,7 +98,8 @@ export function SwitchesWidget(props: WidgetProps) {
const availableEntities = useMemo(() => {
const getEntities =
(props.options?.getEntities as
((context: FormContext) => string[]) | undefined) || (() => []);
| ((context: FormContext) => string[])
| undefined) || (() => []);
if (context) {
return getEntities(context);
}
@@ -108,8 +109,8 @@ export function SwitchesWidget(props: WidgetProps) {
const getDisplayLabel = useMemo(
() =>
(props.options?.getDisplayLabel as
((entity: string, context?: FormContext) => string) | undefined) ||
((entity: string) => entity),
| ((entity: string, context?: FormContext) => string)
| undefined) || ((entity: string) => entity),
[props.options],
);
@@ -0,0 +1,91 @@
import { useRef, useCallback, useEffect, type ReactNode } from "react";
import { ScrollFollow } from "@melloware/react-logviewer";
export type ScrollFollowProps = {
startFollowing?: boolean;
render: (renderProps: ScrollFollowRenderProps) => ReactNode;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
};
export type ScrollFollowRenderProps = {
follow: boolean;
onScroll: (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => void;
startFollowing: () => void;
stopFollowing: () => void;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
};
const SCROLL_BUFFER = 5;
export default function EnhancedScrollFollow(props: ScrollFollowProps) {
const followRef = useRef(props.startFollowing || false);
const prevScrollTopRef = useRef<number | undefined>(undefined);
useEffect(() => {
prevScrollTopRef.current = undefined;
}, []);
const wrappedRender = useCallback(
(renderProps: ScrollFollowRenderProps) => {
const wrappedOnScroll = (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => {
// Check if scrolling up and immediately stop following
if (
prevScrollTopRef.current !== undefined &&
args.scrollTop < prevScrollTopRef.current
) {
if (followRef.current) {
renderProps.stopFollowing();
followRef.current = false;
}
}
const bottomThreshold =
args.scrollHeight - args.clientHeight - SCROLL_BUFFER;
const isNearBottom = args.scrollTop >= bottomThreshold;
if (isNearBottom && !followRef.current) {
renderProps.startFollowing();
followRef.current = true;
} else if (!isNearBottom && followRef.current) {
renderProps.stopFollowing();
followRef.current = false;
}
prevScrollTopRef.current = args.scrollTop;
renderProps.onScroll(args);
if (props.onCustomScroll) {
props.onCustomScroll(
args.scrollTop,
args.scrollHeight,
args.clientHeight,
);
}
};
return props.render({
...renderProps,
onScroll: wrappedOnScroll,
follow: followRef.current,
});
},
[props],
);
return <ScrollFollow {...props} render={wrappedRender} />;
}
@@ -10,7 +10,6 @@ import { MdHome } from "react-icons/md";
import { Button, buttonVariants } from "../ui/button";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
@@ -67,8 +66,7 @@ import { z } from "zod";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { deleteUserNamespacedKey } from "@/hooks/use-user-persistence";
import { AuthContext } from "@/context/auth-context";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import * as LuIcons from "react-icons/lu";
@@ -501,8 +499,9 @@ function NewGroupDialog({
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
const [isLoading, setIsLoading] = useState(false);
const { auth } = useContext(AuthContext);
const username = auth?.user?.username;
const [, , , deleteGridLayout] = useUserPersistence(
`${activeGroup}-draggable-layout`,
);
useEffect(() => {
if (!open) {
@@ -514,14 +513,15 @@ function NewGroupDialog({
const onDeleteGroup = useCallback(
async (name: string) => {
deleteGridLayout();
deleteGroup();
await axios
.put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
.then((res) => {
if (res.status === 200) {
deleteUserNamespacedKey(`${name}-draggable-layout`, username);
if (activeGroup == name) {
// deleting current group
deleteGroup();
setGroup("default");
}
updateConfig();
@@ -557,7 +557,15 @@ function NewGroupDialog({
setIsLoading(false);
});
},
[updateConfig, activeGroup, setGroup, setOpen, deleteGroup, username, t],
[
updateConfig,
activeGroup,
setGroup,
setOpen,
deleteGroup,
deleteGridLayout,
t,
],
);
const onSave = () => {
@@ -1,55 +0,0 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent } from "../ui/tooltip";
import { TooltipTrigger } from "@radix-ui/react-tooltip";
type RuntimeOverrideIndicatorProps = {
/** The value the camera is running with, which differs from the saved config. */
runtimeValue: unknown;
className?: string;
};
/**
* Field-level companion to the section override badges. Marks a field the
* running camera has drifted from, so the saved value on screen never reads as
* the live one.
*/
export default function RuntimeOverrideIndicator({
runtimeValue,
className,
}: RuntimeOverrideIndicatorProps) {
const { t } = useTranslation(["views/settings", "common"]);
const displayValue =
typeof runtimeValue === "boolean"
? t(runtimeValue ? "button.on" : "button.off", { ns: "common" })
: Array.isArray(runtimeValue)
? runtimeValue.join(", ")
: String(runtimeValue);
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className={cn(
"cursor-default border-2 border-selected text-center align-middle text-xs font-normal text-primary-variant",
className,
)}
>
{t("button.overriddenLive", { ns: "views/settings" })}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-72">
<p>{t("button.overriddenLiveTooltip", { ns: "views/settings" })}</p>
<p className="mt-1">
{t("button.overriddenLiveValue", {
ns: "views/settings",
value: displayValue,
})}
</p>
</TooltipContent>
</Tooltip>
);
}
+1 -5
View File
@@ -27,8 +27,6 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
placeholderId?: string;
idVisible?: boolean;
idDisabled?: boolean;
// Derive the id from the name as the user types
autoFillId?: boolean;
};
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
@@ -45,7 +43,6 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
placeholderId,
idVisible,
idDisabled,
autoFillId = true,
}: NameAndIdFieldsProps<T>) {
const { t } = useTranslation(["common"]);
const { watch, setValue, trigger, formState } = useFormContext<T>();
@@ -64,7 +61,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
const effectiveProcessId = processId || defaultProcessId;
useEffect(() => {
if (idDisabled || !autoFillId) {
if (idDisabled) {
return;
}
const subscription = watch((value, { name }) => {
@@ -84,7 +81,6 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
idField,
effectiveProcessId,
idDisabled,
autoFillId,
]);
// Auto-expand if there's an error on the ID field after user has typed
+28 -37
View File
@@ -179,9 +179,6 @@ export default function LiveContextMenu({
],
);
const isForcedLowBandwidth =
groupStreamingSettings?.[camera]?.playerMode === "jsmpeg";
// ui
const audioControlsUsed = useRef(false);
@@ -273,14 +270,12 @@ export default function LiveContextMenu({
<div className="text-primary-variant smart-capitalize">
<CameraNameLabel camera={camera} />
</div>
{preferredLiveMode == "jsmpeg" &&
isRestreamed &&
!isForcedLowBandwidth && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
</div>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
</div>
)}
</div>
{preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && (
<>
@@ -379,23 +374,21 @@ export default function LiveContextMenu({
</ContextMenuItem>
</>
)}
{preferredLiveMode == "jsmpeg" &&
isRestreamed &&
!isForcedLowBandwidth && (
<>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">
{t("button.reset", { ns: "common" })}
</div>
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">
{t("button.reset", { ns: "common" })}
</div>
</ContextMenuItem>
</>
)}
</div>
</ContextMenuItem>
</>
)}
{notificationsEnabledInConfig && isEnabled && (
<>
<ContextMenuSeparator />
@@ -555,16 +548,14 @@ export default function LiveContextMenu({
</ContextMenu>
<Dialog open={showSettings} onOpenChange={setShowSettings}>
{showSettings && (
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
streamMetadata={streamMetadata}
/>
)}
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
streamMetadata={streamMetadata}
/>
</Dialog>
</div>
);
+1 -1
View File
@@ -19,7 +19,7 @@ import useNavigation from "@/hooks/use-navigation";
import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-context";
} from "@/context/statusbar-provider";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
@@ -174,6 +174,7 @@ export default function JSMpegPlayer({
streamType: "jsmpeg",
bandwidth: Math.round(bitrate),
totalFrames: frameCount,
latency: undefined,
droppedFrames: undefined,
decodedFrames: undefined,
droppedFrameRate: undefined,
+2 -9
View File
@@ -11,7 +11,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import {
LivePlayerError,
TwoWayTalkError,
LivePlayerMode,
PlayerStatsType,
VideoResolutionType,
@@ -52,7 +51,6 @@ type LivePlayerProps = {
onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void;
onResetLiveMode?: () => void;
};
@@ -78,7 +76,6 @@ export default function LivePlayer({
onClick,
setFullResolution,
onError,
onMicrophoneError,
onResetLiveMode,
}: LivePlayerProps) {
const { t } = useTranslation(["components/player"]);
@@ -101,6 +98,7 @@ export default function LivePlayer({
const [stats, setStats] = useState<PlayerStatsType>({
streamType: "-",
bandwidth: 0, // in kBps
latency: undefined, // in seconds
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
@@ -274,7 +272,6 @@ export default function LivePlayer({
onPlaying={playerIsPlaying}
pip={pip}
onError={onError}
onMicrophoneError={onMicrophoneError}
/>
);
} else if (preferredLiveMode == "mse") {
@@ -366,11 +363,7 @@ export default function LivePlayer({
{cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
!liveReady && (
<div className="absolute inset-0 flex items-center justify-center">
<ActivityIndicator />
</div>
)}
!liveReady && <ActivityIndicator />}
{((showStillWithoutActivity && !liveReady) || liveReady) &&
objects.length > 0 && (
+11
View File
@@ -759,6 +759,15 @@ function MSEPlayer({
lastLoadedBytes = bytesLoaded;
lastTimestamp = now;
const latency =
video.seekable.length > 0
? Math.max(
0,
video.seekable.end(video.seekable.length - 1) -
video.currentTime,
)
: 0;
const videoQuality = video.getVideoPlaybackQuality();
const { totalVideoFrames, droppedVideoFrames } = videoQuality;
const droppedFrameRate = totalVideoFrames
@@ -768,6 +777,7 @@ function MSEPlayer({
setStats?.({
streamType: "MSE",
bandwidth,
latency,
totalFrames: totalVideoFrames,
droppedFrames: droppedVideoFrames || undefined,
decodedFrames: totalVideoFrames - droppedVideoFrames,
@@ -783,6 +793,7 @@ function MSEPlayer({
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
+27 -7
View File
@@ -8,21 +8,27 @@ type PlayerStatsProps = {
};
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
const { t } = useTranslation(["components/player", "views/live"]);
const streamTypeLabel = t(
`stream.technology.name.${stats.streamType.toLowerCase()}`,
{ ns: "views/live", defaultValue: stats.streamType },
);
const { t } = useTranslation(["components/player"]);
const fullStatsContent = (
<>
<p>
<span className="text-white/70">{t("stats.streamType.title")}</span>{" "}
<span className="text-white">{streamTypeLabel}</span>
<span className="text-white">{stats.streamType}</span>
</p>
<p>
<span className="text-white/70">{t("stats.bandwidth.title")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kBps</span>
</p>
{stats.latency != undefined && (
<p>
<span className="text-white/70">{t("stats.latency.title")}</span>{" "}
<span
className={`text-white ${stats.latency > 2 ? "text-danger" : ""}`}
>
{t("stats.latency.value", { seconds: stats.latency.toFixed(2) })}
</span>
</p>
)}
<p>
<span className="text-white/70">{t("stats.totalFrames")}</span>{" "}
<span className="text-white">{stats.totalFrames}</span>
@@ -56,12 +62,26 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
<div className="flex flex-row items-center justify-center gap-4">
<div className="flex flex-col items-center justify-start gap-1">
<span className="text-white/70">{t("stats.streamType.short")}</span>
<span className="text-white">{streamTypeLabel}</span>
<span className="text-white">{stats.streamType}</span>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-white/70">{t("stats.bandwidth.short")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kBps</span>
</div>
{stats.latency != undefined && (
<div className="hidden flex-col items-center gap-1 md:flex">
<span className="text-white/70">
{t("stats.latency.short.title")}
</span>
<span
className={`text-white ${stats.latency >= 2 ? "text-danger" : ""}`}
>
{t("stats.latency.short.value", {
seconds: stats.latency.toFixed(2),
})}
</span>
</div>
)}
{stats.droppedFrames != undefined && (
<div className="flex flex-col items-center justify-end gap-1">
<span className="text-white/70">
@@ -1,13 +0,0 @@
export abstract class PreviewController {
public camera = "";
constructor(camera: string) {
this.camera = camera;
}
abstract scrubToTimestamp(time: number): boolean;
abstract finishedSeeking(): void;
abstract setNewPreviewStartTime(time: number): void;
}
+14 -1
View File
@@ -22,7 +22,6 @@ import {
} from "@/hooks/use-camera-previews";
import { useTranslation } from "react-i18next";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { PreviewController } from "./PreviewController";
type PreviewPlayerProps = {
previewRef?: (ref: HTMLDivElement | null) => void;
@@ -103,6 +102,20 @@ export default function PreviewPlayer({
);
}
export abstract class PreviewController {
public camera = "";
constructor(camera: string) {
this.camera = camera;
}
abstract scrubToTimestamp(time: number): boolean;
abstract finishedSeeking(): void;
abstract setNewPreviewStartTime(time: number): void;
}
type PreviewVideoPlayerProps = {
visibilityRef?: (ref: HTMLDivElement | null) => void;
className?: string;
@@ -1,96 +0,0 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { LivePlayerMode, WebRTCUnavailableReason } from "@/types/live";
import { LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next";
type StreamTechnologySelectProps = {
value: LivePlayerMode;
onValueChange: (value: LivePlayerMode) => void;
isWebRTCAvailable: boolean;
webRTCUnavailableReason?: WebRTCUnavailableReason;
disabled: boolean;
};
export default function StreamTechnologySelect({
value,
onValueChange,
isWebRTCAvailable,
webRTCUnavailableReason,
disabled,
}: StreamTechnologySelectProps) {
const { t } = useTranslation(["views/live"]);
const isChecking = webRTCUnavailableReason === "checking";
return (
<Select
value={value}
onValueChange={onValueChange as (value: string) => void}
disabled={disabled}
>
<SelectTrigger className="w-full">
<span>{t(`stream.technology.name.${value}`)}</span>
</SelectTrigger>
<SelectContent className="w-[var(--radix-select-trigger-width)]">
<SelectItem value="mse">
<span className="flex flex-col gap-0.5 whitespace-normal">
<span>{t("stream.technology.name.mse")}</span>
<span className="text-xs text-muted-foreground">
{t("stream.technology.tips.mse")}
</span>
</span>
</SelectItem>
<SelectItem
value="webrtc"
disabled={!isWebRTCAvailable}
className="data-[disabled]:opacity-100"
>
<span className="flex flex-col gap-0.5 whitespace-normal">
<span className={cn(!isWebRTCAvailable && "opacity-50")}>
{t("stream.technology.name.webrtc")}
</span>
<span
className={cn(
"text-xs text-muted-foreground",
!isWebRTCAvailable && "opacity-50",
)}
>
{t("stream.technology.tips.webrtc")}
</span>
{!isWebRTCAvailable && webRTCUnavailableReason && (
<span
className={cn(
"mt-1 flex flex-row items-start gap-1.5 rounded-md border p-2",
isChecking
? "border-secondary-foreground/20 bg-secondary-foreground/10"
: "border-danger/20 bg-danger/10",
)}
>
{isChecking ? (
<ActivityIndicator
className="mt-0.5 size-3.5 shrink-0"
size={14}
/>
) : (
<LuX className="mt-0.5 size-3.5 shrink-0 text-danger" />
)}
<span className="text-xs text-secondary-foreground">
{t(
`stream.technology.unavailable.${webRTCUnavailableReason}`,
)}
</span>
</span>
)}
</span>
</SelectItem>
</SelectContent>
</Select>
);
}
+60 -147
View File
@@ -1,13 +1,6 @@
import { baseUrl } from "@/api/baseUrl";
import {
LivePlayerError,
PlayerStatsType,
TwoWayTalkError,
} from "@/types/live";
import { FrigateConfig } from "@/types/frigateConfig";
import { webRTCIceServers } from "@/utils/webrtcUtil";
import { LivePlayerError, PlayerStatsType } from "@/types/live";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
type WebRtcPlayerProps = {
className?: string;
@@ -22,7 +15,6 @@ type WebRtcPlayerProps = {
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void;
};
export default function WebRtcPlayer({
@@ -38,22 +30,9 @@ export default function WebRtcPlayer({
setStats,
onPlaying,
onError,
onMicrophoneError,
}: WebRtcPlayerProps) {
// metadata
const { data: config } = useSWR<FrigateConfig>("config");
// Keyed on the serialized list so an unrelated config update doesn't
// reconnect every WebRTC player.
const iceServersKey = JSON.stringify(
config?.go2rtc?.webrtc?.ice_servers ?? [],
);
const iceServers = useMemo(
() => webRTCIceServers(JSON.parse(iceServersKey)),
[iceServersKey],
);
const wsURL = useMemo(() => {
return `${baseUrl.replace(/^http/, "ws")}live/webrtc/api/ws?src=${camera}`;
}, [camera]);
@@ -75,10 +54,6 @@ export default function WebRtcPlayer({
const pcRef = useRef<RTCPeerConnection | undefined>(undefined);
const wsRef = useRef<WebSocket | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
// Separate sendonly-audio connection for two-way talk: go2rtc only wires the
// backchannel from a connection's initial offer.
const micPcRef = useRef<RTCPeerConnection | undefined>(undefined);
const micWsRef = useRef<WebSocket | null>(null);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>(undefined);
@@ -90,7 +65,7 @@ export default function WebRtcPlayer({
const pc = new RTCPeerConnection({
bundlePolicy: "max-bundle",
iceServers,
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
const localTracks = [];
@@ -130,7 +105,7 @@ export default function WebRtcPlayer({
videoRef.current.srcObject = new MediaStream(localTracks);
return pc;
},
[videoRef, iceServers],
[videoRef],
);
async function getMediaTracks(
@@ -148,57 +123,51 @@ export default function WebRtcPlayer({
}
}
// Offer/answer/ICE exchange over the WebSocket; shared by both connections.
const startSignaling = useCallback((pc: RTCPeerConnection, ws: WebSocket) => {
ws.addEventListener("open", () => {
pc.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) return;
ws.send(
JSON.stringify({
type: "webrtc/candidate",
value: ev.candidate.candidate,
}),
);
});
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer))
.then(() => {
ws.send(
JSON.stringify({
type: "webrtc/offer",
value: pc.localDescription?.sdp,
}),
);
});
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "webrtc/candidate") {
pc.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
} else if (msg.type === "webrtc/answer") {
pc.setRemoteDescription({ type: "answer", sdp: msg.value });
}
});
}, []);
const connect = useCallback(
async (aPc: Promise<RTCPeerConnection | undefined>) => {
if (!aPc) {
return;
}
const pc = await aPc;
if (!pc) {
return;
}
pcRef.current = pc;
pcRef.current = await aPc;
wsRef.current = new WebSocket(wsURL);
startSignaling(pc, wsRef.current);
const ws = wsRef.current;
ws.addEventListener("open", () => {
pcRef.current?.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) return;
const msg = {
type: "webrtc/candidate",
value: ev.candidate.candidate,
};
ws.send(JSON.stringify(msg));
});
pcRef.current
?.createOffer()
.then((offer) => pcRef.current?.setLocalDescription(offer))
.then(() => {
const msg = {
type: "webrtc/offer",
value: pcRef.current?.localDescription?.sdp,
};
ws.send(JSON.stringify(msg));
});
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "webrtc/candidate") {
pcRef.current?.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
} else if (msg.type === "webrtc/answer") {
pcRef.current?.setRemoteDescription({
type: "answer",
sdp: msg.value,
});
}
});
},
[wsURL, startSignaling],
[wsURL],
);
useEffect(() => {
@@ -210,8 +179,9 @@ export default function WebRtcPlayer({
return;
}
// No mic here. It's a separate connection, so toggling talk never reloads.
const aPc = PeerConnection("video+audio");
const aPc = PeerConnection(
microphoneEnabled ? "video+audio+microphone" : "video+audio",
);
connect(aPc);
return () => {
@@ -224,80 +194,14 @@ export default function WebRtcPlayer({
pcRef.current = undefined;
}
};
}, [camera, connect, PeerConnection, pcRef, videoRef, playbackEnabled]);
// Backchannel connection, alive only while the mic is on.
useEffect(() => {
if (!microphoneEnabled || !playbackEnabled) {
return;
}
let cancelled = false;
(async () => {
const tracks = await getMediaTracks("user", {
video: false,
audio: true,
});
if (cancelled) {
tracks.forEach((track) => track.stop());
return;
}
if (tracks.length === 0) {
onMicrophoneError?.("microphone");
return;
}
const pc = new RTCPeerConnection({
bundlePolicy: "max-bundle",
iceServers,
});
tracks.forEach((track) =>
pc.addTransceiver(track, { direction: "sendonly" }),
);
micPcRef.current = pc;
const ws = new WebSocket(wsURL);
micWsRef.current = ws;
startSignaling(pc, ws);
// go2rtc sends an error instead of an answer when it can't attach the
// microphone to the camera's backchannel.
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type !== "error" || cancelled) {
return;
}
// eslint-disable-next-line no-console
console.error(
`${camera} - Two-way talk error: ${msg.value} See the documentation: https://docs.frigate.video/configuration/live/#two-way-talk`,
);
onMicrophoneError?.("refused");
});
})();
return () => {
cancelled = true;
micPcRef.current?.getSenders().forEach((sender) => sender.track?.stop());
if (micWsRef.current) {
micWsRef.current.close();
micWsRef.current = null;
}
if (micPcRef.current) {
micPcRef.current.close();
micPcRef.current = undefined;
}
};
}, [
microphoneEnabled,
playbackEnabled,
wsURL,
startSignaling,
iceServers,
camera,
onMicrophoneError,
connect,
PeerConnection,
pcRef,
videoRef,
playbackEnabled,
microphoneEnabled,
]);
// ios compat
@@ -358,7 +262,9 @@ export default function WebRtcPlayer({
const report = await pcRef.current.getStats();
let bytesReceived = 0;
let timestamp = 0;
let roundTripTime = 0;
let framesReceived = 0;
let framesDropped = 0;
let framesDecoded = 0;
report.forEach((stat) => {
@@ -366,8 +272,12 @@ export default function WebRtcPlayer({
bytesReceived = stat.bytesReceived;
timestamp = stat.timestamp;
framesReceived = stat.framesReceived;
framesDropped = stat.framesDropped;
framesDecoded = stat.framesDecoded;
}
if (stat.type === "candidate-pair" && stat.state === "succeeded") {
roundTripTime = stat.currentRoundTripTime;
}
});
const timeDiff = (timestamp - lastTimestamp) / 1000; // in seconds
@@ -379,10 +289,12 @@ export default function WebRtcPlayer({
setStats?.({
streamType: "WebRTC",
bandwidth: Math.round(bitrate),
latency: roundTripTime,
totalFrames: framesReceived,
droppedFrames: undefined,
droppedFrames: framesDropped,
decodedFrames: framesDecoded,
droppedFrameRate: undefined,
droppedFrameRate:
framesReceived > 0 ? (framesDropped / framesReceived) * 100 : 0,
});
lastBytesReceived = bytesReceived;
@@ -395,6 +307,7 @@ export default function WebRtcPlayer({
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
@@ -8,7 +8,11 @@
*/
export type DownswitchReason =
"stall" | "bandwidth" | "fatal-error" | "startup" | "codec";
| "stall"
| "bandwidth"
| "fatal-error"
| "startup"
| "codec";
// stalls just after a seek are expected on any network (the target
// position is rarely buffered), so they get a longer budget and are
@@ -1,6 +1,6 @@
import { Recording } from "@/types/record";
import { DynamicPlayback } from "@/types/playback";
import { PreviewController } from "../PreviewController";
import { PreviewController } from "../PreviewPlayer";
import { TimeRange, TrackingDetailsSequence } from "@/types/timeline";
import {
calculateInpointOffset,
@@ -16,8 +16,7 @@ import {
RecordingCoverage,
} from "@/types/record";
import { Preview } from "@/types/preview";
import PreviewPlayer from "../PreviewPlayer";
import { PreviewController } from "../PreviewController";
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
import { DynamicVideoController } from "./DynamicVideoController";
import HlsVideoPlayer, { HlsSource } from "../HlsVideoPlayer";
import { useDetailStream } from "@/context/detail-stream-context";
@@ -30,14 +30,11 @@ import ActivityIndicator from "../indicators/activity-indicator";
import useSWR from "swr";
import { LuCheck, LuExternalLink, LuInfo, LuX } from "react-icons/lu";
import { Link } from "react-router-dom";
import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
import { LiveStreamMetadata } from "@/types/live";
import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
import { Switch } from "@/components/ui/switch";
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
type CameraStreamingDialogProps = {
camera: string;
@@ -58,11 +55,7 @@ export function CameraStreamingDialog({
setIsDialogOpen,
onSave,
}: CameraStreamingDialogProps) {
const { t } = useTranslation([
"components/camera",
"components/dialog",
"views/live",
]);
const { t } = useTranslation(["components/camera", "components/dialog"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config");
@@ -75,8 +68,6 @@ export function CameraStreamingDialog({
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] || "",
);
const [streamType, setStreamType] = useState<StreamType>("smart");
const [playerMode, setPlayerMode] = useState<LivePlayerMode>("mse");
const [forceLowBandwidth, setForceLowBandwidth] = useState(false);
const [compatibilityMode, setCompatibilityMode] = useState(false);
// metadata
@@ -88,39 +79,13 @@ export function CameraStreamingDialog({
[config, streamName],
);
// Fetch the go2rtc stream metadata directly when the parent didn't provide it
// so codec/availability detection works regardless of caller
const { data: fetchedMetadata } = useSWR<LiveStreamMetadata>(
isRestreamed && streamName && !streamMetadata?.[streamName]
? `go2rtc/streams/${streamName}`
: null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
},
);
const cameraMetadata = streamName
? (streamMetadata?.[streamName] ?? fetchedMetadata)
: undefined;
const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined;
const { audioOutput: supportsAudioOutput } = useMemo(
() => detectCameraAudioFeatures(cameraMetadata),
[cameraMetadata],
);
const { available: isWebRTCAvailable, reason: webRTCUnavailableReason } =
useWebRTCAvailableForStream(cameraMetadata, streamName);
// The chosen technology resolved for the currently selected stream, WITHOUT
// rewriting the saved choice
const resolvedPlayerMode = useMemo<LivePlayerMode>(
() => (playerMode === "webrtc" && !isWebRTCAvailable ? "mse" : playerMode),
[playerMode, isWebRTCAvailable],
);
// handlers
useEffect(() => {
@@ -142,15 +107,10 @@ export function CameraStreamingDialog({
setStreamName(streamExists ? streamNameFromSettings : firstStreamEntry);
setStreamType(cameraSettings.streamType || "smart");
const savedPlayerMode = cameraSettings.playerMode || "mse";
setPlayerMode(savedPlayerMode === "jsmpeg" ? "mse" : savedPlayerMode);
setForceLowBandwidth(savedPlayerMode === "jsmpeg");
setCompatibilityMode(cameraSettings.compatibilityMode || false);
} else {
setStreamName(firstStreamEntry);
setStreamType("smart");
setPlayerMode("mse");
setForceLowBandwidth(false);
setCompatibilityMode(false);
}
}, [groupStreamingSettings, camera, config]);
@@ -162,7 +122,6 @@ export function CameraStreamingDialog({
[camera]: {
streamName,
streamType,
playerMode: forceLowBandwidth ? "jsmpeg" : playerMode,
compatibilityMode,
playAudio: groupStreamingSettings?.[camera]?.playAudio ?? false,
volume: groupStreamingSettings?.[camera]?.volume ?? 1,
@@ -179,8 +138,6 @@ export function CameraStreamingDialog({
camera,
streamName,
streamType,
playerMode,
forceLowBandwidth,
compatibilityMode,
setIsDialogOpen,
onSave,
@@ -205,15 +162,10 @@ export function CameraStreamingDialog({
setStreamName(streamExists ? streamNameFromSettings : firstStreamEntry);
setStreamType(cameraSettings.streamType || "smart");
const savedPlayerMode = cameraSettings.playerMode || "mse";
setPlayerMode(savedPlayerMode === "jsmpeg" ? "mse" : savedPlayerMode);
setForceLowBandwidth(savedPlayerMode === "jsmpeg");
setCompatibilityMode(cameraSettings.compatibilityMode || false);
} else {
setStreamName(firstStreamEntry);
setStreamType("smart");
setPlayerMode("mse");
setForceLowBandwidth(false);
setCompatibilityMode(false);
}
@@ -282,11 +234,7 @@ export function CameraStreamingDialog({
<Label htmlFor="stream" className="text-right">
{t("group.camera.setting.stream")}
</Label>
<Select
value={streamName}
onValueChange={setStreamName}
disabled={forceLowBandwidth}
>
<Select value={streamName} onValueChange={setStreamName}>
<SelectTrigger className="">
<SelectValue
placeholder={t("group.camera.setting.placeholder")}
@@ -302,86 +250,46 @@ export function CameraStreamingDialog({
),
)}
</SelectContent>
{!forceLowBandwidth && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("group.camera.setting.audioIsAvailable")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>
{t("group.camera.setting.audioIsUnavailable")}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("group.camera.setting.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/live")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("group.camera.setting.audioIsAvailable")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("group.camera.setting.audioIsUnavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("group.camera.setting.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/live")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
</Select>
</div>
)}
{isRestreamed &&
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-technology" className="text-right">
{t("stream.mode", { ns: "views/live" })}
</Label>
<StreamTechnologySelect
value={resolvedPlayerMode}
onValueChange={setPlayerMode}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCUnavailableReason}
disabled={forceLowBandwidth}
/>
<p className="text-sm text-muted-foreground">
{t("stream.technology.description", { ns: "views/live" })}
</p>
</div>
)}
{isRestreamed &&
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
<div className="flex flex-col items-start gap-2">
<div className="flex w-full items-center justify-between">
<Label htmlFor="force-low-bandwidth" className="cursor-pointer">
{t("stream.lowBandwidth.force.label", { ns: "views/live" })}
</Label>
<Switch
id="force-low-bandwidth"
checked={forceLowBandwidth}
onCheckedChange={setForceLowBandwidth}
/>
</div>
<p className="text-sm text-muted-foreground">
{t("stream.lowBandwidth.force.desc", { ns: "views/live" })}
</p>
</div>
)}
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-method" className="text-right">
{t("group.camera.setting.streamMethod.label")}
@@ -65,7 +65,7 @@ import {
import { buildConfigDataForPath } from "@/utils/configUtil";
import { useConfigSchema } from "@/hooks/use-config-schema";
import { useRestart } from "@/api/ws";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import SaveAllPreviewPopover from "@/components/overlay/detail/SaveAllPreviewPopover";
@@ -203,8 +203,7 @@ export function PolygonCanvas({
if (stage) {
// we add an unfilled line for adding points when finished
const index = e.target.index - (activePolygon.isFinished ? 2 : 1);
const dragged = e.target.getAbsolutePosition();
let pos = [dragged.x, dragged.y];
let pos = [e.target._lastPos!.x, e.target._lastPos!.y];
if (snapPoints) {
// Snap to other polygons' edges
+141 -76
View File
@@ -22,12 +22,12 @@ import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import { isMobile } from "react-device-detect";
import { toRGBColorString } from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas";
import { useCallback, useContext, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import axios from "axios";
import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
import { removeRequiredZoneQuery, reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper";
import { buttonVariants } from "@/components/ui/button";
import { Trans, useTranslation } from "react-i18next";
@@ -35,7 +35,6 @@ import ActivityIndicator from "../indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
import { getProfileColor } from "@/utils/profileColors";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
type PolygonItemProps = {
polygon: Polygon;
@@ -73,7 +72,6 @@ export default function PolygonItem({
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const statusBar = useContext(StatusBarMessagesContext);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { payload: motionMaskState, send: sendMotionMaskState } =
useMotionMaskState(polygon.camera, polygon.name);
@@ -139,66 +137,148 @@ export default function PolygonItem({
setIsLoading(true);
setLoadingPolygonIndex(index);
let cameraUpdate: Record<string, unknown>;
let needsRestart = false;
if (polygon.type === "zone") {
const deleteSection = { zones: { [polygon.name]: null } };
let url: string;
if (editingProfile) {
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
// Profile mode: just delete the profile zone
url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`;
} else {
const references = zoneReferenceUpdates(cameraConfig, polygon.name);
// Processes other than object tracking only reload these on restart
needsRestart = Object.keys(references).length > 0;
cameraUpdate = { ...deleteSection, ...references };
}
} else {
const deleteSection =
polygon.type === "motion_mask"
? { motion: { mask: { [polygon.name]: null } } }
: !polygon.objects.length
? { objects: { mask: { [polygon.name]: null } } }
: {
objects: {
filters: {
[polygon.objects[0]]: {
mask: { [polygon.name]: null },
},
},
},
};
if (editingProfile) {
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
} else {
// Base mode: also delete from profiles that have overrides for this mask
const profileDeletes: Record<string, unknown> = {};
// Base mode: handle review queries
const { alertQueries, detectionQueries } = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
const genaiQueries = removeRequiredZoneQuery(
polygon.name,
polygon.camera,
"objects.genai",
cameraConfig?.objects.genai.required_zones || [],
);
const snapshotQueries = removeRequiredZoneQuery(
polygon.name,
polygon.camera,
"snapshots",
cameraConfig?.snapshots.required_zones || [],
);
const mqttQueries = removeRequiredZoneQuery(
polygon.name,
polygon.camera,
"mqtt",
cameraConfig?.mqtt.required_zones || [],
);
const autotrackQueries = removeRequiredZoneQuery(
polygon.name,
polygon.camera,
"onvif.autotracking",
cameraConfig?.onvif.autotracking.required_zones || [],
);
// Also delete from profiles that have overrides for this zone
let profileQueries = "";
if (allProfileNames && cameraConfig) {
for (const profileName of allProfileNames) {
const profileData = cameraConfig.profiles?.[profileName];
if (!profileData) continue;
const hasMask =
polygon.type === "motion_mask"
? profileData.motion?.mask?.[polygon.name] !== undefined
: polygon.type === "object_mask"
? profileData.objects?.mask?.[polygon.name] !== undefined ||
Object.values(profileData.objects?.filters || {}).some(
(f) => f?.mask?.[polygon.name] !== undefined,
)
: false;
if (hasMask) {
profileDeletes[profileName] = deleteSection;
if (
cameraConfig.profiles?.[profileName]?.zones?.[polygon.name] !==
undefined
) {
profileQueries += `&cameras.${polygon.camera}.profiles.${profileName}.zones.${polygon.name}`;
}
}
}
cameraUpdate =
Object.keys(profileDeletes).length > 0
? { ...deleteSection, profiles: profileDeletes }
: deleteSection;
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}${genaiQueries}${snapshotQueries}${mqttQueries}${autotrackQueries}${profileQueries}`;
}
await axios
.put(`config/set?${url}`, {
requires_restart: 0,
update_topic: updateTopic,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{ position: "top-center" },
);
updateConfig();
onDeleted?.();
} else {
toast.error(
t("toast.save.error.title", {
ns: "common",
errorMessage: res.statusText,
}),
{ position: "top-center" },
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
return;
}
// Motion masks and object masks use JSON body format
const deleteSection =
polygon.type === "motion_mask"
? { motion: { mask: { [polygon.name]: null } } }
: !polygon.objects.length
? { objects: { mask: { [polygon.name]: null } } }
: {
objects: {
filters: {
[polygon.objects[0]]: {
mask: { [polygon.name]: null },
},
},
},
};
let cameraUpdate: Record<string, unknown>;
if (editingProfile) {
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
} else {
// Base mode: also delete from profiles that have overrides for this mask
const profileDeletes: Record<string, unknown> = {};
if (allProfileNames && cameraConfig) {
for (const profileName of allProfileNames) {
const profileData = cameraConfig.profiles?.[profileName];
if (!profileData) continue;
const hasMask =
polygon.type === "motion_mask"
? profileData.motion?.mask?.[polygon.name] !== undefined
: polygon.type === "object_mask"
? profileData.objects?.mask?.[polygon.name] !== undefined ||
Object.values(profileData.objects?.filters || {}).some(
(f) => f?.mask?.[polygon.name] !== undefined,
)
: false;
if (hasMask) {
profileDeletes[profileName] = deleteSection;
}
}
}
cameraUpdate =
Object.keys(profileDeletes).length > 0
? { ...deleteSection, profiles: profileDeletes }
: deleteSection;
}
const configUpdate = {
@@ -210,29 +290,17 @@ export default function PolygonItem({
await axios
.put("config/set", {
config_data: configUpdate,
requires_restart: needsRestart ? 1 : 0,
requires_restart: 0,
update_topic: updateTopic,
})
.then((res) => {
if (res.status === 200) {
if (needsRestart) {
statusBar?.addMessage(
"config_restart_required",
t("configForm.restartRequiredFooter"),
undefined,
"config_restart_required",
);
toast.success(t("toast.successRestartRequired"), {
position: "top-center",
});
} else {
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{ position: "top-center" },
);
}
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{ position: "top-center" },
);
updateConfig();
onDeleted?.();
} else {
@@ -270,7 +338,6 @@ export default function PolygonItem({
editingProfile,
allProfileNames,
onDeleted,
statusBar,
],
);
@@ -526,7 +593,6 @@ export default function PolygonItem({
<TooltipTrigger asChild>
<IconWrapper
icon={LuPencil}
aria-label={t("button.edit", { ns: "common" })}
disabled={isLoading}
className={cn(
"size-[15px] cursor-pointer",
@@ -571,7 +637,6 @@ export default function PolygonItem({
<TooltipTrigger asChild>
<IconWrapper
icon={HiTrash}
aria-label={t("button.delete", { ns: "common" })}
disabled={isLoading}
className={cn(
"size-[15px] cursor-pointer",
+155 -102
View File
@@ -11,14 +11,14 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ZoneFormValuesType, Polygon } from "@/types/canvas";
import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
import { reviewQueries } from "@/utils/zoneEdutUtil";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import PolygonEditControls from "./PolygonEditControls";
@@ -35,7 +35,6 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields";
import { useZoneState } from "@/api/ws";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
type ZoneEditPaneProps = {
polygons?: Polygon[];
@@ -72,7 +71,6 @@ export default function ZoneEditPane({
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const statusBar = useContext(StatusBarMessagesContext);
const cameras = useMemo(() => {
if (!config) {
@@ -97,8 +95,29 @@ export default function ZoneEditPane({
const isExistingZone = !!polygon && polygon.name.length > 0;
// A profile zone overrides a base zone by name, so its id is fixed
const idDisabled = isExistingZone && !!editingProfile;
const idDisabled = useMemo(() => {
if (!isExistingZone || !polygon) {
return false;
}
if (editingProfile) {
return true;
}
const cam = config?.cameras[polygon.camera];
if (!cam) {
return false;
}
const inRequiredZones =
cam.review.alerts.required_zones.includes(polygon.name) ||
cam.review.detections.required_zones.includes(polygon.name) ||
cam.objects.genai.required_zones.includes(polygon.name) ||
cam.snapshots.required_zones.includes(polygon.name) ||
cam.mqtt.required_zones.includes(polygon.name) ||
cam.onvif.autotracking.required_zones.includes(polygon.name);
const hasProfileOverride = Object.values(cam.profiles ?? {}).some(
(profile) => profile?.zones && polygon.name in profile.zones,
);
return inRequiredZones || hasProfileOverride;
}, [config, polygon, editingProfile, isExistingZone]);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
@@ -297,11 +316,7 @@ export default function ZoneEditPane({
return profileZone ?? cam.zones[polygon.name];
}, [polygon, config, editingProfile]);
const form = useForm<
z.input<typeof formSchema>,
unknown,
z.output<typeof formSchema>
>({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
@@ -351,7 +366,7 @@ export default function ZoneEditPane({
}, [polygon?.isFinished, form]);
const saveToConfig = useCallback(
(
async (
{
name: zoneName,
friendly_name,
@@ -372,130 +387,169 @@ export default function ZoneEditPane({
return;
}
// Determine config path prefix based on profile mode
const pathPrefix = editingProfile
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${zoneName}`
: `cameras.${polygon.camera}.zones.${zoneName}`;
const oldPathPrefix = editingProfile
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`
: `cameras.${polygon.camera}.zones.${polygon.name}`;
let mutatedConfig: typeof config;
let alertQueries = "";
let detectionQueries = "";
const renamingZone = zoneName != polygon.name && polygon.name != "";
// config/set fails to delete a key the YAML lacks. The base zone comes
// back with defaults filled in, so only a non-empty value proves the key
// is written. Profile overrides hold only keys set in the YAML.
const writtenZone = (
renamingZone
? undefined
: editingProfile
? cameraConfig?.profiles?.[editingProfile]?.zones?.[polygon.name]
: (cameraConfig?.base_config?.zones ?? cameraConfig?.zones)?.[
polygon.name
]
) as Record<string, unknown> | undefined;
const canRemove = (key: string) => {
const value = writtenZone?.[key];
return value != null && !(Array.isArray(value) && value.length == 0);
};
const zoneData: Record<string, unknown> = {
coordinates: flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(","),
enabled,
};
if (renamingZone) {
// The form has no filters field, so carry the old zone's over
const baseZones = (cameraConfig?.base_config?.zones ??
cameraConfig?.zones) as
Record<string, { filters?: Record<string, unknown> }> | undefined;
const filters = baseZones?.[polygon.name]?.filters;
// rename - delete old zone and replace with new
// Only handle review queries for base config (not profiles)
if (!editingProfile) {
const zoneInAlerts =
cameraConfig?.review.alerts.required_zones.includes(polygon.name) ??
false;
const zoneInDetections =
cameraConfig?.review.detections.required_zones.includes(
polygon.name,
) ?? false;
if (filters && Object.keys(filters).length > 0) {
zoneData.filters = filters;
const {
alertQueries: renameAlertQueries,
detectionQueries: renameDetectionQueries,
} = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
try {
await axios.put(
`config/set?${oldPathPrefix}${renameAlertQueries}${renameDetectionQueries}`,
{
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
},
);
// Wait for the config to be updated
mutatedConfig = await updateConfig();
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
// make sure new zone name is readded to review
({ alertQueries, detectionQueries } = reviewQueries(
zoneName,
zoneInAlerts,
zoneInDetections,
polygon.camera,
mutatedConfig?.cameras[polygon.camera]?.review.alerts
.required_zones || [],
mutatedConfig?.cameras[polygon.camera]?.review.detections
.required_zones || [],
));
} else {
// Profile mode: just delete the old profile zone path
try {
await axios.put(`config/set?${oldPathPrefix}`, {
requires_restart: 0,
});
mutatedConfig = await updateConfig();
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
}
}
const coordinates = flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
let objectQueries = objects
.map((object) => `&${pathPrefix}.objects=${object}`)
.join("");
const same_objects =
form_objects.length == objects.length &&
form_objects.every(function (element, index) {
return element === objects[index];
});
if (objects.length > 0) {
zoneData.objects = objects;
} else if (!same_objects && canRemove("objects")) {
zoneData.objects = null;
// deleting objects
if (!objectQueries && !same_objects && !renamingZone) {
objectQueries = `&${pathPrefix}.objects`;
}
let inertiaQuery = "";
if (inertia) {
zoneData.inertia = inertia;
inertiaQuery = `&${pathPrefix}.inertia=${inertia}`;
}
if (typeof loitering_time === "number") {
zoneData.loitering_time = loitering_time;
let loiteringTimeQuery = "";
if (loitering_time >= 0) {
loiteringTimeQuery = `&${pathPrefix}.loitering_time=${loitering_time}`;
}
let distancesQuery = "";
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
if (speedEstimation) {
zoneData.distances = distances;
} else if (canRemove("distances")) {
zoneData.distances = null;
distancesQuery = `&${pathPrefix}.distances=${distances}`;
} else {
if (distances != "") {
distancesQuery = `&${pathPrefix}.distances`;
}
}
if (typeof speed_threshold === "number" && speedEstimation) {
zoneData.speed_threshold = speed_threshold;
} else if (canRemove("speed_threshold")) {
zoneData.speed_threshold = null;
let speedThresholdQuery = "";
if (speed_threshold >= 0 && speedEstimation) {
speedThresholdQuery = `&${pathPrefix}.speed_threshold=${speed_threshold}`;
} else {
if (resolvedZoneData?.speed_threshold) {
speedThresholdQuery = `&${pathPrefix}.speed_threshold`;
}
}
// The name field falls back to the old id when the zone has no name
const unnamed = !polygon.friendly_name && friendly_name === polygon.name;
if (friendly_name && friendly_name !== zoneName && !unnamed) {
zoneData.friendly_name = friendly_name;
let friendlyNameQuery = "";
if (friendly_name && friendly_name !== zoneName) {
friendlyNameQuery = `&${pathPrefix}.friendly_name=${encodeURIComponent(friendly_name)}`;
}
const zones = renamingZone
? { [polygon.name]: null, [zoneName]: zoneData }
: { [zoneName]: zoneData };
const references =
renamingZone && !editingProfile && cameraConfig
? zoneReferenceUpdates(cameraConfig, polygon.name, zoneName)
: {};
// Processes other than object tracking only reload these on restart
const needsRestart = Object.keys(references).length > 0;
const cameraData = editingProfile
? { profiles: { [editingProfile]: { zones } } }
: { zones, ...references };
const enabledQuery = `&${pathPrefix}.enabled=${enabled ? "True" : "False"}`;
const updateTopic = editingProfile
? undefined
: `config/cameras/${polygon.camera}/zones`;
axios
.put("config/set", {
config_data: { cameras: { [polygon.camera]: cameraData } },
requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic,
})
.put(
`config/set?${pathPrefix}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
{
requires_restart: 0,
update_topic: updateTopic,
},
)
.then((res) => {
if (res.status === 200) {
if (needsRestart) {
statusBar?.addMessage(
"config_restart_required",
t("configForm.restartRequiredFooter"),
undefined,
"config_restart_required",
);
toast.success(t("toast.successRestartRequired"), {
toast.success(
t("masksAndZones.zones.toast.success", {
zoneName: friendly_name || zoneName,
}),
{
position: "top-center",
});
} else {
toast.success(
t("masksAndZones.zones.toast.success", {
zoneName: friendly_name || zoneName,
}),
{
position: "top-center",
},
);
}
},
);
updateConfig();
// Only publish WS state for base config when zone has a name and
// wasn't renamed (the hook is bound to the old name).
@@ -543,11 +597,11 @@ export default function ZoneEditPane({
t,
sendZoneState,
editingProfile,
statusBar,
resolvedZoneData,
],
);
function onSubmit(values: z.output<typeof formSchema>) {
function onSubmit(values: z.infer<typeof formSchema>) {
if (activePolygonIndex === undefined || !values || !polygons) {
return;
}
@@ -620,7 +674,6 @@ export default function ZoneEditPane({
nameDescription={t("masksAndZones.zones.name.tips")}
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
idDisabled={idDisabled}
autoFillId={!isExistingZone}
/>
<FormField
control={form.control}
+3 -3
View File
@@ -1,5 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root;
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio };
export { AspectRatio }
+1 -2
View File
@@ -24,8 +24,7 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
+1 -2
View File
@@ -35,8 +35,7 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
+9 -9
View File
@@ -1,8 +1,8 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@@ -11,8 +11,8 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
@@ -22,7 +22,7 @@ const Checkbox = React.forwardRef<
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox };
export { Checkbox }
@@ -99,11 +99,7 @@ export default function AnimatedCircularProgressBar({
</svg>
<span
data-current-value={currentPercent}
className="absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
style={{
animationDuration: "var(--transition-length)",
animationDelay: "var(--delay)",
}}
className="duration-[var(--transition-length)] delay-[var(--delay)] absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
>
{currentPercent}%
</span>

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