Compare commits

...

4 Commits

Author SHA1 Message Date
Josh Hawkins
c5fec3271f
Improve matching go2rtc stream names with cameras (#20586)
* improve matching go2rtc stream names with cameras

* fix unrelated lint issue
2025-10-20 10:33:02 -05:00
Josh Hawkins
0743cb57c2
fix birdseye and empty card (#20582) 2025-10-20 07:03:22 -06:00
Josh Hawkins
4319118e94
enforce at least one letter in zone names (#20561) 2025-10-19 05:21:15 -06:00
Francesco Durighetto
4c689dde8e
Add optional idle heartbeat for Birdseye (#20453)
* Add optional idle heartbeat for Birdseye (periodic frame emission when idle)

birdseye: add optional idle heartbeat and FFmpeg tuning envs (default off)

This adds an optional configuration field `birdseye.idle_heartbeat_fps` to
enable a lightweight idle heartbeat mechanism in Birdseye. When set to a value
greater than 0, Birdseye periodically re-sends the last composed frame during
idle periods (no motion or active updates).

This helps downstream consumers such as go2rtc, Alexa, or Scrypted to attach
faster and maintain a low-latency RTSP stream when the system is idle.

Key details:
- Config-based (`birdseye.idle_heartbeat_fps`), default `0` (disabled).
- Uses existing Birdseye rendering pipeline; minimal performance impact.
- Does not alter behavior when unset.

Documentation: added tip section in docs/configuration/restream.md.

* Update docs/docs/configuration/restream.md

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>

* Update docs/docs/configuration/reference.md

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>

* Refactors Birdseye idle frame broadcasting

Simplifies the idle frame broadcasting logic by removing the dedicated thread.

The idle frame is now resent directly within the main loop,
improving efficiency and reducing complexity.  Also, limits the idle
heartbeat FPS to a maximum of 10 since the framebuffer is limited to 10 anyway

* ruff fix

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
Co-authored-by: Francesco Durighetto <francesco.durighetto@subbyx.com>
Co-authored-by: duri <duri@homelabubuntu.durihome.unifi>
2025-10-19 05:20:36 -06:00
11 changed files with 72 additions and 31 deletions

View File

@ -240,6 +240,8 @@ birdseye:
scaling_factor: 2.0
# Optional: Maximum number of cameras to show at one time, showing the most recent (default: show all cameras)
max_cameras: 1
# Optional: Frames-per-second to re-send the last composed Birdseye frame when idle (no motion or active updates). (default: shown below)
idle_heartbeat_fps: 0.0
# Optional: ffmpeg configuration
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets

View File

@ -24,6 +24,11 @@ birdseye:
restream: True
```
:::tip
To improve connection speed when using Birdseye via restream you can enable a small idle heartbeat by setting `birdseye.idle_heartbeat_fps` to a low value (e.g. `12`). This makes Frigate periodically push the last frame even when no motion is detected, reducing initial connection latency.
:::
### Securing Restream With Authentication
The go2rtc restream can be secured with RTSP based username / password authentication. Ex:

View File

@ -55,6 +55,12 @@ class BirdseyeConfig(FrigateBaseModel):
layout: BirdseyeLayoutConfig = Field(
default_factory=BirdseyeLayoutConfig, title="Birdseye Layout Config"
)
idle_heartbeat_fps: float = Field(
default=0.0,
ge=0.0,
le=10.0,
title="Idle heartbeat FPS (0 disables, max 10)",
)
# uses BaseModel because some global attributes are not available at the camera level

View File

@ -9,6 +9,7 @@ import os
import queue
import subprocess as sp
import threading
import time
import traceback
from typing import Any, Optional
@ -791,6 +792,10 @@ class Birdseye:
self.frame_manager = SharedMemoryFrameManager()
self.stop_event = stop_event
self.requestor = InterProcessRequestor()
self.idle_fps: float = self.config.birdseye.idle_heartbeat_fps
self._idle_interval: Optional[float] = (
(1.0 / self.idle_fps) if self.idle_fps > 0 else None
)
if config.birdseye.restream:
self.birdseye_buffer = self.frame_manager.create(
@ -848,6 +853,15 @@ class Birdseye:
if frame_layout_changed:
coordinates = self.birdseye_manager.get_camera_coordinates()
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
if self._idle_interval:
now = time.monotonic()
is_idle = len(self.birdseye_manager.camera_layout) == 0
if (
is_idle
and (now - self.birdseye_manager.last_output_time)
>= self._idle_interval
):
self.__send_new_frame()
def stop(self) -> None:
self.converter.join()

View File

@ -175,8 +175,8 @@
"exitEdit": "Exit Editing"
},
"noCameras": {
"title": "No Cameras Set Up",
"description": "Get started by connecting a camera.",
"title": "No Cameras Configured",
"description": "Get started by connecting a camera to Frigate.",
"buttonText": "Add Camera"
}
}

View File

@ -385,7 +385,8 @@
"mustNotBeSameWithCamera": "Zone name must not be the same as camera name.",
"alreadyExists": "A zone with this name already exists for this camera.",
"mustNotContainPeriod": "Zone name must not contain periods.",
"hasIllegalCharacter": "Zone name contains illegal characters."
"hasIllegalCharacter": "Zone name contains illegal characters.",
"mustHaveAtLeastOneLetter": "Zone name must have at least one letter."
}
},
"distance": {
@ -443,7 +444,7 @@
"name": {
"title": "Name",
"inputPlaceHolder": "Enter a name…",
"tips": "Name must be at least 2 characters and must not be the name of a camera or another zone."
"tips": "Name must be at least 2 characters, must have at least one letter, and must not be the name of a camera or another zone."
},
"inertia": {
"title": "Inertia",

View File

@ -1,27 +1,30 @@
import React from "react";
import { Button } from "../ui/button";
import Heading from "../ui/heading";
import { Link } from "react-router-dom";
type EmptyCardProps = {
icon: React.ReactNode;
title: string;
description: string;
buttonText?: string;
link?: string;
};
export function EmptyCard({
icon,
title,
description,
buttonText,
link,
}: EmptyCardProps) {
return (
<div className="flex flex-col items-center gap-2">
{icon}
<Heading as="h4">{title}</Heading>
<div className="text-secondary-foreground">{description}</div>
<div className="mb-3 text-secondary-foreground">{description}</div>
{buttonText?.length && (
<Button size="sm" variant="select">
{buttonText}
<Link to={link ?? "#"}>{buttonText}</Link>
</Button>
)}
</div>

View File

@ -150,7 +150,9 @@ export default function SearchThumbnail({
.filter(
(item) => item !== undefined && !item.includes("-verified"),
)
.map((text) => getTranslatedLabel(text, searchResult.data.type))
.map((text) =>
getTranslatedLabel(text, searchResult.data.type),
)
.sort()
.join(", ")
.replaceAll("-verified", "")}

View File

@ -152,36 +152,38 @@ export default function CameraEditForm({
}))
: defaultValues.ffmpeg.inputs;
// Load go2rtc streams for this camera
const go2rtcStreams = config.go2rtc?.streams || {};
const cameraStreams: Record<string, string[]> = {};
// Find streams that match this camera's name pattern
Object.entries(go2rtcStreams).forEach(([streamName, urls]) => {
if (streamName.startsWith(cameraName) || streamName === cameraName) {
cameraStreams[streamName] = Array.isArray(urls) ? urls : [urls];
}
});
// get candidate stream names for this camera. could be the camera's own name,
// any restream names referenced by this camera, or any keys under live --> streams
const validNames = new Set<string>();
validNames.add(cameraName);
// Also deduce go2rtc streams from restream URLs in camera inputs
camera.ffmpeg?.inputs?.forEach((input, index) => {
// deduce go2rtc stream names from rtsp restream inputs
camera.ffmpeg?.inputs?.forEach((input) => {
// exclude any query strings or trailing slashes from the stream name
const restreamMatch = input.path.match(
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
/^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/,
);
if (restreamMatch) {
const streamName = restreamMatch[1];
// Find the corresponding go2rtc stream
const go2rtcStream = Object.entries(go2rtcStreams).find(
([name]) =>
name === streamName ||
name === `${cameraName}_${index + 1}` ||
name === cameraName,
);
if (go2rtcStream) {
cameraStreams[go2rtcStream[0]] = Array.isArray(go2rtcStream[1])
? go2rtcStream[1]
: [go2rtcStream[1]];
}
validNames.add(streamName);
}
});
// Include live --> streams keys
const liveStreams = camera?.live?.streams;
if (liveStreams) {
Object.keys(liveStreams).forEach((key) => {
validNames.add(key);
});
}
// Map only go2rtc entries that match the collected names
Object.entries(go2rtcStreams).forEach(([name, urls]) => {
if (validNames.has(name)) {
cameraStreams[name] = Array.isArray(urls) ? urls : [urls];
}
});

View File

@ -149,6 +149,11 @@ export default function ZoneEditPane({
)
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
})
.refine((value: string) => /[a-zA-Z]/.test(value), {
message: t(
"masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter",
),
}),
inertia: z.coerce
.number()

View File

@ -354,7 +354,7 @@ export default function LiveDashboardView({
onSaveMuting(true);
};
if (cameras.length == 0) {
if (cameras.length == 0 && !includeBirdseye) {
return <NoCameraView />;
}
@ -625,6 +625,7 @@ function NoCameraView() {
title={t("noCameras.title")}
description={t("noCameras.description")}
buttonText={t("noCameras.buttonText")}
link="/settings?page=cameraManagement"
/>
</div>
);