diff --git a/web/e2e/specs/settings/runtime-overrides.spec.ts b/web/e2e/specs/settings/runtime-overrides.spec.ts new file mode 100644 index 0000000000..85277d407e --- /dev/null +++ b/web/e2e/specs/settings/runtime-overrides.spec.ts @@ -0,0 +1,117 @@ +/** + * Runtime override tests -- MEDIUM tier. + * + * Live view, MQTT, and Home Assistant toggles change a camera's running config + * without touching yaml, and the change persists across restarts. The settings + * form edits yaml, so it keeps showing the saved value. Without a marker on the + * field and runtime-aware wording on dependent warnings, the two read as a + * contradiction: "audio detection is not enabled" next to a switch that is on. + */ + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test, expect } from "../../fixtures/frigate-test"; +import type { Page } from "@playwright/test"; +import { configFactory } 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", + ), +); + +const CAMERA = "front_door"; +const TRANSCRIPTION_URL = `/settings?page=cameraAudioTranscription&camera=${CAMERA}`; +const AUDIO_URL = `/settings?page=cameraAudioEvents&camera=${CAMERA}`; + +const CONFIG_DISABLED = /Audio detection is not enabled for this camera/; +const RUNTIME_DISABLED = + /Audio detection is enabled in your config, but it is currently turned off/; + +type AudioState = { enabled: boolean; enabled_in_config: boolean }; + +async function installRoutes(page: Page, audio: AudioState) { + const config = configFactory({ + cameras: { + [CAMERA]: { + audio, + // audio detection only runs on a stream carrying the audio role + ffmpeg: { + inputs: [ + { + path: "rtsp://user:pass@host/front", + roles: ["record", "detect", "audio"], + }, + ], + }, + audio_transcription: { enabled: true, enabled_in_config: true }, + }, + }, + }); + + await page.route("**/api/config/raw_paths", (route) => + route.fulfill({ + json: { cameras: { [CAMERA]: { ffmpeg: { inputs: [] } } } }, + }), + ); + await page.route("**/api/config/schema.json", (route) => + route.fulfill({ json: CONFIG_SCHEMA }), + ); + await page.route("**/api/config", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ json: config }); + } + return route.fulfill({ json: { success: true } }); + }); +} + +test.describe("runtime overrides @medium", () => { + test("a runtime-only toggle gets its own wording and a field marker", async ({ + frigateApp, + }) => { + await installRoutes(frigateApp.page, { + enabled: false, + enabled_in_config: true, + }); + + await frigateApp.goto(TRANSCRIPTION_URL); + await expect(frigateApp.page.getByText(RUNTIME_DISABLED)).toBeVisible(); + await expect(frigateApp.page.getByText(CONFIG_DISABLED)).toBeHidden(); + + // The audio section still shows the saved value, so the switch stays on and + // the marker carries the live state. + await frigateApp.goto(AUDIO_URL); + await expect( + frigateApp.page.getByRole("switch", { name: "Enable audio detection" }), + ).toHaveAttribute("data-state", "checked"); + // the boolean layout renders a mobile and a desktop label block, so only + // one of the two badges is on screen + await expect( + frigateApp.page.getByText("Overridden (Live)").filter({ visible: true }), + ).toHaveCount(1); + }); + + test("a config-disabled section keeps the original wording", async ({ + frigateApp, + }) => { + await installRoutes(frigateApp.page, { + enabled: false, + enabled_in_config: false, + }); + + await frigateApp.goto(TRANSCRIPTION_URL); + await expect(frigateApp.page.getByText(CONFIG_DISABLED)).toBeVisible(); + await expect(frigateApp.page.getByText(RUNTIME_DISABLED)).toBeHidden(); + + await frigateApp.goto(AUDIO_URL); + await expect( + frigateApp.page.getByRole("switch", { name: "Enable audio detection" }), + ).toHaveAttribute("data-state", "unchecked"); + await expect( + frigateApp.page.getByText("Overridden (Live)").filter({ visible: true }), + ).toHaveCount(0); + }); +}); diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 21cab3ba7b..dbf8f1874a 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -23,6 +23,9 @@ "overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:", "overriddenGlobalHeading_other": "This camera overrides {{count}} fields from the global config:", "overriddenGlobalNoDeltas": "This camera overrides the global config, but no field values differ.", + "overriddenLive": "Overridden (Live)", + "overriddenLiveTooltip": "This camera is running with a different value than the one saved in your config, which is shown here. The live view, MQTT, or an active profile can change it while Frigate is running.", + "overriddenLiveValue": "Running value: {{value}}", "overriddenBaseConfig": "Overridden (Base Config)", "overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section", "overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:", @@ -1954,20 +1957,25 @@ "configMessages": { "review": { "recordDisabled": "Recording is disabled, review items will not be generated.", + "recordRuntimeDisabled": "Recording is enabled in your config, but it is currently turned off for this camera, so review items will not be generated. Turn it back on from the camera's live view, or check whether an active profile is disabling it.", "detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.", + "detectRuntimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Review items require detected objects to categorize alerts and detections. Turn it back on from the camera's live view, or check whether an active profile is disabling it.", "allNonAlertDetections": "All non-alert activity will be included as detections.", - "genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images." + "genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.", + "genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images." }, "audio": { "noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function." }, "audioTranscription": { "audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active.", + "audioDetectionRuntimeDisabled": "Audio detection is enabled in your config, but it is currently turned off for this camera, so audio transcription will not run. Turn it back on from the camera's live view, or check whether an active profile is disabling it.", "genaiProviderSelected": "A GenAI provider is selected, so the device and model size settings are ignored." }, "detect": { "fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.", "disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.", + "runtimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function until it is turned back on from the camera's live view, or until the active profile stops disabling it.", "sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the model with a scene of 'All cameras'. Add a model for this scene to give the camera its own.", "resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.", "aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image.", @@ -2002,10 +2010,12 @@ "noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function." }, "birdseye": { - "objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye." + "objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.", + "objectTrackingDetectRuntimeDisabled": "Birdseye includes tracked objects, but object detection is currently turned off for this camera even though your config enables it. The camera will not appear in Birdseye until it is turned back on from the camera's live view, or until the active profile stops disabling it." }, "snapshots": { - "detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created." + "detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created.", + "detectRuntimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera, so snapshots will not be created. Turn it back on from the camera's live view, or check whether an active profile is disabling it." }, "semanticSearch": { "jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended." diff --git a/web/src/components/config-form/section-configs/audio_transcription.ts b/web/src/components/config-form/section-configs/audio_transcription.ts index 52cac940f6..c359611b86 100644 --- a/web/src/components/config-form/section-configs/audio_transcription.ts +++ b/web/src/components/config-form/section-configs/audio_transcription.ts @@ -9,6 +9,11 @@ const audioTranscription: SectionConfigOverrides = { health: (ctx) => ctx.fullCameraConfig?.audio_transcription?.enabled === true, messageKey: "configMessages.audioTranscription.audioDetectionDisabled", + runtimeOverride: { + section: "audio", + messageKey: + "configMessages.audioTranscription.audioDetectionRuntimeDisabled", + }, severity: "warning", condition: (ctx) => { if (ctx.level === "camera" && ctx.fullCameraConfig) { diff --git a/web/src/components/config-form/section-configs/birdseye.ts b/web/src/components/config-form/section-configs/birdseye.ts index 1965e027cb..f6fff41b80 100644 --- a/web/src/components/config-form/section-configs/birdseye.ts +++ b/web/src/components/config-form/section-configs/birdseye.ts @@ -7,6 +7,11 @@ const birdseye: SectionConfigOverrides = { { key: "object-tracking-detect-disabled", messageKey: "configMessages.birdseye.objectTrackingDetectDisabled", + runtimeOverride: { + section: "detect", + messageKey: + "configMessages.birdseye.objectTrackingDetectRuntimeDisabled", + }, severity: "info", condition: (ctx) => { if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; diff --git a/web/src/components/config-form/section-configs/objects.ts b/web/src/components/config-form/section-configs/objects.ts index 070981ad5d..704f95958f 100644 --- a/web/src/components/config-form/section-configs/objects.ts +++ b/web/src/components/config-form/section-configs/objects.ts @@ -63,6 +63,10 @@ const objects: SectionConfigOverrides = { { key: "detect-disabled", messageKey: "configMessages.detect.disabled", + runtimeOverride: { + section: "detect", + messageKey: "configMessages.detect.runtimeDisabled", + }, severity: "info", condition: (ctx) => ctx.level === "camera" && diff --git a/web/src/components/config-form/section-configs/review.ts b/web/src/components/config-form/section-configs/review.ts index 47fd032b16..5e97d09a09 100644 --- a/web/src/components/config-form/section-configs/review.ts +++ b/web/src/components/config-form/section-configs/review.ts @@ -7,6 +7,10 @@ const review: SectionConfigOverrides = { { key: "record-disabled", messageKey: "configMessages.review.recordDisabled", + runtimeOverride: { + section: "record", + messageKey: "configMessages.review.recordRuntimeDisabled", + }, severity: "warning", condition: (ctx) => { if (ctx.level === "camera" && ctx.fullCameraConfig) { @@ -18,6 +22,10 @@ const review: SectionConfigOverrides = { { key: "detect-disabled", messageKey: "configMessages.review.detectDisabled", + runtimeOverride: { + section: "detect", + messageKey: "configMessages.review.detectRuntimeDisabled", + }, severity: "info", condition: (ctx) => { if (ctx.level === "camera" && ctx.fullCameraConfig) { @@ -64,6 +72,11 @@ const review: SectionConfigOverrides = { field: "genai.image_source", messageKey: "configMessages.review.genaiImageSourceRecordingsRecordDisabled", + runtimeOverride: { + section: "record", + messageKey: + "configMessages.review.genaiImageSourceRecordingsRecordRuntimeDisabled", + }, severity: "warning", position: "after", condition: (ctx) => { diff --git a/web/src/components/config-form/section-configs/snapshots.ts b/web/src/components/config-form/section-configs/snapshots.ts index e5b5caa208..8a4ef192d9 100644 --- a/web/src/components/config-form/section-configs/snapshots.ts +++ b/web/src/components/config-form/section-configs/snapshots.ts @@ -7,6 +7,10 @@ const snapshots: SectionConfigOverrides = { { key: "detect-disabled", messageKey: "configMessages.snapshots.detectDisabled", + runtimeOverride: { + section: "detect", + messageKey: "configMessages.snapshots.detectRuntimeDisabled", + }, severity: "info", condition: (ctx) => { if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; diff --git a/web/src/components/config-form/section-configs/types.ts b/web/src/components/config-form/section-configs/types.ts index 1e75b0c8d6..4c2d762778 100644 --- a/web/src/components/config-form/section-configs/types.ts +++ b/web/src/components/config-form/section-configs/types.ts @@ -28,6 +28,17 @@ export type ConditionalMessage = { values?: Record; /** Optional documentation path (e.g. "/configuration/object_detectors#model"). */ docLink?: string; + /** + * Alternate wording for when the section this message depends on is enabled + * in the config but turned off on the running camera. Without it the message + * reads as a contradiction, since the form shows the saved config value. + */ + runtimeOverride?: { + /** Camera section whose runtime state explains the message, e.g. "audio". */ + section: string; + /** Translation key used in place of `messageKey`. */ + messageKey: string; + }; /** * Whether the Health tab evaluates this message against the saved config. * Absent or false: form only. true: shown whenever condition() holds. A diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 7e94800ddf..01f5894c64 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -1022,6 +1022,7 @@ export function ConfigSection({ formContext={{ level: effectiveLevel, cameraName, + sectionPath, globalValue, cameraValue, hasChanges, diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index 139b517f30..1db4121b60 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -19,6 +19,8 @@ import { LuExternalLink } from "react-icons/lu"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { requiresRestartForFieldPath } from "@/utils/configUtil"; import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; +import RuntimeOverrideIndicator from "@/components/indicators/RuntimeOverrideIndicator"; +import { getRuntimeOverride } from "@/utils/runtimeOverrides"; import { buildTranslationPath, resolveConfigTranslation, @@ -211,6 +213,18 @@ export function FieldTemplate(props: FieldTemplateProps) { defaultRequiresRestart, ); + // The form shows saved config values, so flag any field the running camera + // currently disagrees with. Profile editing shows that profile's overrides + // instead, where the comparison does not apply. + const runtimeOverride = + isCameraLevel && !formContext?.isProfile + ? getRuntimeOverride( + formContext?.fullCameraConfig, + formContext?.sectionPath, + pathSegments.join("."), + ) + : undefined; + // Use schema title/description as primary source (from JSON Schema) const schemaTitle = schema.title; const schemaDescription = schema.description; @@ -502,6 +516,12 @@ export function FieldTemplate(props: FieldTemplateProps) { {finalLabel} {required && *} {fieldRequiresRestart && } + {runtimeOverride && ( + + )} ); }; @@ -519,6 +539,12 @@ export function FieldTemplate(props: FieldTemplateProps) { {finalLabel} {required && *} {fieldRequiresRestart && } + {runtimeOverride && ( + + )} ); }; @@ -540,6 +566,12 @@ export function FieldTemplate(props: FieldTemplateProps) { {finalLabel} {required && *} {fieldRequiresRestart && } + {runtimeOverride && ( + + )} ); }; diff --git a/web/src/components/indicators/RuntimeOverrideIndicator.tsx b/web/src/components/indicators/RuntimeOverrideIndicator.tsx new file mode 100644 index 0000000000..9e3bc52c75 --- /dev/null +++ b/web/src/components/indicators/RuntimeOverrideIndicator.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent } from "../ui/tooltip"; +import { TooltipTrigger } from "@radix-ui/react-tooltip"; + +type RuntimeOverrideIndicatorProps = { + /** The value the camera is running with, which differs from the saved config. */ + runtimeValue: unknown; + className?: string; +}; + +/** + * Field-level companion to the section override badges. Marks a field the + * running camera has drifted from, so the saved value on screen never reads as + * the live one. + */ +export default function RuntimeOverrideIndicator({ + runtimeValue, + className, +}: RuntimeOverrideIndicatorProps) { + const { t } = useTranslation(["views/settings", "common"]); + + const displayValue = + typeof runtimeValue === "boolean" + ? t(runtimeValue ? "button.on" : "button.off", { ns: "common" }) + : Array.isArray(runtimeValue) + ? runtimeValue.join(", ") + : String(runtimeValue); + + return ( + + + + {t("button.overriddenLive", { ns: "views/settings" })} + + + +

