mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-05 05:10:33 +03:00
Miscellaneous fixes (#24878)
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 / 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 / Assemble and push default 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 / 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 / Assemble and push default build (push) Blocked by required conditions
* don't calibrate or skip capability checks when autotracking is enabled at runtime Enabling autotracking from the UI on a camera that had been disabled for missing FOV relative movement or MoveStatus skipped both checks because ONVIF was already initialized, then ran calibration on the object processing thread and hung waiting for a motor stop that never came. Setup now runs the capability checks every time, only calibrates at startup, stops tracking the triggering object when setup disables the camera, and publishes the OFF state so the toggle reflects it. * only show settings reset buttons when the reset can succeed Reset to Global and Reset to Default delete the section from config.yml, and the delete raised a KeyError (HTTP 500) when the key wasn't in the file. Camera live sections always read as overridden because the backend fills in live.streams per camera, and the global Reset to Default button showed for every section, including ones the user never wrote. Global reset now only shows when the section differs from its schema defaults, ignoring fields the backend resolves at runtime (ffmpeg and record export hwaccel_args, audio filters). Camera live overrides skip streams, ffmpeg overrides skip inputs, and reset is hidden for camera ffmpeg and detection models, where deleting the section can't produce a valid config. Birdseye modes uses a plain default so the schema carries it. * Added an e2e spec covering reset visibility for default and customized global sections, the runtime-resolved fields, camera live overrides, and camera ffmpeg
This commit is contained in:
@@ -57,11 +57,6 @@ def birdseye_modes_to_mqtt_payload(modes: list[BirdseyeModeEnum]) -> str:
|
||||
return payload or MQTT_NO_MODES
|
||||
|
||||
|
||||
def default_birdseye_modes() -> list[BirdseyeModeEnum]:
|
||||
"""Return the default Birdseye activity modes."""
|
||||
return [BirdseyeModeEnum.all_objects]
|
||||
|
||||
|
||||
class BirdseyeLayoutConfig(FrigateBaseModel):
|
||||
scaling_factor: float = Field(
|
||||
default=2.0,
|
||||
@@ -84,7 +79,7 @@ class BirdseyeConfig(FrigateBaseModel):
|
||||
description="Enable or disable the Birdseye view feature.",
|
||||
)
|
||||
modes: list[BirdseyeModeEnum] = Field(
|
||||
default_factory=default_birdseye_modes,
|
||||
default=[BirdseyeModeEnum.all_objects],
|
||||
title="Activity types",
|
||||
description="Activity types that include cameras in Birdseye.",
|
||||
)
|
||||
@@ -139,7 +134,7 @@ class BirdseyeCameraConfig(BaseModel):
|
||||
description="Enable or disable the Birdseye view feature.",
|
||||
)
|
||||
modes: list[BirdseyeModeEnum] = Field(
|
||||
default_factory=default_birdseye_modes,
|
||||
default=[BirdseyeModeEnum.all_objects],
|
||||
title="Activity types",
|
||||
description="Activity types that include cameras in Birdseye.",
|
||||
)
|
||||
|
||||
+31
-25
@@ -233,7 +233,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
and camera_config.onvif.autotracking.enabled_in_config
|
||||
):
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._autotracker_setup(camera_config, camera), self.onvif.loop
|
||||
self._autotracker_setup(camera_config, camera, startup=True),
|
||||
self.onvif.loop,
|
||||
)
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
@@ -261,7 +262,9 @@ class PtzAutoTracker(threading.Thread):
|
||||
self.config_subscriber.stop()
|
||||
logger.info("Exiting autotracker...")
|
||||
|
||||
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
||||
async def _autotracker_setup(
|
||||
self, camera_config: CameraConfig, camera: str, startup: bool = False
|
||||
):
|
||||
logger.debug(f"{camera}: Autotracker init")
|
||||
|
||||
self.tracked_object[camera] = None
|
||||
@@ -288,35 +291,34 @@ class PtzAutoTracker(threading.Thread):
|
||||
self._disable(camera, "Unable to initialize onvif")
|
||||
return
|
||||
|
||||
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
||||
self._disable(camera, "FOV relative movement not supported")
|
||||
return
|
||||
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
||||
self._disable(camera, "FOV relative movement not supported")
|
||||
return
|
||||
|
||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||
|
||||
if str(move_status_supported).lower() != "true":
|
||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
||||
return
|
||||
if str(move_status_supported).lower() != "true":
|
||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
||||
return
|
||||
|
||||
if self.onvif.cams[camera]["init"]:
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
# movement queue with asyncio on OnvifController loop
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._process_move_queue(camera), self.onvif.loop
|
||||
)
|
||||
# movement queue with asyncio on OnvifController loop
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._process_move_queue(camera), self.onvif.loop
|
||||
)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
(
|
||||
self.ptz_metrics[camera].min_zoom.value,
|
||||
self.ptz_metrics[camera].max_zoom.value,
|
||||
self.intercept[camera],
|
||||
*self.move_coefficients[camera],
|
||||
self.zoom_time[camera],
|
||||
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
(
|
||||
self.ptz_metrics[camera].min_zoom.value,
|
||||
self.ptz_metrics[camera].max_zoom.value,
|
||||
self.intercept[camera],
|
||||
*self.move_coefficients[camera],
|
||||
self.zoom_time[camera],
|
||||
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
||||
|
||||
if camera_config.onvif.autotracking.calibrate_on_startup:
|
||||
await self._calibrate_camera(camera)
|
||||
if startup and camera_config.onvif.autotracking.calibrate_on_startup:
|
||||
await self._calibrate_camera(camera)
|
||||
|
||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
|
||||
self.autotracker_init[camera] = True
|
||||
@@ -331,6 +333,7 @@ class PtzAutoTracker(threading.Thread):
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
|
||||
autotracking_config,
|
||||
)
|
||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/state", "OFF", retain=True)
|
||||
|
||||
def _reset_tracked_object_metrics(self, camera: str) -> None:
|
||||
self.tracked_object_metrics[camera] = {}
|
||||
@@ -1269,6 +1272,9 @@ class PtzAutoTracker(threading.Thread):
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
|
||||
if not camera_config.onvif.autotracking.enabled:
|
||||
return
|
||||
|
||||
if self.calibrating[camera]:
|
||||
logger.debug(f"{camera}: Calibrating camera")
|
||||
return
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Section reset button visibility -- MEDIUM tier.
|
||||
*
|
||||
* Reset deletes the section from config.yml, which fails when the key isn't
|
||||
* in the file. The button only shows when the section's values must have
|
||||
* come from the YAML.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect, type FrigateApp } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
configFactory,
|
||||
type DeepPartial,
|
||||
} 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",
|
||||
),
|
||||
);
|
||||
|
||||
async function openSettings(
|
||||
app: FrigateApp,
|
||||
url: string,
|
||||
overrides: DeepPartial<ReturnType<typeof configFactory>> = {},
|
||||
) {
|
||||
const { page } = app;
|
||||
const config = configFactory(overrides);
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: { cameras: {}, go2rtc: { streams: {} } } }),
|
||||
);
|
||||
|
||||
await app.goto(url);
|
||||
}
|
||||
|
||||
const resetToDefault = (page: Page) =>
|
||||
page.getByRole("button", { name: "Reset to Default" });
|
||||
const resetToGlobal = (page: Page) =>
|
||||
page.getByRole("button", { name: "Reset to Global" });
|
||||
|
||||
test.describe("section reset visibility @medium", () => {
|
||||
test("global section at its defaults hides reset", async ({ frigateApp }) => {
|
||||
await openSettings(frigateApp, "/settings?page=globalRecording");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText("Retention", { exact: false }).first(),
|
||||
).toBeVisible();
|
||||
await expect(resetToDefault(frigateApp.page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("global section with a changed value shows reset", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await openSettings(frigateApp, "/settings?page=globalRecording", {
|
||||
record: { expire_interval: 30 },
|
||||
});
|
||||
|
||||
await expect(resetToDefault(frigateApp.page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("global ffmpeg with only resolved hwaccel args hides reset", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await openSettings(frigateApp, "/settings?page=globalFfmpeg");
|
||||
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.getByText("Hardware acceleration", { exact: false })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(resetToDefault(frigateApp.page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("camera live with only backend streams hides reset", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await openSettings(
|
||||
frigateApp,
|
||||
"/settings?page=cameraLivePlayback&camera=front_door",
|
||||
);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("switch", {
|
||||
name: "Enable transcoded streams",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(resetToGlobal(frigateApp.page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("camera live with a quality override shows reset", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await openSettings(
|
||||
frigateApp,
|
||||
"/settings?page=cameraLivePlayback&camera=front_door",
|
||||
{ cameras: { front_door: { live: { quality: 4 } } } },
|
||||
);
|
||||
|
||||
await expect(resetToGlobal(frigateApp.page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("camera ffmpeg never shows reset", async ({ frigateApp }) => {
|
||||
await openSettings(
|
||||
frigateApp,
|
||||
"/settings?page=cameraFfmpeg&camera=front_door",
|
||||
{ cameras: { front_door: { ffmpeg: { output_args: { record: "x" } } } } },
|
||||
);
|
||||
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.getByText("Hardware acceleration", { exact: false })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(resetToGlobal(frigateApp.page)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ const audio: SectionConfigOverrides = {
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["num_threads"],
|
||||
runtimeFields: ["filters"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["num_threads"],
|
||||
|
||||
@@ -103,7 +103,6 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
hiddenFields: ["retry_interval"],
|
||||
advancedFields: ["path", "global_args", "gpu"],
|
||||
overrideFields: [
|
||||
"inputs",
|
||||
"path",
|
||||
"global_args",
|
||||
"input_args",
|
||||
@@ -169,6 +168,7 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
runtimeFields: ["hwaccel_args"],
|
||||
fieldOrder: [
|
||||
"hwaccel_args",
|
||||
"path",
|
||||
@@ -194,6 +194,8 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
// reset would delete the camera's inputs along with its overrides
|
||||
hideReset: true,
|
||||
fieldGroups: {
|
||||
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ const live: SectionConfigOverrides = {
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["height", "quality"],
|
||||
overrideFields: ["transcode", "height", "quality"],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["streams", "height", "quality"],
|
||||
|
||||
@@ -16,6 +16,7 @@ const modelFields = [
|
||||
const models: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/object_detectors",
|
||||
hideReset: true,
|
||||
// the default-model rule must be enforced as the list is edited, not
|
||||
// only when the form is submitted
|
||||
liveValidate: true,
|
||||
|
||||
@@ -116,6 +116,7 @@ const record: SectionConfigOverrides = {
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
runtimeFields: ["export.hwaccel_args"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
|
||||
@@ -37,6 +37,7 @@ import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import get from "lodash/get";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import omit from "lodash/omit";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -54,7 +55,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, isJsonObject } from "@/lib/utils";
|
||||
import {
|
||||
ConfigSectionData,
|
||||
HiddenFieldEntry,
|
||||
@@ -125,6 +126,10 @@ export interface SectionConfig {
|
||||
fieldMessages?: FieldConditionalMessage[];
|
||||
/** Maps whose key order is saved (e.g. live stream ladder order) */
|
||||
orderedMaps?: string[];
|
||||
/** Fields the backend resolves at runtime, ignored when checking for reset */
|
||||
runtimeFields?: string[];
|
||||
/** Hide the Reset to Global / Reset to Default button */
|
||||
hideReset?: boolean;
|
||||
}
|
||||
|
||||
export interface BaseSectionProps {
|
||||
@@ -195,6 +200,17 @@ export interface CreateSectionOptions {
|
||||
|
||||
export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions;
|
||||
|
||||
// applySchemaDefaults only descends into objects present in the data, and
|
||||
// default_factory objects carry no schema default, so keep the nesting.
|
||||
function objectShape(value: unknown): Record<string, unknown> {
|
||||
if (!isJsonObject(value)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([, v]) => isJsonObject(v))
|
||||
.map(([k, v]) => [k, objectShape(v)]),
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigSection({
|
||||
sectionPath,
|
||||
defaultConfig,
|
||||
@@ -492,6 +508,15 @@ export function ConfigSection({
|
||||
[rawFormData, sanitizeSectionData],
|
||||
);
|
||||
|
||||
// A section at its schema defaults may be absent from the YAML, and
|
||||
// deleting an absent key fails. Non-default values must come from the YAML.
|
||||
const globalDiffersFromDefaults = useMemo(() => {
|
||||
if (!modifiedSchema) return true;
|
||||
const current = omit(compareBaseData, sectionConfig.runtimeFields ?? []);
|
||||
const defaults = applySchemaDefaults(modifiedSchema, objectShape(current));
|
||||
return buildOverrides(current, undefined, defaults) !== undefined;
|
||||
}, [compareBaseData, modifiedSchema, sectionConfig.runtimeFields]);
|
||||
|
||||
const orderedMaps = sectionConfig.orderedMaps;
|
||||
const buildSectionOverrides = useCallback(
|
||||
(current: unknown, base: unknown, defaults: unknown) =>
|
||||
@@ -1133,7 +1158,8 @@ export function ConfigSection({
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
|
||||
{((effectiveLevel === "camera" && isOverridden) ||
|
||||
effectiveLevel === "global") &&
|
||||
(effectiveLevel === "global" && globalDiffersFromDefaults)) &&
|
||||
!sectionConfig.hideReset &&
|
||||
!hasChanges &&
|
||||
!skipSave &&
|
||||
!profileName && (
|
||||
|
||||
@@ -473,7 +473,7 @@ export const OVERRIDABLE_SECTIONS: ReadonlyArray<{
|
||||
{ key: "review" },
|
||||
{ key: "audio" },
|
||||
{ key: "notifications" },
|
||||
{ key: "live" },
|
||||
{ key: "live", compareFields: ["transcode", "height", "quality"] },
|
||||
{ key: "timestamp_style" },
|
||||
{
|
||||
key: "audio_transcription",
|
||||
|
||||
Reference in New Issue
Block a user