[Tuto] Notification prioritaire sur Signal, et Fallback (Telegram/Matrix/Discord) si hors-ligne

Hello,

Je vous partage aujourd'hui un concept d'automatisation pour mettre en place des notifications en cascade avec acquittement automatique (système de Fallback).

L'idée est simple : lorsqu'un événement important se produit (ouverture d'une boîte aux lettres, alarme, etc.), Home Assistant envoie une notification sur un canal prioritaire et sécurisé (Signal). Si vous ne recevez pas ou ne lisez pas le message dans un délai imparti (par exemple, si votre téléphone est hors-ligne ou en mode avion), Home Assistant bascule automatiquement sur des canaux de secours (ex: Matrix, Telegram, Discord). Accessoirement on envoie pas de messages inutiles.. en doublon, triplon voir plus si affinité

:open_book: Un peu de vocabulaire :
Fallback : C'est la bascule automatique vers une solution de secours (le "Plan B") lorsque le système principal échoue ou n'est pas disponible , on parle donc bien d'un mécanisme de Fallback (secours de notification).

:hammer_and_wrench: Prérequis
Pour réaliser ce tutoriel, vous aurez besoin de :

L'Add-on Signal Messenger REST API (de haberda/hassio_addons ou similaire) configuré en mode json-rpc (obligatoire pour avoir les événements en temps réel).
L'intégration HACS Signal Messenger WebSocket (de mattleh/signal_websocket) pour capter les événements et accusés de réception.
Avoir relié votre numéro Signal à l'Add-on.

:busts_in_silhouette: Étape 1 : Créer un groupe privé Signal (Pour les accusés automatiques)
Pour que votre téléphone envoie un accusé de réception automatique dès que le message s'affiche sur votre écran (sans que vous ayez besoin d'ouvrir l'application) :

Créez un groupe dans l'application Signal sur votre téléphone.
Ajoutez un contact temporaire (obligatoire pour créer un groupe dans Signal), nommez le groupe (ex: HA Notifications), puis retirez le contact temporaire. Vous êtes désormais seul dans ce groupe.
Envoyez un message (ex: "test") dans ce groupe.
Dans Home Assistant, allez dans Outils de développement ➔ Événements ➔ Écoutez l'événement signal_received pour attraper l'événement. Vous y trouverez l'identifiant officiel du groupe (qui ressemble à : group.xxxxx...=).

:robot: L'automatisation simplifiée (Modèle à copier-coller)
Voici une version générique et simplifiée de l'automatisation. Elle utilise un capteur de mouvement pour déclencher une prise de photo par une caméra, puis gère l'envoi intelligent :

Dépliez pour voir l'Automation
alias: Notification événement avec Fallback Signal
description: Envoi prioritaire sur Signal, avec bascule après 60s vers d'autres canaux
trigger:
  - platform: state
    entity_id: binary_sensor.votre_capteur  # 1. Votre capteur ici
    from: "off"
    to: "on"
variables:
  # --- Vos variables de chemin et d'entités ---
  camera_entity: camera.votre_camera                  # 2. Votre caméra ici
  photo_path: /config/www/alerte.jpg                 # Chemin de sauvegarde de l'image
  signal_group_id: "group.votre_identifiant_de_groupe_signal" # 3. Votre identifiant de groupe ici
  signal_received_confirmed: false
action:
  # 1. Prise de photo (Snapshot)
  - action: camera.snapshot
    target:
      entity_id: "{{ camera_entity }}"
    data:
      filename: "{{ photo_path }}"
  # =========================================================
  # ÉTAPE 1 : ENVOI SUR SIGNAL
  # =========================================================
  # On sépare le texte de l'image pour éviter que le texte ne se retrouve coincé sous l'image dans l'application
  
  # A. Envoi du texte explicatif
  - action: signal_websocket.send_message
    continue_on_error: true # <--- SÉCURITÉ : Passe à la suite si Signal est HS
    data:
      number: "+336XXXXXXXX"  # Votre numéro expéditeur relié à l'Add-on
      group_id: "{{ signal_group_id }}"
      message: "✉️ Mouvement détecté sur la caméra !"
  # B. Envoi de l'image dans une bulle séparée
  - action: signal_websocket.send_message
    data:
      number: "+336XXXXXXXX"
      group_id: "{{ signal_group_id }}"
      message: ""
      attachments:
        - "{{ photo_path }}"
  # =========================================================
  # ÉTAPE 2 : ATTENTE ACQUITTEMENT (60 SECONDES MAX)
  # =========================================================
  # On boucle 12 fois (5 secondes par boucle) pour écouter le WebSocket.
  # Dès qu'un accusé de lecture (readMessages) ou de réception (isDelivery: true) arrive, on valide.
  - repeat:
      count: 12
      sequence:
        - if:
            - condition: template
              value_template: "{{ not signal_received_confirmed }}"
          then:
            - wait_for_trigger:
                - trigger: event
                  event_type: signal_received
              timeout: "00:00:05"
              continue_on_timeout: true
            - if:
                - condition: template
                  value_template: >-
                    {% if wait.trigger is not none %}
                      {# Détection de la lecture (Note à moi-même / Groupe) #}
                      {% set is_read = wait.trigger.event.data.envelope.syncMessage is defined and wait.trigger.event.data.envelope.syncMessage.readMessages is defined %}
                      {# Détection de la réception automatique #}
                      {% set is_delivered = wait.trigger.event.data.envelope.receiptMessage is defined and wait.trigger.event.data.envelope.receiptMessage.isDelivery == true %}
                      {{ is_read or is_delivered }}
                    {% else %}
                      false
                    {% endif %}
              then:
                - variables:
                    signal_received_confirmed: true
  # =========================================================
  # ÉTAPE 3 : ARRÊT DE L'AUTOMATISATION SI REÇU SUR SIGNAL
  # =========================================================
  - if:
      - condition: template
        value_template: "{{ signal_received_confirmed }}"
    then:
      - stop: "Message reçu sur Signal. Cascade annulée."
  # =========================================================
  # ÉTAPE 4 : CANAUX DE SECOURS (FALLBACK)
  # =========================================================
  # Si on arrive ici, c'est que les 60 secondes se sont écoulées sans accusé de réception sur Signal.
  # On envoie alors aux autres applications en parallèle.
  - parallel:
      # A. Secours Telegram
      - sequence:
          - action: telegram_bot.send_message
            data:
              message: "✉️ Mouvement détecté (Bascule secours) !"
          - action: telegram_bot.send_photo
            data:
              file: "{{ photo_path }}"
              
      # B. Secours Discord
      - action: notify.votre_notificateur_discord
        data:
          message: "✉️ Mouvement détecté (Bascule secours) !"
          target:
            - "votre_id_de_salon_discord"  # 4. Votre ID de canal Discord ici
          data:
            images:
              - "{{ photo_path }}"
              
      # C. Secours Matrix
      - action: matrix.send_message
        data:
          target: "!votre_salon:matrix.org"
          message: "<b>✉️ Mouvement détecté (Bascule secours) !</b>"
          format: html
          data:
            images:
              - "{{ photo_path }}"
mode: single

:light_bulb: Comment ça réagit en pratique ?
Si votre téléphone est connecté (Normal) : Dès que la notification s'affiche ou que vous lisez le message sur Signal, HA capte l'accusé de réception via le WebSocket, l'automatisation s'arrête d'elle-même au prochain cycle de 5 secondes, et vos comptes Matrix/Telegram/Discord restent silencieux.
Si vous êtes hors-ligne (Mode avion / Perte réseau) : Votre téléphone ne renvoie aucun accusé. Au bout de 60 secondes, la boucle d'attente expire et HA envoie immédiatement l'alerte sur Matrix et/ou Discord et/ou Telegram.

Vous n'êtes bien évidement pas obligé de les utiliser tous, je suis en pleine migration et j'ai donc les 3 à disposition.

Avec un poil de complexité qui me caractérise :smiley: , on arrive sur ça, on voit bien que les 3 notifs du bas sont ignorées.

alors qu'au contraire ( j'avais désactivé matrix pour ce test )

Dans tous les cas signal étant la notification prioritaire, vous recevrez la notification dessus au retour de la connexion.

Edit: j'ajoute la version script ( non testée en simplifiée )

script
  alias: "Notification cascade securise messageries"
  description: "Envoi prioritaire Signal et secours (Matrix/Telegram/Discord) avec détection de réception"
  fields:
    message_title:
      name: "Titre du message"
      description: "Le titre court ou l'entête de l'alerte"
      example: "✉️ Boite lettres ouverte"
      required: true
    url_local:
      name: "URL Locale"
      description: "Lien réseau local (optionnel)"
      example: "http://IP_LOCALE_FRIGATE:5000/review?cameras=NOM_CAMERA"
      required: false
    url_zt:
      name: "URL ZeroTier"
      description: "Lien à distance ZeroTier (optionnel)"
      example: "http://IP_ZEROTIER_FRIGATE:5000/review?cameras=NOM_CAMERA"
      required: false
    attachment:
      name: "Pièce jointe"
      description: "Chemin complet vers la photo/vidéo (optionnel)"
      example: "/dev/shm/courrier_alerte.jpg"
      required: false
  sequence:
    - variables:
        signal_received_confirmed: false

    # 1. SIGNAL - Envoi du texte
    - action: signal_websocket.send_message
      continue_on_error: true
      data:
        number: "+336XXXXXXXX"
        group_id: "group.votre_id_de_groupe_signal_ici"
        message: >-
          {{ message_title }} ({{ now().strftime('%Hh%M') }})
          {% if url_local is defined and url_local %}
          
          Local : {{ url_local }}
          {% endif %}
          {% if url_zt is defined and url_zt %}
          
          ZeroTier : {{ url_zt }}
          {% endif %}

    # 2. SIGNAL - Envoi de l'image (si présente)
    - if:
        - condition: template
          value_template: "{{ attachment is defined and attachment != '' }}"
      then:
        - action: signal_websocket.send_message
          continue_on_error: true
          data:
            number: "+336XXXXXXXX"
            group_id: "group.votre_id_de_groupe_signal_ici"
            message: ""
            attachments:
              - "{{ attachment }}"

    # 3. Attente acquittement (60 secondes max)
    - repeat:
        count: 12
        sequence:
          - if:
              - condition: template
                value_template: "{{ not signal_received_confirmed }}"
            then:
              - wait_for_trigger:
                  - trigger: event
                    event_type: signal_received
                timeout: "00:00:05"
                continue_on_timeout: true
              - if:
                  - condition: template
                    value_template: >-
                      {% if wait.trigger is not none %}
                        {% set is_read = wait.trigger.event.data.envelope.syncMessage is defined and wait.trigger.event.data.envelope.syncMessage.readMessages is defined %}
                        {% set is_delivered = wait.trigger.event.data.envelope.receiptMessage is defined and wait.trigger.event.data.envelope.receiptMessage.isDelivery == true %}
                        {{ is_read or is_delivered }}
                      {% else %}
                        false
                      {% endif %}
                then:
                  - variables:
                      signal_received_confirmed: true

    # 4. Arrêt si reçu
    - if:
        - condition: template
          value_template: "{{ signal_received_confirmed }}"
      then:
        - stop: "Message reçu sur Signal. Cascade annulée."

    # 5. Secours en parallèle
    - parallel:
        # A. Telegram
        - sequence:
            - action: telegram_bot.send_message
              data:
                parse_mode: html
                message: >-
                  <b>{{ message_title }}</b> ({{ now().strftime('%Hh%M') }})
                  {% if url_local is defined and url_local or url_zt is defined and url_zt %}
                  🔗 {% if url_local is defined and url_local %}<a href="{{ url_local }}">🏠 Local</a>{% endif %}{% if url_local is defined and url_local and url_zt is defined and url_zt %} | {% endif %}{% if url_zt is defined and url_zt %}<a href="{{ url_zt }}">🌍 ZeroTier</a>{% endif %}
                  {% endif %}
            - if:
                - condition: template
                  value_template: "{{ attachment is defined and attachment != '' }}"
              then:
                - action: telegram_bot.send_photo
                  data:
                    authentication: digest
                    file: "{{ attachment }}"

        # B. Discord
        - action: notify.freetronichabot
          data:
            message: ""
            target:
              - "votre_id_de_salon_discord"
            data:
              images: >-
                {{ [attachment] if (attachment is defined and attachment != '') else [] }}
              embed:
                title: "{{ message_title }} ({{ now().strftime('%Hh%M') }})"
                description: |
                  {% if url_local is defined and url_local %}[🏠 Local](<{{ url_local }}>){% endif %}{% if url_local is defined and url_local and url_zt is defined and url_zt %} — {% endif %}{% if url_zt is defined and url_zt %}[🌍 ZeroTier](<{{ url_zt }}>){% endif %}
                color: 3447003
                image:
                  url: "{{ 'attachment://' + attachment.split('/')[-1] if (attachment is defined and attachment != '') else '' }}"

        # C. Matrix
        - action: matrix.send_message
          data:
            target: "!votre_salon:matrix.org"
            message: >-
              <b>{{ message_title }}</b> ({{ now().strftime('%Hh%M') }})
              {% if url_local is defined and url_local %}<a href="{{ url_local }}">🏠 Local</a>{% endif %}{% if url_local is defined and url_local and url_zt is defined and url_zt %} | {% endif %}{% if url_zt is defined and url_zt %}<a href="{{ url_zt }}">🌍 ZeroTier</a>{% endif %}
            format: html
            data: >-
              {{ {'images': [attachment]} if (attachment is defined and attachment != '') else {} }}
automation allant avec le script
alias: alias: Surveillance courrier v4 avec script (Simplifiée)
description: Notification courrier avec appel du script de cascade
triggers:
  - trigger: state
    entity_id: binary_sensor.mvt_courrier_occupancy
    from: "off"
    to: "on"
actions:
  # (Prise de photo locale, TTS, etc.)
  - action: camera.snapshot
    target:
      entity_id: camera.sonette
    data:
      filename: /dev/shm/courrier_alerte.jpg
  # Appel simple du script de cascade avec les données de l'événement
  - action: script.Notification_cascade_securise_messageries
    data:
      message_title: "✉️ Boite lettres ouverte"
      url_local: "http://IP_LOCALE_FRIGATE:5000/review?cameras=NOM_CAMERA"
      url_zt: "http://IP_ZEROTIER_FRIGATE:5000/review?cameras=NOM_CAMERA"
      attachment: "/dev/shm/courrier_alerte.jpg"
mode: single

cdt

L'utilisation d'un script permettrait de ne pas le reproduire dans toutes les automatisations.

Oui sans doute, à chacun d'adapter, je met la base pour s'assurer du bon fonctionnement :slight_smile: