import logging import threading from collections.abc import Callable from typing import Any import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion from frigate.comms.base_communicator import Communicator from frigate.config import FrigateConfig logger = logging.getLogger(__name__) class MqttClient(Communicator): """Frigate wrapper for mqtt client.""" def __init__(self, config: FrigateConfig) -> None: self.config = config self.mqtt_config = config.mqtt self.connected = False def subscribe(self, receiver: Callable) -> None: """Wrapper for allowing dispatcher to subscribe.""" self._dispatcher = receiver self._start() def publish(self, topic: str, payload: Any, retain: bool = False) -> None: """Wrapper for publishing when client is in valid state.""" if not self.connected: logger.debug(f"Unable to publish to {topic}: client is not connected") return self.client.publish( f"{self.mqtt_config.topic_prefix}/{topic}", payload, qos=self.config.mqtt.qos, retain=retain, ) def stop(self) -> None: 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(): self.publish( f"{camera_name}/enabled/state", "ON" if camera.enabled_in_config else "OFF", retain=True, ) self.publish( f"{camera_name}/recordings/state", "ON" if camera.record.enabled_in_config else "OFF", retain=True, ) self.publish( f"{camera_name}/snapshots/state", "ON" if camera.snapshots.enabled else "OFF", retain=True, ) self.publish( f"{camera_name}/audio/state", "ON" if camera.audio.enabled_in_config else "OFF", retain=True, ) self.publish( f"{camera_name}/audio_transcription/state", "ON" if camera.audio_transcription.live_enabled else "OFF", retain=True, ) self.publish( f"{camera_name}/detect/state", "ON" if camera.detect.enabled else "OFF", retain=True, ) self.publish( f"{camera_name}/motion/state", "ON", retain=True, ) self.publish( f"{camera_name}/improve_contrast/state", "ON" if camera.motion.improve_contrast else "OFF", retain=True, ) self.publish( f"{camera_name}/ptz_autotracker/state", "ON" if camera.onvif.autotracking.enabled_in_config else "OFF", retain=True, ) self.publish( f"{camera_name}/motion_threshold/state", camera.motion.threshold, retain=True, ) self.publish( f"{camera_name}/motion_contour_area/state", camera.motion.contour_area, retain=True, ) self.publish( f"{camera_name}/motion", "OFF", retain=False, ) self.publish( f"{camera_name}/birdseye/state", "ON" if camera.birdseye.enabled else "OFF", retain=True, ) self.publish( f"{camera_name}/birdseye_mode/state", ( camera.birdseye.mode.value.upper() if camera.birdseye.enabled else "OFF" ), retain=True, ) self.publish( f"{camera_name}/review_alerts/state", "ON" if camera.review.alerts.enabled_in_config else "OFF", retain=True, ) self.publish( f"{camera_name}/review_detections/state", "ON" if camera.review.detections.enabled_in_config else "OFF", retain=True, ) self.publish( f"{camera_name}/object_descriptions/state", "ON" if camera.objects.genai.enabled_in_config else "OFF", retain=True, ) self.publish( f"{camera_name}/review_descriptions/state", "ON" if camera.review.genai.enabled_in_config else "OFF", retain=True, ) for mask_name, motion_mask in camera.motion.mask.items(): if motion_mask: self.publish( f"{camera_name}/motion_mask/{mask_name}/state", "ON" if motion_mask.enabled else "OFF", retain=True, ) for mask_name, object_mask in camera.objects.mask.items(): if object_mask: self.publish( f"{camera_name}/object_mask/{mask_name}/state", "ON" if object_mask.enabled else "OFF", retain=True, ) for zone_name, zone in camera.zones.items(): self.publish( f"{camera_name}/zone/{zone_name}/state", "ON" if zone.enabled else "OFF", retain=True, ) if self._notifications_enabled_in_config(): self.publish( "notifications/state", "ON" if self.config.notifications.enabled else "OFF", retain=True, ) self.publish( "profile/state", self.config.active_profile or "none", retain=True, ) self.publish("available", "online", retain=True) def on_mqtt_command( self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage ) -> None: self._dispatcher( message.topic.replace(f"{self.mqtt_config.topic_prefix}/", "", 1), message.payload.decode(), ) def _on_connect( self, client: mqtt.Client, userdata: Any, flags: Any, reason_code: mqtt.ReasonCode, # type: ignore[name-defined] properties: Any, ) -> None: """Mqtt connection callback.""" threading.current_thread().name = "mqtt" if reason_code != 0: if reason_code == "Server unavailable": logger.error( "Unable to connect to MQTT server: MQTT Server unavailable" ) elif reason_code == "Bad user name or password": logger.error( "Unable to connect to MQTT server: MQTT Bad username or password" ) elif reason_code == "Not authorized": logger.error("Unable to connect to MQTT server: MQTT Not authorized") else: logger.error( "Unable to connect to MQTT server: Connection refused. Error code: %s", reason_code.getName(), ) self.connected = True logger.debug("MQTT connected") client.subscribe(f"{self.mqtt_config.topic_prefix}/#", qos=self.config.mqtt.qos) self._set_initial_topics() def _on_disconnect( self, client: mqtt.Client, userdata: Any, flags: Any, reason_code: mqtt.ReasonCode, # type: ignore[name-defined] properties: Any, ) -> None: """Mqtt disconnection callback.""" self.connected = False logger.error("MQTT disconnected") def _start(self) -> None: """Start mqtt client.""" self.client = mqtt.Client( callback_api_version=CallbackAPIVersion.VERSION2, client_id=self.mqtt_config.client_id, ) self.client.on_connect = self._on_connect self.client.on_disconnect = self._on_disconnect self.client.will_set( self.mqtt_config.topic_prefix + "/available", payload="offline", qos=1, retain=True, ) # register callbacks callback_types = [ "enabled", "recordings", "snapshots", "detect", "audio", "audio_transcription", "motion", "improve_contrast", "ptz_autotracker", "motion_threshold", "motion_contour_area", "birdseye", "birdseye_mode", "review_alerts", "review_detections", "object_descriptions", "review_descriptions", "notifications", ] for name in self.config.cameras.keys(): for callback in callback_types: self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/{name}/{callback}/set", 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", self.on_mqtt_command, ) for mask_name in self.config.cameras[name].motion.mask.keys(): self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/{name}/motion_mask/{mask_name}/set", self.on_mqtt_command, ) for mask_name in self.config.cameras[name].objects.mask.keys(): self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/{name}/object_mask/{mask_name}/set", self.on_mqtt_command, ) for zone_name in self.config.cameras[name].zones.keys(): self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/{name}/zone/{zone_name}/set", self.on_mqtt_command, ) if self._notifications_enabled_in_config(): self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/notifications/set", self.on_mqtt_command, ) self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/profile/set", self.on_mqtt_command, ) self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/onConnect", self.on_mqtt_command ) self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command ) if self.mqtt_config.tls_ca_certs is not None: if ( self.mqtt_config.tls_client_cert is not None and self.mqtt_config.tls_client_key is not None ): self.client.tls_set( self.mqtt_config.tls_ca_certs, self.mqtt_config.tls_client_cert, self.mqtt_config.tls_client_key, ) else: self.client.tls_set(self.mqtt_config.tls_ca_certs) if self.mqtt_config.tls_insecure is not None: self.client.tls_insecure_set(self.mqtt_config.tls_insecure) if self.mqtt_config.user is not None: self.client.username_pw_set( self.mqtt_config.user, password=self.mqtt_config.password ) try: # https://stackoverflow.com/a/55390477 # with connect_async, retries are handled automatically self.client.connect_async(self.mqtt_config.host, self.mqtt_config.port, 60) self.client.loop_start() except Exception as e: logger.error(f"Unable to connect to MQTT server: {e}") return