Chatière connectée avec identification des chats via leur puce RFID implantée

Voici comment connecter à Home Assistant sa chatière pour détecter le passage des chats via leur puce RFID implantée.
Chatière connectée avec identification des chats via leur puce RFID implantée

Sommaire

Je partage avec vous mon projet de chatière connectée, entièrement intégrée à Home Assistant, qui me permet de savoir à tout moment si mes chats (Kiwi et Maya) sont à l’intérieur ou à l’extérieur de la maison. Le système est basé sur la lecture de leur puce électronique vétérinaire implantée dans le cou, de type “FDX-B”.

Ma motivation pour ce projet

Mes deux chats sont libres de rentrer ou sortir de la maison comme ils le souhaitent, via une chatière. Lorsque je suis absent ou en vacances, je veux m’assurer que les chats sont là, et savoir qui est à la maison et qui est dehors. Les projets de ce type qui existent déjà utilisent tous une puce RFID attachée à un collier. Mais cette option n'est pas envisageable pour moi, car mes chats sortent souvent, et le risque d’étranglement est réel.
Il y a quelques années, j’avais acheté une chatière de marque “Sureflap”, qui lit les puces FDX-B implantées des chats, mais j’ai dû la retourner car la puce de l’un de mes deux chats n’était presque jamais détectée.

Après plusieurs semaines de réflexion et de recherches infructueuses en ligne, j’ai finalement réussi à concevoir ma propre chatière intelligente.

Matériel nécessaire

Voici la liste complète du matériel que j’ai utilisé pour ce projet :

  • Module RFID WL134 (FDX-B / EM4100 / ISO11784/11785) 
    C’est le cœur du système : il lit directement les puces de vos chats. L’antenne est fournie avec le module. J’ai essayé de fabriquer une antenne plus large pour entourer toute la chatière, mais cela ne marche pas : la sensibilité chute trop.
    Le meilleur résultat est obtenu en collant l’antenne incluse sur la porte intérieure de la chatière.
  • Alimentation linéaire 6 V DC
    Quand je l’ai achetée, elle était moins chère… Peut-être il vous faut trouver une autre référence, mais évitez absolument les alimentations à découpage premier prix (j’y reviens plus tard)
  • Convertisseur DC-DC
    Pour convertir le 6V en 5V et alimenter le microcontrôleur (ESP32) à partir de la même source. On évite ainsi les multi-blocs de prises.
  • Accéléromètre MPU6050
    Pour connaître le sens dans lequel s'ouvre la chatière
  • Module ESP32
    Pour envoyer les données du lecteur RFID vers Home Assistant via MQTT.
  • Adaptateur DC 5.5 x 2.1 mm, bien que j’aurais pu couper le câble et directement faire mes soudures mais c’est moins joli

Ce que j’avais déjà, mais dont vous aurez besoin : des fils électriques fins de type AWG28 en silicone, un fer à souder et de l’étain, un multimètre, deux petits boitiers pour la finition.

Optionnel quelques puces RFID pour tester parce qu’à un moment le chat n’était plus trop coopératif lors de mes tests et en avait marre de mon antenne 😺

Expériences et erreurs à éviter

L’alimentation du module WL-134 est la clé de la réussite, car elle est très sensible aux interférences. Au départ, j’ai essayé de l’alimenter avec un bloc 9 V classique, couplé à des résistances et transistors pour réguler la tension. Résultat : un week-end entier de perdu, la lecture était trop aléatoire.

J’ai demandé une documentation au vendeur du module, que je mets à disposition ici. Il explique qu’il faut une alimentation linéaire 6 V stable délivrant environ 80 mA pour que le WL-134 lise correctement les puces FDX-B. Le document nous apprend aussi qu’on peut alimenter le module avec une batterie Li-ion (entre 5V et 9V) et que dans ce cas les interférences sont quasi nulles. Je n’ai personnellement pas essayé la méthode de la batterie, mais je pense que ça serait une méthode beaucoup plus économique.

Il existe peut-être de meilleures méthodes pour alimenter le module, ou des alimentations moins chères qui n’enverront pas d’interférences, mais je n'ai pas eu l'occasion de les tester. Vous pouvez toujours étudier le fichier joint et décider de l’alimentation pour laquelle vous allez opter.

Schéma de branchement

Un schéma est toujours plus parlant que des mots, voici les soudures ou branchements à effectuer :

Quelques précisions :

  • Il faut bien relier le RX2 du ESP32 sur la borne 3.3vTX du WL-134 et pas sur la borne 5vTX sinon c’est l’assurance de griller l’ESP32.
  • Branchement du MPU6050 :
    3V3 ESP32 vers VCC MPU6050
    GND ESP32 vers GND MPU6050
    GPIO22 ESP32 vers SCL MPU6050
    GPIO21 ESP32 vers SDA MPU6050
Le LM2596 et l’ESP32 sont fixés dans un boîtier en plastique. J'ai depuis supprimé la fiche wago qui prenait de la place pour rien et mis des fils plus fins.

Mise en situation :

Le montage final

Calibrage du WL-134

Le WL-134 doit être calibré pour que l’antenne soit en parfaite résonance.

Le module a 5 cavaliers J1 à J5 qui règlent la capacité de résonance de l’antenne. Si vous achetez la même alimentation que la mienne, vous pouvez ignorer cette étape. Vérifiez juste que les cavaliers J4 et J5 sont reliés (soudés) et que les cavaliers J1 à J3 sont libres (dessoudés), car j’ai remarqué que c’est la meilleure configuration avec cette alim.

Si vous utilisez une autre alimentation ou voulez mieux régler la résonance de l’antenne, voici comment procéder. Un WL-134 bien réglé consomme environ 80 mA à 6 V et lit les puces à plusieurs centimètres. Un mauvais réglage se traduit par une consommation beaucoup plus faible (< 50 mA) et une portée réduite.

  1. Coupez l’alimentation. On garde les branchements précédents (on déconnecte l’esp32 par précaution), mais on va insérer le multimètre en série sur le fil +6V, afin qu’il devienne un « pont » entre l’alim et le module WL-134.
    Multimètre réglé sur mA DC. Borne mA rouge du multimètre à la sortie +6V de l’alimentation et borne noire COM du multimètre sur l’entrée+5-9V du WL-134.
    Rebranchez l’alimentation
  2. Dessoudez tous les cavaliers (J1 à J5 ouverts). Notez le courant de départ (par ex. 55 mA)
  3. Remettez J5 (ressoudez) et observez si le courant augmente ou diminue. S’il augmente, gardez J5 : vous allez dans la bonne direction. S’il baisse, retirez le et passez à J4.
  4. Continuez ainsi (J4, puis J3, etc.) en testant chaque combinaison.
  5. L’objectif est d’obtenir la consommation la plus élevée possible (souvent entre 75 et 85 mA à 6 V). Si le courant dépasse 100 mA, c’est que la résonance est trop forte (capacité trop élevée) ; retirez alors un cavalier

Le code à injecter dans l’ESP32

L’ESP lit les trames envoyées par le WL-134 et les publie dans Home Assistant. Le MPU6050 permet de savoir si la chatière s'ouvre ou se ferme selon l'inclinaison du module.

Dans Home Assistant, un sensor permet ensuite d’afficher le nom du chat détecté, l'état du clapet de la chatière, et de déclencher des automatisations (notifications Telegram, journal des passages, etc.).

Je ne vais pas étendre ce tuto au paramétrage d’un ESP32, mais basiquement, vous le branchez à votre PC, vous installez les drivers, et vous y injectez le code yaml ci-dessous. Il vous faudra le module “ESPHome Device Builder” dans Home Assistant.

Collez le code ci-après, n’oubliez pas d’éditer le SSID wifi et le mot de passe en fonction de votre propre réseau (lignes 16 et 17). Branchez l’ESP32 et incluez-le dans Home Assistant. Normalement, il va apparaitre tout seul dans “appareils et services” et il suffira de l’ajouter à votre HA. Pour le moment ce code ne prend pas en compte l'accéléromètre.

esphome:
  name: lecteur-chatiere
  on_boot:
    priority: -10
    then:
      - text_sensor.template.publish:
          id: rfid_chat
          state: ""

esp32:
  board: nodemcu-32s
  framework:
    type: arduino

wifi:
  ssid: "VOTRE RESEAU WIFI"
  password: "VOTRE MOT DE PASSE WIFI"
  power_save_mode: none

api:
  reboot_timeout: 0s

ota:
  platform: esphome

uart:
  id: rfid_uart
  rx_pin: GPIO16
  baud_rate: 9600
  data_bits: 8
  parity: NONE
  stop_bits: 2  

text_sensor:
  - platform: template
    name: "RFID UID"
    id: rfid_uid
    icon: "mdi:identifier"

interval:
  - interval: 100ms
    then:
      - lambda: |-
          static std::string buf;
          static uint32_t last = 0;

          // Lire tout ce qui arrive
          while (id(rfid_uart).available()) {
            uint8_t b; id(rfid_uart).read_byte(&b);
            buf.push_back((char)b);
            last = millis();
          }

          // Traiter après ~120 ms de silence
          if (!buf.empty() && (millis() - last) > 120) {
            // Chercher STX(0x02) ... ETX(0x03)
            auto stx = buf.find('\x02');
            auto etx = buf.rfind('\x03');
            if (stx != std::string::npos && etx != std::string::npos && etx > stx + 1) {
              // Charge utile entre STX et ETX, retirer tout après 0x7E s'il existe
              std::string payload = buf.substr(stx + 1, etx - (stx + 1));
              auto tilde = payload.find('\x7E');
              if (tilde != std::string::npos) payload = payload.substr(0, tilde);

              // On attend 26 caractères hex ASCII (ex: "5460D5C3D3483011000000000000")
              if (payload.size() >= 16) {
                // Prendre les 16 premiers caractères = UID (8 octets)
                std::string uid_hex = payload.substr(0, 16);

                // Anti-doublon 5 s
                static std::string last_uid;
                static uint32_t last_uid_ms = 0;
                if (uid_hex != last_uid || (millis() - last_uid_ms) > 5000) {
                  id(rfid_uid).publish_state(uid_hex.c_str());
                  last_uid = uid_hex;
                  last_uid_ms = millis();
                }
              }
            }
            buf.clear();
          }

