Build Your Own Smart Desktop Notifications for Frigate & Home Assistant

Looking for a highly customisable way to get instant alerts from your smart home directly on your desktop, especially when other solutions haven't quite met your needs?

Many users, myself included, have found that while solutions like the Home Assistant Companion App offer a great starting point for notifications, they sometimes don't consistently deliver rich media (like Frigate snapshots) or seamlessly unify all voice alerts across different systems. If you've encountered similar challenges, or simply prefer a more hands-on approach to tailor your notifications exactly how you like them, this guide is for you. In my experience, this custom-built solution has provided reliable visual and audio alerts, giving me complete control over my desktop notifications.

With this setup, you can expect:

Of course, if you have other operating systems and you are happy to try yourself, then that's up to you. I only have Windows and Debian and that's why I did this.

All the example setup is done with 4 cameras but can easily be changed depending on your setup.

Ready to take control of your home security alerts and finally get the desktop notifications that truly meet your needs? Let's get started.

Even though I have found this reliable, please note if you decide to follow my guide you are doing it at your own risk and I am not liable for anything that might go wrong.

1. Get Your Foundations Ready (Core Components)

Before we dive into the desktop magic, let's make sure your core smart home components are in place. These steps are the same regardless of whether you're using Windows or Debian.

A. Frigate & MQTT: The Detection Brain & The Messenger

You'll need a running **Frigate** instance that's detecting objects and publishing events to an **MQTT broker**.

Finding Your Frigate MQTT Topics

To configure your config.yaml and Node-RED flow correctly, you'll need to know the exact MQTT topics Frigate is publishing. There are a couple of easy ways to find these:

Using MQTT Explorer (Recommended Desktop Tool)

MQTT Explorer is a fantastic desktop application that allows you to easily inspect all traffic on your MQTT broker. This is often the quickest way to see the topics and payloads in real-time.

  1. Download MQTT Explorer: Get it from mqtt-explorer.com and install it on your desktop.
  2. Connect to your Broker:
  3. Observe Topics:
  4. Note Down Topics: Make a note of the exact camera names and detected object labels (e.g., `dog`, `person`, `car` (So you could have backgardencam dog)) that Frigate is publishing events and snapshots for. These are what you'll use in your config.yaml and Node-RED flow.
Using Home Assistant Developer Tools (MQTT)

You can also listen to MQTT topics directly from within Home Assistant's Developer Tools.

  1. Navigate to Developer Tools: In Home Assistant, go to Developer Tools (the icon that looks like `< >`).
  2. Go to MQTT: Click on the MQTT tab.
  3. Listen to a Topic:
  4. Note Down Topics: Similar to MQTT Explorer, identify the full topics for your camera events and snapshots.

B. Your Notification Settings: config.yaml

This file holds all the key settings for your desktop notifier, like MQTT connection details, camera names, and the exact phrases for your voice alerts.

  1. Create the Folder: On your desktop computer (Windows or Debian), create a new folder where you'll store all the notifier files.
  2. Create config.yaml: Inside this new folder, create a new text file and name it config.yaml.
  3. Copy & Paste: Copy the entire code block below and **paste it** into your config.yaml file.
    # config.yaml
    # Configuration for the Windows/Linux Frigate Notifier Script
    
    mqtt:
      broker: "YOUR_MQTT_BROKER_IP" # IMPORTANT: Replace with your MQTT broker's IP address or hostname (e.g., 192.168.1.100)
      port: 1883
      username: "YOUR_MQTT_USERNAME" # IMPORTANT: Replace with your actual MQTT username
      password: "YOUR_MQTT_PASSWORD" # IMPORTANT: Replace with your actual MQTT password
      control_topic: "notifier/control"
      snapshot_base_topic: "notifier/" # Base topic for camera snapshots (e.g., notifier/camera_one/snapshot) No need to change this line!
    
    general_settings:
      image_cache_dir: "MotionNotifierCache" # For Windows: relative path; For Linux: will be ~/.cache/MotionNotifierCache
      cooldown_period_seconds: 10 # Minimum time between image pop-ups from ANY camera (per camera)
      enable_large_image_popup: true
      large_image_display_duration_seconds: 5
      image_display_width: 320 # Width for the image pop-up
      image_display_height: 240 # Height for the image pop-up
      fixed_offset_x: 10 # Offset from right of screen
      fixed_offset_y: 10 # Offset from bottom of screen
      border_width: 3
      window_internal_padding: 5
      text_label_height_estimate: 25
      tts_script_path: "tts_speaker.py" # Relative to notifier script's directory. ENSURE THE CORRECT tts_speaker.py FOR YOUR OS IS IN THIS DIRECTORY.
      tts_enabled_on_startup: true # Initial state of global TTS toggle
      initial_volume: 1.0 # Initial TTS volume (0.0 to 1.0) - Note: tts_speaker.py will ignore this for now.
      default_border_color: "#800080" # Fallback border color if camera not specified
    
    camera_configurations:
      # Define each camera that this notifier should handle.
      # The 'topic_suffix' here corresponds to the MQTT topic your Node-RED
      # will publish to for this camera's snapshots.
      camera_one: # this would be the actual name of the camera found in mqtt (e.g. 'gardencam')
        topic_suffix: "camera_one/snapshot" # an example might be 'gardencam/snapshot'
        enabled_on_startup: true
        border_color: "#FF4500" # OrangeRed
      camera_two:
        topic_suffix: "camera_two/snapshot"
        enabled_on_startup: true
        border_color: "#FFD700" # Gold
      camera_three:
        topic_suffix: "camera_three/snapshot"
        enabled_on_startup: true
        border_color: "#1E90FF" # DodgerBlue
      camera_four:
        topic_suffix: "camera_four/snapshot"
        enabled_on_startup: true
        border_color: "#32CD32" # LimeGreen
      # Add new cameras here in the future, e.g.:
      # camera_five:
      #   topic_suffix: "camera_five/snapshot"
      #   enabled_on_startup: true
      #   border_color: "#8A2BE2" # BlueViolet
    
    tts_messages:
      # Define specific TTS messages for each camera and detected object.
      # These will be used by the notifier script.
      camera_one: 
        person: "A person is in the back garden!"
        dog: "Our dog is in the back garden!"
      camera_two:
        person: "Person detected on the drive!"
        car: "Car detected on the drive!"
      camera_three:
        person: "Person detected at the door!"
      camera_four:
        person: "Person detected to the right!"
        car: "Car detected to the right!"
      # Add default fallback message if no specific message is found
      default: "Motion detected somewhere!"
    
  4. Edit These Lines (CRITICAL!):

C. Python 3 and Virtual Environment (venv)

It's crucial to set up Python 3 within a virtual environment to manage dependencies properly and avoid conflicts with your system's Python installations.

Before proceeding, ensure you have Python 3 installed on your system. If not, follow the instructions below for your operating system.

Installing Python 3:

  1. Open Terminal/Command Prompt: Navigate to the FrigateNotifier folder you created earlier (e.g., cd C:\Users\YOUR_WINDOWS_USERNAME\Documents\FrigateNotifier or cd ~/FrigateNotifier).
  2. Create Virtual Environment:
    python3 -m venv venv
  3. Activate Virtual Environment:

    You should see (venv) prefixing your command prompt, indicating the virtual environment is active.

  4. Install Python Packages:

    With your virtual environment activated, install the required Python packages:

    pip install gtts PyYAML Pillow paho-mqtt

    Note for Windows users: You will also need to install `pywin32` for the Windows notifier script:

    pip install pywin32

    Note for Debian/Linux users: You will also need to install `playsound` for the Debian notifier script:

    pip install playsound
    Additionally, `playsound` might require additional system dependencies for audio playback (e.g., `gstreamer`). If you encounter issues, try installing `gstreamer` plugins:
    sudo apt install python3-gi python3-gst-1.0 gir1.2-gst-plugins-base-1.0
    Or, depending on your desktop environment, you might need:
    sudo apt install libgstreamer1.0-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-doc gstreamer1.0-tools gstreamer1.0-x gstreamer1.0-alsa gstreamer1.0-gl gstreamer1.0-gtk3 gstreamer1.0-qt5 gstreamer1.0-pulseaudio

