Register per-camera notifications MQTT callbacks (#23637)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* fix per-camera notification MQTT topics never being registered

register notifications/set and notifications/suspend callbacks for each camera, and gate the global notifications topics on per-camera config as well as global (matching WebPushClient creation in app.py). Unregistered topics were silently dropped by paho since only registered callbacks receive messages.

* add tests
This commit is contained in:
Josh Hawkins 2026-07-05 17:24:39 -05:00 committed by GitHub
parent 1c745e0847
commit a26487c4f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 121 additions and 2 deletions

View File

@ -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,

View File

@ -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()