Compare commits

..

7 Commits

Author SHA1 Message Date
Josh Hawkins
78bc11d7e0 formatting 2026-03-16 13:46:04 -05:00
Josh Hawkins
56679a041b publish camera state when changing profiles 2026-03-16 13:41:30 -05:00
Josh Hawkins
eeeec2db86 don't require restart for camera enabled change for profiles 2026-03-16 13:35:26 -05:00
Josh Hawkins
b1081d7217 formatting 2026-03-16 13:17:17 -05:00
Josh Hawkins
3a08b3d54b fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
2026-03-16 13:14:58 -05:00
Josh Hawkins
d41d328a9b use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
2026-03-16 12:43:45 -05:00
Josh Hawkins
80239a8017 fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.

Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.

Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
2026-03-16 12:23:07 -05:00
15 changed files with 232 additions and 109 deletions

View File

@ -170,6 +170,19 @@ def config(request: Request):
) )
) )
# When a profile is active, the top-level camera sections contain
# profile-merged (effective) values. Include the original base
# configs so the frontend settings can display them separately.
if (
config_obj.active_profile is not None
and request.app.profile_manager is not None
):
base_sections = request.app.profile_manager.get_base_configs_for_api(
camera_name
)
if base_sections:
camera_dict["base_config"] = base_sections
# remove go2rtc stream passwords # remove go2rtc stream passwords
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump( go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
mode="json", warnings="none", exclude_none=True mode="json", warnings="none", exclude_none=True

View File

@ -352,7 +352,9 @@ class FrigateApp:
) )
def init_profile_manager(self) -> None: def init_profile_manager(self) -> None:
self.profile_manager = ProfileManager(self.config, self.inter_config_updater) self.profile_manager = ProfileManager(
self.config, self.inter_config_updater, self.dispatcher
)
self.dispatcher.profile_manager = self.profile_manager self.dispatcher.profile_manager = self.profile_manager
persisted = ProfileManager.load_persisted_profile() persisted = ProfileManager.load_persisted_profile()

View File

@ -12,7 +12,6 @@ from pydantic import (
Field, Field,
TypeAdapter, TypeAdapter,
ValidationInfo, ValidationInfo,
field_serializer,
field_validator, field_validator,
model_validator, model_validator,
) )
@ -98,8 +97,7 @@ stream_info_retriever = StreamInfoRetriever()
class RuntimeMotionConfig(MotionConfig): class RuntimeMotionConfig(MotionConfig):
"""Runtime version of MotionConfig with rasterized masks.""" """Runtime version of MotionConfig with rasterized masks."""
# The rasterized numpy mask (combination of all enabled masks) rasterized_mask: np.ndarray = Field(default=None, exclude=True)
rasterized_mask: np.ndarray = None
def __init__(self, **config): def __init__(self, **config):
frame_shape = config.get("frame_shape", (1, 1)) frame_shape = config.get("frame_shape", (1, 1))
@ -145,24 +143,13 @@ class RuntimeMotionConfig(MotionConfig):
empty_mask[:] = 255 empty_mask[:] = 255
self.rasterized_mask = empty_mask self.rasterized_mask = empty_mask
def dict(self, **kwargs):
ret = super().model_dump(**kwargs)
if "rasterized_mask" in ret:
ret.pop("rasterized_mask")
return ret
@field_serializer("rasterized_mask", when_used="json")
def serialize_rasterized_mask(self, value: Any, info):
return None
model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore") model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore")
class RuntimeFilterConfig(FilterConfig): class RuntimeFilterConfig(FilterConfig):
"""Runtime version of FilterConfig with rasterized masks.""" """Runtime version of FilterConfig with rasterized masks."""
# The rasterized numpy mask (combination of all enabled masks) rasterized_mask: Optional[np.ndarray] = Field(default=None, exclude=True)
rasterized_mask: Optional[np.ndarray] = None
def __init__(self, **config): def __init__(self, **config):
frame_shape = config.get("frame_shape", (1, 1)) frame_shape = config.get("frame_shape", (1, 1))
@ -226,16 +213,6 @@ class RuntimeFilterConfig(FilterConfig):
else: else:
self.rasterized_mask = None self.rasterized_mask = None
def dict(self, **kwargs):
ret = super().model_dump(**kwargs)
if "rasterized_mask" in ret:
ret.pop("rasterized_mask")
return ret
@field_serializer("rasterized_mask", when_used="json")
def serialize_rasterized_mask(self, value: Any, info):
return None
model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore") model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore")

