mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-26 00:14:41 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0300864ca | ||
|
|
f236b1a0c0 | ||
|
|
22e478cd82 |
@@ -2,10 +2,11 @@
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from multiprocessing import Queue
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
@@ -255,7 +256,9 @@ def process_frames(
|
||||
region_grid: list[list[dict[str, Any]]],
|
||||
exit_on_empty: bool = False,
|
||||
):
|
||||
next_region_update = get_tomorrow_at_time(2)
|
||||
# cameras share one dispatcher, so stagger the daily grid rebuilds
|
||||
region_update_offset = timedelta(seconds=random.randrange(3600))
|
||||
next_region_update = get_tomorrow_at_time(2) + region_update_offset
|
||||
config_subscriber = CameraConfigUpdateSubscriber(
|
||||
None,
|
||||
{camera_config.name: camera_config},
|
||||
@@ -344,7 +347,7 @@ def process_frames(
|
||||
if updated_grid:
|
||||
region_grid = updated_grid
|
||||
|
||||
next_region_update = get_tomorrow_at_time(2)
|
||||
next_region_update = get_tomorrow_at_time(2) + region_update_offset
|
||||
|
||||
try:
|
||||
if exit_on_empty:
|
||||
|
||||
@@ -474,10 +474,10 @@ class CameraWatchdog(threading.Thread):
|
||||
if not self.capture_thread.is_alive():
|
||||
self.detect_status.send("offline", now)
|
||||
self.camera_fps.value = 0
|
||||
self.logger.error(
|
||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
||||
)
|
||||
if can_restart:
|
||||
self.logger.error(
|
||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
||||
)
|
||||
self.reset_capture_thread(terminate=False)
|
||||
last_restart_time = now
|
||||
elif self.camera_fps.value >= (self.config.detect.fps + 10):
|
||||
@@ -498,10 +498,10 @@ class CameraWatchdog(threading.Thread):
|
||||
elif now - self.capture_thread.current_frame.value > 20:
|
||||
self.detect_status.send("offline", now)
|
||||
self.camera_fps.value = 0
|
||||
self.logger.info(
|
||||
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
||||
)
|
||||
if can_restart:
|
||||
self.logger.info(
|
||||
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
||||
)
|
||||
self.reset_capture_thread()
|
||||
last_restart_time = now
|
||||
else:
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Add-camera wizard Step 1 validation -- MEDIUM tier.
|
||||
*
|
||||
* Continue validates every field, so an invalid ONVIF port or a non-RTSP
|
||||
* custom URL must show its translated message and keep the wizard on Step 1.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const PORT_ERROR = "ONVIF port must be a whole number from 1 to 65535";
|
||||
|
||||
async function openWizard(page: Page) {
|
||||
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("step1_camera");
|
||||
return dialog;
|
||||
}
|
||||
|
||||
test.describe("Camera wizard step 1 validation @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.page.route("**/api/onvif/probe**", (route) =>
|
||||
route.fulfill({ json: { success: false } }),
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraManagement");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("an invalid ONVIF port blocks Continue with a translated message", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const dialog = await openWizard(frigateApp.page);
|
||||
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
|
||||
const port = dialog.getByPlaceholder("80", { exact: true });
|
||||
const next = dialog.getByRole("button", { name: /^Continue$/i });
|
||||
|
||||
for (const value of ["abc", "0", "70000", "80.5"]) {
|
||||
await port.fill(value);
|
||||
await next.click();
|
||||
await expect(dialog.getByText(PORT_ERROR)).toBeVisible();
|
||||
await expect(port).toBeVisible();
|
||||
}
|
||||
|
||||
await port.fill("8080");
|
||||
await next.click();
|
||||
await expect(port).toBeHidden();
|
||||
await expect(dialog.getByText(PORT_ERROR)).toBeHidden();
|
||||
});
|
||||
|
||||
test("a custom URL must use RTSP", async ({ frigateApp }) => {
|
||||
const dialog = await openWizard(frigateApp.page);
|
||||
await dialog.getByText("Manual selection", { exact: true }).click();
|
||||
await dialog.getByRole("combobox").click();
|
||||
await frigateApp.page.getByRole("option", { name: "Other" }).click();
|
||||
const url = dialog.getByPlaceholder(
|
||||
"rtsp://username:password@host:port/path",
|
||||
);
|
||||
const next = dialog.getByRole("button", { name: /^Continue$/i });
|
||||
|
||||
await url.fill("http://192.168.1.100/stream");
|
||||
await next.click();
|
||||
await expect(
|
||||
dialog.getByText(/Custom URLs must begin with "rtsp:\/\/"/),
|
||||
).toBeVisible();
|
||||
|
||||
await url.fill("rtsp://192.168.1.100/stream");
|
||||
await next.click();
|
||||
await expect(url).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -121,14 +121,9 @@ async function confirmImport(page: Page) {
|
||||
// route change, so #pageRoot is already present and waiting for it
|
||||
// alone can race the navigation. Wait for the "load" event first so
|
||||
// the evaluate() calls below run against the post-reload document.
|
||||
// scoped to the dialog: until it opens, a page-wide "Import" match is the
|
||||
// Import Settings button, which only reopens the file chooser
|
||||
await Promise.all([
|
||||
page.waitForEvent("load"),
|
||||
page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Import", exact: true })
|
||||
.click(),
|
||||
page.getByRole("button", { name: "Import" }).click(),
|
||||
]);
|
||||
await page.waitForSelector("#pageRoot");
|
||||
}
|
||||
@@ -275,9 +270,6 @@ test.describe("UI settings import/export @medium", () => {
|
||||
// naturalAspectLayout preference must not override that.
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
|
||||
// the view reads the setting once on mount, so load it again after the
|
||||
// write
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
await chooseImportFile(
|
||||
frigateApp.page,
|
||||
|
||||
@@ -248,37 +248,4 @@ test.describe("zone rename and delete @medium @mobile", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("inertia below 1 blocks saving and a valid value saves as a number", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||
const inertia = frigateApp.page.getByRole("textbox", {
|
||||
name: "Inertia",
|
||||
exact: true,
|
||||
});
|
||||
const save = frigateApp.page.getByRole("button", { name: "Save" });
|
||||
|
||||
await inertia.fill("0");
|
||||
await expect(
|
||||
frigateApp.page.getByText("Inertia must be above 0."),
|
||||
).toBeVisible();
|
||||
await expect(save).toBeDisabled();
|
||||
|
||||
await inertia.fill("5");
|
||||
await expect(
|
||||
frigateApp.page.getByText("Inertia must be above 0."),
|
||||
).toBeHidden();
|
||||
await save.click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(requests[0].body).toMatchObject({
|
||||
config_data: {
|
||||
cameras: { front_door: { zones: { driveway: { inertia: 5 } } } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+32
-4
@@ -83,7 +83,7 @@
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.53.6",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^4.6.7"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -3131,6 +3131,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3148,6 +3151,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3165,6 +3171,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3182,6 +3191,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3199,6 +3211,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3216,6 +3231,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5097,6 +5115,16 @@
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks/node_modules/zod": {
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||
@@ -10627,9 +10655,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.53.6",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^4.6.7"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
@@ -367,8 +367,7 @@
|
||||
"nameLength": "Camera name must be 64 characters or less",
|
||||
"invalidCharacters": "Camera name contains invalid characters",
|
||||
"nameExists": "Camera name already exists",
|
||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams.",
|
||||
"onvifPortInvalid": "ONVIF port must be a whole number from 1 to 65535"
|
||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams."
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function ClassificationModelEditDialog({
|
||||
const sharedFields = {
|
||||
enabled: z.boolean(),
|
||||
saveAttempts: z.coerce
|
||||
.number<number>({ message: t("edit.errors.saveAttemptsInvalid") })
|
||||
.number({ message: t("edit.errors.saveAttemptsInvalid") })
|
||||
.int(t("edit.errors.saveAttemptsInvalid"))
|
||||
.min(0, t("edit.errors.saveAttemptsInvalid")),
|
||||
};
|
||||
@@ -136,7 +136,7 @@ export default function ClassificationModelEditDialog({
|
||||
(val) =>
|
||||
val === "" || val === null || val === undefined ? undefined : val,
|
||||
z.coerce
|
||||
.number<number>({ message: t("edit.errors.intervalInvalid") })
|
||||
.number({ message: t("edit.errors.intervalInvalid") })
|
||||
.int(t("edit.errors.intervalInvalid"))
|
||||
.positive(t("edit.errors.intervalInvalid"))
|
||||
.optional(),
|
||||
@@ -171,6 +171,7 @@ export default function ClassificationModelEditDialog({
|
||||
resolver: zodResolver(
|
||||
formSchema as z.ZodType<
|
||||
ObjectFormData | StateFormData,
|
||||
z.ZodTypeDef,
|
||||
ObjectFormData | StateFormData
|
||||
>,
|
||||
),
|
||||
|
||||
@@ -332,7 +332,9 @@ function PreviewVideoPlayer({
|
||||
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
||||
</div>
|
||||
)}
|
||||
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
|
||||
{firstLoad && (
|
||||
<Skeleton className="absolute aspect-video size-full rounded-lg md:rounded-2xl" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -555,7 +557,9 @@ function PreviewFramesPlayer({
|
||||
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
||||
</div>
|
||||
)}
|
||||
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
|
||||
{firstLoad && (
|
||||
<Skeleton className="absolute aspect-video size-full rounded-lg md:rounded-2xl" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,13 +200,13 @@ export default function ZoneEditPane({
|
||||
),
|
||||
enabled: z.boolean().default(true),
|
||||
inertia: z.coerce
|
||||
.number<number>()
|
||||
.number()
|
||||
.min(1, {
|
||||
message: t("masksAndZones.form.inertia.error.mustBeAboveZero"),
|
||||
})
|
||||
.or(z.literal("")),
|
||||
loitering_time: z.coerce
|
||||
.number<number>()
|
||||
.number()
|
||||
.min(0, {
|
||||
message: t(
|
||||
"masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero",
|
||||
@@ -222,35 +222,35 @@ export default function ZoneEditPane({
|
||||
review_detections: z.boolean().default(false).optional(),
|
||||
speedEstimation: z.boolean().default(false),
|
||||
lineA: z.coerce
|
||||
.number<number>()
|
||||
.number()
|
||||
.min(0.1, {
|
||||
message: t("masksAndZones.form.distance.error.text"),
|
||||
})
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
lineB: z.coerce
|
||||
.number<number>()
|
||||
.number()
|
||||
.min(0.1, {
|
||||
message: t("masksAndZones.form.distance.error.text"),
|
||||
})
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
lineC: z.coerce
|
||||
.number<number>()
|
||||
.number()
|
||||
.min(0.1, {
|
||||
message: t("masksAndZones.form.distance.error.text"),
|
||||
})
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
lineD: z.coerce
|
||||
.number<number>()
|
||||
.number()
|
||||
.min(0.1, {
|
||||
message: t("masksAndZones.form.distance.error.text"),
|
||||
})
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
speed_threshold: z.coerce
|
||||
.number<number>()
|
||||
.number()
|
||||
.min(0.1, {
|
||||
message: t("masksAndZones.form.speed.error.mustBeGreaterOrEqualTo"),
|
||||
})
|
||||
|
||||
@@ -81,14 +81,7 @@ export default function Step1NameCamera({
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||
onvifPort: z.coerce
|
||||
.number<number>({
|
||||
message: t("cameraWizard.step1.errors.onvifPortInvalid"),
|
||||
})
|
||||
.int(t("cameraWizard.step1.errors.onvifPortInvalid"))
|
||||
.min(1, t("cameraWizard.step1.errors.onvifPortInvalid"))
|
||||
.max(65535, t("cameraWizard.step1.errors.onvifPortInvalid"))
|
||||
.optional(),
|
||||
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
useDigestAuth: z.boolean().optional(),
|
||||
customUrl: z
|
||||
.string()
|
||||
|
||||
Reference in New Issue
Block a user