mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-07 23:32:48 +03:00
Improve Reolink camera wizard detection (#24570)
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Assemble and push default build (push) Blocked by required conditions
* try http-flv first for reolink cameras above 5MP in the camera wizard The wizard picked RTSP for every Reolink above 5MP and then warned that RTSP isn't recommended, telling users to enable HTTP and restart, which just produced the same result. It now tries the http-flv stream first for those cameras and falls back to RTSP when the stream doesn't probe, since only the newer models serve H.265 over enhanced FLV. The RTSP warning is skipped when the camera's resolution made RTSP a valid choice. http-flv no longer needs go2rtc's ffmpeg module, so the compatibility mode warning for Reolink HTTP streams is removed and the docs drop the `ffmpeg:` prefix from http-flv sources that have no transcoding modifiers. * add reolink tests * keep using ffmpeg module
This commit is contained in:
@@ -169,10 +169,10 @@ The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) i
|
|||||||
|
|
||||||
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
|
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
|
||||||
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
|
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
|
||||||
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower, or an RTSP stream for higher resolution cameras.
|
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower. For higher resolution cameras, it tries the http-flv stream first and falls back to RTSP when the camera does not support it.
|
||||||
4. In the validation step, enable **Use stream compatibility mode** for http-flv streams when the wizard recommends it.
|
4. The wizard turns on **Use stream compatibility mode** for the http-flv stream it selects. Enable it for any other http-flv stream you add, such as the sub stream.
|
||||||
|
|
||||||
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras where http-flv is recommended, the wizard will show a warning in the validation step.
|
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras 5MP and lower, where http-flv is recommended, the wizard will show a warning in the validation step.
|
||||||
|
|
||||||
The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below.
|
The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Add-camera wizard - Reolink stream selection with the brand template.
|
||||||
|
*
|
||||||
|
* The wizard asks the camera for its resolution, then probes http-flv first
|
||||||
|
* above 5MP and falls back to RTSP. The Step 4 RTSP warning is only for
|
||||||
|
* cameras that should be on http-flv. An http-flv stream the wizard selects
|
||||||
|
* is registered with go2rtc through the ffmpeg module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from "../../fixtures/frigate-test";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const FLV_PATH = "channel0_main.bcs";
|
||||||
|
const RTSP_PATH = "Preview_01_main";
|
||||||
|
const RTSP_WARNING = "Reolink RTSP is not recommended";
|
||||||
|
const HTTP_WARNING = "Reolink HTTP streams should use FFmpeg";
|
||||||
|
|
||||||
|
const FFPROBE_OK = [
|
||||||
|
{
|
||||||
|
return_code: 0,
|
||||||
|
stderr: [],
|
||||||
|
stdout: {
|
||||||
|
streams: [
|
||||||
|
{
|
||||||
|
codec_type: "video",
|
||||||
|
codec_name: "hevc",
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
avg_frame_rate: "15/1",
|
||||||
|
},
|
||||||
|
{ codec_type: "audio", codec_name: "aac" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FFPROBE_FAILED = [
|
||||||
|
{ return_code: 1, stderr: ["probe failed"], stdout: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock the camera's answers and drive the wizard to Step 3. Returns the
|
||||||
|
* dialog and the stream paths the wizard probed, in order.
|
||||||
|
*/
|
||||||
|
async function gotoStep3(
|
||||||
|
page: Page,
|
||||||
|
{ protocol, flvProbes }: { protocol: string | null; flvProbes: boolean },
|
||||||
|
) {
|
||||||
|
const probed: string[] = [];
|
||||||
|
|
||||||
|
await page.route("**/api/reolink/detect**", (route) =>
|
||||||
|
route.fulfill({ json: { success: protocol !== null, protocol } }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/ffprobe**", (route) => {
|
||||||
|
const paths = new URL(route.request().url()).searchParams.get("paths");
|
||||||
|
const isFlv = !!paths?.includes(FLV_PATH);
|
||||||
|
probed.push(isFlv ? FLV_PATH : RTSP_PATH);
|
||||||
|
return route.fulfill({
|
||||||
|
json: isFlv && !flvProbes ? FFPROBE_FAILED : FFPROBE_OK,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/ffprobe/snapshot**", (route) =>
|
||||||
|
route.fulfill({ status: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /Add New Camera/i }).click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByPlaceholder(/front_door/i).fill("reolink_test_camera");
|
||||||
|
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
|
||||||
|
await dialog.getByPlaceholder("Optional").first().fill("admin");
|
||||||
|
await dialog.getByPlaceholder("Optional").last().fill("pw");
|
||||||
|
await dialog.getByText("Manual selection").click();
|
||||||
|
await dialog.getByRole("combobox").click();
|
||||||
|
await page.getByRole("option", { name: "Reolink" }).click();
|
||||||
|
await dialog.getByRole("button", { name: /^Continue$/i }).click();
|
||||||
|
|
||||||
|
// Step 2 tests the connection on its own, then offers Continue
|
||||||
|
const next = dialog.getByRole("button", { name: /^Continue$/i });
|
||||||
|
await expect(next).toBeEnabled({ timeout: 10_000 });
|
||||||
|
await next.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("button", { name: /Add Another Stream/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
return { dialog, probed };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Camera wizard Reolink stream selection @medium @mobile", () => {
|
||||||
|
test.beforeEach(async ({ frigateApp }) => {
|
||||||
|
// not in the default mock; unmocked it 500s and trips the error collector
|
||||||
|
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
|
||||||
|
route.fulfill({ json: {} }),
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/settings?page=cameraManagement");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("above 5MP keeps http-flv when it probes", async ({ frigateApp }) => {
|
||||||
|
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||||
|
protocol: "rtsp",
|
||||||
|
flvProbes: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probed).toEqual([FLV_PATH]);
|
||||||
|
await expect(dialog.locator(`input[value*="${FLV_PATH}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
const registered: string[] = [];
|
||||||
|
await frigateApp.page.route("**/api/go2rtc/streams/**", (route) => {
|
||||||
|
const src = new URL(route.request().url()).searchParams.get("src");
|
||||||
|
if (src) registered.push(src);
|
||||||
|
return route.fulfill({ json: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("button", { name: /Save New Camera/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect.poll(() => registered[0]).toMatch(/^ffmpeg:http:\/\//);
|
||||||
|
await expect(dialog.getByText(HTTP_WARNING)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("above 5MP falls back to RTSP without a warning", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||||
|
protocol: "rtsp",
|
||||||
|
flvProbes: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probed).toEqual([FLV_PATH, RTSP_PATH]);
|
||||||
|
await expect(dialog.locator(`input[value*="${RTSP_PATH}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("button", { name: /Save New Camera/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(dialog.getByText(RTSP_WARNING)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed detection uses RTSP and warns", async ({ frigateApp }) => {
|
||||||
|
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||||
|
protocol: null,
|
||||||
|
flvProbes: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probed).toEqual([RTSP_PATH]);
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||||
|
await expect(dialog.getByText(RTSP_WARNING)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -222,6 +222,7 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
wizardData.username,
|
wizardData.username,
|
||||||
wizardData.password,
|
wizardData.password,
|
||||||
);
|
);
|
||||||
|
update.reolinkProtocol = protocol;
|
||||||
if (protocol === "http-flv") {
|
if (protocol === "http-flv") {
|
||||||
update.brandTemplate = "reolink";
|
update.brandTemplate = "reolink";
|
||||||
}
|
}
|
||||||
@@ -294,73 +295,72 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
[probeUri],
|
[probeUri],
|
||||||
);
|
);
|
||||||
|
|
||||||
const generateDynamicStreamUrl = useCallback(
|
const generateDynamicStreamUrls = useCallback(
|
||||||
async (data: Partial<WizardFormData>): Promise<string | null> => {
|
async (data: Partial<WizardFormData>): Promise<string[]> => {
|
||||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||||
if (!brand || !data.host) return null;
|
const host = data.host;
|
||||||
|
if (!brand || !host) return [];
|
||||||
|
|
||||||
let protocol = undefined;
|
let protocol = undefined;
|
||||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||||
try {
|
try {
|
||||||
protocol = await detectReolinkCamera(
|
protocol = await detectReolinkCamera(
|
||||||
data.host,
|
host,
|
||||||
data.username,
|
data.username,
|
||||||
data.password,
|
data.password,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
|
onUpdate({ reolinkProtocol: protocol });
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocolKey = protocol || "rtsp";
|
// Only some Reolink cameras above 5MP serve http-flv, so RTSP is the
|
||||||
|
// fallback when the http-flv stream does not probe.
|
||||||
|
const protocolKeys =
|
||||||
|
protocol === "rtsp" ? ["http-flv", "rtsp"] : [protocol || "rtsp"];
|
||||||
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||||
|
|
||||||
if (Object.keys(templates).includes(protocolKey)) {
|
return protocolKeys
|
||||||
const template =
|
.filter((key) => key in templates)
|
||||||
templates[protocolKey as keyof typeof brand.dynamicTemplates];
|
.map((key) =>
|
||||||
return template
|
templates[key]
|
||||||
.replace("{username}", data.username || "")
|
.replace("{username}", data.username || "")
|
||||||
.replace("{password}", data.password || "")
|
.replace("{password}", data.password || "")
|
||||||
.replace("{host}", data.host);
|
.replace("{host}", host),
|
||||||
}
|
);
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
[],
|
[onUpdate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const generateStreamUrl = useCallback(
|
const generateStreamUrls = useCallback(
|
||||||
async (data: Partial<WizardFormData>): Promise<string> => {
|
async (data: Partial<WizardFormData>): Promise<string[]> => {
|
||||||
if (data.brandTemplate === "other") {
|
if (data.brandTemplate === "other") {
|
||||||
return data.customUrl || "";
|
return data.customUrl ? [data.customUrl] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||||
if (!brand || !data.host) return "";
|
if (!brand || !data.host) return [];
|
||||||
|
|
||||||
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||||
const dynamicUrl = await generateDynamicStreamUrl(data);
|
return generateDynamicStreamUrls(data);
|
||||||
|
|
||||||
if (dynamicUrl) {
|
|
||||||
return dynamicUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return brand.template
|
return [
|
||||||
.replace("{username}", data.username || "")
|
brand.template
|
||||||
.replace("{password}", data.password || "")
|
.replace("{username}", data.username || "")
|
||||||
.replace("{host}", data.host);
|
.replace("{password}", data.password || "")
|
||||||
|
.replace("{host}", data.host),
|
||||||
|
];
|
||||||
},
|
},
|
||||||
[generateDynamicStreamUrl],
|
[generateDynamicStreamUrls],
|
||||||
);
|
);
|
||||||
|
|
||||||
const testConnection = useCallback(
|
const testConnection = useCallback(
|
||||||
async (showToast = true) => {
|
async (showToast = true) => {
|
||||||
const streamUrl = await generateStreamUrl(wizardData);
|
const streamUrls = await generateStreamUrls(wizardData);
|
||||||
|
|
||||||
if (!streamUrl) {
|
if (streamUrls.length === 0) {
|
||||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -370,8 +370,18 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
let streamUrl = streamUrls[0];
|
||||||
const result = await probeUri(streamUrl, true, setTestStatus);
|
let result: TestResult | undefined;
|
||||||
|
|
||||||
|
for (const url of streamUrls) {
|
||||||
|
streamUrl = url;
|
||||||
|
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||||
|
result = await probeUri(url, true, setTestStatus);
|
||||||
|
|
||||||
|
if (result.success && result.resolution) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
setTestResult(result);
|
setTestResult(result);
|
||||||
@@ -383,6 +393,9 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
url: streamUrl,
|
url: streamUrl,
|
||||||
roles: ["detect"] as StreamRole[],
|
roles: ["detect"] as StreamRole[],
|
||||||
testResult: result,
|
testResult: result,
|
||||||
|
useFfmpeg:
|
||||||
|
wizardData.brandTemplate === "reolink" &&
|
||||||
|
streamUrl.startsWith("http://"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -434,7 +447,7 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
setTestStatus("");
|
setTestStatus("");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[wizardData, generateStreamUrl, t, onUpdate, probeUri],
|
[wizardData, generateStreamUrls, t, onUpdate, probeUri],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContinue = useCallback(() => {
|
const handleContinue = useCallback(() => {
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ function StreamIssues({
|
|||||||
url: stream.url,
|
url: stream.url,
|
||||||
roles: stream.roles,
|
roles: stream.roles,
|
||||||
brand: wizardData.brandTemplate,
|
brand: wizardData.brandTemplate,
|
||||||
|
reolinkProtocol: wizardData.reolinkProtocol,
|
||||||
useFfmpeg: stream.useFfmpeg,
|
useFfmpeg: stream.useFfmpeg,
|
||||||
restream: stream.restream,
|
restream: stream.restream,
|
||||||
testResult: stream.testResult,
|
testResult: stream.testResult,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export type WizardFormData = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
brandTemplate?: CameraBrand;
|
brandTemplate?: CameraBrand;
|
||||||
|
reolinkProtocol?: "http-flv" | "rtsp" | null; // null when detection failed
|
||||||
customUrl?: string;
|
customUrl?: string;
|
||||||
streams?: StreamConfig[];
|
streams?: StreamConfig[];
|
||||||
probeMode?: boolean; // true for probe, false for manual
|
probeMode?: boolean; // true for probe, false for manual
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type StreamIssueInput = {
|
|||||||
url: string;
|
url: string;
|
||||||
roles: StreamRole[];
|
roles: StreamRole[];
|
||||||
brand?: CameraBrand;
|
brand?: CameraBrand;
|
||||||
|
reolinkProtocol?: "http-flv" | "rtsp" | null;
|
||||||
useFfmpeg?: boolean;
|
useFfmpeg?: boolean;
|
||||||
restream?: boolean;
|
restream?: boolean;
|
||||||
testResult?: TestResult;
|
testResult?: TestResult;
|
||||||
@@ -96,7 +97,7 @@ export function getStreamIssues(
|
|||||||
|
|
||||||
if (input.brand === "reolink") {
|
if (input.brand === "reolink") {
|
||||||
const streamUrl = input.url.toLowerCase();
|
const streamUrl = input.url.toLowerCase();
|
||||||
if (streamUrl.startsWith("rtsp://")) {
|
if (streamUrl.startsWith("rtsp://") && input.reolinkProtocol !== "rtsp") {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
rule: "reolink-rtsp",
|
rule: "reolink-rtsp",
|
||||||
|
|||||||
Reference in New Issue
Block a user