View File

@ -29,6 +29,7 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = {
"record": CameraConfigUpdateEnum.record, "record": CameraConfigUpdateEnum.record,
"review": CameraConfigUpdateEnum.review, "review": CameraConfigUpdateEnum.review,
"snapshots": CameraConfigUpdateEnum.snapshots, "snapshots": CameraConfigUpdateEnum.snapshots,
"zones": CameraConfigUpdateEnum.zones,
} }
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile" PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile"
@ -41,12 +42,15 @@ class ProfileManager:
self, self,
config, config,
config_updater: CameraConfigUpdatePublisher, config_updater: CameraConfigUpdatePublisher,
dispatcher=None,
): ):
from frigate.config.config import FrigateConfig from frigate.config.config import FrigateConfig
self.config: FrigateConfig = config self.config: FrigateConfig = config
self.config_updater = config_updater self.config_updater = config_updater
self.dispatcher = dispatcher
self._base_configs: dict[str, dict[str, dict]] = {} self._base_configs: dict[str, dict[str, dict]] = {}
self._base_api_configs: dict[str, dict[str, dict]] = {}
self._base_enabled: dict[str, bool] = {} self._base_enabled: dict[str, bool] = {}
self._base_zones: dict[str, dict[str, ZoneConfig]] = {} self._base_zones: dict[str, dict[str, ZoneConfig]] = {}
self._snapshot_base_configs() self._snapshot_base_configs()
@ -55,12 +59,39 @@ class ProfileManager:
"""Snapshot each camera's current section configs, enabled, and zones.""" """Snapshot each camera's current section configs, enabled, and zones."""
for cam_name, cam_config in self.config.cameras.items(): for cam_name, cam_config in self.config.cameras.items():
self._base_configs[cam_name] = {} self._base_configs[cam_name] = {}
self._base_api_configs[cam_name] = {}
self._base_enabled[cam_name] = cam_config.enabled self._base_enabled[cam_name] = cam_config.enabled
self._base_zones[cam_name] = copy.deepcopy(cam_config.zones) self._base_zones[cam_name] = copy.deepcopy(cam_config.zones)
for section in PROFILE_SECTION_UPDATES: for section in PROFILE_SECTION_UPDATES:
section_config = getattr(cam_config, section, None) section_value = getattr(cam_config, section, None)
if section_config is not None: if section_value is None:
self._base_configs[cam_name][section] = section_config.model_dump() continue
if section == "zones":
# zones is a dict of ZoneConfig models
self._base_configs[cam_name][section] = {
name: zone.model_dump() for name, zone in section_value.items()
}
self._base_api_configs[cam_name][section] = {
name: {
**zone.model_dump(
mode="json",
warnings="none",
exclude_none=True,
),
"color": zone.color,
}
for name, zone in section_value.items()
}
else:
self._base_configs[cam_name][section] = section_value.model_dump()
self._base_api_configs[cam_name][section] = (
section_value.model_dump(
mode="json",
warnings="none",
exclude_none=True,
)
)
def update_config(self, new_config) -> None: def update_config(self, new_config) -> None:
"""Update config reference after config/set replaces the in-memory config. """Update config reference after config/set replaces the in-memory config.
@ -74,6 +105,7 @@ class ProfileManager:
# Re-snapshot base configs from the new config (which has base values) # Re-snapshot base configs from the new config (which has base values)
self._base_configs.clear() self._base_configs.clear()
self._base_api_configs.clear()
self._base_enabled.clear() self._base_enabled.clear()
self._base_zones.clear() self._base_zones.clear()
self._snapshot_base_configs() self._snapshot_base_configs()
@ -144,9 +176,11 @@ class ProfileManager:
cam_config.zones = copy.deepcopy(base_zones) cam_config.zones = copy.deepcopy(base_zones)
changed.setdefault(cam_name, set()).add("zones") changed.setdefault(cam_name, set()).add("zones")
# Restore section configs # Restore section configs (zones handled above)
base = self._base_configs.get(cam_name, {}) base = self._base_configs.get(cam_name, {})
for section in PROFILE_SECTION_UPDATES: for section in PROFILE_SECTION_UPDATES:
if section == "zones":
continue
base_data = base.get(section) base_data = base.get(section)
if base_data is None: if base_data is None:
continue continue
@ -180,12 +214,23 @@ class ProfileManager:
base_zones = self._base_zones.get(cam_name, {}) base_zones = self._base_zones.get(cam_name, {})
merged_zones = copy.deepcopy(base_zones) merged_zones = copy.deepcopy(base_zones)
merged_zones.update(profile.zones) merged_zones.update(profile.zones)
# Profile zone objects are parsed without colors or contours
# (those are set during CameraConfig init / post-validation).
# Inherit the base zone's color when available, and ensure
# every zone has a valid contour for rendering.
for name, zone in merged_zones.items():
if zone.contour.size == 0:
zone.generate_contour(cam_config.frame_shape)
if zone.color == (0, 0, 0) and name in base_zones:
zone._color = base_zones[name].color
cam_config.zones = merged_zones cam_config.zones = merged_zones
changed.setdefault(cam_name, set()).add("zones") changed.setdefault(cam_name, set()).add("zones")
base = self._base_configs.get(cam_name, {}) base = self._base_configs.get(cam_name, {})
for section in PROFILE_SECTION_UPDATES: for section in PROFILE_SECTION_UPDATES:
if section == "zones":
continue
profile_section = getattr(profile, section, None) profile_section = getattr(profile, section, None)
if profile_section is None: if profile_section is None:
continue continue
@ -220,6 +265,12 @@ class ProfileManager:
), ),
cam_config.enabled, cam_config.enabled,
) )
if self.dispatcher is not None:
self.dispatcher.publish(
f"{cam_name}/enabled/state",
"ON" if cam_config.enabled else "OFF",
retain=True,
)
continue continue
if section == "zones": if section == "zones":
@ -260,6 +311,14 @@ class ProfileManager:
logger.exception("Failed to load persisted profile") logger.exception("Failed to load persisted profile")
return None return None
def get_base_configs_for_api(self, camera_name: str) -> dict[str, dict]:
"""Return base (pre-profile) section configs for a camera.
These are JSON-serializable dicts suitable for direct inclusion in
the /api/config response, with None values already excluded.
"""
return self._base_api_configs.get(camera_name, {})
def get_available_profiles(self) -> list[dict[str, str]]: def get_available_profiles(self) -> list[dict[str, str]]:
"""Get list of all profile definitions from the top-level config.""" """Get list of all profile definitions from the top-level config."""
return [ return [

View File

@ -557,6 +557,34 @@ class TestProfileManager(unittest.TestCase):
assert "armed" in names assert "armed" in names
assert "disarmed" in names assert "disarmed" in names
@patch.object(ProfileManager, "_persist_active_profile")
def test_base_configs_for_api_unchanged_after_activation(self, mock_persist):
"""API base configs reflect pre-profile values after activation."""
base_track = self.config.cameras["front"].objects.track[:]
assert base_track == ["person"]
self.manager.activate_profile("armed")
# In-memory config has the profile-merged values
assert self.config.cameras["front"].objects.track == [
"person",
"car",
"package",
]
# But the API base configs still return the original base values
api_base = self.manager.get_base_configs_for_api("front")
assert "objects" in api_base
assert api_base["objects"]["track"] == ["person"]
def test_base_configs_for_api_are_json_serializable(self):
"""API base configs are JSON-serializable (mode='json')."""
import json
api_base = self.manager.get_base_configs_for_api("front")
# Should not raise
json.dumps(api_base)
class TestProfilePersistence(unittest.TestCase): class TestProfilePersistence(unittest.TestCase):
"""Test profile persistence to disk.""" """Test profile persistence to disk."""

View File

@ -717,7 +717,7 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[
if section == "motion": if section == "motion":
merged = deep_merge( merged = deep_merge(
current.model_dump(exclude_unset=True, exclude={"rasterized_mask"}), current.model_dump(exclude_unset=True),
update, update,
override=True, override=True,
) )
@ -727,9 +727,7 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[
elif section == "objects": elif section == "objects":
merged = deep_merge( merged = deep_merge(
current.model_dump( current.model_dump(),
exclude={"filters": {"__all__": {"rasterized_mask"}}}
),
update, update,
override=True, override=True,
) )

View File

@ -65,6 +65,7 @@ import {
globalCameraDefaultSections, globalCameraDefaultSections,
buildOverrides, buildOverrides,
buildConfigDataForPath, buildConfigDataForPath,
getBaseCameraSectionValue,
sanitizeSectionData as sharedSanitizeSectionData, sanitizeSectionData as sharedSanitizeSectionData,
requiresRestartForOverrides as sharedRequiresRestartForOverrides, requiresRestartForOverrides as sharedRequiresRestartForOverrides,
} from "@/utils/configUtil"; } from "@/utils/configUtil";
@ -303,12 +304,20 @@ export function ConfigSection({
profileOverridesSection ? "profile" : isOverridden ? "global" : undefined; profileOverridesSection ? "profile" : isOverridden ? "global" : undefined;
// Get current form data // Get current form data
// When editing a profile, show base camera config deep-merged with profile overrides // When a profile is active the top-level camera sections contain the
// effective (profile-merged) values. For the base-config view we read
// from `base_config` (original values before the profile was applied).
// When editing a profile, we merge the base value with profile overrides.
const rawSectionValue = useMemo(() => { const rawSectionValue = useMemo(() => {
if (!config) return undefined; if (!config) return undefined;
if (effectiveLevel === "camera" && cameraName) { if (effectiveLevel === "camera" && cameraName) {
const baseValue = get(config.cameras?.[cameraName], sectionPath); // Base value: prefer base_config (pre-profile) over effective value
const baseValue = getBaseCameraSectionValue(
config,
cameraName,
sectionPath,
);
if (profileName) { if (profileName) {
const profileOverrides = get( const profileOverrides = get(
config.cameras?.[cameraName], config.cameras?.[cameraName],

View File

@ -7,41 +7,35 @@ import type { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { JsonObject } from "@/types/configForm"; import { JsonObject } from "@/types/configForm";
function extractListenLabels(value: unknown): string[] {
if (value && typeof value === "object" && !Array.isArray(value)) {
const listenValue = (value as JsonObject).listen;
if (Array.isArray(listenValue)) {
return listenValue.filter(
(item): item is string => typeof item === "string",
);
}
}
return [];
}
function getEnabledAudioLabels(context: FormContext): string[] { function getEnabledAudioLabels(context: FormContext): string[] {
let cameraLabels: string[] = []; let cameraLabels: string[] = [];
let globalLabels: string[] = []; let globalLabels: string[] = [];
let formDataLabels: string[] = [];
if (context) { if (context) {
// context.cameraValue and context.globalValue should be the entire audio section // context.cameraValue and context.globalValue should be the entire audio section
if ( cameraLabels = extractListenLabels(context.cameraValue);
context.cameraValue && globalLabels = extractListenLabels(context.globalValue);
typeof context.cameraValue === "object" &&
!Array.isArray(context.cameraValue)
) {
const listenValue = (context.cameraValue as JsonObject).listen;
if (Array.isArray(listenValue)) {
cameraLabels = listenValue.filter(
(item): item is string => typeof item === "string",
);
}
}
if ( // Include labels from the current form data so that labels added via
context.globalValue && // profile overrides (or user edits) are always visible as switches.
typeof context.globalValue === "object" && formDataLabels = extractListenLabels(context.formData);
!Array.isArray(context.globalValue)
) {
const globalListenValue = (context.globalValue as JsonObject).listen;
if (Array.isArray(globalListenValue)) {
globalLabels = globalListenValue.filter(
(item): item is string => typeof item === "string",
);
}
}
} }
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels; const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
return [...sourceLabels].sort(); return [...new Set([...sourceLabels, ...formDataLabels])].sort();
} }
function getAudioLabelDisplayName(label: string): string { function getAudioLabelDisplayName(label: string): string {

View File

@ -40,43 +40,42 @@ function getLabelmapLabels(context: FormContext): string[] {
return [...labels]; return [...labels];
} }
// Extract track labels from an objects section value.
function extractTrackLabels(value: unknown): string[] {
if (value && typeof value === "object" && !Array.isArray(value)) {
const trackValue = (value as JsonObject).track;
if (Array.isArray(trackValue)) {
return trackValue.filter(
(item): item is string => typeof item === "string",
);
}
}
return [];
}
// Build the list of labels for switches (labelmap + configured track list). // Build the list of labels for switches (labelmap + configured track list).
function getObjectLabels(context: FormContext): string[] { function getObjectLabels(context: FormContext): string[] {
const labelmapLabels = getLabelmapLabels(context); const labelmapLabels = getLabelmapLabels(context);
let cameraLabels: string[] = []; let cameraLabels: string[] = [];
let globalLabels: string[] = []; let globalLabels: string[] = [];
let formDataLabels: string[] = [];
if (context) { if (context) {
// context.cameraValue and context.globalValue should be the entire objects section // context.cameraValue and context.globalValue should be the entire objects section
if ( cameraLabels = extractTrackLabels(context.cameraValue);
context.cameraValue && globalLabels = extractTrackLabels(context.globalValue);
typeof context.cameraValue === "object" &&
!Array.isArray(context.cameraValue)
) {
const trackValue = (context.cameraValue as JsonObject).track;
if (Array.isArray(trackValue)) {
cameraLabels = trackValue.filter(
(item): item is string => typeof item === "string",
);
}
}
if ( // Include labels from the current form data so that labels added via
context.globalValue && // profile overrides (or user edits) are always visible as switches.
typeof context.globalValue === "object" && formDataLabels = extractTrackLabels(context.formData);
!Array.isArray(context.globalValue)
) {
const globalTrackValue = (context.globalValue as JsonObject).track;
if (Array.isArray(globalTrackValue)) {
globalLabels = globalTrackValue.filter(
(item): item is string => typeof item === "string",
);
}
}
} }
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels; const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
const combinedLabels = new Set<string>([...labelmapLabels, ...sourceLabels]); const combinedLabels = new Set<string>([
...labelmapLabels,
...sourceLabels,
...formDataLabels,
]);
return [...combinedLabels].sort(); return [...combinedLabels].sort();
} }

View File

@ -20,6 +20,7 @@ type FormContext = Pick<
| "globalValue" | "globalValue"
| "fullCameraConfig" | "fullCameraConfig"
| "fullConfig" | "fullConfig"
| "formData"
| "t" | "t"
| "level" | "level"
> & { > & {

View File

@ -6,6 +6,7 @@ import set from "lodash/set";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject, JsonValue } from "@/types/configForm"; import { JsonObject, JsonValue } from "@/types/configForm";
import { isJsonObject } from "@/lib/utils"; import { isJsonObject } from "@/lib/utils";
import { getBaseCameraSectionValue } from "@/utils/configUtil";
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"]; const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
@ -144,7 +145,13 @@ export function useConfigOverride({
}; };
} }
const cameraValue = get(cameraConfig, sectionPath); // Prefer the base (pre-profile) value so that override detection and
// widget context reflect the camera's own config, not profile effects.
const cameraValue = getBaseCameraSectionValue(
config,
cameraName,
sectionPath,
);
const normalizedGlobalValue = normalizeConfigValue(globalValue); const normalizedGlobalValue = normalizeConfigValue(globalValue);
const normalizedCameraValue = normalizeConfigValue(cameraValue); const normalizedCameraValue = normalizeConfigValue(cameraValue);
@ -256,7 +263,9 @@ export function useAllCameraOverrides(
for (const { key, compareFields } of sectionsToCheck) { for (const { key, compareFields } of sectionsToCheck) {
const globalValue = normalizeConfigValue(get(config, key)); const globalValue = normalizeConfigValue(get(config, key));
const cameraValue = normalizeConfigValue(get(cameraConfig, key)); const cameraValue = normalizeConfigValue(
getBaseCameraSectionValue(config, cameraName, key),
);
const comparisonGlobal = compareFields const comparisonGlobal = compareFields
? pickFields(globalValue, compareFields) ? pickFields(globalValue, compareFields)

View File

@ -316,6 +316,8 @@ export interface CameraConfig {
}; };
}; };
profiles?: Record<string, CameraProfileConfig>; profiles?: Record<string, CameraProfileConfig>;
/** Pre-profile base section configs, present only when a profile is active */
base_config?: Record<string, Record<string, unknown>>;
} }
export type CameraProfileConfig = { export type CameraProfileConfig = {

View File

@ -73,6 +73,25 @@ export const globalCameraDefaultSections = new Set([
// Profile helpers // Profile helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Get the base (pre-profile) value for a camera section.
*
* 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.
*/
export function getBaseCameraSectionValue(
config: FrigateConfig | undefined,
cameraName: string | undefined,
sectionPath: string,
): unknown {
if (!config || !cameraName) return undefined;
const cam = config.cameras?.[cameraName];
if (!cam) return undefined;
const base = cam.base_config?.[sectionPath];
return base !== undefined ? base : get(cam, sectionPath);
}
/** Sections that can appear inside a camera profile definition. */ /** Sections that can appear inside a camera profile definition. */
export const PROFILE_ELIGIBLE_SECTIONS = new Set([ export const PROFILE_ELIGIBLE_SECTIONS = new Set([
"audio", "audio",
@ -504,8 +523,9 @@ export function prepareSectionSavePayload(opts: {
let rawSectionValue: unknown; let rawSectionValue: unknown;
if (level === "camera" && cameraName) { if (level === "camera" && cameraName) {
if (profileInfo.isProfile) { if (profileInfo.isProfile) {
const baseValue = get( const baseValue = getBaseCameraSectionValue(
config.cameras?.[cameraName], config,
cameraName,
profileInfo.actualSection, profileInfo.actualSection,
); );
const profileOverrides = get(config.cameras?.[cameraName], sectionPath); const profileOverrides = get(config.cameras?.[cameraName], sectionPath);
@ -523,7 +543,12 @@ export function prepareSectionSavePayload(opts: {
rawSectionValue = baseValue; rawSectionValue = baseValue;
} }
} else { } else {
rawSectionValue = get(config.cameras?.[cameraName], sectionPath); // Use base (pre-profile) value so the diff matches what the form shows
rawSectionValue = getBaseCameraSectionValue(
config,
cameraName,
sectionPath,
);
} }
} else { } else {
rawSectionValue = get(config, sectionPath); rawSectionValue = get(config, sectionPath);

View File

@ -435,7 +435,10 @@ function ProfileCameraEnableSection({
}, },
}; };
await axios.put("config/set", { config_data: configData }); await axios.put("config/set", {
requires_restart: 0,
config_data: configData,
});
await onConfigChanged(); await onConfigChanged();
setLocalOverrides((prev) => ({ setLocalOverrides((prev) => ({

View File

@ -249,17 +249,26 @@ export default function MasksAndZonesView({
? cameraConfig.profiles?.[currentEditingProfile] ? cameraConfig.profiles?.[currentEditingProfile]
: undefined; : undefined;
// When a profile is active, the top-level sections contain
// effective (profile-merged) values. Use base_config for the
// original base values so the "Base Config" view is accurate and
// the base layer for profile merging is correct.
const baseMotion = (cameraConfig.base_config?.motion ??
cameraConfig.motion) as typeof cameraConfig.motion;
const baseObjects = (cameraConfig.base_config?.objects ??
cameraConfig.objects) as typeof cameraConfig.objects;
const baseZones = (cameraConfig.base_config?.zones ??
cameraConfig.zones) as typeof cameraConfig.zones;
// Build base zone names set for source tracking // Build base zone names set for source tracking
const baseZoneNames = new Set(Object.keys(cameraConfig.zones)); const baseZoneNames = new Set(Object.keys(baseZones));
const profileZoneNames = new Set(Object.keys(profileData?.zones ?? {})); const profileZoneNames = new Set(Object.keys(profileData?.zones ?? {}));
const baseMotionMaskNames = new Set( const baseMotionMaskNames = new Set(Object.keys(baseMotion.mask || {}));
Object.keys(cameraConfig.motion.mask || {}),
);
const profileMotionMaskNames = new Set( const profileMotionMaskNames = new Set(
Object.keys(profileData?.motion?.mask ?? {}), Object.keys(profileData?.motion?.mask ?? {}),
); );
const baseGlobalObjectMaskNames = new Set( const baseGlobalObjectMaskNames = new Set(
Object.keys(cameraConfig.objects.mask || {}), Object.keys(baseObjects.mask || {}),
); );
const profileGlobalObjectMaskNames = new Set( const profileGlobalObjectMaskNames = new Set(
Object.keys(profileData?.objects?.mask ?? {}), Object.keys(profileData?.objects?.mask ?? {}),
@ -274,7 +283,7 @@ export default function MasksAndZonesView({
} }
>(); >();
for (const [name, zoneData] of Object.entries(cameraConfig.zones)) { for (const [name, zoneData] of Object.entries(baseZones)) {
if (currentEditingProfile && profileZoneNames.has(name)) { if (currentEditingProfile && profileZoneNames.has(name)) {
// Profile overrides this base zone // Profile overrides this base zone
mergedZones.set(name, { mergedZones.set(name, {
@ -302,7 +311,8 @@ export default function MasksAndZonesView({
const zones: Polygon[] = []; const zones: Polygon[] = [];
for (const [name, { data: zoneData, source }] of mergedZones) { for (const [name, { data: zoneData, source }] of mergedZones) {
const isBase = source === "base" && !!currentEditingProfile; const isBase = source === "base" && !!currentEditingProfile;
const baseColor = zoneData.color ?? [128, 128, 0]; const baseColor = zoneData.color ??
baseZones[name]?.color ?? [128, 128, 0];
zones.push({ zones.push({
type: "zone" as PolygonType, type: "zone" as PolygonType,
typeIndex: zoneIndex, typeIndex: zoneIndex,
@ -339,9 +349,7 @@ export default function MasksAndZonesView({
} }
>(); >();
for (const [maskId, maskData] of Object.entries( for (const [maskId, maskData] of Object.entries(baseMotion.mask || {})) {
cameraConfig.motion.mask || {},
)) {
if (currentEditingProfile && profileMotionMaskNames.has(maskId)) { if (currentEditingProfile && profileMotionMaskNames.has(maskId)) {
mergedMotionMasks.set(maskId, { mergedMotionMasks.set(maskId, {
data: profileData!.motion!.mask![maskId], data: profileData!.motion!.mask![maskId],
@ -406,9 +414,7 @@ export default function MasksAndZonesView({
} }
>(); >();
for (const [maskId, maskData] of Object.entries( for (const [maskId, maskData] of Object.entries(baseObjects.mask || {})) {
cameraConfig.objects.mask || {},
)) {
if (currentEditingProfile && profileGlobalObjectMaskNames.has(maskId)) { if (currentEditingProfile && profileGlobalObjectMaskNames.has(maskId)) {
mergedGlobalObjectMasks.set(maskId, { mergedGlobalObjectMasks.set(maskId, {
data: profileData!.objects!.mask![maskId], data: profileData!.objects!.mask![maskId],
@ -472,7 +478,7 @@ export default function MasksAndZonesView({
// Build per-object filter mask names for profile tracking // Build per-object filter mask names for profile tracking
const baseFilterMaskNames = new Set<string>(); const baseFilterMaskNames = new Set<string>();
for (const [, filterConfig] of Object.entries( for (const [, filterConfig] of Object.entries(
cameraConfig.objects.filters, baseObjects.filters || {},
)) { )) {
for (const maskId of Object.keys(filterConfig.mask || {})) { for (const maskId of Object.keys(filterConfig.mask || {})) {
if (!maskId.startsWith("global_")) { if (!maskId.startsWith("global_")) {
@ -495,9 +501,7 @@ export default function MasksAndZonesView({
} }
// Per-object filter masks (base) // Per-object filter masks (base)
const objectMasks: Polygon[] = Object.entries( const objectMasks: Polygon[] = Object.entries(baseObjects.filters || {})
cameraConfig.objects.filters,
)
.filter( .filter(
([, filterConfig]) => ([, filterConfig]) =>
filterConfig.mask && Object.keys(filterConfig.mask).length > 0, filterConfig.mask && Object.keys(filterConfig.mask).length > 0,