diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index bb122cc1a9..d109bdecbc 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -1,7 +1,9 @@ """Profile manager for activating/deactivating named config profiles.""" import copy +import json import logging +from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -32,7 +34,7 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = { "zones": CameraConfigUpdateEnum.zones, } -PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile" +PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles" class ProfileManager: @@ -291,25 +293,36 @@ class ProfileManager: ) def _persist_active_profile(self, profile_name: Optional[str]) -> None: - """Persist the active profile name to disk.""" + """Persist the active profile state to disk as JSON.""" try: - if profile_name is None: - PERSISTENCE_FILE.unlink(missing_ok=True) - else: - PERSISTENCE_FILE.write_text(profile_name) + data = self._load_persisted_data() + data["active"] = profile_name + if profile_name is not None: + data.setdefault("last_activated", {})[profile_name] = datetime.now( + timezone.utc + ).timestamp() + PERSISTENCE_FILE.write_text(json.dumps(data)) except OSError: logger.exception("Failed to persist active profile") @staticmethod - def load_persisted_profile() -> Optional[str]: - """Load the persisted active profile name from disk.""" + def _load_persisted_data() -> dict: + """Load the full persisted profile data from disk.""" try: if PERSISTENCE_FILE.exists(): - name = PERSISTENCE_FILE.read_text().strip() - return name if name else None - except OSError: - logger.exception("Failed to load persisted profile") - return None + raw = PERSISTENCE_FILE.read_text().strip() + if raw: + return json.loads(raw) + except (OSError, json.JSONDecodeError): + logger.exception("Failed to load persisted profile data") + return {"active": None, "last_activated": {}} + + @staticmethod + def load_persisted_profile() -> Optional[str]: + """Load the persisted active profile name from disk.""" + data = ProfileManager._load_persisted_data() + name = data.get("active") + return name if name else None def get_base_configs_for_api(self, camera_name: str) -> dict[str, dict]: """Return base (pre-profile) section configs for a camera. @@ -328,7 +341,9 @@ class ProfileManager: def get_profile_info(self) -> dict: """Get profile state info for API responses.""" + data = self._load_persisted_data() return { "profiles": self.get_available_profiles(), "active_profile": self.config.active_profile, + "last_activated": data.get("last_activated", {}), } diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py index b77d3ebb65..c60b6f3846 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -1,5 +1,6 @@ """Tests for the profiles system.""" +import json import os import tempfile import unittest @@ -549,10 +550,17 @@ class TestProfileManager(unittest.TestCase): def test_get_profile_info(self): """Profile info returns correct structure with friendly names.""" - info = self.manager.get_profile_info() + with patch.object( + ProfileManager, + "_load_persisted_data", + return_value={"active": None, "last_activated": {}}, + ): + info = self.manager.get_profile_info() assert "profiles" in info assert "active_profile" in info + assert "last_activated" in info assert info["active_profile"] is None + assert info["last_activated"] == {} names = [p["name"] for p in info["profiles"]] assert "armed" in names assert "disarmed" in names @@ -590,33 +598,22 @@ class TestProfilePersistence(unittest.TestCase): """Test profile persistence to disk.""" def test_persist_and_load(self): - """Active profile name can be persisted and loaded.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - temp_path = f.name - - try: - from pathlib import Path - - path = Path(temp_path) - path.write_text("armed") - loaded = path.read_text().strip() - assert loaded == "armed" - finally: - os.unlink(temp_path) + """Active profile name can be persisted and loaded via JSON.""" + data = {"active": "armed", "last_activated": {"armed": 1700000000.0}} + with patch.object( + ProfileManager, + "_load_persisted_data", + return_value=data, + ): + result = ProfileManager.load_persisted_profile() + assert result == "armed" def test_load_empty_file(self): """Empty persistence file returns None.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - f.write("") - temp_path = f.name - - try: - with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True): - with patch.object(type(PERSISTENCE_FILE), "read_text", return_value=""): - result = ProfileManager.load_persisted_profile() - assert result is None - finally: - os.unlink(temp_path) + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True): + with patch.object(type(PERSISTENCE_FILE), "read_text", return_value=""): + result = ProfileManager.load_persisted_profile() + assert result is None def test_load_missing_file(self): """Missing persistence file returns None.""" @@ -624,6 +621,118 @@ class TestProfilePersistence(unittest.TestCase): result = ProfileManager.load_persisted_profile() assert result is None + def test_load_persisted_data_valid_json(self): + """Valid JSON file is loaded correctly.""" + data = {"active": "home", "last_activated": {"home": 1700000000.0}} + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True): + with patch.object( + type(PERSISTENCE_FILE), + "read_text", + return_value=json.dumps(data), + ): + result = ProfileManager._load_persisted_data() + assert result == data + + def test_load_persisted_data_invalid_json(self): + """Invalid JSON returns default structure.""" + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True): + with patch.object( + type(PERSISTENCE_FILE), "read_text", return_value="not json" + ): + result = ProfileManager._load_persisted_data() + assert result == {"active": None, "last_activated": {}} + + def test_load_persisted_data_missing_file(self): + """Missing file returns default structure.""" + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=False): + result = ProfileManager._load_persisted_data() + assert result == {"active": None, "last_activated": {}} + + def test_persist_records_timestamp(self): + """Persisting a profile records the activation timestamp.""" + config_data = { + "mqtt": {"host": "mqtt"}, + "profiles": {"armed": {"friendly_name": "Armed"}}, + "cameras": { + "front": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "profiles": {"armed": {"detect": {"enabled": True}}}, + }, + }, + } + if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR): + os.makedirs(MODEL_CACHE_DIR) + config = FrigateConfig(**config_data) + manager = ProfileManager(config, MagicMock()) + + written_data = {} + + def mock_write(content): + written_data.update(json.loads(content)) + + with patch.object( + ProfileManager, + "_load_persisted_data", + return_value={"active": None, "last_activated": {}}, + ): + with patch.object(type(PERSISTENCE_FILE), "write_text", mock_write): + manager._persist_active_profile("armed") + + assert written_data["active"] == "armed" + assert "armed" in written_data["last_activated"] + assert isinstance(written_data["last_activated"]["armed"], float) + + def test_persist_deactivate_keeps_timestamps(self): + """Deactivating sets active to None but preserves last_activated.""" + existing = { + "active": "armed", + "last_activated": {"armed": 1700000000.0}, + } + written_data = {} + + def mock_write(content): + written_data.update(json.loads(content)) + + config_data = { + "mqtt": {"host": "mqtt"}, + "profiles": {"armed": {"friendly_name": "Armed"}}, + "cameras": { + "front": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "profiles": {"armed": {"detect": {"enabled": True}}}, + }, + }, + } + if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR): + os.makedirs(MODEL_CACHE_DIR) + config = FrigateConfig(**config_data) + manager = ProfileManager(config, MagicMock()) + + with patch.object( + ProfileManager, "_load_persisted_data", return_value=existing + ): + with patch.object(type(PERSISTENCE_FILE), "write_text", mock_write): + manager._persist_active_profile(None) + + assert written_data["active"] is None + assert written_data["last_activated"]["armed"] == 1700000000.0 + if __name__ == "__main__": unittest.main() diff --git a/web/src/types/profile.ts b/web/src/types/profile.ts index ea3273ecac..d3c2a6dcd6 100644 --- a/web/src/types/profile.ts +++ b/web/src/types/profile.ts @@ -14,6 +14,7 @@ export type ProfileInfo = { export type ProfilesApiResponse = { profiles: ProfileInfo[]; active_profile: string | null; + last_activated: Record; }; export type ProfileState = {