{t("button.overriddenLiveTooltip", { ns: "views/settings" })}

+

+ {t("button.overriddenLiveValue", { + ns: "views/settings", + value: displayValue, + })} +

+
+
+ ); +} diff --git a/web/src/hooks/use-config-messages.ts b/web/src/hooks/use-config-messages.ts index 6c2b43853e..23b2d751aa 100644 --- a/web/src/hooks/use-config-messages.ts +++ b/web/src/hooks/use-config-messages.ts @@ -4,6 +4,7 @@ import type { FieldConditionalMessage, MessageConditionContext, } from "@/components/config-form/section-configs/types"; +import { resolveMessageKey } from "@/utils/runtimeOverrides"; export function useConfigMessages( messages: ConditionalMessage[] | undefined, @@ -15,12 +16,16 @@ export function useConfigMessages( } { const activeMessages = useMemo(() => { if (!messages || !context) return []; - return messages.filter((msg) => msg.condition(context)); + return messages + .filter((msg) => msg.condition(context)) + .map((msg) => ({ ...msg, messageKey: resolveMessageKey(msg, context) })); }, [messages, context]); const activeFieldMessages = useMemo(() => { if (!fieldMessages || !context) return []; - return fieldMessages.filter((msg) => msg.condition(context)); + return fieldMessages + .filter((msg) => msg.condition(context)) + .map((msg) => ({ ...msg, messageKey: resolveMessageKey(msg, context) })); }, [fieldMessages, context]); return { activeMessages, activeFieldMessages }; diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts index 1f4c57c393..2fe1080cbd 100644 --- a/web/src/types/configForm.ts +++ b/web/src/types/configForm.ts @@ -30,6 +30,8 @@ export type HiddenFieldEntry = string | ((ctx: HiddenFieldContext) => string[]); export type ConfigFormContext = { level?: "global" | "camera"; cameraName?: string; + /** Config section being edited, e.g. "audio" or "review". */ + sectionPath?: string; globalValue?: JsonValue; cameraValue?: JsonValue; overrides?: JsonValue; diff --git a/web/src/utils/configHealth.ts b/web/src/utils/configHealth.ts index e2eeb9d8cd..97a6d5bf9c 100644 --- a/web/src/utils/configHealth.ts +++ b/web/src/utils/configHealth.ts @@ -10,6 +10,7 @@ import type { FrigateConfig } from "@/types/frigateConfig"; import type { HealthProblem } from "@/types/health"; import { getSectionConfig } from "@/utils/configUtil"; import { activeCameras } from "@/utils/health"; +import { resolveMessageKey } from "@/utils/runtimeOverrides"; function healthMessages( section: string, @@ -47,7 +48,7 @@ function toProblem( severity: message.severity, scope, scopeIsCamera, - text: t(message.messageKey, { + text: t(resolveMessageKey(message, ctx), { ns: "views/settings", ...(message.values ?? {}), }), diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index ae377db408..48854a84e9 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -13,6 +13,7 @@ import set from "lodash/set"; import { isJsonObject } from "@/lib/utils"; import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const"; import { applySchemaDefaults } from "@/lib/config-schema"; +import { applyConfiguredToggles } from "@/utils/runtimeOverrides"; import { normalizeConfigValue } from "@/hooks/use-config-override"; import { modifySchemaForSection, @@ -103,11 +104,13 @@ export const globalCameraDefaultSections = new Set([ // --------------------------------------------------------------------------- /** - * Get the base (pre-profile) value for a camera section. + * Get the saved-config value for a camera section, which is what the settings + * form edits. * - * When a profile is active the API populates `base_config` with original - * section values. This helper returns that value when available, falling - * back to the top-level (effective) value otherwise. + * Two things move the top-level (effective) value away from yaml. A profile + * merges its overrides into it, and the API then populates `base_config` with + * the originals. Runtime toggles from the live view, MQTT, or Home Assistant + * change it in place, and `applyConfiguredToggles` puts those fields back. */ export function getBaseCameraSectionValue( config: FrigateConfig | undefined, @@ -118,7 +121,11 @@ export function getBaseCameraSectionValue( const cam = config.cameras?.[cameraName]; if (!cam) return undefined; const base = cam.base_config?.[sectionPath]; - return base !== undefined ? base : get(cam, sectionPath); + return applyConfiguredToggles( + cam, + sectionPath, + base !== undefined ? base : get(cam, sectionPath), + ); } // mergeWith customizer that replaces arrays wholesale instead of merging them diff --git a/web/src/utils/runtimeOverrides.ts b/web/src/utils/runtimeOverrides.ts new file mode 100644 index 0000000000..3bbf17fa63 --- /dev/null +++ b/web/src/utils/runtimeOverrides.ts @@ -0,0 +1,148 @@ +import get from "lodash/get"; +import isEqual from "lodash/isEqual"; +import set from "lodash/set"; +import cloneDeep from "lodash/cloneDeep"; +import type { CameraConfig } from "@/types/frigateConfig"; +import type { + ConditionalMessage, + MessageConditionContext, +} from "@/components/config-form/section-configs/types"; + +/** + * Camera fields the dispatcher can change at runtime from the live view, MQTT, + * or Home Assistant. Mirrors the camera command handlers in + * frigate/comms/dispatcher.py. Runtime changes persist across restarts and win + * over yaml until the field is saved again, so the settings form (which edits + * yaml) has to show the config value and flag the divergence. + * + * Paths are relative to the section. + */ +export const RUNTIME_TOGGLEABLE_FIELDS: Record = { + audio: ["enabled"], + birdseye: ["enabled", "modes"], + detect: ["enabled"], + motion: ["enabled", "improve_contrast", "threshold", "contour_area"], + notifications: ["enabled"], + objects: ["genai.enabled"], + onvif: ["autotracking.enabled"], + record: ["enabled"], + review: ["alerts.enabled", "detections.enabled", "genai.enabled"], + snapshots: ["enabled"], +}; + +/** + * The value a field holds in yaml, or undefined when the backend exposes no + * config-side copy of it. + * + * Two sources carry it. `base_config` is the pre-profile snapshot, sent only + * while a profile is active. The `_in_config` siblings are always sent, + * but only exist for the toggles the backend tracks that way (`detect`, + * `snapshots`, and `birdseye` have none). + */ +export function getConfiguredFieldValue( + cameraConfig: CameraConfig | undefined, + sectionPath: string, + fieldPath: string, +): unknown { + if (!cameraConfig) return undefined; + + const inConfig = get(cameraConfig, `${sectionPath}.${fieldPath}_in_config`); + if (inConfig !== undefined && inConfig !== null) { + return inConfig; + } + + const base = cameraConfig.base_config?.[sectionPath]; + return base !== undefined ? get(base, fieldPath) : undefined; +} + +export type RuntimeOverride = { + /** The value saved in yaml. */ + configured: unknown; + /** The value the camera is running with right now. */ + runtime: unknown; +}; + +/** + * Describes a field whose live value has drifted from the saved config, or + * undefined when the two agree or the config value can't be read. + */ +export function getRuntimeOverride( + cameraConfig: CameraConfig | undefined, + sectionPath: string | undefined, + fieldPath: string, +): RuntimeOverride | undefined { + if (!cameraConfig || !sectionPath) return undefined; + if (!RUNTIME_TOGGLEABLE_FIELDS[sectionPath]?.includes(fieldPath)) { + return undefined; + } + + const configured = getConfiguredFieldValue( + cameraConfig, + sectionPath, + fieldPath, + ); + if (configured === undefined) return undefined; + + const runtime = get(cameraConfig, `${sectionPath}.${fieldPath}`); + if (runtime === undefined || isEqual(configured, runtime)) return undefined; + + return { configured, runtime }; +} + +/** + * Whether a section is enabled in yaml but turned off on the running camera. + * This is the state that makes a dependent warning read as a contradiction, + * since the form shows the section switch on. + */ +export function isSectionRuntimeDisabled( + cameraConfig: CameraConfig | undefined, + sectionPath: string, +): boolean { + const override = getRuntimeOverride(cameraConfig, sectionPath, "enabled"); + return override?.configured === true && override.runtime === false; +} + +/** + * Overlays the saved config values onto a section so the form edits yaml + * rather than live state. Returns the section unchanged when nothing drifted. + */ +export function applyConfiguredToggles( + cameraConfig: CameraConfig | undefined, + sectionPath: string, + sectionValue: unknown, +): unknown { + const fields = RUNTIME_TOGGLEABLE_FIELDS[sectionPath]; + if (!fields || !sectionValue || typeof sectionValue !== "object") { + return sectionValue; + } + + let result = sectionValue; + for (const field of fields) { + const override = getRuntimeOverride(cameraConfig, sectionPath, field); + if (!override) continue; + + if (result === sectionValue) { + result = cloneDeep(sectionValue); + } + set(result as object, field, override.configured); + } + + return result; +} + +/** + * Picks the wording for a message. A message that depends on another section + * being on switches to its runtime wording when the config has that section + * enabled but the running camera has it off. + */ +export function resolveMessageKey( + message: Pick, + ctx: MessageConditionContext | undefined, +): string { + const runtime = message.runtimeOverride; + if (!runtime || !ctx || ctx.level !== "camera") return message.messageKey; + + return isSectionRuntimeDisabled(ctx.fullCameraConfig, runtime.section) + ? runtime.messageKey + : message.messageKey; +}