mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 23:02:49 +03:00
Compare commits
3
Commits
dev
...
improve-reolink
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8935ec1b2 | ||
|
|
3180d63df1 | ||
|
|
fa1c41974f |
@@ -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