Faites passer une puce RFID type FDX-B (ou votre chat) devant l’antenne. Une LED sur le WL-134 devient bleue si la puce est détectée.

Regardez dans Home Assistant les caractères qui sont lus. Vous devriez voir un code d’identification au format UID hex, composé de 16 caractères

Une fois que vous avez les codes de tous vos animaux, vous pouvez demander à Home Assistant de noter le nom du chat plutôt que le code hex. C’est plus explicite que de remonter un numéro. Pour cela, remplacez le code précédent par celui ci-dessous.
Vous devez à nouveau éditer les paramètres WIFI (lignes 19 et 20) et éditer les noms de vos chats lignes 299 et 300 avec leurs identifiants trouvés précédemment.

esphome:
  name: lecteur-chatiere
  on_boot:
    priority: -10
    then:
      - text_sensor.template.publish:
          id: rfid_chat
          state: ""
      - text_sensor.template.publish:
          id: sens_chatiere
          state: "Fermée"

esp32:
  board: nodemcu-32s
  framework:
    type: arduino

wifi:
  ssid: "VOTRE SSID WIFI"
  password: "MOT DE PASSE WIFI"
  power_save_mode: none
  reboot_timeout: 15min

logger:
  level: INFO
  baud_rate: 0
  logs:
    sensor: WARN
    text_sensor: WARN
    component: ERROR

api:
  reboot_timeout: 0s

  

ota:
  platform: esphome

globals:
  - id: last_closed_z
    type: float
    restore_value: no
    initial_value: '0.0'

  - id: last_peak_z
    type: float
    restore_value: no
    initial_value: '0.0'


time:
  - platform: sntp
    id: sntp_time
    timezone: Europe/Paris
    servers: [0.pool.ntp.org, 1.pool.ntp.org, 2.pool.ntp.org]
    on_time:
      - seconds: 0
        minutes: 0
        hours: 3
        then:
          - logger.log: "🔄 Reboot quotidien (03:00 Europe/Paris)"
          - delay: 1s
          - lambda: |-
              esp_restart();

# Configuration I2C pour le MPU-6050
i2c:
  sda: GPIO21
  scl: GPIO22
  scan: true
  id: bus_a

# Configuration UART pour le lecteur RFID
uart:
  id: rfid_uart
  rx_pin: GPIO16
  baud_rate: 9600
  data_bits: 8
  parity: NONE
  stop_bits: 2

# Capteur MPU-6050
sensor:
  - platform: mpu6050
    address: 0x68
    i2c_id: bus_a
    update_interval: 100ms
    accel_x:
      name: "Chatière Accel X"
      id: accel_x
      internal: true
      filters:
        - sliding_window_moving_average:
            window_size: 5
            send_every: 1
    accel_y:
      name: "Chatière Accel Y"
      id: accel_y
      internal: true
      filters:
        - sliding_window_moving_average:
            window_size: 5
            send_every: 1
    accel_z:
      name: "Chatière Accel Z brute"
      id: accel_z
      internal: true
      filters:
        # Inverse le signe pour corriger l'orientation mécanique
        - multiply: -1
        - sliding_window_moving_average:
            window_size: 5
            send_every: 1
    gyro_x:
      name: "Chatière Gyro X"
      id: gyro_x
      internal: true
    gyro_y:
      name: "Chatière Gyro Y"
      id: gyro_y
      internal: true
    gyro_z:
      name: "Chatière Gyro Z"
      id: gyro_z
      internal: true
    temperature:
      name: "Température MPU-6050"
      id: mpu_temp
      internal: true

  # 👉 Z en temps réel publiée dans HA
  - platform: template
    name: "Chatière Accel Z"
    id: accel_z_monitor
    unit_of_measurement: "m/s²"
    accuracy_decimals: 1
    update_interval: 0.5s
    entity_category: diagnostic
    lambda: |-
      return id(accel_z).state;

  # 👉 Dernière valeur Z en position fermée
  - platform: template
    name: "Chatière Z Fermée"
    id: z_fermee_sensor
    unit_of_measurement: "m/s²"
    accuracy_decimals: 2
    entity_category: diagnostic
    lambda: |-
      return id(last_closed_z);

  # 👉 Dernier pic de mouvement (entrée/sortie)
  - platform: template
    name: "Chatière Z Pic Mouvement"
    id: z_pic_mouvement_sensor
    unit_of_measurement: "m/s²"
    accuracy_decimals: 2
    entity_category: diagnostic
    lambda: |-
      return id(last_peak_z);


