mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-10 07:25:27 +03:00
Compare commits
7 Commits
f7271e0a5b
...
78bc11d7e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78bc11d7e0 | ||
|
|
56679a041b | ||
|
|
eeeec2db86 | ||
|
|
b1081d7217 | ||
|
|
3a08b3d54b | ||
|
|
d41d328a9b | ||
|
|
80239a8017 |
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 [
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ type FormContext = Pick<
|
|||||||
| "globalValue"
|
| "globalValue"
|
||||||
| "fullCameraConfig"
|
| "fullCameraConfig"
|
||||||
| "fullConfig"
|
| "fullConfig"
|
||||||
|
| "formData"
|
||||||
| "t"
|
| "t"
|
||||||
| "level"
|
| "level"
|
||||||
> & {
|
> & {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) => ({
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user