diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index 689e99f4b1..90ce717293 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -59,6 +59,64 @@ function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue { return cloned; } +/** + * Field paths that the backend resolves per-camera at runtime (from `fps`, + * stream introspection, or other camera-local state) but defaults to `None` + * in the global Pydantic model. Because the `/config` endpoint serializes + * with `exclude_none=True`, these paths are absent from the global section + * yet always populated on cameras, which would otherwise make every camera + * appear to override fields the user never set globally. + */ +const AUTO_DERIVED_FIELDS: Record = { + detect: [ + "width", + "height", + "min_initialized", + "max_disappeared", + "stationary.interval", + "stationary.threshold", + ], +}; + +/** + * Drop auto-derived field paths from the camera value when the global value + * has no explicit setting for that path. If the user later sets one of these + * fields globally, the path will be present in `globalValue` and normal + * comparison resumes. + */ +function stripAutoDerivedMissingFromGlobal( + sectionPath: string, + globalValue: JsonValue, + cameraValue: JsonValue, +): JsonValue { + const fields = AUTO_DERIVED_FIELDS[sectionPath]; + if (!fields || !isJsonObject(cameraValue)) return cameraValue; + const cloned = cloneDeep(cameraValue) as JsonObject; + for (const path of fields) { + if (get(globalValue, path) === undefined) { + unsetWithWildcard(cloned as Record, path); + } + } + return cloned; +} + +/** + * Whether the given field is auto-derived for `sectionPath` and the global + * value at that path is missing — in which case a per-camera value should + * not be treated as an override. + */ +function isAutoDerivedMissingFromGlobal( + sectionPath: string, + fieldPath: string, + globalValue: unknown, +): boolean { + const fields = AUTO_DERIVED_FIELDS[sectionPath]; + if (!fields) return false; + if (!fields.includes(fieldPath)) return false; + const value = get(globalValue as JsonObject, fieldPath); + return value === undefined || value === null; +} + /** * Collapse null and empty-object values for override comparisons so * semantically equivalent shapes match. The schema may default `mask: None` @@ -234,10 +292,15 @@ export function useConfigOverride({ collapseEmpty(normalizedGlobalValue), hiddenFields, ); - const collapsedCamera = stripHiddenPaths( + const collapsedCameraRaw = stripHiddenPaths( collapseEmpty(normalizedCameraValue), hiddenFields, ); + const collapsedCamera = stripAutoDerivedMissingFromGlobal( + sectionPath, + collapsedGlobal, + collapsedCameraRaw, + ); const comparisonGlobal = compareFields ? pickFields(collapsedGlobal, compareFields) @@ -258,6 +321,20 @@ export function useConfigOverride({ const globalFieldValue = get(normalizedGlobalValue, fieldPath); const cameraFieldValue = get(normalizedCameraValue, fieldPath); + if ( + isAutoDerivedMissingFromGlobal( + sectionPath, + fieldPath, + normalizedGlobalValue, + ) + ) { + return { + isOverridden: false, + globalValue: globalFieldValue, + cameraValue: cameraFieldValue, + }; + } + return { isOverridden: !isEqual( collapseEmpty(globalFieldValue as JsonValue), @@ -367,10 +444,15 @@ export function useAllCameraOverrides( collapseEmpty(globalValue), hiddenFields, ); - const collapsedCamera = stripHiddenPaths( + const collapsedCameraRaw = stripHiddenPaths( collapseEmpty(cameraValue), hiddenFields, ); + const collapsedCamera = stripAutoDerivedMissingFromGlobal( + key, + collapsedGlobal, + collapsedCameraRaw, + ); const comparisonGlobal = compareFields ? pickFields(collapsedGlobal, compareFields) : collapsedGlobal; @@ -615,7 +697,11 @@ export function useCamerasOverridingSection( const deltasByPath = new Map(); // 1. Camera-level overrides (uses base_config when a profile is active) - const cameraValue = collapseEmpty(cameraSectionValues[idx]); + const cameraValue = stripAutoDerivedMissingFromGlobal( + sectionPath, + globalValue, + collapseEmpty(cameraSectionValues[idx]), + ); for (const delta of collectFieldDeltas( globalValue, cameraValue, @@ -696,9 +782,13 @@ export function useCameraSectionDeltas( const globalValue = collapseEmpty( getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema), ); - const cameraValue = collapseEmpty( - normalizeConfigValue( - getBaseCameraSectionValue(config, cameraName, sectionPath), + const cameraValue = stripAutoDerivedMissingFromGlobal( + sectionPath, + globalValue, + collapseEmpty( + normalizeConfigValue( + getBaseCameraSectionValue(config, cameraName, sectionPath), + ), ), );