# Capteurs texte
text_sensor:
  # Capteur RFID (comme avant)
  - platform: template
    name: "RFID Chat"
    id: rfid_chat
    icon: "mdi:cat"

  # Nouveau capteur pour le sens de la chatière
  - platform: template
    name: "Sens Chatière"
    id: sens_chatiere
    icon: "mdi:door"

# Scripts
script:
  - id: clear_chat
    then:
      - delay: 2s
      - text_sensor.template.publish:
          id: rfid_chat
          state: ""

  - id: detect_direction
    mode: restart
    then:
      - lambda: |-
          static uint32_t last_change_ms = 0;
          static std::string current_state = "Fermée";
          static std::string last_published = "";
          static uint32_t state_locked_until = 0;

          float az = id(accel_z).state;
          uint32_t now = millis();

          // SEUILS CORRIGÉS pour éviter fausses détections
          const float THRESHOLD_SORTIE = -6.5;   // Sortie détectée si Z < -6.5
          const float THRESHOLD_ENTREE = 6.5;    // Entrée détectée si Z > 6.5
          const float THRESHOLD_FERME_MIN = 0.8; 
          const float THRESHOLD_FERME_MAX = 3.0;
          const uint32_t DEBOUNCE_MS = 400;
          const uint32_t STATE_LOCK_MS = 3000;

          bool state_changed = false;
          std::string new_state = current_state;

          if (now < state_locked_until) {
            // Pendant le verrouillage, on continue quand même à suivre le pic
            if (current_state == "Entrée" || current_state == "Sortie") {
              if (fabsf(az) > fabsf(id(last_peak_z))) {
                id(last_peak_z) = az;
              }
            }
            return;
          }

          // Suivi du pic pendant mouvement (Entrée/Sortie)
          if (current_state == "Entrée" || current_state == "Sortie") {
            if (fabsf(az) > fabsf(id(last_peak_z))) {
              id(last_peak_z) = az;
            }
          }

          // Détection SORTIE (Z < -3.5)
          if (current_state == "Fermée" && az < THRESHOLD_SORTIE &&
              (now - last_change_ms) > DEBOUNCE_MS) {
            new_state = "Sortie";
            state_changed = true;
            id(last_peak_z) = az;  // on initialise le pic à la 1ère valeur
            ESP_LOGI("chatiere", "SORTIE (Z=%.1f)", az);
          }
          // Détection ENTRÉE (Z > 6.5)
          else if (current_state == "Fermée" && az > THRESHOLD_ENTREE &&
                   (now - last_change_ms) > DEBOUNCE_MS) {
            new_state = "Entrée";
            state_changed = true;
            id(last_peak_z) = az;  // on initialise le pic à la 1ère valeur
            ESP_LOGI("chatiere", "ENTRÉE (Z=%.1f)", az);
          }
          // Retour FERMÉE (Z entre 1.5 et 6.0)
          else if ((current_state == "Entrée" || current_state == "Sortie") &&
                   az > THRESHOLD_FERME_MIN && az < THRESHOLD_FERME_MAX &&
                   (now - last_change_ms) > DEBOUNCE_MS) {
            new_state = "Fermée";
            state_changed = true;

            // On mémorise la valeur fermée et on log les infos de calibration
            id(last_closed_z) = az;
            ESP_LOGI("chatiere", "✓ FERMÉE (Z=%.1f) | Dernier pic mouvement = %.1f",
                     az, id(last_peak_z));

            // Si tu veux, tu peux aussi remettre last_peak_z à 0 après fermeture
            // id(last_peak_z) = 0.0f;
          }

          if (state_changed && new_state != last_published) {
            current_state = new_state;
            last_published = new_state;
            id(sens_chatiere).publish_state(new_state.c_str());
            last_change_ms = now;
            state_locked_until = now + STATE_LOCK_MS;
            ESP_LOGI("chatiere", "✓ Publié: %s | Verrouillé pour 3s", new_state.c_str());
          }


