reorder sections for save and collapse to single put for new camera

This commit is contained in:
Josh Hawkins 2026-05-28 09:10:33 -05:00
parent f9f7d6e8fc
commit b852b65024

View File

@ -1,5 +1,6 @@
import cloneDeep from "lodash/cloneDeep"; import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import merge from "lodash/merge";
import type { RJSFSchema } from "@rjsf/utils"; import type { RJSFSchema } from "@rjsf/utils";
import { import {
@ -133,6 +134,37 @@ function stripResetMarkers(
return Object.keys(result).length > 0 ? result : undefined; return Object.keys(result).length > 0 ? result : undefined;
} }
/**
* Collapse per-section payloads into one camera-level payload + `…/add`
* topic. New cameras are created atomically by the backend's `add`
* handler, so a single PUT avoids the intermediate-validation ordering
* problem (e.g. a `review` required_zone referencing zones not yet written)
* that the per-section path is subject to.
*/
function bundleNewCameraPayload(
payloads: SectionSavePayload[],
target: string,
): SectionSavePayload {
const prefix = `cameras.${target}`;
const camera: JsonObject = {};
for (const p of payloads) {
if (p.basePath === prefix) {
merge(camera, p.sanitizedOverrides);
} else if (p.basePath.startsWith(`${prefix}.`)) {
merge(camera, {
[p.basePath.slice(prefix.length + 1)]: p.sanitizedOverrides,
});
}
}
return {
basePath: prefix,
sanitizedOverrides: camera,
updateTopic: `config/cameras/${target}/add`,
needsRestart: true,
pendingDataKey: `${target}::add`,
};
}
/** /**
* Drop empty `*_args` arrays from ffmpeg inputs. Mirrors * Drop empty `*_args` arrays from ffmpeg inputs. Mirrors
* `sanitizeOverridesForSection`'s ffmpeg cleanup, which we don't go * `sanitizeOverridesForSection`'s ffmpeg cleanup, which we don't go
@ -442,13 +474,19 @@ export function buildClonedCameraPayloads({
} }
// Section-backed categories — flow through prepareSectionSavePayload // Section-backed categories — flow through prepareSectionSavePayload
// for matching restart/update-topic behavior. // for matching restart/update-topic behavior. Order matters for the
// existing-camera multi-PUT path: each PUT re-validates the whole
// config, so dependency providers must precede consumers — `detect`
// (resolution) then `zones` before sections that reference zones via
// `required_zones` (review, objects, snapshots, mqtt).
const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [ const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [
{ key: "detect", section: "detect" },
{ key: "zones", section: "zones" },
{ key: "motion", section: "motion" },
{ key: "objects", section: "objects" },
{ key: "record", section: "record" }, { key: "record", section: "record" },
{ key: "snapshots", section: "snapshots" }, { key: "snapshots", section: "snapshots" },
{ key: "review", section: "review" }, { key: "review", section: "review" },
{ key: "motion", section: "motion" },
{ key: "objects", section: "objects" },
{ key: "audio", section: "audio" }, { key: "audio", section: "audio" },
{ key: "audio_transcription", section: "audio_transcription" }, { key: "audio_transcription", section: "audio_transcription" },
{ key: "notifications", section: "notifications" }, { key: "notifications", section: "notifications" },
@ -460,8 +498,6 @@ export function buildClonedCameraPayloads({
{ key: "face_recognition", section: "face_recognition" }, { key: "face_recognition", section: "face_recognition" },
{ key: "semantic_search", section: "semantic_search" }, { key: "semantic_search", section: "semantic_search" },
{ key: "genai", section: "genai" }, { key: "genai", section: "genai" },
{ key: "detect", section: "detect" },
{ key: "zones", section: "zones" },
]; ];
// Synthetic target so we can reuse prepareSectionSavePayload unchanged. // Synthetic target so we can reuse prepareSectionSavePayload unchanged.
@ -673,15 +709,19 @@ export function buildClonedCameraPayloads({
} }
} }
// Reset markers are meaningless for a new camera; see stripResetMarkers. // New camera: scrub Reset markers (see stripResetMarkers), then bundle
// into one atomic `…/add` PUT so the backend validates the full camera
// at once (avoids per-section ordering issues).
if (targetIsNew) { if (targetIsNew) {
return payloads const scrubbed = payloads
.map((p) => { .map((p) => {
const cleaned = stripResetMarkers(p.sanitizedOverrides as JsonValue); const cleaned = stripResetMarkers(p.sanitizedOverrides as JsonValue);
if (cleaned === undefined) return null; return cleaned === undefined
return { ...p, sanitizedOverrides: cleaned as JsonObject }; ? null
: { ...p, sanitizedOverrides: cleaned as JsonObject };
}) })
.filter((p): p is SectionSavePayload => p !== null); .filter((p): p is SectionSavePayload => p !== null);
return [bundleNewCameraPayload(scrubbed, target)];
} }
return payloads; return payloads;
@ -695,12 +735,15 @@ export function buildClonePreviewItems(
payloads: SectionSavePayload[], payloads: SectionSavePayload[],
targetCamera: string, targetCamera: string,
): SaveAllPreviewItem[] { ): SaveAllPreviewItem[] {
const cameraPrefix = `cameras.${targetCamera}.`; const cameraBase = `cameras.${targetCamera}`;
return payloads.flatMap((p) => { return payloads.flatMap((p) => {
const flattened = flattenOverrides(p.sanitizedOverrides as JsonValue); const flattened = flattenOverrides(p.sanitizedOverrides as JsonValue);
const sectionRelativeBase = p.basePath.startsWith(cameraPrefix) const sectionRelativeBase =
? p.basePath.slice(cameraPrefix.length) p.basePath === cameraBase
: p.basePath; ? ""
: p.basePath.startsWith(`${cameraBase}.`)
? p.basePath.slice(cameraBase.length + 1)
: p.basePath;
return flattened.map(({ path, value }) => ({ return flattened.map(({ path, value }) => ({
scope: "camera" as const, scope: "camera" as const,
cameraName: targetCamera, cameraName: targetCamera,