diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index 6aba8f1942..e0a40ee353 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -5,7 +5,7 @@ import json import logging from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Any, Callable, Optional from frigate.config.camera.updater import ( CameraConfigUpdateEnum, @@ -34,6 +34,45 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = { "zones": CameraConfigUpdateEnum.zones, } +# Retained MQTT switch topics per profile section, with a payload getter. +# Republished on profile change so MQTT/HA don't show a stale toggle. +SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = { + "audio": [("audio", lambda c: "ON" if c.audio.enabled else "OFF")], + "birdseye": [ + ("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"), + ( + "birdseye_mode", + lambda c: c.birdseye.mode.value.upper() if c.birdseye.enabled else "OFF", + ), + ], + "detect": [("detect", lambda c: "ON" if c.detect.enabled else "OFF")], + "motion": [ + ("motion", lambda c: "ON" if c.motion.enabled else "OFF"), + ("improve_contrast", lambda c: "ON" if c.motion.improve_contrast else "OFF"), + ("motion_threshold", lambda c: c.motion.threshold), + ("motion_contour_area", lambda c: c.motion.contour_area), + ], + "notifications": [ + ("notifications", lambda c: "ON" if c.notifications.enabled else "OFF"), + ], + "objects": [ + ("object_descriptions", lambda c: "ON" if c.objects.genai.enabled else "OFF"), + ], + "record": [("recordings", lambda c: "ON" if c.record.enabled else "OFF")], + "review": [ + ("review_alerts", lambda c: "ON" if c.review.alerts.enabled else "OFF"), + ( + "review_detections", + lambda c: "ON" if c.review.detections.enabled else "OFF", + ), + ( + "review_descriptions", + lambda c: "ON" if c.review.genai.enabled else "OFF", + ), + ], + "snapshots": [("snapshots", lambda c: "ON" if c.snapshots.enabled else "OFF")], +} + PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles" @@ -310,6 +349,15 @@ class ProfileManager: settings, ) + # republish MQTT switch states + if self.dispatcher is not None: + for suffix, get_payload in SECTION_STATE_TOPICS.get(section, ()): + self.dispatcher.publish( + f"{cam_name}/{suffix}/state", + get_payload(cam_config), + retain=True, + ) + def _persist_active_profile(self, profile_name: Optional[str]) -> None: """Persist the active profile state to disk as JSON.""" try: diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py index 6766b39163..59dc077466 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -1,5 +1,6 @@ """Tests for the profiles system.""" +import copy import json import os import unittest @@ -746,6 +747,36 @@ class TestProfileManager(unittest.TestCase): manager.activate_profile(None) dispatcher.clear_runtime_state.assert_called_once_with() + @patch.object(ProfileManager, "_persist_active_profile") + def test_profile_change_republishes_switch_states(self, mock_persist): + """Profile changes republish MQTT switch states so HA stays in sync. + + Regression: activating/deactivating a profile updated the in-memory + config (and Frigate's behavior) but left the retained MQTT state + topics stale, so external integrations like Home Assistant kept + showing the pre-profile toggle position. + """ + config_data = copy.deepcopy(self.config_data) + config_data["cameras"]["front"]["profiles"]["disarmed"]["review"] = { + "alerts": {"enabled": False}, + } + config = FrigateConfig(**config_data) + dispatcher = MagicMock() + manager = ProfileManager(config, self.mock_updater, dispatcher) + + # Activating disarmed turns alerts off -> MQTT state must follow + manager.activate_profile("disarmed") + dispatcher.publish.assert_any_call( + "front/review_alerts/state", "OFF", retain=True + ) + + # Deactivating restores the base (alerts on) -> MQTT state must follow + dispatcher.publish.reset_mock() + manager.activate_profile(None) + dispatcher.publish.assert_any_call( + "front/review_alerts/state", "ON", retain=True + ) + @patch.object(ProfileManager, "_persist_active_profile") def test_startup_replay_does_not_clear_runtime_state(self, mock_persist): """Startup callers pass clear_runtime_overrides=False to preserve state."""