# Intervalles
interval:
  # Détection RFID
  - interval: 100ms
    then:
      - lambda: |-
          static std::string buf;
          static uint32_t last_rx_ms = 0;
          const uint32_t COOLDOWN_MS = 60000; // 1 minute evite double detection rfid

          while (id(rfid_uart).available()) {
            uint8_t b;
            id(rfid_uart).read_byte(&b);
            buf.push_back((char)b);
            last_rx_ms = millis();
          }

          if (!buf.empty() && (millis() - last_rx_ms) > 120) {
            auto stx = buf.find('\x02');
            auto etx = buf.rfind('\x03');

            if (stx != std::string::npos && etx != std::string::npos && etx > stx + 1) {
              std::string payload = buf.substr(stx + 1, etx - (stx + 1));
              auto tilde = payload.find('\x7E');
              if (tilde != std::string::npos) payload = payload.substr(0, tilde);

              if (payload.size() >= 16) {
                std::string uid = payload.substr(0, 16);
                std::string name;

                if (uid == "AAAAAAAAAAAAAAAA") name = "Kiwi";
                else if (uid == "88ACBA19E3AF0001") name = "Maya";
                else name = "Inconnu (" + uid + ")";

                struct Seen { std::string u; uint32_t t; };
                static Seen last_seen = {"", 0};
                const uint32_t now = millis();
                const bool is_new_uid = (uid != last_seen.u);
                const bool cooldown_over = (now - last_seen.t) > COOLDOWN_MS;

                if (is_new_uid || cooldown_over) {
                  id(rfid_chat).publish_state(name.c_str());
                  id(clear_chat).execute();
                  last_seen = {uid, now};
                  ESP_LOGI("rfid", "Publish: %s", name.c_str());
                } else {
                  ESP_LOGD("rfid", "Duplicate ignored: %s", name.c_str());
                }
              }
            }
            buf.clear();
          }

  # Détection direction chatière
  - interval: 100ms
    then:
      - script.execute: detect_direction

Le script évite les doublons, notamment quand les chats rentrent en défonçant la chatière et qu'elle fait plusieurs aller-retours. Il y a aussi un verrouillage temporaire après détection et un temps de refroidissement qui empêche toute répétition immédiate, sur la lecture de la puce, comme sur l'accéléromètre.
On se retrouve avec un nouvel appareil dans Home Assistant :

Il nous reste maintenant à nous occuper de l'accéléromètre, pour savoir si la chatière s'ouvre ou se ferme. Home assistant renvoie les valeurs Z de l'inclinaison. Laissez la chatière fermée, notez la valeur "Chatière Accel Z". Mettez-la en position sortie, notez la nouvelle valeur maximale ; puis notez la valeur maximale en position entrée.
Ensuite, on édite les lignes 200 à 203 du script précédent. Chez moi la chatière fermée a des valeurs comprises entre 1.3 et 2.7 ; cette valeur a tendance à fluctuer avec le temps. J'ai donc noté min 0.8 et max 3.0 pour laisser un peu de marge. Je valide la sortie pour une valeur inférieure à -6.5 (la valeur descend chez moi jusque -11) et l'entrée à partir de 6.5 (la valeur monte chez moi jusque 11).

      const float THRESHOLD_SORTIE = -6.5;   // Sortie détectée si Z < -6.5
      const float THRESHOLD_ENTREE = 6.5;    // Entrée détectée si Z > 6.5
      const float THRESHOLD_FERME_MIN = 0.8; 
      const float THRESHOLD_FERME_MAX = 3.0;

Intégration dans Home Assistant

On en vient enfin à ce qui nous intéresse. On va exploiter deux entités créées par notre script ESP 32 : sensor.lecteur_chatiere_rfid_chat et sensor.lecteur_chatiere_sens_chatiere. Je veux recevoir une notification telegram à l'entrée ou à la sortie des chats, et je veux qu'une entité home/not_home soit mise à jour selon la présence des animaux dans la maison.

On commence par créer deux interrupteurs dans Appareils & services > Entrées

Puis on créé des capteurs de présence dans configuration.yaml

- template:
  - binary_sensor:
      - name: "Kiwi présent"
        unique_id: kiwi_present   
        device_class: presence
        state: "{{ is_state('input_boolean.kiwi_home', 'on') }}"

      - name: "Maya présente"
        unique_id: maya_presente
        device_class: presence
        state: "{{ is_state('input_boolean.maya_home', 'on') }}"

Et voici l'automatisation finale :

alias: Chatière — RFID + Sens
description: Détecte RFID + direction, met à jour sensor et notifie Telegram
triggers:
  - id: rfid_kiwi
    trigger: state
    entity_id: sensor.lecteur_chatiere_rfid_chat
    to: Kiwi
  - id: rfid_maya
    trigger: state
    entity_id: sensor.lecteur_chatiere_rfid_chat
    to: Maya
  - id: sens_entree
    trigger: state
    entity_id: sensor.lecteur_chatiere_sens_chatiere
    to: Entrée
  - id: sens_sortie
    trigger: state
    entity_id: sensor.lecteur_chatiere_sens_chatiere
    to: Sortie
conditions:
  - condition: template
    value_template: >
      {% set last = this.attributes.last_triggered %} {% set last_ts =
      as_timestamp(last) if last is not none else 0 %} {{ last is none or
      (as_timestamp(now()) - last_ts) > 5 }}
