diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 89a986e08..b02dbffb0 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -41,6 +41,18 @@ class MqttClient(Communicator): self.publish("available", "stopped", retain=True) self.client.disconnect() + def _notifications_enabled_in_config(self) -> bool: + """Whether notifications are configured globally or on any camera. + + Notifications can be enabled per camera with the global config left + disabled, so the global topics must consider both (matching how + app.py decides to create the WebPushClient). + """ + return self.config.notifications.enabled_in_config or any( + cam.enabled and cam.notifications.enabled_in_config + for cam in self.config.cameras.values() + ) + def _set_initial_topics(self) -> None: """Set initial state topics.""" for camera_name, camera in self.config.cameras.items(): @@ -157,7 +169,7 @@ class MqttClient(Communicator): retain=True, ) - if self.config.notifications.enabled_in_config: + if self._notifications_enabled_in_config(): self.publish( "notifications/state", "ON" if self.config.notifications.enabled else "OFF", @@ -256,6 +268,7 @@ class MqttClient(Communicator): "review_detections", "object_descriptions", "review_descriptions", + "notifications", ] for name in self.config.cameras.keys(): @@ -265,6 +278,12 @@ class MqttClient(Communicator): self.on_mqtt_command, ) + # notifications suspend doesn't follow the /set topic pattern + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/{name}/notifications/suspend", + self.on_mqtt_command, + ) + if self.config.cameras[name].onvif.host: self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/{name}/ptz", @@ -289,7 +308,7 @@ class MqttClient(Communicator): self.on_mqtt_command, ) - if self.config.notifications.enabled_in_config: + if self._notifications_enabled_in_config(): self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/notifications/set", self.on_mqtt_command, diff --git a/frigate/test/test_mqtt_topic_registration.py b/frigate/test/test_mqtt_topic_registration.py new file mode 100644 index 000000000..309fafb98 --- /dev/null +++ b/frigate/test/test_mqtt_topic_registration.py @@ -0,0 +1,100 @@ +"""Tests for MQTT command topic callback registration.""" + +import unittest +from unittest.mock import MagicMock, patch + +from frigate.comms.mqtt import MqttClient + + +def _make_camera_mock( + *, + enabled: bool = True, + notifications_enabled_in_config: bool = False, +) -> MagicMock: + """Build a camera config mock with the fields _start() reads.""" + camera = MagicMock() + camera.enabled = enabled + camera.notifications.enabled_in_config = notifications_enabled_in_config + camera.onvif.host = None + camera.motion.mask = {} + camera.objects.mask = {} + camera.zones = {} + return camera + + +def _registered_topics( + cameras: dict[str, MagicMock], + *, + global_notifications_enabled_in_config: bool = False, +) -> set[str]: + """Start an MqttClient against a mocked paho client and collect the + topics registered via message_callback_add.""" + config = MagicMock() + config.cameras = cameras + config.notifications.enabled_in_config = global_notifications_enabled_in_config + config.mqtt.topic_prefix = "frigate" + config.mqtt.client_id = "frigate" + config.mqtt.user = None + config.mqtt.tls_ca_certs = None + config.mqtt.tls_insecure = None + + with patch("frigate.comms.mqtt.mqtt.Client") as client_cls: + mqtt_client = MqttClient(config) + mqtt_client.subscribe(MagicMock()) + + paho_client = client_cls.return_value + return {call.args[0] for call in paho_client.message_callback_add.call_args_list} + + +class TestMqttTopicRegistration(unittest.TestCase): + def test_camera_notification_topics_registered(self): + """Per-camera notification set/suspend must be registered so paho + routes them to the dispatcher (unregistered topics drop silently).""" + topics = _registered_topics( + {"front_door": _make_camera_mock(notifications_enabled_in_config=True)} + ) + + self.assertIn("frigate/front_door/notifications/set", topics) + self.assertIn("frigate/front_door/notifications/suspend", topics) + + def test_global_set_registered_with_camera_only_notifications(self): + """The global topic must work when notifications are enabled only at + the camera level, matching the WebPushClient gating in app.py.""" + topics = _registered_topics( + {"front_door": _make_camera_mock(notifications_enabled_in_config=True)}, + global_notifications_enabled_in_config=False, + ) + + self.assertIn("frigate/notifications/set", topics) + + def test_global_set_registered_with_global_notifications(self): + topics = _registered_topics( + {"front_door": _make_camera_mock()}, + global_notifications_enabled_in_config=True, + ) + + self.assertIn("frigate/notifications/set", topics) + + def test_global_set_not_registered_when_notifications_unconfigured(self): + topics = _registered_topics( + {"front_door": _make_camera_mock()}, + global_notifications_enabled_in_config=False, + ) + + self.assertNotIn("frigate/notifications/set", topics) + + def test_disabled_camera_does_not_enable_global_set(self): + topics = _registered_topics( + { + "front_door": _make_camera_mock( + enabled=False, notifications_enabled_in_config=True + ) + }, + global_notifications_enabled_in_config=False, + ) + + self.assertNotIn("frigate/notifications/set", topics) + + +if __name__ == "__main__": + unittest.main()