D. The Voice Script: tts_speaker.py (Choose ONE based on your OS)

This script handles all the text-to-speech magic. You will place one of the following two versions into your FrigateNotifier folder, and name it tts_speaker.py.

IMPORTANT: The tts_script_path in your config.yaml should be set to "tts_speaker.py". Ensure you only have *one* file named tts_speaker.py in your FrigateNotifier directory, and it should be the version appropriate for your operating system.

Important Note on Text-to-Speech (gTTS): The gTTS library relies on Google's online text-to-speech services. Therefore, your desktop computer running the notifier script must have an active internet connection for the voice alerts to work correctly."

For Windows (using FFmpeg's ffplay)

This version uses `ffplay` (part of FFmpeg) for audio playback. You'll need to install FFmpeg separately.

  1. Install FFmpeg:

    Download and install FFmpeg from their official website: https://ffmpeg.org/download.html. Make sure to add the FFmpeg `bin` directory to your system's PATH environment variable so `ffplay` can be found by the script.

  2. Create tts_speaker.py: In your FrigateNotifier folder, create a new file named tts_speaker.py.
  3. Copy & Paste: Copy the entire code block below and **paste it** into your tts_speaker.py file.
    # tts_speaker.py (ffplay version for Windows)
    # This script takes a text string as a command-line argument and speaks it aloud using gTTS.
    # It requires FFmpeg (specifically ffplay) to be installed and in your system's PATH.
    
    import sys
    import os
    import logging
    import tempfile
    from gtts import gTTS
    import subprocess
    
    logging.basicConfig(level=logging.INFO, format='%(asctime)s - TTS_SPEAKER - %(levelname)s - %(message)s')
    
    def speak_text(text):
        """Generates speech using gTTS and plays it using ffplay."""
        temp_audio_file = None
        try:
            logging.info(f"Attempting to speak: '{text}' using gTTS (en-uk voice)")
            tts = gTTS(text=text, lang='en-uk', slow=False) # lang='en-uk' for British English
    
            with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as fp:
                temp_audio_file = fp.name
                tts.save(temp_audio_file)
            
            logging.info(f"Speech saved to temporary file: {temp_audio_file}")
    
            # Play the temporary audio file using ffplay (from FFmpeg)
            # -nodisp: don't show a video window
            # -autoexit: exit when playback finishes
            # -i: input file
            # creationflags=subprocess.CREATE_NO_WINDOW: IMPORTANT for silent operation on Windows
            subprocess.run(['ffplay', '-nodisp', '-autoexit', '-i', temp_audio_file], 
                           stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, timeout=10,
                           creationflags=subprocess.CREATE_NO_WINDOW)
            
            logging.info("TTS playback complete.")
    
        except FileNotFoundError:
            logging.error("ffplay command not found. Please ensure FFmpeg is installed and its 'bin' directory is in your system's PATH.")
            logging.error("Download FFmpeg from https://ffmpeg.org/download.html")
        except subprocess.CalledProcessError as e:
            logging.error(f"ffplay failed with exit code {e.returncode}: {e.stderr.decode().strip()}")
        except subprocess.TimeoutExpired:
            logging.error("TTS script timed out after 10 seconds.")
        except Exception as e:
            logging.error(f"Error during gTTS generation or ffplay playback: {type(e).__name__}: {e}")
        finally:
            if temp_audio_file and os.path.exists(temp_audio_file):
                os.remove(temp_audio_file)
                logging.info(f"Temporary audio file removed: {temp_audio_file}")
    
    if __name__ == "__main__":
        if len(sys.argv) > 1:
            text_to_speak = sys.argv[1]
            speak_text(text_to_speak)
        else:
            logging.warning("No text provided as a command-line argument. Usage: python tts_speaker.py 'Your text here'")
            print("Usage: python tts_speaker.py 'Your text here'")
    

For Debian/Linux (using playsound)

This version uses the `playsound` library for audio playback. It might require additional system dependencies on Linux.

  1. Create tts_speaker.py: In your FrigateNotifier folder, create a new file named tts_speaker.py.
  2. Copy & Paste: Copy the entire code block below and **paste it** into your tts_speaker.py file.
    # tts_speaker.py (playsound version for Debian/Linux)
    # This script takes a text string as a command-line argument and speaks it aloud using gTTS.
    # It requires an internet connection to function.
    
    import sys
    import os
    import logging
    from gtts import gTTS
    from playsound import playsound
    import tempfile
    
    logging.basicConfig(level=logging.INFO, format='%(asctime)s - TTS_SPEAKER - %(levelname)s - %(message)s')
    
    def speak_text(text):
        """Generates speech using gTTS and plays it."""
        temp_audio_path = None
        try:
            logging.info(f"Attempting to speak: '{text}' using gTTS (en-uk voice)")
            
            # Create gTTS object with British English voice in this example
            # Using 'en' for language and 'co.uk' for top-level domain helps ensure British accent.
            # Other choices American tld='com' Australian tld='com.au' Irish tld='ie' Indian English tld='co.in'
            tts = gTTS(text=text, lang='en', tld='co.uk') 
    
            # Create a temporary file to save the speech audio
            # Use a specific suffix to ensure it's recognized as an MP3
            with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as fp:
                temp_audio_path = fp.name
            
            tts.save(temp_audio_path)
            logging.info(f"Speech saved to temporary file: {temp_audio_path}")
    
            # Play the temporary audio file using playsound
            playsound(temp_audio_path)
            logging.info("TTS playback complete.")
    
        except Exception as e:
            logging.error(f"Error during gTTS or playsound: {type(e).__name__}: {e}")
            logging.error("This often indicates no internet connection, or missing audio playback dependencies (e.g., gstreamer on Linux for playsound).")
        finally:
            # Clean up the temporary audio file
            if 'temp_audio_path' in locals() and os.path.exists(temp_audio_path):
                try:
                    os.remove(temp_audio_path)
                    logging.info(f"Temporary audio file removed: {temp_audio_path}")
                except Exception as e:
                    logging.warning(f"Could not remove temporary audio file {temp_audio_path}: {e}")
    
    if __name__ == "__main__":
        if len(sys.argv) > 1:
            text_to_speak = sys.argv[1]
            speak_text(text_to_speak)
        else:
            logging.warning("No text provided as a command-line argument. Usage: python tts_speaker.py 'Your text here'")
            print("Usage: python tts_speaker.py 'Your text here'")
    

2. Node-RED: The Intelligent Bridge

Node-RED acts as the brain that connects Frigate's raw messages to your desktop notifier. It processes events, grabs snapshots, and then sends a neatly packaged alert to your computer.

A. Node-RED Installation

If you're already using Node-RED, you can skip this. Otherwise, the easiest way to run it alongside Home Assistant is via its official add-on.

B. Import the Node-RED Flow

This JSON block contains the entire Node-RED flow. This flow listens for Frigate events, finds the matching snapshot, combines them, encodes the image, and sends it all to your notifier's MQTT topic. It also includes the dashboard elements and volume control.

  1. Open Node-RED Editor: Go to your Node-RED editor (usually http://YOUR_HOME_ASSISTANT_IP:1880 or via the Home Assistant add-on web UI).
  2. Install Node-RED Dashboard:

    If you haven't already, you'll need the Node-RED Dashboard nodes to display images and controls.

  3. Import the Flow:

    IMPORTANT! Customise this flow for your cameras: The imported flow uses generic camera names (camera_one, camera_two, etc.). You must change these to the **exact names** you found for your own cameras earlier when you were finding your MQTT topics.

    You will need to do the following for each of your cameras:

    1. For each `mqtt in` node: Double-click the node to open its properties. In the "Topic" field, replace frigate/generic_name/person (or car, dog, etc.) with your camera's actual name (e.g., frigate/backgardencam/person). Do the same for the snapshot 'mqtt' in node (e.g., frigate/backgardencam/person/snapshot). Change the "Name" field to match.
    2. In all the 'join' nodes: Change the 'Name' field to match the camera names (e.g., Join backgardencam person)
    3. In all the 'function' nodes 'function camera_...': Change the 'Name' field to match the camera names (e.g., function backgardencam person). Change all instances of the camera name and object to match your specific camera name and object name (e.g. frigate/backgardencam/person/snapshot) or (frigate/backgardencam/person)
    4. In the `function` node 'Prepare JSON Payload & Dashboard Image': In the code, you will find lines like const msgForCameraOneDashboard = (cameraName === "camera_one") ? { payload: dashboardImagePayload } : null;. Change all instances of "camera_one", "camera_two", etc., to your specific camera names to ensure the dashboard images display correctly.
    5. In the 'Edit template' nodes: Edit the 'Name' fields and after theh3tags to be descriptive for your cameras (e.g., Backgardencam).
    
    
    
    
    
  4. Configure MQTT Broker in Node-RED (CRITICAL!):

    After importing, the nodes will appear on your canvas. You need to tell them which MQTT broker to use.

    Quick Explanation of the Flow (Updated for Join Node):

    Remember to configure the MQTT Broker in Node-RED for EACH MQTT node. Double-click the MQTT nodes and ensure they point to your actual MQTT broker using the pencil icon. The example flow uses `your_mqtt_broker_config_id` as a placeholder.

  5. Deploy the Flow: Once you've imported the flow and configured the MQTT broker, click the **"Deploy"** button in the top right corner of the Node-RED editor to activate it.
  6. Check the Dashboard: You can view the Node-RED Dashboard by clicking the dashboard icon (usually a square with a small dot) on the top right sidebar, or by navigating to http://YOUR_HOME_ASSISTANT_IP:1880/ui. You should see the live image display and the control switches.

3. The Desktop Notifier Application (Windows & Debian)

This is the Python script that runs on your desktop, constantly listening for MQTT messages from Node-RED. When it receives a message, it pops up the image and plays the TTS message.

A. Windows Notifier (win_notifier.py)

This script is tailored for Windows, using tkinter for the pop-up window and `win32api`/`win32gui` for fullscreen detection.

  1. Create win_notifier.py: In your FrigateNotifier folder (the same one with config.yaml and tts_speaker.py), create a new file named win_notifier.py. Again, remember to change "Save as type" to "All Files (*.)" and add the `.py` extension manually if using Notepad.
  2. Copy & Paste: Copy the entire code block below and **paste it** into your win_notifier.py file.
    # --- SCRIPT VERSION: 20250717.08 (JSON Payload & Config TTS - Compatible with User's gTTS) ---
    # This version expects JSON payload with image_data and detected_label from Node-RED.
    # It calls tts_speaker.py without a volume argument to match the user's provided script.
    
    # --- IMMEDIATE DEBUGGING START ---
    import sys
    import subprocess # For calling external TTS script
    import threading # For TTS queue, image queue, and fullscreen monitor
    import collections # For deque (double-ended queue)
    import os # For path manipulation
    sys.stdout.reconfigure(line_buffering=True)
    sys.stderr.reconfigure(line_buffering=True)
    print("DEBUG: Script started. Attempting imports...")
    # --- IMMEDIATE DEBUGGING END ---
    
    import logging
    import time
    import json
    import tkinter as tk
    from PIL import Image, ImageTk
    
    # Use a try-except block for critical imports to catch issues early
    try:
        import paho.mqtt.client as mqtt
        import win32api 
        import win32gui 
        import yaml # New import for YAML parsing
        print("DEBUG: All core imports successful.")
    except ImportError as e:
        print(f"CRITICAL ERROR: Failed to import a required module: {e}")
        print("Please ensure all Python libraries are installed. Run: pip install paho-mqtt Pillow pywin32 PyYAML") # Added PyYAML
        sys.exit(1) 
    except Exception as e:
        print(f"CRITICAL ERROR: An unexpected error occurred during imports: {type(e).__name__}: {e}")
        sys.exit(1)
    
    
    # Configure logging
    logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s',
                        handlers=[logging.StreamHandler()])
    
    # --- Configuration File Path ---
    CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
    
    # Function to load configuration from YAML file
    def load_config():
        try:
            with open(CONFIG_FILE, 'r') as f:
                config = yaml.safe_load(f)
            logging.info(f"Configuration loaded from {CONFIG_FILE}")
            return config
        except FileNotFoundError:
            logging.error(f"ERROR: Configuration file not found at {CONFIG_FILE}. Please create it.")
            sys.exit(1)
        except yaml.YAMLError as e:
            logging.error(f"ERROR: Error parsing configuration file {CONFIG_FILE}: {e}")
            sys.exit(1)
        except Exception as e:
            logging.error(f"ERROR: An unexpected error occurred while loading config: {type(e).__name__}: {e}")
            sys.exit(1)
    
    # Load configuration at script start
    app_config = load_config()
    
    # --- MQTT Configuration (Loaded from config.yaml) ---
    MQTT_BROKER = app_config.get('mqtt', {}).get('broker', '127.0.0.1')
    MQTT_PORT = app_config.get('mqtt', {}).get('port', 1883)
    MQTT_USERNAME = app_config.get('mqtt', {}).get('username', None)
    MQTT_PASSWORD = app_config.get('mqtt', {}).get('password', None)
    
    # Base MQTT topic for receiving camera snapshots (must match Node-RED)
    MQTT_SNAPSHOT_BASE_TOPIC = app_config.get('mqtt', {}).get('snapshot_base_topic', 'notifier/')
    # MQTT topic for receiving control commands (e.g., from Node-RED dashboard)
    MQTT_CONTROL_TOPIC = app_config.get('mqtt', {}).get('control_topic', 'notifier/control')
    
    IMAGE_CACHE_DIR = os.path.join(os.environ.get('LOCALAPPDATA', os.path.expanduser('~')), app_config.get('general_settings', {}).get('image_cache_dir', 'MotionNotifierCache'))
    
    # Initial check for the directory - this should run once on script start
    try:
        os.makedirs(IMAGE_CACHE_DIR, exist_ok=True)
        logging.info(f"Image cache directory: {IMAGE_CACHE_DIR}")
    except Exception as e:
        logging.error(f"CRITICAL ERROR: Could not create image cache directory '{IMAGE_CACHE_DIR}': {type(e).__name__}: {e}")
        sys.exit(1)
    
    # --- Notification Specifics (General) ---
    COOLDOWN_PERIOD = app_config.get('general_settings', {}).get('cooldown_period_seconds', 10) # seconds
    
    # --- Large Image Pop-up Settings (using Tkinter for a single, queued pop-up) ---
    ENABLE_LARGE_IMAGE_POPUP = app_config.get('general_settings', {}).get('enable_large_image_popup', True)
    LARGE_IMAGE_DISPLAY_DURATION = app_config.get('general_settings', {}).get('large_image_display_duration_seconds', 5) # seconds
    
    # Fixed dimensions for the single pop-up window
    IMAGE_DISPLAY_WIDTH = app_config.get('general_settings', {}).get('image_display_width', 320)
    IMAGE_DISPLAY_HEIGHT = app_config.get('general_settings', {}).get('image_display_height', 240)
    
    # Fixed position for the single pop-up window (bottom-right)
    FIXED_OFFSET_X = app_config.get('general_settings', {}).get('fixed_offset_x', 10)
    FIXED_OFFSET_Y = app_config.get('general_settings', {}).get('fixed_offset_y', 10)
    
    # --- Custom Border and Text Settings ---
    BORDER_WIDTH = app_config.get('general_settings', {}).get('border_width', 3)
    WINDOW_INTERNAL_PADDING = app_config.get('general_settings', {}).get('window_internal_padding', 5)
    TEXT_LABEL_HEIGHT_ESTIMATE = app_config.get('general_settings', {}).get('text_label_height_estimate', 25)
    
    # Define border colors for each camera (now loaded from config)
    # Default to a fallback if not found in config
    CAMERA_BORDER_COLORS = {
        cam_name: cam_cfg.get('border_color', '#800080') # Default to Purple if not specified
        for cam_name, cam_cfg in app_config.get('camera_configurations', {}).items()
    }
    # Add a default fallback for any camera not explicitly listed in config
    CAMERA_BORDER_COLORS['default'] = app_config.get('general_settings', {}).get('default_border_color', '#800080')
    
    
    # --- Text-to-Speech (TTS) Settings ---
    ENABLE_TTS_NOTIFICATION = app_config.get('general_settings', {}).get('tts_enabled_on_startup', True)
    TTS_SCRIPT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), app_config.get('general_settings', {}).get('tts_script_path', 'tts_speaker.py'))
    # TTS_PREFIX is now less critical as specific messages are loaded, but kept for fallback consistency
    TTS_PREFIX = app_config.get('general_settings', {}).get('tts_prefix', 'Motion detected at the ')
    # Initial TTS volume loaded from config.yaml (this will be passed, but tts_speaker.py will ignore it for now)
    initial_tts_volume_config = app_config.get('general_settings', {}).get('initial_volume', 1.0) 
    
    # Global variable for TTS volume, controlled by MQTT. Initialized from config.
    current_tts_volume = max(0.0, min(1.0, float(initial_tts_volume_config))) 
    logging.info(f"Initial TTS volume set to {current_tts_volume} (from config.yaml).")
    
    # Load specific TTS messages from config.yaml
    TTS_MESSAGES = app_config.get('tts_messages', {})
    logging.info(f"Loaded TTS messages from config: {TTS_MESSAGES}")
    
    # Lock and Queue for TTS to ensure sequential announcements
    tts_lock = threading.Lock()
    tts_queue = collections.deque()
    
    
    # --- Camera-specific configurations (now loaded from config.yaml) ---
    CAMERA_CONFIGS = app_config.get('camera_configurations', {})
    # Initialize cooldown timers for cameras if they weren't loaded from config
    for cam_name, cam_cfg in CAMERA_CONFIGS.items():
        if "cooldown_timer" not in cam_cfg:
            cam_cfg["cooldown_timer"] = 0
        # Ensure 'enabled' state is set from config, defaulting to True
        if "enabled_on_startup" not in cam_cfg:
            cam_cfg["enabled"] = True
        else:
            cam_cfg["enabled"] = cam_cfg["enabled_on_startup"]
    
    # --- Global State for Fullscreen Detection ---
    IS_FULLSCREEN_ACTIVE = False
    last_fullscreen_state = False # To log state changes only
    
    # --- Tkinter Root Window (initialized once in main thread) ---
    root = None 
    
    # Image display queue and lock
    image_display_queue = collections.deque()
    image_queue_lock = threading.Lock()
    current_popup_window = None # Reference to the currently displayed Tkinter window
    
    # --- Image Popup Class (uses Tkinter) ---
    class ImagePopup:
        def __init__(self, master, camera_name, image_path, display_duration, final_width, final_height, x_pos, y_pos, border_color, label_text):
            logging.info(f"ImagePopup: Initializing for {camera_name} at fixed position {x_pos},{y_pos} with total size {final_width}x{final_height}")
            self.master = master
            self.camera_name = camera_name
            self.image_path = image_path
            self.display_duration = display_duration
            self.total_window_width = final_width 
            self.total_window_height = final_height 
    
            self.window = tk.Toplevel(master)
            self.window.attributes('-topmost', True) 
            self.window.overrideredirect(True) # Remove window decorations (title bar, border)
            self.window.config(bg='black') # Set window background to black
            self.window.withdraw() # Hide it immediately after creation
    
            self.border_frame = tk.Frame(self.window, bg=border_color, bd=BORDER_WIDTH, relief='solid')
            self.border_frame.pack(fill=tk.BOTH, expand=True, padx=WINDOW_INTERNAL_PADDING, pady=WINDOW_INTERNAL_PADDING)
    
            self.text_label = tk.Label(self.border_frame, text=label_text, fg='white', bg=border_color, font=('Arial', 12, 'bold'))
            self.text_label.pack(side=tk.TOP, pady=2) 
    
            self.image_label = tk.Label(self.border_frame, bg='black') # Set label background to black
            self.image_label.pack(side=tk.TOP, fill=tk.BOTH, expand=True) # Image label fills remaining space
    
            self.load_image_for_display() # This will now determine the actual image size within the label
    
            # Set window geometry using actual image dimensions and calculated position
            self.window.geometry(f"{self.total_window_width}x{self.total_window_height}+{x_pos}+{y_pos}") # Use fixed total window size
            logging.info(f"ImagePopup: Window geometry set to {self.total_window_width}x{self.total_window_height}+{x_pos}+{y_pos}")
    
            self.window.update_idletasks() # Ensure all pending geometry calculations are done
            
            # Only deiconify if not in fullscreen mode
            if not IS_FULLSCREEN_ACTIVE:
                self.window.deiconify() 
                logging.info(f"Image popup for {camera_name} deiconified (shown).")
            else:
                logging.info(f"Image popup for {camera_name} kept hidden due to fullscreen mode.")
    
    
        def load_image_for_display(self):
            try:
                pil_image = Image.open(self.image_path)
                logging.debug(f"DEBUG: Original image size for {self.camera_name}: {pil_image.width}x{pil_image.height}")
                
                # Calculate image size based on total window size minus border, padding, and text area
                image_target_width = self.total_window_width - (2 * BORDER_WIDTH) - (2 * WINDOW_INTERNAL_PADDING)
                image_target_height = self.total_window_height - TEXT_LABEL_HEIGHT_ESTIMATE - (2 * BORDER_WIDTH) - (2 * WINDOW_INTERNAL_PADDING) - 2 # -2 for minor adjustments
    
                pil_image.thumbnail((image_target_width, image_target_height), Image.LANCZOS)
                logging.debug(f"DEBUG: Thumbnail image size for {self.camera_name}: {pil_image.width}x{pil_image.height}")
                
                if pil_image.width == 0 or pil_image.height == 0:
                    logging.error(f"Error: Resized image for {self.camera_name} has zero dimensions. Skipping.")
                    # Set a minimal size to avoid Tkinter errors, though image won't be visible
                    self.actual_image_width = 1
                    self.actual_image_height = 1
                    return
    
                self.tk_image = ImageTk.PhotoImage(pil_image)
                self.image_label.config(image=self.tk_image)
                self.image_label.image = self.tk_image # Keep a reference!
    
                # Store actual dimensions of the resized image (within the label)
                self.actual_image_width = pil_image.width
                self.actual_image_height = pil_image.height
    
                logging.info(f"Image loaded into label for {self.camera_name}: {self.image_path} (Actual size: {self.actual_image_width}x{self.actual_image_height})")
            except FileNotFoundError:
                logging.error(f"Image file not found for popup: {self.image_path}")
                # Do not destroy window here, let the queue processor handle it
            except Exception as e:
                logging.error(f"Error loading image into popup: {type(e).__name__}: {e}")
                # Do not destroy window here, let the queue processor handle it
    
        def destroy_window(self):
            if self.window.winfo_exists():
                self.window.destroy()
                logging.info(f"Image popup for {self.camera_name} destroyed.")
    
    
    # --- Function to check for fullscreen status ---
    def check_fullscreen_status():
        global IS_FULLSCREEN_ACTIVE, last_fullscreen_state, current_popup_window
        
        screen_width = win32api.GetSystemMetrics(0) 
        screen_height = win32api.GetSystemMetrics(1) 
    
        try:
            hwnd = win32gui.GetForegroundWindow()
            rect = win32gui.GetWindowRect(hwnd)
    
            current_fullscreen_state = (rect[0] == 0 and rect[1] == 0 and rect[2] == screen_width and rect[3] == screen_height)
    
            if current_fullscreen_state and not last_fullscreen_state:
                logging.info("Fullscreen application detected. Image pop-ups will be suppressed.")
                if current_popup_window and current_popup_window.window.winfo_exists():
                    current_popup_window.window.withdraw() # Hide the current popup
                    logging.info(f"Hidden current popup ({current_popup_window.camera_name}) due to fullscreen.")
            elif not current_fullscreen_state and last_fullscreen_state:
                logging.info("Fullscreen application no longer detected. Image pop-ups re-enabled.")
                if current_popup_window and current_popup_window.window.winfo_exists() and not current_popup_window.window.winfo_ismapped():
                    current_popup_window.window.deiconify() # Show the current popup if it was hidden
                    logging.info(f"Reshown current popup ({current_popup_window.camera_name}) as fullscreen exited.")
            
            IS_FULLSCREEN_ACTIVE = current_fullscreen_state
            last_fullscreen_state = current_fullscreen_state
    
        except Exception as e:
            logging.error(f"Error checking fullscreen status: {type(e).__name__}: {e}")
    
    # --- Thread for periodic fullscreen check ---
    def fullscreen_monitor_thread():
        while True:
            check_fullscreen_status()
            time.sleep(5) 
    
    # --- Queue Processor Threads ---
    
    def display_queue_processor():
        global root, current_popup_window, IS_FULLSCREEN_ACTIVE
    
        while True:
            try:
                # Wait for an item in the queue
                with image_queue_lock:
                    if not image_display_queue:
                        # If queue is empty, release lock and wait a bit before checking again
                        time.sleep(0.1) 
                        continue
                    # Get the next item from the queue
                    camera_name, temp_image_path, config, label_text = image_display_queue.popleft() # Added label_text
                
                logging.info(f"Processing image from queue for {camera_name}. Image Queue size: {len(image_display_queue)}")
    
                # Calculate total window dimensions based on fixed image size plus border, padding, and text area
                total_window_width = IMAGE_DISPLAY_WIDTH + (2 * BORDER_WIDTH) + (2 * WINDOW_INTERNAL_PADDING)
                total_window_height = IMAGE_DISPLAY_HEIGHT + TEXT_LABEL_HEIGHT_ESTIMATE + (2 * BORDER_WIDTH) + (2 * WINDOW_INTERNAL_PADDING) + 2 
    
                screen_width = root.winfo_screenwidth()
                screen_height = root.winfo_screenheight()
    
                # Fixed position: bottom-right
                x_pos = screen_width - total_window_width - FIXED_OFFSET_X
                y_pos = screen_height - total_window_height - FIXED_OFFSET_Y
    
                logging.info(f"DEBUG: Calculated popup position for {camera_name}: x_pos={x_pos}, y_pos={y_pos}")
    
                # Destroy any currently active popup before creating a new one
                if current_popup_window and current_popup_window.window.winfo_exists():
                    logging.info(f"Destroying current popup for {current_popup_window.camera_name} to show next image.")
                    current_popup_window.destroy_window()
                    current_popup_window = None
                
                logging.info(f"DEBUG: Attempting to create new ImagePopup for {camera_name} on main thread.")
                # Create the new ImagePopup on the main Tkinter thread
                # Use root.after to safely interact with Tkinter from another thread
                event = threading.Event() # To wait for popup creation to complete
                root.after(0, lambda: _create_image_popup_on_main_thread_synced(
                    camera_name, temp_image_path, total_window_width, total_window_height, 
                    x_pos, y_pos, CAMERA_BORDER_COLORS.get(camera_name, CAMERA_BORDER_COLORS["default"]), 
                    label_text, event # Pass label_text here
                ))
                event.wait() # Wait for the popup to be created on the main thread
                logging.info(f"DEBUG: ImagePopup creation for {camera_name} signalled complete.")
    
                # Keep the image displayed for the duration
                logging.info(f"DEBUG: Displaying image for {LARGE_IMAGE_DISPLAY_DURATION} seconds.")
                time.sleep(LARGE_IMAGE_DISPLAY_DURATION)
    
                # Destroy the popup after its display duration
                if current_popup_window and current_popup_window.window.winfo_exists():
                    logging.info(f"DEBUG: Destroying image popup for {current_popup_window.camera_name} after display duration.")
                    current_popup_window.destroy_window()
                    current_popup_window = None
                
                # Clean up the temporary image file
                if os.path.exists(temp_image_path):
                    os.remove(temp_image_path)
                    logging.info(f"Cleaned up temporary audio file removed: {temp_image_path}") # Corrected log message
                    
            except Exception as e:
                logging.error(f"Error in display_queue_processor: {type(e).__name__}: {e}")
                # Ensure the current_popup_window is cleared on error to prevent blocking
                if current_popup_window and current_popup_window.window.winfo_exists():
                    current_popup_window.destroy_window()
                    current_popup_window = None
                time.sleep(1) # Prevent busy-looping on persistent errors
    
    # Helper function to create ImagePopup on main Tkinter thread
    def _create_image_popup_on_main_thread_synced(camera_name, temp_image_path, total_window_width, total_window_height, x_pos, y_pos, border_color, label_text, event):
        global root, current_popup_window
        try:
            current_popup_window = ImagePopup(root, camera_name, temp_image_path, LARGE_IMAGE_DISPLAY_DURATION, 
                                              total_window_width, total_window_height, x_pos, y_pos, border_color, label_text)
            logging.info(f"ImagePopup instance created on main thread for {camera_name}.")
        except Exception as e:
            logging.error(f"Error creating ImagePopup on main thread: {type(e).__name__}: {e}")
        finally:
            event.set() # Signal that creation attempt is complete
    
    def tts_processor():
        """Processes TTS messages from the queue sequentially."""
        global ENABLE_TTS_NOTIFICATION
        while True:
            try:
                with tts_lock: # Acquire lock to ensure only one TTS plays at a time
                    if not tts_queue:
                        time.sleep(0.1) # Small sleep if queue is empty
                        continue
                    # Get message, script path, python executable. Volume is NOT passed to tts_speaker.py
                    message_to_speak, tts_script_path, venv_python_executable = tts_queue.popleft() 
                    logging.info(f"Processing TTS from queue: '{message_to_speak}'. TTS Queue size: {len(tts_queue)}")
    
                    if ENABLE_TTS_NOTIFICATION:
                        try:
                            # Pass only the message to tts_speaker.py, matching the user's provided script
                            subprocess.run([venv_python_executable, tts_script_path, message_to_speak],
                                             stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, timeout=10)
                            logging.info(f"Launched TTS for: '{message_to_speak}' (completed).")
                        except FileNotFoundError:
                            logging.error(f"TTS script or Python interpreter not found. Ensure '{venv_python_executable}' and '{tts_script_path}' exist.")
                            logging.error("If ffplay error, ensure FFmpeg is installed and its 'bin' directory is in your system's PATH.")
                        except subprocess.CalledProcessError as e:
                            logging.error(f"TTS script failed with exit code {e.returncode}: {e.stderr.decode().strip()}")
                        except subprocess.TimeoutExpired:
                            logging.error("TTS script timed out after 10 seconds.")
                        except Exception as e:
                            logging.error(f"Error launching TTS script: {type(e).__name__}: {e}")
                    else:
                        logging.info(f"TTS notification disabled. Skipping: '{message_to_speak}'")
            except Exception as e:
                logging.error(f"Error in TTS processor: {type(e).__name__}: {e}")
            time.sleep(0.01) # Small delay to prevent busy-looping on persistent errors
    
    # --- MQTT Callbacks ---
    def on_connect(client, userdata, flags, rc, properties):
        if rc == 0:
            logging.info(f"Connected to MQTT Broker: {userdata['broker_address']}:{userdata['broker_port']}")
            
            for camera_name, config in userdata['CAMERA_CONFIGS'].items(): # Access CAMERA_CONFIGS from userdata
                full_topic = userdata['MQTT_SNAPSHOT_BASE_TOPIC'] + config["topic_suffix"]
                client.subscribe(full_topic)
                logging.info(f"Subscribed to snapshot topic: {full_topic}")
            
            client.subscribe(userdata['MQTT_CONTROL_TOPIC']) # Access MQTT_CONTROL_TOPIC from userdata
            logging.info(f"Subscribed to control topic: {userdata['MQTT_CONTROL_TOPIC']}")
    
        else:
            logging.error(f"Failed to connect to MQTT, return code {rc}")
            logging.error("Retrying connection in run_notifier loop...")
    
    def on_message(client, userdata, msg):
        # --- IMPORTANT: Declare global variables used in this function ---
        # Only IS_FULLSCREEN_ACTIVE, ENABLE_TTS_NOTIFICATION, and current_tts_volume need to be global
        # as they are modified by other threads/control messages.
        global IS_FULLSCREEN_ACTIVE, ENABLE_TTS_NOTIFICATION, current_tts_volume 
        
        current_time = time.time()
        
        # --- Handle Control Messages ---
        if msg.topic == userdata['MQTT_CONTROL_TOPIC']:
            try:
                control_payload = json.loads(msg.payload.decode('utf-8'))
                logging.info(f"Received control message: {control_payload}")
    
                camera_name = control_payload.get("camera_name") # Can be 'all' or specific camera name
                action = control_payload.get("action")
                value = control_payload.get("value")
    
                if action == "set_enabled" and isinstance(value, bool):
                    if camera_name == "all":
                        for cam_cfg in CAMERA_CONFIGS.values():
                            cam_cfg["enabled"] = value
                        logging.info(f"All cameras set to enabled={value}")
                    elif camera_name in CAMERA_CONFIGS:
                        CAMERA_CONFIGS[camera_name]["enabled"] = value
                        logging.info(f"Camera '{camera_name}' set to enabled={value}")
                    else:
                        logging.warning(f"Control message for unknown camera: '{camera_name}'.")
                elif action == "set_tts_enabled" and isinstance(value, bool): 
                    ENABLE_TTS_NOTIFICATION = value 
                    logging.info(f"TTS notifications set to enabled={ENABLE_TTS_NOTIFICATION}")
                elif action == "set_volume" and isinstance(value, (int, float)): # Handle volume control
                    current_tts_volume = max(0.0, min(1.0, float(value))) # Ensure volume is 0.0-1.0
                    logging.info(f"TTS volume set to {current_tts_volume} (from control message).")
                # --- NEW CODE START ---
                elif action == "speak_message" and isinstance(value, str):
                    if ENABLE_TTS_NOTIFICATION:
                        logging.info(f"Received 'speak_message' action. Adding '{value}' to TTS queue.")
                        # Pass the received message directly to the TTS queue
                        tts_queue.append((value, userdata['TTS_SCRIPT_PATH'], userdata['VENV_PYTHON_EXECUTABLE']))
                    else:
                        logging.info(f"TTS notifications are disabled. Skipping custom message: '{value}'")
                # --- NEW CODE END ---
                else:
                    logging.warning(f"Unknown action '{action}' or invalid value for control message.")
            except json.JSONDecodeError:
                logging.error(f"Failed to decode control message JSON: {msg.payload.decode('utf-8')}")
            except Exception as e:
                logging.error(f"Error processing control message: {type(e).__name__}: {e}")
            return
    
        # --- Handle Snapshot Messages ---
        for camera_name, config in userdata['CAMERA_CONFIGS'].items(): # Access CAMERA_CONFIGS from userdata
            full_topic = userdata['MQTT_SNAPSHOT_BASE_TOPIC'] + config["topic_suffix"]
            if msg.topic == full_topic:
                # Parse the incoming MQTT payload as a UTF-8 JSON string
                payload_data = {} # Initialize to empty dict
                image_data_base64 = None
                detected_label = "unknown" # Default label
    
                try:
                    # Node-RED is now expected to send a JSON string
                    payload_string = msg.payload.decode('utf-8')
                    payload_data = json.loads(payload_string)
                    image_data_base64 = payload_data.get('image_data')
                    detected_label = payload_data.get('detected_label', 'unknown') 
                    logging.info(f"Parsed JSON payload for {camera_name}. Detected Label: {detected_label}")
                except (UnicodeDecodeError, json.JSONDecodeError) as e:
                    # If decoding or JSON parsing fails, it means Node-RED didn't send JSON as expected.
                    # Log the error and fall back to 'unknown' label and no image display.
                    logging.error(f"Failed to decode/parse MQTT payload as JSON for {camera_name}: {type(e).__name__}: {e}. Payload: '{msg.payload.decode('utf-8', errors='ignore')}'")
                    logging.warning("Expected JSON payload from Node-RED, but received invalid data. Skipping image display and using default TTS.")
                    image_data_base64 = None # Ensure no image is processed if payload is bad
                    detected_label = "unknown" # Fallback label
    
    
                # On Linux, we're not suppressing pop-ups for fullscreen initially
                # if IS_FULLSCREEN_ACTIVE:
                #     logging.info(f"Skipping image pop-up for '{camera_name}': Fullscreen application detected.")
                #     # We still want TTS if enabled, even if image is skipped
                #     if ENABLE_TTS_NOTIFICATION:
                #         message_to_speak = TTS_MESSAGES.get(camera_name, {}).get(detected_label, TTS_MESSAGES.get('default', "Motion detected!"))
                #         tts_queue.append((message_to_speak, userdata['TTS_SCRIPT_PATH'], userdata['VENV_PYTHON_EXECUTABLE']))
                #         logging.info(f"Added TTS for: '{message_to_speak}' to queue due to fullscreen.")
                #     return
    
                if not config["enabled"]:
                    logging.info(f"Camera '{camera_name}' is disabled. Skipping notification.")
                    return
    
                if current_time - config["cooldown_timer"] < userdata['COOLDOWN_PERIOD']: # Access COOLDOWN_PERIOD from userdata
                    logging.info(f"Notification for '{camera_name}' on cooldown. Skipping. Time remaining: {userdata['COOLDOWN_PERIOD'] - (current_time - config['cooldown_timer']):.1f}s")
                    return
                
                userdata['CAMERA_CONFIGS'][camera_name]["cooldown_timer"] = current_time
    
                logging.info(f"Received snapshot from topic '{msg.topic}' (Camera: {camera_name}, Label: {detected_label})")
                
                # --- Determine TTS Message from config.yaml ---
                # Use .get() with a default for camera and label to prevent KeyError
                message_to_speak = TTS_MESSAGES.get(camera_name, {}).get(detected_label, TTS_MESSAGES.get('default', "Motion detected!"))
                logging.info(f"Determined TTS message from config: '{message_to_speak}'")
    
                # --- TTS Notification (add to TTS queue) ---
                if ENABLE_TTS_NOTIFICATION: # Use the global state
                    # Add message, script path, python executable to the TTS queue
                    tts_queue.append((message_to_speak, userdata['TTS_SCRIPT_PATH'], userdata['VENV_PYTHON_EXECUTABLE'])) 
                    logging.info(f"Added TTS for: '{message_to_speak}' to queue. TTS Queue size: {len(tts_queue)}")
    
                temp_image_path = None
                if image_data_base64: # Only proceed if base64 image data was successfully extracted
                    try:
                        import base64
                        raw_image_bytes = base64.b64decode(image_data_base64)
                        
                        timestamp = int(time.time() * 1000)
                        image_filename = f"{camera_name}_{detected_label}_snapshot_{timestamp}.jpg" # Include label in filename
                        temp_image_path = os.path.join(userdata['image_cache_dir'], image_filename) 
                        
                        logging.info(f"Saving image to {temp_image_path}")
                        with open(temp_image_path, 'wb') as handler:
                            handler.write(raw_image_bytes)
                        logging.info(f"Image saved successfully to {temp_image_path}")
                    except Exception as e:
                        logging.error(f"Error decoding or saving image for '{camera_name}': {type(e).__name__}: {e}")
                        temp_image_path = None
                else:
                    logging.warning(f"No valid image data found in payload for {camera_name}. Skipping image display.")
    
                if userdata['ENABLE_LARGE_IMAGE_POPUP'] and temp_image_path: 
                    # Add image display request to the queue, passing the determined TTS message as label_text
                    image_display_queue.append((camera_name, temp_image_path, config, message_to_speak))
                    logging.info(f"Added image for {camera_name} to display queue. Image Queue size: {len(image_display_queue)}")
                break 
        else:
            logging.warning(f"Received message on unexpected topic: {msg.topic}")
    
    
    # --- Main execution ---
    def run_notifier():
        global root 
    
        root = tk.Tk()
        root.withdraw() 
        root.title("Motion Notifier Background (Debian)") # Changed title for Linux
    
        # Determine the path to the virtual environment's python.exe (or python on Linux)
        # This assumes motion_notifier.py is run from the venv, so sys.executable points to venv's python
        venv_python_executable = os.path.join(os.path.dirname(sys.executable), "python") # Changed to 'python' for Linux
        logging.info(f"DEBUG: Determined VENV_PYTHON_EXECUTABLE: {venv_python_executable}")
    
    
        # Populate client_userdata with all necessary configurations
        client_userdata = {
            'broker_address': MQTT_BROKER,
            'broker_port': MQTT_PORT,
            'image_cache_dir': IMAGE_CACHE_DIR, 
            'CAMERA_CONFIGS': CAMERA_CONFIGS, # Pass the entire CAMERA_CONFIGS dictionary
            'COOLDOWN_PERIOD': COOLDOWN_PERIOD, 
            'ENABLE_LARGE_IMAGE_POPUP': ENABLE_LARGE_IMAGE_POPUP, 
            'IMAGE_DISPLAY_WIDTH': IMAGE_DISPLAY_WIDTH, 
            'IMAGE_DISPLAY_HEIGHT': IMAGE_DISPLAY_HEIGHT, 
            'IMAGE_DISPLAY_DURATION': LARGE_IMAGE_DISPLAY_DURATION, 
            'FIXED_OFFSET_X': FIXED_OFFSET_X, 
            'FIXED_OFFSET_Y': FIXED_OFFSET_Y, 
            'MQTT_SNAPSHOT_BASE_TOPIC': MQTT_SNAPSHOT_BASE_TOPIC, 
            'MQTT_CONTROL_TOPIC': MQTT_CONTROL_TOPIC, 
            'ENABLE_TTS_NOTIFICATION': ENABLE_TTS_NOTIFICATION, 
            'TTS_SCRIPT_PATH': TTS_SCRIPT_PATH, 
            #'TTS_PREFIX': TTS_PREFIX, 
            'VENV_PYTHON_EXECUTABLE': venv_python_executable, 
            'CAMERA_BORDER_COLORS': CAMERA_BORDER_COLORS, 
            'BORDER_WIDTH': BORDER_WIDTH, 
            'WINDOW_INTERNAL_PADDING': WINDOW_INTERNAL_PADDING, 
            'TEXT_LABEL_HEIGHT_ESTIMATE': TEXT_LABEL_HEIGHT_ESTIMATE, 
        }
    
        client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
        client.user_data_set(client_userdata) 
        
        client.on_connect = on_connect 
        client.on_message = on_message
    
        if MQTT_USERNAME and MQTT_PASSWORD:
            client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
            logging.info("MQTT client configured with username/password.")
        else:
            logging.warning("WARNING: MQTT client connecting without username/password. Ensure your broker allows anonymous access or provide credentials.")
        
        while True:
            try:
                logging.info(f"Attempting to connect to MQTT broker at {MQTT_BROKER}:{MQTT_PORT}...")
                client.connect(MQTT_BROKER, MQTT_PORT, 60)
                logging.info("MQTT connection attempt successful.")
                break 
            except Exception as e:
                logging.error(f"Could not connect to MQTT broker: {type(e).__name__}: {e}. Retrying in 5 seconds...")
                time.sleep(5)
    
        client.loop_start() 
        logging.info("MQTT client loop started in background thread.")
    
        # Fullscreen monitor thread is removed for Linux simplicity
        # fullscreen_thread = threading.Thread(target=fullscreen_monitor_thread)
        # fullscreen_thread.daemon = True
        # fullscreen_thread.start()
        # logging.info("Fullscreen monitor thread started.")
    
        # Start the TTS queue processor in a separate daemon thread
        tts_processor_thread = threading.Thread(target=tts_processor)
        tts_processor_thread.daemon = True
        tts_processor_thread.start()
        logging.info("TTS queue processor thread started.")
    
        # Start the image display queue processor in a separate daemon thread
        image_queue_processor_thread = threading.Thread(target=display_queue_processor)
        image_queue_processor_thread.daemon = True
        image_queue_processor_thread.start()
        logging.info("Image display queue processor thread started.")
    
        logging.info("Starting Tkinter mainloop for GUI management.")
        root.mainloop() 
    
    if __name__ == "__main__":
        run_notifier()
    ```
  3. Ensure Dependencies: Make sure you have the required Python libraries installed (from Section 1.C.4). You'll also need the Pillow library for image manipulation:
    pip install Pillow
  4. Install tkinter (if missing): On some Debian/Ubuntu systems, tkinter might not be installed by default. You can install it with:
    sudo apt install python3-tk
  5. Run the Notifier:

    Open your terminal, navigate to the FrigateNotifier folder, activate your virtual environment, and run:

    source venv/bin/activate
    python deb_notifier.py

    Keep this terminal window open. It will show log messages and spawn the pop-ups.

  6. Running on Startup (Debian/Linux with systemd)

    For Debian-based systems (like Ubuntu), `systemd` is the standard way to manage services that run automatically on startup. This will allow your notifier to start in the background when your desktop environment loads.

    1. Create the Service File:

      Open a terminal and create a new systemd service file for your user. This ensures it runs after your graphical session starts.

      nano ~/.config/systemd/user/frigate-notifier.service

      If the `user` directory doesn't exist, you might need to create it first: `mkdir -p ~/.config/systemd/user`

    2. Copy & Paste Service Content:

      Paste the following content into the `frigate-notifier.service` file:

      [Unit]
      Description=Frigate Desktop Notifier
      After=graphical-session.target network-online.target
      
      [Service]
      Type=simple
      ExecStart=/home/YOUR_USERNAME/FrigateNotifier/venv/bin/python /home/YOUR_USERNAME/FrigateNotifier/deb_notifier.py
      WorkingDirectory=/home/YOUR_USERNAME/FrigateNotifier
      Restart=on-failure
      RestartSec=5
      
      [Install]
      WantedBy=graphical-session.target
      
    3. Edit the Service File (CRITICAL!):
      • Replace YOUR_USERNAME with your actual Debian/Linux username in both the `ExecStart` and `WorkingDirectory` lines.
      • Ensure the path to your virtual environment's Python executable (`/home/YOUR_USERNAME/FrigateNotifier/venv/bin/python`) is correct. If you named your virtual environment something other than `venv`, adjust the path accordingly.
      • Ensure the path to `deb_notifier.py` is correct.
    4. Save and Exit: Save the file (Ctrl+O, Enter) and exit nano (Ctrl+X).
    5. Reload systemd and Enable Service:

      In the terminal, run these commands to reload systemd and enable your new service:

      systemctl --user daemon-reload
      systemctl --user enable frigate-notifier.service
      systemctl --user start frigate-notifier.service

      The `systemctl --user start frigate-notifier.service` command will start the notifier immediately. It will then automatically start on future logins.

    6. Check Service Status (Optional):

      To check if the service is running correctly, use:

      systemctl --user status frigate-notifier.service

3.C. Other Operating Systems (macOS, Fedora, etc.)

While this guide focuses on Windows and Debian, the core Python notifier script (deb_notifier.py, or `win_notifier.py` if adapted) is largely cross-platform, especially the MQTT communication and image/TTS queuing logic. However, certain aspects will require adjustments for other operating systems:

  1. Python and Dependencies:

    The Python 3 and virtual environment setup (Section 1.C) is generally applicable. You'll still need to install gtts, PyYAML, Pillow, and paho-mqtt via `pip` within your virtual environment.

  2. TTS Playback (tts_speaker.py):

    The `tts_speaker.py` script uses either `ffplay` (Windows) or `playsound` (Debian). For other operating systems:

    • macOS: `playsound` might work, but you may also consider using `afplay` (built-in macOS command-line audio player) via `subprocess.run`, similar to how `ffplay` is used in the Windows version.
    • Other Linux Distributions (e.g., Fedora, Arch): `playsound` is likely the best option, but you might need to install different underlying audio libraries (e.g., `gstreamer` plugins specific to your distribution) if you encounter playback issues. Refer to your distribution's documentation for multimedia dependencies.
  3. Tkinter (GUI Pop-ups):

    Tkinter is a standard Python GUI library and should work across most desktop environments where Python is installed. If you encounter issues, ensure your system has the Tkinter development packages installed (e.g., `python3-tk` on Debian/Ubuntu, `python3-tkinter` on Fedora, or similar for macOS). The pop-up positioning logic might need minor adjustments depending on how different desktop environments handle window geometry.

  4. Fullscreen Detection:

    The `win_notifier.py` script includes Windows-specific `win32api`/`win32gui` calls for fullscreen detection. This functionality is generally OS-specific and would need to be re-implemented using native APIs for macOS or other Linux desktop environments (e.g., X11/Wayland APIs for Linux). For simplicity, the `deb_notifier.py` version omits fullscreen detection, which might be a reasonable approach for other OSes if a robust cross-platform solution isn't easily found.

  5. Running on Startup:

    This is the most OS-dependent part. You'll need to research the standard method for running user-specific applications on startup for your chosen operating system:

    • macOS: Typically involves creating a `.plist` file and placing it in `~/Library/LaunchAgents/`.
    • Fedora/Other Systemd-based Linux: Similar to the Debian `systemd --user` service described in Section 3.B.1, but paths and package names might vary slightly.
    • Other Linux Desktop Environments (e.g., KDE, XFCE): Often have a "Startup Applications" or "Autostart" setting in their system settings where you can add a command to run your Python script.

By understanding these general principles, you should be able to adapt the provided scripts to a wider range of desktop operating systems.

4. Home Assistant Integration (Automations)

Now that your desktop notifier is ready to receive messages and snapshots, we can set up the Home Assistant side. This involves creating a helper, a script, and an automation to send custom text messages to your desktop for speech output.

The Helper: input_boolean.desktop_notifier_tts_enabled

This helper acts as a master on/off switch for your desktop TTS notifications. You can toggle it from your dashboard or use another automation to control it.

To create this helper, go to Settings > Devices & Services > Helpers and click "Create Helper". Choose "Toggle" and give it the name Desktop Notifier TTS Enabled.

Alternatively, you can add it to your configuration.yaml or a dedicated helpers.yaml file:

input_boolean:
  desktop_notifier_tts_enabled:
    name: Desktop Notifier TTS Enabled
    initial: on
    icon: mdi:volume-high

The Script: script.desktop_notifier_speak

This script is the central tool for sending text messages to your notifier via MQTT. It accepts a message field and publishes it to the notifier/control topic.

You can create this script by going to Settings > Automations & Scenes > Scripts and creating a new script. Paste this YAML into the script editor.

alias: desktop_notifier_speak
description: Sends a custom text message to the desktop notifier for speech output.
fields:
  message:
    name: Message
    description: The text message to be spoken by the desktop notifier.
    required: true
    selector:
      text: null
sequence:
  - data:
      topic: notifier/control
      payload: |
        {"action": "speak_message", "value": "{{ message }}"}
      qos: "0"
      retain: false
    action: mqtt.publish
mode: single

Example Automation

This automation ties everything together. It listens for a trigger event, checks the input_boolean helper as a condition, and then calls the desktop_notifier_speak script with your custom message.

alias: Front Door Opened - Speak Notification
description: Speaks a message on the desktop when the front door opens, if TTS is enabled.
trigger:
  - platform: state
    entity_id: binary_sensor.front_door_contact # Replace with your door sensor's entity_id
    to: "on"
condition:
  - condition: state
    entity_id: input_boolean.desktop_notifier_tts_enabled
    state: "on"
action:
  - service: script.desktop_notifier_speak
    data:
      message: "The front door has just been opened."
mode: single

Please note: There is one quirk I can't iron out, after restarting the machine or the scrip it with run through all the cameras once, notifying each.

Congratulations! You now have a powerful, custom desktop notification system for your Frigate and Home Assistant events.