actions:
  - variables:
      rfid_detected: |-
        {% if trigger.id in ['rfid_kiwi','rfid_maya'] %}
          {{ trigger.to_state.state }}
        {% else %}
          {{ states('sensor.lecteur_chatiere_rfid_chat') }}
        {% endif %}
      sens_detected: |-
        {% if trigger.id in ['sens_entree','sens_sortie'] %}
          {{ trigger.to_state.state }}
        {% else %}
          {{ states('sensor.lecteur_chatiere_sens_chatiere') }}
        {% endif %}
      trigger_source: "{{ trigger.id }}"
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ trigger_source in ['rfid_kiwi','rfid_maya'] }}"
        sequence:
          - wait_for_trigger:
              - id: wait_entree
                trigger: state
                entity_id: sensor.lecteur_chatiere_sens_chatiere
                to: Entrée
              - id: wait_sortie
                trigger: state
                entity_id: sensor.lecteur_chatiere_sens_chatiere
                to: Sortie
            timeout: "00:00:08"
            continue_on_timeout: true
          - variables:
              final_sens: |-
                {% if wait.trigger %}
                  {% if wait.trigger.id == 'wait_entree' %}Entrée
                  {% elif wait.trigger.id == 'wait_sortie' %}Sortie
                  {% else %}Inconnu{% endif %}
                {% else %}Timeout{% endif %}
              final_chat: "{{ rfid_detected }}"
      - conditions:
          - condition: template
            value_template: "{{ trigger_source in ['sens_entree','sens_sortie'] }}"
        sequence:
          - wait_for_trigger:
              - id: wait_kiwi
                trigger: state
                entity_id: sensor.lecteur_chatiere_rfid_chat
                to: Kiwi
              - id: wait_maya
                trigger: state
                entity_id: sensor.lecteur_chatiere_rfid_chat
                to: Maya
            timeout: "00:00:08"
            continue_on_timeout: true
          - variables:
              final_chat: |-
                {% if wait.trigger %}
                  {% if wait.trigger.id == 'wait_kiwi' %}Kiwi
                  {% elif wait.trigger.id == 'wait_maya' %}Maya
                  {% else %}Inconnu{% endif %}
                {% else %}Timeout{% endif %}
              final_sens: "{{ sens_detected }}"
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ final_sens == 'Entrée' and final_chat == 'Kiwi' }}"
        sequence:
          - action: input_boolean.turn_on
            target:
              entity_id: input_boolean.kiwi_home
          - action: notify.telegram
            data:
              message: 🐾 Kiwi est entré (RFID)
      - conditions:
          - condition: template
            value_template: "{{ final_sens == 'Entrée' and final_chat == 'Maya' }}"
        sequence:
          - action: input_boolean.turn_on
            target:
              entity_id: input_boolean.maya_home
          - action: notify.telegram
            data:
              message: 🐾 Maya est entrée (RFID)
      - conditions:
          - condition: template
            value_template: "{{ final_sens == 'Sortie' and final_chat == 'Kiwi' }}"
        sequence:
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.kiwi_home
          - action: notify.telegram
            data:
              message: 🐾 Kiwi est sorti (RFID)
      - conditions:
          - condition: template
            value_template: "{{ final_sens == 'Sortie' and final_chat == 'Maya' }}"
        sequence:
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.maya_home
          - action: notify.telegram
            data:
              message: 🐾 Maya est sortie (RFID)
    default:
      - action: notify.telegram
        data:
          message: >-
            ⚠️ Passage détecté — Chat: {{ final_chat }} | Direction: {{
            final_sens }}
mode: parallel
max: 3

Le résultat en image dans mes notifications Telegram :

Affichage dans le dashboard

Je me suis amusé à créer des templates de suivi pour chaque chat :

- template:
# ----- KIWI -----
  - platform: history_stats
    name: "Kiwi – entrées (aujourd'hui)"
    entity_id: binary_sensor.kiwi_present
    state: "on"
    type: count
    start: "{{ today_at('00:00') }}"
    end: "{{ now() }}"

  - platform: history_stats
    name: "Kiwi – sorties (aujourd'hui)"
    entity_id: binary_sensor.kiwi_present
    state: "off"
    type: count
    start: "{{ today_at('00:00') }}"
    end: "{{ now() }}"

  - platform: history_stats
    name: "Kiwi – temps maison (aujourd'hui)"
    entity_id: binary_sensor.kiwi_present
    state: "on"
    type: time
    start: "{{ today_at('00:00') }}"
    end: "{{ now() }}"

  - platform: history_stats
    name: "Kiwi – temps dehors (aujourd'hui)"
    entity_id: binary_sensor.kiwi_present
    state: "off"
    type: time
    start: "{{ today_at('00:00') }}"
    end: "{{ now() }}"


  # ----- MAYA -----
  - platform: history_stats
    name: "Maya – entrées (aujourd'hui)"
    entity_id: binary_sensor.maya_presente
    state: "on"
    type: count
    start: "{{ today_at('00:00') }}"
    end: "{{ now() }}"

  - platform: history_stats
    name: "Maya – sorties (aujourd'hui)"
    entity_id: binary_sensor.maya_presente
    state: "off"
    type: count
    start: "{{ today_at('00:00') }}"
    end: "{{ now() }}"

  - platform: history_stats
    name: "Maya – temps maison (aujourd'hui)"
    entity_id: binary_sensor.maya_presente
    state: "on"
    type: time
    start: "{{ today_at('00:00') }}"
    end: "{{ now() }}"

  - platform: history_stats
    name: "Maya – temps dehors (aujourd'hui)"
    entity_id: binary_sensor.maya_presente
    state: "off"
    type: time
    start: "{{ today_at('00:00') }}"
    end: "{{ now() }}"
    

Je place mes deux photos des chats dans config/www/

www/kiwi.jpg et www/maya.jpg

Et voici le code lovelace pour le dashboard, un peu compliqué :

type: grid
cards:
  - type: heading
    heading: Les chats
    heading_style: title
  - type: custom:stack-in-card
    mode: horizontal
    cards:
      - type: vertical-stack
        cards:
          - type: picture-elements
            image: /local/kiwi.jpg
            aspect_ratio: 1/5
            tap_action: none
            card_mod:
              style: |
                ha-card {
                  border-radius: 18px 18px 0 0;
                  overflow: hidden;
                }
            elements:
              - type: conditional
                conditions:
                  - entity: input_boolean.kiwi_home
                    state: "on"
                elements:
                  - type: icon
                    icon: mdi:home
                    style:
                      top: 20px
                      right: "-10px"
                      color: rgba(76,175,80,0.9)
                      "--mdc-icon-size": 36px
              - type: conditional
                conditions:
                  - entity: input_boolean.kiwi_home
                    state: "off"
                elements:
                  - type: icon
                    icon: mdi:home-off-outline
                    style:
                      top: 20px
                      right: "-10px"
                      color: rgba(220,20,60,0.9)
                      "--mdc-icon-size": 36px
          - type: custom:mushroom-template-card
            primary: |
              {% if is_state('input_boolean.kiwi_home','on') %}
                KIWI PRESENT
              {% else %}
                KIWI ABSENT
              {% endif %}
            icon: >
              {{
              iif(is_state('binary_sensor.kiwi_present','on'),'mdi:home','mdi:home-export-outline')
              }}
            icon_color: >
              {{ iif(is_state('binary_sensor.kiwi_present','on'),'green','red')
              }}
            multiline_secondary: true
            secondary: >
              {% set s = as_timestamp(now()) -
              as_timestamp(states.input_boolean.kiwi_home.last_changed) %}

              {% set m = (s // 60) | int %}

              {% set h = (s // 3600) | int %}

              {% set d = (s // 86400) | int %}

              {% if d >= 1 %}
                Depuis {{ d }} jour{{ 's' if d > 1 else '' }}
              {% elif h >= 1 %}
                Depuis {{ h }} heure{{ 's' if h > 1 else '' }}
              {% else %}
                Depuis {{ m }} minute{{ 's' if m > 1 else '' }}
              {% endif %}
            tap_action: none
            fill_container: true
            card_mod:
              style:
                .: |
                  ha-card {
                    text-align: center;
                    border-radius: 0 0 18px 18px;
                    margin-top: -8px;
                    background-color:
                      {% if is_state('input_boolean.kiwi_home','on') %}
                        rgba(76,175,80,.1);
                      {% else %}
                        rgba(158,158,158,.1);
                      {% endif %};
                  }
                  :host {
                    --primary-text-color:
                      {% if is_state('input_boolean.kiwi_home','on') %}
                        rgb(76,175,80);
                      {% else %}
                        rgb(255,0,0);
                      {% endif %};
                    font-weight: 600;
                  }
                mushroom-template-card:
                  $: |
                    .primary {
                      font-size: 1.4rem !important;
                      font-weight: 700 !important;
                      line-height: 1.3;
                    }
                    .secondary {
                      font-size: 1.0rem !important;
                      opacity: 0.9;
                      line-height: 1.4;
                    }
      - type: vertical-stack
        cards:
          - type: picture-elements
            image: /local/maya.jpg
            aspect_ratio: 1/5
            tap_action: none
            card_mod:
              style: |
                ha-card {
                  border-radius: 18px 18px 0 0;
                  overflow: hidden;
                }
            elements:
              - type: conditional
                conditions:
                  - entity: input_boolean.maya_home
                    state: "on"
                elements:
                  - type: icon
                    icon: mdi:home
                    style:
                      top: 20px
                      right: "-10px"
                      color: rgba(76,175,80,0.9)
                      "--mdc-icon-size": 36px
              - type: conditional
                conditions:
                  - entity: input_boolean.maya_home
                    state: "off"
                elements:
                  - type: icon
                    icon: mdi:home-off-outline
                    style:
                      top: 20px
                      right: "-10px"
                      color: rgba(220,20,60,0.9)
                      "--mdc-icon-size": 36px
          - type: custom:mushroom-template-card
            primary: |
              {% if is_state('input_boolean.maya_home','on') %}
                MAYA PRESENTE
              {% else %}
                MAYA ABSENTE
              {% endif %}
            multiline_primary: true
            secondary: >
              {% set s = as_timestamp(now()) -
              as_timestamp(states.input_boolean.maya_home.last_changed) %}

              {% set m = (s // 60) | int %}

              {% set h = (s // 3600) | int %}

              {% set d = (s // 86400) | int %}

              {% if d >= 1 %}
                Depuis {{ d }} jour{{ 's' if d > 1 else '' }}
              {% elif h >= 1 %}
                Depuis {{ h }} heure{{ 's' if h > 1 else '' }}
              {% else %}
                Depuis {{ m }} minute{{ 's' if m > 1 else '' }}
              {% endif %}
            icon: >
              {{
              iif(is_state('binary_sensor.maya_presente','on'),'mdi:home','mdi:home-export-outline')
              }}
            icon_color: >
              {{ iif(is_state('binary_sensor.maya_presente','on'),'green','red')
              }}  
            tap_action: none
            fill_container: true
            card_mod:
              style:
                .: |
                  ha-card {
                    text-align: center;
                    border-radius: 0 0 18px 18px;
                    margin-top: -8px;
                    background-color:
                      {% if is_state('input_boolean.maya_home','on') %}
                        rgba(76,175,80,.1);
                      {% else %}
                        rgba(158,158,158,.1);
                      {% endif %};
                  }
                  :host {
                    --primary-text-color:
                      {% if is_state('input_boolean.maya_home','on') %}
                        rgb(76,175,80);
                      {% else %}
                        rgb(255,0,0);
                      {% endif %};
                    font-weight: 600;
                  }
                mushroom-template-card:
                  $: |
                    .primary {
                      font-size: 1.4rem !important;
                      font-weight: 700 !important;
                      line-height: 1.3;
                    }
                    .secondary {
                      font-size: 1.0rem !important;
                      opacity: 0.9;
                      line-height: 1.4;
                    }
    card_mod:
      style: |
        ha-card {
          background: none;
          box-shadow: none;
        }
  - type: custom:stack-in-card
    mode: horizontal
    cards:
      - type: vertical-stack
        cards:
          - type: custom:mushroom-template-card
            primary: Stats du jour
            secondary: >
              {% set m_in =
              (states('sensor.kiwi_temps_maison_aujourd_hui')|float(0)*60)|int
              %} {% set m_out =
              (states('sensor.kiwi_temps_dehors_aujourd_hui')|float(0)*60)|int
              %} {{ (m_in//60) }}h{{ '%02d' % (m_in%60) }} dedans · {{
              (m_out//60) }}h{{ '%02d' % (m_out%60) }} dehors
            icon: mdi:calendar-clock
            icon_color: purple
            multiline_secondary: true
          - type: grid
            columns: 1
            square: false
            cards:
              - type: custom:mushroom-template-card
                primary: Entré
                secondary: "{{ states('sensor.kiwi_entrees_aujourd_hui')|int }} fois"
                icon: mdi:login
                icon_color: teal
              - type: custom:mushroom-template-card
                primary: Sorti
                secondary: "{{ states('sensor.kiwi_sorties_aujourd_hui')|int }} fois"
                icon: mdi:logout
                icon_color: deep-orange
      - type: vertical-stack
        cards:
          - type: custom:mushroom-template-card
            primary: Stats du jour
            secondary: >
              {% set m_in =
              (states('sensor.maya_temps_maison_aujourd_hui')|float(0)*60)|int
              %} {% set m_out =
              (states('sensor.maya_temps_dehors_aujourd_hui')|float(0)*60)|int
              %} {{ (m_in//60) }}h{{ '%02d' % (m_in%60) }} dedans · {{
              (m_out//60) }}h{{ '%02d' % (m_out%60) }} dehors
            icon: mdi:calendar-clock
            icon_color: purple
            multiline_secondary: true
          - type: grid
            columns: 1
            square: false
            cards:
              - type: custom:mushroom-template-card
                primary: Entrée
                secondary: "{{ states('sensor.maya_entrees_aujourd_hui')|int }} fois"
                icon: mdi:login
                icon_color: teal
              - type: custom:mushroom-template-card
                primary: Sortie
                secondary: "{{ states('sensor.maya_sorties_aujourd_hui')|int }} fois"
                icon: mdi:logout
                icon_color: deep-orange
  - type: vertical-stack
    cards: []
column_span: 1

Ce qui donne ce résultat visuel, avec les deux stars de ce post :

Photo plus contractuelle, Kiwi a maintenant un bout d'oreille en moins 😦

Évolutions possibles

Il arrive rarement que la puce FDX-B ne soit pas détectée, notamment quand les chats passent la chatière en courant. J'ai placé une caméra près de la chatière et dans ce cas, c'est l'IA qui analyse le chat qui passe (via llmvision). Je ne partage pas le code ici, car cela devient très spécifique à mon usage, mais sachez que cela et possible et complète bien la détection.

On pourrait aussi envisager une chatière qui ne s’ouvre que si la puce d’un de mes chats est lue, mais je n’ai personnellement pas cette utilité. Mon chat est le caïd du coin, personne ne vient chez lui (le tigré avec les yeux sournois).